@prisma-next/family-sql 0.12.0-dev.4 → 0.12.0-dev.40

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 (38) hide show
  1. package/dist/control-adapter-uYnmu04p.d.mts +181 -0
  2. package/dist/control-adapter-uYnmu04p.d.mts.map +1 -0
  3. package/dist/control-adapter.d.mts +2 -109
  4. package/dist/control.d.mts +118 -4
  5. package/dist/control.d.mts.map +1 -1
  6. package/dist/control.mjs +214 -29
  7. package/dist/control.mjs.map +1 -1
  8. package/dist/migration.d.mts +1 -1
  9. package/dist/runtime.d.mts +4 -2
  10. package/dist/runtime.d.mts.map +1 -1
  11. package/dist/runtime.mjs +3 -1
  12. package/dist/runtime.mjs.map +1 -1
  13. package/dist/schema-verify.d.mts +2 -1
  14. package/dist/schema-verify.d.mts.map +1 -1
  15. package/dist/schema-verify.mjs +1 -1
  16. package/dist/{types-CeeCStqw.d.mts → types-DxNgxFkF.d.mts} +61 -7
  17. package/dist/types-DxNgxFkF.d.mts.map +1 -0
  18. package/dist/{verify-sql-schema-CN7pPoTC.d.mts → verify-sql-schema-Cj3c2t5Z.d.mts} +8 -2
  19. package/dist/verify-sql-schema-Cj3c2t5Z.d.mts.map +1 -0
  20. package/dist/{verify-sql-schema-CYLsGCFO.mjs → verify-sql-schema-fljAiO-C.mjs} +403 -310
  21. package/dist/verify-sql-schema-fljAiO-C.mjs.map +1 -0
  22. package/package.json +21 -21
  23. package/src/core/control-adapter.ts +112 -3
  24. package/src/core/control-instance.ts +141 -54
  25. package/src/core/default-namespace.ts +9 -0
  26. package/src/core/migrations/control-policy.ts +322 -0
  27. package/src/core/migrations/plan-helpers.ts +16 -0
  28. package/src/core/migrations/types.ts +12 -2
  29. package/src/core/schema-verify/control-verify-emit.ts +46 -0
  30. package/src/core/schema-verify/verifier-disposition.ts +53 -0
  31. package/src/core/schema-verify/verify-helpers.ts +151 -110
  32. package/src/core/schema-verify/verify-sql-schema.ts +291 -155
  33. package/src/exports/control.ts +6 -0
  34. package/src/exports/runtime.ts +7 -0
  35. package/dist/control-adapter.d.mts.map +0 -1
  36. package/dist/types-CeeCStqw.d.mts.map +0 -1
  37. package/dist/verify-sql-schema-CN7pPoTC.d.mts.map +0 -1
  38. package/dist/verify-sql-schema-CYLsGCFO.mjs.map +0 -1
@@ -1,4 +1,8 @@
1
- import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types';
1
+ import type {
2
+ Contract,
3
+ ContractMarkerRecord,
4
+ LedgerEntryRecord,
5
+ } from '@prisma-next/contract/types';
2
6
  import type {
3
7
  TargetBoundComponentDescriptor,
4
8
  TargetDescriptor,
@@ -31,14 +35,10 @@ import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/cano
31
35
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
32
36
  import type {
33
37
  AnyQueryAst,
38
+ DdlNode,
34
39
  LoweredStatement,
35
40
  LowererContext,
36
41
  } from '@prisma-next/sql-relational-core/ast';
37
- import {
38
- ensureSchemaStatement,
39
- ensureTableStatement,
40
- writeContractMarker,
41
- } from '@prisma-next/sql-runtime';
42
42
  import { defaultIndexName } from '@prisma-next/sql-schema-ir/naming';
43
43
  import type { SqlSchemaIR, SqlTableIR } from '@prisma-next/sql-schema-ir/types';
44
44
  import { ifDefined } from '@prisma-next/utils/defined';
@@ -241,30 +241,64 @@ export interface SqlControlFamilyInstance
241
241
 
242
242
  inferPslContract(schemaIR: SqlSchemaIR): PslDocumentAst;
243
243
 
244
- lowerAst(ast: AnyQueryAst, context: LowererContext<unknown>): LoweredStatement;
244
+ lowerAst(ast: AnyQueryAst | DdlNode, context: LowererContext<unknown>): LoweredStatement;
245
+
246
+ /**
247
+ * Inserts the initial marker row for `space` (upsert on `space`).
248
+ * Delegates to the target control adapter's write SPI; see
249
+ * `SqlControlAdapter.initMarker`.
250
+ */
251
+ initMarker(options: {
252
+ readonly driver: ControlDriverInstance<'sql', string>;
253
+ readonly space: string;
254
+ readonly destination: {
255
+ readonly storageHash: string;
256
+ readonly profileHash: string;
257
+ readonly invariants?: readonly string[];
258
+ };
259
+ }): Promise<void>;
260
+
261
+ /**
262
+ * Compare-and-swap advance of the marker row for `space`. Returns `true`
263
+ * when the swap matched a row; see `SqlControlAdapter.updateMarker`.
264
+ */
265
+ updateMarker(options: {
266
+ readonly driver: ControlDriverInstance<'sql', string>;
267
+ readonly space: string;
268
+ readonly expectedFrom: string;
269
+ readonly destination: {
270
+ readonly storageHash: string;
271
+ readonly profileHash: string;
272
+ readonly invariants?: readonly string[];
273
+ };
274
+ }): Promise<boolean>;
275
+
276
+ /**
277
+ * Appends a ledger entry for `space`; see
278
+ * `SqlControlAdapter.writeLedgerEntry`.
279
+ */
280
+ writeLedgerEntry(options: {
281
+ readonly driver: ControlDriverInstance<'sql', string>;
282
+ readonly space: string;
283
+ readonly entry: {
284
+ readonly edgeId: string;
285
+ readonly from: string;
286
+ readonly to: string;
287
+ readonly migrationName: string;
288
+ readonly migrationHash: string;
289
+ readonly operations: readonly unknown[];
290
+ };
291
+ }): Promise<void>;
292
+
293
+ bootstrapControlTableQueries(): readonly DdlNode[];
294
+
295
+ bootstrapSignMarkerQueries(): readonly DdlNode[];
245
296
 
246
297
  toOperationPreview(operations: readonly MigrationPlanOperation[]): OperationPreview;
247
298
  }
248
299
 
249
300
  export type SqlFamilyInstance = SqlControlFamilyInstance;
250
301
 
251
- function isSqlControlAdapter<TTargetId extends string>(
252
- value: unknown,
253
- ): value is SqlControlAdapter<TTargetId> {
254
- return (
255
- typeof value === 'object' &&
256
- value !== null &&
257
- 'introspect' in value &&
258
- typeof (value as { introspect: unknown }).introspect === 'function' &&
259
- 'readMarker' in value &&
260
- typeof (value as { readMarker: unknown }).readMarker === 'function' &&
261
- 'readAllMarkers' in value &&
262
- typeof (value as { readAllMarkers: unknown }).readAllMarkers === 'function' &&
263
- 'lower' in value &&
264
- typeof (value as { lower: unknown }).lower === 'function'
265
- );
266
- }
267
-
268
302
  interface DescriptorWithStorageTypes {
269
303
  readonly targetId?: string | undefined;
270
304
  readonly types?:
@@ -361,19 +395,11 @@ export function createSqlFamilyInstance<TTargetId extends string>(
361
395
  extensionPacks: extensions,
362
396
  });
363
397
 
364
- // Family-instance methods accept `ControlDriverInstance<'sql', string>` —
365
- // the family API isn't generic on the target id. Letting `isSqlControlAdapter`
366
- // default its type parameter narrows the adapter to `SqlControlAdapter<string>`,
367
- // which matches the family-level driver type without any cast at call sites.
368
- const getControlAdapter = () => {
369
- const controlAdapter = adapter.create(stack);
370
- if (!isSqlControlAdapter(controlAdapter)) {
371
- throw new Error(
372
- 'Adapter does not implement SqlControlAdapter (missing introspect, readMarker, or readAllMarkers)',
373
- );
374
- }
375
- return controlAdapter;
376
- };
398
+ // Family-instance methods accept `ControlDriverInstance<'sql', string>` — the
399
+ // family API isn't generic on the target id. The adapter descriptor's `create`
400
+ // returns the concrete `SqlControlAdapter<TTargetId>`; widening the target id to
401
+ // `string` here matches the family-level driver type without a per-method probe.
402
+ const getControlAdapter = (): SqlControlAdapter<string> => adapter.create(stack);
377
403
 
378
404
  const targetSerializer = (
379
405
  target as unknown as {
@@ -540,6 +566,7 @@ export function createSqlFamilyInstance<TTargetId extends string>(
540
566
  frameworkComponents: options.frameworkComponents,
541
567
  ...ifDefined('normalizeDefault', controlAdapter.normalizeDefault),
542
568
  ...ifDefined('normalizeNativeType', controlAdapter.normalizeNativeType),
569
+ ...ifDefined('columnsCompatible', controlAdapter.columnsCompatible),
543
570
  ...ifDefined('resolveExistingEnumValues', resolveExistingEnumValues),
544
571
  });
545
572
  },
@@ -561,24 +588,24 @@ export function createSqlFamilyInstance<TTargetId extends string>(
561
588
  : contractStorageHash;
562
589
  const contractTarget = contract.target;
563
590
 
564
- await driver.query(ensureSchemaStatement.sql, ensureSchemaStatement.params);
565
- await driver.query(ensureTableStatement.sql, ensureTableStatement.params);
591
+ const controlAdapter = getControlAdapter();
592
+ const lowererContext = { contract };
593
+ for (const query of controlAdapter.bootstrapSignMarkerQueries()) {
594
+ const lowered = controlAdapter.lower(query, lowererContext);
595
+ await driver.query(lowered.sql, lowered.params);
596
+ }
566
597
 
567
- const existingMarker = await getControlAdapter().readMarker(driver, APP_SPACE_ID);
598
+ const existingMarker = await controlAdapter.readMarker(driver, APP_SPACE_ID);
568
599
 
569
600
  let markerCreated = false;
570
601
  let markerUpdated = false;
571
602
  let previousHashes: { storageHash?: string; profileHash?: string } | undefined;
572
603
 
573
604
  if (!existingMarker) {
574
- const write = writeContractMarker({
575
- space: APP_SPACE_ID,
605
+ await controlAdapter.insertMarker(driver, APP_SPACE_ID, {
576
606
  storageHash: contractStorageHash,
577
607
  profileHash: contractProfileHash,
578
- contractJson: contractInput,
579
- canonicalVersion: 1,
580
608
  });
581
- await driver.query(write.insert.sql, write.insert.params);
582
609
  markerCreated = true;
583
610
  } else {
584
611
  const existingStorageHash = existingMarker.storageHash;
@@ -592,14 +619,18 @@ export function createSqlFamilyInstance<TTargetId extends string>(
592
619
  storageHash: existingStorageHash,
593
620
  profileHash: existingProfileHash,
594
621
  };
595
- const write = writeContractMarker({
596
- space: APP_SPACE_ID,
597
- storageHash: contractStorageHash,
598
- profileHash: contractProfileHash,
599
- contractJson: contractInput,
600
- canonicalVersion: existingMarker.canonicalVersion ?? 1,
601
- });
602
- await driver.query(write.update.sql, write.update.params);
622
+ const updated = await controlAdapter.updateMarker(
623
+ driver,
624
+ APP_SPACE_ID,
625
+ existingStorageHash,
626
+ {
627
+ storageHash: contractStorageHash,
628
+ profileHash: contractProfileHash,
629
+ },
630
+ );
631
+ if (!updated) {
632
+ throw new Error('CAS conflict: marker was modified by another process during sign');
633
+ }
603
634
  markerUpdated = true;
604
635
  }
605
636
  }
@@ -651,6 +682,54 @@ export function createSqlFamilyInstance<TTargetId extends string>(
651
682
  }): Promise<ReadonlyMap<string, ContractMarkerRecord>> {
652
683
  return getControlAdapter().readAllMarkers(options.driver);
653
684
  },
685
+ async readLedger(options: {
686
+ readonly driver: ControlDriverInstance<'sql', string>;
687
+ readonly space?: string;
688
+ }): Promise<readonly LedgerEntryRecord[]> {
689
+ return getControlAdapter().readLedger(options.driver, options.space);
690
+ },
691
+ async initMarker(options: {
692
+ readonly driver: ControlDriverInstance<'sql', string>;
693
+ readonly space: string;
694
+ readonly destination: {
695
+ readonly storageHash: string;
696
+ readonly profileHash: string;
697
+ readonly invariants?: readonly string[];
698
+ };
699
+ }): Promise<void> {
700
+ return getControlAdapter().initMarker(options.driver, options.space, options.destination);
701
+ },
702
+ async updateMarker(options: {
703
+ readonly driver: ControlDriverInstance<'sql', string>;
704
+ readonly space: string;
705
+ readonly expectedFrom: string;
706
+ readonly destination: {
707
+ readonly storageHash: string;
708
+ readonly profileHash: string;
709
+ readonly invariants?: readonly string[];
710
+ };
711
+ }): Promise<boolean> {
712
+ return getControlAdapter().updateMarker(
713
+ options.driver,
714
+ options.space,
715
+ options.expectedFrom,
716
+ options.destination,
717
+ );
718
+ },
719
+ async writeLedgerEntry(options: {
720
+ readonly driver: ControlDriverInstance<'sql', string>;
721
+ readonly space: string;
722
+ readonly entry: {
723
+ readonly edgeId: string;
724
+ readonly from: string;
725
+ readonly to: string;
726
+ readonly migrationName: string;
727
+ readonly migrationHash: string;
728
+ readonly operations: readonly unknown[];
729
+ };
730
+ }): Promise<void> {
731
+ return getControlAdapter().writeLedgerEntry(options.driver, options.space, options.entry);
732
+ },
654
733
  async introspect(options: {
655
734
  readonly driver: ControlDriverInstance<'sql', string>;
656
735
  readonly contract?: unknown;
@@ -662,10 +741,18 @@ export function createSqlFamilyInstance<TTargetId extends string>(
662
741
  return sqlSchemaIrToPslAst(schemaIR);
663
742
  },
664
743
 
665
- lowerAst(ast: AnyQueryAst, context: LowererContext<unknown>): LoweredStatement {
744
+ lowerAst(ast: AnyQueryAst | DdlNode, context: LowererContext<unknown>): LoweredStatement {
666
745
  return getControlAdapter().lower(ast, context);
667
746
  },
668
747
 
748
+ bootstrapControlTableQueries(): readonly DdlNode[] {
749
+ return getControlAdapter().bootstrapControlTableQueries();
750
+ },
751
+
752
+ bootstrapSignMarkerQueries(): readonly DdlNode[] {
753
+ return getControlAdapter().bootstrapSignMarkerQueries();
754
+ },
755
+
669
756
  toOperationPreview(operations: readonly MigrationPlanOperation[]): OperationPreview {
670
757
  return sqlOperationsToPreview(operations);
671
758
  },
@@ -0,0 +1,9 @@
1
+ export {
2
+ type ResolvedDomainModel,
3
+ resolveDomainModel,
4
+ UNBOUND_DOMAIN_NAMESPACE_ID,
5
+ } from '@prisma-next/contract/types';
6
+ export {
7
+ type ResolvedStorageTable,
8
+ resolveStorageTable,
9
+ } from '@prisma-next/sql-contract/resolve-storage-table';
@@ -0,0 +1,322 @@
1
+ import {
2
+ type Contract,
3
+ type ControlPolicy,
4
+ effectiveControlPolicy,
5
+ } from '@prisma-next/contract/types';
6
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
7
+ import { ifDefined } from '@prisma-next/utils/defined';
8
+ import type { SqlPlannerConflict } from './types';
9
+
10
+ /**
11
+ * The target object a control policy governs for a single planner call,
12
+ * resolved from the target's own IR. `undefined` means the call's target
13
+ * object could not be positively established — a fail-closed signal: any
14
+ * policy stricter than `managed` drops such a call rather than emitting it.
15
+ */
16
+ export interface ControlPolicySubject {
17
+ readonly namespaceId: string;
18
+ readonly explicitNodeControlPolicy?: ControlPolicy;
19
+ readonly table?: string;
20
+ readonly column?: string;
21
+ readonly typeName?: string;
22
+ /**
23
+ * Whether the call creates a whole, previously-absent top-level storage
24
+ * object (e.g. a table or an enum/type), as opposed to modifying an
25
+ * existing object. This is the only thing `tolerated` permits: it is a
26
+ * create-if-absent policy, so an op that touches an existing object — add
27
+ * column, add index/constraint, alter, drop — is never allowed under it.
28
+ */
29
+ readonly createsNewObject: boolean;
30
+ }
31
+
32
+ /**
33
+ * The control policy that governs a single call. The `external` default is an
34
+ * un-overridable namespace floor: when the contract default is `external`, no
35
+ * per-object `managed` override can escalate DDL above the floor, so the
36
+ * policy is forced to `external` regardless of the node's own declaration.
37
+ * Every other default defers to the node's effective control policy.
38
+ */
39
+ export function controlPolicyForCall(
40
+ subject: ControlPolicySubject | undefined,
41
+ defaultControlPolicy: ControlPolicy | undefined,
42
+ ): ControlPolicy {
43
+ if (defaultControlPolicy === 'external') {
44
+ return 'external';
45
+ }
46
+ return effectiveControlPolicy(subject?.explicitNodeControlPolicy, defaultControlPolicy);
47
+ }
48
+
49
+ /**
50
+ * Whether a call is allowed to emit under a given control policy.
51
+ *
52
+ * - `managed` — full lifecycle, every op allowed.
53
+ * - `tolerated` — create-if-absent only: allowed iff the call creates a whole
54
+ * new top-level object (and its subject was positively resolved). Anything
55
+ * that modifies an existing object, and anything whose subject could not be
56
+ * resolved, is suppressed.
57
+ * - `external` / `observed` — no DDL at all.
58
+ */
59
+ function callAllowedUnderControlPolicy(
60
+ policy: ControlPolicy,
61
+ subject: ControlPolicySubject | undefined,
62
+ ): boolean {
63
+ switch (policy) {
64
+ case 'managed':
65
+ return true;
66
+ case 'tolerated':
67
+ return subject?.createsNewObject === true;
68
+ case 'external':
69
+ case 'observed':
70
+ return false;
71
+ }
72
+ }
73
+
74
+ function defaultSubjectLabel(
75
+ factoryName: string,
76
+ subject: ControlPolicySubject | undefined,
77
+ ): string {
78
+ if (subject?.table) {
79
+ return `${factoryName}(${subject.table})`;
80
+ }
81
+ if (subject?.typeName) {
82
+ return `${factoryName}(${subject.typeName})`;
83
+ }
84
+ return factoryName;
85
+ }
86
+
87
+ function suppressionSummary(
88
+ subjectLabel: string,
89
+ subject: ControlPolicySubject | undefined,
90
+ effectivePolicy: ControlPolicy,
91
+ ): string {
92
+ const namespace = subject?.namespaceId ?? 'unknown';
93
+ const declared = subject?.explicitNodeControlPolicy;
94
+ if (effectivePolicy === 'external' && declared === 'managed') {
95
+ return `control policy suppressed: ${subjectLabel} — namespace '${namespace}' has effective control 'external' but table declared 'managed'`;
96
+ }
97
+ const declaredSuffix = declared ? ` but table declared '${declared}'` : '';
98
+ return `control policy suppressed: ${subjectLabel} — namespace '${namespace}' has effective control '${effectivePolicy}'${declaredSuffix}`;
99
+ }
100
+
101
+ function buildSubjectSuppressionWarning(
102
+ subject: ControlPolicySubject | undefined,
103
+ effectivePolicy: ControlPolicy,
104
+ factoryName: string,
105
+ formatSubjectLabel: (factoryName: string, subject: ControlPolicySubject | undefined) => string,
106
+ ): SqlPlannerConflict {
107
+ const subjectLabel = formatSubjectLabel(factoryName, subject);
108
+ return {
109
+ kind: 'controlPolicySuppressedCall',
110
+ summary: suppressionSummary(subjectLabel, subject, effectivePolicy),
111
+ location: {
112
+ ...ifDefined('namespace', subject?.namespaceId),
113
+ ...ifDefined('table', subject?.table),
114
+ ...ifDefined('column', subject?.column),
115
+ ...ifDefined('type', subject?.typeName),
116
+ },
117
+ meta: {
118
+ controlPolicy: effectivePolicy,
119
+ factoryName,
120
+ ...ifDefined('declaredControlPolicy', subject?.explicitNodeControlPolicy),
121
+ },
122
+ };
123
+ }
124
+
125
+ function defaultModificationFactoryNameForSubject(subject: ControlPolicySubject): string {
126
+ if (subject.table) return 'alterTable';
127
+ if (subject.typeName) return 'alterType';
128
+ return 'alterSchema';
129
+ }
130
+
131
+ /**
132
+ * Partition the calls produced for a single set of subjects into those the
133
+ * effective control policy permits (`kept`) and a list of
134
+ * {@link SqlPlannerConflict} warnings describing the suppressed calls.
135
+ *
136
+ * **Prefer {@link partitionIssuesByControlPolicy}** for the schema-issue
137
+ * pipeline: it filters subjects out of the planner's *input* so the planner
138
+ * never has to reason about un-modeled state on `external`/`observed`
139
+ * subjects. This call-level helper remains for paths that bypass the issue
140
+ * pipeline — currently the codec-emitted field-event ops, which originate
141
+ * from declared contract fields rather than from introspected schema state
142
+ * and therefore cannot trip the diff engine.
143
+ */
144
+ export function partitionCallsByControlPolicy<TCall>(options: {
145
+ readonly calls: readonly TCall[];
146
+ readonly contract: Contract<SqlStorage>;
147
+ readonly resolveControlPolicySubject: (call: TCall) => ControlPolicySubject | undefined;
148
+ readonly resolveFactoryName: (call: TCall) => string;
149
+ readonly formatSubjectLabel?: (
150
+ factoryName: string,
151
+ subject: ControlPolicySubject | undefined,
152
+ ) => string;
153
+ }): {
154
+ readonly kept: readonly TCall[];
155
+ readonly warnings: readonly SqlPlannerConflict[];
156
+ } {
157
+ const defaultControlPolicy = options.contract.defaultControlPolicy;
158
+ const formatSubjectLabel = options.formatSubjectLabel ?? defaultSubjectLabel;
159
+ const kept: TCall[] = [];
160
+ const warnings: SqlPlannerConflict[] = [];
161
+
162
+ for (const call of options.calls) {
163
+ const subject = options.resolveControlPolicySubject(call);
164
+ const policy = controlPolicyForCall(subject, defaultControlPolicy);
165
+ if (callAllowedUnderControlPolicy(policy, subject)) {
166
+ kept.push(call);
167
+ } else {
168
+ const factoryName = options.resolveFactoryName(call);
169
+ warnings.push(
170
+ buildSubjectSuppressionWarning(subject, policy, factoryName, formatSubjectLabel),
171
+ );
172
+ }
173
+ }
174
+
175
+ return Object.freeze({
176
+ kept: Object.freeze(kept),
177
+ warnings: Object.freeze(warnings),
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Partition a list of schema-issue-shaped inputs by the effective control
183
+ * policy of each issue's subject *before* the planner is invoked.
184
+ *
185
+ * `plannable` is the list of issues whose subject's effective policy permits
186
+ * the planner to act on them (`managed`, or `tolerated` for whole-object
187
+ * creation issues only). Issues for `external`/`observed` subjects, and
188
+ * non-creation issues for `tolerated` subjects, are dropped from the planner's
189
+ * input entirely — they never enter introspection-driven planning, never feed
190
+ * the diff engine, and never produce DDL calls that would have to be
191
+ * post-filtered. This sidesteps a class of failure where the diff engine
192
+ * cannot reason about the live shape of a subject the user marked as
193
+ * out-of-scope (`external`).
194
+ *
195
+ * `warnings` is one {@link SqlPlannerConflict} per suppressed subject (not per
196
+ * suppressed issue). `factoryName` is inferred from the subject's issue mix:
197
+ * if any of the subject's issues is whole-object creation, the warning takes
198
+ * the corresponding creation factoryName (e.g. `createTable`,
199
+ * `createEnumType`, `createSchema`); otherwise it falls back to
200
+ * `defaultModificationFactoryName(subject)` — a synthetic label that names
201
+ * the *kind* of mutation that would have run, since no concrete DDL call was
202
+ * generated.
203
+ *
204
+ * Unresolved-subject issues (`resolveControlPolicySubject` returns
205
+ * `undefined`) emit one warning each; they cannot be deduplicated because
206
+ * they carry no subject coordinate.
207
+ */
208
+ export function partitionIssuesByControlPolicy<TIssue>(options: {
209
+ readonly issues: readonly TIssue[];
210
+ readonly contract: Contract<SqlStorage>;
211
+ /**
212
+ * Resolve the subject targeted by this issue (or `undefined` to fail-closed:
213
+ * any policy stricter than `managed` drops the issue).
214
+ */
215
+ readonly resolveControlPolicySubject: (issue: TIssue) => ControlPolicySubject | undefined;
216
+ /**
217
+ * Resolve a creation factoryName for this issue if it represents the
218
+ * absence of the whole top-level object (e.g. `'createTable'` for a
219
+ * missing-table issue). When the issue describes a modification to an
220
+ * existing object, return `undefined`. Both decisions feed off this signal:
221
+ *
222
+ * 1. Under `tolerated`, only issues whose `resolveCreationFactoryName`
223
+ * returns a value flow into the planner (create-if-absent).
224
+ * 2. Subjects that have at least one creation-flavoured issue use the
225
+ * resolved creation factoryName for their suppression warning;
226
+ * otherwise they fall back to `defaultModificationFactoryName`.
227
+ */
228
+ readonly resolveCreationFactoryName: (issue: TIssue) => string | undefined;
229
+ /**
230
+ * Default modification factoryName for a suppressed subject whose issues
231
+ * are all non-creation (the subject exists but has a different shape).
232
+ * Defaults to `'alterTable'` / `'alterType'` / `'alterSchema'` based on the
233
+ * subject's populated coordinates.
234
+ */
235
+ readonly defaultModificationFactoryName?: (subject: ControlPolicySubject) => string;
236
+ readonly formatSubjectLabel?: (
237
+ factoryName: string,
238
+ subject: ControlPolicySubject | undefined,
239
+ ) => string;
240
+ }): {
241
+ readonly plannable: readonly TIssue[];
242
+ readonly warnings: readonly SqlPlannerConflict[];
243
+ } {
244
+ const defaultControlPolicy = options.contract.defaultControlPolicy;
245
+ const formatSubjectLabel = options.formatSubjectLabel ?? defaultSubjectLabel;
246
+ const inferModificationFactoryName =
247
+ options.defaultModificationFactoryName ?? defaultModificationFactoryNameForSubject;
248
+
249
+ const plannable: TIssue[] = [];
250
+ // Resolved-subject suppressions are deduplicated by subject key so we emit
251
+ // one warning per suppressed subject, not one per suppressed issue.
252
+ // `creationFactoryName` upgrades from `undefined` to a concrete creation
253
+ // name the first time we see a creation-flavoured issue for the subject.
254
+ const suppressedSubjects = new Map<
255
+ string,
256
+ {
257
+ readonly subject: ControlPolicySubject;
258
+ readonly policy: ControlPolicy;
259
+ creationFactoryName?: string;
260
+ }
261
+ >();
262
+ const unresolvedSuppressions: SqlPlannerConflict[] = [];
263
+
264
+ for (const issue of options.issues) {
265
+ const subject = options.resolveControlPolicySubject(issue);
266
+ const policy = controlPolicyForCall(subject, defaultControlPolicy);
267
+ const creationFactoryName = options.resolveCreationFactoryName(issue);
268
+
269
+ if (policy === 'managed') {
270
+ plannable.push(issue);
271
+ continue;
272
+ }
273
+ if (
274
+ policy === 'tolerated' &&
275
+ subject !== undefined &&
276
+ creationFactoryName !== undefined &&
277
+ subject.createsNewObject
278
+ ) {
279
+ plannable.push(issue);
280
+ continue;
281
+ }
282
+
283
+ if (subject === undefined) {
284
+ const factoryName = creationFactoryName ?? 'unknown';
285
+ unresolvedSuppressions.push(
286
+ buildSubjectSuppressionWarning(undefined, policy, factoryName, formatSubjectLabel),
287
+ );
288
+ continue;
289
+ }
290
+
291
+ const key = subjectKey(subject);
292
+ const existing = suppressedSubjects.get(key);
293
+ if (existing) {
294
+ if (existing.creationFactoryName === undefined && creationFactoryName !== undefined) {
295
+ existing.creationFactoryName = creationFactoryName;
296
+ }
297
+ } else {
298
+ suppressedSubjects.set(key, {
299
+ subject,
300
+ policy,
301
+ ...ifDefined('creationFactoryName', creationFactoryName),
302
+ });
303
+ }
304
+ }
305
+
306
+ const warnings: SqlPlannerConflict[] = [...unresolvedSuppressions];
307
+ for (const entry of suppressedSubjects.values()) {
308
+ const factoryName = entry.creationFactoryName ?? inferModificationFactoryName(entry.subject);
309
+ warnings.push(
310
+ buildSubjectSuppressionWarning(entry.subject, entry.policy, factoryName, formatSubjectLabel),
311
+ );
312
+ }
313
+
314
+ return Object.freeze({
315
+ plannable: Object.freeze(plannable),
316
+ warnings: Object.freeze(warnings),
317
+ });
318
+ }
319
+
320
+ function subjectKey(subject: ControlPolicySubject): string {
321
+ return `${subject.namespaceId}\u0000${subject.table ?? ''}\u0000${subject.typeName ?? ''}`;
322
+ }
@@ -111,10 +111,26 @@ export function createMigrationPlan<TTargetDetails>(
111
111
 
112
112
  export function plannerSuccess<TTargetDetails>(
113
113
  plan: SqlMigrationPlan<TTargetDetails>,
114
+ warnings?: readonly SqlPlannerConflict[],
114
115
  ): SqlPlannerSuccessResult<TTargetDetails> {
115
116
  return Object.freeze({
116
117
  kind: 'success',
117
118
  plan,
119
+ ...(warnings && warnings.length > 0
120
+ ? {
121
+ warnings: Object.freeze(
122
+ warnings.map((conflict) =>
123
+ Object.freeze({
124
+ kind: conflict.kind,
125
+ summary: conflict.summary,
126
+ ...(conflict.why ? { why: conflict.why } : {}),
127
+ ...(conflict.location ? { location: Object.freeze({ ...conflict.location }) } : {}),
128
+ ...(conflict.meta ? { meta: cloneRecord(conflict.meta) } : {}),
129
+ }),
130
+ ),
131
+ ),
132
+ }
133
+ : {}),
118
134
  });
119
135
  }
120
136
 
@@ -22,6 +22,7 @@ import type {
22
22
  SchemaIssue,
23
23
  SchemaVerifier,
24
24
  } from '@prisma-next/framework-components/control';
25
+ import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate';
25
26
  import type {
26
27
  SqlStorage,
27
28
  StorageColumn,
@@ -31,6 +32,7 @@ import type {
31
32
  import type { SqlOperationDescriptors } from '@prisma-next/sql-operations';
32
33
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
33
34
  import type { Result } from '@prisma-next/utils/result';
35
+ import type { SqlControlAdapter } from '../control-adapter';
34
36
  import type { SqlControlFamilyInstance } from '../control-instance';
35
37
 
36
38
  export type AnyRecord = Readonly<Record<string, unknown>>;
@@ -174,7 +176,7 @@ export interface SqlControlExtensionDescriptor<TTargetId extends string>
174
176
  }
175
177
 
176
178
  export interface SqlControlAdapterDescriptor<TTargetId extends string>
177
- extends ControlAdapterDescriptor<'sql', TTargetId> {
179
+ extends ControlAdapterDescriptor<'sql', TTargetId, SqlControlAdapter<TTargetId>> {
178
180
  readonly queryOperations?: () => SqlOperationDescriptors;
179
181
  }
180
182
 
@@ -268,9 +270,11 @@ export type SqlPlannerConflictKind =
268
270
  | 'indexIncompatible'
269
271
  | 'foreignKeyConflict'
270
272
  | 'missingButNonAdditive'
271
- | 'unsupportedOperation';
273
+ | 'unsupportedOperation'
274
+ | 'controlPolicySuppressedCall';
272
275
 
273
276
  export interface SqlPlannerConflictLocation {
277
+ readonly namespace?: string;
274
278
  readonly table?: string;
275
279
  readonly column?: string;
276
280
  readonly index?: string;
@@ -384,12 +388,18 @@ export interface SqlMigrationRunnerExecuteOptions<TTargetDetails> {
384
388
  * All components must have matching familyId ('sql') and targetId.
385
389
  */
386
390
  readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
391
+ /**
392
+ * Per-edge breakdown from graph-walk planning. When present, the runner
393
+ * writes one ledger row per edge instead of one collapsed row per apply.
394
+ */
395
+ readonly migrationEdges: readonly AggregateMigrationEdgeRef[];
387
396
  }
388
397
 
389
398
  export type SqlMigrationRunnerErrorCode =
390
399
  | 'DESTINATION_CONTRACT_MISMATCH'
391
400
  | 'LEGACY_MARKER_SHAPE'
392
401
  | 'MARKER_ORIGIN_MISMATCH'
402
+ | 'MARKER_CAS_FAILURE'
393
403
  | 'POLICY_VIOLATION'
394
404
  | 'PRECHECK_FAILED'
395
405
  | 'POSTCHECK_FAILED'