@prisma-next/family-sql 0.13.0-dev.3 → 0.13.0-dev.30

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 (53) hide show
  1. package/dist/authoring-type-constructors-CjFfO6LM.mjs +342 -0
  2. package/dist/authoring-type-constructors-CjFfO6LM.mjs.map +1 -0
  3. package/dist/{control-adapter-CgIL9Vtx.d.mts → control-adapter-Cmw9LvEP.d.mts} +16 -33
  4. package/dist/control-adapter-Cmw9LvEP.d.mts.map +1 -0
  5. package/dist/control-adapter.d.mts +2 -2
  6. package/dist/control.d.mts +36 -34
  7. package/dist/control.d.mts.map +1 -1
  8. package/dist/control.mjs +24 -77
  9. package/dist/control.mjs.map +1 -1
  10. package/dist/ir.d.mts +3 -2
  11. package/dist/ir.d.mts.map +1 -1
  12. package/dist/ir.mjs +1 -1
  13. package/dist/migration.d.mts +1 -1
  14. package/dist/migration.d.mts.map +1 -1
  15. package/dist/migration.mjs +2 -1
  16. package/dist/migration.mjs.map +1 -1
  17. package/dist/pack.d.mts +16 -3
  18. package/dist/pack.d.mts.map +1 -1
  19. package/dist/pack.mjs +4 -2
  20. package/dist/pack.mjs.map +1 -1
  21. package/dist/schema-verify.d.mts +1 -1
  22. package/dist/schema-verify.mjs +1 -1
  23. package/dist/{sql-contract-serializer-CY7qnms7.mjs → sql-contract-serializer-D6-28zKd.mjs} +26 -15
  24. package/dist/sql-contract-serializer-D6-28zKd.mjs.map +1 -0
  25. package/dist/{types-CbwQCzXY.d.mts → types-kgstZ_Zd.d.mts} +5 -5
  26. package/dist/types-kgstZ_Zd.d.mts.map +1 -0
  27. package/dist/{verify-sql-schema-DcMaT5Zj.d.mts → verify-sql-schema-thU-jKpf.d.mts} +2 -14
  28. package/dist/verify-sql-schema-thU-jKpf.d.mts.map +1 -0
  29. package/dist/{verify-sql-schema-DlAgBiT_.mjs → verify-sql-schema-xT4udQLQ.mjs} +25 -118
  30. package/dist/verify-sql-schema-xT4udQLQ.mjs.map +1 -0
  31. package/package.json +21 -21
  32. package/src/core/authoring-entity-types.ts +178 -0
  33. package/src/core/authoring-field-presets.ts +8 -3
  34. package/src/core/control-adapter.ts +18 -49
  35. package/src/core/control-descriptor.ts +3 -0
  36. package/src/core/control-instance.ts +13 -11
  37. package/src/core/ir/sql-contract-serializer-base.ts +76 -60
  38. package/src/core/migrations/contract-to-schema-ir.ts +47 -112
  39. package/src/core/migrations/types.ts +4 -1
  40. package/src/core/psl-contract-infer/postgres-type-map.ts +5 -13
  41. package/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +17 -70
  42. package/src/core/schema-verify/verify-sql-schema.ts +10 -146
  43. package/src/core/sql-migration.ts +5 -1
  44. package/src/exports/control-adapter.ts +1 -0
  45. package/src/exports/control.ts +1 -1
  46. package/src/exports/pack.ts +3 -0
  47. package/dist/authoring-type-constructors-D4lQ-qpj.mjs +0 -192
  48. package/dist/authoring-type-constructors-D4lQ-qpj.mjs.map +0 -1
  49. package/dist/control-adapter-CgIL9Vtx.d.mts.map +0 -1
  50. package/dist/sql-contract-serializer-CY7qnms7.mjs.map +0 -1
  51. package/dist/types-CbwQCzXY.d.mts.map +0 -1
  52. package/dist/verify-sql-schema-DcMaT5Zj.d.mts.map +0 -1
  53. package/dist/verify-sql-schema-DlAgBiT_.mjs.map +0 -1
@@ -0,0 +1,178 @@
1
+ import type { JsonValue } from '@prisma-next/contract/types';
2
+ import type {
3
+ AuthoringEntityContext,
4
+ AuthoringEntityTypeDescriptor,
5
+ AuthoringEntityTypeNamespace,
6
+ AuthoringPslBlockDescriptorNamespace,
7
+ PslExtensionBlock,
8
+ } from '@prisma-next/framework-components/authoring';
9
+ import { type EnumTypeHandle, enumType } from '@prisma-next/sql-contract-ts/contract-builder';
10
+ import { blindCast } from '@prisma-next/utils/casts';
11
+
12
+ function parseQuotedString(raw: string): string | undefined {
13
+ if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
14
+ return raw.slice(1, -1);
15
+ }
16
+ return undefined;
17
+ }
18
+
19
+ export const sqlFamilyEnumEntityDescriptor = {
20
+ kind: 'entity' as const,
21
+ discriminator: 'enum',
22
+ output: {
23
+ factory: (
24
+ block: PslExtensionBlock,
25
+ ctx: AuthoringEntityContext,
26
+ ): EnumTypeHandle | undefined => {
27
+ const sourceId = ctx.sourceId ?? 'unknown';
28
+ const diagnostics = ctx.diagnostics;
29
+
30
+ const typeAttr = block.blockAttributes.find((a) => a.name === 'type');
31
+ if (!typeAttr) {
32
+ diagnostics?.push({
33
+ code: 'PSL_ENUM_MISSING_TYPE',
34
+ message: `enum "${block.name}" is missing a @@type("codecId") attribute`,
35
+ sourceId,
36
+ span: block.span,
37
+ });
38
+ return undefined;
39
+ }
40
+
41
+ const rawCodecArg = typeAttr.args[0]?.value;
42
+ const codecId = rawCodecArg !== undefined ? parseQuotedString(rawCodecArg) : undefined;
43
+ if (!codecId) {
44
+ diagnostics?.push({
45
+ code: 'PSL_ENUM_MISSING_TYPE',
46
+ message: `enum "${block.name}" @@type attribute must have a quoted codec id argument`,
47
+ sourceId,
48
+ span: typeAttr.span,
49
+ });
50
+ return undefined;
51
+ }
52
+
53
+ const nativeType = ctx.codecLookup?.targetTypesFor(codecId)?.[0];
54
+ if (nativeType === undefined) {
55
+ const typeArgSpan = typeAttr.args[0]?.span ?? typeAttr.span;
56
+ diagnostics?.push({
57
+ code: 'PSL_EXTENSION_INVALID_VALUE',
58
+ message: `enum "${block.name}" @@type references unknown codec "${codecId}"`,
59
+ sourceId,
60
+ span: typeArgSpan,
61
+ });
62
+ return undefined;
63
+ }
64
+
65
+ const codec = ctx.codecLookup?.get(codecId);
66
+ if (codec === undefined) {
67
+ const typeArgSpan = typeAttr.args[0]?.span ?? typeAttr.span;
68
+ diagnostics?.push({
69
+ code: 'PSL_EXTENSION_INVALID_VALUE',
70
+ message: `enum "${block.name}" @@type codec "${codecId}" resolves in targetTypesFor but is absent from codecLookup.get`,
71
+ sourceId,
72
+ span: typeArgSpan,
73
+ });
74
+ return undefined;
75
+ }
76
+
77
+ const seenValues = new Set<string>();
78
+ const members: { name: string; value: unknown }[] = [];
79
+ let memberError = false;
80
+
81
+ for (const [memberName, paramValue] of Object.entries(block.parameters)) {
82
+ let value: unknown;
83
+ if (paramValue.kind === 'bare') {
84
+ try {
85
+ value = codec.decodeJson(memberName);
86
+ } catch {
87
+ diagnostics?.push({
88
+ code: 'PSL_ENUM_BARE_MEMBER_NON_STRING_CODEC',
89
+ message: `enum "${block.name}" member "${memberName}" has no value and codec "${codecId}" does not accept a bare name as input`,
90
+ sourceId,
91
+ span: paramValue.span,
92
+ });
93
+ memberError = true;
94
+ continue;
95
+ }
96
+ } else if (paramValue.kind === 'value') {
97
+ let jsonValue: unknown;
98
+ try {
99
+ jsonValue = JSON.parse(paramValue.raw);
100
+ } catch {
101
+ diagnostics?.push({
102
+ code: 'PSL_EXTENSION_INVALID_VALUE',
103
+ message: `enum "${block.name}" member "${memberName}" value "${paramValue.raw}" is not valid JSON`,
104
+ sourceId,
105
+ span: paramValue.span,
106
+ });
107
+ memberError = true;
108
+ continue;
109
+ }
110
+ try {
111
+ value = codec.decodeJson(
112
+ blindCast<JsonValue, 'JSON.parse returns a JsonValue-compatible value'>(jsonValue),
113
+ );
114
+ } catch (err) {
115
+ const reason = err instanceof Error ? err.message : String(err);
116
+ diagnostics?.push({
117
+ code: 'PSL_EXTENSION_INVALID_VALUE',
118
+ message: `enum "${block.name}" member "${memberName}" was rejected by codec "${codecId}": ${reason}`,
119
+ sourceId,
120
+ span: paramValue.span,
121
+ });
122
+ memberError = true;
123
+ continue;
124
+ }
125
+ } else {
126
+ continue;
127
+ }
128
+
129
+ const valueKey = String(value);
130
+ if (seenValues.has(valueKey)) {
131
+ diagnostics?.push({
132
+ code: 'PSL_ENUM_DUPLICATE_MEMBER_VALUE',
133
+ message: `enum "${block.name}": duplicate member value "${valueKey}"`,
134
+ sourceId,
135
+ span: paramValue.span,
136
+ });
137
+ memberError = true;
138
+ continue;
139
+ }
140
+ seenValues.add(valueKey);
141
+ members.push({ name: memberName, value });
142
+ }
143
+
144
+ if (memberError) return undefined;
145
+
146
+ if (members.length === 0) {
147
+ diagnostics?.push({
148
+ code: 'PSL_ENUM_MISSING_TYPE',
149
+ message: `enum "${block.name}" must have at least one member`,
150
+ sourceId,
151
+ span: block.span,
152
+ });
153
+ return undefined;
154
+ }
155
+
156
+ return enumType(
157
+ block.name,
158
+ { codecId, nativeType },
159
+ ...members.map((m) => ({ name: m.name, value: m.value })),
160
+ );
161
+ },
162
+ },
163
+ } satisfies AuthoringEntityTypeDescriptor;
164
+
165
+ export const sqlFamilyEntityTypes: AuthoringEntityTypeNamespace = {
166
+ enum: sqlFamilyEnumEntityDescriptor,
167
+ };
168
+
169
+ export const sqlFamilyPslBlockDescriptors = {
170
+ enum: {
171
+ kind: 'pslBlock',
172
+ keyword: 'enum',
173
+ discriminator: 'enum',
174
+ name: { required: true },
175
+ parameters: {},
176
+ variadicParameters: true,
177
+ },
178
+ } as const satisfies AuthoringPslBlockDescriptorNamespace;
@@ -9,6 +9,11 @@ import type { AuthoringFieldNamespace } from '@prisma-next/framework-components/
9
9
  * (`character`) regardless of target, and the PSL interpreter lets the
10
10
  * generator override the scalar descriptor.
11
11
  *
12
+ * The `uuidString` / `id.uuidv4String` / `id.uuidv7String` presets store UUID
13
+ * values as `character(36)` — portable across all SQL targets. For a native
14
+ * Postgres `uuid` column use `uuidNative` / `id.uuidv4Native` /
15
+ * `id.uuidv7Native` from `@prisma-next/target-postgres`.
16
+ *
12
17
  * Scalar presets that map to target-specific codecs (e.g. `text`, `int`,
13
18
  * `boolean`, `dateTime`) are contributed by the target pack (see
14
19
  * `postgresAuthoringFieldPresets` in `@prisma-next/target-postgres`) so the
@@ -34,7 +39,7 @@ const nanoidOptionsArgument = {
34
39
  } as const;
35
40
 
36
41
  export const sqlFamilyAuthoringFieldPresets = {
37
- uuid: {
42
+ uuidString: {
38
43
  kind: 'fieldPreset',
39
44
  output: {
40
45
  codecId: CHARACTER_CODEC_ID,
@@ -91,7 +96,7 @@ export const sqlFamilyAuthoringFieldPresets = {
91
96
  },
92
97
  },
93
98
  id: {
94
- uuidv4: {
99
+ uuidv4String: {
95
100
  kind: 'fieldPreset',
96
101
  output: {
97
102
  codecId: CHARACTER_CODEC_ID,
@@ -108,7 +113,7 @@ export const sqlFamilyAuthoringFieldPresets = {
108
113
  id: true,
109
114
  },
110
115
  },
111
- uuidv7: {
116
+ uuidv7String: {
112
117
  kind: 'fieldPreset',
113
118
  output: {
114
119
  codecId: CHARACTER_CODEC_ID,
@@ -1,22 +1,15 @@
1
- import type {
2
- Contract,
3
- ContractMarkerRecord,
4
- LedgerEntryRecord,
5
- } from '@prisma-next/contract/types';
1
+ import type { ContractMarkerRecord, LedgerEntryRecord } from '@prisma-next/contract/types';
6
2
  import type {
7
3
  ControlAdapterInstance,
8
4
  ControlStack,
9
5
  } from '@prisma-next/framework-components/control';
10
- import type {
11
- PostgresEnumStorageEntry,
12
- SqlControlDriverInstance,
13
- SqlStorage,
14
- } from '@prisma-next/sql-contract/types';
6
+ import type { SqlControlDriverInstance } from '@prisma-next/sql-contract/types';
15
7
  import type {
16
8
  AnyQueryAst,
17
9
  DdlNode,
18
10
  LoweredStatement,
19
11
  LowererContext,
12
+ SqlExecuteRequest,
20
13
  } from '@prisma-next/sql-relational-core/ast';
21
14
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
22
15
  import type { DefaultNormalizer, NativeTypeNormalizer } from './schema-verify/verify-sql-schema';
@@ -32,6 +25,19 @@ export interface Lowerer {
32
25
  lower(ast: AnyQueryAst | DdlNode, context: LowererContext<unknown>): LoweredStatement;
33
26
  }
34
27
 
28
+ /**
29
+ * Extends {@link Lowerer} with async codec-routed DDL lowering. Control
30
+ * adapters implement this; the planner's `CreateTableCall.toOp` and
31
+ * `CreateSchemaCall.toOp` accept it to produce executable DDL statements
32
+ * with literal defaults encoded through their codec.
33
+ */
34
+ export interface ExecuteRequestLowerer extends Lowerer {
35
+ lowerToExecuteRequest(
36
+ ast: AnyQueryAst | DdlNode,
37
+ context?: LowererContext<unknown>,
38
+ ): Promise<SqlExecuteRequest>;
39
+ }
40
+
35
41
  /**
36
42
  * SQL control adapter interface for control-plane operations.
37
43
  * Implemented by target-specific adapters (e.g., Postgres, MySQL).
@@ -39,7 +45,8 @@ export interface Lowerer {
39
45
  * @template TTarget - The target ID (e.g., 'postgres', 'mysql')
40
46
  */
41
47
  export interface SqlControlAdapter<TTarget extends string = string>
42
- extends ControlAdapterInstance<'sql', TTarget> {
48
+ extends ControlAdapterInstance<'sql', TTarget>,
49
+ ExecuteRequestLowerer {
43
50
  /**
44
51
  * Reads the contract marker for `space` from the database, returning
45
52
  * `null` if no marker row exists for that space (or if the marker
@@ -181,33 +188,6 @@ export interface SqlControlAdapter<TTarget extends string = string>
181
188
  */
182
189
  readonly normalizeNativeType?: NativeTypeNormalizer;
183
190
 
184
- /**
185
- * Optional bridging adapter for resolving the existing values of a
186
- * native enum type from the introspected schema IR. Targets supply
187
- * this so the family-level schema verifier can walk
188
- * `PostgresEnumStorageEntry` entries natively without needing to
189
- * know the target-specific `schema.annotations` shape
190
- * (e.g. `schema.annotations.pg.storageTypes`).
191
- */
192
- readonly resolveExistingEnumValues?: (
193
- schema: SqlSchemaIR,
194
- enumType: PostgresEnumStorageEntry,
195
- namespaceId: string,
196
- ) => readonly string[] | null;
197
- /**
198
- * Optional contract-scoped factory for {@link resolveExistingEnumValues}.
199
- * Targets that need the contract storage to resolve namespace → DDL schema
200
- * supply this; the family control instance prefers it over the bare adapter
201
- * hook when present.
202
- */
203
- readonly resolveExistingEnumValuesForContract?: (
204
- contract: Contract<SqlStorage>,
205
- ) => (
206
- schema: SqlSchemaIR,
207
- enumType: PostgresEnumStorageEntry,
208
- namespaceId: string,
209
- ) => readonly string[] | null;
210
-
211
191
  /**
212
192
  * Ordered DDL queries that bootstrap marker/ledger control tables for migration
213
193
  * runners. Postgres includes `CREATE SCHEMA`; SQLite does not.
@@ -219,17 +199,6 @@ export interface SqlControlAdapter<TTarget extends string = string>
219
199
  * `sign` — excludes the ledger table.
220
200
  */
221
201
  bootstrapSignMarkerQueries(): readonly DdlNode[];
222
-
223
- /**
224
- * Lower a SQL query AST into a target-flavored `{ sql, params }` payload.
225
- *
226
- * Migration tooling (e.g. the `dataTransform` operation) needs to materialize
227
- * SQL at emit/plan time without instantiating the runtime adapter. The control
228
- * adapter's `lower` is byte-equivalent to the runtime adapter's `lower` for the
229
- * same AST and contract, ensuring planned SQL matches what the runtime would
230
- * emit.
231
- */
232
- lower(ast: AnyQueryAst | DdlNode, context: LowererContext<unknown>): LoweredStatement;
233
202
  }
234
203
 
235
204
  /**
@@ -4,6 +4,7 @@ import type {
4
4
  } from '@prisma-next/framework-components/control';
5
5
  import type { EmissionSpi } from '@prisma-next/framework-components/emission';
6
6
  import { sqlEmission } from '@prisma-next/sql-contract-emitter';
7
+ import { sqlFamilyEntityTypes, sqlFamilyPslBlockDescriptors } from './authoring-entity-types';
7
8
  import { sqlFamilyAuthoringFieldPresets } from './authoring-field-presets';
8
9
  import { sqlFamilyAuthoringTypes } from './authoring-type-constructors';
9
10
  import { createSqlFamilyInstance, type SqlControlFamilyInstance } from './control-instance';
@@ -19,6 +20,8 @@ export class SqlFamilyDescriptor
19
20
  readonly authoring = {
20
21
  field: sqlFamilyAuthoringFieldPresets,
21
22
  type: sqlFamilyAuthoringTypes,
23
+ entityTypes: sqlFamilyEntityTypes,
24
+ pslBlockDescriptors: sqlFamilyPslBlockDescriptors,
22
25
  } as const;
23
26
 
24
27
  create<TTargetId extends string>(
@@ -35,8 +35,8 @@ import type { SqlControlDriverInstance, SqlStorage } from '@prisma-next/sql-cont
35
35
  import type {
36
36
  AnyQueryAst,
37
37
  DdlNode,
38
- LoweredStatement,
39
38
  LowererContext,
39
+ SqlExecuteRequest,
40
40
  } from '@prisma-next/sql-relational-core/ast';
41
41
  import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
42
42
  import type { SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types';
@@ -68,10 +68,10 @@ function extractCodecTypeIdsFromContract(contract: unknown): readonly string[] {
68
68
  ) {
69
69
  const namespaces = contract.storage.namespaces as Record<
70
70
  string,
71
- { readonly entries: { readonly table: Readonly<Record<string, unknown>> } }
71
+ { readonly entries: Readonly<Record<string, Readonly<Record<string, unknown>>>> }
72
72
  >;
73
73
  for (const ns of Object.values(namespaces)) {
74
- const tbls = ns.entries.table;
74
+ const tbls = ns.entries['table'];
75
75
  if (typeof tbls !== 'object' || tbls === null) continue;
76
76
  for (const table of Object.values(tbls)) {
77
77
  if (
@@ -240,7 +240,10 @@ export interface SqlControlFamilyInstance
240
240
 
241
241
  inferPslContract(schemaIR: SqlSchemaIR): PslDocumentAst;
242
242
 
243
- lowerAst(ast: AnyQueryAst | DdlNode, context: LowererContext<unknown>): LoweredStatement;
243
+ lowerAst(
244
+ ast: AnyQueryAst | DdlNode,
245
+ context: LowererContext<unknown>,
246
+ ): Promise<SqlExecuteRequest>;
244
247
 
245
248
  /**
246
249
  * Inserts the initial marker row for `space` (upsert on `space`).
@@ -672,9 +675,6 @@ export function createSqlFamilyInstance<TTargetId extends string>(
672
675
  }): VerifyDatabaseSchemaResult {
673
676
  const contract = deserializeWithTargetSerializer(options.contract) as Contract<SqlStorage>;
674
677
  const controlAdapter = getControlAdapter();
675
- const resolveExistingEnumValues =
676
- controlAdapter.resolveExistingEnumValuesForContract?.(contract) ??
677
- controlAdapter.resolveExistingEnumValues;
678
678
  return verifySqlSchema({
679
679
  contract,
680
680
  schema: options.schema,
@@ -683,7 +683,6 @@ export function createSqlFamilyInstance<TTargetId extends string>(
683
683
  frameworkComponents: options.frameworkComponents,
684
684
  ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault),
685
685
  ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType),
686
- ...ifDefined('resolveExistingEnumValues', resolveExistingEnumValues),
687
686
  });
688
687
  },
689
688
  async sign(options: {
@@ -707,7 +706,7 @@ export function createSqlFamilyInstance<TTargetId extends string>(
707
706
  const controlAdapter = getControlAdapter();
708
707
  const lowererContext = { contract };
709
708
  for (const query of controlAdapter.bootstrapSignMarkerQueries()) {
710
- const lowered = controlAdapter.lower(query, lowererContext);
709
+ const lowered = await controlAdapter.lowerToExecuteRequest(query, lowererContext);
711
710
  await driver.query(lowered.sql, lowered.params);
712
711
  }
713
712
 
@@ -857,8 +856,11 @@ export function createSqlFamilyInstance<TTargetId extends string>(
857
856
  return sqlSchemaIrToPslAst(schemaIR);
858
857
  },
859
858
 
860
- lowerAst(ast: AnyQueryAst | DdlNode, context: LowererContext<unknown>): LoweredStatement {
861
- return getControlAdapter().lower(ast, context);
859
+ lowerAst(
860
+ ast: AnyQueryAst | DdlNode,
861
+ context: LowererContext<unknown>,
862
+ ): Promise<SqlExecuteRequest> {
863
+ return getControlAdapter().lowerToExecuteRequest(ast, context);
862
864
  },
863
865
 
864
866
  bootstrapControlTableQueries(): readonly DdlNode[] {
@@ -21,6 +21,7 @@ import {
21
21
  } from '@prisma-next/sql-contract/types';
22
22
  import {
23
23
  createSqlContractSchema,
24
+ createSqlEntrySchemaRegistry,
24
25
  validateSqlContractFully,
25
26
  } from '@prisma-next/sql-contract/validators';
26
27
  import { blindCast } from '@prisma-next/utils/casts';
@@ -72,18 +73,20 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
72
73
  private readonly contractSchema: Type<unknown> | undefined;
73
74
 
74
75
  constructor(
75
- protected readonly entityTypeRegistry: ReadonlyMap<
76
+ protected readonly entityHydrationRegistry: ReadonlyMap<
76
77
  string,
77
78
  SqlEntityHydrationFactory
78
79
  > = new Map(),
79
- validatorFragments?: ReadonlyMap<string, Type<unknown>>,
80
+ validatorRegistry: ReadonlyMap<string, Type<unknown>> = new Map(),
80
81
  ) {
81
- // Only build a fragments-aware contract schema when pack contributions
82
- // exist. The cached module-level default in `validators.ts` covers the
83
- // no-contributions case and avoids per-instance schema compilation.
82
+ // One uniform registry: SQL core's kinds and pack contributions are
83
+ // composed into the same map. Only build a per-instance contract
84
+ // schema when pack contributions exist the cached module-level
85
+ // default in `validators.ts` is the same composition with no pack
86
+ // entries and avoids per-instance schema compilation.
84
87
  this.contractSchema =
85
- validatorFragments !== undefined && validatorFragments.size > 0
86
- ? createSqlContractSchema(validatorFragments)
88
+ validatorRegistry.size > 0
89
+ ? createSqlContractSchema(createSqlEntrySchemaRegistry(validatorRegistry))
87
90
  : undefined;
88
91
  }
89
92
 
@@ -182,74 +185,87 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
182
185
  return raw;
183
186
  }
184
187
  const rawRecord = isPlainRecord(raw) ? raw : {};
185
- if (
186
- Object.hasOwn(rawRecord, 'tables') ||
187
- Object.hasOwn(rawRecord, 'enum') ||
188
- Object.hasOwn(rawRecord, 'collections')
189
- ) {
190
- throw new ContractValidationError(
191
- 'Namespace envelope uses deprecated flat slot keys; expected `entries: { table? }`',
192
- 'structural',
193
- );
194
- }
195
188
  const id = typeof rawRecord['id'] === 'string' ? rawRecord['id'] : nsId;
196
189
  const parsed = NamespaceRawSchema({ ...rawRecord, id });
197
190
  if (parsed instanceof type.errors) {
198
191
  const messages = parsed.map((p: { message: string }) => p.message).join('; ');
199
192
  throw new ContractValidationError(`Namespace hydration failed: ${messages}`, 'structural');
200
193
  }
201
- // Default to empty table; overwritten below if raw entries carry a table slot.
202
- const entriesInput: {
203
- table: Record<string, StorageTable>;
204
- valueSet?: Record<string, StorageValueSet>;
205
- } = { table: {} };
194
+ const entriesOutput: Record<string, Record<string, unknown>> = {};
206
195
  const entriesRaw = parsed.entries;
207
196
  if (entriesRaw !== undefined && typeof entriesRaw === 'object' && entriesRaw !== null) {
208
197
  const rawEntries = entriesRaw as Record<string, unknown>;
209
- const tableSlot = rawEntries['table'];
210
- if (tableSlot !== null && typeof tableSlot === 'object' && !Array.isArray(tableSlot)) {
211
- entriesInput.table = Object.fromEntries(
212
- Object.entries(tableSlot as Record<string, unknown>).map(([tableName, table]) => [
213
- tableName,
214
- table instanceof StorageTable ? table : new StorageTable(table as StorageTableInput),
215
- ]),
216
- );
198
+ for (const [key, innerMap] of Object.entries(rawEntries)) {
199
+ const hydrated = this.hydrateEntriesKind(key, innerMap);
200
+ if (hydrated === undefined) {
201
+ throw new ContractValidationError(
202
+ `Unknown entries key "${key}" in namespace "${id}"; no hydration factory registered for this entity kind`,
203
+ 'structural',
204
+ );
205
+ }
206
+ entriesOutput[key] = hydrated;
217
207
  }
218
- const valueSetSlot = rawEntries['valueSet'];
219
- if (
220
- valueSetSlot !== null &&
221
- typeof valueSetSlot === 'object' &&
222
- !Array.isArray(valueSetSlot)
223
- ) {
224
- entriesInput.valueSet = Object.fromEntries(
225
- Object.entries(
226
- blindCast<
227
- Record<string, unknown>,
228
- 'valueSet slot is a plain record after object check'
229
- >(valueSetSlot),
230
- ).map(([vsName, vs]) => [
231
- vsName,
232
- vs instanceof StorageValueSet
233
- ? vs
234
- : new StorageValueSet(
235
- blindCast<
236
- StorageValueSetInput,
237
- 'non-instance valueSet entry is StorageValueSetInput'
238
- >(vs),
239
- ),
240
- ]),
241
- );
242
- }
243
- // Target-specific slots (e.g. postgres `type`) are left for target
244
- // overrides to extract from the original `raw` parameter.
208
+ }
209
+ // Always ensure a 'table' key is present (may be empty).
210
+ if (!Object.hasOwn(entriesOutput, 'table')) {
211
+ entriesOutput['table'] = {};
245
212
  }
246
213
 
247
- return blindCast<SqlNamespaceTablesInput, 'hydrated namespace tables input'>({
214
+ return blindCast<SqlNamespaceTablesInput, 'hydrated namespace entries input'>({
248
215
  id,
249
- entries: entriesInput,
216
+ entries: entriesOutput,
250
217
  });
251
218
  }
252
219
 
220
+ protected hydrateEntriesKind(
221
+ key: string,
222
+ innerMap: unknown,
223
+ ): Record<string, unknown> | undefined {
224
+ if (key === 'table') {
225
+ if (innerMap === null || typeof innerMap !== 'object' || Array.isArray(innerMap)) {
226
+ return {};
227
+ }
228
+ return Object.fromEntries(
229
+ Object.entries(
230
+ blindCast<
231
+ Record<string, unknown>,
232
+ 'table inner map is a plain record after object check'
233
+ >(innerMap),
234
+ ).map(([tableName, table]) => [
235
+ tableName,
236
+ new StorageTable(
237
+ blindCast<
238
+ StorageTableInput,
239
+ 'each table value is StorageTableInput by contract schema'
240
+ >(table),
241
+ ),
242
+ ]),
243
+ );
244
+ }
245
+ if (key === 'valueSet') {
246
+ if (innerMap === null || typeof innerMap !== 'object' || Array.isArray(innerMap)) {
247
+ return {};
248
+ }
249
+ return Object.fromEntries(
250
+ Object.entries(
251
+ blindCast<
252
+ Record<string, unknown>,
253
+ 'valueSet inner map is a plain record after object check'
254
+ >(innerMap),
255
+ ).map(([vsName, vs]) => [
256
+ vsName,
257
+ new StorageValueSet(
258
+ blindCast<StorageValueSetInput, 'valueSet entry is StorageValueSetInput after parse'>(
259
+ vs,
260
+ ),
261
+ ),
262
+ ]),
263
+ );
264
+ }
265
+ // Delegate unknown keys to subclass — return undefined to fail closed.
266
+ return undefined;
267
+ }
268
+
253
269
  protected hydrateStorageTypeEntry(entry: SqlStorageTypeEntry): SqlStorageTypeEntry {
254
270
  if (typeof entry !== 'object' || entry === null) {
255
271
  return entry;
@@ -258,7 +274,7 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
258
274
  if (typeof kind !== 'string') {
259
275
  return entry;
260
276
  }
261
- const factory = this.entityTypeRegistry.get(kind);
277
+ const factory = this.entityHydrationRegistry.get(kind);
262
278
  if (factory === undefined) {
263
279
  return entry;
264
280
  }