Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/20749-spec-strings-stage6-conformance-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
---

The shared conformance tables' case notes and names no longer cite tracker numbers; each one states the decision behind it in words

Clause-②: no

The conformance tables in `@objectstack/spec` (`FILTER_LOGIC_CASES`, `FILTER_TEXT_CASES`, `FILTER_COMPARAND_TYPE_CASES`, `AGGREGATION_CASES`, `TEMPORAL_ROWS` / `TEMPORAL_CASES` / `TEMPORAL_TIME_CASES`, `VALUE_ROUNDTRIP_CASES`, `TEXT_OPERATOR_DOOR_TYPE_CLASSES` and `METADATA_ROUNDTRIP_CASES`) are what every driver, and any third-party implementation, is measured against. A case's `note` or `why` is printed when that case fails, and some drivers print it in the test title. Fifty-eight of those texts pointed at an issue-tracker number for the reason a case exists. The number goes; where the sentence did not already say what was decided, it now does. For example:

- The four empty-combinator cases say every face reduces an empty combinator to its boolean identity, and why `{}` and `$not: {}` follow from it.
- The no-value cases say `$ne`, `$nin`, `$notContains` and `$not` are NULL-safe on every face, and that `$exists` means "has a value" because SQL cannot tell a missing key from a stored null.
- The boolean-aggregand cases say a boolean is worth 1 or 0 on every face, including for `min` / `max`, and that this ruling superseded an earlier `false` / `true` answer.
- The `$empty` cases say every face answers `$empty` by the field's declared type.

Six case names change with them: `icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — …` in `FILTER_TEXT_CASES`, and five names in `FILTER_COMPARAND_TYPE_CASES` (the control cell, the bigint crash cell, and the three array-in-the-equality-slot refusals, which now say they are refused at the shared face).

Text only: no case's filter, input, expected rows, verdict, error code, order or count changes, and no export, type or schema moves. A tool or test that selects or pins a case by its old note or name (for example by a tracker-number substring) needs the new spelling.
2 changes: 1 addition & 1 deletion packages/lint/src/validate-empty-combinators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ describe('validateEmptyCombinators — the vocabulary is the runtime\'s (#5322/#
* retyped: `filter-logic-conformance.ts` is what every backend is measured
* against, so a message derived from anything else would be a second opinion.
*/
const identityCases = FILTER_LOGIC_CASES.filter((c) => (c.note ?? '').includes('#5322'));
const identityCases = FILTER_LOGIC_CASES.filter((c) => (c.note ?? '').includes('every face reduces an empty combinator to its boolean identity'));

it('the identity cases are still findable in the conformance table', () => {
// Guarded body: if the selection ever returns nothing, the loop below would
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ describe('[#15780] the three compilers, EXECUTED on a real SQLite engine', () =>
'ASCII-only: a lower-case non-ASCII comparand does NOT match its upper-case row',
'ASCII-only: an upper-case non-ASCII comparand does NOT match its lower-case row',
'$icontains treats % as a literal character, not a LIKE wildcard',
'icontains (the infix/view spelling, #8934) lowers to $icontains — % stays a LITERAL through that door too',
'icontains (the infix/view spelling, ruled never an alias of ilike) lowers to $icontains — % stays a LITERAL through that door too',
'$icontains treats _ as a literal character, not a single-character wildcard',
'$icontains treats . as a literal character, not a regex metacharacter',
]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,15 +191,15 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [
writes: [{ type: 'object', name: 'pin_account', data: PIN_ACCOUNT }],
read: { type: 'object', name: 'pin_account' },
expected: { kind: 'readable', document: PIN_ACCOUNT },
why: 'The contract\'s first two CRUD members, on the type whose reads are special-cased. This is the row #6725 would have failed.',
why: 'The contract\'s first two CRUD members, on the type whose reads are special-cased. This is the row that catches an object write landing in a store none of the object reads consult — the hole a shipped implementation once fell through.',
},
{
id: 'nonobject-roundtrip',
title: "register('view', n, d) is readable back through get('view', n)",
writes: [{ type: 'view', name: 'pin_grid', data: PIN_GRID }],
read: { type: 'view', name: 'pin_grid' },
expected: { kind: 'readable', document: PIN_GRID },
why: 'The generic type store — NOT special-cased on the read side of SchemaRegistry, which is the asymmetry that produced #6725. An implementation can pass one of these two rows and fail the other.',
why: 'The generic type store — NOT special-cased on the read side of SchemaRegistry, which is the asymmetry that let an object write land in the generic store while every object read looked elsewhere. An implementation can pass one of these two rows and fail the other.',
},
{
id: 'get-before-register-object',
Expand Down Expand Up @@ -277,7 +277,7 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [
writes: [{ type: 'object', name: 'pin_key', data: PIN_KEYED_OBJECT }],
read: { type: 'object', name: 'pin_key' },
expected: { kind: 'refused' },
why: 'Whether `name` or `data.name` is the key is the whole round-trip. **Ruled** (#7378, maintainer 2026-08-12, row 1, superseding the 2026-08-11 option (a) this row used to state): a disagreement is refused loudly — it is almost always an authoring bug, and silent resolution in EITHER direction can file the item under a key the author never wrote. A document with no `name` of its own still registers under the argument; the objectql driver pins that boundary.',
why: 'Whether `name` or `data.name` is the key is the whole round-trip. **Ruled** (maintainer 2026-08-12, row 1 of the round-trip ruling, superseding the 2026-08-11 option (a) this row used to state): a disagreement is refused loudly — it is almost always an authoring bug, and silent resolution in EITHER direction can file the item under a key the author never wrote. A document with no `name` of its own still registers under the argument; the objectql driver pins that boundary.',
},
{
id: 'data-name-mismatch-refused-nonobject',
Expand All @@ -293,15 +293,15 @@ export const METADATA_ROUNDTRIP_CASES: readonly MetadataRoundTripCase[] = [
writes: [{ type: 'objects', name: 'pin_plural', data: PIN_PLURAL }],
read: { type: 'object', name: 'pin_plural' },
expected: { kind: 'readable', document: PIN_PLURAL },
why: "The two spellings of the object type address ONE store. **Ruled** (#7378, maintainer 2026-08-12, row 2): every implementation gives one answer, converged with `check:meta-type-normalized`'s enforced direction — the type folds plural→singular (`PLURAL_TO_SINGULAR`, `../shared`) before any store decision. This row was `plural-objects-type-is-its-own-store` (expected `absent`) while the answer was still a measured divergence; the reverse read direction is pinned driver-locally in objectql.",
why: "The two spellings of the object type address ONE store. **Ruled** (maintainer 2026-08-12, row 2 of the round-trip ruling): every implementation gives one answer, converged with `check:meta-type-normalized`'s enforced direction — the type folds plural→singular (`PLURAL_TO_SINGULAR`, `../shared`) before any store decision. This row was `plural-objects-type-is-its-own-store` (expected `absent`) while the answer was still a measured divergence; the reverse read direction is pinned driver-locally in objectql.",
},
{
id: 'primitive-data-refused',
title: 'a non-object `data` value is REFUSED, never accepted-and-dropped',
writes: [{ type: 'setting', name: 'pin_flag', data: 'enabled' }],
read: { type: 'setting', name: 'pin_flag' },
expected: { kind: 'refused' },
why: '`data` is declared `unknown`, not `object`, so this is a runtime refusal (#7378, maintainer 2026-08-12, row 3): a value the service cannot key was measured as accept-then-drop — written, then readable back through NO member — which is indefensible; and coercing it into storability is equally forbidden. The ruling fixes 「接受再丢」, it does not demand 「必须存下」.',
why: '`data` is declared `unknown`, not `object`, so this is a runtime refusal (maintainer 2026-08-12, row 3 of the round-trip ruling): a value the service cannot key was measured as accept-then-drop — written, then readable back through NO member — which is indefensible; and coercing it into storability is equally forbidden. The ruling fixes 「接受再丢」, it does not demand 「必须存下」.',
},
{
id: 'array-data-refused',
Expand Down
33 changes: 21 additions & 12 deletions packages/spec/src/data/aggregation-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
field: 'stage',
expected: [{ group: null, value: 2 }],
note:
'#6409: `won` twice collapses to one and the two nulls contribute nothing — '
'count_distinct was kept and enforced on every face, as distinct non-null values: '
+ '`won` twice collapses to one and the two nulls contribute nothing — '
+ '4 means the dedup was dropped, 3 means NULL was counted as a value, '
+ '6 means the lowering fell through to count(*).',
},
Expand Down Expand Up @@ -395,17 +396,20 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
field: 'flag',
expected: [{ group: null, value: 3 }],
note:
'#11065/#11151: an arithmetic accumulator that drops booleans answers its '
+ 'identity 0 here — a plausible number, which is why this case exists.',
'A boolean aggregand is worth 1 or 0 on every face — driver-memory and then '
+ 'driver-mongodb were both moved onto that answer: an arithmetic accumulator '
+ 'that drops booleans answers its identity 0 here — a plausible number, which '
+ 'is why this case exists.',
},
{
name: 'avg(flag) is the true-rate',
function: 'avg',
field: 'flag',
expected: [{ group: null, value: 0.5 }],
note:
'#11065: the rate-over-a-flag-column shape (an SLA-violation rate, a win '
+ 'rate). A face that drops booleans answers null — a blank tile, '
'The rate-over-a-flag-column shape (an SLA-violation rate, a win rate) '
+ 'that driver-memory answered null for until it counted a boolean as 1 or 0. '
+ 'A face that drops booleans answers null — a blank tile, '
+ 'indistinguishable from "no matching rows".',
},
{
Expand All @@ -414,9 +418,10 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
field: 'flag',
expected: [{ group: null, value: 0 }],
note:
'#11152 ruling (2026-08-28): booleans aggregate as numbers with no '
'Ruled 2026-08-28: booleans aggregate as numbers with no '
+ 'per-aggregate exception, so the order statistics answer 0/1 in the '
+ 'same domain sum/avg answer in — not false/true (#11249, superseded).',
+ 'same domain sum/avg answer in — not the false/true an earlier ruling '
+ 'had chosen, which this one superseded.',
},
{
name: 'max(flag) answers the NUMBER 1',
Expand Down Expand Up @@ -532,7 +537,8 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
{ group: 'west', value: 40 },
],
note:
'#15546: `east` has two rows and nothing to add. A face that hands SQL\'s '
'Ruled: a non-empty group whose aggregand is NULL in every row sums to 0. '
+ '`east` has two rows and nothing to add. A face that hands SQL\'s '
+ 'NULL through answers null here (a blank tile, indistinguishable from '
+ '"not computed"), and `west` at 40 keeps a face that folded EVERY sum '
+ 'to 0 from passing.',
Expand All @@ -547,7 +553,7 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
{ group: 'west', value: 2 },
],
note:
'#15546: the reachability control for the case above. COUNT(col) is '
'The reachability control for the all-NULL-sums-to-0 case above. COUNT(col) is '
+ 'defined over non-null values on every backend, so `east` at 0 proves '
+ 'the seed stored NULLs — a harness that wrote 0 in place of a null '
+ 'answers 2 here and turns the sum cell green for the wrong reason.',
Expand All @@ -558,7 +564,7 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
field: 'amount',
expected: [{ group: null, value: 40 }],
note:
'#15546: the partial-null control. Four nulls among six rows contribute '
'The partial-null control for the same ruling. Four nulls among six rows contribute '
+ 'nothing and the two values add to 40 on every face — the case that was '
+ 'always green, which is why the all-null cell went unmeasured.',
},
Expand All @@ -574,7 +580,9 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
{ group: 'west', value: 4 },
],
note:
'#6401: the VALUES are the `count(*) grouped by region` case verbatim — only '
'A structured group node\'s `alias` names the projected group column on every '
+ 'face — the SQL faces that ignored it were made to honour it. '
+ 'The VALUES are the `count(*) grouped by region` case verbatim — only '
+ 'the key moves. A face that ignores `alias` returns the same two numbers '
+ 'under `region`, so this case can only fail on the KEY, which is the whole '
+ 'point: every wrong answer here is a valid query returning plausible rows. '
Expand All @@ -593,7 +601,8 @@ export const AGGREGATION_CASES: readonly AggregationCase[] = [
{ group: 'west', value: 100 },
],
note:
'#6401: the degenerate alias. Its twin above cannot see a face that emits '
'The degenerate alias, under the same every-face `alias` rule. Its twin above '
+ 'cannot see a face that emits '
+ '`"region" AS "region"` and breaks on the self-rename, and a face that '
+ 'special-cases `alias === field` needs the case that exercises the '
+ 'special case.',
Expand Down
14 changes: 7 additions & 7 deletions packages/spec/src/data/filter-comparand-type-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,14 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [
expected: ['2'],
},
{
name: 'number compiles and matches — the #7956 control cell',
name: 'number compiles and matches — the control cell of the cross-driver comparand-type measurement',
filter: () => ({ qty: { $eq: 100 } }),
verdict: 'matches',
expected: ['1'],
note: 'The matrix\'s control: this row returning on every driver is what made its zeros real answers.',
},
{
name: 'bigint compiles and matches — the crash cell, dead (#7872)',
name: 'bigint compiles and matches — the crash cell, dead at the shared comparand door',
filter: () => ({ qty: { $eq: BigInt(100) } }),
verdict: 'matches',
expected: ['1'],
Expand All @@ -175,7 +175,7 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [
filter: () => ({ qty: { $in: [BigInt(100), 999] } }),
verdict: 'matches',
expected: ['1'],
note: '$in/$nin members are comparands in their own right (#5234) — the door narrows each.',
note: '$in/$nin members are comparands in their own right, each judged like a scalar comparand — the door narrows each.',
},
{
name: 'boolean compiles and matches — implicit-equality form',
Expand All @@ -188,7 +188,7 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [
filter: () => ({ note: null }),
verdict: 'matches',
expected: ['1'],
note: 'null IS a comparand and IS the null predicate (#6050\'s untouched half) — the door must not confuse it with undefined.',
note: 'null IS a comparand and IS the null predicate — the half left untouched when an undefined comparand was ruled a loud refusal — so the door must not confuse it with undefined.',
},
{
name: 'Date compiles — row agreement is temporal-conformance\'s subject',
Expand Down Expand Up @@ -269,7 +269,7 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [
+ 'instead of deep-equality-on-two-drivers, refusal-on-three.',
},
{
name: 'an ARRAY in the implicit-equality slot is refused (#19757)',
name: 'an ARRAY in the implicit-equality slot is refused at the shared face',
filter: () => ({ label: ['alpha'] as unknown as string }),
verdict: 'door-refusal',
code: 'INVALID_FILTER',
Expand All @@ -279,14 +279,14 @@ export const FILTER_COMPARAND_TYPE_CASES: readonly ComparandTypeCase[] = [
+ 'it with MongoDB\'s array equality (a stored array equal to the list, or holding it as an element).',
},
{
name: 'an ARRAY under $eq is refused (#19757)',
name: 'an ARRAY under $eq is refused at the shared face',
filter: () => ({ label: { $eq: ['alpha'] as unknown as string } }),
verdict: 'door-refusal',
code: 'INVALID_FILTER',
mustMention: ['Operator "$eq"', 'at where.label.$eq.', '{"$in": […]}'],
},
{
name: 'an ARRAY in the equality slot is refused nested in $or too (#19757)',
name: 'an ARRAY in the equality slot is refused nested in $or too, at the shared face',
filter: () => ({ $or: [{ qty: 100 }, { label: ['alpha'] as unknown as string }] }),
verdict: 'door-refusal',
code: 'INVALID_FILTER',
Expand Down
Loading
Loading