@prisma-next/sql-contract 0.11.0-dev.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ import type { PreserveEmptyPredicate, StorageSort } from '@prisma-next/contract/hashing';
2
+ import {
3
+ createPreserveEmptyPredicate,
4
+ createStorageSort,
5
+ type NamedArraySortTarget,
6
+ type PathPattern,
7
+ } from '@prisma-next/contract/hashing-utils';
8
+
9
+ const preserveEmptyPatterns = [
10
+ ['storage', 'namespaces', '*', 'tables'],
11
+ ['storage', 'namespaces', '*', 'tables', '*'],
12
+ ['storage', 'namespaces', '*', 'tables', '*', ['uniques', 'indexes', 'foreignKeys']],
13
+ ['storage', 'namespaces', '*', 'tables', '*', 'foreignKeys', ['constraint', 'index']],
14
+ ['storage', 'types', '*', 'typeParams'],
15
+ ] as const satisfies readonly PathPattern[];
16
+
17
+ const sortTargets = [
18
+ { path: ['namespaces', '*', 'tables', '*'], arrayKeys: ['indexes', 'uniques'] },
19
+ ] as const satisfies readonly NamedArraySortTarget[];
20
+
21
+ const shouldPreserveEmpty: PreserveEmptyPredicate =
22
+ createPreserveEmptyPredicate(preserveEmptyPatterns);
23
+
24
+ const sortStorage: StorageSort = createStorageSort(sortTargets);
25
+
26
+ export const sqlContractCanonicalizationHooks: {
27
+ readonly shouldPreserveEmpty: PreserveEmptyPredicate;
28
+ readonly sortStorage: StorageSort;
29
+ } = {
30
+ shouldPreserveEmpty,
31
+ sortStorage,
32
+ };
@@ -0,0 +1 @@
1
+ export { sqlContractCanonicalizationHooks } from '../canonicalization-hooks';
@@ -37,6 +37,8 @@ export type {
37
37
  } from '../types';
38
38
  export {
39
39
  applyFkDefaults,
40
+ buildSqlNamespace,
41
+ buildSqlNamespaceMap,
40
42
  CODEC_INSTANCE_KIND,
41
43
  DEFAULT_FK_CONSTRAINT,
42
44
  DEFAULT_FK_INDEX,
package/src/factories.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ScalarFieldType } from '@prisma-next/contract/types';
1
+ import { asNamespaceId, type ScalarFieldType } from '@prisma-next/contract/types';
2
2
  import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
3
3
  import {
4
4
  applyFkDefaults,
@@ -38,7 +38,7 @@ export function fk(
38
38
  opts?: ForeignKeyOptions & { constraint?: boolean; index?: boolean; namespaceId?: string },
39
39
  ): ForeignKey {
40
40
  const defaults = applyFkDefaults({ constraint: opts?.constraint, index: opts?.index });
41
- const namespaceId = opts?.namespaceId ?? UNBOUND_NAMESPACE_ID;
41
+ const namespaceId = asNamespaceId(opts?.namespaceId ?? UNBOUND_NAMESPACE_ID);
42
42
  return new ForeignKey({
43
43
  source: { namespaceId, tableName: srcTableName, columns: srcColumns },
44
44
  target: { namespaceId, tableName: targetTableName, columns: targetColumns },
@@ -0,0 +1,89 @@
1
+ import {
2
+ freezeNode,
3
+ type Namespace,
4
+ NamespaceBase,
5
+ UNBOUND_NAMESPACE_ID,
6
+ } from '@prisma-next/framework-components/ir';
7
+ import { blindCast, castAs } from '@prisma-next/utils/casts';
8
+ import type { PostgresEnumStorageEntry } from './postgres-enum-storage-entry';
9
+ import type { SqlNamespace, SqlNamespaceTablesInput } from './sql-storage';
10
+ import { SqlUnboundNamespace } from './sql-unbound-namespace';
11
+ import { StorageTable } from './storage-table';
12
+
13
+ const SQL_NAMESPACE_KIND = 'sql-namespace' as const;
14
+
15
+ function isMaterializedSqlNamespace(ns: Namespace | SqlNamespaceTablesInput): ns is SqlNamespace {
16
+ if (typeof ns !== 'object' || ns === null) {
17
+ return false;
18
+ }
19
+ const proto = Object.getPrototypeOf(ns);
20
+ if (proto === Object.prototype || proto === null) {
21
+ return false;
22
+ }
23
+ return (ns as Namespace).kind === SQL_NAMESPACE_KIND;
24
+ }
25
+
26
+ class SqlBoundNamespace extends NamespaceBase {
27
+ declare readonly kind: string;
28
+ declare readonly enum?: Readonly<Record<string, PostgresEnumStorageEntry>>;
29
+
30
+ readonly id: string;
31
+ readonly tables: Readonly<Record<string, StorageTable>>;
32
+
33
+ static fromTablesInput(input: SqlNamespaceTablesInput): SqlNamespace {
34
+ const tableCount = Object.keys(input.tables ?? {}).length;
35
+ const enumCount = Object.keys(input.enum ?? {}).length;
36
+ if (input.id === UNBOUND_NAMESPACE_ID && tableCount === 0 && enumCount === 0) {
37
+ return castAs<SqlNamespace>(SqlUnboundNamespace.instance);
38
+ }
39
+ return castAs<SqlNamespace>(new SqlBoundNamespace(input));
40
+ }
41
+
42
+ private constructor(input: SqlNamespaceTablesInput) {
43
+ super();
44
+ this.id = input.id;
45
+ this.tables = Object.freeze(
46
+ Object.fromEntries(
47
+ Object.entries(input.tables ?? {}).map(([name, t]) => [
48
+ name,
49
+ t instanceof StorageTable ? t : new StorageTable(t),
50
+ ]),
51
+ ),
52
+ );
53
+ if (input.enum !== undefined && Object.keys(input.enum).length > 0) {
54
+ Object.defineProperty(this, 'enum', {
55
+ value: Object.freeze({ ...input.enum }),
56
+ writable: false,
57
+ enumerable: true,
58
+ configurable: false,
59
+ });
60
+ }
61
+ Object.defineProperty(this, 'kind', {
62
+ value: SQL_NAMESPACE_KIND,
63
+ writable: false,
64
+ enumerable: false,
65
+ configurable: true,
66
+ });
67
+ freezeNode(this);
68
+ }
69
+ }
70
+
71
+ export function buildSqlNamespace(input: SqlNamespaceTablesInput): SqlNamespace {
72
+ return SqlBoundNamespace.fromTablesInput(input);
73
+ }
74
+
75
+ export function buildSqlNamespaceMap(
76
+ namespaces: Readonly<Record<string, Namespace | SqlNamespaceTablesInput>>,
77
+ ): Readonly<Record<string, SqlNamespace>> {
78
+ return Object.fromEntries(
79
+ Object.entries(namespaces).map(([nsKey, ns]) => [
80
+ nsKey,
81
+ isMaterializedSqlNamespace(ns)
82
+ ? blindCast<
83
+ SqlNamespace,
84
+ 'a materialised SQL-family namespace entry in a namespace map is a SqlNamespace'
85
+ >(ns)
86
+ : SqlBoundNamespace.fromTablesInput(ns),
87
+ ]),
88
+ );
89
+ }
@@ -1,3 +1,4 @@
1
+ import { asNamespaceId, type NamespaceId } from '@prisma-next/contract/types';
1
2
  import { freezeNode } from '@prisma-next/framework-components/ir';
2
3
  import { SqlNode } from './sql-node';
3
4
 
@@ -15,13 +16,13 @@ export interface ForeignKeyReferenceInput {
15
16
  * as the sentinel `namespaceId` for single-namespace (unbound) references.
16
17
  */
17
18
  export class ForeignKeyReference extends SqlNode {
18
- readonly namespaceId: string;
19
+ readonly namespaceId: NamespaceId;
19
20
  readonly tableName: string;
20
21
  readonly columns: readonly string[];
21
22
 
22
23
  constructor(input: ForeignKeyReferenceInput) {
23
24
  super();
24
- this.namespaceId = input.namespaceId;
25
+ this.namespaceId = asNamespaceId(input.namespaceId);
25
26
  this.tableName = input.tableName;
26
27
  this.columns = input.columns;
27
28
  freezeNode(this);
@@ -1,18 +1,11 @@
1
1
  import type { StorageHashBase } from '@prisma-next/contract/types';
2
- import {
3
- freezeNode,
4
- type Namespace,
5
- NamespaceBase,
6
- type Storage,
7
- UNBOUND_NAMESPACE_ID,
8
- } from '@prisma-next/framework-components/ir';
2
+ import { freezeNode, type Namespace, type Storage } from '@prisma-next/framework-components/ir';
9
3
  import {
10
4
  isPostgresEnumStorageEntry,
11
5
  type PostgresEnumStorageEntry,
12
6
  } from './postgres-enum-storage-entry';
13
7
  import { SqlNode } from './sql-node';
14
- import { SqlUnboundNamespace } from './sql-unbound-namespace';
15
- import { StorageTable, type StorageTableInput } from './storage-table';
8
+ import type { StorageTable, StorageTableInput } from './storage-table';
16
9
  import {
17
10
  isStorageTypeInstance,
18
11
  type StorageTypeInstance,
@@ -30,10 +23,6 @@ export type SqlStorageTypeEntry =
30
23
  | StorageTypeInstanceInput
31
24
  | PostgresEnumStorageEntry;
32
25
 
33
- const DEFAULT_NAMESPACES: Readonly<Record<string, Namespace>> = Object.freeze({
34
- [UNBOUND_NAMESPACE_ID]: SqlUnboundNamespace.instance,
35
- });
36
-
37
26
  export interface SqlNamespaceTablesInput {
38
27
  readonly id: string;
39
28
  readonly tables?: Record<string, StorageTable | StorageTableInput>;
@@ -43,59 +32,7 @@ export interface SqlNamespaceTablesInput {
43
32
  export interface SqlStorageInput<THash extends string = string> {
44
33
  readonly storageHash: StorageHashBase<THash>;
45
34
  readonly types?: Record<string, SqlStorageTypeEntry>;
46
- readonly namespaces?: Readonly<Record<string, Namespace | SqlNamespaceTablesInput>>;
47
- }
48
-
49
- class SqlNamespacePayload extends NamespaceBase {
50
- declare readonly kind: string;
51
- declare readonly enum?: Readonly<Record<string, PostgresEnumStorageEntry>>;
52
-
53
- readonly id: string;
54
- readonly tables: Readonly<Record<string, StorageTable>>;
55
-
56
- constructor(input: SqlNamespaceTablesInput) {
57
- super();
58
- this.id = input.id;
59
- this.tables = Object.freeze(
60
- Object.fromEntries(
61
- Object.entries(input.tables ?? {}).map(([name, t]) => [
62
- name,
63
- t instanceof StorageTable ? t : new StorageTable(t),
64
- ]),
65
- ),
66
- );
67
- if (input.enum !== undefined && Object.keys(input.enum).length > 0) {
68
- Object.defineProperty(this, 'enum', {
69
- value: Object.freeze({ ...input.enum }),
70
- writable: false,
71
- enumerable: true,
72
- configurable: false,
73
- });
74
- }
75
- Object.defineProperty(this, 'kind', {
76
- value: 'sql-namespace',
77
- writable: false,
78
- enumerable: false,
79
- configurable: true,
80
- });
81
- freezeNode(this);
82
- }
83
- }
84
-
85
- function normaliseNamespaceEntry(
86
- nsKey: string,
87
- ns: Namespace | SqlNamespaceTablesInput,
88
- ): Namespace {
89
- if (ns instanceof NamespaceBase) {
90
- return ns;
91
- }
92
- const input = ns as SqlNamespaceTablesInput; // JSON namespace payloads match SqlNamespaceTablesInput before SqlNamespacePayload materialises StorageTable instances.
93
- const tableCount = Object.keys(input.tables ?? {}).length;
94
- const typeCount = Object.keys(input.enum ?? {}).length;
95
- if (nsKey === UNBOUND_NAMESPACE_ID && tableCount === 0 && typeCount === 0) {
96
- return SqlUnboundNamespace.instance;
97
- }
98
- return new SqlNamespacePayload(input);
35
+ readonly namespaces: Readonly<Record<string, SqlNamespace>>;
99
36
  }
100
37
 
101
38
  /**
@@ -108,16 +45,11 @@ function normaliseNamespaceEntry(
108
45
  * target-specific storage extensions).
109
46
  *
110
47
  * Honours the framework `Storage` interface: every SQL IR carries a
111
- * `namespaces` map keyed by namespace id. The default singleton
112
- * (`{ [UNBOUND_NAMESPACE_ID]: SqlUnboundNamespace.instance }`)
113
- * binds every contract authored before per-target namespace concretions
114
- * land; per-target namespace classes (`PostgresSchema.unbound`,
115
- * `SqliteUnboundDatabase.instance`) earn their slots when each
116
- * target's namespace shape lands.
48
+ * `namespaces` map keyed by namespace id. Callers must supply fully
49
+ * constructed `Namespace` instances construction discipline lives
50
+ * in the authoring builders and deserializer hydration paths.
117
51
  *
118
- * The constructor normalises optional `types` into class instances and
119
- * materialises plain namespace envelope objects into `Namespace` class
120
- * instances so downstream walks see a uniform AST.
52
+ * The constructor normalises optional `types` into class instances.
121
53
  * `types` is polymorphic per Decision 18 Option B: codec-triple inputs
122
54
  * are stamped with `kind: 'codec-instance'`; class-instance kinds
123
55
  * (e.g. Postgres-enum entries satisfying `PostgresEnumStorageEntry`)
@@ -129,7 +61,7 @@ function normaliseNamespaceEntry(
129
61
  // SQL concretions always store `StorageTable`-shaped values in `tables`.
130
62
  // `tables` is a SQL-family idiom — the framework `Namespace` contract no
131
63
  // longer mandates this field; Mongo namespaces carry `collections`
132
- // instead. The `__unbound__` slot uses the same narrowing as every other
64
+ // instead. The `tables` slot uses the same narrowing as every other
133
65
  // SQL namespace; the wider `Record<string, object>` on `StorageTable` is
134
66
  // only there so emitted `contract.d.ts` table literals (which lack the
135
67
  // runtime `kind` discriminator on `StorageTable`) structurally satisfy
@@ -141,27 +73,13 @@ export type SqlNamespace = Namespace & {
141
73
 
142
74
  export class SqlStorage<THash extends string = string> extends SqlNode implements Storage {
143
75
  readonly storageHash: StorageHashBase<THash>;
144
- readonly namespaces: Readonly<Record<string, SqlNamespace>> & {
145
- readonly __unbound__: SqlNamespace;
146
- };
76
+ readonly namespaces: Readonly<Record<string, SqlNamespace>>;
147
77
  declare readonly types?: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
148
78
 
149
79
  constructor(input: SqlStorageInput<THash>) {
150
80
  super();
151
81
  this.storageHash = input.storageHash;
152
- const inputNamespaces = input.namespaces ?? DEFAULT_NAMESPACES;
153
- const normalised: Record<string, SqlNamespace> = Object.fromEntries(
154
- Object.entries(inputNamespaces).map(([nsKey, ns]) => [
155
- nsKey,
156
- normaliseNamespaceEntry(nsKey, ns) as SqlNamespace,
157
- ]),
158
- );
159
- if (!normalised[UNBOUND_NAMESPACE_ID]) {
160
- normalised[UNBOUND_NAMESPACE_ID] = SqlUnboundNamespace.instance as SqlNamespace;
161
- }
162
- this.namespaces = Object.freeze(normalised) as Readonly<Record<string, SqlNamespace>> & {
163
- readonly __unbound__: SqlNamespace;
164
- };
82
+ this.namespaces = Object.freeze(input.namespaces);
165
83
  if (input.types !== undefined) {
166
84
  this.types = Object.freeze(
167
85
  Object.fromEntries(
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { CodecTrait } from '@prisma-next/framework-components/codec';
2
2
  import type { ReferentialAction } from './ir/foreign-key';
3
3
 
4
+ export { buildSqlNamespace, buildSqlNamespaceMap } from './ir/build-sql-namespace';
4
5
  export {
5
6
  ForeignKey,
6
7
  type ForeignKeyInput,
package/src/validators.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { ContractValidationError } from '@prisma-next/contract/contract-validation-error';
2
- import type { Contract, ContractField, ContractModel } from '@prisma-next/contract/types';
2
+ import {
3
+ type Contract,
4
+ type ContractField,
5
+ type ContractModel,
6
+ CrossReferenceSchema,
7
+ } from '@prisma-next/contract/types';
3
8
  import { validateContractDomain } from '@prisma-next/contract/validate-domain';
4
- import type { Namespace } from '@prisma-next/framework-components/ir';
9
+ import { type Namespace, UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
10
+ import { blindCast } from '@prisma-next/utils/casts';
11
+ import { ifDefined } from '@prisma-next/utils/defined';
5
12
  import { type Type, type } from 'arktype';
13
+ import { buildSqlNamespaceMap } from './ir/build-sql-namespace';
14
+ import { SqlUnboundNamespace } from './ir/sql-unbound-namespace';
6
15
  import {
7
16
  type ForeignKeyInput,
8
17
  type ForeignKeyReferenceInput,
@@ -129,11 +138,12 @@ export const IndexSchema = type({
129
138
  'options?': 'Record<string, unknown>',
130
139
  });
131
140
 
132
- export const ForeignKeyReferenceSchema = type.declare<ForeignKeyReferenceInput>().type({
141
+ export const ForeignKeyReferenceSchema = type({
142
+ '+': 'reject',
133
143
  namespaceId: 'string',
134
144
  tableName: 'string',
135
145
  columns: type.string.array().readonly(),
136
- });
146
+ }) satisfies Type<ForeignKeyReferenceInput>;
137
147
 
138
148
  export const ReferentialActionSchema = type
139
149
  .declare<ReferentialAction>()
@@ -256,6 +266,11 @@ export function createSqlStorageSchema(
256
266
  '+': 'reject',
257
267
  storageHash: 'string',
258
268
  'types?': type({ '[string]': DocumentScopedStorageTypeSchema }),
269
+ // `__unbound__` is NOT required here: cross-namespace contracts can
270
+ // declare only named namespaces (see cross-namespace FK fixtures). The
271
+ // `__unbound__` brand on `SqlStorageInput['namespaces']` is kept sound at
272
+ // construction time by injecting the unbound singleton when absent
273
+ // (see `validateStorage` / `hydrateSqlStorage`), not by structural require.
259
274
  'namespaces?': type({ '[string]': namespaceEntry }),
260
275
  }) as Type<unknown>;
261
276
  }
@@ -351,13 +366,32 @@ const ModelStorageSchema = type({
351
366
  fields: type({ '[string]': ModelStorageFieldSchema }),
352
367
  });
353
368
 
369
+ const ContractReferenceRelationSchema = type({
370
+ '+': 'reject',
371
+ to: CrossReferenceSchema,
372
+ cardinality: "'1:1' | '1:N' | 'N:1'",
373
+ on: type({
374
+ '+': 'reject',
375
+ localFields: type.string.array().readonly(),
376
+ targetFields: type.string.array().readonly(),
377
+ }),
378
+ });
379
+
380
+ const ContractEmbedRelationSchema = type({
381
+ '+': 'reject',
382
+ to: CrossReferenceSchema,
383
+ cardinality: "'1:1' | '1:N'",
384
+ });
385
+
386
+ const ContractRelationSchema = ContractReferenceRelationSchema.or(ContractEmbedRelationSchema);
387
+
354
388
  const ModelSchema = type({
355
389
  storage: ModelStorageSchema,
356
390
  'fields?': type({ '[string]': ModelFieldSchema }),
357
- 'relations?': type({ '[string]': 'unknown' }),
391
+ 'relations?': type({ '[string]': ContractRelationSchema }),
358
392
  'discriminator?': 'unknown',
359
393
  'variants?': 'unknown',
360
- 'base?': 'string',
394
+ 'base?': CrossReferenceSchema,
361
395
  'owner?': 'string',
362
396
  });
363
397
 
@@ -383,10 +417,15 @@ export function createSqlContractSchema(
383
417
  'capabilities?': 'Record<string, Record<string, boolean>>',
384
418
  'extensionPacks?': 'Record<string, unknown>',
385
419
  'meta?': ContractMetaSchema,
386
- 'roots?': 'Record<string, string>',
387
- models: type({ '[string]': ModelSchema }),
388
- 'valueObjects?': 'Record<string, unknown>',
389
- 'domain?': 'unknown',
420
+ 'roots?': type({ '[string]': CrossReferenceSchema }),
421
+ domain: type({
422
+ namespaces: type({
423
+ '[string]': type({
424
+ models: type({ '[string]': ModelSchema }),
425
+ 'valueObjects?': 'Record<string, unknown>',
426
+ }),
427
+ }),
428
+ }),
390
429
  storage,
391
430
  'execution?': ExecutionSchema,
392
431
  }) as Type<unknown>;
@@ -413,11 +452,26 @@ export function validateStorage(value: unknown): SqlStorage {
413
452
  const messages = result.map((p: { message: string }) => p.message).join('; ');
414
453
  throw new Error(`Storage validation failed: ${messages}`);
415
454
  }
416
- // The arktype-validated shape matches `SqlStorageInput`
417
- // structurally. Funnel through the constructor so nested IR fields
418
- // (`types`) are normalised into class instances and the
419
- // branded `storageHash` is preserved on the returned `SqlStorage`.
420
- return new SqlStorage(result as SqlStorageInput);
455
+ // Arktype validates the JSON-safe envelope, but the `ColumnDefault`
456
+ // union carries runtime-only `bigint | Date` that the validation DSL
457
+ // can't express (see NOTE above), so bridge the validated shape to the
458
+ // input type. Construction below re-materialises nested IR fields.
459
+ const validated = blindCast<
460
+ SqlStorageInput & { readonly namespaces?: SqlStorageInput['namespaces'] },
461
+ 'arktype validated the JSON envelope but its output type is unknown (ColumnDefault carries runtime-only bigint|Date); bridge to the input shape'
462
+ >(result);
463
+ const namespaces = buildSqlNamespaceMap(validated.namespaces ?? {});
464
+ // Compatibility shim: inject the empty unbound singleton when absent so that
465
+ // production code paths which address __unbound__ for table metadata have a
466
+ // slot to read or write into. The `SqlStorageInput['namespaces']` type no
467
+ // longer requires __unbound__, so this is a runtime convenience, not a type
468
+ // invariant.
469
+ const unbound = namespaces[UNBOUND_NAMESPACE_ID] ?? SqlUnboundNamespace.instance;
470
+ return new SqlStorage({
471
+ storageHash: validated.storageHash,
472
+ ...ifDefined('types', validated.types),
473
+ namespaces: { ...namespaces, [UNBOUND_NAMESPACE_ID]: unbound },
474
+ });
421
475
  }
422
476
 
423
477
  export function validateModel(value: unknown): unknown {
@@ -633,43 +687,46 @@ export function validateStorageSemantics(storage: SqlStorage): string[] {
633
687
  * columns. Throws `ContractValidationError` on the first mismatch.
634
688
  */
635
689
  export function validateModelStorageReferences(contract: Contract<SqlStorage>): void {
636
- const models = contract.models as Record<string, ContractModel<SqlModelStorage>>;
637
- for (const [modelName, model] of Object.entries(models)) {
638
- const storageTable = model.storage.table;
639
-
640
- const rawTable = findStorageTableByTableName(contract.storage, storageTable);
641
- if (rawTable === undefined) {
642
- throw new ContractValidationError(
643
- `Model "${modelName}" references non-existent table "${storageTable}"`,
644
- 'storage',
645
- );
646
- }
647
-
648
- const table = rawTable as StorageTable;
649
-
650
- const columnNames = new Set(Object.keys(table.columns));
651
- for (const [fieldName, field] of Object.entries(model.storage.fields)) {
652
- if (!columnNames.has(field.column)) {
690
+ for (const [namespaceId, namespace] of Object.entries(contract.domain.namespaces)) {
691
+ const models = namespace.models as Record<string, ContractModel<SqlModelStorage>>;
692
+ for (const [modelName, model] of Object.entries(models)) {
693
+ const qualifiedName = `${namespaceId}:${modelName}`;
694
+ const storageTable = model.storage.table;
695
+
696
+ const rawTable = findStorageTableByTableName(contract.storage, storageTable);
697
+ if (rawTable === undefined) {
653
698
  throw new ContractValidationError(
654
- `Model "${modelName}" field "${fieldName}" references non-existent column "${field.column}" in table "${storageTable}"`,
699
+ `Model "${qualifiedName}" references non-existent table "${storageTable}"`,
655
700
  'storage',
656
701
  );
657
702
  }
658
- }
659
703
 
660
- const JSON_NATIVE_TYPES = new Set(['json', 'jsonb']);
661
- for (const [fieldName, domainField] of Object.entries(model.fields ?? {})) {
662
- const f = domainField as ContractField;
663
- if (f.type?.kind !== 'valueObject') continue;
664
- const storageField = model.storage.fields[fieldName];
665
- if (!storageField) continue;
666
- const column = table.columns[storageField.column];
667
- if (!column) continue;
668
- if (!JSON_NATIVE_TYPES.has(column.nativeType)) {
669
- throw new ContractValidationError(
670
- `Model "${modelName}" field "${fieldName}" is a value object but storage column "${storageField.column}" has nativeType "${column.nativeType}" (expected json or jsonb)`,
671
- 'storage',
672
- );
704
+ const table = rawTable as StorageTable;
705
+
706
+ const columnNames = new Set(Object.keys(table.columns));
707
+ for (const [fieldName, field] of Object.entries(model.storage.fields)) {
708
+ if (!columnNames.has(field.column)) {
709
+ throw new ContractValidationError(
710
+ `Model "${qualifiedName}" field "${fieldName}" references non-existent column "${field.column}" in table "${storageTable}"`,
711
+ 'storage',
712
+ );
713
+ }
714
+ }
715
+
716
+ const JSON_NATIVE_TYPES = new Set(['json', 'jsonb']);
717
+ for (const [fieldName, domainField] of Object.entries(model.fields ?? {})) {
718
+ const f = domainField as ContractField;
719
+ if (f.type?.kind !== 'valueObject') continue;
720
+ const storageField = model.storage.fields[fieldName];
721
+ if (!storageField) continue;
722
+ const column = table.columns[storageField.column];
723
+ if (!column) continue;
724
+ if (!JSON_NATIVE_TYPES.has(column.nativeType)) {
725
+ throw new ContractValidationError(
726
+ `Model "${qualifiedName}" field "${fieldName}" is a value object but storage column "${storageField.column}" has nativeType "${column.nativeType}" (expected json or jsonb)`,
727
+ 'storage',
728
+ );
729
+ }
673
730
  }
674
731
  }
675
732
  }
@@ -809,8 +866,7 @@ export function validateSqlContractFully<T extends Contract<SqlStorage>>(
809
866
  const validated = validateSqlContractStructure<T>(stripped, schema);
810
867
  validateContractDomain({
811
868
  roots: validated.roots,
812
- models: validated.models,
813
- ...(validated.valueObjects ? { valueObjects: validated.valueObjects } : {}),
869
+ domain: validated.domain,
814
870
  });
815
871
  validateSqlStorageConsistency(validated);
816
872
  const semanticErrors = validateStorageSemantics(validated.storage);