@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.
- package/README.md +8 -0
- package/dist/{build-contract-BCYW3_wE.mjs → build-contract-HP3IjjLv.mjs} +220 -79
- package/dist/build-contract-HP3IjjLv.mjs.map +1 -0
- package/dist/config-types.d.mts +7 -3
- package/dist/config-types.d.mts.map +1 -1
- package/dist/config-types.mjs +12 -9
- package/dist/config-types.mjs.map +1 -1
- package/dist/contract-builder.d.mts +395 -39
- package/dist/contract-builder.d.mts.map +1 -1
- package/dist/contract-builder.mjs +295 -44
- package/dist/contract-builder.mjs.map +1 -1
- package/package.json +13 -13
- package/schemas/data-contract-sql-v1.json +31 -0
- package/src/build-contract.ts +348 -102
- package/src/config-types.ts +24 -6
- package/src/contract-builder.ts +171 -21
- package/src/contract-definition.ts +57 -3
- package/src/contract-dsl.ts +346 -18
- package/src/contract-lowering.ts +188 -15
- package/src/contract-types.ts +123 -47
- package/src/enum-type.ts +306 -0
- package/src/exports/contract-builder.ts +13 -0
- package/dist/build-contract-BCYW3_wE.mjs.map +0 -1
package/src/build-contract.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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 =
|
|
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 "${
|
|
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
|
-
|
|
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) ??
|
|
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(
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
330
|
+
readonly documentTypes: Record<string, StorageTypeInstance>;
|
|
261
331
|
readonly namespaceEnumTypesById: Record<string, Record<string, PostgresEnumStorageEntry>>;
|
|
262
332
|
} {
|
|
263
|
-
const documentTypes: Record<string, StorageTypeInstance
|
|
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
|
|
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
|
-
:
|
|
407
|
+
: defaultNamespaceId;
|
|
323
408
|
modelNameToNamespaceId.set(semanticModel.modelName, namespaceId);
|
|
324
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
571
|
-
|
|
800
|
+
entries: {
|
|
801
|
+
table: tablesByNamespace[id] ?? {},
|
|
802
|
+
...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
|
|
803
|
+
? { valueSet: valueSetEntries }
|
|
804
|
+
: {}),
|
|
805
|
+
},
|
|
572
806
|
};
|
|
573
|
-
return [
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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,
|