Skip to content

Commit 31204d1

Browse files
spec(data): a shared NULL-distinct unique conformance case-set, scored by the driver census (#29808)
domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBY61hHdLdFdzwXbxaNtv58y)_ Fixes #29377 Supersedes #29509 (same commits, resumed on a new branch) Ruling-ref: 6595804527 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:`@objectstack/spec/data` 新增一套共享的一致性用例 `UNIQUE_NULL_DISTINCT_CASES`(10 条):唯一键任一列为空(`null` 或没写)的两条记录都能保存;`''` 和相同的非空值照旧冲突;单列 `unique` 与两列联合唯一索引各有覆盖。驱动一致性普查把它登记为新的一列,6 个驱动各记一条 DEBT 待接入。 - **为什么改**:MongoDB 驱动曾把第二条空值记录拒掉(#29278,已由 #29339 修好),期间普查全绿,因为没有任何用例问这个问题。有了这套用例,下一个驱动或回归再把空值合并成同一个键,就会在普查里被记为未覆盖、在驱动接入后直接变红。 - **风险与回滚**:只新增测试支撑导出,不改任何驱动、schema 或运行时行为。回滚 = revert 本 PR。新增 4 个公开名(见下文 New public names);普查账本多出 6 条 DEBT,已按裁决 6595804527(A,维护者「同意」)原样保留。 - **先看**:`packages/spec/src/data/unique-null-distinct-conformance.ts`(用例与实测出处);`scripts/check-driver-conformance.mjs` 新增的 6 条 DEBT;`unique-null-distinct-conformance.test.ts` 的六种错误读法。 - **两分钟验证**:`node scripts/check-driver-conformance.mjs`,预期矩阵多出 `UNIQUE_NULL_DISTINCT` 一列、6 格 `DEBT`,结尾 `OK — 66 covered cell(s), 6 in the DEBT ledger`。 - **需要你做的**:无,席位落地。 ## Resume note This PR carries #29509's five commits unchanged (head `0c0e86d0`, contract review PASS, record 6583589505 on #29509), resumed on this branch because a card thread can push only its own branch. On top: one merge of `origin/main` @ `25450217` (no conflict; main had not moved `scripts/check-driver-conformance.mjs` since the old merge base, so the census and the six DEBT rows are byte-identical to #29509), and one regeneration commit for `packages/spec/api-surface/data.json` and `packages/spec/export-origins/data.json` (both moved on main; `os-regen-merge.sh` took main's side and `check:generated --fix` re-added exactly this PR's four names). The case-set, its test, the changeset and the census are unchanged from `0c0e86d0`. ## What changes - **New case-set** `packages/spec/src/data/unique-null-distinct-conformance.ts`, beside `value-roundtrip-conformance.ts` and `readable-ids-conformance.ts`, in their shape. Ten cases over two fixture objects: `unique_null_single` (field-level `unique: 'global'` on `code`) and `unique_null_compound` (declared index `{ fields: ['region', 'tag'], unique: 'global' }`). | case | rows | second write | |---|---|---| | `single_null_null` | `code: null` twice | admitted | | `single_absent_absent` | `code` omitted twice | admitted | | `single_null_absent` | `null`, then omitted | admitted | | `single_equal_value` | the same string twice | refused | | `single_empty_string` | `''` twice | refused | | `compound_one_null` | `(r1, null)` twice | admitted | | `compound_leading_null` | `(null, t2)` twice | admitted | | `compound_one_absent` | `(r3, omitted)` twice | admitted | | `compound_equal` | `(r4, t4)` twice | refused | | `compound_differs_in_one_part` | `(r5, t5a)`, `(r5, t5b)` | admitted | The consumption recipe is in the module docblock: sync both objects, `create()` the first row (must be admitted), `create()` the second and record `refused` only when `isUniqueViolationError` (`@objectstack/types`) recognises the error, count the rows labelled with the case, and hand both to `uniqueNullDistinctDivergence`. The count is there because a driver can refuse a write and keep it. Every non-NULL key value belongs to one case, so a suite may run all ten on one synced pair of objects (pinned). - **Scope spelled `'global'` on both surfaces.** The scope of `unique` (ADR-0120 D1, ADR-0131 D1) is a different axis and stays out of this table; `'global'` is the one spelling that means the same key on every driver with or without a tenant column. A bare `true` on a declared index is refused since protocol 18, and the test parses both fixture objects through `ObjectSchema`. - **`packages/spec/src/data/index.ts`**: the `export *` line with its comment. - **Census registration** in `scripts/check-driver-conformance.mjs`: a `CASE_SETS` row (`file`, `marker: 'UNIQUE_NULL_DISTINCT_CASES'`, `what`), and one `LEDGER` row per driver (`kind: 'DEBT'`, `issue: '#29377'`), each with its measured state in `why`. A history note above `LEDGER` records why the column opens with six rows (the FILTER_TEXT shape: the contract lands ahead of its suites) and how a row clears (the driver's PR adds the suite and deletes its row; RECONCILED reds a row kept beside a suite). No driver package is touched. - Regenerated: `packages/spec/api-surface/data.json`, `packages/spec/export-origins/data.json` (four added names each). - Changeset `.changeset/29377-unique-null-distinct-conformance.md`: `@objectstack/spec` patch. ## New public names Under `@objectstack/spec` (the `data` entry), spelled like the sibling case-sets: ### `UNIQUE_NULL_DISTINCT_CASES` The cases constant and the census marker (the census discovers `export const ..._CASES`). ### `UNIQUE_NULL_DISTINCT_OBJECTS` The two fixture objects, as `syncSchema` takes them (cf. `VALUE_ROUNDTRIP_FIELDS`, `READABLE_IDS_PARENT_OBJECT`). ### `UniqueNullDistinctCase` The row type (cf. `ValueRoundTripCase`, `ReadableIdsCase`). ### `uniqueNullDistinctDivergence` The shared judge, `(case, second, stored) => string | null` (cf. `valueRoundTripDivergence`). A pure comparison over two observed values, no runtime logic. ## Why PR #29339 (#29278) made driver-mongodb admit a second `null` in a unique field. Its old sparse index indexed an explicit `null` like a value. driver-sql and driver-memory already admitted it, and the census stayed green through the whole divergence because no case-set asked the question. `value-roundtrip-conformance.ts` describes the same gap for value storage: no case-set, no evidence. ## Tests No existing test was edited. One new file, `packages/spec/src/data/unique-null-distinct-conformance.test.ts` (23 tests): - **The table, executed** against a reference store written from the declared rule (a NULL in any key part exempts the row; `''` is a value): each case alone, and all ten in order on one store. - **Not vacuous**: six wrong readings each fail the table. They are a sparse index (null indexed), null and absent folded onto one value, `''` treated as NULL (fails exactly `single_empty_string`), a compound keyed on its first column, a constraint never checked, and a refusal that keeps the row (both fail exactly the three refused rows). - **The judge**: `null` only on the expected answer and the expected count. - **Well-formed**: unique names, rows labelled, non-NULL key values owned by one case, both answers on both objects, both objects accepted by `ObjectSchema`. ## Acceptance notes - **The compound row was measured before it was written. The three drivers agree.** `driver-memory` and `driver-sql` (better-sqlite3) were run in-process over all ten cases on `origin/main` @ `f95d0dba`, judged by `uniqueNullDistinctDivergence` and `isUniqueViolationError`: 10 of 10 agree on each (readings below). `driver-mongodb` was **not run against a server here**: no mongod binary was reachable in this container (`fastdl.mongodb.org` is blocked, and fetching the conda-forge build was refused). Its standing readings are PR #29339's real-mongod suite (mongod 8.3.7), which covers six of the ten rows: `single_null_null`, `single_absent_absent`, `single_equal_value`, `single_empty_string`, `compound_one_null`, `compound_equal`. The other four follow from the index shape that suite's server-free half pins (a `partialFilterExpression` admitting only non-NULL types on every key part). Its server-free half ran here: `mongodb-unique-null-distinct.test.ts` 7 passed, 8 skipped (real-mongod, opt-in). The driver's adoption PR is the measurement for those four. - **The update door is out of the table.** Clearing a unique field to `null` by `update()` was measured on driver-mongodb in #29339 only, so it is named as absent in the docblock rather than added without a measurement. - **driver-odata** gets a DEBT row like the others, but its `syncSchema` is a no-op (the remote service owns constraints). Its adoption card decides whether that cell is a suite or an EXEMPT row. - The census's own CONSUMED message marks a ledger entry `⛔ MAINTAINER-ONLY`. These six rows are what the card asks for (a case-set landing ahead of its drivers), so the maintainer's review of this PR is that agreement. ## Verification <details><summary>Driver measurement, origin/main @ f95d0db (in-process, scratch script outside the tree; carried from #29509)</summary> ``` memory single_null_null second=admitted stored=2 agrees memory single_absent_absent second=admitted stored=2 agrees memory single_null_absent second=admitted stored=2 agrees memory single_equal_value second=refused stored=1 agrees UNIQUE_VIOLATION memory single_empty_string second=refused stored=1 agrees UNIQUE_VIOLATION memory compound_one_null second=admitted stored=2 agrees memory compound_leading_null second=admitted stored=2 agrees memory compound_one_absent second=admitted stored=2 agrees memory compound_equal second=refused stored=1 agrees UNIQUE_VIOLATION memory compound_differs_in_one_part second=admitted stored=2 agrees memory divergent cases: 0 sql (same ten rows, same answers; each refusal SQLITE_CONSTRAINT_UNIQUE) sql divergent cases: 0 ``` Every first write was admitted on both drivers. </details> <details><summary>Local runs on head 4ee136f (main merged at 2545021)</summary> Heavy runs in one `os-verify-lock.sh` script, VERDICT `command-exit 0` (held 22m32s on a shared box). | run | reading | |---|---| | `turbo run build --concurrency=2` (whole repo) | exit 0 · Tasks 118 successful, 118 total | | `pnpm --filter @objectstack/spec test` | exit 0 · Test Files 805 passed (805) · Tests 23903 passed, 1 todo | | `pnpm --filter @objectstack/spec test:repo` | exit 0 · Test Files 89 passed (89) · Tests 1522 passed | | `node scripts/check-driver-conformance.mjs` | exit 0 · `OK — 66 covered cell(s), 6 in the DEBT ledger, 0 exempt` | | `pnpm --filter @objectstack/spec check:generated` | exit 0 (after `--fix` re-added this PR's four names to api-surface/data.json and export-origins/data.json) | | `dispatch-gates --commands` (100 families, after the full build) | 100 exit 0; `--ran`: 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN | | `check-changeset-no-major.mjs --base origin/main --event` (this body) | exit 0 · `Clause-②: yes (widening)` · `Metadata-protocol: none` · level stays `patch` (a non-protocol change) | Before opening, `origin/main` had moved 13 commits to `0e4b0fa4`; `git merge-tree` against it reports no conflict, and none of the 13 touches the census, `packages/spec/src/data/index.ts` or the two generated files. No ablation: the diff changes no behaviour a test pinned before. The new test proves the table is not vacuous with six wrong readings instead (see Tests). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Test-support constants and a pure judge, as in `value-roundtrip-conformance.ts`.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (eslint over the four touched source files locally; the repo-wide run is CI's.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfXhPVhbNCfA7cqGdi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0afb1b2 commit 31204d1

7 files changed

Lines changed: 594 additions & 6 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
spec(data): a shared NULL-distinct `unique` conformance case-set, so every driver proves two NULLs do not collide
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`@objectstack/spec/data` now exports `UNIQUE_NULL_DISTINCT_CASES`, the case-set a driver suite runs to show that its `unique` constraint follows SQL's NULL rule: a row whose unique key holds a NULL (`null` or an absent value) in any key part is exempt, while `''` and equal non-null values still collide. It covers a field-level `unique` and a declared two-column unique index. `UNIQUE_NULL_DISTINCT_OBJECTS` is the two fixture objects, `UniqueNullDistinctCase` the row type, and `uniqueNullDistinctDivergence(case, second, stored)` the shared judge, which also checks how many rows the store kept.
11+
12+
The driver-conformance census (`pnpm check:driver-conformance`) scores the new column. No driver runs it yet: each driver adopts it in its own PR, and until then its cell is a tracked DEBT row. Nothing about how any driver stores, accepts or refuses a value changes.

‎packages/spec/api-surface/data.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,10 +812,13 @@
812812
"TursoTransportMode (type)",
813813
"TursoTransportModeSchema (const)",
814814
"UNBOUNDED_ABOVE (const)",
815+
"UNIQUE_NULL_DISTINCT_CASES (const)",
816+
"UNIQUE_NULL_DISTINCT_OBJECTS (const)",
815817
"UNRESOLVED_PLACEHOLDER_REFUSED (const)",
816818
"URL_CREDENTIAL_QUERY_PARAM_REFUSED (const)",
817819
"URL_EMBEDDED_CREDENTIAL_REFUSED (const)",
818820
"UnboundedAbove (type)",
821+
"UniqueNullDistinctCase (interface)",
819822
"UniqueScope (type)",
820823
"UniqueScopeSchema (const)",
821824
"UnknownAuthoringKeyFinding (interface)",
@@ -1027,6 +1030,7 @@
10271030
"textOperatorDoorVerdict (function)",
10281031
"undeclaredHookRefusalAtRun (function)",
10291032
"undeclaredHookRefusalCodeAtSave (function)",
1033+
"uniqueNullDistinctDivergence (function)",
10301034
"unknownImportMappingTargets (function)",
10311035
"unprovisionedInjectedColumns (function)",
10321036
"urlCredentialQueryParams (function)",

‎packages/spec/export-origins/data.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,10 +798,13 @@
798798
"TursoTransportMode": "src/data/driver/turso.zod.ts#TursoTransportMode (type)",
799799
"TursoTransportModeSchema": "src/data/driver/turso.zod.ts#TursoTransportModeSchema (const)",
800800
"UNBOUNDED_ABOVE": "src/data/calendar-day.ts#UNBOUNDED_ABOVE (const)",
801+
"UNIQUE_NULL_DISTINCT_CASES": "src/data/unique-null-distinct-conformance.ts#UNIQUE_NULL_DISTINCT_CASES (const)",
802+
"UNIQUE_NULL_DISTINCT_OBJECTS": "src/data/unique-null-distinct-conformance.ts#UNIQUE_NULL_DISTINCT_OBJECTS (const)",
801803
"UNRESOLVED_PLACEHOLDER_REFUSED": "src/data/driver/common.zod.ts#UNRESOLVED_PLACEHOLDER_REFUSED (const)",
802804
"URL_CREDENTIAL_QUERY_PARAM_REFUSED": "src/data/driver/common.zod.ts#URL_CREDENTIAL_QUERY_PARAM_REFUSED (const)",
803805
"URL_EMBEDDED_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#URL_EMBEDDED_CREDENTIAL_REFUSED (const)",
804806
"UnboundedAbove": "src/data/calendar-day.ts#UnboundedAbove (type)",
807+
"UniqueNullDistinctCase": "src/data/unique-null-distinct-conformance.ts#UniqueNullDistinctCase (interface)",
805808
"UniqueScope": "src/data/field.zod.ts#UniqueScope (type)",
806809
"UniqueScopeSchema": "src/data/field.zod.ts#UniqueScopeSchema (const)",
807810
"UnknownAuthoringKeyFinding": "src/data/authoring-key-lint.ts#UnknownAuthoringKeyFinding (interface)",
@@ -1013,6 +1016,7 @@
10131016
"textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)",
10141017
"undeclaredHookRefusalAtRun": "src/data/hook-refusal-codes.ts#undeclaredHookRefusalAtRun (function)",
10151018
"undeclaredHookRefusalCodeAtSave": "src/data/hook-refusal-codes.ts#undeclaredHookRefusalCodeAtSave (function)",
1019+
"uniqueNullDistinctDivergence": "src/data/unique-null-distinct-conformance.ts#uniqueNullDistinctDivergence (function)",
10161020
"unknownImportMappingTargets": "src/data/import-mapping-target.ts#unknownImportMappingTargets (function)",
10171021
"unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)",
10181022
"urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)",

‎packages/spec/src/data/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ export * from './aggregation-conformance';
149149
// values in them are, and its absence is why that family (commit 4045b954d, #11535,
150150
// #11782, #10995) kept arriving one card at a time.
151151
export * from './value-roundtrip-conformance';
152+
// Canonical conformance cases for NULL-distinct `unique` (#29377) — two rows
153+
// whose unique key holds a NULL (`null` or absent) in any key part both land,
154+
// while `''` and equal non-null values still collide. driver-mongodb's sparse
155+
// index refused the second `null` until #29339 while every census cell stayed
156+
// green: no case-set asked the question.
157+
export * from './unique-null-distinct-conformance';
152158
export * from './date-macros.zod';
153159
// Dashboard date-range preset names (#4614 single source, re-homed by #8793) —
154160
// declared for the dashboard date-filter positions, REFUSED as bare ordering
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29377] `UNIQUE_NULL_DISTINCT_CASES` lands ahead of every driver that runs
5+
* it — each driver's adoption is its own lane's PR — so between now and then
6+
* nothing executes the table, and a wrong `expected` would sit quietly until a
7+
* driver author trusted it. This file executes it, against a reference store
8+
* written from the declared rule: a row whose unique key has a NULL (`null` or
9+
* absent) in ANY key part is exempt; `''` is a value.
10+
*
11+
* The second job is proving the table is not VACUOUS: each way a driver can
12+
* get the rule wrong — the sparse index that indexes `null` (driver-mongodb
13+
* before #29339), folding `null` and absent onto one value, treating `''` as a
14+
* NULL, keying a compound on one column, enforcing nothing, refusing and
15+
* keeping the row — must fail at least one case.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import { ObjectSchema } from './object.zod';
20+
import {
21+
UNIQUE_NULL_DISTINCT_CASES,
22+
UNIQUE_NULL_DISTINCT_OBJECTS,
23+
uniqueNullDistinctDivergence,
24+
type UniqueNullDistinctCase,
25+
} from './unique-null-distinct-conformance';
26+
27+
// ── A reference store, from the declared rule ────────────────────────────────
28+
29+
type Row = Readonly<Record<string, unknown>>;
30+
31+
/** The ways a driver can get NULL-distinct `unique` wrong — `faithful` is the rule. */
32+
type Reading =
33+
| 'faithful'
34+
| 'null-is-a-value' // a sparse index: an explicit null is indexed, only a missing field is skipped
35+
| 'null-and-absent-fold' // every NULL is one shared value
36+
| 'empty-string-is-null' // '' exempts the row like a NULL
37+
| 'first-part-only' // a compound key built over its first column alone
38+
| 'not-enforced' // the constraint is declared and never checked
39+
| 'refuses-and-keeps'; // the duplicate is refused, and landed anyway
40+
41+
/** The key parts of each object's one unique constraint. */
42+
const KEY_PARTS: Readonly<Record<UniqueNullDistinctCase['object'], readonly string[]>> = {
43+
unique_null_single: ['code'],
44+
unique_null_compound: UNIQUE_NULL_DISTINCT_OBJECTS.unique_null_compound.indexes[0].fields,
45+
};
46+
47+
const ABSENT = Symbol('absent');
48+
49+
/** One key part as `reading` sees it: `null` when it exempts the row, else the value it keys on. */
50+
function part(row: Row, field: string, reading: Reading): unknown {
51+
if (!Object.prototype.hasOwnProperty.call(row, field) || row[field] === undefined) {
52+
return reading === 'null-and-absent-fold' ? ABSENT : null;
53+
}
54+
const value = row[field];
55+
if (value === null) return reading === 'null-is-a-value' || reading === 'null-and-absent-fold' ? ABSENT : null;
56+
if (value === '' && reading === 'empty-string-is-null') return null;
57+
return value;
58+
}
59+
60+
/** The row's key under `reading`, or `null` when the row is exempt from the constraint. */
61+
function keyOf(object: UniqueNullDistinctCase['object'], row: Row, reading: Reading): string | null {
62+
const fields = reading === 'first-part-only' ? KEY_PARTS[object].slice(0, 1) : KEY_PARTS[object];
63+
const parts = fields.map((f) => part(row, f, reading));
64+
if (parts.some((p) => p === null)) return null;
65+
return JSON.stringify(parts.map((p) => (p === ABSENT ? '<<NULL>>' : p)));
66+
}
67+
68+
class UniqueRefusal extends Error {
69+
// The shape `driver-memory` throws, which `isUniqueViolationError` recognises.
70+
readonly code = 'UNIQUE_VIOLATION';
71+
}
72+
73+
/** A store holding both objects, one unique constraint each, as `reading` enforces them. */
74+
class ReferenceStore {
75+
private readonly rows = new Map<string, Row[]>();
76+
77+
constructor(private readonly reading: Reading) {}
78+
79+
create(object: UniqueNullDistinctCase['object'], row: Row): void {
80+
const table = this.rows.get(object) ?? [];
81+
this.rows.set(object, table);
82+
const key = keyOf(object, row, this.reading);
83+
const collides = key !== null && this.reading !== 'not-enforced'
84+
&& table.some((other) => keyOf(object, other, this.reading) === key);
85+
if (collides && this.reading !== 'refuses-and-keeps') throw new UniqueRefusal('unique violation');
86+
table.push(row);
87+
if (collides) throw new UniqueRefusal('unique violation');
88+
}
89+
90+
countLabelled(object: UniqueNullDistinctCase['object'], label: string): number {
91+
return (this.rows.get(object) ?? []).filter((r) => r.label === label).length;
92+
}
93+
}
94+
95+
/**
96+
* Run the consumption recipe the case-set documents, on one store for every
97+
* case, in table order: first admitted, second admitted or refused, count by
98+
* label, judge. Returns the names of the cases that diverge.
99+
*/
100+
function divergent(reading: Reading, cases: readonly UniqueNullDistinctCase[] = UNIQUE_NULL_DISTINCT_CASES): string[] {
101+
const store = new ReferenceStore(reading);
102+
const out: string[] = [];
103+
for (const c of cases) {
104+
let firstAdmitted = true;
105+
try {
106+
store.create(c.object, c.first);
107+
} catch {
108+
firstAdmitted = false;
109+
}
110+
let second: 'admitted' | 'refused' = 'admitted';
111+
try {
112+
store.create(c.object, c.second);
113+
} catch (e) {
114+
// The recipe's judge: only a recognised unique refusal is a refusal.
115+
if (!(e instanceof UniqueRefusal)) throw e;
116+
second = 'refused';
117+
}
118+
const verdict = uniqueNullDistinctDivergence(c, second, store.countLabelled(c.object, c.name));
119+
if (!firstAdmitted || verdict !== null) out.push(c.name);
120+
}
121+
return out;
122+
}
123+
124+
// ── The table, executed ──────────────────────────────────────────────────────
125+
126+
describe('UNIQUE_NULL_DISTINCT_CASES — the table agrees with the declared rule', () => {
127+
for (const c of UNIQUE_NULL_DISTINCT_CASES) {
128+
it(c.name, () => {
129+
expect(divergent('faithful', [c])).toEqual([]);
130+
});
131+
}
132+
133+
it('every case on ONE store, in table order, still agrees: no case collides with another', () => {
134+
expect(divergent('faithful')).toEqual([]);
135+
});
136+
});
137+
138+
describe('UNIQUE_NULL_DISTINCT_CASES — each wrong reading of the rule fails the table', () => {
139+
it('a sparse index (null indexed like a value) fails the two-nulls rows', () => {
140+
expect(divergent('null-is-a-value')).toEqual(
141+
expect.arrayContaining(['single_null_null', 'compound_one_null', 'compound_leading_null']),
142+
);
143+
});
144+
145+
it('folding null and absent onto one value fails the absent rows too', () => {
146+
expect(divergent('null-and-absent-fold')).toEqual(
147+
expect.arrayContaining(['single_absent_absent', 'single_null_absent', 'compound_one_absent']),
148+
);
149+
});
150+
151+
it("treating '' as a NULL fails exactly the empty-string row", () => {
152+
expect(divergent('empty-string-is-null')).toEqual(['single_empty_string']);
153+
});
154+
155+
it('a compound key built over its first column alone fails', () => {
156+
expect(divergent('first-part-only')).toEqual(
157+
expect.arrayContaining(['compound_differs_in_one_part']),
158+
);
159+
});
160+
161+
it('a constraint that is never checked fails every refused row', () => {
162+
expect(divergent('not-enforced')).toEqual(
163+
UNIQUE_NULL_DISTINCT_CASES.filter((c) => c.expected === 'refused').map((c) => c.name),
164+
);
165+
});
166+
167+
it('a refusal that lands the row anyway fails every refused row, on the count', () => {
168+
expect(divergent('refuses-and-keeps')).toEqual(
169+
UNIQUE_NULL_DISTINCT_CASES.filter((c) => c.expected === 'refused').map((c) => c.name),
170+
);
171+
});
172+
});
173+
174+
describe('uniqueNullDistinctDivergence — the shared judge', () => {
175+
const admitted = UNIQUE_NULL_DISTINCT_CASES.find((c) => c.expected === 'admitted') as UniqueNullDistinctCase;
176+
const refused = UNIQUE_NULL_DISTINCT_CASES.find((c) => c.expected === 'refused') as UniqueNullDistinctCase;
177+
178+
it('answers null only on the expected answer AND the expected count', () => {
179+
expect(uniqueNullDistinctDivergence(admitted, 'admitted', 2)).toBeNull();
180+
expect(uniqueNullDistinctDivergence(refused, 'refused', 1)).toBeNull();
181+
});
182+
183+
it('names the case, both rows and what was observed when it diverges', () => {
184+
const d = uniqueNullDistinctDivergence(admitted, 'refused', 1);
185+
expect(d).toContain(admitted.name);
186+
expect(d).toContain(JSON.stringify(admitted.second));
187+
expect(uniqueNullDistinctDivergence(admitted, 'admitted', 1)).not.toBeNull();
188+
expect(uniqueNullDistinctDivergence(refused, 'refused', 2)).not.toBeNull();
189+
expect(uniqueNullDistinctDivergence(refused, 'admitted', 2)).not.toBeNull();
190+
});
191+
});
192+
193+
describe('UNIQUE_NULL_DISTINCT_CASES — the table is well-formed', () => {
194+
it('names are unique and each row is labelled with its case', () => {
195+
const names = UNIQUE_NULL_DISTINCT_CASES.map((c) => c.name);
196+
expect(new Set(names).size).toBe(names.length);
197+
for (const c of UNIQUE_NULL_DISTINCT_CASES) {
198+
expect(c.first.label, c.name).toBe(c.name);
199+
expect(c.second.label, c.name).toBe(c.name);
200+
}
201+
});
202+
203+
it('every non-NULL key value belongs to one case, so a suite may share one table', () => {
204+
const owner = new Map<string, string>();
205+
for (const c of UNIQUE_NULL_DISTINCT_CASES) {
206+
for (const row of [c.first, c.second]) {
207+
for (const f of KEY_PARTS[c.object]) {
208+
const v = row[f];
209+
if (v === null || v === undefined) continue;
210+
const k = `${c.object}.${f}=${JSON.stringify(v)}`;
211+
expect(owner.get(k) ?? c.name, k).toBe(c.name);
212+
owner.set(k, c.name);
213+
}
214+
}
215+
}
216+
});
217+
218+
it('both answers are represented on both objects', () => {
219+
for (const object of Object.keys(UNIQUE_NULL_DISTINCT_OBJECTS)) {
220+
const answers = new Set(UNIQUE_NULL_DISTINCT_CASES.filter((c) => c.object === object).map((c) => c.expected));
221+
expect([...answers].sort(), object).toEqual(['admitted', 'refused']);
222+
}
223+
});
224+
225+
it('the fixture objects are authorable: ObjectSchema accepts each, scope stated', () => {
226+
for (const o of Object.values(UNIQUE_NULL_DISTINCT_OBJECTS)) {
227+
const parsed = ObjectSchema.safeParse(o);
228+
expect(parsed.success, `${o.name}: ${JSON.stringify(parsed.error?.issues)}`).toBe(true);
229+
}
230+
});
231+
});

0 commit comments

Comments
 (0)