@fougere/cli 0.2.0-alpha.2 → 0.4.0-alpha.0

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 (81) hide show
  1. package/README.md +11 -2
  2. package/app/commands/BuildCommand.ts +39 -0
  3. package/app/commands/CallCommand.ts +3 -3
  4. package/app/commands/CheckCommand.ts +2 -1
  5. package/app/commands/ExplainCommand.ts +124 -0
  6. package/app/commands/FreezeCommand.ts +107 -0
  7. package/app/commands/GrantCommand.ts +44 -0
  8. package/app/commands/KeysCommand.ts +56 -0
  9. package/app/commands/MigrateCommand.ts +54 -0
  10. package/app/commands/NewCommand.ts +5 -5
  11. package/app/commands/ServeCommand.ts +74 -7
  12. package/app/commands/grant-material.ts +5 -0
  13. package/dist/bin.js +55 -10
  14. package/dist/bin.js.map +1 -1
  15. package/dist/bridge.d.ts.map +1 -1
  16. package/dist/bridge.js +15 -8
  17. package/dist/bridge.js.map +1 -1
  18. package/dist/completion.d.ts +4 -1
  19. package/dist/completion.d.ts.map +1 -1
  20. package/dist/completion.js +54 -20
  21. package/dist/completion.js.map +1 -1
  22. package/dist/loader.d.ts +11 -0
  23. package/dist/loader.d.ts.map +1 -0
  24. package/dist/loader.js +23 -0
  25. package/dist/loader.js.map +1 -0
  26. package/dist/runner.d.ts.map +1 -1
  27. package/dist/runner.js +7 -6
  28. package/dist/runner.js.map +1 -1
  29. package/fronds/analysis/entities/Build.ts +7 -0
  30. package/fronds/analysis/entities/Explain.ts +9 -0
  31. package/fronds/analysis/entities/Freeze.ts +7 -0
  32. package/fronds/analysis/entities/Migrate.ts +7 -0
  33. package/fronds/analysis/handlers/BuildHandler.ts +60 -0
  34. package/fronds/analysis/handlers/CheckHandler.ts +40 -41
  35. package/fronds/analysis/handlers/ExplainHandler.ts +254 -0
  36. package/fronds/analysis/handlers/FreezeHandler.ts +176 -0
  37. package/fronds/analysis/handlers/MigrateHandler.ts +97 -0
  38. package/fronds/analysis/services/ProjectScan.ts +23 -7
  39. package/fronds/analysis/versions.ts +58 -0
  40. package/fronds/scaffold/entities/BuildFrond.ts +1 -1
  41. package/fronds/scaffold/entities/Call.ts +1 -1
  42. package/fronds/scaffold/entities/Grant.ts +6 -0
  43. package/fronds/scaffold/entities/Keys.ts +4 -0
  44. package/fronds/scaffold/entities/Serve.ts +2 -1
  45. package/fronds/scaffold/entities/Sync.ts +1 -1
  46. package/fronds/scaffold/handlers/BuildFrondHandler.ts +13 -15
  47. package/fronds/scaffold/handlers/GrantHandler.ts +8 -0
  48. package/fronds/scaffold/handlers/KeysHandler.ts +8 -0
  49. package/fronds/scaffold/handlers/SyncHandler.ts +40 -37
  50. package/fronds/scaffold/services/ProjectWriter.ts +9 -8
  51. package/package.json +10 -8
  52. package/src/bin.ts +83 -0
  53. package/src/bridge.ts +70 -0
  54. package/src/completion.ts +152 -0
  55. package/src/index.ts +3 -0
  56. package/src/loader.ts +28 -0
  57. package/src/runner.ts +139 -0
  58. package/src/theme.ts +19 -0
  59. package/src/ui.ts +131 -0
  60. package/templates/admin/fronds/admin/handlers/UserHandler.ts +3 -5
  61. package/templates/admin/fronds/admin/package.json +1 -1
  62. package/templates/api/fronds/api/handlers/TaskHandler.ts +3 -5
  63. package/templates/api/fronds/api/package.json +1 -1
  64. package/templates/apps/nuxt/app/pages/index.vue +1 -1
  65. package/templates/blog/app/pages/posts/index.vue +1 -1
  66. package/templates/blog/app/pages/posts/manage.vue +1 -1
  67. package/templates/blog/app/pages/posts/new.vue +1 -1
  68. package/templates/blog/fronds/blog/handlers/PostHandler.ts +3 -5
  69. package/templates/blog/fronds/blog/package.json +1 -1
  70. package/templates/flat/AGENTS.md +14 -0
  71. package/templates/flat/CLAUDE.md +25 -3
  72. package/templates/frond/AGENTS.md +14 -0
  73. package/templates/frond/CLAUDE.md +25 -3
  74. package/templates/frond/fronds/__name__/handlers/PostHandler.ts +3 -5
  75. package/templates/frond/fronds/__name__/package.json +1 -1
  76. package/templates/frond/serve.mjs +3 -2
  77. package/templates/fronds/blank/package.json +1 -1
  78. package/templates/fronds/blog/handlers/PostHandler.ts +2 -4
  79. package/templates/fronds/blog/package.json +1 -1
  80. package/templates/workspace/AGENTS.md +14 -0
  81. package/templates/workspace/CLAUDE.md +25 -3
@@ -1,4 +1,10 @@
1
- import { buildGraph, clusterEntities, crossFrondImports, resolveContracts, type ScanDiagnostic } from '@fougere/core';
1
+ import {
2
+ buildGraph,
3
+ clusterEntities,
4
+ resolveEffectiveOperations,
5
+ type ScanDiagnostic,
6
+ } from '@fougere/core';
7
+ import { crossFrondImports } from '@fougere/core/node';
2
8
  import ProjectScan from '../services/ProjectScan.js';
3
9
 
4
10
  /** One thing that does not hold, in the terms of whoever has to fix it. */
@@ -8,6 +14,12 @@ export interface Finding {
8
14
  code: string;
9
15
  /** Where to go and look. */
10
16
  filePath: string;
17
+ /**
18
+ * What the finding is ABOUT — `PostHandler.whoNull(user)` — when the rule holds it
19
+ * as a fact rather than inside its sentence. Two ops of one handler breaking the
20
+ * same rule read as one repeated line without it.
21
+ */
22
+ subject?: string;
11
23
  /** What is wrong, and what it costs. One sentence. */
12
24
  message: string;
13
25
  }
@@ -18,57 +30,38 @@ export interface CheckResult {
18
30
  findings: Finding[];
19
31
  }
20
32
 
33
+ /** Shared with `fougere graph` — one threshold, so the two never disagree. */
34
+ const DOMAIN_SPLIT_MIN_ENTITIES = 6;
35
+
21
36
  /**
22
37
  * What does not hold in this app — derived from its declarations, not from tests.
23
38
  *
24
39
  * It scans rather than boots — see `ProjectScan`, which states what that costs.
25
40
  *
26
- * Two rules today, and the shape for the rest:
41
+ * It raises no operation-resolution rule of its own: those findings come from the same
42
+ * EffectiveOperation resolver boot consumes. The remaining findings come from their
43
+ * existing owners — scan diagnostics, the import reader and domain clustering. A second
44
+ * opinion here would report what the runtime does not serve, precisely what a checker
45
+ * must avoid.
27
46
  *
28
- * - what the scan could not do (`ScanResult.diagnostics`) an unreadable
29
- * directory, a handler that would not parse, an `extends` it could not follow;
30
- * - an operation whose parameters have no binding plan — it is served, and it
31
- * receives nothing;
32
- * - a relative import that resolves into another frond — a colocation constraint
33
- * nothing declares, which holds until the day the other frond is not on this disk.
34
- *
35
- * A rule about an ABSENCE is only sound if the analysis attests it looked, which
36
- * is why the first bullet had to exist before this command could.
47
+ * A rule about an ABSENCE is only sound if the analysis attests it looked, which is
48
+ * why the scan's diagnostics had to exist before this command could.
37
49
  */
38
- /** Shared with `fougere graph` — one threshold, so the two never disagree. */
39
- const DOMAIN_SPLIT_MIN_ENTITIES = 6;
40
-
41
50
  export default class CheckHandler {
42
51
  constructor(private projectScan: ProjectScan) {}
43
52
 
44
53
  /** Report what does not hold in a Fougere app, without booting it. */
45
54
  async execute(input: { root?: string }): Promise<CheckResult> {
46
- const { fronds, diagnostics } = await this.projectScan.at(input.root);
47
- const findings: Finding[] = diagnostics.map(asFinding);
48
- let handlers = 0;
49
-
50
- for (const frond of fronds) {
51
- const collectorEntityNames = new Set(frond.collectors.map((c) => c.entityName));
52
- for (const handler of frond.handlers) {
53
- handlers++;
54
- // The same merge the façade performs — asked for, not redone. A second
55
- // opinion here would report a contract the runtime does not serve.
56
- const contracts = resolveContracts(handler, frond.operationsOverrides, collectorEntityNames);
57
-
58
- for (const [op, contract] of contracts) {
59
- const params = contract.signature?.params.length ?? 0;
60
- if (params > 0 && !contract.binding) {
61
- findings.push({
62
- severity: 'blocking',
63
- code: 'operation-unbound',
64
- filePath: handler.filePath,
65
- message: `${handler.ctor.name}.${op} declares ${params} parameter(s) and has no `
66
- + `binding plan — it is served, and it receives none of them.`,
67
- });
68
- }
69
- }
70
- }
71
- }
55
+ const { fronds, diagnostics, config } = await this.projectScan.at(input.root);
56
+ // This is the same pure resolution the boot consumes. No app lifecycle, database,
57
+ // migration, seed or adapter mount is needed for a global semantic check.
58
+ const model = resolveEffectiveOperations(fronds, {
59
+ diagnostics,
60
+ remotes: config.remotes,
61
+ adapters: config.adapters,
62
+ });
63
+ const findings: Finding[] = model.diagnostics.map(asFinding);
64
+ const handlers = fronds.reduce((count, frond) => count + frond.handlers.length, 0);
72
65
 
73
66
  /**
74
67
  * Entity groups with no `ref()` between them, in one frond. Reported as a FACT, not a
@@ -113,5 +106,11 @@ export default class CheckHandler {
113
106
 
114
107
  /** A scan diagnostic IS a finding — same vocabulary, so the renderer has one shape. */
115
108
  function asFinding(d: ScanDiagnostic): Finding {
116
- return { severity: d.severity, code: d.code, filePath: d.filePath, message: d.message };
109
+ return {
110
+ severity: d.severity,
111
+ code: d.code,
112
+ filePath: d.filePath,
113
+ subject: d.subject,
114
+ message: d.message,
115
+ };
117
116
  }
@@ -0,0 +1,254 @@
1
+ import {
2
+ resolveEffectiveOperations,
3
+ type EffectiveOperation,
4
+ type OperationContract,
5
+ } from '@fougere/core';
6
+ import { relative } from 'node:path';
7
+ import ProjectScan from '../services/ProjectScan.js';
8
+ import { ANONYMOUS_SCHEMA_NAME, lowerFirst, type SchemaView } from '@fougere/schema';
9
+
10
+ type Cardinality = NonNullable<OperationContract['cardinality']>;
11
+ type Binding = EffectiveOperation['binding'][number];
12
+
13
+ export type ExplainedBinding =
14
+ | { kind: 'collector'; typeName: string }
15
+ | { kind: 'fact'; factName: string }
16
+ | { kind: 'param'; name: string; coerce?: 'number' | 'boolean' }
17
+ | { kind: 'body' }
18
+ | { kind: 'context' }
19
+ | { kind: 'query' };
20
+
21
+ export interface ExplainedParameter {
22
+ name: string;
23
+ type: string | null;
24
+ optional: boolean;
25
+ nullable: boolean;
26
+ undefinable: boolean;
27
+ binding: ExplainedBinding | null;
28
+ }
29
+
30
+ export interface ExplainedCollector {
31
+ typeName: string;
32
+ class: string;
33
+ file: string;
34
+ }
35
+
36
+ /** Stable, JSON-shaped projection of core's canonical EffectiveOperation. */
37
+ export interface ExplainResult {
38
+ operation: string;
39
+ handler: {
40
+ class: string;
41
+ address: string;
42
+ method: string;
43
+ file: string | null;
44
+ };
45
+ kind: 'query' | 'command';
46
+ description: string | null;
47
+ input: string | null;
48
+ output: { type: string; cardinality: Cardinality | null } | null;
49
+ parameters: ExplainedParameter[];
50
+ collectors: ExplainedCollector[];
51
+ contexts: string[];
52
+ semantics: EffectiveOperation['semantics'];
53
+ exposure: {
54
+ surfaces: string[];
55
+ adapters: string[];
56
+ };
57
+ placement: {
58
+ frond: string;
59
+ runtime: 'local' | 'remote';
60
+ remote: string | null;
61
+ };
62
+ }
63
+
64
+ /** What this project serves, when no single operation was named. */
65
+ export interface ExplainListing {
66
+ fronds: {
67
+ name: string;
68
+ runtime: 'local' | 'remote';
69
+ remote: string | null;
70
+ operations: number;
71
+ }[];
72
+ operations: string[];
73
+ }
74
+
75
+ interface Selector {
76
+ frond?: string;
77
+ surface?: string;
78
+ address: string;
79
+ op: string;
80
+ }
81
+
82
+ /** Read one operation directly from the model boot and check also consume. */
83
+ export default class ExplainHandler {
84
+ constructor(private projectScan: ProjectScan) {}
85
+
86
+ /** The names this project serves — the list `explain` used to spell in a refusal only. */
87
+ async list(input: { root?: string }): Promise<ExplainListing> {
88
+ const { scan, model } = await this.modelOf(input.root);
89
+ const remotes = scan.config.remotes ?? {};
90
+ const counted = new Map<string, number>();
91
+ for (const operation of model.operations) {
92
+ const frond = operation.placement.frond;
93
+ counted.set(frond, (counted.get(frond) ?? 0) + 1);
94
+ }
95
+
96
+ return {
97
+ fronds: scan.fronds.map((frond) => ({
98
+ name: frond.name,
99
+ runtime: remotes[frond.name] ? 'remote' as const : 'local' as const,
100
+ remote: remotes[frond.name] ?? null,
101
+ operations: counted.get(frond.name) ?? 0,
102
+ })).sort((a, b) => a.name.localeCompare(b.name)),
103
+ operations: model.operations.map((operation) => operation.id).sort(),
104
+ };
105
+ }
106
+
107
+ private async modelOf(root?: string) {
108
+ const scan = await this.projectScan.at(root);
109
+ return {
110
+ scan,
111
+ model: resolveEffectiveOperations(scan.fronds, {
112
+ diagnostics: scan.diagnostics,
113
+ remotes: scan.config.remotes,
114
+ adapters: scan.config.adapters,
115
+ }),
116
+ };
117
+ }
118
+
119
+ /** Print the contract one operation will be served under. */
120
+ async execute(input: { operation?: string; root?: string; json?: boolean }): Promise<ExplainResult> {
121
+ const requested = input.operation?.trim();
122
+ if (!requested) throw new Error('Usage: fougere explain <Operation> [--json] [--root <directory>]');
123
+
124
+ const selector = parseSelector(requested);
125
+ const { scan, model } = await this.modelOf(input.root);
126
+ const candidates = model.operations.filter((operation) => matches(operation, selector));
127
+
128
+ if (candidates.length === 0) {
129
+ const failed = model.resolutionDiagnostics.find((diagnostic) =>
130
+ diagnostic.subject?.toLowerCase().includes(`.${selector.op.toLowerCase()}`)
131
+ && diagnostic.subject.toLowerCase().includes(selector.address.toLowerCase()));
132
+ if (failed) throw new Error(`[${failed.code}] ${failed.message}`);
133
+
134
+ const available = model.operations.map((operation) => operation.id).sort();
135
+ throw new Error(
136
+ `Unknown operation '${requested}'. `
137
+ + (available.length > 0
138
+ ? `Available operations: ${available.join(', ')}.`
139
+ : 'This project exposes no resolved operation.'),
140
+ );
141
+ }
142
+
143
+ if (candidates.length > 1) {
144
+ throw new Error(
145
+ `Ambiguous operation '${requested}'. Matches: `
146
+ + `${candidates.map((operation) => operation.id).sort().join(', ')}. `
147
+ + 'Use one of these qualified names.',
148
+ );
149
+ }
150
+
151
+ const selected = candidates[0]!;
152
+ const invalid = model.resolutionDiagnostics.find((diagnostic) =>
153
+ diagnostic.subject?.startsWith(`${selected.handler.className}.${selected.name}`));
154
+ if (invalid) throw new Error(`[${invalid.code}] ${invalid.message}`);
155
+
156
+ return project(selected, scan.root);
157
+ }
158
+ }
159
+
160
+ function project(operation: EffectiveOperation, root: string): ExplainResult {
161
+ return {
162
+ operation: operation.operation,
163
+ handler: {
164
+ class: operation.implementation.className,
165
+ address: operation.handler.address,
166
+ method: operation.implementation.method,
167
+ file: operation.implementation.filePath
168
+ ? relative(root, operation.implementation.filePath)
169
+ : null,
170
+ },
171
+ kind: operation.kind,
172
+ description: operation.description ?? null,
173
+ input: inputTypeOf(operation),
174
+ output: outputOf(operation),
175
+ parameters: operation.parameters.map((parameter) => ({
176
+ name: parameter.name,
177
+ type: parameter.type,
178
+ optional: parameter.optional,
179
+ nullable: parameter.nullable,
180
+ undefinable: parameter.undefinable,
181
+ binding: bindingOf(parameter.binding),
182
+ })),
183
+ collectors: operation.collectors.map((collector) => ({
184
+ typeName: collector.typeName,
185
+ class: collector.className,
186
+ file: relative(root, collector.filePath),
187
+ })),
188
+ contexts: operation.contexts,
189
+ semantics: operation.semantics,
190
+ exposure: operation.exposure,
191
+ placement: {
192
+ frond: operation.placement.frond,
193
+ runtime: operation.placement.runtime,
194
+ remote: operation.placement.remote ?? null,
195
+ },
196
+ };
197
+ }
198
+
199
+ function parseSelector(value: string): Selector {
200
+ const dot = value.lastIndexOf('.');
201
+ if (dot <= 0 || dot === value.length - 1) {
202
+ throw new Error(`Invalid operation '${value}'. Expected '<Handler>.<method>', for example 'Post.publish'.`);
203
+ }
204
+
205
+ const path = value.slice(0, dot).split('/').filter(Boolean);
206
+ if (path.length === 0 || path.length > 3) {
207
+ throw new Error(`Invalid operation '${value}'. Expected 'Post.publish', 'blog/Post.publish', or 'blog/public/Post.publish'.`);
208
+ }
209
+
210
+ const qualifiers = path.slice(0, -1);
211
+ return {
212
+ ...(qualifiers[0] ? { frond: qualifiers[0] } : {}),
213
+ ...(qualifiers[1] ? { surface: qualifiers[1] } : {}),
214
+ address: addressOf(path.at(-1)!),
215
+ op: value.slice(dot + 1),
216
+ };
217
+ }
218
+
219
+ function matches(operation: EffectiveOperation, selector: Selector): boolean {
220
+ return operation.handler.address.toLowerCase() === selector.address.toLowerCase()
221
+ && operation.name === selector.op
222
+ && (!selector.frond || operation.placement.frond === selector.frond)
223
+ && (!selector.surface || operation.exposure.surfaces.includes(selector.surface));
224
+ }
225
+
226
+ function addressOf(value: string): string {
227
+ const base = value.endsWith('Handler') ? value.slice(0, -'Handler'.length) : value;
228
+ return lowerFirst(base);
229
+ }
230
+
231
+ function inputTypeOf(operation: EffectiveOperation): string | null {
232
+ const body = operation.parameters.find((parameter) => parameter.binding.source.kind === 'body');
233
+ return body?.type ?? schemaName(operation.input) ?? null;
234
+ }
235
+
236
+ function outputOf(operation: EffectiveOperation): ExplainResult['output'] {
237
+ const type = schemaName(operation.output) ?? parsedOutput(operation.signature?.returnType?.raw);
238
+ return type ? { type, cardinality: operation.cardinality ?? null } : null;
239
+ }
240
+
241
+ function schemaName(schema: SchemaView | undefined): string | undefined {
242
+ if (!schema) return undefined;
243
+ if (schema.name && schema.name !== ANONYMOUS_SCHEMA_NAME) return schema.name;
244
+ return schema.derivation?.sourceName;
245
+ }
246
+
247
+ function parsedOutput(raw: string | undefined): string | undefined {
248
+ if (!raw) return undefined;
249
+ return raw.startsWith('Promise<') && raw.endsWith('>') ? raw.slice(8, -1) : raw;
250
+ }
251
+
252
+ function bindingOf(binding: Binding): ExplainedBinding {
253
+ return { ...binding.source } as ExplainedBinding;
254
+ }
@@ -0,0 +1,176 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { Bundle, lowerFirst, type SchemaBundle, type SchemaView, type SetDiff } from '@fougere/schema';
4
+ import ProjectScan from '../services/ProjectScan.js';
5
+ import { VERSIONS, chainOf } from '../versions.js';
6
+ import type Freeze from '../entities/Freeze.js';
7
+
8
+ export interface FreezeInspection {
9
+ version: string;
10
+ /** The version this one follows, or absent when it is the first. */
11
+ previous?: string;
12
+ entities: string[];
13
+ /** What the step contains — absent when there is nothing before to step from. */
14
+ step?: SetDiff;
15
+ /** Per entity, the pairs the calculation refuses to decide. Empty means it was written. */
16
+ ambiguous: Record<string, { removed: string; added: string }[]>;
17
+ /** Whether anything reached the disk — false while a question stands. */
18
+ written: boolean;
19
+ }
20
+
21
+ /**
22
+ * Freezing a version — the artefact three readers share.
23
+ *
24
+ * A snapshot alone loses the INTENT (a field gone plus a field appeared cannot be told
25
+ * from a rename), and a step alone corrupts in silence. Both are written, and replaying
26
+ * the step over the previous snapshot must reproduce this one — which is what catches a
27
+ * missing step or a hand-edited file.
28
+ *
29
+ * Nothing is written while an ambiguity stands: the only information the code does not
30
+ * hold is what the person who made the change meant, and this is the one place asking
31
+ * for it is justified.
32
+ */
33
+ export default class FreezeHandler {
34
+ constructor(private projectScan: ProjectScan) {}
35
+
36
+ /**
37
+ * Record this version — or report what stops it, having written nothing.
38
+ *
39
+ * One op and not two: it is idempotent while it refuses, so a caller settles the
40
+ * ambiguities and calls again with `renamed`. Splitting it would let a caller write
41
+ * a version it never inspected.
42
+ */
43
+ async execute(input: Freeze & { renamed?: Record<string, Record<string, string>> }): Promise<FreezeInspection> {
44
+ const fronds = await this.read(input);
45
+ const version = input.version;
46
+ const entities = fronds.flatMap(({ bundle }) => Object.keys(bundle.$defs ?? {}));
47
+ // Two entrances, one map: what the entities declare, and what a caller answered.
48
+ // The answer wins — it is the later word on a question the declaration left open.
49
+ const renamed = settled(fronds.map(({ declared }) => declared), input.renamed ?? {});
50
+
51
+ // Every frond is inspected before ANY of them writes: a question standing in one
52
+ // frond must not leave the others recorded, or a second run cuts half a version.
53
+ const inspected = fronds.map(({ path, bundle, previous }) => ({
54
+ path,
55
+ bundle,
56
+ previous,
57
+ step: previous
58
+ ? Bundle.fromDescriptor(previous.bundle).diff(Bundle.fromDescriptor(bundle), { renamed })
59
+ : undefined,
60
+ }));
61
+
62
+ const ambiguous: FreezeInspection['ambiguous'] = {};
63
+ for (const { step } of inspected) {
64
+ for (const [name, answer] of Object.entries(step?.entities ?? {})) {
65
+ if (answer.ambiguous.length > 0) ambiguous[name] = answer.ambiguous;
66
+ }
67
+ }
68
+ // Nothing on disk while a question stands. The only information the code does not
69
+ // hold is what the person who made the change meant.
70
+ if (Object.keys(ambiguous).length > 0) {
71
+ return { version, previous: previousName(inspected), entities, step: merge(inspected), ambiguous, written: false };
72
+ }
73
+
74
+ for (const { path, bundle, previous, step } of inspected) {
75
+ await this.record(path, version, bundle);
76
+ if (!previous || !step) continue;
77
+ // `previous` is recorded rather than re-derived: the chain is a fact of the moment
78
+ // this version was cut, and a later sort of directory names is not that fact.
79
+ await writeFile(
80
+ join(path, VERSIONS, version, 'from.json'),
81
+ `${JSON.stringify({ previous: previous.name, renamed, ...step }, null, 2)}\n`,
82
+ );
83
+ }
84
+
85
+ return { version, previous: previousName(inspected), entities, step: merge(inspected), ambiguous: {}, written: true };
86
+ }
87
+
88
+ private async record(root: string, version: string, bundle: SchemaBundle): Promise<void> {
89
+ const directory = join(root, VERSIONS, version);
90
+ await mkdir(directory, { recursive: true });
91
+ await writeFile(join(directory, 'shape.json'), `${JSON.stringify(bundle, null, 2)}\n`);
92
+ }
93
+
94
+ /**
95
+ * Today's shapes and the version before them, one entry per frond.
96
+ *
97
+ * `Fronds.schemas()` is deliberately flat — a fact heard in one frond is declared in
98
+ * another — so the per-frond map is built here, where the question IS per frond.
99
+ */
100
+ private async read(input: Freeze) {
101
+ const scan = await this.projectScan.at(input.root ?? undefined);
102
+ return Promise.all(
103
+ scan.fronds
104
+ .filter((frond) => frond.entities.length > 0)
105
+ .map(async (frond) => ({
106
+ path: frond.source.path,
107
+ bundle: Bundle.fromSchemas(
108
+ Object.fromEntries(frond.entities.map((e) => [e.name, e.entityClass])),
109
+ ).descriptor,
110
+ declared: declaredRenames(frond.entities),
111
+ previous: await previousOf(frond.source.path, input.version),
112
+ })),
113
+ );
114
+ }
115
+ }
116
+
117
+ type Inspected = { previous?: { name: string }; step?: SetDiff };
118
+
119
+ /**
120
+ * What the entities state about themselves — `previous` says what a field WAS, while
121
+ * `Bundle.diff` reads old to new, so the pair is turned around here and nowhere else.
122
+ */
123
+ function declaredRenames(
124
+ entities: readonly { name: string; entityClass: unknown }[],
125
+ ): Record<string, Record<string, string>> {
126
+ const out: Record<string, Record<string, string>> = {};
127
+ for (const { name, entityClass } of entities) {
128
+ const previous = (entityClass as SchemaView).previous;
129
+ // Keyed as `Bundle.fromSchemas` keys `$defs`, which is what `Bundle.diff` reads. Spelling the
130
+ // convention a second way here is the defect this repo has already recorded twice.
131
+ const key = lowerFirst(name);
132
+ if (previous) out[key] = Object.fromEntries(Object.entries(previous).map(([now, was]) => [was, now]));
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** Every source of an answer, folded per entity — later sources win field by field. */
138
+ function settled(
139
+ sources: readonly Record<string, Record<string, string>>[],
140
+ answers: Record<string, Record<string, string>>,
141
+ ): Record<string, Record<string, string>> {
142
+ const out: Record<string, Record<string, string>> = {};
143
+ for (const source of [...sources, answers]) {
144
+ for (const [entity, pairs] of Object.entries(source)) out[entity] = { ...out[entity], ...pairs };
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** The version every frond steps from. They are cut together, so they agree. */
150
+ function previousName(inspected: readonly Inspected[]): string | undefined {
151
+ return inspected.find(({ previous }) => previous)?.previous?.name;
152
+ }
153
+
154
+ /**
155
+ * One report out of several fronds — entity names are unique across a scan, so the
156
+ * union loses nothing. Writing stays per frond; only the telling is gathered.
157
+ */
158
+ function merge(inspected: readonly Inspected[]): SetDiff | undefined {
159
+ const steps = inspected.map(({ step }) => step).filter((step): step is SetDiff => Boolean(step));
160
+ if (steps.length === 0) return undefined;
161
+ return {
162
+ entities: Object.assign({}, ...steps.map((step) => step.entities)),
163
+ entitiesAdded: steps.flatMap((step) => step.entitiesAdded),
164
+ entitiesRemoved: steps.flatMap((step) => step.entitiesRemoved),
165
+ };
166
+ }
167
+
168
+ /** The version this one steps from: the tip of the chain, the links read rather than sorted. */
169
+ async function previousOf(root: string, version: string): Promise<{ name: string; bundle: SchemaBundle } | undefined> {
170
+ const chain = (await chainOf(root)).filter((cut) => cut.name !== version);
171
+ const last = chain.at(-1)?.name;
172
+ if (!last) return undefined;
173
+
174
+ const raw = await readFile(join(root, VERSIONS, last, 'shape.json'), 'utf8').catch(() => undefined);
175
+ return raw ? { name: last, bundle: JSON.parse(raw) as SchemaBundle } : undefined;
176
+ }
@@ -0,0 +1,97 @@
1
+ import { loadConfig } from '@fougere/core/node';
2
+ import { resolveStorage } from '@fougere/defaults';
3
+ import { actualState, desiredTables, planStep, collapseChain, applyStep, type Plan, type StepChange } from '@fougere/adapter-sql';
4
+ import type { SetDiff } from '@fougere/schema';
5
+ import ProjectScan from '../services/ProjectScan.js';
6
+ import { chainOf } from '../versions.js';
7
+ import type Migrate from '../entities/Migrate.js';
8
+
9
+ export interface MigrationPlan {
10
+ /** Versions whose step was read, oldest first. */
11
+ chain: string[];
12
+ changes: StepChange[];
13
+ refusals: Plan['refusals'];
14
+ /** The statements actually run — empty unless `apply` was asked for. */
15
+ ran: string[];
16
+ }
17
+
18
+ /**
19
+ * Catching the database up with the frozen chain.
20
+ *
21
+ * Every step is replayed in order and what has already happened is SKIPPED, read off the
22
+ * columns themselves rather than a ledger of applied migrations. That is what makes the
23
+ * chain safe to replay whole: nothing here has to know which version the database sits
24
+ * at, and a column renamed by hand is seen rather than contradicted.
25
+ *
26
+ * The additive pass is not repeated here — a boot already creates missing tables and
27
+ * columns. What this adds is the half that touches live data, and only what a human
28
+ * declared at `fougere freeze`.
29
+ */
30
+ export default class MigrateHandler {
31
+ constructor(private projectScan: ProjectScan) {}
32
+
33
+ /** Realise the frozen steps this database has not caught up with. */
34
+ async execute(input: Migrate): Promise<MigrationPlan> {
35
+ const scan = await this.projectScan.at(input.root ?? undefined);
36
+ const perFrond = await Promise.all(scan.fronds.map((frond) => stepsOf(frond.source.path)));
37
+ const steps = perFrond.flat();
38
+ if (steps.length === 0) return { chain: [], changes: [], refusals: [], ran: [] };
39
+
40
+ const config = await loadConfig(scan.root);
41
+ const storage = resolveStorage(config.db ?? {});
42
+ if (!storage.db) {
43
+ return {
44
+ chain: versionsOf(steps),
45
+ changes: [],
46
+ refusals: [{ entity: '*', field: '*', reason: 'no `db` in fougere.config.ts — nothing to migrate' }],
47
+ ran: [],
48
+ };
49
+ }
50
+
51
+ const tables = desiredTables(scan as never);
52
+ // Each frond's chain is composed on its own — its versions are its own line — and the
53
+ // results are gathered by SOURCE, because an engine is what a statement runs against.
54
+ const composed = collapseChain(perFrond.map((chain) => collapseChain(chain.map(({ step }) => step))));
55
+ const sourceOf = storage.sourceOf ?? (() => 'db');
56
+
57
+ const changes: StepChange[] = [];
58
+ const refusals: Plan['refusals'] = [];
59
+ const ran: string[] = [];
60
+ for (const source of storage.sources?.() ?? ['db']) {
61
+ const db = (source === 'db' ? storage.db : storage.dbOf?.(source)) as Parameters<typeof actualState>[0];
62
+ if (!db) continue;
63
+
64
+ const mine = onSource(composed, source, sourceOf);
65
+ if (Object.keys(mine.entities).length === 0) continue;
66
+
67
+ const plan = planStep(mine, tables, { actual: await actualState(db) });
68
+ changes.push(...plan.changes);
69
+ refusals.push(...plan.refusals);
70
+ // Held back: a refusal anywhere stops every engine, for the reason it stops every
71
+ // statement — half a chain is worse across two engines than within one.
72
+ if (input.apply && refusals.length === 0) ran.push(...(await applyStep(plan, db)));
73
+ }
74
+
75
+ return { chain: versionsOf(steps), changes, refusals, ran: refusals.length > 0 ? [] : ran };
76
+ }
77
+ }
78
+
79
+ /** The versions read, oldest first and each named once however many fronds cut it. */
80
+ function versionsOf(steps: readonly { version: string }[]): string[] {
81
+ return [...new Set(steps.map(({ version }) => version))].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
82
+ }
83
+
84
+ /** The part of a step whose entities live on one engine. */
85
+ function onSource(step: SetDiff, source: string, sourceOf: (entity: string) => string): SetDiff {
86
+ return {
87
+ ...step,
88
+ entities: Object.fromEntries(Object.entries(step.entities).filter(([entity]) => sourceOf(entity) === source)),
89
+ };
90
+ }
91
+
92
+ /** Every recorded step, oldest first — the chain composes, so it is replayed whole. */
93
+ async function stepsOf(frondPath: string): Promise<{ version: string; step: SetDiff }[]> {
94
+ const chain = await chainOf(frondPath);
95
+ // The first version has a shape and no step — there was nothing before it to move from.
96
+ return chain.flatMap(({ name, step }) => (step ? [{ version: name, step }] : []));
97
+ }