@prisma-next/cli 0.12.0-dev.2 → 0.12.0-dev.21

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 (108) hide show
  1. package/dist/cli.mjs +177 -160
  2. package/dist/cli.mjs.map +1 -1
  3. package/dist/{client-KgJorIvG.mjs → client-Cdxcme1x.mjs} +21 -8
  4. package/dist/client-Cdxcme1x.mjs.map +1 -0
  5. package/dist/{command-helpers-Bbw1GbwL.mjs → command-helpers-Cmdqyhz9.mjs} +32 -2
  6. package/dist/{command-helpers-Bbw1GbwL.mjs.map → command-helpers-Cmdqyhz9.mjs.map} +1 -1
  7. package/dist/commands/contract-emit.mjs +1 -1
  8. package/dist/commands/contract-infer.mjs +1 -1
  9. package/dist/commands/db-init.mjs +4 -4
  10. package/dist/commands/db-schema.mjs +3 -3
  11. package/dist/commands/db-sign.mjs +4 -4
  12. package/dist/commands/db-update.mjs +5 -5
  13. package/dist/commands/db-verify.mjs +1 -1
  14. package/dist/commands/migrate.d.mts +1 -1
  15. package/dist/commands/migrate.mjs +5 -5
  16. package/dist/commands/migration-check.mjs +1 -1
  17. package/dist/commands/migration-graph.d.mts +23 -5
  18. package/dist/commands/migration-graph.d.mts.map +1 -1
  19. package/dist/commands/migration-graph.mjs +2 -2
  20. package/dist/commands/migration-list.d.mts +3 -3
  21. package/dist/commands/migration-list.mjs +3 -3
  22. package/dist/commands/migration-log.d.mts +3 -3
  23. package/dist/commands/migration-log.mjs +3 -3
  24. package/dist/commands/migration-new.mjs +3 -3
  25. package/dist/commands/migration-plan.d.mts +1 -1
  26. package/dist/commands/migration-plan.mjs +1 -1
  27. package/dist/commands/migration-show.d.mts +1 -1
  28. package/dist/commands/migration-show.mjs +3 -3
  29. package/dist/commands/migration-status.d.mts +1 -1
  30. package/dist/commands/migration-status.mjs +4 -4
  31. package/dist/commands/migration-status.mjs.map +1 -1
  32. package/dist/commands/ref.d.mts +1 -1
  33. package/dist/commands/ref.mjs +2 -2
  34. package/dist/commands/telemetry/index.d.mts +7 -0
  35. package/dist/commands/telemetry/index.d.mts.map +1 -0
  36. package/dist/commands/telemetry/index.mjs +2 -0
  37. package/dist/{contract-at-errors-BxP-TOMl.mjs → contract-at-errors-Cz0z5PJi.mjs} +2 -2
  38. package/dist/{contract-at-errors-BxP-TOMl.mjs.map → contract-at-errors-Cz0z5PJi.mjs.map} +1 -1
  39. package/dist/{contract-emit-D-4jrNve.mjs → contract-emit-CC9jDOmu.mjs} +3 -3
  40. package/dist/{contract-emit-D-4jrNve.mjs.map → contract-emit-CC9jDOmu.mjs.map} +1 -1
  41. package/dist/{contract-emit-DxcGl4Uq.mjs → contract-emit-DPMij44i.mjs} +3 -3
  42. package/dist/{contract-emit-DxcGl4Uq.mjs.map → contract-emit-DPMij44i.mjs.map} +1 -1
  43. package/dist/{contract-infer-D8uEbJuu.mjs → contract-infer-DaFPNrZH.mjs} +3 -3
  44. package/dist/{contract-infer-D8uEbJuu.mjs.map → contract-infer-DaFPNrZH.mjs.map} +1 -1
  45. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs → contract-space-aggregate-loader-CirAEsM8.mjs} +2 -2
  46. package/dist/{contract-space-aggregate-loader-DvZwdkrr.mjs.map → contract-space-aggregate-loader-CirAEsM8.mjs.map} +1 -1
  47. package/dist/{db-verify-v_vUKXTU.mjs → db-verify-BSA1a_W_.mjs} +4 -4
  48. package/dist/{db-verify-v_vUKXTU.mjs.map → db-verify-BSA1a_W_.mjs.map} +1 -1
  49. package/dist/exports/control-api.d.mts +1 -1
  50. package/dist/exports/control-api.d.mts.map +1 -1
  51. package/dist/exports/control-api.mjs +2 -2
  52. package/dist/exports/index.mjs +1 -1
  53. package/dist/exports/init-output.mjs +1 -1
  54. package/dist/{framework-components-fYXjz_in.mjs → framework-components-DynSvww4.mjs} +2 -2
  55. package/dist/{framework-components-fYXjz_in.mjs.map → framework-components-DynSvww4.mjs.map} +1 -1
  56. package/dist/{global-flags-DEHjV8_s.d.mts → global-flags-DG4uY5tV.d.mts} +1 -1
  57. package/dist/{global-flags-DEHjV8_s.d.mts.map → global-flags-DG4uY5tV.d.mts.map} +1 -1
  58. package/dist/{init-Cv9UzWL5.mjs → init-B6kKrmf7.mjs} +5 -58
  59. package/dist/init-B6kKrmf7.mjs.map +1 -0
  60. package/dist/{inspect-live-schema-C6ohV_oQ.mjs → inspect-live-schema-Dn56wDhG.mjs} +3 -3
  61. package/dist/{inspect-live-schema-C6ohV_oQ.mjs.map → inspect-live-schema-Dn56wDhG.mjs.map} +1 -1
  62. package/dist/{migration-check-BiBJoYYW.mjs → migration-check-DzH1u-O1.mjs} +2 -2
  63. package/dist/{migration-check-BiBJoYYW.mjs.map → migration-check-DzH1u-O1.mjs.map} +1 -1
  64. package/dist/{migration-command-scaffold-CjvwO6at.mjs → migration-command-scaffold-V52dV2Tv.mjs} +3 -3
  65. package/dist/{migration-command-scaffold-CjvwO6at.mjs.map → migration-command-scaffold-V52dV2Tv.mjs.map} +1 -1
  66. package/dist/{migration-graph-D7DVUElV.mjs → migration-graph-DKl_IYsF.mjs} +377 -85
  67. package/dist/migration-graph-DKl_IYsF.mjs.map +1 -0
  68. package/dist/{migration-list-styler-BRwF4-gy.mjs → migration-list-styler-COQbZmXk.mjs} +61 -46
  69. package/dist/migration-list-styler-COQbZmXk.mjs.map +1 -0
  70. package/dist/{migration-plan-9DJ7q7_z.mjs → migration-plan-CaeKCKp4.mjs} +5 -5
  71. package/dist/{migration-plan-9DJ7q7_z.mjs.map → migration-plan-CaeKCKp4.mjs.map} +1 -1
  72. package/dist/{migration-types-D2FW63pr.d.mts → migration-types-CAQ-0TEE.d.mts} +1 -1
  73. package/dist/{migration-types-D2FW63pr.d.mts.map → migration-types-CAQ-0TEE.d.mts.map} +1 -1
  74. package/dist/{migrations-Cv2jxNNK.mjs → migrations-DQ1t3XFL.mjs} +2 -2
  75. package/dist/{migrations-Cv2jxNNK.mjs.map → migrations-DQ1t3XFL.mjs.map} +1 -1
  76. package/dist/{output-B60Gw5fu.mjs → output-CF_hqzI-.mjs} +1 -1
  77. package/dist/{output-B60Gw5fu.mjs.map → output-CF_hqzI-.mjs.map} +1 -1
  78. package/dist/telemetry-Q88WHwlv.mjs +122 -0
  79. package/dist/telemetry-Q88WHwlv.mjs.map +1 -0
  80. package/dist/{terminal-ui-5Y6mrg93.d.mts → terminal-ui-C3xGyxW-.d.mts} +1 -1
  81. package/dist/{terminal-ui-5Y6mrg93.d.mts.map → terminal-ui-C3xGyxW-.d.mts.map} +1 -1
  82. package/dist/{types-Dt_SfqFm.d.mts → types-DiC683UW.d.mts} +8 -2
  83. package/dist/{types-Dt_SfqFm.d.mts.map → types-DiC683UW.d.mts.map} +1 -1
  84. package/dist/{verify-DCA9Sldu.mjs → verify-CreSJ1Mz.mjs} +2 -2
  85. package/dist/{verify-DCA9Sldu.mjs.map → verify-CreSJ1Mz.mjs.map} +1 -1
  86. package/package.json +22 -18
  87. package/src/cli.ts +5 -0
  88. package/src/commands/init/index.ts +6 -35
  89. package/src/commands/init/init.ts +1 -14
  90. package/src/commands/init/inputs.ts +0 -75
  91. package/src/commands/migration-graph.ts +43 -2
  92. package/src/commands/migration-status.ts +1 -1
  93. package/src/commands/telemetry/index.ts +107 -0
  94. package/src/commands/telemetry/status.ts +67 -0
  95. package/src/control-api/client.ts +11 -1
  96. package/src/control-api/operations/apply.ts +1 -0
  97. package/src/control-api/operations/migration-apply.ts +10 -3
  98. package/src/control-api/types.ts +12 -1
  99. package/src/utils/formatters/migration-graph-lane-colors.ts +31 -0
  100. package/src/utils/formatters/migration-graph-layout.ts +51 -7
  101. package/src/utils/formatters/migration-graph-tree-render.ts +414 -51
  102. package/src/utils/formatters/migration-list-graph-topology.ts +67 -83
  103. package/src/utils/global-flags.ts +35 -0
  104. package/src/utils/telemetry.ts +68 -32
  105. package/dist/client-KgJorIvG.mjs.map +0 -1
  106. package/dist/init-Cv9UzWL5.mjs.map +0 -1
  107. package/dist/migration-graph-D7DVUElV.mjs.map +0 -1
  108. package/dist/migration-list-styler-BRwF4-gy.mjs.map +0 -1
@@ -1,9 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import * as clack from '@clack/prompts';
3
- import { readUserConfig, resolveGating, writeUserConfig } from '@prisma-next/cli-telemetry';
4
3
  import { extname, join, normalize } from 'pathe';
5
4
  import type { GlobalFlags } from '../../utils/global-flags';
6
- import { isCI } from '../../utils/is-ci';
7
5
  import {
8
6
  errorInitAuthoringSchemaPathMismatch,
9
7
  errorInitInvalidFlagValue,
@@ -71,18 +69,6 @@ export interface ResolvedInitInputs {
71
69
  * is added separately via the install step.
72
70
  */
73
71
  readonly removePreviousFacade: string | null;
74
- /**
75
- * Telemetry consent answer recorded during this `init` run, or `null`
76
- * when no prompt was shown. The prompt fires only on the
77
- * canPrompt + !autoAcceptPrompts + no env/CI opt-out +
78
- * `enableTelemetry === undefined` intersection; outside that window
79
- * the field is `null` and the stored preference (if any) stays
80
- * unchanged. The answer has already been persisted to
81
- * `$XDG_CONFIG_HOME/prisma-next/config.json` by
82
- * the time `runInit` sees this value — it's surfaced here purely so
83
- * the post-init summary can mention what the user chose.
84
- */
85
- readonly enableTelemetry: boolean | null;
86
72
  /**
87
73
  * Whether to run `npx skills add prisma/prisma-next#v<version>` at the
88
74
  * project level after install + emit. True by default; `--no-skill`
@@ -106,12 +92,6 @@ const AUTHORING_VALUES: ReadonlyMap<string, AuthoringId> = new Map([
106
92
  ['ts', 'typescript'],
107
93
  ]);
108
94
 
109
- export const TELEMETRY_CONSENT_MESSAGE = [
110
- 'Help us prioritize features by sharing anonymous CLI usage data?',
111
- 'The telemetry implementation is open source and fully transparent.',
112
- '(packages/1-framework/3-tooling/cli-telemetry and apps/telemetry-backend).',
113
- ].join(' ');
114
-
115
95
  /**
116
96
  * Resolves every required input for `runInit`. In interactive mode, missing
117
97
  * inputs are prompted via clack; in non-interactive mode, missing required
@@ -197,11 +177,6 @@ export async function resolveInitInputs(ctx: {
197
177
  autoAcceptPrompts,
198
178
  });
199
179
 
200
- const enableTelemetry = await resolveTelemetryConsent({
201
- canPrompt,
202
- autoAcceptPrompts,
203
- });
204
-
205
180
  // Skill-install gating. `--no-skill` (commander parses
206
181
  // `options.skill === false`) is the only escape hatch; otherwise
207
182
  // project-level install is unconditional. The skill is always
@@ -219,60 +194,10 @@ export async function resolveInitInputs(ctx: {
219
194
  strictProbe: Boolean(options.strictProbe),
220
195
  reinit,
221
196
  removePreviousFacade,
222
- enableTelemetry,
223
197
  installProjectSkill,
224
198
  };
225
199
  }
226
200
 
227
- /**
228
- * The interactive telemetry consent prompt. Shown as the last
229
- * question of the `init` sequence iff:
230
- * 1. `canPrompt === true` (interactive stdin / stdout combo),
231
- * 2. `autoAcceptPrompts === false` (the user did not pass `--yes`),
232
- * 3. neither telemetry env opt-out is active,
233
- * 4. the process is not running in CI,
234
- * 5. the stored `enableTelemetry` value is `undefined` (the user
235
- * has never been asked, or skipped the prompt previously).
236
- *
237
- * Outside that intersection the function returns `null` and leaves the
238
- * stored preference untouched. Inside it, the user's answer is
239
- * persisted via `writeUserConfig({ enableTelemetry })` before this
240
- * function returns; on an affirmative answer `writeUserConfig`
241
- * generates and stores the v4 `installationId` in the same write.
242
- *
243
- * The wording names CLI usage data and points to the open-source
244
- * client/backend paths. Default value is `true` to match precedent
245
- * and to make the consent answer a single keystroke once it's been
246
- * disclosed.
247
- */
248
- async function resolveTelemetryConsent(opts: {
249
- readonly canPrompt: boolean;
250
- readonly autoAcceptPrompts: boolean;
251
- }): Promise<boolean | null> {
252
- if (!opts.canPrompt || opts.autoAcceptPrompts || isCI()) {
253
- return null;
254
- }
255
- const config = readUserConfig();
256
- const gating = resolveGating({ env: process.env, config });
257
- if (!gating.enabled && gating.reason === 'env-override') {
258
- return null;
259
- }
260
- const stored = config.enableTelemetry;
261
- if (stored !== undefined) {
262
- return null;
263
- }
264
- const result = await clack.confirm({
265
- message: TELEMETRY_CONSENT_MESSAGE,
266
- initialValue: true,
267
- output: process.stderr,
268
- });
269
- if (clack.isCancel(result)) {
270
- throw errorInitUserAborted();
271
- }
272
- writeUserConfig({ enableTelemetry: Boolean(result) });
273
- return Boolean(result);
274
- }
275
-
276
201
  async function resolveWriteEnv(opts: {
277
202
  readonly flag: boolean | undefined;
278
203
  readonly canPrompt: boolean;
@@ -16,7 +16,10 @@ import { migrationGraphToRenderInput } from '../utils/formatters/graph-migration
16
16
  import { graphRenderer } from '../utils/formatters/graph-render';
17
17
  import { buildMigrationGraphLayout } from '../utils/formatters/migration-graph-layout';
18
18
  import { buildMigrationGraphRows } from '../utils/formatters/migration-graph-rows';
19
- import { renderMigrationGraphTree } from '../utils/formatters/migration-graph-tree-render';
19
+ import {
20
+ renderMigrationGraphLegend,
21
+ renderMigrationGraphTree,
22
+ } from '../utils/formatters/migration-graph-tree-render';
20
23
  import { formatStyledHeader } from '../utils/formatters/styled';
21
24
  import type { CommonCommandOptions } from '../utils/global-flags';
22
25
  import { type GlobalFlags, parseGlobalFlagsOrExit } from '../utils/global-flags';
@@ -29,6 +32,32 @@ interface MigrationGraphOptions extends CommonCommandOptions {
29
32
  readonly dot?: boolean;
30
33
  readonly tree?: boolean;
31
34
  readonly ascii?: boolean;
35
+ readonly legend?: boolean;
36
+ }
37
+
38
+ /**
39
+ * `--legend` describes the `--tree` visual language, so passing it auto-enables
40
+ * the tree path (it has nothing to say about the legacy dagre default).
41
+ */
42
+ export function migrationGraphUsesTree(options: {
43
+ readonly tree?: boolean;
44
+ readonly legend?: boolean;
45
+ }): boolean {
46
+ return options.tree === true || options.legend === true;
47
+ }
48
+
49
+ /**
50
+ * The legend is decoration printed alongside the command header on stderr, so
51
+ * it is suppressed for the machine-readable / silent paths (`--json`, `--dot`,
52
+ * `--quiet`) exactly as the header is.
53
+ */
54
+ export function migrationGraphShowsLegend(
55
+ options: { readonly legend?: boolean; readonly dot?: boolean },
56
+ flags: GlobalFlags,
57
+ ): boolean {
58
+ return (
59
+ options.legend === true && options.dot !== true && flags.json !== true && flags.quiet !== true
60
+ );
32
61
  }
33
62
 
34
63
  export interface MigrationGraphResult {
@@ -61,6 +90,16 @@ export async function executeMigrationGraphCommand(
61
90
  flags,
62
91
  });
63
92
  ui.stderr(header);
93
+ if (migrationGraphShowsLegend(options, flags)) {
94
+ ui.stderr(
95
+ renderMigrationGraphLegend({
96
+ colorize: flags.color !== false,
97
+ glyphMode: ui.resolveGlyphMode(options.ascii === true),
98
+ }),
99
+ );
100
+ // Blank line separating the stderr key from the graph that follows on stdout.
101
+ ui.stderr('');
102
+ }
64
103
  }
65
104
 
66
105
  const loaded = await buildReadAggregate(config, { migrationsDir });
@@ -102,6 +141,7 @@ export function createMigrationGraphCommand(): Command {
102
141
  'prisma-next migration graph --dot',
103
142
  'prisma-next migration graph --tree',
104
143
  'prisma-next migration graph --tree --ascii',
144
+ 'prisma-next migration graph --legend',
105
145
  ]);
106
146
  setCommandSeeAlso(command, [
107
147
  { verb: 'migration status', oneLiner: 'Show migration path and pending status' },
@@ -114,6 +154,7 @@ export function createMigrationGraphCommand(): Command {
114
154
  .option('--dot', 'Output in Graphviz DOT format')
115
155
  .option('--tree', 'Experimental condensed annotated tree renderer')
116
156
  .option('--ascii', 'Use ASCII glyphs for --tree (pipe-friendly)')
157
+ .option('--legend', 'Print a key for the --tree glyphs and lane colors (implies --tree)')
117
158
  .action(async (options: MigrationGraphOptions) => {
118
159
  const flags = parseGlobalFlagsOrExit(options);
119
160
  const ui = createTerminalUI(flags);
@@ -145,7 +186,7 @@ export function createMigrationGraphCommand(): Command {
145
186
  JSON.stringify({ ok: true, nodes, edges, summary: graphResult.summary }, null, 2),
146
187
  );
147
188
  } else if (!flags.quiet) {
148
- if (options.tree) {
189
+ if (migrationGraphUsesTree(options)) {
149
190
  const refsByHash = new Map<string, string[]>();
150
191
  for (const ref of graphResult.refs) {
151
192
  const existing = refsByHash.get(ref.hash);
@@ -500,7 +500,7 @@ export async function loadAggregateStatusSpaces(args: {
500
500
  // Count pending *migrations* (graph edges), not operations: a
501
501
  // single authored migration that lowers to N ops or zero ops
502
502
  // both count as exactly one pending unit of work for the user.
503
- pendingCount = walked.result.migrationEdges?.length ?? 0;
503
+ pendingCount = walked.result.migrationEdges.length;
504
504
  if (liveMarker === null) {
505
505
  status = pendingCount === 0 ? 'no-marker' : 'pending';
506
506
  } else {
@@ -0,0 +1,107 @@
1
+ import { userConfigPath, writeUserConfig } from '@prisma-next/cli-telemetry';
2
+ import { Command } from 'commander';
3
+ import {
4
+ addGlobalOptions,
5
+ setCommandDescriptions,
6
+ setCommandExamples,
7
+ } from '../../utils/command-helpers';
8
+ import { formatCommandHelp } from '../../utils/formatters/help';
9
+ import {
10
+ type CommonCommandOptions,
11
+ parseGlobalFlags,
12
+ parseGlobalFlagsOrExit,
13
+ } from '../../utils/global-flags';
14
+ import { isCI } from '../../utils/is-ci';
15
+ import { createTerminalUI } from '../../utils/terminal-ui';
16
+ import { formatTelemetryStatusLines, resolveTelemetryStatus } from './status';
17
+
18
+ function createTelemetryStatusCommand(): Command {
19
+ const command = new Command('status');
20
+ setCommandDescriptions(
21
+ command,
22
+ 'Show whether anonymous CLI telemetry is enabled and why',
23
+ 'Reports whether telemetry is currently enabled or disabled and the reason\n' +
24
+ '(default-on, stored opt-out, environment opt-out, or CI), the path to your\n' +
25
+ 'user-level config file, and whether an installation ID has been stored.\n' +
26
+ 'Read-only: never sends an event, never mints an ID, never writes anything.',
27
+ );
28
+ return addGlobalOptions(command).action((options: CommonCommandOptions) => {
29
+ const flags = parseGlobalFlagsOrExit(options);
30
+ const ui = createTerminalUI(flags);
31
+ const status = resolveTelemetryStatus({ env: process.env, inCI: isCI() });
32
+ if (flags.json) {
33
+ ui.output(JSON.stringify(status));
34
+ } else {
35
+ for (const line of formatTelemetryStatusLines(status)) {
36
+ ui.output(line);
37
+ }
38
+ }
39
+ process.exit(0);
40
+ });
41
+ }
42
+
43
+ function createTelemetryEnableCommand(): Command {
44
+ const command = new Command('enable');
45
+ setCommandDescriptions(
46
+ command,
47
+ 'Enable anonymous CLI telemetry',
48
+ 'Stores "enableTelemetry": true in your user-level config and mints an\n' +
49
+ 'installation ID if one is not already stored.',
50
+ );
51
+ return addGlobalOptions(command).action((options: CommonCommandOptions) => {
52
+ const flags = parseGlobalFlagsOrExit(options);
53
+ writeUserConfig({ enableTelemetry: true });
54
+ const ui = createTerminalUI(flags);
55
+ if (flags.json) {
56
+ ui.output(JSON.stringify({ enableTelemetry: true, configPath: userConfigPath() }));
57
+ } else {
58
+ ui.output(`Telemetry enabled. Preference stored in ${userConfigPath()}.`);
59
+ }
60
+ process.exit(0);
61
+ });
62
+ }
63
+
64
+ function createTelemetryDisableCommand(): Command {
65
+ const command = new Command('disable');
66
+ setCommandDescriptions(
67
+ command,
68
+ 'Disable anonymous CLI telemetry',
69
+ 'Stores "enableTelemetry": false in your user-level config. No installation\n' +
70
+ 'ID is minted and no event is sent.',
71
+ );
72
+ return addGlobalOptions(command).action((options: CommonCommandOptions) => {
73
+ const flags = parseGlobalFlagsOrExit(options);
74
+ writeUserConfig({ enableTelemetry: false });
75
+ const ui = createTerminalUI(flags);
76
+ if (flags.json) {
77
+ ui.output(JSON.stringify({ enableTelemetry: false, configPath: userConfigPath() }));
78
+ } else {
79
+ ui.output(`Telemetry disabled. Preference stored in ${userConfigPath()}.`);
80
+ }
81
+ process.exit(0);
82
+ });
83
+ }
84
+
85
+ export function createTelemetryCommand(): Command {
86
+ const command = new Command('telemetry');
87
+ setCommandDescriptions(
88
+ command,
89
+ 'Inspect and change anonymous CLI telemetry',
90
+ 'Show telemetry status, or enable / disable anonymous CLI usage data.\n' +
91
+ 'Telemetry is on by default (opt-out); see https://prisma-next.dev/docs/cli/telemetry\n' +
92
+ 'for what is collected and why.',
93
+ );
94
+ setCommandExamples(command, [
95
+ 'prisma-next telemetry status',
96
+ 'prisma-next telemetry disable',
97
+ 'prisma-next telemetry enable',
98
+ ]);
99
+ command.configureHelp({
100
+ formatHelp: (cmd) => formatCommandHelp({ command: cmd, flags: parseGlobalFlags({}) }),
101
+ subcommandDescription: () => '',
102
+ });
103
+ command.addCommand(createTelemetryStatusCommand());
104
+ command.addCommand(createTelemetryEnableCommand());
105
+ command.addCommand(createTelemetryDisableCommand());
106
+ return command;
107
+ }
@@ -0,0 +1,67 @@
1
+ import { readUserConfig, resolveGating, userConfigPath } from '@prisma-next/cli-telemetry';
2
+
3
+ /**
4
+ * Why telemetry resolves the way it does, in the order the CLI's
5
+ * `resolveTelemetryGate` evaluates: CI hard-disables first, then the env
6
+ * opt-outs, then the stored `enableTelemetry`, then the opt-out default.
7
+ */
8
+ export type TelemetryStatusReason =
9
+ | 'ci'
10
+ | 'env-opt-out'
11
+ | 'stored-opt-out'
12
+ | 'stored-opt-in'
13
+ | 'default-on';
14
+
15
+ export interface TelemetryStatus {
16
+ readonly enabled: boolean;
17
+ readonly reason: TelemetryStatusReason;
18
+ readonly configPath: string;
19
+ readonly installationIdStored: boolean;
20
+ }
21
+
22
+ /**
23
+ * Resolves the same gate the runtime uses (CI check + `resolveGating`) and
24
+ * projects it into a user-facing status. Pure read: never mints, never
25
+ * writes. The `installationId` value itself is never surfaced — only its
26
+ * presence — so `status` discloses nothing identifying.
27
+ */
28
+ export function resolveTelemetryStatus(inputs: {
29
+ readonly env: Readonly<Record<string, string | undefined>>;
30
+ readonly inCI: boolean;
31
+ }): TelemetryStatus {
32
+ const config = readUserConfig();
33
+ const configPath = userConfigPath();
34
+ const installationIdStored =
35
+ typeof config.installationId === 'string' && config.installationId.length > 0;
36
+
37
+ if (inputs.inCI) {
38
+ return { enabled: false, reason: 'ci', configPath, installationIdStored };
39
+ }
40
+
41
+ const gating = resolveGating({ env: inputs.env, config });
42
+ if (!gating.enabled) {
43
+ const reason: TelemetryStatusReason =
44
+ gating.reason === 'env-override' ? 'env-opt-out' : 'stored-opt-out';
45
+ return { enabled: false, reason, configPath, installationIdStored };
46
+ }
47
+
48
+ const reason: TelemetryStatusReason =
49
+ config.enableTelemetry === true ? 'stored-opt-in' : 'default-on';
50
+ return { enabled: true, reason, configPath, installationIdStored };
51
+ }
52
+
53
+ const REASON_EXPLANATION: Record<TelemetryStatusReason, string> = {
54
+ ci: 'CI environment detected — telemetry is hard-disabled.',
55
+ 'env-opt-out': 'an environment opt-out is set (DO_NOT_TRACK / PRISMA_NEXT_DISABLE_TELEMETRY).',
56
+ 'stored-opt-out': '"enableTelemetry": false is stored in your config.',
57
+ 'stored-opt-in': '"enableTelemetry": true is stored in your config.',
58
+ 'default-on': 'no explicit choice is stored, so the opt-out default applies.',
59
+ };
60
+
61
+ export function formatTelemetryStatusLines(status: TelemetryStatus): string[] {
62
+ return [
63
+ `Telemetry is ${status.enabled ? 'enabled' : 'disabled'}: ${REASON_EXPLANATION[status.reason]}`,
64
+ `Config file: ${status.configPath}`,
65
+ `Installation ID: ${status.installationIdStored ? 'stored' : 'not stored'}`,
66
+ ];
67
+ }
@@ -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 { emit as emitContractArtifacts } from '@prisma-next/emitter';
3
7
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
4
8
  import type {
@@ -447,6 +451,12 @@ class ControlClientImpl implements ControlClient {
447
451
  return familyInstance.readAllMarkers({ driver });
448
452
  }
449
453
 
454
+ /** Reads the per-migration journal for `space` (defaults to the app contract space). */
455
+ async readLedger(space = APP_SPACE_ID): Promise<readonly LedgerEntryRecord[]> {
456
+ const { driver, familyInstance } = await this.ensureConnected();
457
+ return familyInstance.readLedger({ driver, space });
458
+ }
459
+
450
460
  async migrationApply(options: MigrationApplyOptions): Promise<MigrationApplyResult> {
451
461
  const { onProgress } = options;
452
462
  await this.connectWithProgress(options.connection, 'migrationApply', onProgress);
@@ -141,6 +141,7 @@ export async function applyMigration<TFamilyId extends string, TTargetId extends
141
141
  destinationContract: r.entry.destinationContract,
142
142
  policy,
143
143
  frameworkComponents,
144
+ migrationEdges: r.entry.migrationEdges,
144
145
  // Per-space post-apply schema verification is non-strict: each
145
146
  // space's `destinationContract` describes only its own slice; a
146
147
  // strict verifier would treat every other space's tables as
@@ -7,6 +7,7 @@ import type {
7
7
  TargetMigrationsCapability,
8
8
  } from '@prisma-next/framework-components/control';
9
9
  import {
10
+ buildSynthMigrationEdge,
10
11
  type ContractMarkerRecordLike,
11
12
  type ContractSpaceAggregate,
12
13
  type ContractSpaceMember,
@@ -319,7 +320,7 @@ export async function executeMigrationApply<TFamilyId extends string, TTargetId
319
320
  includeMarkers: true,
320
321
  });
321
322
  const totalMigrationsApplied = applied.value.orderedResolutions.reduce(
322
- (sum, r) => sum + (r.entry.migrationEdges?.length ?? 0),
323
+ (sum, r) => sum + r.entry.migrationEdges.length,
323
324
  0,
324
325
  );
325
326
  const summary = `Applied ${totalMigrationsApplied} migration(s) (${applied.value.totalOpsExecuted} operation(s)) across ${orderedAll.length} contract space(s)`;
@@ -361,7 +362,13 @@ function buildAtHeadResolution(args: {
361
362
  displayOps: [],
362
363
  destinationContract: member.contract(),
363
364
  strategy: 'graph-walk',
364
- migrationEdges: [],
365
+ migrationEdges: [
366
+ buildSynthMigrationEdge({
367
+ currentMarkerStorageHash: liveMarker?.storageHash,
368
+ destinationStorageHash: targetHash,
369
+ operationCount: 0,
370
+ }),
371
+ ],
365
372
  };
366
373
  }
367
374
 
@@ -404,7 +411,7 @@ function buildSuccess(args: BuildSuccessArgs): MigrationApplySuccess {
404
411
  // JSON-shape consumers (e.g. `parsed.applied.length` in integration
405
412
  // tests). The aggregate per-space breakdown lives on `perSpace[]`.
406
413
  const applied = args.orderedResolutions.flatMap((r) => {
407
- const edges = r.entry.migrationEdges ?? [];
414
+ const edges = r.entry.migrationEdges;
408
415
  return edges.map((edge) => ({
409
416
  spaceId: r.spaceId,
410
417
  dirName: edge.dirName,
@@ -2,7 +2,11 @@ import type {
2
2
  ContractSourceDiagnostics,
3
3
  ContractSourceProvider,
4
4
  } from '@prisma-next/config/config-types';
5
- import type { Contract, ContractMarkerRecord } from '@prisma-next/contract/types';
5
+ import type {
6
+ Contract,
7
+ ContractMarkerRecord,
8
+ LedgerEntryRecord,
9
+ } from '@prisma-next/contract/types';
6
10
  import type {
7
11
  ControlAdapterDescriptor,
8
12
  ControlDriverDescriptor,
@@ -876,6 +880,13 @@ export interface ControlClient {
876
880
  */
877
881
  readAllMarkers(): Promise<ReadonlyMap<string, ContractMarkerRecord>>;
878
882
 
883
+ /**
884
+ * Reads the per-migration ledger journal for `space` in apply order.
885
+ * Returns an empty array when the ledger store does not yet exist or
886
+ * has no rows for that space.
887
+ */
888
+ readLedger(space?: string): Promise<readonly LedgerEntryRecord[]>;
889
+
879
890
  /**
880
891
  * Applies pre-planned on-disk migrations to the database.
881
892
  * Each migration runs in its own transaction with full execution checks.
@@ -0,0 +1,31 @@
1
+ import { createColors } from 'colorette';
2
+
3
+ export type LaneColorizer = (text: string) => string;
4
+
5
+ const { magenta, cyan, green, yellow, blueBright, red } = createColors({ useColor: true });
6
+
7
+ export const LANE_COLOR_CYCLE: readonly LaneColorizer[] = [
8
+ magenta,
9
+ cyan,
10
+ green,
11
+ yellow,
12
+ blueBright,
13
+ red,
14
+ ];
15
+
16
+ /**
17
+ * The hue for a gutter column. The leftmost lane (column 0) is **neutral** — it
18
+ * has nothing to be told apart from in the common single-lane linear case, so
19
+ * the renderer dims it rather than tinting it; the rotating palette is reserved
20
+ * for columns ≥ 1 (where a second lane exists to distinguish). Callers must dim
21
+ * column 0 themselves; this returns identity for it as a guard. A lane freed and
22
+ * reused by a later branch keeps its column's hue — coloring is by position, not
23
+ * branch identity, exactly like `git log --graph`.
24
+ */
25
+ export function laneColorForColumn(column: number): LaneColorizer {
26
+ if (column <= 0) {
27
+ return (text) => text;
28
+ }
29
+ const colorizer = LANE_COLOR_CYCLE[(column - 1) % LANE_COLOR_CYCLE.length];
30
+ return colorizer ?? ((text) => text);
31
+ }
@@ -21,6 +21,7 @@ export type StructuralCell =
21
21
  | { readonly kind: 'arc-branch-corner' }
22
22
  | { readonly kind: 'arc-branch-tee' }
23
23
  | { readonly kind: 'arc-land-corner' }
24
+ | { readonly kind: 'arc-land-tee' }
24
25
  | { readonly kind: 'arc-crossing' }
25
26
  | { readonly kind: 'arc-land-bridge' }
26
27
  | {
@@ -379,6 +380,7 @@ function emptyCells(width: number): StructuralCell[] {
379
380
  function buildBranchConnectorCells(
380
381
  startLane: number,
381
382
  endLane: number,
383
+ fanTargetLanes: ReadonlySet<number>,
382
384
  activeLanes: ReadonlySet<number>,
383
385
  gridWidth: number,
384
386
  ): StructuralCell[] {
@@ -393,7 +395,13 @@ function buildBranchConnectorCells(
393
395
  } else if (lane === endLane) {
394
396
  cells[lane] = { kind: 'branch-corner' };
395
397
  } else if (lane > startLane && lane < endLane) {
396
- cells[lane] = { kind: 'branch-tee' };
398
+ if (fanTargetLanes.has(lane)) {
399
+ cells[lane] = { kind: 'branch-tee' };
400
+ } else if (activeLanes.has(lane)) {
401
+ cells[lane] = { kind: 'arc-crossing' };
402
+ } else {
403
+ cells[lane] = { kind: 'branch-tee' };
404
+ }
397
405
  }
398
406
  }
399
407
  return cells;
@@ -402,6 +410,7 @@ function buildBranchConnectorCells(
402
410
  function buildMergeConnectorCells(
403
411
  startLane: number,
404
412
  endLane: number,
413
+ fanTargetLanes: ReadonlySet<number>,
405
414
  activeLanes: ReadonlySet<number>,
406
415
  gridWidth: number,
407
416
  ): StructuralCell[] {
@@ -416,7 +425,13 @@ function buildMergeConnectorCells(
416
425
  } else if (lane === endLane) {
417
426
  cells[lane] = { kind: 'merge-corner' };
418
427
  } else if (lane > startLane && lane < endLane) {
419
- cells[lane] = activeLanes.has(lane) ? { kind: 'merge-tee' } : { kind: 'horizontal-pass' };
428
+ if (fanTargetLanes.has(lane)) {
429
+ cells[lane] = { kind: 'merge-tee' };
430
+ } else if (activeLanes.has(lane)) {
431
+ cells[lane] = { kind: 'arc-crossing' };
432
+ } else {
433
+ cells[lane] = { kind: 'horizontal-pass' };
434
+ }
420
435
  }
421
436
  }
422
437
  return cells;
@@ -689,6 +704,15 @@ function applySkipRollbackRouting(
689
704
  .map((other) => other.backLane);
690
705
  const maxCoSourcedLane = Math.max(...coSourcedLanes);
691
706
 
707
+ // Back-lanes of arcs that converge on this same target node. They share the
708
+ // node's landing row, so each inner lane reads as a `┴` junction (the outer
709
+ // arcs' horizontal bridge passes through it on the way to the node) and only
710
+ // the outermost closes the corner with `╯`.
711
+ const coLandingLanes = routes
712
+ .filter((other) => other.edge.to === edge.to)
713
+ .map((other) => other.backLane);
714
+ const maxCoLandingLane = Math.max(...coLandingLanes);
715
+
692
716
  const sourceRow = result[sourceRowIndex];
693
717
  if (sourceRow !== undefined) {
694
718
  const cells = sourceRow.cells;
@@ -750,6 +774,7 @@ function applySkipRollbackRouting(
750
774
  const existing = cells[backLane];
751
775
  if (
752
776
  existing?.kind !== 'arc-land-corner' &&
777
+ existing?.kind !== 'arc-land-tee' &&
753
778
  existing?.kind !== 'arc-land-bridge' &&
754
779
  existing?.kind !== 'arc-branch-corner' &&
755
780
  existing?.kind !== 'arc-branch-tee' &&
@@ -766,6 +791,12 @@ function applySkipRollbackRouting(
766
791
  const contractHash = targetRow.contractHash ?? EMPTY_CONTRACT_HASH;
767
792
  cells[targetCol] = { kind: 'node', contractHash, arcLand: true };
768
793
  for (let lane = targetCol + 1; lane < backLane; lane += 1) {
794
+ // An inner converging arc's own landing junction: the outer arcs' bridge
795
+ // passes through it (`┴`) while its own vertical run closes here.
796
+ if (coLandingLanes.includes(lane)) {
797
+ cells[lane] = { kind: 'arc-land-tee' };
798
+ continue;
799
+ }
769
800
  // A bridged lane that carries another arc OR a forward vertical still
770
801
  // active at this row must cross over it (`┼`) rather than overwrite it
771
802
  // with a bare bridge (`──`).
@@ -774,7 +805,8 @@ function applySkipRollbackRouting(
774
805
  existing !== undefined &&
775
806
  existing.kind !== 'empty' &&
776
807
  existing.kind !== 'horizontal-pass' &&
777
- existing.kind !== 'arc-land-bridge';
808
+ existing.kind !== 'arc-land-bridge' &&
809
+ existing.kind !== 'arc-land-tee';
778
810
  const crossed =
779
811
  occupied ||
780
812
  routes.some(
@@ -785,7 +817,10 @@ function applySkipRollbackRouting(
785
817
  );
786
818
  cells[lane] = crossed ? { kind: 'arc-crossing' } : { kind: 'arc-land-bridge' };
787
819
  }
788
- cells[backLane] = { kind: 'arc-land-corner' };
820
+ // Inner converging arcs close as a landing tee so the outermost arc's
821
+ // bridge reads through to the node; only the outermost arc draws `╯`.
822
+ cells[backLane] =
823
+ backLane < maxCoLandingLane ? { kind: 'arc-land-tee' } : { kind: 'arc-land-corner' };
789
824
  for (const other of routes) {
790
825
  if (other.backLane <= backLane) continue;
791
826
  if (!routeCrossesRow(other, targetRowIndex, result)) continue;
@@ -793,6 +828,7 @@ function applySkipRollbackRouting(
793
828
  const existing = cells[other.backLane];
794
829
  if (
795
830
  existing?.kind !== 'arc-land-corner' &&
831
+ existing?.kind !== 'arc-land-tee' &&
796
832
  existing?.kind !== 'arc-land-bridge' &&
797
833
  existing?.kind !== 'node'
798
834
  ) {
@@ -922,13 +958,14 @@ function layoutComponent(
922
958
  const endLane = Math.max(...laneIndices);
923
959
  ensureGridWidth(endLane + 1);
924
960
  const activeLanes = new Set(activeLaneIndices());
961
+ const fanTargetLanes = new Set(laneIndices);
925
962
  rows.push({
926
963
  kind: 'merge-connector',
927
964
  contractHash,
928
965
  startLane,
929
966
  endLane,
930
967
  branchCount: laneIndices.length,
931
- cells: buildMergeConnectorCells(startLane, endLane, activeLanes, gridWidth),
968
+ cells: buildMergeConnectorCells(startLane, endLane, fanTargetLanes, activeLanes, gridWidth),
932
969
  });
933
970
  for (const index of laneIndices) {
934
971
  if (index !== startLane) setLane(index, null);
@@ -941,6 +978,7 @@ function layoutComponent(
941
978
  startLane: number,
942
979
  endLane: number,
943
980
  branchCount: number,
981
+ fanTargetLanes: readonly number[],
944
982
  ): void {
945
983
  ensureGridWidth(endLane + 1);
946
984
  const activeLanes = new Set(activeLaneIndices());
@@ -950,7 +988,13 @@ function layoutComponent(
950
988
  startLane,
951
989
  endLane,
952
990
  branchCount,
953
- cells: buildBranchConnectorCells(startLane, endLane, activeLanes, gridWidth),
991
+ cells: buildBranchConnectorCells(
992
+ startLane,
993
+ endLane,
994
+ new Set(fanTargetLanes),
995
+ activeLanes,
996
+ gridWidth,
997
+ ),
954
998
  });
955
999
  }
956
1000
 
@@ -1046,7 +1090,7 @@ function layoutComponent(
1046
1090
 
1047
1091
  if (groups.length >= 2) {
1048
1092
  const endLane = Math.max(...laneForGroup);
1049
- emitBranchConnector(node, column, endLane, groups.length);
1093
+ emitBranchConnector(node, column, endLane, groups.length, laneForGroup);
1050
1094
  }
1051
1095
 
1052
1096
  for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {