@prisma-next/family-sql 0.11.0 → 0.12.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.
Files changed (46) hide show
  1. package/dist/authoring-type-constructors-F4JpCJl7.mjs.map +1 -1
  2. package/dist/control-adapter.d.mts +11 -4
  3. package/dist/control-adapter.d.mts.map +1 -1
  4. package/dist/control.d.mts +25 -2
  5. package/dist/control.d.mts.map +1 -1
  6. package/dist/control.mjs +46 -26
  7. package/dist/control.mjs.map +1 -1
  8. package/dist/ir.d.mts +4 -1
  9. package/dist/ir.d.mts.map +1 -1
  10. package/dist/ir.mjs +1 -1
  11. package/dist/ir.mjs.map +1 -1
  12. package/dist/migration.d.mts +1 -1
  13. package/dist/migration.d.mts.map +1 -1
  14. package/dist/migration.mjs.map +1 -1
  15. package/dist/pack.mjs.map +1 -1
  16. package/dist/runtime.d.mts.map +1 -1
  17. package/dist/runtime.mjs +1 -1
  18. package/dist/runtime.mjs.map +1 -1
  19. package/dist/schema-verify.d.mts +1 -1
  20. package/dist/schema-verify.d.mts.map +1 -1
  21. package/dist/schema-verify.mjs +1 -1
  22. package/dist/{sql-contract-serializer-COnYiewe.mjs → sql-contract-serializer-8axtK4lg.mjs} +31 -13
  23. package/dist/sql-contract-serializer-8axtK4lg.mjs.map +1 -0
  24. package/dist/{timestamp-now-generator-r7BP5n3l.mjs → timestamp-now-generator-BkjCQIde.mjs} +2 -1
  25. package/dist/{timestamp-now-generator-r7BP5n3l.mjs.map → timestamp-now-generator-BkjCQIde.mjs.map} +1 -1
  26. package/dist/{types-hQoMXr54.d.mts → types-CeeCStqw.d.mts} +22 -41
  27. package/dist/types-CeeCStqw.d.mts.map +1 -0
  28. package/dist/verify-Crewz6hG.mjs.map +1 -1
  29. package/dist/{verify-sql-schema-Bfvz07Ik.d.mts → verify-sql-schema-CN7pPoTC.d.mts} +2 -2
  30. package/dist/verify-sql-schema-CN7pPoTC.d.mts.map +1 -0
  31. package/dist/{verify-sql-schema-Bj4Wqe2c.mjs → verify-sql-schema-CYLsGCFO.mjs} +52 -38
  32. package/dist/verify-sql-schema-CYLsGCFO.mjs.map +1 -0
  33. package/dist/verify.d.mts.map +1 -1
  34. package/package.json +32 -21
  35. package/src/core/control-adapter.ts +16 -2
  36. package/src/core/control-instance.ts +6 -1
  37. package/src/core/ir/sql-contract-serializer-base.ts +67 -33
  38. package/src/core/migrations/contract-to-schema-ir.ts +86 -37
  39. package/src/core/migrations/types.ts +21 -45
  40. package/src/core/schema-verify/verify-sql-schema.ts +92 -43
  41. package/src/core/timestamp-now-generator.ts +1 -0
  42. package/src/exports/control.ts +1 -3
  43. package/dist/sql-contract-serializer-COnYiewe.mjs.map +0 -1
  44. package/dist/types-hQoMXr54.d.mts.map +0 -1
  45. package/dist/verify-sql-schema-Bfvz07Ik.d.mts.map +0 -1
  46. package/dist/verify-sql-schema-Bj4Wqe2c.mjs.map +0 -1
@@ -1,11 +1,19 @@
1
1
  import { ContractValidationError } from '@prisma-next/contract/contract-validation-error';
2
2
  import type { Contract } from '@prisma-next/contract/types';
3
3
  import type { ContractSerializer } from '@prisma-next/framework-components/control';
4
- import { type Namespace, NamespaceBase } from '@prisma-next/framework-components/ir';
5
4
  import {
5
+ type Namespace,
6
+ NamespaceBase,
7
+ UNBOUND_NAMESPACE_ID,
8
+ } from '@prisma-next/framework-components/ir';
9
+ import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks';
10
+ import {
11
+ buildSqlNamespace,
6
12
  type SqlNamespaceTablesInput,
7
13
  SqlStorage,
14
+ type SqlStorageInput,
8
15
  type SqlStorageTypeEntry,
16
+ SqlUnboundNamespace,
9
17
  StorageTable,
10
18
  type StorageTableInput,
11
19
  } from '@prisma-next/sql-contract/types';
@@ -13,12 +21,17 @@ import {
13
21
  createSqlContractSchema,
14
22
  validateSqlContractFully,
15
23
  } from '@prisma-next/sql-contract/validators';
24
+ import { blindCast } from '@prisma-next/utils/casts';
25
+ import { ifDefined } from '@prisma-next/utils/defined';
16
26
  import type { JsonObject } from '@prisma-next/utils/json';
17
27
  import { type Type, type } from 'arktype';
18
28
 
19
29
  const NamespaceRawSchema = type({
20
30
  id: 'string',
21
31
  'kind?': 'string',
32
+ // Undeclared keys (`tables`, `enum`, and any pack-contributed slot maps)
33
+ // intentionally pass through; the slot loop below iterates them by name.
34
+ '+': 'ignore',
22
35
  });
23
36
 
24
37
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
@@ -79,6 +92,10 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
79
92
  return contract as unknown as JsonObject;
80
93
  }
81
94
 
95
+ shouldPreserveEmpty = sqlContractCanonicalizationHooks.shouldPreserveEmpty;
96
+
97
+ sortStorage = sqlContractCanonicalizationHooks.sortStorage;
98
+
82
99
  protected parseSqlContractStructure(json: unknown): Contract<SqlStorage> {
83
100
  return validateSqlContractFully<Contract<SqlStorage>>(
84
101
  json,
@@ -99,27 +116,51 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
99
116
  : undefined;
100
117
 
101
118
  const rawNamespaces = validated.storage.namespaces;
102
- const hydratedNamespaces =
103
- rawNamespaces !== undefined ? this.hydrateSqlNamespaceMap(rawNamespaces) : undefined;
119
+ if (rawNamespaces === undefined) {
120
+ throw new ContractValidationError(
121
+ 'Contract storage.namespaces is required after structural validation',
122
+ 'structural',
123
+ );
124
+ }
125
+ const hydratedNamespaces = this.hydrateSqlNamespaceMap(rawNamespaces);
126
+ // Compatibility shim: production code that addresses `__unbound__` for table
127
+ // metadata lookups (collection-contract, query-plan-mutations, model-accessor,
128
+ // query-plan-meta, where-binding) uses optional chaining and tolerates absence,
129
+ // but runtime-qualification (TML-2605) has not yet landed cross-namespace table
130
+ // routing. Injecting the empty singleton here keeps helpers that augment the
131
+ // deserialized JSON (e.g. buildMixedPolyContract) working by providing a slot to
132
+ // write into. Once runtime-qualification routes table lookups by namespace, this
133
+ // shim should be removed.
134
+ const unbound = hydratedNamespaces[UNBOUND_NAMESPACE_ID] ?? SqlUnboundNamespace.instance;
104
135
 
105
136
  return {
106
137
  ...validated,
107
138
  storage: new SqlStorage({
108
139
  storageHash: validated.storage.storageHash,
109
- ...(hydratedTypes !== undefined ? { types: hydratedTypes } : {}),
110
- ...(hydratedNamespaces !== undefined ? { namespaces: hydratedNamespaces } : {}),
140
+ ...ifDefined('types', hydratedTypes),
141
+ // Cast narrows the result of hydrateSqlNamespaceMap from the wider
142
+ // framework `Namespace` to the SQL-family `SqlNamespace`.
143
+ namespaces: blindCast<
144
+ SqlStorageInput['namespaces'],
145
+ 'hydrated SQL namespaces are SqlNamespace instances (family hydration guarantees this)'
146
+ >({ ...hydratedNamespaces, [UNBOUND_NAMESPACE_ID]: unbound }),
111
147
  }),
112
148
  };
113
149
  }
114
150
 
115
151
  protected hydrateSqlNamespaceMap(
116
152
  namespaces: Readonly<Record<string, Namespace | Record<string, unknown>>>,
117
- ): Readonly<Record<string, Namespace | SqlNamespaceTablesInput>> {
153
+ ): Readonly<Record<string, Namespace>> {
118
154
  return Object.fromEntries(
119
- Object.entries(namespaces).map(([nsId, raw]) => [
120
- nsId,
121
- this.hydrateSqlNamespaceEntry(nsId, raw),
122
- ]),
155
+ Object.entries(namespaces).map(([nsId, namespaceEntryRaw]) => {
156
+ // Raw entries passed structural validation; hydrate materialises family IR class instances.
157
+ const namespaceHydrated = this.hydrateSqlNamespaceEntry(nsId, namespaceEntryRaw);
158
+ const namespaceMaterialised =
159
+ namespaceHydrated instanceof NamespaceBase
160
+ ? namespaceHydrated
161
+ : buildSqlNamespace(namespaceHydrated);
162
+ return [nsId, namespaceMaterialised];
163
+ }),
123
164
  );
124
165
  }
125
166
 
@@ -173,34 +214,27 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
173
214
  }
174
215
  }
175
216
 
176
- const typesRaw = rawRecord['types'];
177
- const hasUnhydratedPostgresEnumEntry =
178
- typesRaw !== undefined &&
179
- typeof typesRaw === 'object' &&
180
- typesRaw !== null &&
181
- Object.values(typesRaw as Record<string, unknown>).some(
182
- (entry) =>
183
- typeof entry === 'object' &&
184
- entry !== null &&
185
- (entry as { kind?: unknown }).kind === 'postgres-enum',
186
- );
187
- if (
188
- hasUnhydratedPostgresEnumEntry &&
189
- this.entityTypeRegistry.get('postgres-enum') === undefined
190
- ) {
191
- throw new ContractValidationError(
192
- 'Per-schema database types (e.g. postgres-enum) under storage.namespaces[..].types require PostgresContractSerializer.',
193
- 'structural',
194
- );
217
+ const enumRaw = rawRecord['enum'];
218
+ if (enumRaw !== undefined && typeof enumRaw === 'object' && enumRaw !== null) {
219
+ for (const entry of Object.values(enumRaw as Record<string, unknown>)) {
220
+ if (typeof entry !== 'object' || entry === null) continue;
221
+ const kind = (entry as { kind?: unknown }).kind;
222
+ if (typeof kind === 'string' && this.entityTypeRegistry.get(kind) === undefined) {
223
+ throw new ContractValidationError(
224
+ `Entry kind '${kind}' has no registered hydration factory.`,
225
+ 'structural',
226
+ );
227
+ }
228
+ }
195
229
  }
196
230
 
197
231
  const tables = (result['tables'] ?? {}) as Record<string, StorageTable>;
198
- const types = result['types'] as NonNullable<SqlNamespaceTablesInput['types']> | undefined;
232
+ const enumSlot = result['enum'] as NonNullable<SqlNamespaceTablesInput['enum']> | undefined;
199
233
  return {
200
- id,
234
+ ...result,
201
235
  tables,
202
- ...(types !== undefined ? { types } : {}),
203
- };
236
+ ...(enumSlot !== undefined ? { enum: enumSlot } : {}),
237
+ } as SqlNamespaceTablesInput;
204
238
  }
205
239
 
206
240
  protected hydrateStorageTypeEntry(entry: SqlStorageTypeEntry): SqlStorageTypeEntry {
@@ -1,5 +1,6 @@
1
1
  import type { ColumnDefault, Contract } from '@prisma-next/contract/types';
2
2
  import type { MigrationPlannerConflict } from '@prisma-next/framework-components/control';
3
+ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
3
4
  import {
4
5
  type ForeignKey,
5
6
  type Index,
@@ -52,6 +53,26 @@ export type NativeTypeExpander = (input: {
52
53
  */
53
54
  export type DefaultRenderer = (def: ColumnDefault, column: StorageColumn) => string;
54
55
 
56
+ /**
57
+ * Target-supplied callback that computes the schema-qualified annotation-map
58
+ * key for a namespace-scoped enum storage type.
59
+ *
60
+ * Enum lookups (`readExistingEnumValues`) are namespace/schema-qualified so two
61
+ * namespaces holding an enum with the same TypeScript name (and even the same
62
+ * native type) resolve to distinct live-database types. The *format* of that
63
+ * key — and the namespace → DDL-schema resolution it depends on — is a
64
+ * target-specific concern (Postgres schemas; SQLite/MySQL differ), so the
65
+ * target injects it here as data rather than the family layer importing a
66
+ * concrete `ddlSchemaName`/key implementation. This keeps the family layer
67
+ * target-agnostic (no `@prisma-next/target-*` dependency) while the projection
68
+ * still emits keys that match the target's read side exactly.
69
+ */
70
+ export type EnumStorageKeyResolver = (
71
+ storage: SqlStorage,
72
+ namespaceId: string,
73
+ nativeType: string,
74
+ ) => string;
75
+
55
76
  function convertColumn(
56
77
  name: string,
57
78
  column: StorageColumn,
@@ -152,6 +173,8 @@ function convertForeignKey(fk: ForeignKey): SqlForeignKeyIR {
152
173
  referencedSchema: fk.target.namespaceId,
153
174
  referencedColumns: fk.target.columns,
154
175
  ...ifDefined('name', fk.name),
176
+ ...ifDefined('onDelete', fk.onDelete),
177
+ ...ifDefined('onUpdate', fk.onUpdate),
155
178
  };
156
179
  }
157
180
 
@@ -263,6 +286,14 @@ export interface ContractToSchemaIROptions {
263
286
  readonly annotationNamespace: string;
264
287
  readonly expandNativeType?: NativeTypeExpander;
265
288
  readonly renderDefault?: DefaultRenderer;
289
+ /**
290
+ * Target-supplied resolver for namespace/schema-qualified enum annotation
291
+ * keys. When provided (Postgres), every namespace-scoped enum is keyed by the
292
+ * resolver's output so the projected `storageTypes` map matches the target's
293
+ * `readExistingEnumValues` lookup. Targets without namespace-qualified enum
294
+ * storage (SQLite) omit it; enums are absent there.
295
+ */
296
+ readonly resolveEnumStorageKey?: EnumStorageKeyResolver;
266
297
  }
267
298
 
268
299
  /**
@@ -296,9 +327,9 @@ export function contractToSchemaIR(
296
327
  ...((storage.types ?? {}) as ResolvedStorageTypes),
297
328
  };
298
329
  for (const ns of Object.values(storage.namespaces)) {
299
- const nsTypes = (ns as { types?: Record<string, PostgresEnumStorageEntry> }).types;
300
- if (nsTypes) {
301
- for (const [k, v] of Object.entries(nsTypes)) {
330
+ const nsEnums = (ns as { enum?: Record<string, PostgresEnumStorageEntry> }).enum;
331
+ if (nsEnums) {
332
+ for (const [k, v] of Object.entries(nsEnums)) {
302
333
  allTypes[k] = v;
303
334
  }
304
335
  }
@@ -328,7 +359,11 @@ export function contractToSchemaIR(
328
359
  }
329
360
  }
330
361
 
331
- const annotations = deriveAnnotations(storage, options.annotationNamespace);
362
+ const annotations = deriveAnnotations(
363
+ storage,
364
+ options.annotationNamespace,
365
+ options.resolveEnumStorageKey,
366
+ );
332
367
 
333
368
  return {
334
369
  tables,
@@ -336,47 +371,61 @@ export function contractToSchemaIR(
336
371
  };
337
372
  }
338
373
 
374
+ /**
375
+ * Normalises a native enum storage entry to the codec-typed annotation shape
376
+ * `{codecId, nativeType, typeParams}` the introspector writes and
377
+ * `readExistingEnumValues` reads (`existing.codecId` + `existing.typeParams.values`).
378
+ * Without this the projector would emit the raw `PostgresEnumStorageEntry`
379
+ * shape (top-level `values`, no `typeParams`) and the enum would read as new.
380
+ */
381
+ function normalizeEnumAnnotation(entry: PostgresEnumStorageEntry): StorageTypeInstance {
382
+ return toStorageTypeInstance({
383
+ codecId: entry.codecId,
384
+ nativeType: entry.nativeType,
385
+ typeParams: { values: entry.values },
386
+ });
387
+ }
388
+
339
389
  function deriveAnnotations(
340
390
  storage: SqlStorage,
341
391
  annotationNamespace: string,
392
+ resolveEnumStorageKey: EnumStorageKeyResolver | undefined,
342
393
  ): SqlAnnotations | undefined {
343
- const allTypes: Record<string, StorageTypeInstance | PostgresEnumStorageEntry> = {
344
- ...((storage.types ?? {}) as ResolvedStorageTypes),
345
- };
346
- for (const ns of Object.values(storage.namespaces)) {
347
- const nsTypes = (ns as { types?: Record<string, PostgresEnumStorageEntry> }).types;
348
- if (nsTypes) {
349
- for (const [k, v] of Object.entries(nsTypes)) {
350
- allTypes[k] = v;
351
- }
352
- }
353
- }
354
- const types = allTypes as ResolvedStorageTypes;
355
- if (Object.keys(types).length === 0) return undefined;
356
- // Re-key by nativeType, normalising every variant to the codec-typed
357
- // annotation shape `{codecId, nativeType, typeParams}` produced by the
358
- // adapter introspector (`introspectPostgresEnumTypes` writes that shape;
359
- // see also `enum-planning.ts § readExistingEnumValues`, which reads
360
- // `existing.codecId` + `existing.typeParams.values`). Without this
361
- // normalisation, the projector would emit the raw
362
- // `PostgresEnumStorageEntry` shape (top-level `values`, no `typeParams`)
363
- // and downstream Schema IR consumers that walk the codec-typed shape
364
- // would see enum entries as new (e.g. the planner emits a fresh
365
- // `CreateEnumTypeCall` instead of the rebuild recipe). Unknown future
366
- // kinds without `nativeType` are skipped rather than crashing.
367
- const byNativeType: Record<string, StorageTypeInstance> = {};
368
- for (const typeInstance of Object.values(types)) {
394
+ const storageTypes: Record<string, StorageTypeInstance> = {};
395
+
396
+ // Top-level `storage.types`: codec-typed entries (vector, decimal, …) keyed
397
+ // by bare `nativeType` (unchanged). Post-S1.B enums live in
398
+ // `namespaces[*].enum`, not here; a defensive top-level enum is still
399
+ // namespace/schema-qualified via the resolver under the unbound coordinate
400
+ // so it never collides on a bare name.
401
+ for (const typeInstance of Object.values((storage.types ?? {}) as ResolvedStorageTypes)) {
369
402
  if (isPostgresEnumStorageEntry(typeInstance)) {
370
- byNativeType[typeInstance.nativeType] = toStorageTypeInstance({
371
- codecId: typeInstance.codecId,
372
- nativeType: typeInstance.nativeType,
373
- typeParams: { values: typeInstance.values },
374
- });
403
+ const key = resolveEnumStorageKey
404
+ ? resolveEnumStorageKey(storage, UNBOUND_NAMESPACE_ID, typeInstance.nativeType)
405
+ : typeInstance.nativeType;
406
+ storageTypes[key] = normalizeEnumAnnotation(typeInstance);
375
407
  continue;
376
408
  }
377
409
  if (isStorageTypeInstance(typeInstance)) {
378
- byNativeType[typeInstance.nativeType] = typeInstance;
410
+ storageTypes[typeInstance.nativeType] = typeInstance;
379
411
  }
380
412
  }
381
- return { [annotationNamespace]: { storageTypes: byNativeType } };
413
+
414
+ // Namespace-scoped enums: schema-qualified compound key matching the target's
415
+ // `readExistingEnumValues` read side, so two namespaces sharing an enum name
416
+ // (or native type) resolve to distinct live-database types.
417
+ for (const [namespaceId, ns] of Object.entries(storage.namespaces)) {
418
+ const nsEnums = (ns as { enum?: Record<string, PostgresEnumStorageEntry> }).enum;
419
+ if (!nsEnums) continue;
420
+ for (const entry of Object.values(nsEnums)) {
421
+ if (!isPostgresEnumStorageEntry(entry)) continue;
422
+ const key = resolveEnumStorageKey
423
+ ? resolveEnumStorageKey(storage, namespaceId, entry.nativeType)
424
+ : entry.nativeType;
425
+ storageTypes[key] = normalizeEnumAnnotation(entry);
426
+ }
427
+ }
428
+
429
+ if (Object.keys(storageTypes).length === 0) return undefined;
430
+ return { [annotationNamespace]: { storageTypes } };
382
431
  }
@@ -15,7 +15,8 @@ import type {
15
15
  MigrationPlanOperation,
16
16
  MigrationRunnerExecutionChecks,
17
17
  MigrationRunnerFailure,
18
- MigrationRunnerSuccessValue,
18
+ MigrationRunnerPerSpaceSuccessValue,
19
+ MigrationRunnerResult,
19
20
  OperationContext,
20
21
  OpFactoryCall,
21
22
  SchemaIssue,
@@ -234,7 +235,7 @@ export interface SqlMigrationPlan<TTargetDetails> extends MigrationPlan {
234
235
  * pass the extension's space id. Required at every call site so the
235
236
  * type system surfaces every place that needs to thread the value
236
237
  * (rather than letting an `?? APP_SPACE_ID` fall-through silently
237
- * collapse multi-space markers onto the `'app'` row).
238
+ * collapse per-space markers onto the `'app'` row).
238
239
  *
239
240
  * @see specs/framework-mechanism.spec.md § 2.
240
241
  */
@@ -401,7 +402,7 @@ export interface SqlMigrationRunnerFailure extends MigrationRunnerFailure {
401
402
  readonly meta?: AnyRecord;
402
403
  }
403
404
 
404
- export interface SqlMigrationRunnerSuccessValue extends MigrationRunnerSuccessValue {}
405
+ export interface SqlMigrationRunnerSuccessValue extends MigrationRunnerPerSpaceSuccessValue {}
405
406
 
406
407
  export type SqlMigrationRunnerResult = Result<
407
408
  SqlMigrationRunnerSuccessValue,
@@ -410,22 +411,29 @@ export type SqlMigrationRunnerResult = Result<
410
411
 
411
412
  export interface SqlMigrationRunner<TTargetDetails> {
412
413
  /**
413
- * Apply a single migration plan, opening and managing its own
414
- * transaction (and any target-specific connection-level setup, e.g.
415
- * SQLite's `PRAGMA foreign_keys` toggle). Existing single-space
416
- * callers route through here.
414
+ * Apply one or more per-space migration plans, opening and managing the
415
+ * outer transaction (and any target-specific connection-level setup, e.g.
416
+ * SQLite's `PRAGMA foreign_keys` toggle). An apply that targets one space
417
+ * passes a one-element `perSpaceOptions` list.
418
+ *
419
+ * The caller orders the input list (typically via the aggregate planner's
420
+ * `applyOrder`: extensions alphabetical, then app). A failure on any space
421
+ * rolls back every space's writes.
422
+ *
423
+ * Each entry must reference the same `driver` as the top-level `driver`
424
+ * (the connection the outer transaction is open on).
417
425
  */
418
- execute(
419
- options: SqlMigrationRunnerExecuteOptions<TTargetDetails>,
420
- ): Promise<SqlMigrationRunnerResult>;
426
+ execute(options: {
427
+ readonly driver: ControlDriverInstance<'sql', string>;
428
+ readonly perSpaceOptions: ReadonlyArray<SqlMigrationRunnerExecuteOptions<TTargetDetails>>;
429
+ }): Promise<MigrationRunnerResult>;
421
430
 
422
431
  /**
423
432
  * Apply a single migration plan against an already-open connection
424
433
  * **without** opening a transaction. The caller is responsible for
425
434
  * wrapping the call (and any siblings) in `BEGIN` / `COMMIT` /
426
- * `ROLLBACK`. Used by the per-space runner wiring to fan out across
427
- * contract spaces inside one outer transaction so a mid-apply
428
- * failure rolls back every space's writes.
435
+ * `ROLLBACK`. Used by {@link SqlMigrationRunner.execute} to fan out
436
+ * across contract spaces inside one outer transaction.
429
437
  *
430
438
  * Idempotent control-table setup (`prisma_contract.*`) and marker
431
439
  * writes use `options.space` to address the per-space marker row.
@@ -433,40 +441,8 @@ export interface SqlMigrationRunner<TTargetDetails> {
433
441
  executeOnConnection(
434
442
  options: SqlMigrationRunnerExecuteOptions<TTargetDetails>,
435
443
  ): Promise<SqlMigrationRunnerResult>;
436
-
437
- /**
438
- * Apply per-space plans across multiple contract spaces inside a
439
- * single outer transaction. The caller orders the input list
440
- * (typically via the aggregate planner's `applyOrder`: extensions
441
- * alphabetical, then app); the runner is responsible for opening
442
- * / committing the outer
443
- * transaction (and any target-specific connection-level setup such
444
- * as the SQLite FK pragma toggle). A failure on any space rolls
445
- * back every space's writes.
446
- *
447
- * Each space's `SqlMigrationRunnerExecuteOptions` must reference the
448
- * same `driver` (the connection the outer transaction is open on).
449
- * Per-space marker writes use `options.space` to address the row.
450
- */
451
- executeAcrossSpaces(options: {
452
- readonly driver: ControlDriverInstance<'sql', string>;
453
- readonly perSpaceOptions: ReadonlyArray<SqlMigrationRunnerExecuteOptions<TTargetDetails>>;
454
- }): Promise<MultiSpaceRunnerResult>;
455
- }
456
-
457
- export interface MultiSpaceRunnerSuccessValue {
458
- readonly perSpaceResults: ReadonlyArray<{
459
- readonly space: string;
460
- readonly value: SqlMigrationRunnerSuccessValue;
461
- }>;
462
- }
463
-
464
- export interface MultiSpaceRunnerFailure extends SqlMigrationRunnerFailure {
465
- readonly failingSpace: string;
466
444
  }
467
445
 
468
- export type MultiSpaceRunnerResult = Result<MultiSpaceRunnerSuccessValue, MultiSpaceRunnerFailure>;
469
-
470
446
  export interface SqlControlTargetDescriptor<
471
447
  TTargetId extends string,
472
448
  TTargetDetails,
@@ -14,6 +14,7 @@ import type {
14
14
  SchemaVerificationNode,
15
15
  VerifyDatabaseSchemaResult,
16
16
  } from '@prisma-next/framework-components/control';
17
+ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
17
18
  import {
18
19
  isPostgresEnumStorageEntry,
19
20
  isStorageTypeInstance,
@@ -98,6 +99,7 @@ export interface VerifySqlSchemaOptions {
98
99
  readonly resolveExistingEnumValues?: (
99
100
  schema: SqlSchemaIR,
100
101
  enumType: PostgresEnumStorageEntry,
102
+ namespaceId: string,
101
103
  ) => readonly string[] | null;
102
104
  }
103
105
 
@@ -129,6 +131,8 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
129
131
 
130
132
  const { contractStorageHash, contractProfileHash, contractTarget } =
131
133
  extractContractMetadata(contract);
134
+ // Column `typeRef` resolution map: keyed by the bare contract type name
135
+ // (columns carry bare `typeRef`s). Used by `verifySchemaTables` only.
132
136
  const allStorageTypesMap: Record<string, PostgresEnumStorageEntry | StorageTypeInstance> = {
133
137
  ...((contract.storage.types ?? {}) as Record<
134
138
  string,
@@ -136,9 +140,9 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
136
140
  >),
137
141
  };
138
142
  for (const ns of Object.values(contract.storage.namespaces)) {
139
- const nsTypes = (ns as { types?: Record<string, PostgresEnumStorageEntry> }).types;
140
- if (nsTypes) {
141
- for (const [k, v] of Object.entries(nsTypes)) {
143
+ const nsEnums = (ns as { enum?: Record<string, PostgresEnumStorageEntry> }).enum;
144
+ if (nsEnums) {
145
+ for (const [k, v] of Object.entries(nsEnums)) {
142
146
  allStorageTypesMap[k] = v;
143
147
  }
144
148
  }
@@ -159,48 +163,86 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
159
163
 
160
164
  validateFrameworkComponentsForExtensions(contract, options.frameworkComponents);
161
165
 
162
- // Verify storage type instances. PostgresEnumStorageEntry entries are walked
163
- // natively (using the bridging adapter `resolveExistingEnumValues`);
164
- // remaining codec-typed entries continue to dispatch through the
165
- // generic codec-hook `verifyType` path.
166
- const storageTypeEntries = Object.entries(storageTypes);
167
- if (storageTypeEntries.length > 0) {
168
- const typeNodes: SchemaVerificationNode[] = [];
169
- for (const [typeName, typeInstance] of storageTypeEntries) {
170
- let typeIssues: readonly SchemaIssue[];
171
- if (isPostgresEnumStorageEntry(typeInstance)) {
172
- typeIssues = verifyEnumType({
166
+ // Verify storage type instances. Codec-typed `storage.types` entries dispatch
167
+ // through the generic codec-hook `verifyType` path (keyed by bare name).
168
+ // `PostgresEnumStorageEntry` enums are walked natively *per namespace* (using
169
+ // the bridging adapter `resolveExistingEnumValues`) so two namespaces that
170
+ // declare an enum with the same name are each verified with their own
171
+ // namespace coordinate a bare-name aggregation would collapse them
172
+ // (last-write-wins) and verify only one.
173
+ const typeNodes: SchemaVerificationNode[] = [];
174
+ const pushTypeNode = (
175
+ typeName: string,
176
+ contractPath: string,
177
+ typeIssues: readonly SchemaIssue[],
178
+ ): void => {
179
+ if (typeIssues.length > 0) {
180
+ issues.push(...typeIssues);
181
+ }
182
+ typeNodes.push({
183
+ status: typeIssues.length > 0 ? 'fail' : 'pass',
184
+ kind: 'storageType',
185
+ name: `type ${typeName}`,
186
+ contractPath,
187
+ code: typeIssues.length > 0 ? (typeIssues[0]?.kind ?? '') : '',
188
+ message:
189
+ typeIssues.length > 0
190
+ ? `${typeIssues.length} issue${typeIssues.length === 1 ? '' : 's'}`
191
+ : '',
192
+ expected: undefined,
193
+ actual: undefined,
194
+ children: [],
195
+ });
196
+ };
197
+
198
+ // Top-level `storage.types`: codec-typed entries via codec hooks; a
199
+ // defensive top-level enum is verified under the unbound coordinate.
200
+ for (const [typeName, typeInstance] of Object.entries(contract.storage.types ?? {})) {
201
+ if (isPostgresEnumStorageEntry(typeInstance)) {
202
+ pushTypeNode(
203
+ typeName,
204
+ `storage.types.${typeName}`,
205
+ verifyEnumType({
173
206
  typeName,
174
207
  typeInstance,
175
208
  schema,
176
209
  resolveExistingEnumValues,
177
- });
178
- } else if (isStorageTypeInstance(typeInstance)) {
179
- const hook = codecHooks.get(typeInstance.codecId);
180
- typeIssues = hook?.verifyType ? hook.verifyType({ typeName, typeInstance, schema }) : [];
181
- } else {
182
- typeIssues = [];
183
- }
184
- if (typeIssues.length > 0) {
185
- issues.push(...typeIssues);
186
- }
187
- const typeStatus = typeIssues.length > 0 ? 'fail' : 'pass';
188
- const typeCode = typeIssues.length > 0 ? (typeIssues[0]?.kind ?? '') : '';
189
- typeNodes.push({
190
- status: typeStatus,
191
- kind: 'storageType',
192
- name: `type ${typeName}`,
193
- contractPath: `storage.types.${typeName}`,
194
- code: typeCode,
195
- message:
196
- typeIssues.length > 0
197
- ? `${typeIssues.length} issue${typeIssues.length === 1 ? '' : 's'}`
198
- : '',
199
- expected: undefined,
200
- actual: undefined,
201
- children: [],
202
- });
210
+ namespaceId: UNBOUND_NAMESPACE_ID,
211
+ }),
212
+ );
213
+ } else if (isStorageTypeInstance(typeInstance)) {
214
+ const hook = codecHooks.get(typeInstance.codecId);
215
+ pushTypeNode(
216
+ typeName,
217
+ `storage.types.${typeName}`,
218
+ hook?.verifyType ? hook.verifyType({ typeName, typeInstance, schema }) : [],
219
+ );
203
220
  }
221
+ }
222
+
223
+ // Namespace-scoped enums, verified per `(namespaceId, typeName)`.
224
+ for (const nsId of Object.keys(contract.storage.namespaces)) {
225
+ const ns = contract.storage.namespaces[nsId];
226
+ if (!ns) continue;
227
+ const nsEnums = ns.enum;
228
+ if (!nsEnums) continue;
229
+ for (const [typeName, entry] of Object.entries(nsEnums)) {
230
+ if (!isPostgresEnumStorageEntry(entry)) continue;
231
+ pushTypeNode(
232
+ typeName,
233
+ `storage.namespaces.${nsId}.enum.${typeName}`,
234
+ verifyEnumType({
235
+ typeName,
236
+ typeInstance: entry,
237
+ schema,
238
+ resolveExistingEnumValues,
239
+ namespaceId: nsId,
240
+ }),
241
+ );
242
+ }
243
+ }
244
+
245
+ if (typeNodes.length > 0) {
204
246
  const typesStatus = typeNodes.some((n) => n.status === 'fail') ? 'fail' : 'pass';
205
247
  rootChildren.push({
206
248
  status: typesStatus,
@@ -275,18 +317,24 @@ function verifyEnumType(options: {
275
317
  readonly typeName: string;
276
318
  readonly typeInstance: PostgresEnumStorageEntry;
277
319
  readonly schema: SqlSchemaIR;
320
+ readonly namespaceId: string;
278
321
  readonly resolveExistingEnumValues?:
279
- | ((schema: SqlSchemaIR, enumType: PostgresEnumStorageEntry) => readonly string[] | null)
322
+ | ((
323
+ schema: SqlSchemaIR,
324
+ enumType: PostgresEnumStorageEntry,
325
+ namespaceId: string,
326
+ ) => readonly string[] | null)
280
327
  | undefined;
281
328
  }): readonly SchemaIssue[] {
282
- const { typeName, typeInstance, schema, resolveExistingEnumValues } = options;
329
+ const { typeName, typeInstance, schema, namespaceId, resolveExistingEnumValues } = options;
283
330
  const desired = typeInstance.values;
284
- const existing = resolveExistingEnumValues?.(schema, typeInstance) ?? null;
331
+ const existing = resolveExistingEnumValues?.(schema, typeInstance, namespaceId) ?? null;
285
332
  if (!existing) {
286
333
  return [
287
334
  {
288
335
  kind: 'type_missing',
289
336
  typeName,
337
+ namespaceId,
290
338
  message: `Type "${typeName}" is missing from database`,
291
339
  },
292
340
  ];
@@ -305,6 +353,7 @@ function verifyEnumType(options: {
305
353
  return [
306
354
  {
307
355
  kind: 'enum_values_changed' as const,
356
+ namespaceId,
308
357
  typeName,
309
358
  addedValues,
310
359
  removedValues,
@@ -45,6 +45,7 @@ export function timestampNowControlDescriptor(): MutationDefaultGeneratorDescrip
45
45
  * `field.temporal.updatedAt()` lower to byte-identical contracts across
46
46
  * targets by construction.
47
47
  */
48
+ /* @__NO_SIDE_EFFECTS__ */
48
49
  export function temporalAuthoringPresets<
49
50
  const CodecId extends string,
50
51
  const NativeType extends string,
@@ -17,6 +17,7 @@ export type { SqlControlFamilyInstance } from '../core/control-instance';
17
17
  export type {
18
18
  ContractToSchemaIROptions,
19
19
  DefaultRenderer,
20
+ EnumStorageKeyResolver,
20
21
  NativeTypeExpander,
21
22
  } from '../core/migrations/contract-to-schema-ir';
22
23
  // Contract → SchemaIR conversion for offline migration planning
@@ -40,9 +41,6 @@ export type {
40
41
  ExpandNativeTypeInput,
41
42
  FieldEvent,
42
43
  FieldEventContext,
43
- MultiSpaceRunnerFailure,
44
- MultiSpaceRunnerResult,
45
- MultiSpaceRunnerSuccessValue,
46
44
  ResolveIdentityValueInput,
47
45
  SqlControlAdapterDescriptor,
48
46
  SqlControlExtensionDescriptor,