@prisma-next/family-sql 0.14.0 → 0.15.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 (80) hide show
  1. package/dist/{authoring-type-constructors-CjFfO6LM.mjs → authoring-type-constructors-CXd-8ydc.mjs} +7 -30
  2. package/dist/authoring-type-constructors-CXd-8ydc.mjs.map +1 -0
  3. package/dist/{control-adapter-Cmw9LvEP.d.mts → control-adapter-BQgad8Zc.d.mts} +30 -8
  4. package/dist/control-adapter-BQgad8Zc.d.mts.map +1 -0
  5. package/dist/control-adapter.d.mts +1 -1
  6. package/dist/control.d.mts +279 -34
  7. package/dist/control.d.mts.map +1 -1
  8. package/dist/control.mjs +364 -1005
  9. package/dist/control.mjs.map +1 -1
  10. package/dist/diff.d.mts +102 -0
  11. package/dist/diff.d.mts.map +1 -0
  12. package/dist/diff.mjs +14 -0
  13. package/dist/diff.mjs.map +1 -0
  14. package/dist/ir.d.mts +26 -9
  15. package/dist/ir.d.mts.map +1 -1
  16. package/dist/ir.mjs +2 -2
  17. package/dist/ir.mjs.map +1 -1
  18. package/dist/migration.d.mts +11 -2
  19. package/dist/migration.d.mts.map +1 -1
  20. package/dist/migration.mjs +7 -0
  21. package/dist/migration.mjs.map +1 -1
  22. package/dist/pack.mjs +1 -1
  23. package/dist/psl-infer.d.mts +119 -0
  24. package/dist/psl-infer.d.mts.map +1 -0
  25. package/dist/psl-infer.mjs +366 -0
  26. package/dist/psl-infer.mjs.map +1 -0
  27. package/dist/schema-differ-DnoopSXm.d.mts +45 -0
  28. package/dist/schema-differ-DnoopSXm.d.mts.map +1 -0
  29. package/dist/schema-verify-W3r631Jh.mjs +226 -0
  30. package/dist/schema-verify-W3r631Jh.mjs.map +1 -0
  31. package/dist/{sql-contract-serializer-BR2vC7Z-.mjs → sql-contract-serializer-C75cfMSS.mjs} +46 -20
  32. package/dist/sql-contract-serializer-C75cfMSS.mjs.map +1 -0
  33. package/dist/{types-kgstZ_Zd.d.mts → types-BPv_y7iS.d.mts} +36 -142
  34. package/dist/types-BPv_y7iS.d.mts.map +1 -0
  35. package/package.json +26 -26
  36. package/src/core/authoring-entity-types.ts +12 -37
  37. package/src/core/control-adapter.ts +11 -5
  38. package/src/core/control-instance.ts +212 -42
  39. package/src/core/control-target-descriptor.ts +98 -0
  40. package/src/core/diff/schema-verify.ts +324 -0
  41. package/src/core/diff/sql-schema-diff.ts +41 -0
  42. package/src/core/diff/verifier-disposition.ts +30 -0
  43. package/src/core/ir/sql-contract-serializer-base.ts +70 -56
  44. package/src/core/ir/sql-contract-serializer.ts +5 -7
  45. package/src/core/ir/sql-schema-verifier-base.ts +5 -5
  46. package/src/core/migrations/contract-to-schema-ir.ts +137 -51
  47. package/src/core/migrations/control-policy.ts +66 -115
  48. package/src/core/migrations/field-event-planner.ts +2 -2
  49. package/src/core/migrations/native-type-expander.ts +28 -0
  50. package/src/core/migrations/schema-differ.ts +41 -0
  51. package/src/core/migrations/types.ts +33 -37
  52. package/src/core/psl-contract-infer/name-transforms.ts +15 -0
  53. package/src/core/psl-contract-infer/printer-config.ts +14 -6
  54. package/src/core/psl-contract-infer/relation-inference.ts +30 -1
  55. package/src/core/sql-migration.ts +12 -1
  56. package/src/exports/control.ts +12 -2
  57. package/src/exports/diff.ts +25 -0
  58. package/src/exports/psl-infer.ts +40 -0
  59. package/dist/authoring-type-constructors-CjFfO6LM.mjs.map +0 -1
  60. package/dist/control-adapter-Cmw9LvEP.d.mts.map +0 -1
  61. package/dist/schema-verify.d.mts +0 -39
  62. package/dist/schema-verify.d.mts.map +0 -1
  63. package/dist/schema-verify.mjs +0 -2
  64. package/dist/sql-contract-serializer-BR2vC7Z-.mjs.map +0 -1
  65. package/dist/test-utils.d.mts +0 -2
  66. package/dist/test-utils.mjs +0 -2
  67. package/dist/types-kgstZ_Zd.d.mts.map +0 -1
  68. package/dist/verify-sql-schema-thU-jKpf.d.mts +0 -66
  69. package/dist/verify-sql-schema-thU-jKpf.d.mts.map +0 -1
  70. package/dist/verify-sql-schema-xT4udQLQ.mjs +0 -1501
  71. package/dist/verify-sql-schema-xT4udQLQ.mjs.map +0 -1
  72. package/src/core/psl-contract-infer/postgres-default-mapping.ts +0 -16
  73. package/src/core/psl-contract-infer/postgres-type-map.ts +0 -157
  74. package/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +0 -795
  75. package/src/core/schema-verify/control-verify-emit.ts +0 -46
  76. package/src/core/schema-verify/verifier-disposition.ts +0 -58
  77. package/src/core/schema-verify/verify-helpers.ts +0 -820
  78. package/src/core/schema-verify/verify-sql-schema.ts +0 -1311
  79. package/src/exports/schema-verify.ts +0 -18
  80. package/src/exports/test-utils.ts +0 -9
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The differ-based SQL schema verify: post-diff filters and verdict.
3
+ *
4
+ * The generic node differ (`diffSchemas`) reports every node-level
5
+ * difference between the derived expected tree and the introspected actual
6
+ * tree. This module is the consumer side the spec assigns to the SQL
7
+ * family: strict-mode extras gating and control-policy disposition are
8
+ * reason/kind-keyed filters applied AFTER the diff — never inside it — and
9
+ * the verify verdict derives from the filtered issue list.
10
+ *
11
+ * `verifySqlSchemaByDiff` wraps the verdict in the issue-based result
12
+ * envelope — this is THE SQL schema verify (the legacy relational walk and
13
+ * its verification tree are retired).
14
+ */
15
+
16
+ import type { Contract, ControlPolicy } from '@prisma-next/contract/types';
17
+ import { effectiveControlPolicy } from '@prisma-next/contract/types';
18
+ import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
19
+ import type {
20
+ DiffSubjectGranularity,
21
+ SchemaDiffIssue,
22
+ VerifierIssueCategory,
23
+ VerifierOutcome,
24
+ VerifyDatabaseSchemaResult,
25
+ } from '@prisma-next/framework-components/control';
26
+ import { dispositionForCategory } from '@prisma-next/framework-components/control';
27
+ import { isStorageTypeInstance, type SqlStorage } from '@prisma-next/sql-contract/types';
28
+ import { RelationalSchemaNodeKind, type SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types';
29
+ import { blindCast } from '@prisma-next/utils/casts';
30
+ import { ifDefined } from '@prisma-next/utils/defined';
31
+ import { extractCodecControlHooks } from '../assembly';
32
+ import type { SqlSchemaDiffFn } from '../migrations/schema-differ';
33
+ import type { CodecControlHooks } from '../migrations/types';
34
+ import { verifierDisposition } from './verifier-disposition';
35
+
36
+ // ============================================================================
37
+ // Subject-granularity classification — nodeKind → framework-neutral granularity
38
+ // ============================================================================
39
+
40
+ function issueNode(issue: SchemaDiffIssue): SqlSchemaIRNode | undefined {
41
+ const node = issue.expected ?? issue.actual;
42
+ if (node === undefined) return undefined;
43
+ return blindCast<
44
+ SqlSchemaIRNode,
45
+ 'every node in a SQL schema diff tree is a SqlSchemaIRNode; nodeKind is its identity'
46
+ >(node);
47
+ }
48
+
49
+ /**
50
+ * Resolves an issue's framework-neutral {@link DiffSubjectGranularity} on
51
+ * demand, from the issue's node's `nodeKind` via the target-provided
52
+ * `granularityOf` map. The node carries only its `nodeKind` identity, never a
53
+ * classification, and nothing is stamped onto the issue — every consumer
54
+ * (the family verdict below, the framework aggregate's unclaimed-elements
55
+ * sweep via {@link import('@prisma-next/framework-components/control').SchemaSubjectClassifierCapable})
56
+ * calls this the same way, resolved by the family/target that owns the node
57
+ * vocabulary. `undefined` for an issue with no node.
58
+ */
59
+ export function classifyDiffSubjectGranularity(
60
+ issue: SchemaDiffIssue,
61
+ granularityOf: (nodeKind: string) => DiffSubjectGranularity,
62
+ ): DiffSubjectGranularity | undefined {
63
+ const node = issueNode(issue);
64
+ return node === undefined ? undefined : granularityOf(node.nodeKind);
65
+ }
66
+
67
+ /**
68
+ * Resolves an issue's storage `entityKind` on demand, from the issue's
69
+ * node's `nodeKind` via the target-provided `entityKindOf` map — the sibling
70
+ * of {@link classifyDiffSubjectGranularity}, called the same way by the same
71
+ * consumers (via
72
+ * {@link import('@prisma-next/framework-components/control').SchemaSubjectClassifierCapable}).
73
+ * `undefined` for an issue with no node, or for a node kind with no storage
74
+ * entity of its own.
75
+ */
76
+ export function classifyDiffEntityKind(
77
+ issue: SchemaDiffIssue,
78
+ entityKindOf: (nodeKind: string) => string | undefined,
79
+ ): string | undefined {
80
+ const node = issueNode(issue);
81
+ return node === undefined ? undefined : entityKindOf(node.nodeKind);
82
+ }
83
+
84
+ // ============================================================================
85
+ // Issue classification — subject granularity + reason → target-neutral category
86
+ // ============================================================================
87
+
88
+ /**
89
+ * Re-keys the legacy `classifySqlVerifierIssueKind` category mapping on the
90
+ * issue's {@link DiffSubjectGranularity} (resolved via `granularityOf`) + the
91
+ * issue reason. The vocabulary maps one-to-one: an undeclared live entity or
92
+ * namespace is `extraTopLevelObject`, an undeclared live field
93
+ * `extraNestedElement`, undeclared auxiliaries (constraints, indexes,
94
+ * defaults) and structural leaves (policies) `extraAuxiliary`; a value-set
95
+ * drift on a check node is `valueDrift`; every other paired divergence is
96
+ * `declaredIncompatible`; anything the database lacks is `declaredMissing`.
97
+ * `granularityOf` is the target's classifier, so target and extension node
98
+ * kinds classify without the family importing them.
99
+ */
100
+ export function classifySqlDiffIssue(
101
+ issue: SchemaDiffIssue,
102
+ granularityOf: (nodeKind: string) => DiffSubjectGranularity,
103
+ ): VerifierIssueCategory {
104
+ if (issue.reason === 'not-found') {
105
+ return 'declaredMissing';
106
+ }
107
+ if (issue.reason === 'not-expected') {
108
+ const granularity = classifyDiffSubjectGranularity(issue, granularityOf);
109
+ if (granularity === 'entity' || granularity === 'namespace') {
110
+ return 'extraTopLevelObject';
111
+ }
112
+ if (granularity === 'field') {
113
+ return 'extraNestedElement';
114
+ }
115
+ return 'extraAuxiliary';
116
+ }
117
+ if (issueNode(issue)?.nodeKind === RelationalSchemaNodeKind.check) {
118
+ return 'valueDrift';
119
+ }
120
+ return 'declaredIncompatible';
121
+ }
122
+
123
+ /**
124
+ * Whether a `not-expected` issue is a strict-mode-only finding. The legacy
125
+ * walk detected every relational extra (namespaces, entities, fields, and
126
+ * their auxiliaries) only under `--strict`; the structural diff (roots, RLS
127
+ * policies, roles) was never strict-gated — its extras fail in both modes.
128
+ * Keyed on the issue's granularity, resolved via `granularityOf`.
129
+ */
130
+ function isStrictOnlyExtra(
131
+ issue: SchemaDiffIssue,
132
+ granularityOf: (nodeKind: string) => DiffSubjectGranularity,
133
+ ): boolean {
134
+ const granularity = classifyDiffSubjectGranularity(issue, granularityOf);
135
+ return (
136
+ granularity === 'namespace' ||
137
+ granularity === 'entity' ||
138
+ granularity === 'field' ||
139
+ granularity === 'auxiliary'
140
+ );
141
+ }
142
+
143
+ // ============================================================================
144
+ // The post-diff filter + verdict
145
+ // ============================================================================
146
+
147
+ export interface SqlDiffVerdictInput {
148
+ /** The full, ownership-scoped diff issue list from the target's differ. */
149
+ readonly issues: readonly SchemaDiffIssue[];
150
+ /** Resolves a diff issue's subject table's declared control policy directly from the contract. */
151
+ readonly resolveControlPolicy: (issue: SchemaDiffIssue) => ControlPolicy | undefined;
152
+ readonly strict: boolean;
153
+ readonly defaultControlPolicy: ControlPolicy | undefined;
154
+ /** The target's classifier: a diff issue node's `nodeKind` → its subject granularity. */
155
+ readonly granularityOf: (nodeKind: string) => DiffSubjectGranularity;
156
+ }
157
+
158
+ export interface SqlDiffVerdict {
159
+ readonly failures: readonly SchemaDiffIssue[];
160
+ readonly warnings: readonly SchemaDiffIssue[];
161
+ }
162
+
163
+ /**
164
+ * Applies the two consumer filters to a diff issue list: strict gating
165
+ * (relational `not-expected` findings drop in lenient mode) and
166
+ * control-policy disposition (each surviving issue grades against its
167
+ * subject table's effective policy; suppressed issues drop, `observed`
168
+ * subjects warn). The verify verdict is `failures.length === 0`.
169
+ */
170
+ export function computeSqlDiffVerdict(input: SqlDiffVerdictInput): SqlDiffVerdict {
171
+ const failures: SchemaDiffIssue[] = [];
172
+ const warnings: SchemaDiffIssue[] = [];
173
+ for (const issue of input.issues) {
174
+ if (
175
+ !input.strict &&
176
+ issue.reason === 'not-expected' &&
177
+ isStrictOnlyExtra(issue, input.granularityOf)
178
+ ) {
179
+ continue;
180
+ }
181
+ const tablePolicy = input.resolveControlPolicy(issue);
182
+ const policy = effectiveControlPolicy(tablePolicy, input.defaultControlPolicy);
183
+ const disposition: VerifierOutcome = dispositionForCategory(
184
+ policy,
185
+ classifySqlDiffIssue(issue, input.granularityOf),
186
+ );
187
+ if (disposition === 'suppress') continue;
188
+ if (disposition === 'warn') {
189
+ warnings.push(issue);
190
+ continue;
191
+ }
192
+ failures.push(issue);
193
+ }
194
+ return { failures, warnings };
195
+ }
196
+
197
+ // ============================================================================
198
+ // Storage-types check — the codec verifyType hook path
199
+ // ============================================================================
200
+
201
+ export interface StorageTypeVerdictInput {
202
+ readonly contract: Contract<SqlStorage>;
203
+ /**
204
+ * Expected/actual namespace-node pairs the target's differ input produced:
205
+ * for a namespaced tree, one entry per expected namespace with a
206
+ * non-empty table set, paired by DDL schema name (absent actual side for
207
+ * a schema the database lacks); a flat tree is the sole pair.
208
+ */
209
+ readonly namespacePairs: ReadonlyArray<{
210
+ readonly actual: SqlSchemaIRNode | undefined;
211
+ }>;
212
+ readonly codecHooks: ReadonlyMap<string, CodecControlHooks>;
213
+ }
214
+
215
+ /**
216
+ * Runs the codec `verifyType` hooks the way the legacy walk did: once per
217
+ * contract namespace with tables, against that namespace's paired actual
218
+ * node (the hook reads namespace-scoped state such as `enums` off it).
219
+ * Issue dispositions grade against the contract default policy, matching
220
+ * the legacy `pushTypeNode` semantics.
221
+ */
222
+ export interface StorageTypeVerdict {
223
+ readonly failures: readonly SchemaDiffIssue[];
224
+ readonly warnings: readonly SchemaDiffIssue[];
225
+ }
226
+
227
+ export function computeStorageTypeVerdict(input: StorageTypeVerdictInput): StorageTypeVerdict {
228
+ const failures: SchemaDiffIssue[] = [];
229
+ const warnings: SchemaDiffIssue[] = [];
230
+ const policy = effectiveControlPolicy(undefined, input.contract.defaultControlPolicy);
231
+ for (const pair of input.namespacePairs) {
232
+ if (pair.actual === undefined) continue;
233
+ for (const [typeName, typeInstance] of Object.entries(input.contract.storage.types ?? {})) {
234
+ if (!isStorageTypeInstance(typeInstance)) continue;
235
+ const hook = input.codecHooks.get(typeInstance.codecId);
236
+ if (!hook?.verifyType) continue;
237
+ const typeIssues = hook.verifyType({ typeName, typeInstance, schema: pair.actual });
238
+ for (const issue of typeIssues) {
239
+ const disposition = verifierDisposition(policy, issue);
240
+ if (disposition === 'suppress') continue;
241
+ if (disposition === 'warn') {
242
+ warnings.push(issue);
243
+ continue;
244
+ }
245
+ failures.push(issue);
246
+ }
247
+ }
248
+ }
249
+ return { failures, warnings };
250
+ }
251
+
252
+ // ============================================================================
253
+ // The issue-based verify envelope
254
+ // ============================================================================
255
+
256
+ export interface VerifySqlSchemaByDiffInput {
257
+ readonly contract: Contract<SqlStorage>;
258
+ readonly schema: SqlSchemaIRNode;
259
+ readonly strict: boolean;
260
+ readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
261
+ /** The target's full-tree node diff (`diffSchema` descriptor hook). */
262
+ readonly diffSchema: SqlSchemaDiffFn;
263
+ /** The target's classifier: a diff issue node's `nodeKind` → its subject granularity. */
264
+ readonly granularityOf: (nodeKind: string) => DiffSubjectGranularity;
265
+ }
266
+
267
+ /**
268
+ * THE SQL schema verify: runs the target's full-tree node diff, grades it
269
+ * through the family's post-diff filters (strict gating + control-policy
270
+ * disposition) plus the codec `verifyType` hook findings, and wraps the
271
+ * verdict in the issue-based result envelope. `ok` holds exactly when both
272
+ * issue lists are empty — the lists carry the verdict's failures.
273
+ */
274
+ export function verifySqlSchemaByDiff(
275
+ input: VerifySqlSchemaByDiffInput,
276
+ ): VerifyDatabaseSchemaResult {
277
+ const startTime = Date.now();
278
+ const verdictDiff = input.diffSchema({
279
+ contract: input.contract,
280
+ schema: input.schema,
281
+ frameworkComponents: input.frameworkComponents,
282
+ });
283
+ const diffVerdict = computeSqlDiffVerdict({
284
+ issues: verdictDiff.issues,
285
+ resolveControlPolicy: verdictDiff.resolveControlPolicy,
286
+ strict: input.strict,
287
+ defaultControlPolicy: input.contract.defaultControlPolicy,
288
+ granularityOf: input.granularityOf,
289
+ });
290
+ const storageTypeVerdict = computeStorageTypeVerdict({
291
+ contract: input.contract,
292
+ namespacePairs: verdictDiff.namespacePairs,
293
+ codecHooks: extractCodecControlHooks(input.frameworkComponents),
294
+ });
295
+ const failCount = diffVerdict.failures.length + storageTypeVerdict.failures.length;
296
+ const ok = failCount === 0;
297
+ const profileHash =
298
+ 'profileHash' in input.contract && typeof input.contract.profileHash === 'string'
299
+ ? input.contract.profileHash
300
+ : undefined;
301
+ return {
302
+ ok,
303
+ ...(ok ? {} : { code: 'PN-SCHEMA-0001' }),
304
+ summary: ok
305
+ ? 'Database schema satisfies contract'
306
+ : `Database schema does not satisfy contract (${failCount} failure${failCount === 1 ? '' : 's'})`,
307
+ contract: {
308
+ storageHash: input.contract.storage.storageHash,
309
+ ...ifDefined('profileHash', profileHash),
310
+ },
311
+ target: {
312
+ expected: input.contract.target,
313
+ actual: input.contract.target,
314
+ },
315
+ schema: {
316
+ issues: [...diffVerdict.failures, ...storageTypeVerdict.failures],
317
+ warnings: {
318
+ issues: [...diffVerdict.warnings, ...storageTypeVerdict.warnings],
319
+ },
320
+ },
321
+ meta: { strict: input.strict },
322
+ timings: { total: Date.now() - startTime },
323
+ };
324
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Shared relational-diff types and helpers. The coordinate-based issue diff
3
+ * this module used to house (`collectSqlSchemaIssues` /
4
+ * `collectSqlSchemaIssuesPerNamespace`) retired once the migration planner
5
+ * took `plan(start, end)` over the one differ (`buildPostgresPlanDiff` /
6
+ * `buildSqlitePlanDiff`); what remains here is consumed by the surviving
7
+ * verify verdict (`schema-verify.ts`) and the control adapters.
8
+ */
9
+
10
+ import type { ColumnDefault } from '@prisma-next/contract/types';
11
+
12
+ /**
13
+ * Function type for normalizing raw database default expressions into ColumnDefault.
14
+ * Target-specific implementations handle database dialect differences.
15
+ */
16
+ export type DefaultNormalizer = (
17
+ rawDefault: string,
18
+ nativeType: string,
19
+ ) => ColumnDefault | undefined;
20
+
21
+ /**
22
+ * Function type for normalizing schema native types to canonical form for comparison.
23
+ * Target-specific implementations handle dialect-specific type name variations
24
+ * (e.g., Postgres 'varchar' → 'character varying', 'timestamptz' normalization).
25
+ */
26
+ export type NativeTypeNormalizer = (nativeType: string) => string;
27
+
28
+ /**
29
+ * Compares two arrays of strings for equality (order-sensitive).
30
+ */
31
+ export function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
32
+ if (a.length !== b.length) {
33
+ return false;
34
+ }
35
+ for (let i = 0; i < a.length; i++) {
36
+ if (a[i] !== b[i]) {
37
+ return false;
38
+ }
39
+ }
40
+ return true;
41
+ }
@@ -0,0 +1,30 @@
1
+ import type { ControlPolicy } from '@prisma-next/contract/types';
2
+ import type {
3
+ SchemaDiffIssue,
4
+ VerifierIssueCategory,
5
+ VerifierOutcome,
6
+ } from '@prisma-next/framework-components/control';
7
+ import { dispositionForCategory } from '@prisma-next/framework-components/control';
8
+
9
+ /**
10
+ * Classifies a codec `verifyType` hook finding into the target-neutral
11
+ * categories the framework grades. A storage type is a named type instance
12
+ * (e.g. a native enum); the only shape divergence it can carry is a change
13
+ * to its value set, so a paired mismatch always classifies as `valueDrift`.
14
+ */
15
+ export function classifyStorageTypeDiffIssue(issue: SchemaDiffIssue): VerifierIssueCategory {
16
+ if (issue.reason === 'not-found') {
17
+ return 'declaredMissing';
18
+ }
19
+ if (issue.reason === 'not-expected') {
20
+ return 'extraAuxiliary';
21
+ }
22
+ return 'valueDrift';
23
+ }
24
+
25
+ export function verifierDisposition(
26
+ controlPolicy: ControlPolicy,
27
+ issue: SchemaDiffIssue,
28
+ ): VerifierOutcome {
29
+ return dispositionForCategory(controlPolicy, classifyStorageTypeDiffIssue(issue));
30
+ }
@@ -6,18 +6,15 @@ import {
6
6
  type AnyEntityKindDescriptor,
7
7
  hydrateNamespaceEntities,
8
8
  type Namespace,
9
- NamespaceBase,
10
- UNBOUND_NAMESPACE_ID,
11
9
  } from '@prisma-next/framework-components/ir';
12
10
  import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks';
13
11
  import { composeSqlEntityKinds } from '@prisma-next/sql-contract/entity-kinds';
14
12
  import {
15
- buildSqlNamespace,
16
- type SqlNamespaceTablesInput,
13
+ isMaterializedSqlNamespace,
14
+ type SqlNamespaceInput,
17
15
  SqlStorage,
18
16
  type SqlStorageInput,
19
17
  type SqlStorageTypeEntry,
20
- SqlUnboundNamespace,
21
18
  } from '@prisma-next/sql-contract/types';
22
19
  import {
23
20
  createSqlContractSchema,
@@ -25,7 +22,7 @@ import {
25
22
  } from '@prisma-next/sql-contract/validators';
26
23
  import { blindCast } from '@prisma-next/utils/casts';
27
24
  import { ifDefined } from '@prisma-next/utils/defined';
28
- import type { JsonObject } from '@prisma-next/utils/json';
25
+ import type { JsonObject, JsonValue } from '@prisma-next/utils/json';
29
26
  import { type Type, type } from 'arktype';
30
27
 
31
28
  const NamespaceRawSchema = type({
@@ -120,77 +117,48 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
120
117
  'structural',
121
118
  );
122
119
  }
123
- const hydratedNamespaces = this.hydrateSqlNamespaceMap(rawNamespaces);
124
- // Compatibility shim: production code that addresses `__unbound__` for table
125
- // metadata lookups (collection-contract, query-plan-mutations, model-accessor,
126
- // query-plan-meta, where-binding) uses optional chaining and tolerates absence,
127
- // but runtime-qualification (TML-2605) has not yet landed cross-namespace table
128
- // routing. Injecting the empty singleton here keeps helpers that augment the
129
- // deserialized JSON (e.g. buildMixedPolyContract) working by providing a slot to
130
- // write into. Once runtime-qualification routes table lookups by namespace, this
131
- // shim should be removed.
132
- //
133
- // TML-2916: the shim only fires when the target's default namespace IS unbound
134
- // (SQLite, Mongo). On Postgres (`defaultNamespaceId === 'public'`) injecting an
135
- // empty `__unbound__` slot violates ADR 223 — un-namespaced PG models belong in
136
- // `public`, not `__unbound__`.
137
- const withInjectedUnbound =
138
- this.defaultNamespaceId === UNBOUND_NAMESPACE_ID
139
- ? {
140
- ...hydratedNamespaces,
141
- [UNBOUND_NAMESPACE_ID]:
142
- hydratedNamespaces[UNBOUND_NAMESPACE_ID] ?? SqlUnboundNamespace.instance,
143
- }
144
- : hydratedNamespaces;
120
+ const hydratedNamespaces = this.hydrateSqlNamespaceMap(
121
+ blindCast<
122
+ Readonly<Record<string, Record<string, unknown>>>,
123
+ 'parseSqlContractStructure validated raw JSON; namespace entries are plain objects, not SqlNamespace instances.'
124
+ >(rawNamespaces),
125
+ );
145
126
 
146
127
  return {
147
128
  ...validated,
148
129
  storage: new SqlStorage({
149
130
  storageHash: validated.storage.storageHash,
150
131
  ...ifDefined('types', hydratedTypes),
151
- // Cast narrows the result of hydrateSqlNamespaceMap from the wider
152
- // framework `Namespace` to the SQL-family `SqlNamespace`.
153
132
  namespaces: blindCast<
154
133
  SqlStorageInput['namespaces'],
155
- 'hydrateSqlNamespaceMap builds each namespace through the SQL family concretions (SqlBoundNamespace / target schema), so every value is a SqlNamespace; the framework return type only promises the base Namespace.'
156
- >(withInjectedUnbound),
134
+ 'hydrateSqlNamespaceMap builds each namespace through the target serializer override, so every value is a SqlNamespace; the framework return type only promises the base Namespace.'
135
+ >(hydratedNamespaces),
157
136
  }),
158
137
  };
159
138
  }
160
139
 
161
- protected abstract get defaultNamespaceId(): string;
162
-
163
140
  protected hydrateSqlNamespaceMap(
164
- namespaces: Readonly<Record<string, Namespace | Record<string, unknown>>>,
141
+ namespaces: Readonly<Record<string, Record<string, unknown>>>,
165
142
  ): Readonly<Record<string, Namespace>> {
166
143
  return Object.fromEntries(
167
144
  Object.entries(namespaces).map(([nsId, namespaceEntryRaw]) => {
168
- // Raw entries passed structural validation; hydrate materialises family IR class instances.
169
145
  const namespaceHydrated = this.hydrateSqlNamespaceEntry(nsId, namespaceEntryRaw);
170
- const namespaceMaterialised =
171
- namespaceHydrated instanceof NamespaceBase
172
- ? namespaceHydrated
173
- : buildSqlNamespace(
174
- blindCast<
175
- SqlNamespaceTablesInput,
176
- 'hydrateSqlNamespaceEntry returns SqlNamespaceTablesInput when raw is not a NamespaceBase'
177
- >(namespaceHydrated),
178
- );
179
- return [nsId, namespaceMaterialised];
146
+ if (!isMaterializedSqlNamespace(namespaceHydrated)) {
147
+ throw new Error(
148
+ `Target serializer bug: hydrateSqlNamespaceEntry for namespace "${nsId}" returned a non-NamespaceBase value. Override hydrateSqlNamespaceEntry to produce a target namespace concretion.`,
149
+ );
150
+ }
151
+ return [nsId, namespaceHydrated];
180
152
  }),
181
153
  );
182
154
  }
183
155
 
184
156
  protected hydrateSqlNamespaceEntry(
185
157
  nsId: string,
186
- raw: Namespace | Record<string, unknown>,
187
- ): Namespace | SqlNamespaceTablesInput {
188
- if (raw instanceof NamespaceBase) {
189
- return raw;
190
- }
191
- const rawRecord = isPlainRecord(raw) ? raw : {};
192
- const id = typeof rawRecord['id'] === 'string' ? rawRecord['id'] : nsId;
193
- const parsed = NamespaceRawSchema({ ...rawRecord, id });
158
+ raw: Record<string, unknown>,
159
+ ): Namespace | SqlNamespaceInput {
160
+ const id = typeof raw['id'] === 'string' ? raw['id'] : nsId;
161
+ const parsed = NamespaceRawSchema({ ...raw, id });
194
162
  if (parsed instanceof type.errors) {
195
163
  const messages = parsed.map((p: { message: string }) => p.message).join('; ');
196
164
  throw new ContractValidationError(`Namespace hydration failed: ${messages}`, 'structural');
@@ -211,8 +179,8 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
211
179
  }
212
180
 
213
181
  return blindCast<
214
- SqlNamespaceTablesInput,
215
- 'entriesOutput holds the hydrated SQL entity-kind maps (table always present); this wraps them as the SqlNamespaceTablesInput the family createNamespace consumes.'
182
+ SqlNamespaceInput,
183
+ 'entriesOutput holds the hydrated SQL entity-kind maps (table always present); this wraps them as the SqlNamespaceInput the target createNamespace consumes.'
216
184
  >({
217
185
  id,
218
186
  entries: entriesOutput,
@@ -240,4 +208,50 @@ export abstract class SqlContractSerializerBase<TContract extends Contract<SqlSt
240
208
  protected constructTargetContract(hydrated: Contract<SqlStorage>): TContract {
241
209
  return hydrated as TContract;
242
210
  }
211
+
212
+ /**
213
+ * Serializes a namespace's `entries` dict by walking every enumerable
214
+ * kind — no kind is named here, mirroring the generic hydrate walk in
215
+ * `hydrateSqlNamespaceEntry` above. `table` is the SQL family's one
216
+ * universal base kind (every namespace carries it), so it is always
217
+ * emitted, even when empty; every other kind — target- or
218
+ * pack-contributed — is emitted only when it holds at least one entry.
219
+ * A kind carried non-enumerable on `entries` is excluded here for free,
220
+ * since `Object.entries` honors enumerability.
221
+ */
222
+ protected serializeNamespaceEntries(
223
+ entries: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
224
+ ): Record<string, Record<string, JsonObject>> {
225
+ const out: Record<string, Record<string, JsonObject>> = {
226
+ table: this.serializeEntries(entries['table'] ?? {}),
227
+ };
228
+ for (const [kind, record] of Object.entries(entries)) {
229
+ if (kind === 'table' || record == null || Object.keys(record).length === 0) {
230
+ continue;
231
+ }
232
+ out[kind] = this.serializeEntries(record);
233
+ }
234
+ return out;
235
+ }
236
+
237
+ private serializeEntries(entries: Readonly<Record<string, unknown>>): Record<string, JsonObject> {
238
+ const out: Record<string, JsonObject> = {};
239
+ for (const [name, entry] of Object.entries(entries)) {
240
+ out[name] = this.serializeJsonObject(entry);
241
+ }
242
+ return out;
243
+ }
244
+
245
+ protected serializeJsonObject(value: unknown): JsonObject {
246
+ return blindCast<
247
+ JsonObject,
248
+ 'serializeJsonValue round-trips an IR node through JSON, yielding a JsonObject'
249
+ >(this.serializeJsonValue(value));
250
+ }
251
+
252
+ private serializeJsonValue(value: unknown): JsonValue {
253
+ return blindCast<JsonValue, 'JSON.parse(JSON.stringify(x)) yields a JsonValue'>(
254
+ JSON.parse(JSON.stringify(value)),
255
+ );
256
+ }
243
257
  }
@@ -1,5 +1,4 @@
1
1
  import type { Contract } from '@prisma-next/contract/types';
2
- import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
3
2
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
4
3
  import { SqlContractSerializerBase } from './sql-contract-serializer-base';
5
4
 
@@ -11,15 +10,14 @@ import { SqlContractSerializerBase } from './sql-contract-serializer-base';
11
10
  * codec-typed envelope wire a target-specific subclass with a populated
12
11
  * registry (see Postgres). Family-level call sites instantiate this
13
12
  * default directly when no target serializer is supplied.
13
+ *
14
+ * Because this serializer has no target concretion, deserialization of
15
+ * contracts that include namespace entries from JSON will throw unless
16
+ * the caller provides pre-hydrated `NamespaceBase` instances. Production
17
+ * paths always supply a target-specific serializer.
14
18
  */
15
19
  export class SqlContractSerializer extends SqlContractSerializerBase<Contract<SqlStorage>> {
16
20
  constructor() {
17
21
  super(new Map());
18
22
  }
19
-
20
- // Family-level fallback when no target descriptor is wired in. Preserves the
21
- // pre-TML-2916 compatibility-shim behaviour for the unbound slot.
22
- protected override get defaultNamespaceId(): string {
23
- return UNBOUND_NAMESPACE_ID;
24
- }
25
23
  }
@@ -1,5 +1,5 @@
1
1
  import type {
2
- SchemaIssue,
2
+ SchemaDiffIssue,
3
3
  SchemaVerifier,
4
4
  SchemaVerifyOptions,
5
5
  SchemaVerifyResult,
@@ -9,7 +9,7 @@ import type {
9
9
  * SQL family `SchemaVerifier` abstract base. Centralises the SQL-shared
10
10
  * walk (table-by-table + column-by-column matching keyed by
11
11
  * `(namespace.id, name)`, FK / unique / index comparisons via the
12
- * existing helpers in `verify-helpers.ts`) and exposes a protected hook
12
+ * shared satisfaction helpers in `sql-schema-diff.ts`) and exposes a protected hook
13
13
  * for target extensions (Postgres functions, RLS policies, future
14
14
  * target-only kinds).
15
15
  *
@@ -29,7 +29,7 @@ export abstract class SqlSchemaVerifierBase<TContract, TSchema>
29
29
  implements SchemaVerifier<TContract, TSchema>
30
30
  {
31
31
  verifySchema(options: SchemaVerifyOptions<TContract, TSchema>): SchemaVerifyResult {
32
- const issues: SchemaIssue[] = [];
32
+ const issues: SchemaDiffIssue[] = [];
33
33
  issues.push(...this.verifyCommonSqlSchema(options));
34
34
  issues.push(...this.verifyTargetExtensions(options));
35
35
  return { ok: issues.length === 0, issues };
@@ -43,7 +43,7 @@ export abstract class SqlSchemaVerifierBase<TContract, TSchema>
43
43
  */
44
44
  protected abstract verifyCommonSqlSchema(
45
45
  options: SchemaVerifyOptions<TContract, TSchema>,
46
- ): readonly SchemaIssue[];
46
+ ): readonly SchemaDiffIssue[];
47
47
 
48
48
  /**
49
49
  * Target-specific extensions — e.g. Postgres functions, future RLS
@@ -52,5 +52,5 @@ export abstract class SqlSchemaVerifierBase<TContract, TSchema>
52
52
  */
53
53
  protected abstract verifyTargetExtensions(
54
54
  options: SchemaVerifyOptions<TContract, TSchema>,
55
- ): readonly SchemaIssue[];
55
+ ): readonly SchemaDiffIssue[];
56
56
  }