@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/LICENSE +21 -0
- package/bin/kedge.mjs +8 -0
- package/kedge-manifest.json +418 -0
- package/package.json +43 -3
- package/src/cf.ts +729 -0
- package/src/check.ts +45 -0
- package/src/cli-session.ts +57 -0
- package/src/code-lint.ts +339 -0
- package/src/commands.ts +959 -0
- package/src/compose.ts +637 -0
- package/src/d1-batch.ts +66 -0
- package/src/deploy.ts +1682 -0
- package/src/doctor.ts +701 -0
- package/src/fork.ts +67 -0
- package/src/index.ts +12 -0
- package/src/invariants.ts +168 -0
- package/src/keys-save.ts +298 -0
- package/src/local-state.ts +110 -0
- package/src/main.ts +470 -0
- package/src/manifest.ts +163 -0
- package/src/n1.ts +217 -0
- package/src/ops.ts +373 -0
- package/src/order.ts +7 -0
- package/src/output.ts +35 -0
- package/src/plugin-lint.ts +145 -0
- package/src/preview.ts +1038 -0
- package/src/schema.ts +72 -0
- package/src/smoke.ts +218 -0
- package/src/sqlite-verify.ts +333 -0
- package/src/store-api.ts +36 -0
- package/src/store.ts +132 -0
- package/src/tar.ts +125 -0
- package/src/target.ts +81 -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/store.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// What a deploy knows about the store before it touches Cloudflare: its names,
|
|
2
|
+
// from wrangler.jsonc (create-kedge gave them a random suffix, so nothing else
|
|
3
|
+
// in the account chooses them, p0.8 §6.1), its Git state, and the effective
|
|
4
|
+
// config a plan freezes.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import { readWrangler } from "./doctor";
|
|
10
|
+
import { installedPackages } from "./manifest";
|
|
11
|
+
import { byCodePoint } from "./order";
|
|
12
|
+
|
|
13
|
+
/** The starter's id until a first deploy writes the real one (§6.1). */
|
|
14
|
+
export const PLACEHOLDER_ID = "00000000-0000-0000-0000-000000000000";
|
|
15
|
+
|
|
16
|
+
export interface StoreNames {
|
|
17
|
+
worker: string;
|
|
18
|
+
database: string;
|
|
19
|
+
databaseId: string;
|
|
20
|
+
queue: string;
|
|
21
|
+
ops: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type WranglerJson = Record<string, unknown> & {
|
|
25
|
+
name?: string;
|
|
26
|
+
main?: string;
|
|
27
|
+
d1_databases?: { binding?: string; database_name?: string; database_id?: string; migrations_dir?: string }[];
|
|
28
|
+
queues?: { producers?: { binding?: string; queue?: string }[]; consumers?: { queue?: string }[] };
|
|
29
|
+
migrations?: Record<string, unknown>[];
|
|
30
|
+
triggers?: { crons?: string[] };
|
|
31
|
+
vars?: Record<string, string>;
|
|
32
|
+
routes?: unknown;
|
|
33
|
+
route?: unknown;
|
|
34
|
+
/** The admin's static assets (p0.9 §2.1). */
|
|
35
|
+
assets?: { directory?: string; binding?: string; not_found_handling?: string; run_worker_first?: unknown };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function storeNames(cwd: string): { names: StoreNames; config: WranglerJson; file: string } {
|
|
39
|
+
const w = readWrangler(cwd);
|
|
40
|
+
if (!w) throw new Error("no wrangler.jsonc in this directory");
|
|
41
|
+
const config = w.config as WranglerJson;
|
|
42
|
+
const db = config.d1_databases?.find((d) => d.binding === "DB");
|
|
43
|
+
const queue = config.queues?.producers?.find((q) => q.binding === "EVENTS")?.queue;
|
|
44
|
+
if (!config.name || !db?.database_name || !queue)
|
|
45
|
+
throw new Error("wrangler.jsonc needs a name, a DB database and an EVENTS queue");
|
|
46
|
+
return {
|
|
47
|
+
names: {
|
|
48
|
+
worker: config.name,
|
|
49
|
+
database: db.database_name,
|
|
50
|
+
databaseId: db.database_id ?? PLACEHOLDER_ID,
|
|
51
|
+
queue,
|
|
52
|
+
ops: `${config.name}-ops`,
|
|
53
|
+
},
|
|
54
|
+
config,
|
|
55
|
+
file: w.file,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const sha256 = (b: Buffer | string) => createHash("sha256").update(b).digest("hex");
|
|
60
|
+
|
|
61
|
+
export interface Git {
|
|
62
|
+
head(): string;
|
|
63
|
+
/** Paths with uncommitted changes. */
|
|
64
|
+
dirty(): string[];
|
|
65
|
+
/** HEAD's version of a file, or null. */
|
|
66
|
+
show(path: string): string | null;
|
|
67
|
+
isAncestor(older: string, newer: string): boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function git(cwd: string): Git {
|
|
71
|
+
const run = (args: string[]) => spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
72
|
+
return {
|
|
73
|
+
head() {
|
|
74
|
+
const r = run(["rev-parse", "HEAD"]);
|
|
75
|
+
if (r.status !== 0) throw new Error("kedge deploy needs a Git repository with a commit");
|
|
76
|
+
return r.stdout.trim();
|
|
77
|
+
},
|
|
78
|
+
dirty() {
|
|
79
|
+
return run(["status", "--porcelain"])
|
|
80
|
+
.stdout.split("\n")
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map((l) => l.slice(3));
|
|
83
|
+
},
|
|
84
|
+
show(path) {
|
|
85
|
+
const r = run(["show", `HEAD:${path}`]);
|
|
86
|
+
return r.status === 0 ? r.stdout : null;
|
|
87
|
+
},
|
|
88
|
+
isAncestor(older, newer) {
|
|
89
|
+
return run(["merge-base", "--is-ancestor", older, newer]).status === 0;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A dirty worktree refuses a deploy, so the plan is the commit's alone, with
|
|
96
|
+
* one exception: wrangler.jsonc whose only change is the first deploy writing
|
|
97
|
+
* the database id over the placeholder (§6.1).
|
|
98
|
+
*/
|
|
99
|
+
export function dirtyBeyondId(cwd: string, g: Git, databaseId: string | null): string[] {
|
|
100
|
+
return g.dirty().filter((p) => {
|
|
101
|
+
if (p !== "wrangler.jsonc" || !databaseId) return true;
|
|
102
|
+
const head = g.show("wrangler.jsonc");
|
|
103
|
+
return head === null || head.replace(PLACEHOLDER_ID, databaseId) !== readFileSync(join(cwd, p), "utf8");
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every file under a directory with its sha256, by path inside it. */
|
|
108
|
+
export function hashTree(dir: string): Record<string, string> {
|
|
109
|
+
const out: Record<string, string> = {};
|
|
110
|
+
const walk = (d: string) => {
|
|
111
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
112
|
+
const p = join(d, e.name);
|
|
113
|
+
if (e.isDirectory()) walk(p);
|
|
114
|
+
else if (e.isFile()) out[relative(dir, p)] = sha256(readFileSync(p));
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
if (existsSync(dir)) walk(dir);
|
|
118
|
+
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => byCodePoint(a, b)));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The store's installed Kedge, by package and version. */
|
|
122
|
+
export function packageVersions(cwd: string): Record<string, string> {
|
|
123
|
+
const out: Record<string, string> = {};
|
|
124
|
+
for (const [name, dir] of installedPackages(cwd))
|
|
125
|
+
out[name] = (JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version: string }).version;
|
|
126
|
+
return Object.fromEntries(Object.entries(out).sort(([a], [b]) => byCodePoint(a, b)));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The DO classes a migrations list leaves declared, as a stable key for "did the lifecycle change". */
|
|
130
|
+
export function classesKey(migrations: WranglerJson["migrations"] = []): string {
|
|
131
|
+
return JSON.stringify(migrations);
|
|
132
|
+
}
|
package/src/tar.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// A tar stream, read as data (docs/proposals/p0.8-cli-agent-kit.md §5.3). A
|
|
2
|
+
// preview's plan may come from somewhere untrusted, so `preview apply` never
|
|
3
|
+
// opens its directory: a path check on a directory can't be made safe
|
|
4
|
+
// (`modules/leak -> /proc/self` makes `modules/leak/environ` look like a
|
|
5
|
+
// regular file, and a source can be swapped between check and read). In a
|
|
6
|
+
// stream a symlink is only an entry, which this refuses.
|
|
7
|
+
|
|
8
|
+
export interface TarEntry {
|
|
9
|
+
path: string;
|
|
10
|
+
data: Buffer;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class TarRefused extends Error {
|
|
14
|
+
constructor(message: string) {
|
|
15
|
+
super(`the plan's tar stream is refused: ${message}`);
|
|
16
|
+
this.name = "TarRefused";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const field = (b: Buffer, at: number, len: number) =>
|
|
21
|
+
b
|
|
22
|
+
.subarray(at, at + len)
|
|
23
|
+
.toString("utf8")
|
|
24
|
+
.replace(/\0.*$/s, "");
|
|
25
|
+
/** An octal field: digits 0–7 only, so no sign and no base-256 form; anything else is NaN and refuses. */
|
|
26
|
+
const octal = (b: Buffer, at: number, len: number) => {
|
|
27
|
+
const s = field(b, at, len).trim();
|
|
28
|
+
if (s === "") return 0;
|
|
29
|
+
return /^[0-7]+$/.test(s) ? Number.parseInt(s, 8) : Number.NaN;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Pax records (`<len> key=value\n`): only `path` matters here. */
|
|
33
|
+
function paxPath(data: Buffer): string | null {
|
|
34
|
+
let rest = data.toString("utf8");
|
|
35
|
+
while (rest.length) {
|
|
36
|
+
const sp = rest.indexOf(" ");
|
|
37
|
+
const len = Number.parseInt(rest.slice(0, sp), 10);
|
|
38
|
+
if (!Number.isFinite(len) || len <= 0) break;
|
|
39
|
+
const rec = rest.slice(sp + 1, len - 1);
|
|
40
|
+
const eq = rec.indexOf("=");
|
|
41
|
+
if (rec.slice(0, eq) === "path") return rec.slice(eq + 1);
|
|
42
|
+
rest = rest.slice(len);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Regular files only, with limits enforced while reading. Directories are skipped; anything else refuses. */
|
|
48
|
+
export function readTar(stream: Buffer, limits: { files: number; fileBytes: number; totalBytes: number }): TarEntry[] {
|
|
49
|
+
const out: TarEntry[] = [];
|
|
50
|
+
let total = 0;
|
|
51
|
+
let entries = 0;
|
|
52
|
+
let at = 0;
|
|
53
|
+
let nextPath: string | null = null;
|
|
54
|
+
while (at + 512 <= stream.length) {
|
|
55
|
+
const h = stream.subarray(at, at + 512);
|
|
56
|
+
if (h.every((x) => x === 0)) break;
|
|
57
|
+
let sum = 0;
|
|
58
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : (h[i] as number);
|
|
59
|
+
if (sum !== octal(h, 148, 8)) throw new TarRefused("a header's checksum is wrong");
|
|
60
|
+
const size = octal(h, 124, 12);
|
|
61
|
+
// A negative or unparsable size would hold the cursor still and loop the trusted process (codex code r2).
|
|
62
|
+
if (!Number.isSafeInteger(size) || size < 0)
|
|
63
|
+
throw new TarRefused("an entry's size isn't a plain non-negative number");
|
|
64
|
+
// Every entry counts against the limits, metadata included, so none can be repeated without end.
|
|
65
|
+
if (++entries > limits.files * 4) throw new TarRefused(`more than ${limits.files * 4} entries`);
|
|
66
|
+
total += size;
|
|
67
|
+
if (total > limits.totalBytes) throw new TarRefused(`the plan is over ${limits.totalBytes} bytes`);
|
|
68
|
+
const type = String.fromCharCode(h[156] as number);
|
|
69
|
+
const prefix = field(h, 345, 155);
|
|
70
|
+
const name = prefix ? `${prefix}/${field(h, 0, 100)}` : field(h, 0, 100);
|
|
71
|
+
const start = at + 512;
|
|
72
|
+
const end = start + size;
|
|
73
|
+
if (end > stream.length) throw new TarRefused("an entry runs past the stream's end");
|
|
74
|
+
const data = stream.subarray(start, end);
|
|
75
|
+
at = start + Math.ceil(size / 512) * 512;
|
|
76
|
+
if (type === "x") {
|
|
77
|
+
nextPath = paxPath(data);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (type === "g") continue;
|
|
81
|
+
if (type === "L") {
|
|
82
|
+
nextPath = data.toString("utf8").replace(/\0.*$/s, "");
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const path = (nextPath ?? name).replace(/^\.\//, "");
|
|
86
|
+
nextPath = null;
|
|
87
|
+
if (type === "5") continue;
|
|
88
|
+
if (type !== "0" && type !== "\0")
|
|
89
|
+
throw new TarRefused(
|
|
90
|
+
`${path} isn't a regular file (type ${JSON.stringify(type)}): links and devices aren't taken`,
|
|
91
|
+
);
|
|
92
|
+
if (path === "" || path.startsWith("/") || path.split("/").includes("..") || path.includes("\\"))
|
|
93
|
+
throw new TarRefused(`${path} is absolute or climbs out`);
|
|
94
|
+
if (size > limits.fileBytes) throw new TarRefused(`${path} is over ${limits.fileBytes} bytes`);
|
|
95
|
+
if (out.length >= limits.files) throw new TarRefused(`more than ${limits.files} files`);
|
|
96
|
+
if (out.some((e) => e.path === path)) throw new TarRefused(`${path} appears twice`);
|
|
97
|
+
out.push({ path, data: Buffer.from(data) });
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A plain ustar stream of regular files, for the plan `preview build` hands `apply`. */
|
|
103
|
+
export function writeTar(entries: readonly TarEntry[]): Buffer {
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
if (Buffer.byteLength(e.path) > 100) throw new Error(`${e.path} is too long for a plan`);
|
|
107
|
+
const h = Buffer.alloc(512);
|
|
108
|
+
h.write(e.path, 0, "utf8");
|
|
109
|
+
h.write("0000644\0", 100);
|
|
110
|
+
h.write("0000000\0", 108);
|
|
111
|
+
h.write("0000000\0", 116);
|
|
112
|
+
h.write(`${e.data.length.toString(8).padStart(11, "0")}\0`, 124);
|
|
113
|
+
h.write("00000000000\0", 136);
|
|
114
|
+
h.write(" ", 148);
|
|
115
|
+
h.write("0", 156);
|
|
116
|
+
h.write("ustar\0", 257);
|
|
117
|
+
h.write("00", 263);
|
|
118
|
+
let sum = 0;
|
|
119
|
+
for (const x of h) sum += x;
|
|
120
|
+
h.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
|
|
121
|
+
chunks.push(h, e.data, Buffer.alloc((512 - (e.data.length % 512)) % 512));
|
|
122
|
+
}
|
|
123
|
+
chunks.push(Buffer.alloc(1024));
|
|
124
|
+
return Buffer.concat(chunks);
|
|
125
|
+
}
|
package/src/target.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Which store a URL is (docs/proposals/p0.10-agent-path.md §4, §6.2). The CLI
|
|
2
|
+
// sends a session, a saved key or a run's record only to a target it can name
|
|
3
|
+
// by identity: production or a preview this store's `<store>-ops` records, or
|
|
4
|
+
// `kedge dev` on this machine. Anything else gets nothing, so an unknown
|
|
5
|
+
// endpoint never receives a token. A store is its account, Worker and
|
|
6
|
+
// database, not its URL: the account's owner can change the workers.dev
|
|
7
|
+
// subdomain, so the URL is computed from the current one on every run.
|
|
8
|
+
import type { Cloudflare } from "./cf";
|
|
9
|
+
import { openOps } from "./deploy";
|
|
10
|
+
import { storeNames } from "./store";
|
|
11
|
+
|
|
12
|
+
export interface StoreTarget {
|
|
13
|
+
kind: "production" | "preview" | "local";
|
|
14
|
+
/** The origin requests go to. */
|
|
15
|
+
url: string;
|
|
16
|
+
/** What records bind to: never the URL. A local target's account is `local`. */
|
|
17
|
+
identity: { account: string; worker: string; database: string };
|
|
18
|
+
/** Where a CLI session's row is written: that target's own database, and no other. */
|
|
19
|
+
database: { name: string; id: string | null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `kedge dev`'s port: the config's `dev.port`, else Wrangler's default 8787
|
|
24
|
+
* (`DEFAULT_LOCAL_PORT`, wrangler-dist/cli.js:212493, 4.152.0). When 8787 is
|
|
25
|
+
* taken Wrangler picks another (`memoizeGetPort`, :213241), which then isn't a
|
|
26
|
+
* target: the command asks for `--key` or `--session` instead.
|
|
27
|
+
*/
|
|
28
|
+
export function devPort(cwd: string): number {
|
|
29
|
+
const dev = storeNames(cwd).config.dev as { port?: unknown } | undefined;
|
|
30
|
+
return typeof dev?.port === "number" ? dev.port : 8787;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
34
|
+
|
|
35
|
+
/** The target `url` names, or null when it's none this checkout knows. */
|
|
36
|
+
export async function resolveTarget(
|
|
37
|
+
url: string,
|
|
38
|
+
cwd: string,
|
|
39
|
+
cloudflare: () => Promise<Cloudflare>,
|
|
40
|
+
): Promise<StoreTarget | null> {
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
const { names } = storeNames(cwd);
|
|
43
|
+
if (LOCAL_HOSTS.has(u.hostname)) {
|
|
44
|
+
if (u.protocol !== "http:" || Number(u.port || 80) !== devPort(cwd)) return null;
|
|
45
|
+
return {
|
|
46
|
+
kind: "local",
|
|
47
|
+
url: u.origin,
|
|
48
|
+
identity: { account: "local", worker: names.worker, database: "local" },
|
|
49
|
+
database: { name: names.database, id: null },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (u.protocol !== "https:") return null;
|
|
53
|
+
const cf = await cloudflare();
|
|
54
|
+
// A lookup, never a create: a store never deployed has no target here.
|
|
55
|
+
if (!(await cf.findD1(names.ops))) return null;
|
|
56
|
+
const { ops } = await openOps(cf, names);
|
|
57
|
+
const host = (worker: string, subdomain: string) => `https://${worker}.${subdomain}.workers.dev`;
|
|
58
|
+
const subdomain = await cf.workersSubdomain();
|
|
59
|
+
if (u.origin === host(names.worker, subdomain)) {
|
|
60
|
+
const db = await ops.resource("d1", names.database);
|
|
61
|
+
if (!db?.id) return null;
|
|
62
|
+
return {
|
|
63
|
+
kind: "production",
|
|
64
|
+
url: u.origin,
|
|
65
|
+
identity: { account: cf.account, worker: names.worker, database: db.id },
|
|
66
|
+
database: { name: names.database, id: db.id },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// The whole origin, as a preview's is made: a host that only begins with a preview's name isn't it.
|
|
70
|
+
const rows = await ops.query(
|
|
71
|
+
"SELECT worker, database_name, database_id FROM preview WHERE state = 'bootstrapped' AND database_id IS NOT NULL",
|
|
72
|
+
);
|
|
73
|
+
const p = rows.find((r) => u.origin === host(String(r.worker), subdomain));
|
|
74
|
+
if (!p) return null;
|
|
75
|
+
return {
|
|
76
|
+
kind: "preview",
|
|
77
|
+
url: u.origin,
|
|
78
|
+
identity: { account: cf.account, worker: String(p.worker), database: String(p.database_id) },
|
|
79
|
+
database: { name: String(p.database_name), id: String(p.database_id) },
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/tz.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Pinning the store's timezone rules (p0.4-dated-capacity.md §4): `kedge
|
|
2
|
+
// generate` compiles the configured zone from the generating Node's IANA
|
|
3
|
+
// data once, writes the table the Worker passes to createKedge, and composes
|
|
4
|
+
// a migration loading the same rows into tz_transition and pinning
|
|
5
|
+
// store_time. Generating again never changes a pinned version (until P2.3).
|
|
6
|
+
import { compileZone, intlOffsetSeconds, markedMigrationSql, type TzTable } from "@kedge/core";
|
|
7
|
+
|
|
8
|
+
/** Rows per INSERT: well under D1's statement size limit. */
|
|
9
|
+
const ROWS_PER_INSERT = 500;
|
|
10
|
+
|
|
11
|
+
/** The zone's transitions from 1970 to 9999 under this toolchain's IANA data. */
|
|
12
|
+
export function compileStoreZone(zone: string): TzTable {
|
|
13
|
+
const version = process.versions.tz;
|
|
14
|
+
if (!version) throw new Error("This Node has no IANA tz data version (process.versions.tz)");
|
|
15
|
+
try {
|
|
16
|
+
new Intl.DateTimeFormat("en-US", { timeZone: zone });
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`kedge.config.ts: "${zone}" isn't an IANA zone this toolchain knows`);
|
|
19
|
+
}
|
|
20
|
+
return compileZone(zone, version, intlOffsetSeconds(zone), {
|
|
21
|
+
from: 0,
|
|
22
|
+
to: Date.UTC(9999, 11, 31, 23, 59, 59),
|
|
23
|
+
// Historical rules can change twice within a week; future ones are seasonal.
|
|
24
|
+
denseUntil: Date.UTC(2050, 0, 1),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sqlText = (s: string) => `'${s.replaceAll("'", "''")}'`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The migration that pins the zone: refused once any dated row recorded the
|
|
32
|
+
* previous rules (a booking's labels would change under it), then the rows,
|
|
33
|
+
* then store_time.
|
|
34
|
+
*/
|
|
35
|
+
export function tzMigrationSql(tz: TzTable): string {
|
|
36
|
+
const v = sqlText(tz.version);
|
|
37
|
+
const rows: [number, number][] = [
|
|
38
|
+
[0, tz.initialOffset],
|
|
39
|
+
...tz.at.map((t, i) => [t, tz.offsets[i] as number] as [number, number]),
|
|
40
|
+
];
|
|
41
|
+
const inserts: string[] = [];
|
|
42
|
+
for (let i = 0; i < rows.length; i += ROWS_PER_INSERT) {
|
|
43
|
+
const chunk = rows.slice(i, i + ROWS_PER_INSERT).map(([at, off]) => `(${v}, ${at}, ${off})`);
|
|
44
|
+
inserts.push(`INSERT INTO tz_transition (version, at_utc, offset_seconds) VALUES ${chunk.join(", ")};`);
|
|
45
|
+
}
|
|
46
|
+
return [
|
|
47
|
+
`-- ${tz.zone} at IANA ${tz.version}, pinned by kedge generate (p0.4 §4). A store's zone and
|
|
48
|
+
-- rules are fixed once any dated row exists: the guard refuses this migration then.
|
|
49
|
+
CREATE TABLE _kedge_tz_guard (dated integer NOT NULL CONSTRAINT kedge_tz_pinned CHECK (dated = 0));`,
|
|
50
|
+
`INSERT INTO _kedge_tz_guard (dated)
|
|
51
|
+
SELECT (SELECT count(*) FROM (SELECT 1 FROM capacity_pool LIMIT 1))
|
|
52
|
+
+ (SELECT count(*) FROM (SELECT 1 FROM capacity_adjustment LIMIT 1))
|
|
53
|
+
+ (SELECT count(*) FROM (SELECT 1 FROM order_line_period_provenance WHERE source = 'wall_time' LIMIT 1));`,
|
|
54
|
+
"DROP TABLE _kedge_tz_guard;",
|
|
55
|
+
// tz_transition and store_time are fenced core tables (p0.5 §7.1).
|
|
56
|
+
markedMigrationSql("migration:tz", [
|
|
57
|
+
...inserts,
|
|
58
|
+
`UPDATE store_time SET zone = ${sqlText(tz.zone)}, tz_rules_version = ${v} WHERE id = 1;`,
|
|
59
|
+
]),
|
|
60
|
+
].join("\n--> statement-breakpoint\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** src/kedge.tz.ts: the same table, for createKedge(config, { tz }). */
|
|
64
|
+
export function tzModule(tz: TzTable): string {
|
|
65
|
+
const what =
|
|
66
|
+
tz.version === "utc" ? "UTC, the store's default until a zone is configured" : `${tz.zone} at IANA ${tz.version}`;
|
|
67
|
+
return `// Generated by kedge generate: ${what}, pinned (p0.4 §4).
|
|
68
|
+
// Never edit: the database holds the same rows, and the checkout claim compares versions.
|
|
69
|
+
import type { TzTable } from "@kedge/runtime-cf";
|
|
70
|
+
|
|
71
|
+
export const tz: TzTable = {
|
|
72
|
+
zone: ${JSON.stringify(tz.zone)},
|
|
73
|
+
version: ${JSON.stringify(tz.version)},
|
|
74
|
+
initialOffset: ${tz.initialOffset},
|
|
75
|
+
at: [${tz.at.join(",")}],
|
|
76
|
+
offsets: [${tz.offsets.join(",")}],
|
|
77
|
+
};
|
|
78
|
+
`;
|
|
79
|
+
}
|
package/src/units.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// `kedge eject` and doctor's `unit-drift` (docs/proposals/p0.8-cli-agent-kit.md §7):
|
|
2
|
+
// one unit of core copied into the store with the upstream's hash recorded,
|
|
3
|
+
// and, when an upgrade changes that upstream, a failing check that prints the
|
|
4
|
+
// diff. Merging is by hand, then `kedge eject --rebase`: overwriting a
|
|
5
|
+
// customised copy is how upstream changes get lost (research/14 §1).
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { KedgeConfig } from "@kedge/core";
|
|
9
|
+
import type { CheckResult } from "./doctor";
|
|
10
|
+
import { installedPackages, type Manifest } from "./manifest";
|
|
11
|
+
import { sha256 } from "./store";
|
|
12
|
+
|
|
13
|
+
export interface EjectHeader {
|
|
14
|
+
unit: string;
|
|
15
|
+
version: string;
|
|
16
|
+
upstreamSha256: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const HEADER = /^\/\/ kedge-eject: (\{.*\})$/m;
|
|
20
|
+
const overridesDir = (cwd: string) => join(cwd, "src", "overrides");
|
|
21
|
+
const origFile = (cwd: string, unit: string) => join(cwd, ".kedge", "eject", `${unit}.orig.ts`);
|
|
22
|
+
|
|
23
|
+
/** The installed upstream source of a unit, by the manifest's package and path. */
|
|
24
|
+
export function upstreamSource(cwd: string, manifest: Manifest, unit: string): string {
|
|
25
|
+
const u = manifest.units[unit];
|
|
26
|
+
if (!u)
|
|
27
|
+
throw new Error(
|
|
28
|
+
`"${unit}" isn't an ejectable unit of Kedge ${manifest.version}: ${Object.keys(manifest.units).join(", ") || "none"}`,
|
|
29
|
+
);
|
|
30
|
+
const dir = installedPackages(cwd).get(u.package);
|
|
31
|
+
if (!dir) throw new Error(`${u.package} isn't installed`);
|
|
32
|
+
return readFileSync(join(dir, u.path), "utf8");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function header(text: string): EjectHeader | null {
|
|
36
|
+
const m = HEADER.exec(text);
|
|
37
|
+
if (!m) return null;
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(m[1] as string) as EjectHeader;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const identifier = (unit: string) => unit.replace(/[.-](\w)/g, (_, c: string) => c.toUpperCase());
|
|
46
|
+
|
|
47
|
+
export function eject(cwd: string, manifest: Manifest, unit: string, reason: string): { file: string; wiring: string } {
|
|
48
|
+
if (!reason.trim())
|
|
49
|
+
throw new Error('kedge eject needs --reason "<why>": a higher rung couldn\'t do this, and the header says why');
|
|
50
|
+
const source = upstreamSource(cwd, manifest, unit);
|
|
51
|
+
const file = join(overridesDir(cwd), `${unit}.ts`);
|
|
52
|
+
if (existsSync(file))
|
|
53
|
+
throw new Error(`${unit} is already ejected; after merging an upstream change, kedge eject --rebase ${unit}`);
|
|
54
|
+
const h: EjectHeader = { unit, version: manifest.version, upstreamSha256: sha256(source) };
|
|
55
|
+
mkdirSync(overridesDir(cwd), { recursive: true });
|
|
56
|
+
writeFileSync(file, `// kedge-eject: ${JSON.stringify(h)}\n// Why: ${reason.replace(/\n/g, " ")}\n${source}`);
|
|
57
|
+
mkdirSync(join(cwd, ".kedge", "eject"), { recursive: true });
|
|
58
|
+
writeFileSync(origFile(cwd, unit), source);
|
|
59
|
+
const id = identifier(unit);
|
|
60
|
+
return {
|
|
61
|
+
file,
|
|
62
|
+
wiring: `import ${id} from "./src/overrides/${unit}";\n// in defineConfig({ … }):\n overrides: { "${unit}": ${id} },`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** After a hand merge: the recorded upstream becomes the installed one. */
|
|
67
|
+
export function rebase(cwd: string, manifest: Manifest, unit: string): void {
|
|
68
|
+
const file = join(overridesDir(cwd), `${unit}.ts`);
|
|
69
|
+
if (!existsSync(file)) throw new Error(`${unit} isn't ejected`);
|
|
70
|
+
const text = readFileSync(file, "utf8");
|
|
71
|
+
const h = header(text);
|
|
72
|
+
if (!h) throw new Error(`${file} has no kedge-eject header`);
|
|
73
|
+
const source = upstreamSource(cwd, manifest, unit);
|
|
74
|
+
const next: EjectHeader = { unit, version: manifest.version, upstreamSha256: sha256(source) };
|
|
75
|
+
writeFileSync(file, text.replace(HEADER, `// kedge-eject: ${JSON.stringify(next)}`));
|
|
76
|
+
writeFileSync(origFile(cwd, unit), source);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A unified diff of two texts, by longest common subsequence of lines. */
|
|
80
|
+
export function unifiedDiff(a: string, b: string, aName: string, bName: string, context = 3): string {
|
|
81
|
+
const x = a.split("\n");
|
|
82
|
+
const y = b.split("\n");
|
|
83
|
+
const n = x.length;
|
|
84
|
+
const m = y.length;
|
|
85
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
86
|
+
for (let i = n - 1; i >= 0; i--)
|
|
87
|
+
for (let j = m - 1; j >= 0; j--)
|
|
88
|
+
(lcs[i] as number[])[j] =
|
|
89
|
+
x[i] === y[j]
|
|
90
|
+
? ((lcs[i + 1] as number[])[j + 1] as number) + 1
|
|
91
|
+
: Math.max((lcs[i + 1] as number[])[j] as number, (lcs[i] as number[])[j + 1] as number);
|
|
92
|
+
const ops: { t: " " | "-" | "+"; s: string; ai: number; bi: number }[] = [];
|
|
93
|
+
let i = 0;
|
|
94
|
+
let j = 0;
|
|
95
|
+
while (i < n || j < m) {
|
|
96
|
+
if (i < n && j < m && x[i] === y[j]) ops.push({ t: " ", s: x[i++] as string, ai: i, bi: ++j });
|
|
97
|
+
// A deletion before an addition, as diff -u prints them.
|
|
98
|
+
else if (i < n && (j >= m || ((lcs[i + 1] as number[])[j] as number) >= ((lcs[i] as number[])[j + 1] as number)))
|
|
99
|
+
ops.push({ t: "-", s: x[i++] as string, ai: i, bi: j });
|
|
100
|
+
else ops.push({ t: "+", s: y[j++] as string, ai: i, bi: j });
|
|
101
|
+
}
|
|
102
|
+
const out = [`--- ${aName}`, `+++ ${bName}`];
|
|
103
|
+
let k = 0;
|
|
104
|
+
while (k < ops.length) {
|
|
105
|
+
if ((ops[k] as { t: string }).t === " ") {
|
|
106
|
+
k++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const start = Math.max(0, k - context);
|
|
110
|
+
let end = k;
|
|
111
|
+
while (end < ops.length) {
|
|
112
|
+
let next = end;
|
|
113
|
+
while (next < ops.length && (ops[next] as { t: string }).t === " ") next++;
|
|
114
|
+
if (next >= ops.length || next - end > context * 2) break;
|
|
115
|
+
end = next + 1;
|
|
116
|
+
}
|
|
117
|
+
end = Math.min(ops.length, end + context);
|
|
118
|
+
const hunk = ops.slice(start, end);
|
|
119
|
+
out.push(`@@ -${hunk[0]?.ai ?? 1} +${hunk[0]?.bi ?? 1} @@`);
|
|
120
|
+
for (const o of hunk) out.push(`${o.t}${o.s}`);
|
|
121
|
+
k = end;
|
|
122
|
+
}
|
|
123
|
+
return out.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The upgrade notes' entries that name a unit, from the installed @kedge/docs. */
|
|
127
|
+
function notesFor(cwd: string, unit: string): string[] {
|
|
128
|
+
const docs = installedPackages(cwd).get("@kedge/docs");
|
|
129
|
+
if (!docs || !existsSync(join(docs, "upgrading"))) return [];
|
|
130
|
+
const out: string[] = [];
|
|
131
|
+
for (const f of readdirSync(join(docs, "upgrading")).filter((f) => f.endsWith(".md"))) {
|
|
132
|
+
const text = readFileSync(join(docs, "upgrading", f), "utf8");
|
|
133
|
+
if (text.includes(`unit:${unit}`)) out.push(`upgrading/${f} names ${unit}`);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Doctor's `unit-drift` (§7.3). */
|
|
139
|
+
export function unitDrift(
|
|
140
|
+
cwd: string,
|
|
141
|
+
manifest: Manifest | null,
|
|
142
|
+
config: KedgeConfig | null,
|
|
143
|
+
o: { ignoreWiring?: boolean } = {},
|
|
144
|
+
): CheckResult {
|
|
145
|
+
const dir = overridesDir(cwd);
|
|
146
|
+
const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".ts")) : [];
|
|
147
|
+
const wired = Object.keys(config?.overrides ?? {});
|
|
148
|
+
if (files.length === 0 && wired.length === 0) return { name: "unit-drift", status: "pass" };
|
|
149
|
+
if (!manifest) return { name: "unit-drift", status: "skip", message: "no release manifest (a workspace install)" };
|
|
150
|
+
const problems: string[] = [];
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
for (const f of files) {
|
|
153
|
+
const unit = f.slice(0, -3);
|
|
154
|
+
seen.add(unit);
|
|
155
|
+
const h = header(readFileSync(join(dir, f), "utf8"));
|
|
156
|
+
if (!h || h.unit !== unit) {
|
|
157
|
+
problems.push(
|
|
158
|
+
`src/overrides/${f} has no kedge-eject header naming ${unit}: eject with kedge eject, never by hand`,
|
|
159
|
+
);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const u = manifest.units[unit];
|
|
163
|
+
if (!u) {
|
|
164
|
+
problems.push(`${unit} isn't an ejectable unit of Kedge ${manifest.version}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!o.ignoreWiring && !wired.includes(unit))
|
|
168
|
+
problems.push(`${unit} is ejected but not wired: add it to overrides in kedge.config.ts`);
|
|
169
|
+
if (u.sha256 !== h.upstreamSha256) {
|
|
170
|
+
const orig = existsSync(origFile(cwd, unit)) ? readFileSync(origFile(cwd, unit), "utf8") : "";
|
|
171
|
+
const now = upstreamSource(cwd, manifest, unit);
|
|
172
|
+
problems.push(
|
|
173
|
+
`${unit} was ejected from ${h.version}, and Kedge ${manifest.version} changed it upstream: merge the change below by hand, then kedge eject --rebase ${unit}`,
|
|
174
|
+
...notesFor(cwd, unit),
|
|
175
|
+
...unifiedDiff(orig, now, `${unit} (${h.version})`, `${unit} (${manifest.version})`).split("\n"),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!o.ignoreWiring)
|
|
180
|
+
for (const w of wired)
|
|
181
|
+
if (!seen.has(w)) problems.push(`overrides wires ${w}, but src/overrides/${w}.ts doesn't exist`);
|
|
182
|
+
return problems.length
|
|
183
|
+
? { name: "unit-drift", status: "fail", message: "an ejected unit drifted from its upstream", details: problems }
|
|
184
|
+
: { name: "unit-drift", status: "pass", message: `${files.length} ejected unit${files.length === 1 ? "" : "s"}` };
|
|
185
|
+
}
|