@prisma-next/cli 0.4.0-dev.6 → 0.4.0-dev.7

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 (47) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +3 -3
  3. package/dist/{client-DUs1DH-1.mjs → client-CJxHfhze.mjs} +3 -1
  4. package/dist/client-CJxHfhze.mjs.map +1 -0
  5. package/dist/commands/contract-emit.mjs +1 -1
  6. package/dist/commands/contract-infer.mjs +1 -1
  7. package/dist/commands/db-init.mjs +2 -2
  8. package/dist/commands/db-schema.mjs +1 -1
  9. package/dist/commands/db-sign.mjs +1 -1
  10. package/dist/commands/db-update.mjs +2 -2
  11. package/dist/commands/db-verify.mjs +1 -1
  12. package/dist/commands/migration-apply.mjs +1 -1
  13. package/dist/commands/migration-emit.mjs +2 -2
  14. package/dist/commands/migration-emit.mjs.map +1 -1
  15. package/dist/commands/migration-new.d.mts.map +1 -1
  16. package/dist/commands/migration-new.mjs +31 -8
  17. package/dist/commands/migration-new.mjs.map +1 -1
  18. package/dist/commands/migration-plan.d.mts.map +1 -1
  19. package/dist/commands/migration-plan.mjs +91 -60
  20. package/dist/commands/migration-plan.mjs.map +1 -1
  21. package/dist/commands/migration-status.mjs +1 -1
  22. package/dist/{contract-emit-7jqH6dq9.mjs → contract-emit-gpJNLGs7.mjs} +2 -2
  23. package/dist/{contract-emit-7jqH6dq9.mjs.map → contract-emit-gpJNLGs7.mjs.map} +1 -1
  24. package/dist/{contract-infer-BFlIbyl9.mjs → contract-infer-BDJgg7Xb.mjs} +2 -2
  25. package/dist/{contract-infer-BFlIbyl9.mjs.map → contract-infer-BDJgg7Xb.mjs.map} +1 -1
  26. package/dist/exports/control-api.mjs +1 -1
  27. package/dist/exports/index.mjs +1 -1
  28. package/dist/{inspect-live-schema-gjmUZ8xm.mjs → inspect-live-schema-ChqrALmw.mjs} +2 -2
  29. package/dist/{inspect-live-schema-gjmUZ8xm.mjs.map → inspect-live-schema-ChqrALmw.mjs.map} +1 -1
  30. package/dist/{migration-command-scaffold-CfllKppa.mjs → migration-command-scaffold-B0oH_hyB.mjs} +2 -2
  31. package/dist/{migration-command-scaffold-CfllKppa.mjs.map → migration-command-scaffold-B0oH_hyB.mjs.map} +1 -1
  32. package/dist/migration-emit-Du4DBMqz.mjs +125 -0
  33. package/dist/migration-emit-Du4DBMqz.mjs.map +1 -0
  34. package/dist/{migration-status-BwKCQB_a.mjs → migration-status-CPamfEPj.mjs} +2 -2
  35. package/dist/{migration-status-BwKCQB_a.mjs.map → migration-status-CPamfEPj.mjs.map} +1 -1
  36. package/package.json +16 -16
  37. package/src/commands/migration-emit.ts +3 -1
  38. package/src/commands/migration-new.ts +48 -13
  39. package/src/commands/migration-plan.ts +140 -90
  40. package/src/control-api/operations/db-init.ts +3 -0
  41. package/src/control-api/operations/db-update.ts +3 -0
  42. package/src/lib/migration-emit.ts +36 -24
  43. package/src/lib/migration-strategy.ts +49 -0
  44. package/src/utils/cli-errors.ts +2 -0
  45. package/dist/client-DUs1DH-1.mjs.map +0 -1
  46. package/dist/migration-emit-dRXV6QSz.mjs +0 -72
  47. package/dist/migration-emit-dRXV6QSz.mjs.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prisma-next/cli",
3
- "version": "0.4.0-dev.6",
3
+ "version": "0.4.0-dev.7",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "files": [
@@ -24,28 +24,28 @@
24
24
  "string-width": "^8.2.0",
25
25
  "strip-ansi": "^7.1.2",
26
26
  "wrap-ansi": "^10.0.0",
27
- "@prisma-next/config": "0.4.0-dev.6",
28
- "@prisma-next/contract": "0.4.0-dev.6",
29
- "@prisma-next/emitter": "0.4.0-dev.6",
30
- "@prisma-next/framework-components": "0.4.0-dev.6",
31
- "@prisma-next/psl-printer": "0.4.0-dev.6",
32
- "@prisma-next/migration-tools": "0.4.0-dev.6",
33
- "@prisma-next/errors": "0.4.0-dev.6",
34
- "@prisma-next/utils": "0.4.0-dev.6"
27
+ "@prisma-next/config": "0.4.0-dev.7",
28
+ "@prisma-next/contract": "0.4.0-dev.7",
29
+ "@prisma-next/emitter": "0.4.0-dev.7",
30
+ "@prisma-next/framework-components": "0.4.0-dev.7",
31
+ "@prisma-next/errors": "0.4.0-dev.7",
32
+ "@prisma-next/psl-printer": "0.4.0-dev.7",
33
+ "@prisma-next/utils": "0.4.0-dev.7",
34
+ "@prisma-next/migration-tools": "0.4.0-dev.7"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "24.10.4",
38
38
  "tsdown": "0.18.4",
39
39
  "typescript": "5.9.3",
40
40
  "vitest": "4.0.17",
41
- "@prisma-next/sql-contract": "0.4.0-dev.6",
42
- "@prisma-next/sql-contract-emitter": "0.4.0-dev.6",
43
- "@prisma-next/sql-contract-ts": "0.4.0-dev.6",
44
- "@prisma-next/sql-operations": "0.4.0-dev.6",
45
- "@prisma-next/tsdown": "0.0.0",
41
+ "@prisma-next/sql-contract-emitter": "0.4.0-dev.7",
42
+ "@prisma-next/sql-contract": "0.4.0-dev.7",
43
+ "@prisma-next/sql-contract-ts": "0.4.0-dev.7",
44
+ "@prisma-next/sql-runtime": "0.4.0-dev.7",
45
+ "@prisma-next/sql-operations": "0.4.0-dev.7",
46
46
  "@prisma-next/tsconfig": "0.0.0",
47
- "@prisma-next/sql-runtime": "0.4.0-dev.6",
48
- "@prisma-next/test-utils": "0.0.1"
47
+ "@prisma-next/test-utils": "0.0.1",
48
+ "@prisma-next/tsdown": "0.0.0"
49
49
  },
50
50
  "exports": {
51
51
  ".": {
@@ -105,7 +105,9 @@ export function createMigrationEmitCommand(): Command {
105
105
  command,
106
106
  'Emit ops.json from migration.ts and compute migrationId',
107
107
  'Evaluates migration.ts in the package directory, resolves it to ops.json,\n' +
108
- 'then computes and persists the content-addressed migrationId in manifest.json.',
108
+ 'then computes and persists the content-addressed migrationId in migration.json.\n' +
109
+ 'If the file contains unfilled placeholder() slots, emit fails with PN-MIG-2001\n' +
110
+ 'and reports the slot to fill in.',
109
111
  );
110
112
  setCommandExamples(command, ['prisma-next migration emit --dir migrations/20250101-add-users']);
111
113
  addGlobalOptions(command)
@@ -1,33 +1,47 @@
1
1
  /**
2
- * `migration new` — scaffolds a migration package with a migration.ts file
3
- * for manual authoring. The user writes operation descriptors and data
4
- * transforms; `migration emit` resolves them to ops.json.
2
+ * `migration new` — scaffolds a migration package with a `migration.ts` file
3
+ * for manual authoring.
4
+ *
5
+ * Both descriptor-flow (Postgres) and class-flow (Mongo) targets go through
6
+ * the same path here: the planner's `emptyMigration(context)` returns a
7
+ * `MigrationPlanWithAuthoringSurface`, whose `renderTypeScript()` produces
8
+ * the target-appropriate empty stub. The CLI writes the returned source
9
+ * verbatim.
5
10
  */
6
11
 
7
12
  import { readFileSync } from 'node:fs';
8
13
  import type { Contract } from '@prisma-next/contract/types';
14
+ import { createControlStack } from '@prisma-next/framework-components/control';
9
15
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
10
16
  import { findLatestMigration, reconstructGraph } from '@prisma-next/migration-tools/dag';
11
17
  import {
18
+ copyContractToMigrationDir,
12
19
  formatMigrationDirName,
13
20
  readMigrationsDir,
14
21
  writeMigrationPackage,
15
22
  } from '@prisma-next/migration-tools/io';
16
- import { scaffoldMigrationTs } from '@prisma-next/migration-tools/migration-ts';
23
+ import { writeMigrationTs } from '@prisma-next/migration-tools/migration-ts';
17
24
  import type { MigrationManifest } from '@prisma-next/migration-tools/types';
18
25
  import { isAttested, MigrationToolsError } from '@prisma-next/migration-tools/types';
19
26
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
20
27
  import { Command } from 'commander';
21
28
  import { join, relative, resolve } from 'pathe';
22
29
  import { loadConfig } from '../config-loader';
23
- import { type CliStructuredError, errorRuntime, errorUnexpected } from '../utils/cli-errors';
30
+ import {
31
+ CliStructuredError,
32
+ errorRuntime,
33
+ errorTargetMigrationNotSupported,
34
+ errorUnexpected,
35
+ } from '../utils/cli-errors';
24
36
  import {
25
37
  addGlobalOptions,
38
+ getTargetMigrations,
26
39
  resolveMigrationPaths,
27
40
  setCommandDescriptions,
28
41
  setCommandExamples,
29
42
  } from '../utils/command-helpers';
30
43
  import { formatStyledHeader } from '../utils/formatters/styled';
44
+ import { assertFrameworkComponentsCompatible } from '../utils/framework-components';
31
45
  import type { CommonCommandOptions } from '../utils/global-flags';
32
46
  import { parseGlobalFlags } from '../utils/global-flags';
33
47
  import { handleResult } from '../utils/result-handler';
@@ -53,7 +67,6 @@ async function executeMigrationNewCommand(
53
67
  const config = await loadConfig(options.config);
54
68
  const { migrationsDir, migrationsRelative } = resolveMigrationPaths(options.config, config);
55
69
 
56
- // Read the emitted contract (destination)
57
70
  const contractPath = config.contract?.output ?? 'contract.json';
58
71
  const contractPathAbsolute = resolve(
59
72
  options.config ? resolve(options.config, '..') : process.cwd(),
@@ -101,7 +114,6 @@ async function executeMigrationNewCommand(
101
114
  );
102
115
  }
103
116
 
104
- // Determine "from" hash
105
117
  let fromContract: Contract | null = null;
106
118
  let fromHash: string = EMPTY_CONTRACT_HASH;
107
119
 
@@ -113,7 +125,6 @@ async function executeMigrationNewCommand(
113
125
  const graph = reconstructGraph(attested);
114
126
 
115
127
  if (options.from) {
116
- // Explicit --from: find the migration with matching to hash
117
128
  const match = attested.find((p) => p.manifest.to.startsWith(options.from!));
118
129
  if (!match) {
119
130
  return notOk(
@@ -151,7 +162,6 @@ async function executeMigrationNewCommand(
151
162
  throw error;
152
163
  }
153
164
 
154
- // Check for no-op
155
165
  if (fromHash === toStorageHash) {
156
166
  return notOk(
157
167
  errorRuntime('No changes detected', {
@@ -161,7 +171,6 @@ async function executeMigrationNewCommand(
161
171
  );
162
172
  }
163
173
 
164
- // Build manifest and write package
165
174
  const timestamp = new Date();
166
175
  const slug = options.name ?? 'migration';
167
176
  const dirName = formatMigrationDirName(timestamp, slug);
@@ -184,12 +193,35 @@ async function executeMigrationNewCommand(
184
193
  createdAt: timestamp.toISOString(),
185
194
  };
186
195
 
196
+ const migrations = getTargetMigrations(config.target);
197
+ if (!migrations) {
198
+ return notOk(
199
+ errorTargetMigrationNotSupported({
200
+ why: `Target "${config.target.targetId}" does not support migrations`,
201
+ }),
202
+ );
203
+ }
204
+
187
205
  try {
188
- // Write package with empty ops (draft)
206
+ assertFrameworkComponentsCompatible(config.family.familyId, config.target.targetId, [
207
+ config.target,
208
+ config.adapter,
209
+ ...(config.extensionPacks ?? []),
210
+ ]);
211
+
189
212
  await writeMigrationPackage(packageDir, manifest, []);
213
+ await copyContractToMigrationDir(packageDir, contractPathAbsolute);
190
214
 
191
- // Scaffold migration.ts
192
- await scaffoldMigrationTs(packageDir);
215
+ const stack = createControlStack(config);
216
+ const familyInstance = config.family.create(stack);
217
+ const planner = migrations.createPlanner(familyInstance);
218
+ const emptyPlan = planner.emptyMigration({
219
+ packageDir,
220
+ contractJsonPath: join(packageDir, 'contract.json'),
221
+ fromHash,
222
+ toHash: toStorageHash,
223
+ });
224
+ await writeMigrationTs(packageDir, emptyPlan.renderTypeScript());
193
225
 
194
226
  return ok({
195
227
  ok: true as const,
@@ -199,6 +231,9 @@ async function executeMigrationNewCommand(
199
231
  summary: `Scaffolded migration at ${relative(process.cwd(), packageDir)}`,
200
232
  });
201
233
  } catch (error) {
234
+ if (CliStructuredError.is(error)) {
235
+ return notOk(error);
236
+ }
202
237
  return notOk(
203
238
  errorUnexpected(error instanceof Error ? error.message : String(error), {
204
239
  why: `Failed to scaffold migration: ${error instanceof Error ? error.message : String(error)}`,
@@ -1,9 +1,14 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import type { Contract } from '@prisma-next/contract/types';
3
+ import { createControlStack } from '@prisma-next/framework-components/control';
3
4
  import { EMPTY_CONTRACT_HASH } from '@prisma-next/migration-tools/constants';
4
5
  import { findLatestMigration } from '@prisma-next/migration-tools/dag';
5
- import { formatMigrationDirName, writeMigrationPackage } from '@prisma-next/migration-tools/io';
6
- import { scaffoldMigrationTs } from '@prisma-next/migration-tools/migration-ts';
6
+ import {
7
+ copyContractToMigrationDir,
8
+ formatMigrationDirName,
9
+ writeMigrationPackage,
10
+ } from '@prisma-next/migration-tools/io';
11
+ import { writeMigrationTs } from '@prisma-next/migration-tools/migration-ts';
7
12
  import { type MigrationManifest, MigrationToolsError } from '@prisma-next/migration-tools/types';
8
13
  import { notOk, ok, type Result } from '@prisma-next/utils/result';
9
14
  import { Command } from 'commander';
@@ -11,6 +16,7 @@ import { join, relative } from 'pathe';
11
16
  import { loadConfig } from '../config-loader';
12
17
  import { extractSqlDdl } from '../control-api/operations/extract-sql-ddl';
13
18
  import { emitMigration } from '../lib/migration-emit';
19
+ import { migrationStrategy } from '../lib/migration-strategy';
14
20
  import {
15
21
  type CliErrorConflict,
16
22
  CliStructuredError,
@@ -239,104 +245,148 @@ async function executeMigrationPlanCommand(
239
245
  [config.target, config.adapter, ...(config.extensionPacks ?? [])],
240
246
  );
241
247
 
242
- // Use descriptor-based planner if available, fall back to old planner
243
- if (migrations.planWithDescriptors) {
244
- const descriptorResult = migrations.planWithDescriptors({
245
- fromContract,
246
- toContract: toContractJson,
247
- frameworkComponents,
248
- });
248
+ const strategy = migrationStrategy(migrations, config.target.targetId);
249
+
250
+ // Build manifest and write migration package
251
+ const timestamp = new Date();
252
+ const slug = options.name ?? 'migration';
253
+ const dirName = formatMigrationDirName(timestamp, slug);
254
+ const packageDir = join(migrationsDir, dirName);
255
+
256
+ const manifest: MigrationManifest = {
257
+ from: fromHash,
258
+ to: toStorageHash,
259
+ migrationId: null,
260
+ kind: 'regular',
261
+ fromContract,
262
+ toContract: toContractJson,
263
+ hints: {
264
+ used: [],
265
+ applied: [],
266
+ plannerVersion: '2.0.0',
267
+ planningStrategy: strategy === 'descriptor' ? 'descriptors' : 'class-based',
268
+ },
269
+ labels: [],
270
+ createdAt: timestamp.toISOString(),
271
+ };
249
272
 
250
- if (!descriptorResult.ok) {
251
- return notOk(
252
- errorMigrationPlanningFailed({
253
- conflicts: descriptorResult.conflicts as readonly CliErrorConflict[],
254
- }),
255
- );
256
- }
273
+ const scaffoldContext = {
274
+ packageDir,
275
+ contractJsonPath: contractPathAbsolute,
276
+ fromHash,
277
+ toHash: toStorageHash,
278
+ };
257
279
 
258
- if (descriptorResult.descriptors.length === 0) {
259
- return notOk(
260
- errorMigrationPlanningFailed({
261
- conflicts: [
262
- {
263
- kind: 'unsupportedChange',
264
- summary:
265
- 'Contract changed but planner produced no operations. ' +
266
- 'This indicates unsupported or ignored changes.',
267
- },
268
- ],
269
- }),
280
+ try {
281
+ let migrationTsContent: string;
282
+
283
+ if (strategy === 'descriptor') {
284
+ if (!migrations.planWithDescriptors || !migrations.renderDescriptorTypeScript) {
285
+ throw errorTargetMigrationNotSupported({
286
+ why: `Target "${config.target.targetId}" advertises descriptor flow but is missing required hooks`,
287
+ });
288
+ }
289
+ const descriptorResult = migrations.planWithDescriptors({
290
+ fromContract,
291
+ toContract: toContractJson,
292
+ frameworkComponents,
293
+ });
294
+ if (!descriptorResult.ok) {
295
+ return notOk(
296
+ errorMigrationPlanningFailed({
297
+ conflicts: descriptorResult.conflicts as readonly CliErrorConflict[],
298
+ }),
299
+ );
300
+ }
301
+ if (descriptorResult.descriptors.length === 0) {
302
+ return notOk(
303
+ errorMigrationPlanningFailed({
304
+ conflicts: [
305
+ {
306
+ kind: 'unsupportedChange',
307
+ summary:
308
+ 'Contract changed but planner produced no operations. ' +
309
+ 'This indicates unsupported or ignored changes.',
310
+ },
311
+ ],
312
+ }),
313
+ );
314
+ }
315
+ migrationTsContent = migrations.renderDescriptorTypeScript(
316
+ descriptorResult.descriptors,
317
+ scaffoldContext,
270
318
  );
319
+ } else {
320
+ const stack = createControlStack(config);
321
+ const familyInstance = config.family.create(stack);
322
+ const planner = migrations.createPlanner(familyInstance);
323
+ const fromSchema = migrations.contractToSchema(fromContract, frameworkComponents);
324
+ const plannerResult = planner.plan({
325
+ contract: toContractJson,
326
+ schema: fromSchema,
327
+ policy: { allowedOperationClasses: ['additive', 'widening', 'destructive', 'data'] },
328
+ fromHash,
329
+ frameworkComponents,
330
+ });
331
+ if (plannerResult.kind === 'failure') {
332
+ return notOk(
333
+ errorMigrationPlanningFailed({
334
+ conflicts: plannerResult.conflicts as readonly CliErrorConflict[],
335
+ }),
336
+ );
337
+ }
338
+ if (plannerResult.plan.operations.length === 0) {
339
+ return notOk(
340
+ errorMigrationPlanningFailed({
341
+ conflicts: [
342
+ {
343
+ kind: 'unsupportedChange',
344
+ summary:
345
+ 'Contract changed but planner produced no operations. ' +
346
+ 'This indicates unsupported or ignored changes.',
347
+ },
348
+ ],
349
+ }),
350
+ );
351
+ }
352
+ migrationTsContent = plannerResult.plan.renderTypeScript();
271
353
  }
272
354
 
273
- // Build manifest and write migration package
274
- const timestamp = new Date();
275
- const slug = options.name ?? 'migration';
276
- const dirName = formatMigrationDirName(timestamp, slug);
277
- const packageDir = join(migrationsDir, dirName);
355
+ await writeMigrationPackage(packageDir, manifest, []);
356
+ await copyContractToMigrationDir(packageDir, contractPathAbsolute);
357
+ await writeMigrationTs(packageDir, migrationTsContent);
358
+
359
+ // Always run emit inline. If migration.ts contains unfilled
360
+ // placeholders (e.g. user must hand-author a dataTransform body),
361
+ // emitMigration throws errorUnfilledPlaceholder (PN-MIG-2001) and
362
+ // we propagate that structured error to the user.
363
+ const { operations, migrationId } = await emitMigration(packageDir, {
364
+ targetId: config.target.targetId,
365
+ migrations,
366
+ frameworkComponents,
367
+ });
278
368
 
279
- const manifest: MigrationManifest = {
369
+ const sql = extractSqlDdl(operations);
370
+ const result: MigrationPlanResult = {
371
+ ok: true,
372
+ noOp: false,
280
373
  from: fromHash,
281
374
  to: toStorageHash,
282
- migrationId: null,
283
- kind: 'regular',
284
- fromContract,
285
- toContract: toContractJson,
286
- hints: {
287
- used: [],
288
- applied: [],
289
- plannerVersion: '2.0.0',
290
- planningStrategy: 'descriptors',
291
- },
292
- labels: [],
293
- createdAt: timestamp.toISOString(),
375
+ migrationId,
376
+ dir: relative(process.cwd(), packageDir),
377
+ operations: operations.map((op) => ({
378
+ id: op.id,
379
+ label: op.label,
380
+ operationClass: op.operationClass,
381
+ })),
382
+ sql,
383
+ summary: `Planned ${operations.length} operation(s)`,
384
+ timings: { total: Date.now() - startTime },
294
385
  };
295
-
296
- try {
297
- await writeMigrationPackage(packageDir, manifest, []);
298
- await scaffoldMigrationTs(packageDir, {
299
- descriptors: descriptorResult.descriptors,
300
- contractJsonPath: contractPathAbsolute,
301
- });
302
-
303
- // Always run emit inline; structured errors thrown during evaluation
304
- // (e.g. invalid migration.ts shape, missing file) propagate through
305
- // emitMigration to the CLI envelope.
306
- const { operations, migrationId } = await emitMigration(packageDir, {
307
- targetId: config.target.targetId,
308
- migrations,
309
- frameworkComponents,
310
- });
311
-
312
- const sql = extractSqlDdl(operations);
313
- const result: MigrationPlanResult = {
314
- ok: true,
315
- noOp: false,
316
- from: fromHash,
317
- to: toStorageHash,
318
- migrationId,
319
- dir: relative(process.cwd(), packageDir),
320
- operations: operations.map((op) => ({
321
- id: op.id,
322
- label: op.label,
323
- operationClass: op.operationClass,
324
- })),
325
- sql,
326
- summary: `Planned ${operations.length} operation(s)`,
327
- timings: { total: Date.now() - startTime },
328
- };
329
- return ok(result);
330
- } catch (error) {
331
- return notOk(mapMigrationToolsError(error));
332
- }
386
+ return ok(result);
387
+ } catch (error) {
388
+ return notOk(mapMigrationToolsError(error));
333
389
  }
334
-
335
- return notOk(
336
- errorTargetMigrationNotSupported({
337
- why: `Target "${config.target.id}" does not support planWithDescriptors`,
338
- }),
339
- );
340
390
  }
341
391
 
342
392
  export function createMigrationPlanCommand(): Command {
@@ -83,6 +83,9 @@ export async function executeDbInit<TFamilyId extends string, TTargetId extends
83
83
  contract,
84
84
  schema: schemaIR,
85
85
  policy,
86
+ // `db init` does not produce a `migration.ts`, so the from-hash on the
87
+ // resulting plan is never surfaced to authoring — pass empty string.
88
+ fromHash: '',
86
89
  frameworkComponents,
87
90
  });
88
91
 
@@ -83,6 +83,9 @@ export async function executeDbUpdate<TFamilyId extends string, TTargetId extend
83
83
  contract,
84
84
  schema: schemaIR,
85
85
  policy,
86
+ // `db update` does not produce a `migration.ts`, so the from-hash on the
87
+ // resulting plan is never surfaced to authoring — pass empty string.
88
+ fromHash: '',
86
89
  frameworkComponents,
87
90
  });
88
91
  if (plannerResult.kind === 'failure') {
@@ -1,23 +1,32 @@
1
1
  /**
2
- * Shared helper for emitting `ops.json` from a migration package's
3
- * `migration.ts`.
2
+ * Shared helper for emitting `ops.json` and attesting `migration.json` for a
3
+ * migration package's `migration.ts`.
4
4
  *
5
5
  * Two flows are dispatched here:
6
- * - Descriptor flow (Postgres): the framework evaluates `migration.ts` (which
7
- * re-exports the planner's descriptor list), calls the target's
8
- * `resolveDescriptors` to produce display-oriented operations, and writes
9
- * `ops.json` + `manifest.json`.
10
- * - Class flow (Mongo): the target owns the source-loading and ops
11
- * serialization step via its `emit` capability. The capability
12
- * dynamic-imports `migration.ts`, invokes its class to produce
13
- * operations, and calls `writeMigrationOps`. The framework helper
14
- * then attests the package (single source of truth for
15
- * `migrationId`).
6
+ * - Descriptor flow (Postgres): the framework evaluates `migration.ts`
7
+ * (which re-exports the planner's descriptor list), calls the target's
8
+ * `resolveDescriptors` to produce display-oriented operations, writes
9
+ * `ops.json`, and attests `migration.json`.
10
+ * - Class flow (Mongo): the target's `emit` capability dynamic-imports
11
+ * `migration.ts`, instantiates the default-exported `Migration` subclass
12
+ * (or invokes the default-exported factory function), reads `operations`,
13
+ * and writes `ops.json`. This helper then attests `migration.json` once
14
+ * the capability returns.
15
+ *
16
+ * In both cases attestation is owned by this helper so the on-disk artifacts
17
+ * are guaranteed to be fully attested when emit returns.
18
+ *
19
+ * Note that this helper is the CLI-driven emit path. Class-flow `migration.ts`
20
+ * files are also self-emitting via `Migration.run(...)` when run directly;
21
+ * that path attests inside `Migration.run` and produces byte-identical
22
+ * artifacts. This helper exists primarily to bridge descriptor-flow targets
23
+ * and to give `migration plan` a single in-process emit dispatch.
16
24
  *
17
25
  * Used by `migration emit` (always) and `migration plan` (always, after
18
26
  * scaffolding `migration.ts`). Both flows run in-process so that structured
19
- * errors thrown during evaluation propagate as real exceptions and the
20
- * CLI's error envelope renders them with full structured metadata.
27
+ * errors thrown during evaluation (notably `errorUnfilledPlaceholder` with
28
+ * code `PN-MIG-2001`) propagate as real exceptions and the CLI's error
29
+ * envelope renders them with full structured metadata.
21
30
  */
22
31
 
23
32
  import assert from 'node:assert/strict';
@@ -31,6 +40,7 @@ import { attestMigration } from '@prisma-next/migration-tools/attestation';
31
40
  import { readMigrationPackage, writeMigrationOps } from '@prisma-next/migration-tools/io';
32
41
  import { evaluateMigrationTs, hasMigrationTs } from '@prisma-next/migration-tools/migration-ts';
33
42
  import { errorMigrationFileMissing, errorTargetMigrationNotSupported } from '../utils/cli-errors';
43
+ import { migrationStrategy } from './migration-strategy';
34
44
 
35
45
  /**
36
46
  * Context passed to `emitMigration`. Captures everything the helper needs to
@@ -45,7 +55,7 @@ export interface EmitMigrationContext {
45
55
  /**
46
56
  * Result of a successful emit: the operations that were written to `ops.json`
47
57
  * (display-oriented shape) and the content-addressed migrationId persisted to
48
- * `manifest.json`.
58
+ * `migration.json`.
49
59
  */
50
60
  export interface EmitMigrationResult {
51
61
  readonly operations: readonly MigrationPlanOperation[];
@@ -68,22 +78,24 @@ export async function emitMigration(
68
78
  throw errorMigrationFileMissing(dir);
69
79
  }
70
80
 
71
- if (ctx.migrations.resolveDescriptors) {
81
+ const strategy = migrationStrategy(ctx.migrations, ctx.targetId);
82
+
83
+ if (strategy === 'descriptor') {
72
84
  return emitDescriptorFlow(dir, ctx.migrations, ctx);
73
85
  }
74
86
 
75
- if (ctx.migrations.emit) {
76
- const operations = await ctx.migrations.emit({
77
- dir,
78
- frameworkComponents: ctx.frameworkComponents,
87
+ if (!ctx.migrations.emit) {
88
+ throw errorTargetMigrationNotSupported({
89
+ why: `Target "${ctx.targetId}" does not implement the class-flow \`emit\` capability; cannot emit a migration package`,
79
90
  });
80
- const migrationId = await attestMigration(dir);
81
- return { operations, migrationId };
82
91
  }
83
92
 
84
- throw errorTargetMigrationNotSupported({
85
- why: `Target "${ctx.targetId}" does not implement resolveDescriptors or emit; cannot emit a migration package`,
93
+ const operations = await ctx.migrations.emit({
94
+ dir,
95
+ frameworkComponents: ctx.frameworkComponents,
86
96
  });
97
+ const migrationId = await attestMigration(dir);
98
+ return { operations, migrationId };
87
99
  }
88
100
 
89
101
  /**
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Migration authoring strategy selector.
3
+ *
4
+ * Targets currently use one of two strategies to author `migration.ts`:
5
+ *
6
+ * - **Descriptor flow** — the planner produces an `OperationDescriptor[]`
7
+ * and `migration.ts` is a `export default () => [...]` file that the CLI
8
+ * later replays through `resolveDescriptors` at emit time. Postgres uses
9
+ * this today.
10
+ * - **Class flow** — the planner produces a `MigrationPlanWithAuthoringSurface`
11
+ * that renders itself as a `class M extends Migration { ... }` file. The
12
+ * CLI dispatches to the target's `emit` capability at emit time. Mongo
13
+ * uses this today.
14
+ *
15
+ * The two are mutually exclusive at the target level: a migrations capability
16
+ * either implements the descriptor-flow trio (`planWithDescriptors`,
17
+ * `resolveDescriptors`, `renderDescriptorTypeScript`) or the class-flow
18
+ * `emit` hook. `migrationStrategy` discriminates between them by observing
19
+ * which hooks are present, and is consumed by `migration new`, `migration
20
+ * plan`, and `migration emit` to keep strategy-specific branching in one
21
+ * place.
22
+ */
23
+
24
+ import { errorTargetHasIncompleteMigrationCapabilities } from '@prisma-next/errors/migration';
25
+ import type { TargetMigrationsCapability } from '@prisma-next/framework-components/control';
26
+
27
+ export type MigrationStrategy = 'descriptor' | 'class-based';
28
+
29
+ /**
30
+ * Determine which authoring strategy a target uses, based on the shape of
31
+ * its `TargetMigrationsCapability`. Callers that need strategy-specific
32
+ * guarantees (e.g. that `resolveDescriptors` is present) should narrow on
33
+ * the returned tag and trust the capability fields directly rather than
34
+ * re-probing.
35
+ *
36
+ * Throws `errorTargetHasIncompleteMigrationCapabilities` (PN-MIG-2011) when
37
+ * the capability registers neither flow. We diagnose this here rather than
38
+ * deferring to the dispatch site so a misconfigured target gets an honest
39
+ * "incomplete capability" error instead of being silently routed to one
40
+ * flow and reported as missing the *other* flow's hook.
41
+ */
42
+ export function migrationStrategy(
43
+ migrations: TargetMigrationsCapability,
44
+ targetId: string,
45
+ ): MigrationStrategy {
46
+ if (migrations.resolveDescriptors) return 'descriptor';
47
+ if (migrations.emit) return 'class-based';
48
+ throw errorTargetHasIncompleteMigrationCapabilities({ targetId });
49
+ }
@@ -34,4 +34,6 @@ export {
34
34
  errorMigrationFileMissing,
35
35
  errorMigrationInvalidDefaultExport,
36
36
  errorMigrationPlanNotArray,
37
+ errorUnfilledPlaceholder,
38
+ placeholder,
37
39
  } from '@prisma-next/errors/migration';