@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/ops.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// The operations database (docs/proposals/p0.8-cli-agent-kit.md §3.1): a
|
|
2
|
+
// second D1 database per store, `<store>-ops`, that no Worker binds. It holds
|
|
3
|
+
// what a lost CI runner can't lose and a restore of the store can't rewind:
|
|
4
|
+
// who is running what on the store's production resources and on each preview
|
|
5
|
+
// (one operation per scope), every control-plane call before it's sent and
|
|
6
|
+
// its outcome, the deployment manifests, the hashes of applied migrations,
|
|
7
|
+
// the store's own resources, and the preview registry.
|
|
8
|
+
//
|
|
9
|
+
// Exclusion is a primary key and compare-and-set updates, read back with
|
|
10
|
+
// RETURNING, so of two callers one wins and a paused one finds its claim gone
|
|
11
|
+
// at its next step. Nothing here takes over on a timer: elapsed time doesn't
|
|
12
|
+
// prove a paused job is gone.
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { hostname } from "node:os";
|
|
15
|
+
import type { Row } from "./cf";
|
|
16
|
+
|
|
17
|
+
export type Query = (sql: string, params?: readonly unknown[]) => Promise<Row[]>;
|
|
18
|
+
|
|
19
|
+
export const OPS_SCHEMA = [
|
|
20
|
+
`CREATE TABLE IF NOT EXISTS kedge_ops_meta (id INTEGER PRIMARY KEY CHECK (id = 1), store TEXT NOT NULL, created_at INTEGER NOT NULL)`,
|
|
21
|
+
`CREATE TABLE IF NOT EXISTS operation (
|
|
22
|
+
scope TEXT PRIMARY KEY, op_id TEXT NOT NULL UNIQUE, kind TEXT NOT NULL, plan TEXT NOT NULL, step TEXT NOT NULL,
|
|
23
|
+
progress TEXT NOT NULL DEFAULT '{}',
|
|
24
|
+
executor TEXT, executor_label TEXT, heartbeat_at INTEGER NOT NULL, started_at INTEGER NOT NULL)`,
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS operation_log (
|
|
26
|
+
op_id TEXT PRIMARY KEY, scope TEXT NOT NULL, kind TEXT NOT NULL, plans TEXT NOT NULL, outcome TEXT NOT NULL,
|
|
27
|
+
started_at INTEGER NOT NULL, ended_at INTEGER NOT NULL)`,
|
|
28
|
+
`CREATE TABLE IF NOT EXISTS intent (
|
|
29
|
+
id TEXT PRIMARY KEY, op_id TEXT NOT NULL, scope TEXT NOT NULL, step TEXT NOT NULL, kind TEXT NOT NULL,
|
|
30
|
+
tag TEXT NOT NULL, detail TEXT NOT NULL,
|
|
31
|
+
state TEXT NOT NULL CHECK (state IN ('open', 'done', 'failed', 'unknown', 'not_executed')),
|
|
32
|
+
created_at INTEGER NOT NULL, closed_at INTEGER, resolved_by TEXT)`,
|
|
33
|
+
`CREATE INDEX IF NOT EXISTS intent_scope_state ON intent (scope, state)`,
|
|
34
|
+
`CREATE TABLE IF NOT EXISTS deployment (
|
|
35
|
+
id TEXT PRIMARY KEY, seq INTEGER NOT NULL UNIQUE, op_id TEXT NOT NULL, mode TEXT NOT NULL,
|
|
36
|
+
before_half TEXT NOT NULL, after_half TEXT, created_at INTEGER NOT NULL, completed_at INTEGER)`,
|
|
37
|
+
`CREATE TABLE IF NOT EXISTS applied (file TEXT PRIMARY KEY, sha256 TEXT NOT NULL, applied_at INTEGER NOT NULL)`,
|
|
38
|
+
`CREATE TABLE IF NOT EXISTS resource (
|
|
39
|
+
kind TEXT NOT NULL, name TEXT NOT NULL, id TEXT, state TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
40
|
+
PRIMARY KEY (kind, name))`,
|
|
41
|
+
`CREATE TABLE IF NOT EXISTS preview (
|
|
42
|
+
identity TEXT PRIMARY KEY, nonce TEXT NOT NULL, worker TEXT NOT NULL, database_name TEXT NOT NULL,
|
|
43
|
+
database_id TEXT, queue_name TEXT NOT NULL, queue_id TEXT, classes TEXT NOT NULL DEFAULT '[]', consumer_id TEXT,
|
|
44
|
+
state TEXT NOT NULL CHECK (state IN ('creating', 'bootstrapped', 'destroying', 'destroyed')),
|
|
45
|
+
credentials TEXT, applied TEXT NOT NULL DEFAULT '{}', lost_create INTEGER NOT NULL DEFAULT 0,
|
|
46
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`,
|
|
47
|
+
// A destroyed generation whose create lost its answer, kept by its own names when the identity
|
|
48
|
+
// is reused, so the sweep still finds what a late create leaves for its 7 days (§3.1, codex code r2).
|
|
49
|
+
`CREATE TABLE IF NOT EXISTS preview_tombstone (
|
|
50
|
+
worker TEXT PRIMARY KEY, identity TEXT NOT NULL, database_name TEXT NOT NULL, queue_name TEXT NOT NULL,
|
|
51
|
+
classes TEXT NOT NULL DEFAULT '[]', destroyed_at INTEGER NOT NULL)`,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
export class OperationHeld extends Error {
|
|
55
|
+
constructor(readonly held: OperationRow) {
|
|
56
|
+
super(
|
|
57
|
+
`${held.scope} is held by operation ${held.op_id} (${held.kind}, at "${held.step}", ${held.executor ? `run by ${held.executor_label ?? held.executor}` : "stopped"}, since ${new Date(held.started_at).toISOString()})`,
|
|
58
|
+
);
|
|
59
|
+
this.name = "OperationHeld";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class LostOwnership extends Error {
|
|
64
|
+
constructor(scope: string, step: string) {
|
|
65
|
+
super(
|
|
66
|
+
`${scope}: this process no longer holds its operation (at "${step}"); another took it over or it ended. Stopping.`,
|
|
67
|
+
);
|
|
68
|
+
this.name = "LostOwnership";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface OperationRow {
|
|
73
|
+
scope: string;
|
|
74
|
+
op_id: string;
|
|
75
|
+
kind: string;
|
|
76
|
+
plan: string;
|
|
77
|
+
step: string;
|
|
78
|
+
/** What the operation has learned so far (ids, versions), so a resume continues from it. */
|
|
79
|
+
progress: string;
|
|
80
|
+
executor: string | null;
|
|
81
|
+
executor_label: string | null;
|
|
82
|
+
heartbeat_at: number;
|
|
83
|
+
started_at: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface IntentRow {
|
|
87
|
+
id: string;
|
|
88
|
+
op_id: string;
|
|
89
|
+
scope: string;
|
|
90
|
+
step: string;
|
|
91
|
+
kind: string;
|
|
92
|
+
tag: string;
|
|
93
|
+
detail: string;
|
|
94
|
+
state: "open" | "done" | "failed" | "unknown" | "not_executed";
|
|
95
|
+
created_at: number;
|
|
96
|
+
closed_at: number | null;
|
|
97
|
+
resolved_by: string | null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DeploymentRow {
|
|
101
|
+
id: string;
|
|
102
|
+
seq: number;
|
|
103
|
+
op_id: string;
|
|
104
|
+
mode: string;
|
|
105
|
+
before_half: string;
|
|
106
|
+
after_half: string | null;
|
|
107
|
+
created_at: number;
|
|
108
|
+
completed_at: number | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Who this process is, for a refusal to name: the host and, in CI, the run. */
|
|
112
|
+
export function executorLabel(env: NodeJS.ProcessEnv = process.env): string {
|
|
113
|
+
const run = env.GITHUB_RUN_ID
|
|
114
|
+
? ` (GitHub run ${env.GITHUB_RUN_ID}${env.GITHUB_RUN_ATTEMPT ? `/${env.GITHUB_RUN_ATTEMPT}` : ""})`
|
|
115
|
+
: "";
|
|
116
|
+
return `${hostname()}:${process.pid}${run}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class OpsStore {
|
|
120
|
+
/** This process's identity: a random id, never reused. */
|
|
121
|
+
readonly executor = `ex_${randomUUID().replaceAll("-", "")}`;
|
|
122
|
+
constructor(
|
|
123
|
+
private readonly q: Query,
|
|
124
|
+
private readonly now: () => number = Date.now,
|
|
125
|
+
readonly label: string = executorLabel(),
|
|
126
|
+
) {}
|
|
127
|
+
|
|
128
|
+
async init(store: string): Promise<void> {
|
|
129
|
+
for (const s of OPS_SCHEMA) await this.q(s);
|
|
130
|
+
await this.q("INSERT INTO kedge_ops_meta (id, store, created_at) VALUES (1, ?, ?) ON CONFLICT (id) DO NOTHING", [
|
|
131
|
+
store,
|
|
132
|
+
this.now(),
|
|
133
|
+
]);
|
|
134
|
+
const [meta] = await this.q("SELECT store FROM kedge_ops_meta WHERE id = 1");
|
|
135
|
+
if (meta?.store !== store)
|
|
136
|
+
throw new Error(`this operations database belongs to ${String(meta?.store)}, not ${store}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ---- operations --------------------------------------------------------------------------------------
|
|
140
|
+
async current(scope: string): Promise<OperationRow | null> {
|
|
141
|
+
return ((await this.q("SELECT * FROM operation WHERE scope = ?", [scope]))[0] as OperationRow | undefined) ?? null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Starts an operation, or refuses naming the one that holds the scope. */
|
|
145
|
+
async start(scope: string, kind: string, plan: unknown, step: string): Promise<string> {
|
|
146
|
+
const opId = `op_${randomUUID().replaceAll("-", "").slice(0, 20)}`;
|
|
147
|
+
const t = this.now();
|
|
148
|
+
const won = await this.q(
|
|
149
|
+
`INSERT INTO operation (scope, op_id, kind, plan, step, executor, executor_label, heartbeat_at, started_at)
|
|
150
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (scope) DO NOTHING RETURNING op_id`,
|
|
151
|
+
[scope, opId, kind, JSON.stringify(plan), step, this.executor, this.label, t, t],
|
|
152
|
+
);
|
|
153
|
+
if (won.length === 1) return opId;
|
|
154
|
+
const held = await this.current(scope);
|
|
155
|
+
if (!held) throw new Error(`${scope}: the operation vanished while starting; run again`);
|
|
156
|
+
throw new OperationHeld(held);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Records the step about to run, and what's been learned; refuses unless this process still holds the operation. */
|
|
160
|
+
async step(scope: string, opId: string, step: string, progress?: unknown): Promise<void> {
|
|
161
|
+
const ok = await this.q(
|
|
162
|
+
`UPDATE operation SET step = ?, heartbeat_at = ?${progress === undefined ? "" : ", progress = ?"}
|
|
163
|
+
WHERE scope = ? AND op_id = ? AND executor = ? RETURNING op_id`,
|
|
164
|
+
[step, this.now(), ...(progress === undefined ? [] : [JSON.stringify(progress)]), scope, opId, this.executor],
|
|
165
|
+
);
|
|
166
|
+
if (ok.length !== 1) throw new LostOwnership(scope, step);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A clean stop: the operation stays, with nobody running it, for a resume. */
|
|
170
|
+
async stop(scope: string, opId: string): Promise<void> {
|
|
171
|
+
await this.q("UPDATE intent SET state = 'unknown' WHERE scope = ? AND op_id = ? AND state = 'open'", [scope, opId]);
|
|
172
|
+
await this.q(
|
|
173
|
+
"UPDATE operation SET executor = NULL, executor_label = NULL WHERE scope = ? AND op_id = ? AND executor = ?",
|
|
174
|
+
[scope, opId, this.executor],
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Claims a stopped operation: of two resumes one wins, and a running one can't be resumed. */
|
|
179
|
+
async resume(scope: string, opId: string): Promise<OperationRow> {
|
|
180
|
+
const ok = await this.q(
|
|
181
|
+
"UPDATE operation SET executor = ?, executor_label = ?, heartbeat_at = ? WHERE scope = ? AND op_id = ? AND executor IS NULL RETURNING *",
|
|
182
|
+
[this.executor, this.label, this.now(), scope, opId],
|
|
183
|
+
);
|
|
184
|
+
if (ok.length === 1) return ok[0] as unknown as OperationRow;
|
|
185
|
+
const held = await this.current(scope);
|
|
186
|
+
if (!held || held.op_id !== opId) throw new Error(`${scope} has no operation ${opId}`);
|
|
187
|
+
throw new OperationHeld(held);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Takes over an operation whose executor is set, on someone's word that it
|
|
192
|
+
* was cancelled (--confirm-stopped). Its open intents become unknown: a call
|
|
193
|
+
* it had sent may still land, so the caller reconciles before acting.
|
|
194
|
+
*/
|
|
195
|
+
async takeOver(scope: string, opId: string, observed: string | null): Promise<OperationRow> {
|
|
196
|
+
// From the executor the caller saw, and only from it: two runners recovering the same dead
|
|
197
|
+
// one can't both win, and the loser stops before it sends anything (codex code r8).
|
|
198
|
+
const ok = await this.q(
|
|
199
|
+
`UPDATE operation SET executor = ?, executor_label = ?, heartbeat_at = ?
|
|
200
|
+
WHERE scope = ? AND op_id = ? AND executor IS ? RETURNING *`,
|
|
201
|
+
[this.executor, this.label, this.now(), scope, opId, observed],
|
|
202
|
+
);
|
|
203
|
+
if (ok.length !== 1) {
|
|
204
|
+
const held = await this.current(scope);
|
|
205
|
+
if (!held || held.op_id !== opId) throw new Error(`${scope} has no operation ${opId}`);
|
|
206
|
+
throw new OperationHeld(held);
|
|
207
|
+
}
|
|
208
|
+
await this.q("UPDATE intent SET state = 'unknown' WHERE scope = ? AND op_id = ? AND state = 'open'", [scope, opId]);
|
|
209
|
+
return ok[0] as unknown as OperationRow;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A new plan under the same operation; the old one stays in its history (§6.2's --supersede). */
|
|
213
|
+
async supersede(scope: string, opId: string, plan: unknown, progress?: unknown): Promise<void> {
|
|
214
|
+
// The plan and what its progress may keep, in one statement: a crash between two writes would
|
|
215
|
+
// pair a new plan with the old plan's uploaded version (codex code r5).
|
|
216
|
+
const ok = await this.q(
|
|
217
|
+
`UPDATE operation SET plan = ?, heartbeat_at = ?${progress === undefined ? "" : ", progress = ?"}
|
|
218
|
+
WHERE scope = ? AND op_id = ? AND executor = ? RETURNING plan`,
|
|
219
|
+
[
|
|
220
|
+
JSON.stringify(plan),
|
|
221
|
+
this.now(),
|
|
222
|
+
...(progress === undefined ? [] : [JSON.stringify(progress)]),
|
|
223
|
+
scope,
|
|
224
|
+
opId,
|
|
225
|
+
this.executor,
|
|
226
|
+
],
|
|
227
|
+
);
|
|
228
|
+
if (ok.length !== 1) throw new LostOwnership(scope, "supersede");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Ends the operation, logged with every plan it ran under. Two requests, so each is safe to
|
|
233
|
+
* repeat: the log row is written only by the owner and only once, and a finish whose release
|
|
234
|
+
* was lost after its log landed releases on the next try instead of failing on the log's key
|
|
235
|
+
* and holding the store for good (codex code r1).
|
|
236
|
+
*/
|
|
237
|
+
async finish(scope: string, opId: string, outcome: string, plans: readonly unknown[]): Promise<void> {
|
|
238
|
+
const row = await this.current(scope);
|
|
239
|
+
if (!row || row.op_id !== opId || row.executor !== this.executor) throw new LostOwnership(scope, "finish");
|
|
240
|
+
await this.q(
|
|
241
|
+
`INSERT INTO operation_log (op_id, scope, kind, plans, outcome, started_at, ended_at)
|
|
242
|
+
SELECT op_id, scope, kind, ?, ?, started_at, ? FROM operation WHERE scope = ? AND op_id = ? AND executor = ?
|
|
243
|
+
ON CONFLICT (op_id) DO NOTHING`,
|
|
244
|
+
[JSON.stringify(plans), outcome, this.now(), scope, opId, this.executor],
|
|
245
|
+
);
|
|
246
|
+
await this.q("DELETE FROM operation WHERE scope = ? AND op_id = ? AND executor = ?", [scope, opId, this.executor]);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- intents (§3.1) ------------------------------------------------------------------------------------
|
|
250
|
+
async openIntent(
|
|
251
|
+
scope: string,
|
|
252
|
+
opId: string,
|
|
253
|
+
step: string,
|
|
254
|
+
kind: string,
|
|
255
|
+
tag: string,
|
|
256
|
+
detail: unknown,
|
|
257
|
+
): Promise<string> {
|
|
258
|
+
const id = `int_${randomUUID().replaceAll("-", "").slice(0, 20)}`;
|
|
259
|
+
// Recorded only by the operation's current executor: a runner that lost it sends nothing.
|
|
260
|
+
const ok = await this.q(
|
|
261
|
+
`INSERT INTO intent (id, op_id, scope, step, kind, tag, detail, state, created_at)
|
|
262
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, 'open', ? WHERE EXISTS
|
|
263
|
+
(SELECT 1 FROM operation WHERE scope = ? AND op_id = ? AND executor = ?) RETURNING id`,
|
|
264
|
+
[id, opId, scope, step, kind, tag, JSON.stringify(detail), this.now(), scope, opId, this.executor],
|
|
265
|
+
);
|
|
266
|
+
if (ok.length !== 1) throw new LostOwnership(scope, step);
|
|
267
|
+
return id;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async closeIntent(
|
|
271
|
+
id: string,
|
|
272
|
+
state: "done" | "failed" | "unknown" | "not_executed",
|
|
273
|
+
resolvedBy: string,
|
|
274
|
+
): Promise<void> {
|
|
275
|
+
await this.q("UPDATE intent SET state = ?, closed_at = ?, resolved_by = ? WHERE id = ?", [
|
|
276
|
+
state,
|
|
277
|
+
this.now(),
|
|
278
|
+
resolvedBy,
|
|
279
|
+
id,
|
|
280
|
+
]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Calls whose outcome nobody knows: nothing in the scope runs while one exists. */
|
|
284
|
+
async unresolved(scope: string): Promise<IntentRow[]> {
|
|
285
|
+
return (await this.q("SELECT * FROM intent WHERE scope = ? AND state IN ('open', 'unknown') ORDER BY created_at", [
|
|
286
|
+
scope,
|
|
287
|
+
])) as unknown as IntentRow[];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async intents(scope: string, opId?: string): Promise<IntentRow[]> {
|
|
291
|
+
return (await this.q(`SELECT * FROM intent WHERE scope = ?${opId ? " AND op_id = ?" : ""} ORDER BY created_at`, [
|
|
292
|
+
scope,
|
|
293
|
+
...(opId ? [opId] : []),
|
|
294
|
+
])) as unknown as IntentRow[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async intent(id: string): Promise<IntentRow | null> {
|
|
298
|
+
return ((await this.q("SELECT * FROM intent WHERE id = ?", [id]))[0] as IntentRow | undefined) ?? null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---- deployments (§6.5) ----------------------------------------------------------------------------------
|
|
302
|
+
async deployments(): Promise<DeploymentRow[]> {
|
|
303
|
+
return (await this.q("SELECT * FROM deployment ORDER BY seq DESC")) as unknown as DeploymentRow[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async addDeployment(opId: string, mode: string, before: unknown): Promise<string> {
|
|
307
|
+
const id = `dep_${randomUUID().replaceAll("-", "").slice(0, 20)}`;
|
|
308
|
+
await this.q(
|
|
309
|
+
`INSERT INTO deployment (id, seq, op_id, mode, before_half, created_at)
|
|
310
|
+
VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM deployment), ?, ?, ?, ?)`,
|
|
311
|
+
[id, opId, mode, JSON.stringify(before), this.now()],
|
|
312
|
+
);
|
|
313
|
+
return id;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async completeDeployment(id: string, after: unknown): Promise<void> {
|
|
317
|
+
await this.q("UPDATE deployment SET after_half = ?, completed_at = ? WHERE id = ?", [
|
|
318
|
+
JSON.stringify(after),
|
|
319
|
+
this.now(),
|
|
320
|
+
id,
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- applied migrations' hashes (§6.1) --------------------------------------------------------------------
|
|
325
|
+
async appliedHashes(): Promise<Map<string, string>> {
|
|
326
|
+
return new Map((await this.q("SELECT file, sha256 FROM applied")).map((r) => [String(r.file), String(r.sha256)]));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async recordApplied(file: string, sha256: string): Promise<void> {
|
|
330
|
+
await this.q("INSERT INTO applied (file, sha256, applied_at) VALUES (?, ?, ?) ON CONFLICT (file) DO NOTHING", [
|
|
331
|
+
file,
|
|
332
|
+
sha256,
|
|
333
|
+
this.now(),
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---- the store's own resources (§6.1) ------------------------------------------------------------------
|
|
338
|
+
async resource(kind: string, name: string): Promise<{ id: string | null; state: string } | null> {
|
|
339
|
+
const [r] = await this.q("SELECT id, state FROM resource WHERE kind = ? AND name = ?", [kind, name]);
|
|
340
|
+
return r ? { id: (r.id as string | null) ?? null, state: String(r.state) } : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Recorded before the resource is used (a Worker before its first upload). */
|
|
344
|
+
async recordResource(kind: string, name: string, id: string | null, state: string): Promise<void> {
|
|
345
|
+
await this.q(
|
|
346
|
+
`INSERT INTO resource (kind, name, id, state, created_at) VALUES (?, ?, ?, ?, ?)
|
|
347
|
+
ON CONFLICT (kind, name) DO UPDATE SET id = COALESCE(excluded.id, resource.id), state = excluded.state`,
|
|
348
|
+
[kind, name, id, state, this.now()],
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
query(sql: string, params?: readonly unknown[]): Promise<Row[]> {
|
|
353
|
+
return this.q(sql, params);
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/order.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strings in code-point order. `localeCompare` follows the machine's ICU locale,
|
|
3
|
+
* which orders `_` differently under C.UTF-8 than under en_US, so a generated
|
|
4
|
+
* file or a hash sorted with it differs between two machines and `generate
|
|
5
|
+
* --check` fails on the one that didn't write it.
|
|
6
|
+
*/
|
|
7
|
+
export const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Lints everything a plugin will run as SQL, not only its migration files:
|
|
2
|
+
// acquire bodies, checkout reads and statements, and release handlers. The
|
|
3
|
+
// functions that build runtime SQL are called with a representative input,
|
|
4
|
+
// since the linter needs their text, not their values.
|
|
5
|
+
import type { PluginDefinition, ScheduleDescriptor, SqlStatement } from "@kedge/core";
|
|
6
|
+
import {
|
|
7
|
+
entityTable,
|
|
8
|
+
entityWriteProblems,
|
|
9
|
+
errorSources,
|
|
10
|
+
isTimerDescriptor,
|
|
11
|
+
type LintIssue,
|
|
12
|
+
type LintOptions,
|
|
13
|
+
lintSql,
|
|
14
|
+
type Owner,
|
|
15
|
+
trackSql,
|
|
16
|
+
} from "@kedge/core";
|
|
17
|
+
|
|
18
|
+
type Context = LintOptions["context"];
|
|
19
|
+
|
|
20
|
+
const sample = {
|
|
21
|
+
cartId: "cart_lint",
|
|
22
|
+
email: "lint@example.com",
|
|
23
|
+
customerId: "cus_lint",
|
|
24
|
+
currency: "USD",
|
|
25
|
+
variantIds: ["var_lint"],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The SQL a plugin builds at runtime, from sample inputs, with where each came from. */
|
|
29
|
+
export function collect<O>(def: PluginDefinition<O>, options: O): { sql: string; context: Context; where: string }[] {
|
|
30
|
+
const out: { sql: string; context: Context; where: string }[] = [];
|
|
31
|
+
// Timer descriptors beside a contribution's SQL are core's to write (p0.6a §3.2): nothing to lint.
|
|
32
|
+
const push = (where: string, context: Context, ss: readonly (SqlStatement | ScheduleDescriptor)[] | undefined) => {
|
|
33
|
+
for (const s of ss ?? []) if (!isTimerDescriptor(s)) out.push({ sql: s.sql, context, where });
|
|
34
|
+
};
|
|
35
|
+
for (const [key, body] of Object.entries(def.acquire ?? {}))
|
|
36
|
+
out.push({ sql: body as string, context: "acquire", where: `acquire ${key}` });
|
|
37
|
+
out.push(...trackSql(def, options));
|
|
38
|
+
const c = def.checkout;
|
|
39
|
+
if (c?.reads) push("checkout.reads", "read", Object.values(c.reads(sample, options)));
|
|
40
|
+
if (c?.statements) {
|
|
41
|
+
// Cite every declared fact under a sample key, so conditional statements are produced.
|
|
42
|
+
const facts = Object.fromEntries(
|
|
43
|
+
Object.keys(c.facts ?? {}).map((n) => [n, new Proxy({}, { get: () => "cfa_lint" })]),
|
|
44
|
+
);
|
|
45
|
+
push(
|
|
46
|
+
"checkout.statements",
|
|
47
|
+
"contribution",
|
|
48
|
+
c.statements({
|
|
49
|
+
orderId: "ord_lint",
|
|
50
|
+
email: sample.email,
|
|
51
|
+
customerId: sample.customerId,
|
|
52
|
+
currency: sample.currency,
|
|
53
|
+
options,
|
|
54
|
+
claim: {},
|
|
55
|
+
lines: [
|
|
56
|
+
{
|
|
57
|
+
id: "ordl_lint",
|
|
58
|
+
variantId: "var_lint",
|
|
59
|
+
sku: "S",
|
|
60
|
+
title: "T",
|
|
61
|
+
quantity: 1,
|
|
62
|
+
unitAmount: 100,
|
|
63
|
+
lineTotal: 100,
|
|
64
|
+
kind: "item",
|
|
65
|
+
parentId: null,
|
|
66
|
+
chargeClass: "revenue",
|
|
67
|
+
taxable: true,
|
|
68
|
+
discountable: true,
|
|
69
|
+
periodStart: null,
|
|
70
|
+
periodEnd: null,
|
|
71
|
+
custom: {},
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
facts,
|
|
75
|
+
fees: [{ code: "lint", amount: 1 }],
|
|
76
|
+
adjustments: [{ code: "lint", amount: -1 }],
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
for (const [key, handler] of Object.entries(def.release ?? {})) {
|
|
81
|
+
if (!handler) continue;
|
|
82
|
+
push(
|
|
83
|
+
`release ${key}`,
|
|
84
|
+
"statement",
|
|
85
|
+
handler(
|
|
86
|
+
{
|
|
87
|
+
uid: "ord_lint:expired",
|
|
88
|
+
aggregate: "order",
|
|
89
|
+
aggregateId: "ord_lint",
|
|
90
|
+
fromState: "pending_payment",
|
|
91
|
+
toState: "expired",
|
|
92
|
+
},
|
|
93
|
+
options,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function lintPlugin<O>(def: PluginDefinition<O>, options: O): LintIssue[] {
|
|
101
|
+
const owner: Owner = { kind: "plugin", name: def.name };
|
|
102
|
+
const issues: LintIssue[] = [];
|
|
103
|
+
const texts: string[] = [];
|
|
104
|
+
for (const m of def.migrations ?? []) {
|
|
105
|
+
texts.push(m.sql);
|
|
106
|
+
for (const i of lintSql(owner, m.sql, { context: "migration" })) issues.push({ ...i, at: `${m.id}: ${i.at}` });
|
|
107
|
+
}
|
|
108
|
+
let runtime: ReturnType<typeof collect>;
|
|
109
|
+
try {
|
|
110
|
+
runtime = collect(def, options);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
issues.push({
|
|
113
|
+
rule: "unlintable",
|
|
114
|
+
message: `building the plugin's SQL with sample input threw: ${(e as Error).message}`,
|
|
115
|
+
at: def.name,
|
|
116
|
+
});
|
|
117
|
+
runtime = [];
|
|
118
|
+
}
|
|
119
|
+
for (const r of runtime) {
|
|
120
|
+
texts.push(r.sql);
|
|
121
|
+
const factTables = Object.values(def.checkout?.facts ?? {}).map((f) => f.table);
|
|
122
|
+
for (const i of lintSql(owner, r.sql, { context: r.context, factTables }))
|
|
123
|
+
issues.push({ ...i, at: `${r.where}: ${i.at}` });
|
|
124
|
+
}
|
|
125
|
+
// Only core writes an entity table (§4.2), from every SQL source a plugin has:
|
|
126
|
+
// a migration's trigger, an acquire body core installs as a trigger, a
|
|
127
|
+
// consequence, a release (codex code r6).
|
|
128
|
+
const entityTables = Object.keys(def.entities ?? {}).map((e) => entityTable(def.name, e));
|
|
129
|
+
const sources = [
|
|
130
|
+
...(def.migrations ?? []).map((m) => ({ where: `migration ${m.id}`, sql: m.sql })),
|
|
131
|
+
...runtime.map((r) => ({ where: r.where, sql: r.sql })),
|
|
132
|
+
];
|
|
133
|
+
for (const s of sources)
|
|
134
|
+
for (const message of entityWriteProblems(s.sql, entityTables))
|
|
135
|
+
issues.push({ rule: "entity-write", message, at: s.where });
|
|
136
|
+
|
|
137
|
+
// The error-map check: every named CHECK and RAISE text maps to a code.
|
|
138
|
+
const mapped = new Set(Object.keys(def.errors ?? {}));
|
|
139
|
+
const { checks, raises } = errorSources(texts.join(";\n"));
|
|
140
|
+
for (const key of [...checks, ...raises]) {
|
|
141
|
+
if (!mapped.has(key))
|
|
142
|
+
issues.push({ rule: "error-map", message: `"${key}" has no entry in the plugin's error map`, at: def.name });
|
|
143
|
+
}
|
|
144
|
+
return issues;
|
|
145
|
+
}
|