Compare commits

..
Author SHA1 Message Date
kartik-mem0 5a1225f8e9 fix(ts-sdk): make natural an optional peer so pg is not installed transitively
natural@8 hard-depends on pg ^8.18.0, mongoose, redis and memjs. As a
required peer it was auto-installed for every mem0ai user, so pg stayed in
the tree even after pg itself became optional. lemmatizeForBm25 already
falls back to a built-in stemmer when natural is missing.
2026-09-25 15:30:23 +05:30
kartik-mem0 1b51ad5f89 fix(ts-sdk): make pg an optional, ranged peer dependency
mem0ai declared pg as a required peer pinned to exactly 8.11.3, so npm
auto-installed it for every user (including hosted MemoryClient users)
and failed with ERESOLVE in any project already on a newer pg.

The pin existed because pgvector.ts imported pg at module load, so
mem0ai/oss crashed without it. PGVector now loads pg through loadPeer on
initialize(), like the other optional vector stores, which lets pg and
@types/pg become optional ^8.11.x peers. @types/jest moves from peer to
dev dependencies.

Closes #3307
2026-09-25 15:14:42 +05:30
12 changed files with 24 additions and 461 deletions
-22
View File
@@ -7,18 +7,6 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-09-25" description="v2.2.1">
**Bug Fixes:**
- **Core:** `add()` (`Memory` and `AsyncMemory`) no longer reports records the vector store rejected as successful `ADD` events. Only records that were actually inserted are written to history, entity-linked, and returned. If none of the extracted memories could be inserted, `add()` now raises `VectorStoreError` instead of returning memories that were never stored ([#7066](https://github.com/mem0ai/mem0/pull/7066))
- **Core:** Restore the context-manager protocol on `Memory` (`with Memory() as m:`) and `AsyncMemory` (`async with AsyncMemory() as m:`), which closes the instance on exit ([#7354](https://github.com/mem0ai/mem0/pull/7354))
- **LLMs:** The AWS Bedrock Anthropic path now returns the first Converse content block that carries text instead of always reading `content[0]`. Claude reasoning models can emit a `reasoningContent` block before the text block, which previously made the call fail with a `KeyError` ([#6369](https://github.com/mem0ai/mem0/pull/6369))
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only `gte` and `lte` were read before, so any other operator was silently dropped and the query returned unfiltered results. An unsupported operator now raises `ValueError` ([#6564](https://github.com/mem0ai/mem0/pull/6564))
- **Vector Stores:** Turbopuffer `search()` scores now respect `distance_metric`. With `euclidean_squared`, the unbounded squared distance maps to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6559](https://github.com/mem0ai/mem0/pull/6559))
- **Vector Stores:** S3 Vectors `search()` scores are now metric-aware. With `euclidean`, the distance maps to `1 / (1 + distance)` instead of `max(0, 1 - distance)`, which collapsed most scores to 0 ([#6547](https://github.com/mem0ai/mem0/pull/6547))
</Update>
<Update label="2026-09-23" description="v2.2.0">
**New Features:**
@@ -1257,16 +1245,6 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-09-25" description="v3.3.1">
**Bug Fixes:**
- **Config (OSS):** `ConfigManager` no longer injects OpenAI's default `baseURL` and `model` into non-OpenAI LLM providers. The defaults now apply only to `openai` and `openai_structured`, so a DeepSeek, xAI, or other provider config without an explicit `baseURL` or `model` falls back to that provider's own defaults and env vars (`DEEPSEEK_API_BASE`, `XAI_API_BASE`, ...) instead of pointing at OpenAI with an OpenAI model name ([#7350](https://github.com/mem0ai/mem0/pull/7350))
- **Vector Stores:** Turbopuffer filters now apply every operator (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`). Only the range operators were read before, so `eq`, `ne`, `in`, and `nin` were silently dropped. A `"*"` value now matches anything instead of nothing, an array value is treated as `in`, and an unsupported operator throws ([#6578](https://github.com/mem0ai/mem0/pull/6578))
- **Vector Stores:** Turbopuffer `search()` with `euclidean_squared` now maps the unbounded squared distance to `1 / (1 + distance)` instead of `1 - distance`, which went negative and inverted ranking for any distance above 1 ([#6580](https://github.com/mem0ai/mem0/pull/6580))
- **Packaging:** `pg`, `@types/pg`, and `natural` are now optional peer dependencies, and `pg` / `@types/pg` accept caret ranges instead of the exact `8.11.3` / `8.11.0` pins, so installing `mem0ai` no longer pulls in `pg` or conflicts with an app's own `pg` version. The PGVector store now imports `pg` only when it is used, so install it alongside `mem0ai` (`npm install pg`) if you use that store. `@types/jest` moved from peer dependencies to dev dependencies ([#7450](https://github.com/mem0ai/mem0/pull/7450))
</Update>
<Update label="2026-09-23" description="v3.3.0">
**New Features:**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0ai",
"version": "3.3.1",
"version": "3.3.0",
"description": "The Memory Layer For Your AI Apps",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
@@ -247,54 +247,23 @@ export class TurbopufferDB implements VectorStore {
}
}
// Maps mem0's universal filter operators to Turbopuffer's filter tokens.
private static readonly OPERATOR_MAP: Record<string, string> = {
eq: "Eq",
ne: "NotEq",
gt: "Gt",
gte: "Gte",
lt: "Lt",
lte: "Lte",
in: "In",
nin: "NotIn",
};
private convertFilters(filters?: SearchFilters): any {
if (!filters || Object.keys(filters).length === 0) return null;
const conditions: any[] = [];
for (const [key, value] of Object.entries(filters)) {
// "*" is a match-any wildcard: it must not constrain the query. The old
// code turned it into `[key, "Eq", "*"]`, matching nothing.
if (value === "*") {
continue;
if (
typeof value === "object" &&
value !== null &&
!Array.isArray(value)
) {
if ("gte" in value) conditions.push([key, "Gte", value.gte]);
if ("lte" in value) conditions.push([key, "Lte", value.lte]);
if ("gt" in value) conditions.push([key, "Gt", value.gt]);
if ("lt" in value) conditions.push([key, "Lt", value.lt]);
} else {
conditions.push([key, "Eq", value]);
}
// Array shorthand: { key: [a, b] } means "in".
if (Array.isArray(value)) {
conditions.push([key, "In", value]);
continue;
}
if (typeof value === "object" && value !== null) {
// Operator dict: every operator present must hold. Previously only
// `gte` and `lte` were read, so `gt`/`lt`/`ne`/`eq`/`in`/`nin` were
// silently dropped and the filter returned unfiltered results.
for (const [op, operand] of Object.entries(value)) {
const token = TurbopufferDB.OPERATOR_MAP[op];
if (!token) {
throw new Error(
`Unsupported Turbopuffer filter operator '${op}' for field '${key}'. ` +
`Supported operators: ${Object.keys(TurbopufferDB.OPERATOR_MAP).join(", ")}.`,
);
}
conditions.push([key, token, operand]);
}
continue;
}
// Scalar shorthand: equality.
conditions.push([key, "Eq", value]);
}
if (conditions.length === 0) return null;
@@ -305,18 +274,7 @@ export class TurbopufferDB implements VectorStore {
private parseRows(rows: any[]): VectorStoreResult[] {
return rows.map((row) => {
const { id, $dist, vector, ...rest } = row;
let score: number | undefined;
if ($dist == null) {
score = undefined;
} else if (this.distanceMetric === "euclidean_squared") {
// euclidean_squared $dist is an unbounded squared distance, so 1 - $dist
// goes negative for any $dist > 1 and inverts ranking. Convert it to a
// bounded higher-is-better score, mirroring the milvus/baidu stores.
score = 1 / (1 + $dist);
} else {
// Cosine distance is in [0, 2]; 1 - $dist stays a meaningful similarity.
score = 1 - $dist;
}
const score = $dist != null ? 1 - $dist : undefined;
return { id: String(id), payload: rest, score };
});
}
@@ -1,76 +0,0 @@
/// <reference types="jest" />
/**
* Turbopuffer vector store — score conversion unit tests.
*
* Drives parseRows() through the public search() API with a mocked
* @turbopuffer/turbopuffer peer, asserting the score returned per metric.
*/
const mockQuery = jest.fn();
jest.mock("@turbopuffer/turbopuffer", () => ({
__esModule: true,
default: class {
namespace() {
return { query: mockQuery };
}
},
}));
import { TurbopufferDB } from "../src/vector_stores/turbopuffer";
function makeStore(distanceMetric?: string) {
return new TurbopufferDB({
apiKey: "test-key",
collectionName: "mem0",
...(distanceMetric ? { distanceMetric } : {}),
} as any);
}
async function scoreFor(
distanceMetric: string | undefined,
row: Record<string, any>,
): Promise<number | undefined> {
mockQuery.mockResolvedValueOnce({ rows: [row] });
const results = await makeStore(distanceMetric).search([0.1, 0.2, 0.3], 5);
return results[0].score;
}
describe("TurbopufferDB score conversion", () => {
it("keeps cosine distance as 1 - dist (default metric)", async () => {
// cosine_distance is the default; a distance of 0.25 -> similarity 0.75.
expect(await scoreFor(undefined, { id: "a", $dist: 0.25 })).toBeCloseTo(
0.75,
10,
);
});
it("bounds an unbounded euclidean_squared distance to a 0..1 similarity", async () => {
// $dist = 4.0 is a squared distance. 1 - 4.0 = -3.0 would invert ranking;
// 1 / (1 + 4.0) = 0.2 keeps it higher-is-better and in range.
const score = await scoreFor("euclidean_squared", { id: "a", $dist: 4.0 });
expect(score).toBeCloseTo(0.2, 10);
expect(score!).toBeGreaterThanOrEqual(0);
expect(score!).toBeLessThanOrEqual(1);
});
it("ranks a nearer euclidean_squared hit above a farther one", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{ id: "near", $dist: 1.0 },
{ id: "far", $dist: 9.0 },
],
});
const results = await makeStore("euclidean_squared").search(
[0.1, 0.2, 0.3],
5,
);
const near = results.find((r) => r.id === "near")!;
const far = results.find((r) => r.id === "far")!;
expect(near.score!).toBeGreaterThan(far.score!);
});
it("preserves an undefined score when the row has no distance", async () => {
expect(await scoreFor("euclidean_squared", { id: "a" })).toBeUndefined();
});
});
@@ -1,97 +0,0 @@
/// <reference types="jest" />
/**
* Turbopuffer vector store — filter translation unit tests.
*
* Drives the private convertFilters() through the public search() API with a
* mocked @turbopuffer/turbopuffer peer, and asserts the filter tuple handed to
* ns.query().
*/
const mockQuery = jest.fn().mockResolvedValue({ rows: [] });
// createClient() does `new sdk.default({...})`, whose namespace() returns the
// object search() calls query() on.
jest.mock("@turbopuffer/turbopuffer", () => ({
__esModule: true,
default: class {
namespace() {
return { query: mockQuery };
}
},
}));
import { TurbopufferDB } from "../src/vector_stores/turbopuffer";
function makeStore() {
return new TurbopufferDB({
apiKey: "test-key",
collectionName: "mem0",
} as any);
}
async function filterFor(filters: any): Promise<any> {
mockQuery.mockClear();
await makeStore().search([0.1, 0.2, 0.3], 5, filters);
return mockQuery.mock.calls[0][0].filters;
}
describe("TurbopufferDB convertFilters", () => {
it("maps every operator, not just gte/lte", async () => {
expect(await filterFor({ age: { gt: 18 } })).toEqual(["age", "Gt", 18]);
expect(await filterFor({ age: { lt: 65 } })).toEqual(["age", "Lt", 65]);
expect(await filterFor({ age: { ne: 40 } })).toEqual(["age", "NotEq", 40]);
expect(await filterFor({ role: { eq: "admin" } })).toEqual([
"role",
"Eq",
"admin",
]);
expect(await filterFor({ tier: { in: ["a", "b"] } })).toEqual([
"tier",
"In",
["a", "b"],
]);
expect(await filterFor({ tier: { nin: ["x"] } })).toEqual([
"tier",
"NotIn",
["x"],
]);
});
it("applies every operator in a compound range (AND), not just the first", async () => {
const filter = await filterFor({ age: { gt: 18, lt: 65 } });
expect(filter[0]).toBe("And");
expect(filter[1]).toEqual(
expect.arrayContaining([
["age", "Gt", 18],
["age", "Lt", 65],
]),
);
});
it("treats a bare array value as an 'in' filter", async () => {
expect(await filterFor({ tier: ["gold", "silver"] })).toEqual([
"tier",
"In",
["gold", "silver"],
]);
});
it("keeps scalar equality working", async () => {
expect(await filterFor({ user_id: "u1" })).toEqual(["user_id", "Eq", "u1"]);
});
it("skips a '*' wildcard value instead of matching it literally", async () => {
// Only the real agent_id clause survives; user_id: "*" contributes nothing.
expect(await filterFor({ user_id: "*", agent_id: "a1" })).toEqual([
"agent_id",
"Eq",
"a1",
]);
});
it("throws on an unsupported operator rather than silently dropping it", async () => {
await expect(
makeStore().search([0.1, 0.2, 0.3], 5, { name: { startsWith: "a" } }),
).rejects.toThrow(/Unsupported Turbopuffer filter operator 'startsWith'/);
});
});
+3 -12
View File
@@ -579,20 +579,11 @@ class AWSBedrockLLM(LLMBase):
# Use converse API for Anthropic models
response = self.client.converse(**converse_params)
# Parse Converse API response. Claude reasoning models can emit a
# `reasoningContent` block before the `text` block, so iterate to
# find the first block that carries text instead of indexing
# content[0] (same approach as the MiniMax branch below).
# Parse Converse API response
if hasattr(response, 'output') and hasattr(response.output, 'message'):
for block in response.output.message.content:
if hasattr(block, 'text'):
return block.text
return ""
return response.output.message.content[0].text
elif 'output' in response and 'message' in response['output']:
for block in response['output']['message']['content']:
if 'text' in block:
return block['text']
return ""
return response['output']['message']['content'][0]['text']
else:
return str(response)
+1 -11
View File
@@ -69,16 +69,6 @@ class S3Vectors(VectorStoreBase):
else:
raise
def _distance_to_score(self, raw_distance: Optional[float]) -> Optional[float]:
if raw_distance is None:
return None
# Euclidean distance is unbounded, so 1 - distance would collapse most
# scores to 0. Use a bounded monotonic map instead. Cosine distance is
# in [0, 2], where 1 - distance is already a valid similarity.
if self.distance_metric == "euclidean":
return 1.0 / (1.0 + raw_distance)
return max(0.0, 1.0 - raw_distance)
def _parse_output(self, vectors: List[Dict]) -> List[OutputData]:
results = []
for v in vectors:
@@ -91,7 +81,7 @@ class S3Vectors(VectorStoreBase):
logger.warning(f"Failed to parse metadata for key {v.get('key')}")
payload = {}
raw_distance = v.get("distance")
score = self._distance_to_score(raw_distance)
score = max(0.0, 1.0 - raw_distance) if raw_distance is not None else None
results.append(OutputData(id=v.get("key"), score=score, payload=payload))
return results
+5 -28
View File
@@ -122,14 +122,7 @@ class TurbopufferDB(VectorStoreBase):
dist = row_dict.pop("$dist", None)
row_dict.pop("vector", None)
if dist is None:
score = None
elif self.distance_metric == "euclidean_squared":
# $dist is unbounded squared-L2 (lower = closer); map to a bounded
# higher-is-better score, mirroring milvus/baidu. Cosine returns 1 - dist.
score = 1.0 / (1.0 + dist)
else:
score = 1 - dist
score = 1 - dist if dist is not None else None
results.append(OutputData(
id=row_id,
@@ -138,18 +131,6 @@ class TurbopufferDB(VectorStoreBase):
))
return results
# Maps mem0 filter operators to their Turbopuffer equivalents.
OPERATOR_MAP = {
"eq": "Eq",
"ne": "NotEq",
"gt": "Gt",
"gte": "Gte",
"lt": "Lt",
"lte": "Lte",
"in": "In",
"nin": "NotIn",
}
def _convert_filters(self, filters: Optional[Dict]):
"""
Convert mem0 filters to Turbopuffer filter format.
@@ -162,14 +143,10 @@ class TurbopufferDB(VectorStoreBase):
conditions = []
for key, value in filters.items():
if isinstance(value, dict):
for op, operand in value.items():
tpuf_op = self.OPERATOR_MAP.get(op)
if tpuf_op is None:
raise ValueError(
f"Unsupported filter operator '{op}' for field '{key}'. "
f"Supported operators: {sorted(self.OPERATOR_MAP)}"
)
conditions.append((key, tpuf_op, operand))
if "gte" in value:
conditions.append((key, "Gte", value["gte"]))
if "lte" in value:
conditions.append((key, "Lte", value["lte"]))
else:
conditions.append((key, "Eq", value))
+2 -10
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "mem0ai"
version = "2.2.1"
version = "2.2.0"
description = "Long-term memory for AI Agents"
authors = [
{ name = "Mem0", email = "support@mem0.ai" }
@@ -83,7 +83,7 @@ test = [
dev = [
"ruff==0.16.0",
"isort>=5.13.2",
"pre-commit>=3.5.0",
"pytest>=8.2.2",
]
[tool.pytest.ini_options]
@@ -111,7 +111,6 @@ features = [
"vector-stores",
"llms",
"extras",
"dev",
]
[tool.hatch.envs.dev_py_3_11]
@@ -121,7 +120,6 @@ features = [
"vector-stores",
"llms",
"extras",
"dev",
]
[tool.hatch.envs.dev_py_3_12]
@@ -131,12 +129,6 @@ features = [
"vector-stores",
"llms",
"extras",
"dev",
]
[tool.hatch.envs.default]
features = [
"dev",
]
[tool.hatch.envs.default.scripts]
-48
View File
@@ -506,51 +506,3 @@ class TestParseResponseLegacy:
response = {"body": body}
result = llm._parse_response(response, tools=None)
assert result == "hello from ai21"
class TestAnthropicConverseContentParsing:
"""The Anthropic Converse branch must not assume content[0] is the text
block: Claude reasoning models emit a reasoningContent block before the
text block, and some stop conditions produce an empty content array. The
parser iterates for the first block carrying text, like the MiniMax branch.
"""
def test_text_after_reasoning_content_block(self, mock_boto3):
mock_boto3.converse.return_value = {
"output": {
"message": {
"content": [
{"reasoningContent": {"reasoningText": {"text": "step by step..."}}},
{"text": "final answer"},
]
}
}
}
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
assert llm.generate_response(MESSAGES) == "final answer"
def test_empty_content_returns_empty_string(self, mock_boto3):
mock_boto3.converse.return_value = {"output": {"message": {"content": []}}}
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
assert llm.generate_response(MESSAGES) == ""
def test_plain_text_content_still_returned(self, mock_boto3):
mock_boto3.converse.return_value = _converse_response("plain answer")
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
assert llm.generate_response(MESSAGES) == "plain answer"
def test_object_style_response_iterates_blocks(self, mock_boto3):
# Defensive attr-style branch: object wrapper with a reasoning block first.
from types import SimpleNamespace
reasoning_block = SimpleNamespace(reasoningContent={"reasoningText": {"text": "hmm"}})
text_block = SimpleNamespace(text="object answer")
mock_boto3.converse.return_value = SimpleNamespace(
output=SimpleNamespace(message=SimpleNamespace(content=[reasoning_block, text_block]))
)
llm = _make_llm("anthropic.claude-3-5-sonnet-20240620-v1:0", mock_boto3)
assert llm.generate_response(MESSAGES) == "object answer"
-39
View File
@@ -186,45 +186,6 @@ def test_search(mock_boto_client):
assert results[0].score == pytest.approx(0.1)
def test_search_score_cosine_uses_one_minus_distance(mock_boto_client):
"""Cosine metric keeps the 1 - distance similarity mapping."""
mock_boto_client.query_vectors.return_value = {"vectors": [{"key": "id1", "distance": 0.25, "metadata": {}}]}
store = S3Vectors(
vector_bucket_name=BUCKET_NAME,
collection_name=INDEX_NAME,
embedding_model_dims=EMBEDDING_DIMS,
distance_metric="cosine",
)
results = store.search(query="test", vectors=[0.1, 0.2], top_k=1)
assert results[0].score == pytest.approx(0.75)
def test_search_score_euclidean_uses_bounded_map(mock_boto_client):
"""Euclidean distance is unbounded; scores must not collapse to 0."""
mock_boto_client.query_vectors.return_value = {
"vectors": [
{"key": "near", "distance": 0.5, "metadata": {}},
{"key": "far", "distance": 4.0, "metadata": {}},
]
}
store = S3Vectors(
vector_bucket_name=BUCKET_NAME,
collection_name=INDEX_NAME,
embedding_model_dims=EMBEDDING_DIMS,
distance_metric="euclidean",
)
results = store.search(query="test", vectors=[0.1, 0.2], top_k=2)
# 1 / (1 + d): a distance > 1 would give a negative (clamped 0) score under
# the old cosine-only formula; the bounded map keeps ranking intact.
assert results[0].score == pytest.approx(1.0 / 1.5)
assert results[1].score == pytest.approx(1.0 / 5.0)
assert results[0].score > results[1].score > 0.0
def test_get(mock_boto_client):
"""Test retrieving a vector by ID."""
mock_boto_client.get_vectors.return_value = {
-63
View File
@@ -232,35 +232,6 @@ class TestParseOutput:
def test_parse_empty_rows(self, db):
assert db._parse_output([]) == []
def test_parse_cosine_score_is_one_minus_dist(self, db):
# Default cosine metric: score = 1 - dist, unchanged by the metric fix.
results = db._parse_output([_make_row("id1", dist=0.25)])
assert results[0].score == pytest.approx(0.75)
def test_parse_euclidean_squared_score_is_bounded(self, mock_client):
# euclidean_squared $dist is unbounded (e.g. 4.0). 1 - dist would give -3.0,
# violating the higher-is-better contract; map it to 1/(1+dist) instead.
db = TurbopufferDB(
collection_name="test_ns",
embedding_model_dims=4,
api_key="tpuf_test_key",
region="gcp-us-central1",
distance_metric="euclidean_squared",
)
results = db._parse_output([_make_row("id1", dist=4.0)])
assert results[0].score == pytest.approx(0.2)
assert 0.0 <= results[0].score <= 1.0
def test_parse_euclidean_squared_preserves_none(self, mock_client):
db = TurbopufferDB(
collection_name="test_ns",
embedding_model_dims=4,
api_key="tpuf_test_key",
region="gcp-us-central1",
distance_metric="euclidean_squared",
)
assert db._parse_output([_make_row("id1")])[0].score is None
# ── _convert_filters ─────────────────────────────────────────────────
@@ -305,40 +276,6 @@ class TestConvertFilters:
assert ("user_id", "Eq", "u1") in conditions
assert ("score", "Gte", 0.5) in conditions
def test_gt_operator_not_dropped(self, db):
"""Regression: {"gt": ...} was silently dropped, returning unfiltered results."""
result = db._convert_filters({"age": {"gt": 18}})
assert result == ("age", "Gt", 18)
@pytest.mark.parametrize(
"op,expected_token",
[
("eq", "Eq"),
("ne", "NotEq"),
("gt", "Gt"),
("gte", "Gte"),
("lt", "Lt"),
("lte", "Lte"),
("in", "In"),
("nin", "NotIn"),
],
)
def test_all_operators_mapped(self, db, op, expected_token):
operand = [1, 2] if op in ("in", "nin") else 5
result = db._convert_filters({"age": {op: operand}})
assert result == ("age", expected_token, operand)
def test_multiple_operators_on_one_field(self, db):
result = db._convert_filters({"age": {"gt": 18, "lt": 65}})
assert result[0] == "And"
conditions = result[1]
assert ("age", "Gt", 18) in conditions
assert ("age", "Lt", 65) in conditions
def test_unknown_operator_raises(self, db):
with pytest.raises(ValueError, match="Unsupported filter operator"):
db._convert_filters({"age": {"between": [1, 2]}})
# ── search ───────────────────────────────────────────────────────────