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

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