@prisma-next/sql-contract 0.11.0 → 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.
package/package.json CHANGED
@@ -1,28 +1,38 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "SQL contract types, validators, and IR factories for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/contract": "0.11.0",
10
- "@prisma-next/framework-components": "0.11.0",
9
+ "@prisma-next/contract": "0.12.0",
10
+ "@prisma-next/framework-components": "0.12.0",
11
+ "@prisma-next/utils": "0.12.0",
11
12
  "arktype": "^2.2.0"
12
13
  },
13
14
  "devDependencies": {
14
- "@prisma-next/test-utils": "0.11.0",
15
- "@prisma-next/tsconfig": "0.11.0",
16
- "@prisma-next/tsdown": "0.11.0",
15
+ "@prisma-next/test-utils": "0.12.0",
16
+ "@prisma-next/tsconfig": "0.12.0",
17
+ "@prisma-next/tsdown": "0.12.0",
17
18
  "tsdown": "0.22.0",
18
19
  "typescript": "5.9.3",
19
20
  "vitest": "4.1.6"
20
21
  },
22
+ "peerDependencies": {
23
+ "typescript": ">=5.9"
24
+ },
25
+ "peerDependenciesMeta": {
26
+ "typescript": {
27
+ "optional": true
28
+ }
29
+ },
21
30
  "files": [
22
31
  "dist",
23
32
  "src"
24
33
  ],
25
34
  "exports": {
35
+ "./canonicalization-hooks": "./dist/canonicalization-hooks.mjs",
26
36
  "./factories": "./dist/factories.mjs",
27
37
  "./index-type-validation": "./dist/index-type-validation.mjs",
28
38
  "./index-types": "./dist/index-types.mjs",
@@ -31,6 +41,9 @@
31
41
  "./validators": "./dist/validators.mjs",
32
42
  "./package.json": "./package.json"
33
43
  },
44
+ "engines": {
45
+ "node": ">=24"
46
+ },
34
47
  "repository": {
35
48
  "type": "git",
36
49
  "url": "https://github.com/prisma/prisma-next.git",
@@ -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,
@@ -23,79 +16,23 @@ import {
23
16
  * Polymorphic value type for document-scoped `SqlStorage.types` entries
24
17
  * (codec aliases / parameterised native type registrations). Postgres
25
18
  * native enum registrations live under
26
- * `storage.namespaces[namespaceId].types` instead.
19
+ * `storage.namespaces[namespaceId].enum` instead.
27
20
  */
28
21
  export type SqlStorageTypeEntry =
29
22
  | StorageTypeInstance
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>;
40
- readonly types?: Record<string, PostgresEnumStorageEntry>;
29
+ readonly enum?: Record<string, PostgresEnumStorageEntry>;
41
30
  }
42
31
 
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 types?: 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.types !== undefined && Object.keys(input.types).length > 0) {
68
- Object.defineProperty(this, 'types', {
69
- value: Object.freeze({ ...input.types }),
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.types ?? {}).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,39 +61,25 @@ 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
136
68
  // the slot without a class-instance check.
137
69
  export type SqlNamespace = Namespace & {
138
70
  readonly tables: Readonly<Record<string, StorageTable>>;
139
- readonly types?: Readonly<Record<string, PostgresEnumStorageEntry>>;
71
+ readonly enum?: Readonly<Record<string, PostgresEnumStorageEntry>>;
140
72
  };
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,
@@ -98,7 +107,7 @@ const StorageTypeInstanceSchema = type
98
107
  });
99
108
 
100
109
  /**
101
- * Postgres native enum entry under `storage.namespaces[namespaceId].types[name]`.
110
+ * Postgres native enum entry under `storage.namespaces[namespaceId].enum[name]`.
102
111
  * Document-scoped `storage.types` carries codec aliases only
103
112
  * (`DocumentScopedStorageTypeSchema`).
104
113
  */
@@ -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>()
@@ -226,11 +236,7 @@ function namespaceSlotEntrySchema(
226
236
  * Builds the per-namespace entry schema for `storage.namespaces[id]`.
227
237
  * Pack-contributed `validatorSchema` fragments — keyed by the
228
238
  * descriptor's `discriminator` — validate each entry by matching the
229
- * entry's `kind` field. The hardcoded `'types?'` slot is preserved
230
- * unconditionally: it coexists additively with any contributed fragment
231
- * that validates the same shape today. The full rename of `types` →
232
- * `postgresEnums` lands later; until then, the redundancy is the F1 cure
233
- * (no relocated dual-shape probe).
239
+ * entry's `kind` field on the `'enum?'` slot.
234
240
  */
235
241
  export function createNamespaceEntrySchema(
236
242
  fragments?: ReadonlyMap<string, Type<unknown>>,
@@ -240,7 +246,7 @@ export function createNamespaceEntrySchema(
240
246
  id: 'string',
241
247
  'kind?': 'string',
242
248
  'tables?': type({ '[string]': StorageTableSchema }),
243
- 'types?': type({
249
+ 'enum?': type({
244
250
  '[string]': namespaceSlotEntrySchema(PostgresEnumTypeSchema, 'postgres-enum', fragments),
245
251
  }),
246
252
  }) as Type<unknown>;
@@ -260,6 +266,11 @@ export function createSqlStorageSchema(
260
266
  '+': 'reject',
261
267
  storageHash: 'string',
262
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.
263
274
  'namespaces?': type({ '[string]': namespaceEntry }),
264
275
  }) as Type<unknown>;
265
276
  }
@@ -355,13 +366,32 @@ const ModelStorageSchema = type({
355
366
  fields: type({ '[string]': ModelStorageFieldSchema }),
356
367
  });
357
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
+
358
388
  const ModelSchema = type({
359
389
  storage: ModelStorageSchema,
360
390
  'fields?': type({ '[string]': ModelFieldSchema }),
361
- 'relations?': type({ '[string]': 'unknown' }),
391
+ 'relations?': type({ '[string]': ContractRelationSchema }),
362
392
  'discriminator?': 'unknown',
363
393
  'variants?': 'unknown',
364
- 'base?': 'string',
394
+ 'base?': CrossReferenceSchema,
365
395
  'owner?': 'string',
366
396
  });
367
397
 
@@ -387,10 +417,15 @@ export function createSqlContractSchema(
387
417
  'capabilities?': 'Record<string, Record<string, boolean>>',
388
418
  'extensionPacks?': 'Record<string, unknown>',
389
419
  'meta?': ContractMetaSchema,
390
- 'roots?': 'Record<string, string>',
391
- models: type({ '[string]': ModelSchema }),
392
- 'valueObjects?': 'Record<string, unknown>',
393
- '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
+ }),
394
429
  storage,
395
430
  'execution?': ExecutionSchema,
396
431
  }) as Type<unknown>;
@@ -417,11 +452,26 @@ export function validateStorage(value: unknown): SqlStorage {
417
452
  const messages = result.map((p: { message: string }) => p.message).join('; ');
418
453
  throw new Error(`Storage validation failed: ${messages}`);
419
454
  }
420
- // The arktype-validated shape matches `SqlStorageInput`
421
- // structurally. Funnel through the constructor so nested IR fields
422
- // (`types`) are normalised into class instances and the
423
- // branded `storageHash` is preserved on the returned `SqlStorage`.
424
- 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
+ });
425
475
  }
426
476
 
427
477
  export function validateModel(value: unknown): unknown {
@@ -637,43 +687,46 @@ export function validateStorageSemantics(storage: SqlStorage): string[] {
637
687
  * columns. Throws `ContractValidationError` on the first mismatch.
638
688
  */
639
689
  export function validateModelStorageReferences(contract: Contract<SqlStorage>): void {
640
- const models = contract.models as Record<string, ContractModel<SqlModelStorage>>;
641
- for (const [modelName, model] of Object.entries(models)) {
642
- const storageTable = model.storage.table;
643
-
644
- const rawTable = findStorageTableByTableName(contract.storage, storageTable);
645
- if (rawTable === undefined) {
646
- throw new ContractValidationError(
647
- `Model "${modelName}" references non-existent table "${storageTable}"`,
648
- 'storage',
649
- );
650
- }
651
-
652
- const table = rawTable as StorageTable;
653
-
654
- const columnNames = new Set(Object.keys(table.columns));
655
- for (const [fieldName, field] of Object.entries(model.storage.fields)) {
656
- 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) {
657
698
  throw new ContractValidationError(
658
- `Model "${modelName}" field "${fieldName}" references non-existent column "${field.column}" in table "${storageTable}"`,
699
+ `Model "${qualifiedName}" references non-existent table "${storageTable}"`,
659
700
  'storage',
660
701
  );
661
702
  }
662
- }
663
703
 
664
- const JSON_NATIVE_TYPES = new Set(['json', 'jsonb']);
665
- for (const [fieldName, domainField] of Object.entries(model.fields ?? {})) {
666
- const f = domainField as ContractField;
667
- if (f.type?.kind !== 'valueObject') continue;
668
- const storageField = model.storage.fields[fieldName];
669
- if (!storageField) continue;
670
- const column = table.columns[storageField.column];
671
- if (!column) continue;
672
- if (!JSON_NATIVE_TYPES.has(column.nativeType)) {
673
- throw new ContractValidationError(
674
- `Model "${modelName}" field "${fieldName}" is a value object but storage column "${storageField.column}" has nativeType "${column.nativeType}" (expected json or jsonb)`,
675
- 'storage',
676
- );
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
+ }
677
730
  }
678
731
  }
679
732
  }
@@ -813,8 +866,7 @@ export function validateSqlContractFully<T extends Contract<SqlStorage>>(
813
866
  const validated = validateSqlContractStructure<T>(stripped, schema);
814
867
  validateContractDomain({
815
868
  roots: validated.roots,
816
- models: validated.models,
817
- ...(validated.valueObjects ? { valueObjects: validated.valueObjects } : {}),
869
+ domain: validated.domain,
818
870
  });
819
871
  validateSqlStorageConsistency(validated);
820
872
  const semanticErrors = validateStorageSemantics(validated.storage);