@prisma-next/sql-contract-ts 0.12.0 → 0.13.0-dev.10

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.
@@ -6,11 +6,12 @@ import {
6
6
  import {
7
7
  asNamespaceId,
8
8
  type ColumnDefault,
9
- type ColumnDefaultLiteralInputValue,
10
9
  type Contract,
10
+ type ContractEnum,
11
11
  type ContractField,
12
12
  type ContractModel,
13
13
  type ContractRelation,
14
+ type ContractRelationThrough,
14
15
  type ContractValueObject,
15
16
  type CrossReference,
16
17
  coreHash,
@@ -18,6 +19,7 @@ import {
18
19
  type ExecutionMutationDefault,
19
20
  type JsonValue,
20
21
  type StorageHashBase,
22
+ type ValueSetRef,
21
23
  } from '@prisma-next/contract/types';
22
24
  import { type CapabilityMatrix, mergeCapabilityMatrices } from '@prisma-next/contract-authoring';
23
25
  import type { CodecLookup } from '@prisma-next/framework-components/codec';
@@ -32,6 +34,7 @@ import {
32
34
  import {
33
35
  applyFkDefaults,
34
36
  buildSqlNamespace,
37
+ type CheckConstraintInput,
35
38
  isPostgresEnumStorageEntry,
36
39
  type PostgresEnumStorageEntry,
37
40
  type SqlNamespaceTablesInput,
@@ -41,6 +44,7 @@ import {
41
44
  StorageTable,
42
45
  type StorageTableInput,
43
46
  type StorageTypeInstance,
47
+ type StorageValueSetInput,
44
48
  toStorageTypeInstance,
45
49
  } from '@prisma-next/sql-contract/types';
46
50
  import { validateStorageSemantics } from '@prisma-next/sql-contract/validators';
@@ -50,6 +54,7 @@ import type {
50
54
  ContractDefinition,
51
55
  FieldNode,
52
56
  ModelNode,
57
+ RelationNode,
53
58
  ValueObjectFieldNode,
54
59
  } from './contract-definition';
55
60
 
@@ -57,16 +62,15 @@ type DomainFieldRef =
57
62
  | { readonly kind: 'scalar'; readonly many?: boolean }
58
63
  | { readonly kind: 'valueObject'; readonly name: string; readonly many?: boolean };
59
64
 
60
- function encodeDefaultLiteralValue(
61
- value: ColumnDefaultLiteralInputValue,
62
- codecId: string,
63
- codecLookup?: CodecLookup,
64
- ): JsonValue {
65
+ function encodeViaCodec(value: unknown, codecId: string, codecLookup?: CodecLookup): JsonValue {
65
66
  const codec = codecLookup?.get(codecId);
66
67
  if (codec) {
67
68
  return codec.encodeJson(value);
68
69
  }
69
- return value as JsonValue;
70
+ return blindCast<
71
+ JsonValue,
72
+ 'no codec lookup at build time: literal/enum member value is already JSON-safe'
73
+ >(value);
70
74
  }
71
75
 
72
76
  function encodeColumnDefault(
@@ -79,7 +83,7 @@ function encodeColumnDefault(
79
83
  }
80
84
  return {
81
85
  kind: 'literal',
82
- value: encodeDefaultLiteralValue(defaultInput.value, codecId, codecLookup),
86
+ value: encodeViaCodec(defaultInput.value, codecId, codecLookup),
83
87
  };
84
88
  }
85
89
 
@@ -118,14 +122,23 @@ function assertStorageSemantics(
118
122
 
119
123
  function assertKnownTargetModel(
120
124
  modelsByName: ReadonlyMap<string, ModelNode>,
125
+ modelsByCoordinate: ReadonlyMap<string, ModelNode>,
121
126
  sourceModelName: string,
122
127
  targetModelName: string,
128
+ targetNamespaceId: string | undefined,
123
129
  context: string,
124
130
  ): ModelNode {
125
- const targetModel = modelsByName.get(targetModelName);
131
+ const targetModel =
132
+ targetNamespaceId !== undefined && targetNamespaceId.length > 0
133
+ ? modelsByCoordinate.get(`${targetNamespaceId}:${targetModelName}`)
134
+ : modelsByName.get(targetModelName);
126
135
  if (!targetModel) {
136
+ const qualified =
137
+ targetNamespaceId !== undefined && targetNamespaceId.length > 0
138
+ ? `${targetNamespaceId}.${targetModelName}`
139
+ : targetModelName;
127
140
  throw new Error(
128
- `${context} on model "${sourceModelName}" references unknown model "${targetModelName}"`,
141
+ `${context} on model "${sourceModelName}" references unknown model "${qualified}"`,
129
142
  );
130
143
  }
131
144
  return targetModel;
@@ -156,16 +169,54 @@ const JSONB_NATIVE_TYPE = 'jsonb';
156
169
  function resolveModelNamespaceId(
157
170
  model: ModelNode,
158
171
  modelNameToNamespaceId: ReadonlyMap<string, string>,
159
- targetId: string,
172
+ defaultNamespaceId: string,
160
173
  ): string {
161
174
  if (model.namespaceId !== undefined && model.namespaceId.length > 0) {
162
175
  return model.namespaceId;
163
176
  }
164
- return modelNameToNamespaceId.get(model.modelName) ?? defaultModelNamespaceId(targetId);
177
+ return modelNameToNamespaceId.get(model.modelName) ?? defaultNamespaceId;
178
+ }
179
+
180
+ function buildThroughDescriptor(
181
+ through: NonNullable<RelationNode['through']>,
182
+ tableNamespaceByName: ReadonlyMap<string, string>,
183
+ targetModel: ModelNode,
184
+ modelName: string,
185
+ fieldName: string,
186
+ ): ContractRelationThrough {
187
+ const namespaceId = tableNamespaceByName.get(through.table);
188
+ if (namespaceId === undefined) {
189
+ throw new Error(
190
+ `buildSqlContractFromDefinition: junction table "${through.table}" for relation "${modelName}.${fieldName}" is not a declared model.`,
191
+ );
192
+ }
193
+
194
+ return {
195
+ table: through.table,
196
+ namespaceId,
197
+ parentColumns: through.parentColumns,
198
+ childColumns: through.childColumns,
199
+ targetColumns: targetColumnsForJunction(targetModel, fieldName),
200
+ };
201
+ }
202
+
203
+ function targetColumnsForJunction(targetModel: ModelNode, fieldName: string): readonly string[] {
204
+ const primaryKeyColumns = targetModel.id?.columns;
205
+ if (primaryKeyColumns && primaryKeyColumns.length > 0) {
206
+ return primaryKeyColumns;
207
+ }
208
+ const firstUnique = targetModel.uniques?.find((u) => u.columns.length > 0);
209
+ if (firstUnique) {
210
+ return firstUnique.columns;
211
+ }
212
+ throw new Error(
213
+ `M:N target model "${targetModel.modelName}" (relation field "${fieldName}") has no primary id or unique key to derive junction targetColumns.`,
214
+ );
165
215
  }
166
216
 
167
217
  function buildStorageColumn(
168
218
  field: FieldNode | ValueObjectFieldNode,
219
+ storageValueSetRef: ValueSetRef | undefined,
169
220
  codecLookup?: CodecLookup,
170
221
  ): StorageColumn {
171
222
  if (isValueObjectField(field)) {
@@ -203,12 +254,14 @@ function buildStorageColumn(
203
254
  ...ifDefined('typeParams', field.descriptor.typeParams),
204
255
  ...ifDefined('default', encodedDefault),
205
256
  ...ifDefined('typeRef', field.descriptor.typeRef),
257
+ ...ifDefined('valueSet', storageValueSetRef),
206
258
  };
207
259
  }
208
260
 
209
261
  function buildDomainField(
210
262
  field: FieldNode | ValueObjectFieldNode,
211
263
  column: StorageColumn,
264
+ domainValueSetRef: ValueSetRef | undefined,
212
265
  ): ContractField {
213
266
  if (isValueObjectField(field)) {
214
267
  return {
@@ -226,12 +279,13 @@ function buildDomainField(
226
279
  },
227
280
  nullable: column.nullable,
228
281
  ...(field.many ? { many: true } : {}),
282
+ ...ifDefined('valueSet', domainValueSetRef),
229
283
  };
230
284
  }
231
285
 
232
286
  function collectStorageNamespaceCoordinateIds(definition: ContractDefinition): Set<string> {
233
287
  const ids = new Set<string>();
234
- ids.add(defaultModelNamespaceId(definition.target.targetId));
288
+ ids.add(definition.target.defaultNamespaceId);
235
289
  for (const id of definition.namespaces ?? []) {
236
290
  if (id.length > 0) {
237
291
  ids.add(id);
@@ -245,22 +299,38 @@ function collectStorageNamespaceCoordinateIds(definition: ContractDefinition): S
245
299
  return ids;
246
300
  }
247
301
 
248
- const POSTGRES_ENUM_NAMESPACE_ID = 'public';
249
- const POSTGRES_DEFAULT_NAMESPACE_ID = 'public';
250
-
251
- function defaultModelNamespaceId(targetId: string): string {
252
- return targetId === 'postgres' ? POSTGRES_DEFAULT_NAMESPACE_ID : UNBOUND_NAMESPACE_ID;
302
+ function ensureUnboundNamespaceSlot(
303
+ namespaces: SqlStorageInput['namespaces'],
304
+ createNamespace: ContractDefinition['createNamespace'],
305
+ ): SqlStorageInput['namespaces'] {
306
+ if (Object.hasOwn(namespaces, UNBOUND_NAMESPACE_ID)) {
307
+ return namespaces;
308
+ }
309
+ const unboundInput: SqlNamespaceTablesInput = {
310
+ id: UNBOUND_NAMESPACE_ID,
311
+ entries: { table: {} },
312
+ };
313
+ const unbound = createNamespace ? createNamespace(unboundInput) : buildSqlNamespace(unboundInput);
314
+ return blindCast<
315
+ SqlStorageInput['namespaces'],
316
+ 'createNamespace may return a target namespace concretion; the unbound slot matches SqlNamespace at runtime'
317
+ >({
318
+ [UNBOUND_NAMESPACE_ID]: unbound,
319
+ ...namespaces,
320
+ });
253
321
  }
254
322
 
323
+ const POSTGRES_ENUM_NAMESPACE_ID = 'public';
324
+
255
325
  function partitionStorageTypesForTarget(
256
326
  targetId: string,
257
327
  types: Record<string, StorageTypeInstance | PostgresEnumStorageEntry>,
258
328
  namespaceTypes?: Readonly<Record<string, Readonly<Record<string, PostgresEnumStorageEntry>>>>,
259
329
  ): {
260
- readonly documentTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry>;
330
+ readonly documentTypes: Record<string, StorageTypeInstance>;
261
331
  readonly namespaceEnumTypesById: Record<string, Record<string, PostgresEnumStorageEntry>>;
262
332
  } {
263
- const documentTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry> = {};
333
+ const documentTypes: Record<string, StorageTypeInstance> = {};
264
334
  const namespaceEnumTypesById: Record<string, Record<string, PostgresEnumStorageEntry>> = {};
265
335
  for (const [name, entry] of Object.entries(types)) {
266
336
  if (isPostgresEnumStorageEntry(entry)) {
@@ -304,24 +374,47 @@ export function buildSqlContractFromDefinition(
304
374
  codecLookup?: CodecLookup,
305
375
  ): Contract<SqlStorage> {
306
376
  const target = definition.target.targetId;
377
+ const defaultNamespaceId = definition.target.defaultNamespaceId;
307
378
  const targetFamily = 'sql';
379
+ const resolveNamespaceId = (m: ModelNode): string =>
380
+ m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId;
308
381
  const modelsByName = new Map(definition.models.map((m) => [m.modelName, m]));
382
+ const tableNamespaceByName = new Map(
383
+ definition.models.map((m) => [
384
+ m.tableName,
385
+ m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId,
386
+ ]),
387
+ );
388
+ const modelsByCoordinate = new Map(
389
+ definition.models.map((m) => [`${resolveNamespaceId(m)}:${m.modelName}`, m]),
390
+ );
309
391
 
310
392
  const tablesByNamespace: Record<string, Record<string, StorageTable>> = {};
311
- const tableNameToNamespaceId = new Map<string, string>();
312
393
  const modelNameToNamespaceId = new Map<string, string>();
313
394
  const executionDefaults: ExecutionMutationDefault[] = [];
314
395
  const modelsByNamespace: Record<string, Record<string, ContractModel>> = {};
315
- const roots: Record<string, CrossReference> = {};
396
+ const rootEntries: Array<{
397
+ readonly tableName: string;
398
+ readonly namespaceId: string;
399
+ readonly ref: CrossReference;
400
+ }> = [];
316
401
 
317
402
  for (const semanticModel of definition.models) {
318
403
  const tableName = semanticModel.tableName;
319
404
  const namespaceId =
320
405
  semanticModel.namespaceId !== undefined && semanticModel.namespaceId.length > 0
321
406
  ? semanticModel.namespaceId
322
- : defaultModelNamespaceId(target);
407
+ : defaultNamespaceId;
323
408
  modelNameToNamespaceId.set(semanticModel.modelName, namespaceId);
324
- roots[tableName] = crossRef(semanticModel.modelName, namespaceId);
409
+ // STI variants share the base table; the base model already owns this
410
+ // table name and its root, so the variant contributes neither.
411
+ if (!semanticModel.sharesBaseTable) {
412
+ rootEntries.push({
413
+ tableName,
414
+ namespaceId,
415
+ ref: crossRef(semanticModel.modelName, namespaceId),
416
+ });
417
+ }
325
418
 
326
419
  // --- Build storage table ---
327
420
 
@@ -348,11 +441,34 @@ export function buildSqlContractFromDefinition(
348
441
  }
349
442
  }
350
443
 
351
- const column = buildStorageColumn(field, codecLookup);
444
+ const enumHandle = !isValueObjectField(field) ? field.enumTypeHandle : undefined;
445
+ // Authored enums are always registered under the contract's defaultNamespaceId
446
+ // (see the enum registration loop below), so refs must point there regardless
447
+ // of which namespace the consuming model lives in.
448
+ const storageValueSetRef: ValueSetRef | undefined =
449
+ enumHandle !== undefined
450
+ ? {
451
+ plane: 'storage',
452
+ entityKind: 'value-set',
453
+ namespaceId: defaultNamespaceId,
454
+ name: enumHandle.enumName,
455
+ }
456
+ : undefined;
457
+ const domainValueSetRef: ValueSetRef | undefined =
458
+ enumHandle !== undefined
459
+ ? {
460
+ plane: 'domain',
461
+ entityKind: 'enum',
462
+ namespaceId: defaultNamespaceId,
463
+ name: enumHandle.enumName,
464
+ }
465
+ : undefined;
466
+
467
+ const column = buildStorageColumn(field, storageValueSetRef, codecLookup);
352
468
  columns[field.columnName] = column;
353
469
  fieldToColumn[field.fieldName] = field.columnName;
354
470
 
355
- domainFields[field.fieldName] = buildDomainField(field, column);
471
+ domainFields[field.fieldName] = buildDomainField(field, column, domainValueSetRef);
356
472
 
357
473
  if (isValueObjectField(field)) {
358
474
  domainFieldRefs[field.fieldName] = {
@@ -374,10 +490,37 @@ export function buildSqlContractFromDefinition(
374
490
  }
375
491
 
376
492
  const foreignKeys = (semanticModel.foreignKeys ?? []).map((fk) => {
493
+ if (fk.references.spaceId !== undefined) {
494
+ // Cross-space FK: the target lives in a different contract space.
495
+ // Skip local model lookup and carry the spaceId coordinate through.
496
+ const targetNamespaceId = fk.references.namespaceId ?? defaultNamespaceId;
497
+ return {
498
+ source: { namespaceId: asNamespaceId(namespaceId), tableName, columns: fk.columns },
499
+ target: {
500
+ namespaceId: asNamespaceId(targetNamespaceId),
501
+ tableName: fk.references.table,
502
+ columns: fk.references.columns,
503
+ spaceId: fk.references.spaceId,
504
+ },
505
+ ...applyFkDefaults(
506
+ {
507
+ ...ifDefined('constraint', fk.constraint),
508
+ ...ifDefined('index', fk.index),
509
+ },
510
+ definition.foreignKeyDefaults,
511
+ ),
512
+ ...ifDefined('name', fk.name),
513
+ ...ifDefined('onDelete', fk.onDelete),
514
+ ...ifDefined('onUpdate', fk.onUpdate),
515
+ };
516
+ }
517
+
377
518
  const targetModel = assertKnownTargetModel(
378
519
  modelsByName,
520
+ modelsByCoordinate,
379
521
  semanticModel.modelName,
380
522
  fk.references.model,
523
+ fk.references.namespaceId,
381
524
  'Foreign key',
382
525
  );
383
526
  assertTargetTableMatches(
@@ -390,7 +533,7 @@ export function buildSqlContractFromDefinition(
390
533
  fk.references.namespaceId ??
391
534
  (targetModel.namespaceId !== undefined && targetModel.namespaceId.length > 0
392
535
  ? targetModel.namespaceId
393
- : defaultModelNamespaceId(target));
536
+ : defaultNamespaceId);
394
537
  return {
395
538
  source: { namespaceId: asNamespaceId(namespaceId), tableName, columns: fk.columns },
396
539
  target: {
@@ -411,48 +554,56 @@ export function buildSqlContractFromDefinition(
411
554
  };
412
555
  });
413
556
 
414
- const existingNs = tableNameToNamespaceId.get(tableName);
415
- if (existingNs !== undefined && existingNs !== namespaceId) {
416
- throw new Error(
417
- `buildSqlContractFromDefinition: table "${tableName}" is mapped in namespace "${namespaceId}" but already exists in namespace "${existingNs}".`,
557
+ // STI variants share the base table: their columns are already
558
+ // materialised onto the base `ModelNode`, so the variant builds a domain
559
+ // model (below) but no storage table of its own.
560
+ if (!semanticModel.sharesBaseTable) {
561
+ const checksForTable: CheckConstraintInput[] = Object.entries(columns).flatMap(
562
+ ([columnName, col]) => {
563
+ const valueSet = col.valueSet;
564
+ return valueSet === undefined
565
+ ? []
566
+ : [{ name: `${tableName}_${columnName}_check`, column: columnName, valueSet }];
567
+ },
418
568
  );
419
- }
420
- tableNameToNamespaceId.set(tableName, namespaceId);
421
569
 
422
- const tableInput: StorageTableInput = {
423
- columns,
424
- uniques: (semanticModel.uniques ?? []).map((u) => ({
425
- columns: u.columns,
426
- ...ifDefined('name', u.name),
427
- })),
428
- indexes: (semanticModel.indexes ?? []).map((i) => ({
429
- columns: i.columns,
430
- ...ifDefined('name', i.name),
431
- ...ifDefined('type', i.type),
432
- ...ifDefined('options', i.options),
433
- })),
434
- foreignKeys,
435
- ...(semanticModel.id
436
- ? {
437
- primaryKey: {
438
- columns: semanticModel.id.columns,
439
- ...ifDefined('name', semanticModel.id.name),
440
- },
441
- }
442
- : {}),
443
- };
570
+ const tableInput: StorageTableInput = {
571
+ columns,
572
+ ...ifDefined('control', semanticModel.control),
573
+ uniques: (semanticModel.uniques ?? []).map((u) => ({
574
+ columns: u.columns,
575
+ ...ifDefined('name', u.name),
576
+ })),
577
+ indexes: (semanticModel.indexes ?? []).map((i) => ({
578
+ columns: i.columns,
579
+ ...ifDefined('name', i.name),
580
+ ...ifDefined('type', i.type),
581
+ ...ifDefined('options', i.options),
582
+ })),
583
+ foreignKeys,
584
+ ...(semanticModel.id
585
+ ? {
586
+ primaryKey: {
587
+ columns: semanticModel.id.columns,
588
+ ...ifDefined('name', semanticModel.id.name),
589
+ },
590
+ }
591
+ : {}),
592
+ ...(checksForTable.length > 0 ? { checks: checksForTable } : {}),
593
+ };
444
594
 
445
- let nsTables = tablesByNamespace[namespaceId];
446
- if (nsTables === undefined) {
447
- nsTables = {};
448
- tablesByNamespace[namespaceId] = nsTables;
449
- }
450
- if (nsTables[tableName] !== undefined) {
451
- throw new Error(
452
- `buildSqlContractFromDefinition: duplicate table "${tableName}" in namespace "${namespaceId}".`,
453
- );
595
+ let nsTables = tablesByNamespace[namespaceId];
596
+ if (nsTables === undefined) {
597
+ nsTables = {};
598
+ tablesByNamespace[namespaceId] = nsTables;
599
+ }
600
+ if (nsTables[tableName] !== undefined) {
601
+ throw new Error(
602
+ `buildSqlContractFromDefinition: duplicate table "${tableName}" in namespace "${namespaceId}".`,
603
+ );
604
+ }
605
+ nsTables[tableName] = new StorageTable(tableInput);
454
606
  }
455
- nsTables[tableName] = new StorageTable(tableInput);
456
607
 
457
608
  // --- Build contract model ---
458
609
 
@@ -466,47 +617,70 @@ export function buildSqlContractFromDefinition(
466
617
  );
467
618
  const modelRelations: Record<string, ContractRelation> = {};
468
619
  for (const relation of semanticModel.relations ?? []) {
620
+ // Cross-space relations have `spaceId` set — the target model lives in
621
+ // a different contract space, so skip local model lookup and validation.
622
+ if (relation.spaceId !== undefined) {
623
+ const targetNamespaceId = relation.namespaceId ?? defaultNamespaceId;
624
+ modelRelations[relation.fieldName] = {
625
+ to: crossRef(relation.toModel, targetNamespaceId, relation.spaceId),
626
+ // Cross-space belongsTo relations are always N:1 (the FK-owning side).
627
+ cardinality: 'N:1',
628
+ on: {
629
+ localFields: relation.on.parentColumns.map((col) => columnToField.get(col) ?? col),
630
+ // For cross-space targets the lowering carries field names directly
631
+ // (no fieldToColumn map available for the remote model).
632
+ targetFields: relation.on.childColumns,
633
+ },
634
+ };
635
+ continue;
636
+ }
637
+
469
638
  const targetModel = assertKnownTargetModel(
470
639
  modelsByName,
640
+ modelsByCoordinate,
471
641
  semanticModel.modelName,
472
642
  relation.toModel,
643
+ relation.toNamespaceId,
473
644
  'Relation',
474
645
  );
475
646
  assertTargetTableMatches(semanticModel.modelName, targetModel, relation.toTable, 'Relation');
476
647
 
477
- if (relation.cardinality === 'N:M' && !relation.through) {
478
- throw new Error(
479
- `Relation "${semanticModel.modelName}.${relation.fieldName}" with cardinality "N:M" requires through metadata`,
480
- );
481
- }
482
-
483
648
  const targetColumnToField = new Map(
484
649
  targetModel.fields.map((f) => [f.columnName, f.fieldName]),
485
650
  );
486
651
 
487
- modelRelations[relation.fieldName] = {
488
- to: crossRef(
489
- relation.toModel,
490
- resolveModelNamespaceId(targetModel, modelNameToNamespaceId, target),
491
- ),
492
- // RelationDefinition.cardinality includes 'N:M' which isn't in
493
- // ContractReferenceRelation yet — cast is needed until the contract
494
- // type is extended to cover many-to-many.
495
- cardinality: relation.cardinality as ContractRelation['cardinality'],
496
- on: {
497
- localFields: relation.on.parentColumns.map((col) => columnToField.get(col) ?? col),
498
- targetFields: relation.on.childColumns.map((col) => targetColumnToField.get(col) ?? col),
499
- },
500
- ...(relation.through
501
- ? {
502
- through: {
503
- table: relation.through.table,
504
- parentCols: relation.through.parentColumns,
505
- childCols: relation.through.childColumns,
506
- },
507
- }
508
- : undefined),
652
+ const to = crossRef(
653
+ relation.toModel,
654
+ relation.toNamespaceId !== undefined && relation.toNamespaceId.length > 0
655
+ ? relation.toNamespaceId
656
+ : resolveModelNamespaceId(targetModel, modelNameToNamespaceId, defaultNamespaceId),
657
+ );
658
+ const on = {
659
+ localFields: relation.on.parentColumns.map((col) => columnToField.get(col) ?? col),
660
+ targetFields: relation.on.childColumns.map((col) => targetColumnToField.get(col) ?? col),
509
661
  };
662
+
663
+ if (relation.cardinality === 'N:M') {
664
+ if (!relation.through) {
665
+ throw new Error(
666
+ `Relation "${semanticModel.modelName}.${relation.fieldName}" with cardinality "N:M" requires through metadata`,
667
+ );
668
+ }
669
+ modelRelations[relation.fieldName] = {
670
+ to,
671
+ cardinality: 'N:M',
672
+ on,
673
+ through: buildThroughDescriptor(
674
+ relation.through,
675
+ tableNamespaceByName,
676
+ targetModel,
677
+ semanticModel.modelName,
678
+ relation.fieldName,
679
+ ),
680
+ };
681
+ } else {
682
+ modelRelations[relation.fieldName] = { to, cardinality: relation.cardinality, on };
683
+ }
510
684
  }
511
685
 
512
686
  let namespaceModels = modelsByNamespace[namespaceId];
@@ -517,6 +691,7 @@ export function buildSqlContractFromDefinition(
517
691
  namespaceModels[semanticModel.modelName] = {
518
692
  storage: {
519
693
  table: tableName,
694
+ namespaceId,
520
695
  fields: storageFields,
521
696
  },
522
697
  fields: domainFields,
@@ -526,6 +701,24 @@ export function buildSqlContractFromDefinition(
526
701
 
527
702
  // --- Assemble contract ---
528
703
 
704
+ // Aggregate roots are keyed by bare storage table name. When two models in
705
+ // different namespaces map to the same bare table name, the bare key would
706
+ // collide (last write wins, silently dropping a root), so those entries fall
707
+ // back to a namespace-qualified key. Single-namespace contracts never
708
+ // collide and keep their bare keys unchanged.
709
+ const rootTableNameCounts = new Map<string, number>();
710
+ for (const entry of rootEntries) {
711
+ rootTableNameCounts.set(entry.tableName, (rootTableNameCounts.get(entry.tableName) ?? 0) + 1);
712
+ }
713
+ const roots: Record<string, CrossReference> = {};
714
+ for (const entry of rootEntries) {
715
+ const key =
716
+ (rootTableNameCounts.get(entry.tableName) ?? 0) > 1
717
+ ? `${entry.namespaceId}.${entry.tableName}`
718
+ : entry.tableName;
719
+ roots[key] = entry.ref;
720
+ }
721
+
529
722
  // Normalise raw codec-triple inputs to the `kind: 'codec-instance'`
530
723
  // discriminator shape before hashing so the storageHash matches the
531
724
  // persisted JSON envelope produced from the SqlStorage class instance
@@ -557,6 +750,42 @@ export function buildSqlContractFromDefinition(
557
750
  for (const id of Object.keys(namespaceEnumTypesById)) {
558
751
  namespaceCoordinateIds.add(id);
559
752
  }
753
+
754
+ // Build per-namespace registries for `enumType()` handles.
755
+ // All authored enums target the contract's default namespace.
756
+ const domainEnumsByNs: Record<string, Record<string, ContractEnum>> = {};
757
+ const storageValueSetsByNs: Record<string, Record<string, StorageValueSetInput>> = {};
758
+ for (const [enumName, handle] of Object.entries(definition.enums ?? {})) {
759
+ if (enumName !== handle.enumName) {
760
+ throw new Error(
761
+ `enum declaration key "${enumName}" must match enumType name "${handle.enumName}". Aliases are not supported.`,
762
+ );
763
+ }
764
+ const nsId = defaultNamespaceId;
765
+ let domainSlot = domainEnumsByNs[nsId];
766
+ if (domainSlot === undefined) {
767
+ domainSlot = {};
768
+ domainEnumsByNs[nsId] = domainSlot;
769
+ }
770
+ domainSlot[enumName] = {
771
+ codecId: handle.codecId,
772
+ members: handle.enumMembers.map((m) => ({
773
+ name: m.name,
774
+ value: encodeViaCodec(m.value, handle.codecId, codecLookup),
775
+ })),
776
+ };
777
+
778
+ let storageSlot = storageValueSetsByNs[nsId];
779
+ if (storageSlot === undefined) {
780
+ storageSlot = {};
781
+ storageValueSetsByNs[nsId] = storageSlot;
782
+ }
783
+ storageSlot[enumName] = {
784
+ kind: 'value-set',
785
+ values: handle.values.map((v) => encodeViaCodec(v, handle.codecId, codecLookup)),
786
+ };
787
+ }
788
+
560
789
  const { createNamespace } = definition;
561
790
  const namespaces = blindCast<
562
791
  SqlStorageInput['namespaces'],
@@ -565,18 +794,26 @@ export function buildSqlContractFromDefinition(
565
794
  Object.fromEntries(
566
795
  [...namespaceCoordinateIds].sort().map((id) => {
567
796
  const enumTypes = namespaceEnumTypesById[id];
797
+ const valueSetEntries = storageValueSetsByNs[id];
568
798
  const nsInput: SqlNamespaceTablesInput = {
569
799
  id,
570
- tables: tablesByNamespace[id] ?? {},
571
- ...ifDefined('enum', enumTypes),
800
+ entries: {
801
+ table: tablesByNamespace[id] ?? {},
802
+ ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
803
+ ? { valueSet: valueSetEntries }
804
+ : {}),
805
+ },
572
806
  };
573
- return [id, createNamespace ? createNamespace(nsInput) : buildSqlNamespace(nsInput)];
807
+ return [
808
+ id,
809
+ createNamespace ? createNamespace(nsInput, enumTypes) : buildSqlNamespace(nsInput),
810
+ ];
574
811
  }),
575
812
  ),
576
813
  );
577
814
  const storageWithoutHash = {
578
815
  ...(Object.keys(documentTypes).length > 0 ? { types: documentTypes } : {}),
579
- namespaces,
816
+ namespaces: ensureUnboundNamespaceSlot(namespaces, createNamespace),
580
817
  };
581
818
  const storageHash: StorageHashBase<string> = definition.storageHash
582
819
  ? coreHash(definition.storageHash)
@@ -672,7 +909,6 @@ export function buildSqlContractFromDefinition(
672
909
  )
673
910
  : undefined;
674
911
 
675
- const defaultNamespaceId = defaultModelNamespaceId(target);
676
912
  const domainNamespaceIds = new Set(Object.keys(modelsByNamespace));
677
913
  if (domainNamespaceIds.size === 0) {
678
914
  domainNamespaceIds.add(defaultNamespaceId);
@@ -680,13 +916,22 @@ export function buildSqlContractFromDefinition(
680
916
  if (valueObjects !== undefined) {
681
917
  domainNamespaceIds.add(defaultNamespaceId);
682
918
  }
919
+ for (const nsId of Object.keys(domainEnumsByNs)) {
920
+ domainNamespaceIds.add(nsId);
921
+ }
683
922
  const domainNamespaces = Object.fromEntries(
684
923
  [...domainNamespaceIds].sort().map((namespaceId) => {
685
924
  const modelsInNs = modelsByNamespace[namespaceId] ?? {};
686
- const namespaceSlice =
687
- namespaceId === defaultNamespaceId && valueObjects !== undefined
688
- ? { models: modelsInNs, valueObjects }
689
- : { models: modelsInNs };
925
+ const enumsInNs = domainEnumsByNs[namespaceId];
926
+ const namespaceSlice = {
927
+ models: modelsInNs,
928
+ ...(namespaceId === defaultNamespaceId && valueObjects !== undefined
929
+ ? { valueObjects }
930
+ : {}),
931
+ ...(enumsInNs !== undefined && Object.keys(enumsInNs).length > 0
932
+ ? { enum: enumsInNs }
933
+ : {}),
934
+ };
690
935
  return [namespaceId, namespaceSlice];
691
936
  }),
692
937
  );
@@ -694,6 +939,7 @@ export function buildSqlContractFromDefinition(
694
939
  const contract: Contract<SqlStorage> = {
695
940
  target,
696
941
  targetFamily,
942
+ ...ifDefined('defaultControlPolicy', definition.defaultControlPolicy),
697
943
  domain: { namespaces: domainNamespaces },
698
944
  roots,
699
945
  storage,