Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.

Commit eb10840

Browse files
sorenbsclaude
andcommitted
Preserve default literals via the SQL family preserve-empty hook
The lint:framework-vocabulary ratchet (new on main) rightly rejects the previous shape: a full storage path (entries.table.*.columns) in the family-blind framework canonicalizer is SQL vocabulary in the wrong layer. The default-omission walk already consults the family shouldPreserveEmpty hook for exactly this kind of veto, so the default.value pattern moves into sqlContractCanonicalizationHooks preserveEmptyPatterns and the framework core reverts to main byte-for- byte. Behavior and hashes are unchanged: the same paths are preserved, now family-owned; the framework tests exercise the mechanism through the mirrored SQL patterns and a new family-level test pins the hook. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Søren Bramer Schmidt <sorenbs@gmail.com>
1 parent d6813e1 commit eb10840

4 files changed

Lines changed: 48 additions & 23 deletions

File tree

packages/1-framework/0-foundation/contract/src/canonicalization.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,6 @@ const STORAGE_NAMESPACE_ENTRIES_PATTERN = [
6262
'*',
6363
'entries',
6464
] as const satisfies PathPattern;
65-
const STORAGE_COLUMN_DEFAULT_VALUE_PATTERN = [
66-
'storage',
67-
'namespaces',
68-
'*',
69-
'entries',
70-
'table',
71-
'*',
72-
'columns',
73-
'*',
74-
'default',
75-
'value',
76-
] as const satisfies PathPattern;
7765

7866
const TOP_LEVEL_ORDER = [
7967
'schemaVersion',
@@ -159,14 +147,6 @@ function omitDefaults(
159147

160148
const isNullableField = key === 'nullable';
161149

162-
// A column default's literal payload is data, not schema shape —
163-
// `{ kind: 'literal', value: false }` (or `value: []`) must survive
164-
// canonicalization or the emitted contract fails validation on read.
165-
const isDefaultLiteralValue = matchesPathPattern(
166-
currentPath,
167-
STORAGE_COLUMN_DEFAULT_VALUE_PATTERN,
168-
);
169-
170150
const isFamilyPreserved = shouldPreserveEmpty?.(currentPath) ?? false;
171151

172152
if (
@@ -184,7 +164,6 @@ function omitDefaults(
184164
!isModelRelations &&
185165
!isModelStorage &&
186166
!isNullableField &&
187-
!isDefaultLiteralValue &&
188167
!isFamilyPreserved
189168
) {
190169
continue;

packages/1-framework/0-foundation/contract/test/canonicalization.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ const sqlPreserveEmptyPatterns = [
4646
['storage', 'namespaces', '*', 'entries', 'table', '*'],
4747
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
4848
['storage', 'namespaces', '*', 'entries', 'table', '*', 'foreignKeys', ['constraint', 'index']],
49+
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
4950
] as const satisfies readonly PathPattern[];
5051

5152
const sqlSortTargets = [
@@ -333,7 +334,7 @@ describe('default omission', () => {
333334
expect(idField).not.toHaveProperty('generated');
334335
});
335336

336-
it('preserves a literal false column default value', () => {
337+
it('preserves a literal false column default value via the family hook', () => {
337338
const result = canonicalizeContractToObject(
338339
minimal({
339340
storage: unboundStorage({
@@ -349,12 +350,13 @@ describe('default omission', () => {
349350
},
350351
}),
351352
}),
353+
{ shouldPreserveEmpty: sqlPreserveEmpty },
352354
);
353355
const done = drill(unboundTables(result), 'task', 'columns', 'done');
354356
expect(done['default']).toEqual({ kind: 'literal', value: false });
355357
});
356358

357-
it('preserves a literal empty-array column default value', () => {
359+
it('preserves a literal empty-array column default value via the family hook', () => {
358360
const result = canonicalizeContractToObject(
359361
minimal({
360362
storage: unboundStorage({
@@ -370,6 +372,7 @@ describe('default omission', () => {
370372
},
371373
}),
372374
}),
375+
{ shouldPreserveEmpty: sqlPreserveEmpty },
373376
);
374377
const labels = drill(unboundTables(result), 'task', 'columns', 'labels');
375378
expect(labels['default']).toEqual({ kind: 'literal', value: [] });

packages/2-sql/1-core/contract/src/canonicalization-hooks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const preserveEmptyPatterns = [
1111
['storage', 'namespaces', '*', 'entries', 'table', '*'],
1212
['storage', 'namespaces', '*', 'entries', 'table', '*', ['uniques', 'indexes', 'foreignKeys']],
1313
['storage', 'namespaces', '*', 'entries', 'table', '*', 'foreignKeys', ['constraint', 'index']],
14+
// A column default's literal payload is data, not shape — `{ kind:
15+
// 'literal', value: false }` (or `value: []`) must survive the
16+
// default-omission walk or the emitted contract fails its own
17+
// validation on the next read (PN-CLI-4003 on `Boolean @default(false)`).
18+
['storage', 'namespaces', '*', 'entries', 'table', '*', 'columns', '*', 'default', 'value'],
1419
] as const satisfies readonly PathPattern[];
1520

1621
const sortTargets = [
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { sqlContractCanonicalizationHooks } from '../src/canonicalization-hooks';
3+
4+
describe('sqlContractCanonicalizationHooks.shouldPreserveEmpty', () => {
5+
it('preserves a column default literal payload (false / empty-array defaults)', () => {
6+
// `{ kind: 'literal', value: false }` reaches the default-omission walk
7+
// as a default value; without this veto the emitted contract fails its
8+
// own validation on the next read (PN-CLI-4003 on Boolean @default(false)).
9+
expect(
10+
sqlContractCanonicalizationHooks.shouldPreserveEmpty([
11+
'storage',
12+
'namespaces',
13+
'unbound',
14+
'entries',
15+
'table',
16+
'task',
17+
'columns',
18+
'done',
19+
'default',
20+
'value',
21+
]),
22+
).toBe(true);
23+
});
24+
25+
it('does not preserve arbitrary domain-side values', () => {
26+
expect(
27+
sqlContractCanonicalizationHooks.shouldPreserveEmpty([
28+
'domain',
29+
'namespaces',
30+
'unbound',
31+
'models',
32+
'Task',
33+
'fields',
34+
'done',
35+
]),
36+
).toBe(false);
37+
});
38+
});

0 commit comments

Comments
 (0)