@kedge/cli 0.0.0-stage → 0.10.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.
package/src/compose.ts ADDED
@@ -0,0 +1,637 @@
1
+ // `kedge generate`'s composer (K11, D-036). Wrangler applies every migration
2
+ // file it hasn't recorded, even one numbered below files already applied
3
+ // (research/15), so filenames can't keep two stores in the same order. The
4
+ // lock owns it: core first, then plugins in config order, then local, and
5
+ // every new file is numbered after every file already in the lock. An
6
+ // applied entry is never edited, removed or re-ordered; the composer refuses
7
+ // rather than writes when one would be.
8
+ import { createHash, randomUUID } from "node:crypto";
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import {
12
+ acquireSql,
13
+ addFieldSql,
14
+ BREAK,
15
+ checkoutTracksShaInput,
16
+ columnsOf,
17
+ createEntitySql,
18
+ type EffectOperation,
19
+ ENTITY_COLUMNS,
20
+ ENTITY_LIMITS,
21
+ type EntityDefinition,
22
+ effectiveMax,
23
+ entityDeclInput,
24
+ entityTriggerNames,
25
+ entityTriggersSql,
26
+ type FactTable,
27
+ factGuardSql,
28
+ factTables,
29
+ generatedObjectNames,
30
+ installSql,
31
+ lintSql,
32
+ markedMigrationSql,
33
+ operationContract,
34
+ overlappingName,
35
+ type PluginInstance,
36
+ type PortDefinition,
37
+ permissionsOf,
38
+ registrationKinds,
39
+ sortIndexSql,
40
+ statementTexts,
41
+ subscriptionSql,
42
+ type TrackLock,
43
+ type TzTable,
44
+ trackLock,
45
+ trackLockProblems,
46
+ tracksShaInput,
47
+ UTC_TZ,
48
+ unreachableStates,
49
+ } from "@kedge/core";
50
+ import { byCodePoint } from "./order";
51
+ import { collect, lintPlugin } from "./plugin-lint";
52
+ import { verifyWithSqlite } from "./sqlite-verify";
53
+ import { tzMigrationSql, tzModule } from "./tz";
54
+
55
+ export interface LockEntry {
56
+ /** The composed file's name, `NNNN_<tag>.sql`. */
57
+ file: string;
58
+ /** Where it came from: `core:<file>`, `plugin:<name>:<id>`, `plugin:<name>:install`, `plugin:<name>:generated:<sha>`, `local:<file>`. */
59
+ source: string;
60
+ sha256: string;
61
+ }
62
+ export interface Lock {
63
+ version: 1;
64
+ entries: LockEntry[];
65
+ plugins: Record<
66
+ string,
67
+ {
68
+ installationId: string;
69
+ /** The current generation's content hash and the objects it installed, to drop when it's replaced. */
70
+ generatedSha256: string;
71
+ generatedObjects: string[];
72
+ /** Every fact table the plugin ever declared: its guards stay while its facts do. */
73
+ factTables?: FactTable[];
74
+ /** How many generations have been composed; each new one is a new source, even if its content repeats an older one. */
75
+ generation: number;
76
+ /** The plugin has declared subscribers at some generation: removing the last one still retires its rows (p0.6a §2.2). */
77
+ subscribed?: boolean;
78
+ /**
79
+ * Every subscriber and timer id it has declared, canonical, with its
80
+ * kind: one may vanish only by `retired` (code r10), and never changes
81
+ * kind (code r11), since a delivery is dispatched by the kind it was
82
+ * stored under.
83
+ */
84
+ registrations?: Record<string, "subscriber" | "timer">;
85
+ /** P0.7 (§3.1): each track's subject, states with their terminal flags, transition names and read permission. */
86
+ tracks?: Record<string, TrackLock>;
87
+ /** P0.7 (§4.3): each entity's physical columns and what decides its rows and readers. */
88
+ entities?: Record<string, EntityLock>;
89
+ }
90
+ >;
91
+ /** P0.7 (§3.3): the store-wide checkout-track fingerprint last composed, and how many times it changed. */
92
+ checkoutTracks?: { sha: string | null; generation: number };
93
+ /** The store's pinned zone and rules version (p0.4 §4): fixed once composed. */
94
+ timezone?: { zone: string; version: string };
95
+ /**
96
+ * Each effect operation version's contract hash, by `<port>.<operation>@<version>`
97
+ * (p0.6a §4.2): a locked version's contract never changes, and no locked
98
+ * version leaves the config, since a record admitted under it is recovered
99
+ * by its own `call` and `observe`.
100
+ */
101
+ contracts?: Record<string, string>;
102
+ }
103
+
104
+ /** What `kedge generate` remembers of an entity (p0.7 §4.3). */
105
+ export interface EntityLock {
106
+ /** How many entity sources were composed for it: its table, then each set of added fields. */
107
+ generation: number;
108
+ order: EntityDefinition["order"];
109
+ read: string;
110
+ /** Every physical column the table has, generated ones included. */
111
+ columns: string[];
112
+ fields: Record<
113
+ string,
114
+ {
115
+ type: string;
116
+ required: boolean;
117
+ class: string;
118
+ max: number | null;
119
+ options: string[];
120
+ scope: string | null;
121
+ sort: boolean;
122
+ }
123
+ >;
124
+ }
125
+
126
+ const fieldLock = (f: EntityDefinition["fields"][string]) => ({
127
+ type: f.type,
128
+ required: Boolean(f.required),
129
+ class: f.type === "staff" ? "pii" : (f.class ?? "public"),
130
+ max: f.max ?? null,
131
+ options: [...(f.options ?? [])],
132
+ scope: f.scope ?? null,
133
+ sort: Boolean(f.sort),
134
+ });
135
+
136
+ /**
137
+ * An entity's evolution (p0.7 §4.3): the refusals, and the SQL a changed
138
+ * declaration composes (a new table, or added nullable columns and indexes).
139
+ */
140
+ function entityChange(
141
+ plugin: string,
142
+ name: string,
143
+ def: EntityDefinition,
144
+ was: EntityLock | undefined,
145
+ ): { problems: string[]; sql: string[]; lock: EntityLock } {
146
+ const problems: string[] = [];
147
+ const at = `${plugin}: entity "${name}"`;
148
+ const fields = Object.fromEntries(Object.entries(def.fields).map(([n, f]) => [n, fieldLock(f)]));
149
+ const columns = [...ENTITY_COLUMNS, ...Object.entries(def.fields).flatMap(([n, f]) => columnsOf(n, f))];
150
+ const read = permissionsOf(plugin, name, def).read;
151
+ if (!was) {
152
+ return {
153
+ problems,
154
+ sql: createEntitySql(plugin, name, def),
155
+ lock: { generation: 1, order: def.order, read, columns, fields },
156
+ };
157
+ }
158
+ if (def.order !== was.order) problems.push(`${at}: order changed from ${was.order}; rows already attach as it said`);
159
+ if (read !== was.read)
160
+ problems.push(
161
+ `${at}: its read permission changed; an older Worker would still read under the old one (P0.8's cutover)`,
162
+ );
163
+ const sql: string[] = [];
164
+ for (const [n, f] of Object.entries(was.fields)) {
165
+ const now = fields[n];
166
+ const f2 = `${at} field "${n}"`;
167
+ if (!now) {
168
+ problems.push(`${f2} was removed; its data stays: keep it declared, or write a reviewed migration`);
169
+ continue;
170
+ }
171
+ if (now.type !== f.type) problems.push(`${f2} changed type from ${f.type} to ${now.type}`);
172
+ if (now.required !== f.required)
173
+ problems.push(`${f2} changed required; stored rows or older readers depend on it (P0.8's cutover)`);
174
+ if (now.class !== f.class)
175
+ problems.push(
176
+ `${f2} changed class from ${f.class}; an older Worker would still project it (P0.8's cutover; or a new field)`,
177
+ );
178
+ if (now.scope !== f.scope) problems.push(`${f2} changed its scope mapping; older readers would still scope by it`);
179
+ // An absent max is its type's default, so declaring one below it narrows too (codex code r1).
180
+ const maxBefore = effectiveMax(f);
181
+ const maxNow = effectiveMax(now);
182
+ if (maxBefore !== null && maxNow !== null && maxNow < maxBefore) problems.push(`${f2}: max can only rise`);
183
+ for (const o of f.options)
184
+ if (!now.options.includes(o)) problems.push(`${f2}: option "${o}" was removed; options may only be added`);
185
+ if (now.sort && !f.sort) sql.push(sortIndexSql(plugin, name, n));
186
+ }
187
+ for (const [n, f] of Object.entries(def.fields)) {
188
+ if (Object.hasOwn(was.fields, n)) continue;
189
+ if (f.required)
190
+ problems.push(`${at} field "${n}" is new and required; stored rows have no value (make it optional)`);
191
+ sql.push(...addFieldSql(plugin, name, n, f));
192
+ if (f.sort) sql.push(sortIndexSql(plugin, name, n));
193
+ }
194
+ const union = new Set([...was.columns, ...columns]);
195
+ if (union.size > ENTITY_LIMITS.COLUMNS_MAX)
196
+ problems.push(
197
+ `${at}: ${union.size} physical columns with those already in the table, over ${ENTITY_LIMITS.COLUMNS_MAX}`,
198
+ );
199
+ return {
200
+ problems,
201
+ sql,
202
+ lock: { generation: was.generation + (sql.length ? 1 : 0), order: def.order, read, columns: [...union], fields },
203
+ };
204
+ }
205
+
206
+ export class ComposeError extends Error {
207
+ constructor(message: string) {
208
+ super(message);
209
+ this.name = "ComposeError";
210
+ }
211
+ }
212
+
213
+ const sha = (s: string) => createHash("sha256").update(s).digest("hex");
214
+ const sqlFiles = (dir: string) =>
215
+ existsSync(dir)
216
+ ? readdirSync(dir)
217
+ .filter((f) => f.endsWith(".sql"))
218
+ .sort((a, b) => Number.parseInt(a, 10) - Number.parseInt(b, 10) || byCodePoint(a, b))
219
+ : [];
220
+ /** Statements re-joined with the separator every migration runner splits on. */
221
+ const normalize = (sql: string) =>
222
+ statementTexts(sql)
223
+ .map((s) => `${s};`)
224
+ .join(BREAK);
225
+
226
+ interface Source {
227
+ source: string;
228
+ tag: string;
229
+ sql: string;
230
+ }
231
+
232
+ export interface ComposeInput {
233
+ /** @kedge/db's migrations, applied in their own order. */
234
+ coreDir: string;
235
+ /** The merchant's plugins, in kedge.config.ts order. */
236
+ // biome-ignore lint/suspicious/noExplicitAny: each plugin brings its own options type
237
+ plugins: readonly PluginInstance<any>[];
238
+ /** The merchant's own migrations (`local/`), linted under the local_ prefix. */
239
+ localDir: string;
240
+ /** Where wrangler applies from. */
241
+ outDir: string;
242
+ lockPath: string;
243
+ now: number;
244
+ newInstallationId?: () => string;
245
+ /** Compute what would be written, and write nothing (`kedge generate --check`). */
246
+ dryRun?: boolean;
247
+ /** kedge.config.ts's `timezone`; the store stays on UTC without one (p0.4 §4). */
248
+ timezone?: string;
249
+ /** Compiles a zone with this toolchain's IANA data; only called the first time. */
250
+ compileZone?: (zone: string) => TzTable;
251
+ /** Where the compiled table goes (src/kedge.tz.ts). */
252
+ tzPath?: string;
253
+ /** kedge.config.ts's own ports; each plugin's come with its definition. */
254
+ ports?: readonly PortDefinition[];
255
+ }
256
+
257
+ export interface ComposeResult {
258
+ written: string[];
259
+ lock: Lock;
260
+ /**
261
+ * The lock would differ from the one on disk (code r12): a new contract
262
+ * version or registration changes it without any migration, and `--check`
263
+ * must refuse that too, or a version CI let through unlocked could later
264
+ * change under the records admitted with it.
265
+ */
266
+ lockChanged: boolean;
267
+ /** Things the merchant should know that aren't failures (a newer toolchain's tz data). */
268
+ notes: string[];
269
+ }
270
+
271
+ /** Composes, lints and writes the new migration files and the lock. Throws ComposeError and writes nothing on any violation. */
272
+ export function compose(input: ComposeInput): ComposeResult {
273
+ const lock: Lock = existsSync(input.lockPath)
274
+ ? (JSON.parse(readFileSync(input.lockPath, "utf8")) as Lock)
275
+ : { version: 1, entries: [], plugins: {} };
276
+ const problems: string[] = [];
277
+ const notes: string[] = [];
278
+ let tzWrite: { path: string; text: string } | null = null;
279
+
280
+ // 1. What the config wants, in order.
281
+ const desired: Source[] = [];
282
+ for (const f of sqlFiles(input.coreDir)) {
283
+ desired.push({
284
+ source: `core:${f}`,
285
+ tag: f.replace(/^\d+_/, "").replace(/\.sql$/, ""),
286
+ sql: readFileSync(join(input.coreDir, f), "utf8"),
287
+ });
288
+ }
289
+ // The store's zone (p0.4 §4), after core (store_time exists) and before
290
+ // plugins. Fixed at install: a different zone is refused, and a newer
291
+ // toolchain's rules are reported, never adopted (P2.3 adopts them).
292
+ let timezone = lock.timezone;
293
+ if (input.timezone === undefined && !timezone && input.tzPath && !existsSync(input.tzPath)) {
294
+ // No zone configured: the Worker still gets its table, UTC at the version the database is
295
+ // seeded with, so `createKedge(config, { tz })` is the same line in every store.
296
+ tzWrite = { path: input.tzPath, text: tzModule(UTC_TZ) };
297
+ }
298
+ if (input.timezone !== undefined) {
299
+ if (timezone && timezone.zone !== input.timezone) {
300
+ problems.push(
301
+ `kedge.config.ts says timezone "${input.timezone}", but this store's zone is fixed at "${timezone.zone}"`,
302
+ );
303
+ } else if (!timezone) {
304
+ if (!input.compileZone || !input.tzPath) problems.push("a timezone needs the zone compiler and src/kedge.tz.ts");
305
+ else {
306
+ const tz = input.compileZone(input.timezone);
307
+ timezone = { zone: tz.zone, version: tz.version };
308
+ desired.push({
309
+ source: `core:tz:${tz.zone}:${tz.version}`,
310
+ tag: `tz_${tz.zone.replace(/[^A-Za-z0-9]+/g, "_").toLowerCase()}`,
311
+ sql: tzMigrationSql(tz),
312
+ });
313
+ tzWrite = { path: input.tzPath, text: tzModule(tz) };
314
+ }
315
+ } else {
316
+ if (input.tzPath && !existsSync(input.tzPath))
317
+ problems.push(
318
+ `${input.tzPath} is missing; restore it from version control (it pins ${timezone.zone} at ${timezone.version})`,
319
+ );
320
+ const current = process.versions.tz;
321
+ if (current && current !== timezone.version)
322
+ notes.push(
323
+ `this toolchain's tz data is ${current}; the store stays pinned to ${timezone.version} until a rules migration (P2.3)`,
324
+ );
325
+ }
326
+ }
327
+ const plugins: Lock["plugins"] = { ...lock.plugins };
328
+ // Names in the lock count too: a plugin removed from config keeps its tables (and its name).
329
+ for (const instance of input.plugins) {
330
+ const clash = overlappingName(instance.definition.name, [
331
+ ...Object.keys(lock.plugins),
332
+ ...input.plugins.map((i) => i.definition.name),
333
+ ]);
334
+ if (clash) problems.push(clash);
335
+ }
336
+ for (const instance of input.plugins) {
337
+ const def = instance.definition;
338
+ for (const issue of lintPlugin(def, instance.options))
339
+ problems.push(`${def.name} [${issue.rule}] ${issue.message} (${issue.at})`);
340
+ for (const m of def.migrations ?? []) {
341
+ desired.push({
342
+ source: `plugin:${def.name}:${m.id}`,
343
+ tag: `plg_${def.name}_${m.id.replace(/^\d+_/, "")}`,
344
+ sql: normalize(m.sql),
345
+ });
346
+ }
347
+ const known = plugins[def.name];
348
+ const installationId = known?.installationId ?? (input.newInstallationId ?? randomUUID)();
349
+ desired.push({
350
+ source: `plugin:${def.name}:install`,
351
+ tag: `plg_${def.name}_install`,
352
+ sql: installSql(def, installationId, input.now),
353
+ });
354
+ // Core-owned SQL is compared with what is installed now, not with history:
355
+ // a declaration removed, or reverted to an earlier version, is a new
356
+ // generation that drops every object the current one installed.
357
+ // Guards cover every fact table the plugin ever declared: removing a
358
+ // declaration doesn't remove the facts already written to it. Dropping
359
+ // such a table is a retirement concern (plugin-lifecycle.md), and the
360
+ // regenerated guard then fails its migration loudly.
361
+ const historical = known?.factTables ?? [];
362
+ const tables = factTables(def, historical);
363
+ const subscribed = Boolean(known?.subscribed) || Object.keys(def.subscribers ?? {}).length > 0;
364
+ // A registration it locked must still be declared, under its id or an
365
+ // alias, or be retired on purpose (code r10): a rename without its alias
366
+ // would strand every delivery and timer stored under the old id.
367
+ const declared = registrationKinds(def);
368
+ for (const [id, kind] of Object.entries(known?.registrations ?? {})) {
369
+ const now = Object.hasOwn(declared, id) ? declared[id] : undefined;
370
+ if (!now && !(def.retired ?? []).includes(id))
371
+ throw new ComposeError(
372
+ `${def.name}: registration ${id} is in kedge.lock.json but no longer declared: keep it, alias its new name to it, or list it in \`retired\``,
373
+ );
374
+ if (now && now !== kind)
375
+ throw new ComposeError(
376
+ `${def.name}: registration ${id} was a ${kind} and is now a ${now}; its stored deliveries would find no handler: declare the ${now} under a new id and retire this one`,
377
+ );
378
+ }
379
+ const registrations = Object.fromEntries(
380
+ Object.entries({ ...declared, ...(known?.registrations ?? {}) }).sort(([a], [b]) => (a < b ? -1 : 1)),
381
+ );
382
+ // P0.7 (§4.3): each entity's table, then its added fields; a removed
383
+ // entity is refused (its rows stay, P2.12 retires it).
384
+ const entityLocks: Record<string, EntityLock> = {};
385
+ for (const name of Object.keys(known?.entities ?? {}))
386
+ if (!Object.hasOwn(def.entities ?? {}, name))
387
+ problems.push(
388
+ `${def.name}: entity "${name}" is in kedge.lock.json but no longer declared; its rows stay (retiring it is P2.12's)`,
389
+ );
390
+ for (const [name, e] of Object.entries(def.entities ?? {})) {
391
+ const was = known?.entities?.[name];
392
+ const change = entityChange(def.name, name, e, was);
393
+ problems.push(...change.problems);
394
+ entityLocks[name] = change.lock;
395
+ if (change.sql.length)
396
+ desired.push({
397
+ source: `plugin:${def.name}:entity:${name}:${change.lock.generation}`,
398
+ tag: `plg_${def.name}_${name}`,
399
+ sql: change.sql.join(BREAK),
400
+ });
401
+ }
402
+ // P0.7 (§3.1, §3.3): a locked track keeps what its rows depend on, and
403
+ // the plugin's tracks fingerprint is set with its generation.
404
+ for (const problem of trackLockProblems(def.name, known?.tracks ?? {}, def as never)) problems.push(problem);
405
+ for (const [name, t] of Object.entries(def.tracks ?? {}))
406
+ for (const state of unreachableStates(t as never))
407
+ notes.push(`${def.name}: track "${name}" can't reach state "${state}" (kept for its rows, or a typo?)`);
408
+ const tracksInput = tracksShaInput(def as never);
409
+ const tracksSql =
410
+ tracksInput !== null || Object.keys(known?.tracks ?? {}).length
411
+ ? [
412
+ markedMigrationSql(`migration:tracks:${def.name}`, [
413
+ `UPDATE plugin_state SET tracks_sha = ${tracksInput === null ? "NULL" : `'${sha(tracksInput)}'`} WHERE plugin = '${def.name}';`,
414
+ ]),
415
+ ]
416
+ : [];
417
+ const entitySql = Object.entries(def.entities ?? {}).flatMap(([name, e]) =>
418
+ entityTriggersSql(def.name, name, e, sha(entityDeclInput(def as never, name, e))),
419
+ );
420
+ for (const st of entitySql)
421
+ if (Buffer.byteLength(st) > ENTITY_LIMITS.GENERATED_SQL_MAX)
422
+ problems.push(
423
+ `${def.name}: a generated statement is ${Buffer.byteLength(st)} bytes, over D1's limit (${st.slice(0, 60)}…)`,
424
+ );
425
+ const generated = [
426
+ ...factGuardSql(def, historical),
427
+ ...acquireSql(def),
428
+ ...subscriptionSql(def, Boolean(known?.subscribed)),
429
+ ...tracksSql,
430
+ ...entitySql,
431
+ ].join(BREAK);
432
+ // The hash covers the plugin's migration ids too: a new migration may
433
+ // rebuild a fact table, and DROP TABLE takes the guards on it with it, so
434
+ // the guards are recreated after every new plugin migration.
435
+ const generatedSha = sha(`${(def.migrations ?? []).map((m) => m.id).join(",")}\n${generated}`);
436
+ const objects = [
437
+ ...generatedObjectNames(def, historical),
438
+ ...Object.entries(def.entities ?? {}).flatMap(([name, e]) => entityTriggerNames(def.name, name, e)),
439
+ ];
440
+ let generation = known?.generation ?? 0;
441
+ if (generatedSha !== (known?.generatedSha256 ?? sha("\n"))) {
442
+ generation++;
443
+ const drops = (known?.generatedObjects ?? []).map((n) => `DROP TRIGGER IF EXISTS ${n};`);
444
+ desired.push({
445
+ source: `plugin:${def.name}:generated:${generation}`,
446
+ tag: `plg_${def.name}_generated`,
447
+ sql: [...drops, ...(generated ? [generated] : [])].join(BREAK),
448
+ });
449
+ }
450
+ plugins[def.name] = {
451
+ installationId,
452
+ generatedSha256: generatedSha,
453
+ generatedObjects: objects,
454
+ generation,
455
+ factTables: tables,
456
+ ...(subscribed ? { subscribed } : {}),
457
+ ...(Object.keys(registrations).length ? { registrations } : {}),
458
+ ...(Object.keys(entityLocks).length ? { entities: entityLocks } : {}),
459
+ ...(def.tracks && Object.keys(def.tracks).length
460
+ ? {
461
+ tracks: Object.fromEntries(Object.entries(def.tracks).map(([name, t]) => [name, trackLock(t as never)])),
462
+ }
463
+ : {}),
464
+ };
465
+ }
466
+ // P0.7 (§3.3): the store-wide checkout-track set. It changes exactly when a
467
+ // composed migration says so, which fences older Workers' checkouts.
468
+ const checkoutInput = checkoutTracksShaInput(
469
+ input.plugins.map((i) => ({ name: i.definition.name, tracks: i.definition.tracks as never })),
470
+ );
471
+ const checkoutSha = checkoutInput === null ? null : sha(checkoutInput);
472
+ let checkoutTracks = lock.checkoutTracks ?? { sha: null, generation: 0 };
473
+ if (checkoutSha !== checkoutTracks.sha) {
474
+ checkoutTracks = { sha: checkoutSha, generation: checkoutTracks.generation + 1 };
475
+ desired.push({
476
+ source: `core:checkout-tracks:${checkoutTracks.generation}`,
477
+ tag: "checkout_tracks",
478
+ sql: markedMigrationSql("migration:checkout-tracks", [
479
+ `UPDATE checkout_tracks SET sha = ${checkoutSha === null ? "NULL" : `'${checkoutSha}'`} WHERE id = 1;`,
480
+ ]),
481
+ });
482
+ }
483
+ // P0.4's inventory_level rebuild (0024) drops and recreates the table's
484
+ // dependents; it can only know core's. The linter already refuses plugin
485
+ // and local SQL that names a core table or keys to one (K1 rule 2), so this
486
+ // is the assertion that none slipped through, before a rebuild could break.
487
+ for (const d of desired) {
488
+ if (!d.source.startsWith("core:") && /\binventory_level\b/i.test(d.sql))
489
+ problems.push(
490
+ `${d.source} names inventory_level, which core rebuilds (p0.4 §3.2); read it through kedge_v1_* views`,
491
+ );
492
+ }
493
+ for (const f of sqlFiles(input.localDir)) {
494
+ const text = readFileSync(join(input.localDir, f), "utf8");
495
+ if (/\binventory_level\b/i.test(text))
496
+ problems.push(`local/${f} names inventory_level, which core rebuilds (p0.4 §3.2)`);
497
+ for (const issue of lintSql({ kind: "local" }, text, { context: "migration" }))
498
+ problems.push(`local/${f} [${issue.rule}] ${issue.message} (${issue.at})`);
499
+ desired.push({ source: `local:${f}`, tag: `local_${f.replace(/^\d+_/, "").replace(/\.sql$/, "")}`, sql: text });
500
+ }
501
+
502
+ // What SQLite itself says each plugin statement touches, against the real schema.
503
+ if (problems.length === 0) {
504
+ // The zone's rows are data, not schema a plugin compiles against.
505
+ const coreSql = desired
506
+ .filter((d) => d.source.startsWith("core:") && !d.source.startsWith("core:tz:"))
507
+ .map((d) => d.sql);
508
+ const verified = verifyWithSqlite({
509
+ coreSql,
510
+ plugins: input.plugins.map((i) => ({
511
+ definition: i.definition,
512
+ runtime: (() => {
513
+ try {
514
+ return collect(i.definition, i.options).filter((r) => r.context !== "acquire");
515
+ } catch {
516
+ return []; // lintPlugin already reported a builder that throws on sample input
517
+ }
518
+ })(),
519
+ historicalFacts: lock.plugins[i.definition.name]?.factTables ?? [],
520
+ })),
521
+ });
522
+ for (const v of verified) problems.push(`${v.plugin} [${v.rule}] ${v.message} (${v.at})`);
523
+ }
524
+
525
+ // 2. Every applied entry must still be wanted, unchanged.
526
+ const bySource = new Map(desired.map((d) => [d.source, d]));
527
+ // Two sources under one identity would both be written, and the lock would then reject its own inputs.
528
+ if (bySource.size !== desired.length) {
529
+ const seen = new Set<string>();
530
+ for (const d of desired) {
531
+ if (seen.has(d.source)) problems.push(`${d.source} appears twice; every migration needs its own id`);
532
+ seen.add(d.source);
533
+ }
534
+ }
535
+ for (const e of lock.entries) {
536
+ // Generated, install and timezone entries are history: their sources are never
537
+ // recomputed (a later generation supersedes them), but their files are
538
+ // verified like any other applied migration.
539
+ // The pinned zone's migration is history the same way: compiled once, never again.
540
+ if (
541
+ !e.source.includes(":generated:") &&
542
+ // An entity's table and added fields are history: a later declaration supersedes them (p0.7 §4.3).
543
+ !e.source.includes(":entity:") &&
544
+ !e.source.endsWith(":install") &&
545
+ !e.source.startsWith("core:tz:") &&
546
+ !e.source.startsWith("core:checkout-tracks:")
547
+ ) {
548
+ const d = bySource.get(e.source);
549
+ if (!d) problems.push(`${e.file}: its source ${e.source} is gone; applied migrations are never removed`);
550
+ else if (sha(d.sql) !== e.sha256)
551
+ problems.push(`${e.file}: ${e.source} changed after it was composed; add a new migration instead`);
552
+ }
553
+ const path = join(input.outDir, e.file);
554
+ if (!existsSync(path)) problems.push(`${e.file} is in the lock but missing from ${input.outDir}`);
555
+ else if (sha(readFileSync(path, "utf8")) !== e.sha256)
556
+ problems.push(`${e.file} was edited; applied migrations are immutable`);
557
+ }
558
+ const lockedFiles = new Set(lock.entries.map((e) => e.file));
559
+ for (const f of sqlFiles(input.outDir)) {
560
+ if (!lockedFiles.has(f))
561
+ problems.push(`${f} in ${input.outDir} isn't in kedge.lock.json; migrations there are written by kedge generate`);
562
+ }
563
+ const contracts = lockContracts(lock.contracts ?? {}, [
564
+ ...(input.ports ?? []),
565
+ ...input.plugins.flatMap((i) => i.definition.ports ?? []),
566
+ ]);
567
+ problems.push(...contracts.problems);
568
+ if (problems.length > 0) throw new ComposeError(problems.join("\n"));
569
+
570
+ // 3. New sources, numbered after everything already composed.
571
+ const done = new Set(lock.entries.map((e) => e.source));
572
+ let next = lock.entries.reduce((max, e) => Math.max(max, Number.parseInt(e.file, 10) + 1), 0);
573
+ const written: string[] = [];
574
+ const entries = [...lock.entries];
575
+ for (const d of desired) {
576
+ if (done.has(d.source)) continue;
577
+ const file = `${String(next++).padStart(4, "0")}_${d.tag}.sql`;
578
+ entries.push({ file, source: d.source, sha256: sha(d.sql) });
579
+ written.push(file);
580
+ if (input.dryRun) continue;
581
+ if (!existsSync(input.outDir)) mkdirSync(input.outDir, { recursive: true });
582
+ writeFileSync(join(input.outDir, file), d.sql);
583
+ }
584
+ const out: Lock = {
585
+ version: 1,
586
+ entries,
587
+ plugins,
588
+ ...(timezone ? { timezone } : {}),
589
+ ...(Object.keys(contracts.locked).length ? { contracts: contracts.locked } : {}),
590
+ ...(checkoutTracks.generation > 0 ? { checkoutTracks } : {}),
591
+ };
592
+ if (tzWrite) written.push(tzWrite.path);
593
+ if (!input.dryRun) {
594
+ if (tzWrite) {
595
+ mkdirSync(dirname(tzWrite.path), { recursive: true });
596
+ writeFileSync(tzWrite.path, tzWrite.text);
597
+ }
598
+ writeFileSync(input.lockPath, `${JSON.stringify(out, null, 2)}\n`);
599
+ }
600
+ const before = existsSync(input.lockPath) ? JSON.stringify(lock) : null;
601
+ return { written, lock: out, notes, lockChanged: before !== JSON.stringify(out) };
602
+ }
603
+
604
+ /**
605
+ * The contract lock (p0.6a §4.2): every declared operation version's
606
+ * contract hashed beside the migrations. A locked version whose contract
607
+ * changed is refused (a change is a new version), and so is a locked version
608
+ * the config no longer declares: a record admitted under it would meet no
609
+ * code that knows how to recover it.
610
+ */
611
+ function lockContracts(
612
+ locked: Readonly<Record<string, string>>,
613
+ ports: readonly PortDefinition[],
614
+ ): { locked: Record<string, string>; problems: string[] } {
615
+ const problems: string[] = [];
616
+ const out: Record<string, string> = { ...locked };
617
+ const declared = new Set<string>();
618
+ for (const port of ports) {
619
+ for (const [name, op] of Object.entries(port.operations)) {
620
+ if (op.kind !== "effect") continue;
621
+ for (const v of [op, ...(op.previous ?? [])] as EffectOperation[]) {
622
+ const id = `${port.name}.${name}@${v.version}`;
623
+ const hash = sha(operationContract(port, v));
624
+ declared.add(id);
625
+ if (locked[id] !== undefined && locked[id] !== hash)
626
+ problems.push(
627
+ `${id}'s contract changed since it was locked; a change is a new version (${name} version ${op.version + 1})`,
628
+ );
629
+ else out[id] = hash;
630
+ }
631
+ }
632
+ }
633
+ for (const id of Object.keys(locked))
634
+ if (!declared.has(id))
635
+ problems.push(`${id} is locked but no longer declared; a port keeps every version a record may need`);
636
+ return { locked: out, problems };
637
+ }