Skip to content

Commit 60a7a2d

Browse files
os-zhuangclaude
andauthored
fix(driver-memory): refuse the filters the live query path cannot evaluate, compile the one it must (#5324, #5328) (#5349)
* fix(driver-memory): refuse the filters the live query path cannot evaluate, compile the one it must (#5324, #5328) `normalizeFilterCondition` had two ways of not refusing a filter it could not compile, in one `switch`: - `default: result[op] = val` handed every unrecognised `$op` to mingo, which answered with a `MingoError` carrying no `code` and no `status` — outside the ADR-0112 envelope, so a client mistake was served as a 500-shaped body (#5324); - the `$between` arm was written conditionally, so a comparand that was not a two-element array skipped it, the constraint vanished, and `find` returned `[]` (#5328). Opposite symptoms, one cause: the shape that could not be evaluated was not refused. #3948 and #4436 settled that an uncompilable filter is a loud refusal rather than a silent answer; this brings that rule to driver-memory's live query path, reusing `filter-refusal.ts`'s existing `INVALID_FILTER` / 400. `$not` goes the other way. It is a declared combinator (`LOGICAL_OPERATORS`), `cel-to-filter` emits it for a CEL `!expr` RLS scope, and driver-sql, driver-mongodb and this package's own matcher all implement it — but MongoDB has no document-level `$not`, so passing it through meant every negated scope threw. It is compiled to `$nor` with one operand (driver-mongodb's rewrite, #4405), which is NULL-safe by construction and so lands on the #5146 canon. Both filter faces now share ONE shape gate rather than a copy of one check. They had drifted: a malformed `$between` returned NO rows from the live path and EVERY row from the reference matcher. Closes the conformance gap that hid this: `FILTER_LOGIC_CASES` reached this backend through the reference matcher only, which the driver does not call, so the table's `$not` case was green while the same filter through `InMemoryDriver.find` threw. It now runs through the real driver too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 * refactor(driver-memory): the zero-operator check keeps its own predicate `assertFieldConstraintShape` had inlined `keys.length === 0`, which left `isEmptyFieldConstraint` — and the #5240 reasoning in its doc comment about what does NOT count as one (a `Date` enumerates to nothing but is a comparand) — attached to nothing. Behaviour-identical: the caller has already established the spec is a filter node, which is the predicate's other half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 * refactor(driver-memory): the translator's floor-throws reuse the gate's wording A malformed `$and`/`$or` operand and a malformed `$not` operand each had two messages: the gate's `filterNodeListExpectedError` / `filterNodeExpectedError`, and a second, differently-worded `unknownLogicalOperatorError` in the translator's unreachable floor. #5240's rule is one condition, one wording — and a floor that says something different from the gate above it is exactly what a reader would use to conclude they had hit a different problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 * fix(driver-memory): `$options` without `$regex` is refused too — the one operator the allowlist would have leaked Measured after the vocabulary landed: `{ field: { $options: 'i' } }` still escaped the ADR-0112 envelope on the live path (`unknown query operator $options`, no code, no status) while the reference matcher ignored it and matched EVERY row. #5324's exact shape, surviving for a single operator — because `$options` is in the vocabulary as a MODIFIER of `$regex`, not a predicate, so allowlisting the key without requiring its partner left the hole open for it alone. Refused when no `$regex` accompanies it, on both faces, and pinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 * docs(changeset): state the $options rule the last fix added Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 553a47f commit 60a7a2d

9 files changed

Lines changed: 1157 additions & 98 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
"@objectstack/driver-memory": patch
3+
---
4+
5+
fix(driver-memory): the live query path refuses the filters it cannot evaluate, and compiles the one it must (#5324, #5328)
6+
7+
**This is an observable behaviour change.** Two filter shapes that used to be
8+
answered *silently* now raise the catalogued `INVALID_FILTER` / 400 every other
9+
filter refusal in this driver and in `driver-sql` already speaks (ADR-0112):
10+
11+
| filter | before | now |
12+
|---|---|---|
13+
| an operator outside the Filter Protocol — `{ name: { $sounds_like: 'x' } }`, `$elemMatch`, `$size`, `$where`, field-level `$not`, … | handed to mingo, which threw a `MingoError` carrying **no `code` and no `status`** — served as a 500-shaped `{ error }` body | `INVALID_FILTER` / 400, naming the operator, the field and its position |
14+
| a `$between` whose comparand is not `[min, max]``{ score: { $between: 5 } }` | the arm was skipped, the constraint **vanished**, and `find` returned `[]` | `INVALID_FILTER` / 400, wording aligned with `driver-sql`'s |
15+
16+
Two more shapes join them, same cause: an undeclared `$`-combinator in a node
17+
position (`{ $nor: … }`, `{ $where: … }``FilterConditionSchema` declares
18+
`$and`/`$or`/`$not` and nothing else), and a combinator operand that is not a
19+
filter condition (`{ $or: 'x' }`, `{ $or: [null] }`, `{ $not: 'x' }`).
20+
21+
If a query of yours starts returning a 400, it was already broken — it was
22+
returning an empty result set or an uncoded 500 for the same input, and
23+
`driver-sql` was rejecting it. The message names the operator and the path
24+
(`filter.$or[1].$and[0].stage`).
25+
26+
**`$not` is the opposite change: it now works.** `$not` is a declared combinator
27+
(`LOGICAL_OPERATORS`), `cel-to-filter` emits it for every CEL `!expr` in an RLS
28+
read scope, and `driver-sql` / `driver-mongodb` / this package's own reference
29+
matcher all implement it — but the live query path passed it to mingo, and
30+
MongoDB has no document-level `$not`, so **every query carrying a negated scope
31+
threw** `unknown top level operator: $not`. It is compiled to `$nor` with one
32+
operand, the same rewrite `driver-mongodb` performs, which is NULL-safe by
33+
construction and therefore lands on the answer #5146 ruled canonical.
34+
35+
Both of this package's filter faces — the live mingo path and the reference
36+
matcher — now share ONE shape gate, so they cannot answer one filter
37+
differently again. They did: given a malformed `$between` the live path returned
38+
NO rows while the matcher returned EVERY row.
39+
40+
The conformance gap that hid all of this is closed too. `FILTER_LOGIC_CASES`
41+
was run against this backend through the reference matcher only — the driver
42+
does not call it — so the table's `$not` case had been green for as long as it
43+
existed while the same filter through `InMemoryDriver.find` threw. The table now
44+
runs through the real driver, as it does for the other three backends.
45+
46+
Accepted operators are the spec's `FILTER_OPERATORS`, plus `$regex` (produced by
47+
plugin-auth's ObjectQL adapter, compiled by `driver-sql`) and its `$options`
48+
companion. `$options` is a modifier, not a predicate: on its own, with no
49+
`$regex` beside it, it is refused like any other filter this driver cannot
50+
evaluate — it used to raise the same uncoded engine error on the live path and
51+
match every row in the matcher.

packages/plugins/driver-memory/src/filter-refusal.ts

Lines changed: 307 additions & 5 deletions
Large diffs are not rendered by default.
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5324] Document-level `$not` on the LIVE query path — the shape the issue was
5+
* filed on.
6+
*
7+
* # Why this is implemented and not refused
8+
*
9+
* #5324 offered both directions and deliberately declined to choose. The
10+
* evidence chooses: `$not` is a DECLARED combinator (`LOGICAL_OPERATORS` in
11+
* `@objectstack/spec/data`, alongside `$and`/`$or`), `driver-sql` compiles it,
12+
* `driver-mongodb` translates it, `memory-matcher` evaluates it, and
13+
* `FILTER_LOGIC_CASES` — the standard every backend is held to — contains a case
14+
* that requires it. Refusing it would have made this driver the only backend
15+
* that cannot run a spec-declared operator, and would have left the conformance
16+
* table with a case it could never pass. "Refuse what you cannot evaluate" has a
17+
* companion clause: what the contract DECLARES, you evaluate.
18+
*
19+
* So the general refusal in `memory-filter-vocabulary-refusal.test.ts` covers
20+
* every operator the Filter Protocol does not declare, and this file covers the
21+
* one it does.
22+
*
23+
* # The rewrite, and why `$nor`
24+
*
25+
* mingo is a MongoDB-semantics engine, and MongoDB has no document-level `$not`
26+
* — `unknown top level operator: $not`, uncoded, was the whole of #5324. The
27+
* negation of a whole condition in MongoDB is `$nor` with a single operand, and
28+
* that is exactly the rewrite `driver-mongodb` performs for the same reason
29+
* (#4405). Nothing else about the condition changes.
30+
*
31+
* # Why the null cases are the load-bearing ones
32+
*
33+
* `cel-to-filter.ts` lowers a CEL `!expr` to `{ $not: {…} }`, which is the
34+
* ordinary product of an RLS read scope — so this operator decides who sees
35+
* which rows. #5146 ruled the JS backends' two-valued reading canonical and
36+
* rewrote `driver-sql`'s SQL to match it, because SQL's `NOT (col = x)` is
37+
* UNKNOWN for a NULL column and a `WHERE` drops the row. `$nor` is total by
38+
* construction and lands on the same answer — asserted below against the exact
39+
* fixture and expectations `memory-matcher-not-null-safe.test.ts` pins, so the
40+
* live path is held to the ruling rather than merely to "it no longer throws".
41+
*/
42+
43+
import { describe, it, expect, beforeAll } from 'vitest';
44+
import type { FilterCondition } from '@objectstack/spec/data';
45+
46+
import { InMemoryDriver } from './memory-driver.js';
47+
import { match } from './memory-matcher.js';
48+
49+
/** Fields present but null — how a SQL NULL round-trips into a record. */
50+
const NULLED = [
51+
{ id: '1', stage: 'won', owner: 'u1', amount: 10 },
52+
{ id: '2', stage: 'lost', owner: 'u2', amount: 20 },
53+
{ id: '3', stage: null, owner: 'u1', amount: null },
54+
{ id: '4', stage: null, owner: null, amount: 40 },
55+
];
56+
57+
/** The same rows with the null fields ABSENT — the shape a partial write leaves. */
58+
const MISSING = [
59+
{ id: '1', stage: 'won', owner: 'u1', amount: 10 },
60+
{ id: '2', stage: 'lost', owner: 'u2', amount: 20 },
61+
{ id: '3', owner: 'u1' },
62+
{ id: '4', amount: 40 },
63+
];
64+
65+
const ALL = ['1', '2', '3', '4'];
66+
67+
const FIELDS = {
68+
id: { type: 'text', name: 'id' },
69+
stage: { type: 'text', name: 'stage' },
70+
owner: { type: 'text', name: 'owner' },
71+
amount: { type: 'number', name: 'amount' },
72+
};
73+
74+
describe('[#5324] InMemoryDriver.find compiles a document-level $not', () => {
75+
let nulled: InMemoryDriver;
76+
let missing: InMemoryDriver;
77+
78+
beforeAll(async () => {
79+
nulled = new InMemoryDriver({ persistence: false });
80+
await nulled.syncSchema('deal', { fields: FIELDS });
81+
for (const row of NULLED) await nulled.create('deal', { ...row });
82+
83+
missing = new InMemoryDriver({ persistence: false });
84+
await missing.syncSchema('deal', { fields: FIELDS });
85+
for (const row of MISSING) await missing.create('deal', { ...row });
86+
});
87+
88+
const idsFrom = async (driver: InMemoryDriver, where: unknown): Promise<string[]> => {
89+
const rows = await driver.find('deal', { object: 'deal', fields: ['id'], where: where as FilterCondition });
90+
return (rows as Array<Record<string, unknown>>).map((r) => String(r.id)).sort();
91+
};
92+
93+
/**
94+
* Both readings of "no value" must give the same answer, and the reference
95+
* matcher must give it too — the same contract
96+
* `memory-matcher-not-null-safe.test.ts` states for its own face, now binding
97+
* on the path that actually serves queries.
98+
*/
99+
const matched = async (where: unknown): Promise<string[]> => {
100+
const fromNulled = await idsFrom(nulled, where);
101+
const fromMissing = await idsFrom(missing, where);
102+
expect(fromMissing, 'a null field and an absent field must match alike').toEqual(fromNulled);
103+
const reference = NULLED.filter((r) => match(r, where)).map((r) => r.id);
104+
expect(fromNulled, 'the live query path and the reference matcher must agree').toEqual(reference);
105+
return fromNulled;
106+
};
107+
108+
describe('the shape #5324 reported — every position, not just the top level', () => {
109+
it('at the top level', async () => {
110+
expect(await matched({ $not: { stage: 'won' } })).toEqual(['2', '3', '4']);
111+
});
112+
113+
it('inside a $or branch', async () => {
114+
// The issue measured all three of these throwing `unknown top level
115+
// operator: $not`; `normalizeFilterCondition` passed `$not` through
116+
// wherever it sat, so nesting never helped.
117+
expect(await matched({ $or: [{ $not: { stage: 'won' } }] })).toEqual(['2', '3', '4']);
118+
});
119+
120+
it('inside a $and branch', async () => {
121+
expect(await matched({ $and: [{ $not: { stage: 'won' } }] })).toEqual(['2', '3', '4']);
122+
});
123+
124+
it('ANDs with its sibling keys', async () => {
125+
expect(await matched({ $not: { stage: 'won' }, owner: 'u1' })).toEqual(['3']);
126+
});
127+
128+
it('nested two combinators deep', async () => {
129+
expect(await matched({ $and: [{ $or: [{ $not: { stage: 'won' }, owner: 'u1' }] }] })).toEqual(['3']);
130+
});
131+
132+
it('the RLS shape a CEL `!(stage == "won")` scope lowers to', async () => {
133+
// `cel-to-filter.ts` emits exactly this for a negated read scope. On this
134+
// driver — the default for dev and test — it used to be an uncoded throw
135+
// on every query the scope touched, not a wrong row count.
136+
expect(await matched({ $not: { stage: 'won' } })).toHaveLength(3);
137+
});
138+
});
139+
140+
describe('the #5146 canon, now answered by the live path too', () => {
141+
it('$not over multiple keys matches a record missing EITHER', async () => {
142+
expect(await matched({ $not: { stage: 'won', owner: 'u1' } })).toEqual(['2', '3', '4']);
143+
});
144+
145+
it('$not of a $or rejects a value-less record whose OTHER branch matches', async () => {
146+
// Record 3 has no stage but owner = 'u1', so the $or holds and the
147+
// negation must reject it. This is the case that forced `driver-sql` to
148+
// compile its NULL guard onto each leaf instead of beside the `NOT`.
149+
expect(await matched({ $not: { $or: [{ stage: 'won' }, { owner: 'u1' }] } })).toEqual(['2', '4']);
150+
});
151+
152+
it('$not of a $and matches every record failing either conjunct', async () => {
153+
expect(await matched({ $not: { $and: [{ stage: 'won' }, { owner: 'u1' }] } })).toEqual(['2', '3', '4']);
154+
});
155+
156+
it('a double negation is the positive filter again', async () => {
157+
expect(await matched({ $not: { $not: { stage: 'won' } } })).toEqual(['1']);
158+
expect(await matched({ $not: { $not: { stage: 'won' } } })).toEqual(await matched({ stage: 'won' }));
159+
});
160+
161+
it('$not of $ne still means "the field IS that value"', async () => {
162+
expect(await matched({ $not: { stage: { $ne: 'won' } } })).toEqual(['1']);
163+
});
164+
165+
it('$not of $in matches the value-less records', async () => {
166+
expect(await matched({ $not: { stage: { $in: ['won'] } } })).toEqual(['2', '3', '4']);
167+
});
168+
169+
it('$not of an ordering comparison matches the value-less records', async () => {
170+
expect(await matched({ $not: { amount: { $gt: 15 } } })).toEqual(['1', '3']);
171+
});
172+
173+
it('$not of $contains matches the value-less records', async () => {
174+
expect(await matched({ $not: { stage: { $contains: 'w' } } })).toEqual(['2', '3', '4']);
175+
});
176+
177+
it('$not of a null predicate', async () => {
178+
expect(await matched({ $not: { stage: { $null: true } } })).toEqual(['1', '2']);
179+
expect(await matched({ $not: { stage: { $null: false } } })).toEqual(['3', '4']);
180+
});
181+
});
182+
183+
describe('the boolean identities (#5134)', () => {
184+
it('$not: {} matches nothing — NOT TRUE ≡ FALSE', async () => {
185+
expect(await matched({ $not: {} })).toEqual([]);
186+
});
187+
188+
it('$not of an empty $or matches everything', async () => {
189+
expect(await matched({ $not: { $or: [] } })).toEqual(ALL);
190+
});
191+
});
192+
193+
/**
194+
* Measured while verifying this fix, and NOT caused by it: three operators
195+
* answer a value-less field differently on the two faces, with or without a
196+
* `$not` around them. mingo reads `$exists` as key presence and lets `$nin`
197+
* match a missing key; the matcher's `value === undefined` guard and its
198+
* `typeof value !== 'string'` test answer the opposite.
199+
*
200+
* This is a SEMANTIC divergence, not a shape one, so the gate this PR adds
201+
* neither causes nor cures it — a ruling on which reading is canonical belongs
202+
* with the identical matcher-vs-formula divergence already filed as **#5299**,
203+
* where this measurement is recorded. Pinned as measured so the fix that lands
204+
* there has to move these lines deliberately.
205+
*/
206+
describe('known two-face divergences on a value-less field — pinned, see #5299', () => {
207+
const liveVsReference = async (where: unknown) => ({
208+
live: await idsFrom(nulled, where),
209+
reference: NULLED.filter((r) => match(r, where)).map((r) => r.id),
210+
});
211+
212+
it('$exists on a present-but-null field: mingo says "the key is there", the matcher says "no value"', async () => {
213+
expect(await liveVsReference({ stage: { $exists: true } })).toEqual({
214+
live: ['1', '2', '3', '4'],
215+
reference: ['1', '2'],
216+
});
217+
});
218+
219+
it('$nin on an ABSENT field', async () => {
220+
const live = await idsFrom(missing, { stage: { $nin: ['won'] } });
221+
const reference = MISSING.filter((r) => match(r, { stage: { $nin: ['won'] } })).map((r) => r.id);
222+
expect({ live, reference }).toEqual({ live: ['2', '3', '4'], reference: ['2'] });
223+
});
224+
225+
it('$notContains on a null field', async () => {
226+
expect(await liveVsReference({ $not: { stage: { $notContains: 'w' } } })).toEqual({
227+
live: ['1'],
228+
reference: ['1', '3', '4'],
229+
});
230+
});
231+
});
232+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#5324/#5328] Filter logical-combinator conformance for the LIVE QUERY PATH —
5+
* `InMemoryDriver.find`, through `normalizeFilterCondition` and mingo.
6+
*
7+
* # Why this file exists at all
8+
*
9+
* `FILTER_LOGIC_CASES` is the one standard five filter backends are held to
10+
* (`@objectstack/spec/data`, #3774). Four of them ran it through the code a real
11+
* query executes: `driver-sql` compiles it to SQL, `driver-sqlite-wasm` runs
12+
* that SQL on sql.js, `driver-mongodb` translates and executes it, and
13+
* `service-analytics` lowers it into its read-scope SQL.
14+
*
15+
* `driver-memory` ran it through `memory-matcher` ONLY
16+
* (`memory-matcher-or-semantics.test.ts`). That file is not a driver test: the
17+
* driver does not call `match()` — it imports exactly one symbol from that
18+
* module, `getValueByPath`, and filters with mingo instead. So this backend's
19+
* half of the conformance table was measured against a REFERENCE implementation
20+
* while the half users actually run was never executed against the standard once.
21+
*
22+
* The cost was not hypothetical. The table's `$not ANDs with its sibling keys
23+
* inside a branch` case was green here for as long as it has existed, while the
24+
* same filter through `InMemoryDriver.find` threw `unknown top level operator:
25+
* $not` — MongoDB has no document-level `$not`, so mingo has none either (#5324).
26+
* A conformance suite that green-lights an operator the driver cannot run is
27+
* worse than no suite: it is a gate reporting coverage it does not have, which
28+
* is the "declared ≠ enforced" shape Prime Directive #10 names.
29+
*
30+
* So the gap is closed the way the other three backends close it — by running
31+
* the table through the thing that serves queries. `memory-matcher-or-semantics`
32+
* stays: the matcher is still the reference evaluator, and holding BOTH faces to
33+
* the same table is what makes "this package has two filter surfaces" a
34+
* statement someone can check.
35+
*/
36+
37+
import { describe, it, expect, beforeAll } from 'vitest';
38+
import { FILTER_LOGIC_CASES, FILTER_LOGIC_ROWS } from '@objectstack/spec/data';
39+
import type { FilterCondition } from '@objectstack/spec/data';
40+
41+
import { InMemoryDriver } from './memory-driver.js';
42+
import { match } from './memory-matcher.js';
43+
44+
const TABLE = 'conformance';
45+
46+
describe('[#5324] InMemoryDriver.find — filter logic conformance (the LIVE query path)', () => {
47+
let driver: InMemoryDriver;
48+
49+
beforeAll(async () => {
50+
driver = new InMemoryDriver({ persistence: false });
51+
await driver.connect();
52+
// Every fixture column is a plain string — the shared table keeps its
53+
// predicates boring on purpose, so nothing here is about coercion. The
54+
// declaration is still made, because that is how a real object reaches the
55+
// driver and how its field kinds are resolved (#4047).
56+
await driver.syncSchema(TABLE, {
57+
fields: {
58+
id: { type: 'text', name: 'id' },
59+
a: { type: 'text', name: 'a' },
60+
b: { type: 'text', name: 'b' },
61+
c: { type: 'text', name: 'c' },
62+
owner: { type: 'text', name: 'owner' },
63+
status: { type: 'text', name: 'status' },
64+
parent_object: { type: 'text', name: 'parent_object' },
65+
parent_id: { type: 'text', name: 'parent_id' },
66+
},
67+
});
68+
for (const row of FILTER_LOGIC_ROWS) await driver.create(TABLE, { ...row });
69+
});
70+
71+
const ids = async (where: FilterCondition): Promise<string[]> => {
72+
const rows = await driver.find(TABLE, { object: TABLE, fields: ['id'], where });
73+
return (rows as Array<Record<string, unknown>>).map((r) => String(r.id)).sort((x, y) => x.localeCompare(y));
74+
};
75+
76+
for (const c of FILTER_LOGIC_CASES) {
77+
it(c.name, async () => {
78+
expect(await ids(c.filter), c.note).toEqual([...c.expected]);
79+
});
80+
}
81+
82+
/**
83+
* The fixture as a whole, so a case that returns nothing because the seed
84+
* failed cannot read as a case that correctly excluded everything.
85+
*/
86+
it('the fixture really is all four rows', async () => {
87+
expect(await ids({})).toEqual(['1', '2', '3', '4']);
88+
});
89+
90+
/**
91+
* The two faces, on the same table, in one assertion.
92+
*
93+
* `memory-matcher-or-semantics.test.ts` already holds the matcher to these
94+
* cases and this file holds the driver to them, so both being green already
95+
* implies agreement. Asserting it directly is still worth one test: it is the
96+
* invariant #5240 established for this package ("a backend whose two halves
97+
* disagree about what a filter MEANS is exactly the divergence the ruling
98+
* closes"), and stated here it survives either suite being edited.
99+
*/
100+
it('both filter faces answer the whole table identically', async () => {
101+
for (const c of FILTER_LOGIC_CASES) {
102+
const live = await ids(c.filter);
103+
const reference = FILTER_LOGIC_ROWS.filter((r) => match(r, c.filter)).map((r) => r.id);
104+
expect(live, `${c.name}: the live query path and the reference matcher disagree`).toEqual(reference);
105+
}
106+
});
107+
});

0 commit comments

Comments
 (0)