@kedge/cli 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +396 -0
- package/package.json +40 -3
- package/src/cf.ts +451 -0
- package/src/check.ts +44 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +757 -0
- package/src/compose.ts +637 -0
- package/src/deploy.ts +1610 -0
- package/src/doctor.ts +636 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/main.ts +378 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +355 -0
- package/src/order.ts +7 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1011 -0
- package/src/schema.ts +72 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/tz.ts +79 -0
- package/src/units.ts +185 -0
- package/src/upgrade.ts +235 -0
- package/src/worker-config.ts +81 -0
- package/README.md +0 -3
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// kedge-manifest.json (docs/proposals/p0.8-cli-agent-kit.md §2): what a release
|
|
2
|
+
// shipped, by package and file, written by scripts/pack-kedge.mjs into
|
|
3
|
+
// @kedge/cli. Doctor compares the store's installed packages, core migrations
|
|
4
|
+
// and ejected units with it. Hashing matches the pack script's, package.json
|
|
5
|
+
// included: its keys are sorted first, because pnpm pack orders the
|
|
6
|
+
// dependencies it rewrites differently from one run to the next.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
export interface Manifest {
|
|
13
|
+
version: string;
|
|
14
|
+
upgradesFrom: string | null;
|
|
15
|
+
doStateFormat: number;
|
|
16
|
+
migrations: Record<string, { sha256: string; mode: "n1" | "cutover" }>;
|
|
17
|
+
units: Record<string, { package: string; path: string; sha256: string }>;
|
|
18
|
+
packages: Record<string, Record<string, string>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sha256 = (buf: Buffer | string) => createHash("sha256").update(buf).digest("hex");
|
|
22
|
+
const sorted = (v: unknown): unknown =>
|
|
23
|
+
Array.isArray(v)
|
|
24
|
+
? v.map(sorted)
|
|
25
|
+
: v && typeof v === "object"
|
|
26
|
+
? Object.fromEntries(
|
|
27
|
+
Object.keys(v)
|
|
28
|
+
.sort()
|
|
29
|
+
.map((k) => [k, sorted((v as Record<string, unknown>)[k])]),
|
|
30
|
+
)
|
|
31
|
+
: v;
|
|
32
|
+
|
|
33
|
+
export function fileHash(path: string, buf: Buffer): string {
|
|
34
|
+
return path === "package.json" || path.endsWith("/package.json")
|
|
35
|
+
? sha256(JSON.stringify(sorted(JSON.parse(buf.toString("utf8")))))
|
|
36
|
+
: sha256(buf);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** This CLI's own manifest, or null where there is none (the monorepo's workspace links). */
|
|
40
|
+
export function ownManifest(): Manifest | null {
|
|
41
|
+
const file = join(dirname(fileURLToPath(import.meta.url)), "..", "kedge-manifest.json");
|
|
42
|
+
return existsSync(file) ? (JSON.parse(readFileSync(file, "utf8")) as Manifest) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function filesUnder(dir: string): string[] {
|
|
46
|
+
const out: string[] = [];
|
|
47
|
+
const walk = (d: string) => {
|
|
48
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
49
|
+
const p = join(d, e.name);
|
|
50
|
+
if (e.isDirectory()) {
|
|
51
|
+
if (e.name !== "node_modules") walk(p);
|
|
52
|
+
} else if (e.isFile()) out.push(relative(dir, p));
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(dir);
|
|
56
|
+
return out.sort();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface InstalledDrift {
|
|
60
|
+
package: string;
|
|
61
|
+
changed: string[];
|
|
62
|
+
added: string[];
|
|
63
|
+
missing: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Where Node would find `name` from `fromDir`: each ancestor's node_modules, as the resolution algorithm walks. */
|
|
67
|
+
function locate(fromDir: string, name: string): string | null {
|
|
68
|
+
let d = fromDir;
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (basename(d) !== "node_modules") {
|
|
71
|
+
const p = join(d, "node_modules", ...name.split("/"));
|
|
72
|
+
if (existsSync(p)) return realpathSync(p);
|
|
73
|
+
}
|
|
74
|
+
const up = dirname(d);
|
|
75
|
+
if (up === d) return null;
|
|
76
|
+
d = up;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every `@kedge/*` package the store loads, found as Node finds it: the
|
|
82
|
+
* store's own dependencies, then theirs. pnpm keeps a transitive package out
|
|
83
|
+
* of the store's node_modules (core, db and server sit beside runtime-cf in
|
|
84
|
+
* its virtual store), and an edit there would run just the same.
|
|
85
|
+
*/
|
|
86
|
+
export function installedPackages(cwd: string): Map<string, string> {
|
|
87
|
+
const found = new Map<string, string>();
|
|
88
|
+
const queue: [string, string][] = [];
|
|
89
|
+
const pj = join(cwd, "package.json");
|
|
90
|
+
const own = existsSync(pj) ? (JSON.parse(readFileSync(pj, "utf8")) as Record<string, Record<string, string>>) : {};
|
|
91
|
+
for (const name of Object.keys({ ...own.dependencies, ...own.devDependencies }))
|
|
92
|
+
if (name.startsWith("@kedge/")) queue.push([cwd, name]);
|
|
93
|
+
while (queue.length) {
|
|
94
|
+
const [from, name] = queue.pop() as [string, string];
|
|
95
|
+
if (found.has(name)) continue;
|
|
96
|
+
const dir = locate(from, name);
|
|
97
|
+
if (!dir) continue;
|
|
98
|
+
found.set(name, dir);
|
|
99
|
+
const deps = (
|
|
100
|
+
JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { dependencies?: Record<string, string> }
|
|
101
|
+
).dependencies;
|
|
102
|
+
for (const d of Object.keys(deps ?? {})) if (d.startsWith("@kedge/")) queue.push([dir, d]);
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Every installed copy of every `@kedge/*` package the store can load, by directory: two dependents
|
|
109
|
+
* can resolve the same name to different copies, and checking only the first would leave the one the
|
|
110
|
+
* runtime loads unchecked (codex code r13).
|
|
111
|
+
*/
|
|
112
|
+
export function installedCopies(cwd: string): { name: string; dir: string }[] {
|
|
113
|
+
const seen = new Map<string, string>();
|
|
114
|
+
const queue: [string, string][] = [];
|
|
115
|
+
const pj = join(cwd, "package.json");
|
|
116
|
+
const own = existsSync(pj) ? (JSON.parse(readFileSync(pj, "utf8")) as Record<string, Record<string, string>>) : {};
|
|
117
|
+
for (const name of Object.keys({ ...own.dependencies, ...own.devDependencies }))
|
|
118
|
+
if (name.startsWith("@kedge/")) queue.push([cwd, name]);
|
|
119
|
+
while (queue.length) {
|
|
120
|
+
const [from, name] = queue.pop() as [string, string];
|
|
121
|
+
const dir = locate(from, name);
|
|
122
|
+
if (!dir || seen.has(dir)) continue;
|
|
123
|
+
seen.set(dir, name);
|
|
124
|
+
const deps = (
|
|
125
|
+
JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { dependencies?: Record<string, string> }
|
|
126
|
+
).dependencies;
|
|
127
|
+
for (const d of Object.keys(deps ?? {})) if (d.startsWith("@kedge/")) queue.push([dir, d]);
|
|
128
|
+
}
|
|
129
|
+
return [...seen].map(([dir, name]) => ({ name, dir }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Each installed copy of each `@kedge/*` package, its files against what the release shipped. */
|
|
133
|
+
export function installedDrift(cwd: string, manifest: Manifest): InstalledDrift[] {
|
|
134
|
+
const out: InstalledDrift[] = [];
|
|
135
|
+
const copies = installedCopies(cwd);
|
|
136
|
+
const count = (n: string) => copies.filter((c) => c.name === n).length;
|
|
137
|
+
for (const { name: pkg, dir } of copies) {
|
|
138
|
+
const name = count(pkg) > 1 ? `${pkg} (${relative(cwd, dir)})` : pkg;
|
|
139
|
+
const files = manifest.packages[pkg];
|
|
140
|
+
if (!files) {
|
|
141
|
+
out.push({
|
|
142
|
+
package: name,
|
|
143
|
+
changed: [],
|
|
144
|
+
added: [`(the whole package: Kedge ${manifest.version} didn't ship it)`],
|
|
145
|
+
missing: [],
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const have = filesUnder(dir);
|
|
150
|
+
const d: InstalledDrift = { package: name, changed: [], added: [], missing: [] };
|
|
151
|
+
for (const f of have) {
|
|
152
|
+
const want = files[f];
|
|
153
|
+
if (want === undefined) {
|
|
154
|
+
// The CLI carries the manifest that lists every other file.
|
|
155
|
+
if (!(pkg === "@kedge/cli" && f === "kedge-manifest.json")) d.added.push(f);
|
|
156
|
+
} else if (fileHash(f, readFileSync(join(dir, f))) !== want) d.changed.push(f);
|
|
157
|
+
}
|
|
158
|
+
const haveSet = new Set(have);
|
|
159
|
+
for (const f of Object.keys(files)) if (!haveSet.has(f)) d.missing.push(f);
|
|
160
|
+
if (d.changed.length || d.added.length || d.missing.length) out.push(d);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
package/src/n1.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Which pending migrations the previous Worker survives (docs/proposals/p0.8-cli-agent-kit.md §6.2).
|
|
2
|
+
// Rollback doesn't change "resources connected to your Worker", so every file
|
|
3
|
+
// an ordinary deploy applies must leave the running code working: by proof
|
|
4
|
+
// (core's release suites, a plugin generation's design) or by a statement
|
|
5
|
+
// someone wrote. Shape alone proves little: adding a column breaks a
|
|
6
|
+
// positional INSERT, and an expression index can throw on an old write.
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { PluginInstance } from "@kedge/core";
|
|
10
|
+
import { statementTexts } from "@kedge/core";
|
|
11
|
+
import { CUTOVER_MIGRATIONS } from "@kedge/db";
|
|
12
|
+
import type { Lock, LockEntry } from "./compose";
|
|
13
|
+
import type { Manifest } from "./manifest";
|
|
14
|
+
import { schemaOf } from "./schema";
|
|
15
|
+
|
|
16
|
+
export type Mode = "n1" | "cutover";
|
|
17
|
+
|
|
18
|
+
export interface Classified {
|
|
19
|
+
file: string;
|
|
20
|
+
source: string;
|
|
21
|
+
mode: Mode;
|
|
22
|
+
/** Why it's N−1: proof, design, shape, or the statement's text. */
|
|
23
|
+
because: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class NotN1 extends Error {
|
|
27
|
+
constructor(readonly problems: string[]) {
|
|
28
|
+
super(`pending migrations the running Worker may not survive:\n ${problems.join("\n ")}`);
|
|
29
|
+
this.name = "NotN1";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const strip = (s: string) =>
|
|
34
|
+
s
|
|
35
|
+
.replace(/--[^\n]*/g, " ")
|
|
36
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
37
|
+
.replace(/\s+/g, " ")
|
|
38
|
+
.trim();
|
|
39
|
+
/** A name as SQLite resolves it: unquoted, and case-blind, so `local_Cache` is `local_cache` (codex code r19). */
|
|
40
|
+
const unquote = (n: string) => n.replace(/^["`[]|["`\]]$/g, "").toLowerCase();
|
|
41
|
+
const NAME = String.raw`(?:"[^"]+"|\x60[^\x60]+\x60|\[[^\]]+\]|[A-Za-z_][\w$]*)`;
|
|
42
|
+
|
|
43
|
+
/** A plain column in an index: a name, maybe a direction or collation. No expression. */
|
|
44
|
+
const plainColumn = (c: string) =>
|
|
45
|
+
new RegExp(String.raw`^${NAME}(\s+COLLATE\s+\w+)?(\s+(ASC|DESC))?$`, "i").test(c.trim());
|
|
46
|
+
|
|
47
|
+
/** The object one statement creates, by kind and name, if it creates one. */
|
|
48
|
+
function createdBy(t: string): string | null {
|
|
49
|
+
const m = new RegExp(
|
|
50
|
+
String.raw`^CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?(?:UNIQUE\s+)?(TABLE|INDEX|TRIGGER|VIEW)\s+(?:IF\s+NOT\s+EXISTS\s+)?(${NAME})`,
|
|
51
|
+
"i",
|
|
52
|
+
).exec(strip(t));
|
|
53
|
+
return m ? `${(m[1] as string).toUpperCase()}:${unquote(m[2] as string)}` : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Objects a file creates, by kind and name. */
|
|
57
|
+
function created(texts: readonly string[]): Set<string> {
|
|
58
|
+
return new Set(texts.map(createdBy).filter((k): k is string => k !== null));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Judges a file's statements in the order they run. A drop may take only what an earlier
|
|
63
|
+
* statement of the same file created and that didn't exist before the file: `DROP x; CREATE x`
|
|
64
|
+
* would otherwise pass as "its own new table" and destroy a live one (codex code r2).
|
|
65
|
+
*/
|
|
66
|
+
function judgeFile(
|
|
67
|
+
texts: readonly string[],
|
|
68
|
+
before: ReadonlySet<string>,
|
|
69
|
+
mayDrop: (kind: string, name: string) => boolean,
|
|
70
|
+
): ReturnType<typeof judge>[] {
|
|
71
|
+
const own = new Set<string>();
|
|
72
|
+
return texts.map((t) => {
|
|
73
|
+
const j = judge(t, own, mayDrop);
|
|
74
|
+
const made = createdBy(t);
|
|
75
|
+
if (made && !before.has(made)) own.add(made);
|
|
76
|
+
const dropped = /^DROP\s+(TABLE|INDEX|TRIGGER|VIEW)\s+(?:IF\s+EXISTS\s+)?(\S+)/i.exec(strip(t));
|
|
77
|
+
if (dropped) own.delete(`${(dropped[1] as string).toUpperCase()}:${unquote(dropped[2] as string)}`);
|
|
78
|
+
return j;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every object that exists before `file` runs, by kind and name: the lock's earlier files applied in order. */
|
|
83
|
+
function existingBefore(input: ClassifyInput, file: string): Set<string> {
|
|
84
|
+
const pending = new Set(input.pending.map((p) => p.file));
|
|
85
|
+
const texts: string[] = [];
|
|
86
|
+
for (const e of input.lock.entries) {
|
|
87
|
+
if (e.file === file) break;
|
|
88
|
+
texts.push(pending.has(e.file) ? input.text(e.file) : readFileSync(join(input.cwd, "migrations", e.file), "utf8"));
|
|
89
|
+
}
|
|
90
|
+
const schema = schemaOf(texts);
|
|
91
|
+
schema.db.close();
|
|
92
|
+
return new Set(
|
|
93
|
+
schema.objects.filter((o) => o.type !== "constraint").map((o) => `${o.type.toUpperCase()}:${o.name.toLowerCase()}`),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What one plugin or local statement is, for the shape rule and the drop rule. */
|
|
98
|
+
function judge(
|
|
99
|
+
stmt: string,
|
|
100
|
+
own: Set<string>,
|
|
101
|
+
mayDrop: (kind: string, name: string) => boolean,
|
|
102
|
+
): { ok: boolean; drop?: string; why: string } {
|
|
103
|
+
const s = strip(stmt);
|
|
104
|
+
if (/^CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TABLE\b/i.test(s)) return { ok: true, why: "CREATE TABLE" };
|
|
105
|
+
const idx = new RegExp(
|
|
106
|
+
String.raw`^CREATE\s+(UNIQUE\s+)?INDEX\s+(?:IF\s+NOT\s+EXISTS\s+)?${NAME}\s+ON\s+${NAME}\s*\((.*)\)\s*(WHERE\b.*)?$`,
|
|
107
|
+
"i",
|
|
108
|
+
).exec(s);
|
|
109
|
+
if (idx) {
|
|
110
|
+
if (idx[1]) return { ok: false, why: "a unique index can refuse the running Worker's inserts" };
|
|
111
|
+
if (idx[3]) return { ok: false, why: "a partial index's predicate can throw on the running Worker's writes" };
|
|
112
|
+
if (!(idx[2] as string).split(",").every(plainColumn))
|
|
113
|
+
return { ok: false, why: "an expression index can throw on the running Worker's writes" };
|
|
114
|
+
return { ok: true, why: "a plain non-unique index" };
|
|
115
|
+
}
|
|
116
|
+
const drop = new RegExp(String.raw`^DROP\s+(TABLE|INDEX|TRIGGER|VIEW)\s+(?:IF\s+EXISTS\s+)?(${NAME})`, "i").exec(s);
|
|
117
|
+
if (drop) {
|
|
118
|
+
const kind = (drop[1] as string).toUpperCase();
|
|
119
|
+
const name = unquote(drop[2] as string);
|
|
120
|
+
if (own.has(`${kind}:${name}`) || mayDrop(kind, name)) return { ok: true, why: "drops what it may" };
|
|
121
|
+
return { ok: false, drop: `${kind} ${name}`, why: "drops something that exists before this file" };
|
|
122
|
+
}
|
|
123
|
+
if (/^ALTER\s+TABLE\b.*\b(RENAME|DROP\s+COLUMN)\b/i.test(s)) {
|
|
124
|
+
const t = new RegExp(String.raw`^ALTER\s+TABLE\s+(${NAME})`, "i").exec(s);
|
|
125
|
+
const name = unquote((t?.[1] as string) ?? "");
|
|
126
|
+
if (own.has(`TABLE:${name}`)) return { ok: true, why: "renames or drops within its own new table" };
|
|
127
|
+
return { ok: false, drop: `TABLE ${name}`, why: "renames or drops part of something that exists before this file" };
|
|
128
|
+
}
|
|
129
|
+
if (/^ALTER\s+TABLE\b.*\bADD\b/i.test(s))
|
|
130
|
+
return { ok: false, why: "a new column breaks the running Worker's positional INSERT" };
|
|
131
|
+
return { ok: false, why: `"${s.slice(0, 60)}" isn't a shape the running Worker can't trip` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ClassifyInput {
|
|
135
|
+
cwd: string;
|
|
136
|
+
lock: Lock;
|
|
137
|
+
pending: readonly LockEntry[];
|
|
138
|
+
manifest: Manifest | null;
|
|
139
|
+
// biome-ignore lint/suspicious/noExplicitAny: each plugin brings its own options type
|
|
140
|
+
plugins: readonly PluginInstance<any>[];
|
|
141
|
+
/** Each file's text, from the staged bytes the deploy applies (§6.2). */
|
|
142
|
+
text: (file: string) => string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Classifies each pending file, or throws NotN1 naming each one that isn't. */
|
|
146
|
+
export function classify(input: ClassifyInput): Classified[] {
|
|
147
|
+
const out: Classified[] = [];
|
|
148
|
+
const problems: string[] = [];
|
|
149
|
+
const cutovers = new Set(CUTOVER_MIGRATIONS);
|
|
150
|
+
for (const e of input.pending) {
|
|
151
|
+
const text = input.text(e.file);
|
|
152
|
+
if (e.source.startsWith("core:0")) {
|
|
153
|
+
const f = e.source.slice("core:".length);
|
|
154
|
+
const mode = input.manifest?.migrations[f]?.mode ?? (cutovers.has(f) ? "cutover" : "n1");
|
|
155
|
+
out.push({
|
|
156
|
+
file: e.file,
|
|
157
|
+
source: e.source,
|
|
158
|
+
mode,
|
|
159
|
+
because:
|
|
160
|
+
mode === "cutover" ? "core marks it a cutover" : "core's release suites ran the previous release over it",
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (e.source.startsWith("core:")) {
|
|
165
|
+
// The pinned timezone's rules and the checkout-track fingerprint: core's generated files, N−1 by design.
|
|
166
|
+
out.push({ file: e.file, source: e.source, mode: "n1", because: "core generates it N−1 (p0.4 §4, p0.7 §6)" });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const [kind, plugin, ...rest] = e.source.split(":");
|
|
170
|
+
const id = rest.join(":");
|
|
171
|
+
const texts = statementTexts(text);
|
|
172
|
+
const before = existingBefore(input, e.file);
|
|
173
|
+
if (kind === "plugin" && (id.startsWith("generated:") || id.startsWith("entity:") || id === "install")) {
|
|
174
|
+
// The composer replacing what it owns: drops only of this plugin's earlier generated objects.
|
|
175
|
+
const earlier = new Set<string>();
|
|
176
|
+
for (const prior of input.lock.entries) {
|
|
177
|
+
if (prior.file === e.file) break;
|
|
178
|
+
if (prior.source.startsWith(`plugin:${plugin}:`))
|
|
179
|
+
for (const o of created(statementTexts(readFileSync(join(input.cwd, "migrations", prior.file), "utf8"))))
|
|
180
|
+
earlier.add(o);
|
|
181
|
+
}
|
|
182
|
+
const bad = judgeFile(texts, before, (k, n) => earlier.has(`${k}:${n}`)).filter((j) => j.drop);
|
|
183
|
+
if (bad.length) problems.push(`${e.file} (${e.source}): ${bad.map((b) => `${b.drop}: ${b.why}`).join("; ")}`);
|
|
184
|
+
else
|
|
185
|
+
out.push({
|
|
186
|
+
file: e.file,
|
|
187
|
+
source: e.source,
|
|
188
|
+
mode: "n1",
|
|
189
|
+
because: "a plugin generation: an older Worker's writes are fenced as PLUGIN_UPGRADING (p0.7 §6)",
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const judged = judgeFile(texts, before, () => false);
|
|
194
|
+
const drops = judged.filter((j) => j.drop);
|
|
195
|
+
if (drops.length) {
|
|
196
|
+
problems.push(
|
|
197
|
+
`${e.file} (${e.source}): ${drops.map((d) => `${d.drop}: ${d.why}; only core's cutovers may, behind the fence`).join("; ")}`,
|
|
198
|
+
);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const statement =
|
|
202
|
+
/--\s*kedge:n-1\s+(\S.*)/.exec(text)?.[1]?.trim() ??
|
|
203
|
+
(kind === "plugin"
|
|
204
|
+
? input.plugins.find((p) => p.definition.name === plugin)?.definition.migrations?.find((m) => m.id === id)?.n1
|
|
205
|
+
: undefined);
|
|
206
|
+
const shaped = judged.filter((j) => !j.ok);
|
|
207
|
+
if (shaped.length === 0)
|
|
208
|
+
out.push({ file: e.file, source: e.source, mode: "n1", because: "shape: only new tables and plain indexes" });
|
|
209
|
+
else if (statement) out.push({ file: e.file, source: e.source, mode: "n1", because: `stated: ${statement}` });
|
|
210
|
+
else
|
|
211
|
+
problems.push(
|
|
212
|
+
`${e.file} (${e.source}): ${[...new Set(shaped.map((j) => j.why))].join("; ")}. If the running Worker survives it, say why in the file: -- kedge:n-1 <why>`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (problems.length) throw new NotN1(problems);
|
|
216
|
+
return out;
|
|
217
|
+
}
|