@prisma-next/sql-contract-ts 0.14.0-dev.51 → 0.14.0-dev.52

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,27 +1,27 @@
1
1
  {
2
2
  "name": "@prisma-next/sql-contract-ts",
3
- "version": "0.14.0-dev.51",
3
+ "version": "0.14.0-dev.52",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "description": "SQL-specific TypeScript contract authoring surface for Prisma Next",
8
8
  "dependencies": {
9
- "@prisma-next/config": "0.14.0-dev.51",
10
- "@prisma-next/contract": "0.14.0-dev.51",
11
- "@prisma-next/contract-authoring": "0.14.0-dev.51",
12
- "@prisma-next/framework-components": "0.14.0-dev.51",
13
- "@prisma-next/sql-contract": "0.14.0-dev.51",
14
- "@prisma-next/utils": "0.14.0-dev.51",
9
+ "@prisma-next/config": "0.14.0-dev.52",
10
+ "@prisma-next/contract": "0.14.0-dev.52",
11
+ "@prisma-next/contract-authoring": "0.14.0-dev.52",
12
+ "@prisma-next/framework-components": "0.14.0-dev.52",
13
+ "@prisma-next/sql-contract": "0.14.0-dev.52",
14
+ "@prisma-next/utils": "0.14.0-dev.52",
15
15
  "arktype": "^2.2.0",
16
16
  "pathe": "^2.0.3",
17
17
  "ts-toolbelt": "^9.6.0"
18
18
  },
19
19
  "devDependencies": {
20
- "@prisma-next/test-utils": "0.14.0-dev.51",
21
- "@prisma-next/tsconfig": "0.14.0-dev.51",
20
+ "@prisma-next/test-utils": "0.14.0-dev.52",
21
+ "@prisma-next/tsconfig": "0.14.0-dev.52",
22
22
  "@types/pg": "8.20.0",
23
23
  "pg": "8.21.0",
24
- "@prisma-next/tsdown": "0.14.0-dev.51",
24
+ "@prisma-next/tsdown": "0.14.0-dev.52",
25
25
  "tsdown": "0.22.1",
26
26
  "typescript": "5.9.3",
27
27
  "vitest": "4.1.8"
@@ -22,9 +22,16 @@ import {
22
22
  type ValueSetRef,
23
23
  } from '@prisma-next/contract/types';
24
24
  import { type CapabilityMatrix, mergeCapabilityMatrices } from '@prisma-next/contract-authoring';
25
- import type { CodecLookup } from '@prisma-next/framework-components/codec';
25
+ import type {
26
+ AuthoringContributions,
27
+ AuthoringEntityTypeDescriptor,
28
+ AuthoringEntityTypeNamespace,
29
+ } from '@prisma-next/framework-components/authoring';
30
+ import { isAuthoringEntityTypeDescriptor } from '@prisma-next/framework-components/authoring';
31
+ import type { CodecLookup, ColumnTypeDescriptor } from '@prisma-next/framework-components/codec';
26
32
  import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
27
33
  import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks';
34
+ import { tableEntityKind, valueSetEntityKind } from '@prisma-next/sql-contract/entity-kinds';
28
35
  import { validateIndexTypes } from '@prisma-next/sql-contract/index-type-validation';
29
36
  import {
30
37
  createIndexTypeRegistry,
@@ -44,6 +51,7 @@ import {
44
51
  toStorageTypeInstance,
45
52
  } from '@prisma-next/sql-contract/types';
46
53
  import { validateStorageSemantics } from '@prisma-next/sql-contract/validators';
54
+ import { deriveValueSetFromEntity } from '@prisma-next/sql-contract/value-set-derivation-hook';
47
55
  import { blindCast } from '@prisma-next/utils/casts';
48
56
  import { ifDefined } from '@prisma-next/utils/defined';
49
57
  import type {
@@ -172,6 +180,174 @@ function isValueObjectField(
172
180
  return 'valueObjectName' in field;
173
181
  }
174
182
 
183
+ /**
184
+ * Resolves a deferred entity-ref column descriptor (e.g. a `pg.enum(handle)`
185
+ * column) against the field's now-known owning namespace: attaches the
186
+ * storage `valueSet` ref the collected entity's derived value-set is stored
187
+ * under. `nativeType` / `typeParams.typeName` stay bare here — schema
188
+ * qualification (e.g. `auth.aal_level`) is a target concern applied in the
189
+ * next step, `qualifyColumnDescriptor`. A descriptor with no `entityRef` (the
190
+ * ordinary case) passes through unchanged.
191
+ */
192
+ function resolveEntityRefDescriptor(
193
+ descriptor: ColumnTypeDescriptor,
194
+ namespaceId: string,
195
+ ): ColumnTypeDescriptor {
196
+ const entityRef = descriptor.entityRef;
197
+ if (entityRef === undefined) return descriptor;
198
+
199
+ return {
200
+ ...descriptor,
201
+ valueSet: {
202
+ plane: 'storage',
203
+ entityKind: 'valueSet',
204
+ namespaceId,
205
+ entityName: entityRef.entityName,
206
+ },
207
+ };
208
+ }
209
+
210
+ /**
211
+ * A target's contract-construction-time column-type qualifier, contributed
212
+ * through `target.authoring.qualifyColumnType`. Given a column's bare type
213
+ * info and its owning `namespaceId`, it returns the type info the target's
214
+ * schema semantics require (e.g. Postgres schema-qualifies a native-enum
215
+ * column's type name to `auth.aal_level`). The dispatch keys off the codec
216
+ * id, so every codec — including ones needing no change — is passed through
217
+ * and the caller stays codec-blind. Targets without the hook leave every
218
+ * column bare.
219
+ */
220
+ type ColumnTypeQualifier = (
221
+ input: {
222
+ readonly codecId: string;
223
+ readonly nativeType: string;
224
+ readonly typeParams?: Record<string, unknown>;
225
+ },
226
+ namespaceId: string,
227
+ ) => { readonly nativeType: string; readonly typeParams?: Record<string, unknown> };
228
+
229
+ /**
230
+ * Structural check for a target that contributes a `qualifyColumnType` hook
231
+ * on its authoring contributions. Duck-typed (mirroring
232
+ * `contract-psl`'s `hasColumnFromEntityHook`) so the SQL family stays blind
233
+ * to the target's qualification logic and no framework/family interface has
234
+ * to name the hook.
235
+ */
236
+ function hasColumnTypeQualifier(
237
+ authoring: AuthoringContributions,
238
+ ): authoring is AuthoringContributions & { readonly qualifyColumnType: ColumnTypeQualifier } {
239
+ return 'qualifyColumnType' in authoring && typeof authoring.qualifyColumnType === 'function';
240
+ }
241
+
242
+ function resolveColumnTypeQualifier(
243
+ target: ContractDefinition['target'],
244
+ ): ColumnTypeQualifier | undefined {
245
+ const authoring = target.authoring;
246
+ if (authoring === undefined) return undefined;
247
+ return hasColumnTypeQualifier(authoring) ? authoring.qualifyColumnType : undefined;
248
+ }
249
+
250
+ /**
251
+ * Applies the target's `qualifyColumnType` hook to a scalar column descriptor
252
+ * at construction, so the storage column and the domain field (which derives
253
+ * its `type.typeParams` from the storage column) are both built already
254
+ * qualified in a single pass. A descriptor whose codec the target leaves
255
+ * unchanged passes through untouched.
256
+ */
257
+ function qualifyColumnDescriptor(
258
+ descriptor: ColumnTypeDescriptor,
259
+ namespaceId: string,
260
+ qualify: ColumnTypeQualifier | undefined,
261
+ ): ColumnTypeDescriptor {
262
+ if (qualify === undefined) return descriptor;
263
+ const qualified = qualify(
264
+ {
265
+ codecId: descriptor.codecId,
266
+ nativeType: descriptor.nativeType,
267
+ ...ifDefined('typeParams', descriptor.typeParams),
268
+ },
269
+ namespaceId,
270
+ );
271
+ if (
272
+ qualified.nativeType === descriptor.nativeType &&
273
+ qualified.typeParams === descriptor.typeParams
274
+ ) {
275
+ return descriptor;
276
+ }
277
+ return {
278
+ ...descriptor,
279
+ nativeType: qualified.nativeType,
280
+ ...ifDefined('typeParams', qualified.typeParams),
281
+ };
282
+ }
283
+
284
+ type CollectedPackEntities = Record<string, Record<string, Record<string, unknown>>>;
285
+
286
+ /**
287
+ * Records a deferred column's entity-ref into the namespace-scoped collection
288
+ * accumulator, keyed the same way author-declared `packEntities` are
289
+ * (`namespaceId → entityKind → entityName`) — folded into the same namespace
290
+ * assembly `derivePackEntityValueSets`/`entries.<kind>` step, so a collected
291
+ * entity gets its value-set the same way an author-declared one does.
292
+ *
293
+ * The same handle reused by many columns in one namespace is normal (a native
294
+ * enum type backs any number of columns) and records the identical entity once.
295
+ * Two *different* entity instances sharing a name+kind in one namespace is a
296
+ * name collision — the emitted `entries.valueSet.<name>` could only reflect one
297
+ * of them, silently mismatching the other column's type/cast. PSL hard-errors
298
+ * on the equivalent (`PSL_DUPLICATE_DECLARATION`); the TS path rejects it too.
299
+ */
300
+ function collectPackEntityFromColumn(
301
+ collected: CollectedPackEntities,
302
+ namespaceId: string,
303
+ entityRef: NonNullable<ColumnTypeDescriptor['entityRef']>,
304
+ ): void {
305
+ const forNs = collected[namespaceId] ?? {};
306
+ const forKind = forNs[entityRef.entityKind] ?? {};
307
+ const existing = forKind[entityRef.entityName];
308
+ if (existing !== undefined && existing !== entityRef.entity) {
309
+ throw new Error(
310
+ `buildSqlContractFromDefinition: two different "${entityRef.entityKind}" entities named "${entityRef.entityName}" in namespace "${namespaceId}" — pack-entity names must be unique per namespace.`,
311
+ );
312
+ }
313
+ forKind[entityRef.entityName] = entityRef.entity;
314
+ forNs[entityRef.entityKind] = forKind;
315
+ collected[namespaceId] = forNs;
316
+ }
317
+
318
+ /**
319
+ * Merges the author-declared `packEntities` for one namespace with the entities
320
+ * collected from that namespace's deferred entity-ref columns. A collected
321
+ * entity that shadows a *different* declared entity of the same kind+name (or
322
+ * vice-versa) is the same name-collision bug `collectPackEntityFromColumn`
323
+ * guards against across columns, so it is rejected the same way — by entity
324
+ * identity, so the same handle declared and used by a column does not throw.
325
+ */
326
+ function mergeCollectedPackEntities(
327
+ namespaceId: string,
328
+ declared: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
329
+ collected: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
330
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined {
331
+ if (declared === undefined) return collected;
332
+ if (collected === undefined) return declared;
333
+ const kinds = new Set([...Object.keys(declared), ...Object.keys(collected)]);
334
+ const result: Record<string, Readonly<Record<string, unknown>>> = {};
335
+ for (const kind of kinds) {
336
+ const declaredForKind = declared[kind];
337
+ const collectedForKind = collected[kind];
338
+ for (const [name, entity] of Object.entries(collectedForKind ?? {})) {
339
+ const existing = declaredForKind?.[name];
340
+ if (existing !== undefined && existing !== entity) {
341
+ throw new Error(
342
+ `buildSqlContractFromDefinition: two different "${kind}" entities named "${name}" in namespace "${namespaceId}" — a collected pack entity conflicts with an author-declared one; pack-entity names must be unique per namespace.`,
343
+ );
344
+ }
345
+ }
346
+ result[kind] = { ...declaredForKind, ...collectedForKind };
347
+ }
348
+ return result;
349
+ }
350
+
175
351
  const JSONB_CODEC_ID = 'pg/jsonb@1';
176
352
  const JSONB_NATIVE_TYPE = 'jsonb';
177
353
 
@@ -310,9 +486,122 @@ function collectStorageNamespaceCoordinateIds(definition: ContractDefinition): S
310
486
  ids.add(model.namespaceId);
311
487
  }
312
488
  }
489
+ for (const id of Object.keys(definition.packEntities ?? {})) {
490
+ if (id.length > 0) {
491
+ ids.add(id);
492
+ }
493
+ }
313
494
  return ids;
314
495
  }
315
496
 
497
+ /**
498
+ * Entry kinds the framework assembler itself manages (`table` from models,
499
+ * `valueSet` from `enums` and pack-entity value-set derivation). An
500
+ * author-declared pack entity claiming one of these would silently clobber
501
+ * or be clobbered by the managed slot, so it is rejected outright.
502
+ */
503
+ const MANAGED_ENTRY_KINDS = new Set([tableEntityKind.kind, valueSetEntityKind.kind]);
504
+
505
+ function assertNoManagedPackEntityKinds(
506
+ namespaceId: string,
507
+ packEntitiesForNs: Readonly<Record<string, unknown>> | undefined,
508
+ ): void {
509
+ if (packEntitiesForNs === undefined) return;
510
+ for (const kind of Object.keys(packEntitiesForNs)) {
511
+ if (MANAGED_ENTRY_KINDS.has(kind)) {
512
+ throw new Error(
513
+ `buildSqlContractFromDefinition: packEntities in namespace "${namespaceId}" declares entry kind "${kind}", which is managed by the framework (table/valueSet) and cannot be supplied via packEntities.`,
514
+ );
515
+ }
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Walks the flat `entityTypes` namespace tree contributed by the target pack
521
+ * and every extension pack, indexing descriptors by their `discriminator` —
522
+ * the same string a pack entity's entries-map key (`entries.<kind>`) uses.
523
+ * Mirrors `contract-psl`'s `buildEntityTypesByDiscriminator`, recomposed here
524
+ * from the packs `ContractDefinition` already carries (`target` +
525
+ * `extensionPacks`) since the TS assembler has no single pre-merged
526
+ * `AuthoringContributions` input to read the way the PSL interpreter does.
527
+ */
528
+ function collectEntityTypeDescriptorsByDiscriminator(
529
+ definition: ContractDefinition,
530
+ ): ReadonlyMap<string, AuthoringEntityTypeDescriptor> {
531
+ const result = new Map<string, AuthoringEntityTypeDescriptor>();
532
+ const walk = (namespace: AuthoringEntityTypeNamespace): void => {
533
+ for (const value of Object.values(namespace)) {
534
+ if (isAuthoringEntityTypeDescriptor(value)) {
535
+ result.set(value.discriminator, value);
536
+ } else {
537
+ walk(value);
538
+ }
539
+ }
540
+ };
541
+ const components = [definition.target, ...Object.values(definition.extensionPacks ?? {})];
542
+ for (const component of components) {
543
+ const entityTypes = component.authoring?.entityTypes;
544
+ if (entityTypes !== undefined) {
545
+ walk(entityTypes);
546
+ }
547
+ }
548
+ return result;
549
+ }
550
+
551
+ /**
552
+ * Derives value-sets for every pack entity declared in one namespace,
553
+ * reusing the same `SqlValueSetDerivingEntityTypeOutput.deriveValueSet` hook
554
+ * `contract-psl`'s `lowerExtensionBlocksForNamespace` folds into
555
+ * `entries.valueSet` on the PSL path — so a TS-attached entity (e.g. a
556
+ * native enum) gets its value-set the same way. Entity kinds with no
557
+ * registered descriptor, or whose descriptor output doesn't derive a
558
+ * value-set, contribute nothing.
559
+ */
560
+ function derivePackEntityValueSets(
561
+ packEntitiesForNs: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
562
+ entityTypesByDiscriminator: ReadonlyMap<string, AuthoringEntityTypeDescriptor>,
563
+ ): Record<string, StorageValueSetInput> | undefined {
564
+ if (packEntitiesForNs === undefined) return undefined;
565
+ let result: Record<string, StorageValueSetInput> | undefined;
566
+ for (const [kind, entitiesByName] of Object.entries(packEntitiesForNs)) {
567
+ const descriptor = entityTypesByDiscriminator.get(kind);
568
+ if (descriptor === undefined) continue;
569
+ for (const [name, entity] of Object.entries(entitiesByName)) {
570
+ const derivedValueSet = deriveValueSetFromEntity(descriptor.output, entity);
571
+ if (derivedValueSet === undefined) continue;
572
+ result ??= {};
573
+ result[name] = derivedValueSet;
574
+ }
575
+ }
576
+ return result;
577
+ }
578
+
579
+ /**
580
+ * Merges a namespace's `enumType()`-derived value-sets with its pack-entity-
581
+ * derived value-sets. Both land in the same `entries.valueSet[name]` slot —
582
+ * which drives value-set → codec typing and the domain-enum CHECK — so a
583
+ * same-named entry in both would let one silently overwrite the other and
584
+ * corrupt whichever column resolves against it. The same collision class the
585
+ * `mergeCollectedPackEntities` guard rejects; the PSL path already hard-errors
586
+ * on the equivalent (`interpretPslDocumentToSqlContract`). Reject it here too.
587
+ */
588
+ function mergeNamespaceValueSets(
589
+ namespaceId: string,
590
+ enumValueSets: Record<string, StorageValueSetInput> | undefined,
591
+ packValueSets: Record<string, StorageValueSetInput> | undefined,
592
+ ): Record<string, StorageValueSetInput> {
593
+ if (enumValueSets !== undefined && packValueSets !== undefined) {
594
+ for (const name of Object.keys(packValueSets)) {
595
+ if (Object.hasOwn(enumValueSets, name)) {
596
+ throw new Error(
597
+ `buildSqlContractFromDefinition: value-set "${name}" in namespace "${namespaceId}" is derived from both an enum and a pack entity — names must be unique per namespace.`,
598
+ );
599
+ }
600
+ }
601
+ }
602
+ return { ...enumValueSets, ...packValueSets };
603
+ }
604
+
316
605
  function ensureUnboundNamespaceSlot(
317
606
  namespaces: SqlStorageInput['namespaces'],
318
607
  createNamespace: ContractDefinition['createNamespace'],
@@ -337,6 +626,7 @@ export function buildSqlContractFromDefinition(
337
626
  ): Contract<SqlStorage> {
338
627
  const target = definition.target.targetId;
339
628
  const defaultNamespaceId = definition.target.defaultNamespaceId;
629
+ const qualifyColumnType = resolveColumnTypeQualifier(definition.target);
340
630
  const targetFamily = 'sql';
341
631
  const resolveNamespaceId = (m: ModelNode): string =>
342
632
  m.namespaceId !== undefined && m.namespaceId.length > 0 ? m.namespaceId : defaultNamespaceId;
@@ -355,6 +645,7 @@ export function buildSqlContractFromDefinition(
355
645
  const modelNameToNamespaceId = new Map<string, string>();
356
646
  const executionDefaults: ExecutionMutationDefault[] = [];
357
647
  const modelsByNamespace: Record<string, Record<string, ContractModel>> = {};
648
+ const collectedPackEntities: CollectedPackEntities = {};
358
649
  const rootEntries: Array<{
359
650
  readonly tableName: string;
360
651
  readonly namespaceId: string;
@@ -427,7 +718,36 @@ export function buildSqlContractFromDefinition(
427
718
  }
428
719
  : undefined;
429
720
 
430
- const column = buildStorageColumn(field, storageValueSetRef, codecLookup);
721
+ // A field authored through a deferred entity-ref column helper (e.g.
722
+ // `pg.enum(handle)`) carries `descriptor.entityRef`: the referenced
723
+ // entity is collected into `collectedPackEntities` (folded into the
724
+ // same `entries.<kind>` + `entries.valueSet` assembly an author-declared
725
+ // `packEntities` entry goes through) and the descriptor is resolved
726
+ // against this field's now-known `namespaceId` — the builder call that
727
+ // produced it ran before the enclosing model associated one. The
728
+ // descriptor is then handed to the target's `qualifyColumnType` hook,
729
+ // which schema-qualifies a native-enum column's type name for its
730
+ // namespace. Keying off the codec id (inside the hook) catches both the
731
+ // TS `pg.enum(handle)` path (via `entityRef`) and the PSL `pg.enum(Ref)`
732
+ // path (resolved inline in the interpreter, no `entityRef`). Because the
733
+ // storage column is built from this qualified descriptor and the domain
734
+ // field derives its `type.typeParams` from that column, both come out
735
+ // qualified in this single pass.
736
+ let resolvedField: FieldNode | ValueObjectFieldNode = field;
737
+ if (!isValueObjectField(field)) {
738
+ let descriptor = field.descriptor;
739
+ const entityRef = descriptor.entityRef;
740
+ if (entityRef !== undefined) {
741
+ collectPackEntityFromColumn(collectedPackEntities, namespaceId, entityRef);
742
+ descriptor = resolveEntityRefDescriptor(descriptor, namespaceId);
743
+ }
744
+ descriptor = qualifyColumnDescriptor(descriptor, namespaceId, qualifyColumnType);
745
+ if (descriptor !== field.descriptor) {
746
+ resolvedField = { ...field, descriptor };
747
+ }
748
+ }
749
+
750
+ const column = buildStorageColumn(resolvedField, storageValueSetRef, codecLookup);
431
751
  columns[field.columnName] = column;
432
752
  fieldToColumn[field.fieldName] = field.columnName;
433
753
 
@@ -749,13 +1069,31 @@ export function buildSqlContractFromDefinition(
749
1069
  }
750
1070
 
751
1071
  const { createNamespace } = definition;
1072
+ const entityTypesByDiscriminator = collectEntityTypeDescriptorsByDiscriminator(definition);
752
1073
  const namespaces: SqlStorageInput['namespaces'] = Object.fromEntries(
753
1074
  [...namespaceCoordinateIds].sort().map((id) => {
754
- const valueSetEntries = storageValueSetsByNs[id];
1075
+ const packEntitiesForNs = mergeCollectedPackEntities(
1076
+ id,
1077
+ definition.packEntities?.[id],
1078
+ collectedPackEntities[id],
1079
+ );
1080
+ assertNoManagedPackEntityKinds(id, packEntitiesForNs);
1081
+
1082
+ const enumValueSetEntries = storageValueSetsByNs[id];
1083
+ const packValueSetEntries = derivePackEntityValueSets(
1084
+ packEntitiesForNs,
1085
+ entityTypesByDiscriminator,
1086
+ );
1087
+ const valueSetEntries =
1088
+ enumValueSetEntries !== undefined || packValueSetEntries !== undefined
1089
+ ? mergeNamespaceValueSets(id, enumValueSetEntries, packValueSetEntries)
1090
+ : undefined;
1091
+
755
1092
  const nsInput: SqlNamespaceInput = {
756
1093
  id,
757
1094
  entries: {
758
1095
  table: tablesByNamespace[id] ?? {},
1096
+ ...packEntitiesForNs,
759
1097
  ...(valueSetEntries !== undefined && Object.keys(valueSetEntries).length > 0
760
1098
  ? { valueSet: valueSetEntries }
761
1099
  : {}),
@@ -18,6 +18,7 @@ import {
18
18
  type ComposedAuthoringHelpers,
19
19
  createComposedAuthoringHelpers,
20
20
  } from './composed-authoring-helpers';
21
+ import type { PackEntitiesInput } from './contract-definition';
21
22
  import {
22
23
  type ContractInput,
23
24
  type ContractModelBuilder,
@@ -76,6 +77,7 @@ type ContractDefinition<
76
77
  readonly models?: Models;
77
78
  readonly codecLookup?: CodecLookup;
78
79
  readonly enums?: Enums;
80
+ readonly packEntities?: PackEntitiesInput;
79
81
  };
80
82
 
81
83
  type ContractScaffold<
@@ -101,6 +103,7 @@ type ContractScaffold<
101
103
  readonly models?: never;
102
104
  readonly codecLookup?: CodecLookup;
103
105
  readonly enums?: Enums;
106
+ readonly packEntities?: PackEntitiesInput;
104
107
  };
105
108
 
106
109
  type ContractFactory<
@@ -114,6 +117,7 @@ type ContractFactory<
114
117
  readonly types?: Types;
115
118
  readonly models?: Models;
116
119
  readonly enums?: Enums;
120
+ readonly packEntities?: PackEntitiesInput;
117
121
  };
118
122
 
119
123
  function validateTargetPackRef(
@@ -322,6 +326,7 @@ type BoundDefinitionInput<
322
326
  readonly models?: Models;
323
327
  readonly codecLookup?: CodecLookup;
324
328
  readonly enums?: Record<string, EnumTypeHandle>;
329
+ readonly packEntities?: PackEntitiesInput;
325
330
  };
326
331
 
327
332
  // A bare `Record<string, EnumTypeHandle>` (no literal keys) is the widened
@@ -345,6 +350,60 @@ type WithFamilyTarget<
345
350
  T extends TargetPackRef<'sql', string>,
346
351
  > = Input & { readonly family: F; readonly target: T };
347
352
 
353
+ // Deep-merges packEntities authored on the scaffold definition with those
354
+ // returned from the factory callback — three levels deep (namespace, kind,
355
+ // name), unlike `enums`' flat shallow merge, since a factory-declared
356
+ // namespace/kind must not silently drop a scaffold-declared sibling entry
357
+ // under the same namespace or kind. A same-name key present on both sides is
358
+ // only merged when it is the identical entity instance; two *different*
359
+ // entities of the same namespace/kind/name is a collision (the emitted
360
+ // entry could reflect only one), rejected here the same way
361
+ // `mergeCollectedPackEntities` in `build-contract.ts` rejects it.
362
+ function mergePackEntityNames(
363
+ namespaceId: string,
364
+ kind: string,
365
+ a: Readonly<Record<string, unknown>> | undefined,
366
+ b: Readonly<Record<string, unknown>> | undefined,
367
+ ): Readonly<Record<string, unknown>> {
368
+ if (a !== undefined && b !== undefined) {
369
+ for (const [name, entity] of Object.entries(b)) {
370
+ const existing = a[name];
371
+ if (existing !== undefined && existing !== entity) {
372
+ throw new Error(
373
+ `defineContract: two different "${kind}" entities named "${name}" in namespace "${namespaceId}" — a factory-returned pack entity conflicts with a scaffold-declared one; pack-entity names must be unique per namespace.`,
374
+ );
375
+ }
376
+ }
377
+ }
378
+ return { ...(a ?? {}), ...(b ?? {}) };
379
+ }
380
+
381
+ function mergePackEntityKinds(
382
+ namespaceId: string,
383
+ a: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
384
+ b: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined,
385
+ ): Readonly<Record<string, Readonly<Record<string, unknown>>>> {
386
+ const kinds = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
387
+ const result: Record<string, Readonly<Record<string, unknown>>> = {};
388
+ for (const kind of kinds) {
389
+ result[kind] = mergePackEntityNames(namespaceId, kind, a?.[kind], b?.[kind]);
390
+ }
391
+ return result;
392
+ }
393
+
394
+ function mergePackEntities(
395
+ a: PackEntitiesInput | undefined,
396
+ b: PackEntitiesInput | undefined,
397
+ ): PackEntitiesInput | undefined {
398
+ if (a === undefined && b === undefined) return undefined;
399
+ const namespaces = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
400
+ const result: Record<string, Readonly<Record<string, Readonly<Record<string, unknown>>>>> = {};
401
+ for (const namespaceId of namespaces) {
402
+ result[namespaceId] = mergePackEntityKinds(namespaceId, a?.[namespaceId], b?.[namespaceId]);
403
+ }
404
+ return result;
405
+ }
406
+
348
407
  /**
349
408
  * Shared builder that assembles a SqlContract with pre-bound family and target.
350
409
  * Extension wrappers keep their own public overloads and delegate their impl body here;
@@ -390,6 +449,7 @@ export function buildBoundContract<
390
449
  readonly types?: Record<string, StorageTypeInstance>;
391
450
  readonly models?: Record<string, ModelLike>;
392
451
  readonly enums?: Record<string, EnumTypeHandle>;
452
+ readonly packEntities?: PackEntitiesInput;
393
453
  },
394
454
  >(
395
455
  family: F,
@@ -415,6 +475,7 @@ export function buildBoundContract(
415
475
  readonly types?: Record<string, StorageTypeInstance>;
416
476
  readonly models?: Record<string, ModelLike>;
417
477
  readonly enums?: Record<string, EnumTypeHandle>;
478
+ readonly packEntities?: PackEntitiesInput;
418
479
  })
419
480
  | undefined,
420
481
  ) {
@@ -429,11 +490,13 @@ export function buildBoundContract(
429
490
  }),
430
491
  );
431
492
  const mergedEnums = { ...(definition.enums ?? {}), ...built.enums };
493
+ const mergedPackEntities = mergePackEntities(definition.packEntities, built.packEntities);
432
494
  return buildContractFromDsl({
433
495
  ...full,
434
496
  ...ifDefined('types', built.types),
435
497
  ...ifDefined('models', built.models),
436
498
  ...ifDefined('enums', Object.keys(mergedEnums).length > 0 ? mergedEnums : undefined),
499
+ ...ifDefined('packEntities', mergedPackEntities),
437
500
  });
438
501
  }
439
502
 
@@ -16,6 +16,20 @@ import type { EnumTypeHandle } from './enum-type';
16
16
 
17
17
  export type { ExecutionMutationDefaultPhases };
18
18
 
19
+ /**
20
+ * An author-declared, namespace-scoped pack entity attachment: namespace id →
21
+ * entity kind (the discriminator the target/extension pack registered its
22
+ * `AuthoringContributions.entityTypes` descriptor under, e.g. `native_enum`)
23
+ * → entity name → the lowered entity instance. Generic on purpose — neither
24
+ * the framework nor `contract-ts` names a specific entity kind here; the
25
+ * shape mirrors `SqlNamespaceInput.entries` (`entries.<kind>[name]`), just
26
+ * namespace-nested so an author can target any declared namespace (default
27
+ * or named), not only the contract's default namespace.
28
+ */
29
+ export type PackEntitiesInput = Readonly<
30
+ Record<string, Readonly<Record<string, Readonly<Record<string, unknown>>>>>
31
+ >;
32
+
19
33
  export interface FieldNode {
20
34
  readonly fieldName: string;
21
35
  readonly columnName: string;
@@ -185,4 +199,13 @@ export interface ContractDefinition {
185
199
  * default namespace.
186
200
  */
187
201
  readonly enums?: Record<string, EnumTypeHandle>;
202
+ /**
203
+ * Author-declared pack entities, keyed by namespace then entity kind then
204
+ * name. Each entity lowers into `storage.namespaces[ns].entries.<kind>`;
205
+ * when the registered entity-type descriptor's factory output implements
206
+ * the `SqlValueSetDerivingEntityTypeOutput.deriveValueSet` hook, the
207
+ * derived value-set also folds into `entries.valueSet`, mirroring how
208
+ * `enums` flows there.
209
+ */
210
+ readonly packEntities?: PackEntitiesInput;
188
211
  }
@@ -1583,6 +1583,15 @@ export type ContractInput<
1583
1583
  * default namespace. Fields reference the enum via `field.namedType(handle)`.
1584
1584
  */
1585
1585
  readonly enums?: Record<string, import('./enum-type').EnumTypeHandle>;
1586
+ /**
1587
+ * Author-declared pack entities, keyed by namespace id then entity kind
1588
+ * then name — e.g. `{ auth: { native_enum: { AalLevel: <entity> } } }`.
1589
+ * Each entity lowers into `storage.namespaces[ns].entries.<kind>`; when its
1590
+ * registered entity-type descriptor derives a value-set, that also folds
1591
+ * into `entries.valueSet`, mirroring how `enums` flows there. Generic on
1592
+ * purpose — neither this type nor the assembler names a specific kind.
1593
+ */
1594
+ readonly packEntities?: import('./contract-definition').PackEntitiesInput;
1586
1595
  };
1587
1596
 
1588
1597
  export function model<
@@ -882,6 +882,9 @@ export function buildContractDefinition(definition: ContractInput): ContractDefi
882
882
  ...(definition.enums && Object.keys(definition.enums).length > 0
883
883
  ? { enums: definition.enums }
884
884
  : {}),
885
+ ...(definition.packEntities && Object.keys(definition.packEntities).length > 0
886
+ ? { packEntities: definition.packEntities }
887
+ : {}),
885
888
  models,
886
889
  };
887
890
  }