@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/schema.ts ADDED
@@ -0,0 +1,72 @@
1
+ // The store's composed schema, built the way D1 applies it: each migration
2
+ // file in one transaction, in lock order, into an in-memory SQLite. Doctor and
3
+ // generate read objects from it rather than from SQL text, so a name inside a
4
+ // comment or a string can't count as an object (docs/proposals/p0.8-cli-agent-kit.md §4.2).
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { DatabaseSync } from "node:sqlite";
8
+ import { BREAK, statementTexts } from "@kedge/core";
9
+ import { byCodePoint } from "./order";
10
+
11
+ export interface SchemaObject {
12
+ type: "table" | "index" | "trigger" | "view" | "constraint";
13
+ name: string;
14
+ table: string;
15
+ sql: string;
16
+ }
17
+
18
+ export interface Schema {
19
+ db: DatabaseSync;
20
+ objects: SchemaObject[];
21
+ }
22
+
23
+ /** Applies each file as one transaction, as D1 does, so a deferred obligation is checked at its commit. */
24
+ export function applyMigrations(db: DatabaseSync, texts: readonly string[]): void {
25
+ for (const text of texts) {
26
+ db.exec("BEGIN");
27
+ try {
28
+ for (const part of text.split(BREAK)) for (const s of statementTexts(part)) db.exec(`${s};`);
29
+ db.exec("COMMIT");
30
+ } catch (e) {
31
+ db.exec("ROLLBACK");
32
+ throw e;
33
+ }
34
+ }
35
+ }
36
+
37
+ /** Named constraints in a table's CREATE statement (`CONSTRAINT <name>`), quoted or not. */
38
+ export function namedConstraints(createSql: string): string[] {
39
+ return [...createSql.matchAll(/\bCONSTRAINT\s+["`[]?([A-Za-z0-9_]+)/gi)].map((m) => m[1] as string);
40
+ }
41
+
42
+ /** RAISE codes in an object's SQL: the text before the first `:` or space, which the error map keys on. */
43
+ export function raiseCodes(sql: string): string[] {
44
+ return [
45
+ ...new Set(
46
+ [...sql.matchAll(/\bRAISE\s*\(\s*(?:ABORT|FAIL|ROLLBACK)\s*,\s*'([^']*)'/gi)].map(
47
+ (m) => (m[1] as string).split(/[: ]/)[0] as string,
48
+ ),
49
+ ),
50
+ ];
51
+ }
52
+
53
+ export function schemaOf(texts: readonly string[]): Schema {
54
+ const db = new DatabaseSync(":memory:");
55
+ applyMigrations(db, texts);
56
+ const rows = db
57
+ .prepare("SELECT type, name, tbl_name AS tbl, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'")
58
+ .all() as { type: SchemaObject["type"]; name: string; tbl: string; sql: string | null }[];
59
+ const objects: SchemaObject[] = [];
60
+ for (const r of rows) {
61
+ objects.push({ type: r.type, name: r.name, table: r.tbl, sql: r.sql ?? "" });
62
+ if (r.type === "table")
63
+ for (const c of namedConstraints(r.sql ?? ""))
64
+ objects.push({ type: "constraint", name: c, table: r.name, sql: r.sql ?? "" });
65
+ }
66
+ return { db, objects: objects.sort((a, b) => byCodePoint(a.name, b.name)) };
67
+ }
68
+
69
+ /** The store's composed migrations, in lock order. */
70
+ export function composedTexts(cwd: string, lock: { entries: readonly { file: string }[] }): string[] {
71
+ return lock.entries.map((e) => readFileSync(join(cwd, "migrations", e.file), "utf8"));
72
+ }
package/src/smoke.ts ADDED
@@ -0,0 +1,218 @@
1
+ // `kedge smoke` (docs/proposals/p0.10-agent-path.md §6.3): a scripted checkout
2
+ // through the public API with the storefront's key, then Phase 0's staff
3
+ // placement, standing in for payment (D-035), with a CLI session holding
4
+ // `order:place`. It never places a second order by accident: each run has a
5
+ // name the caller keeps, recorded in `.kedge/state.db` before its cart exists,
6
+ // and three moments are each committed before the request they guard: the
7
+ // completion's exact body (a rerun sends it unchanged, never a fresh price),
8
+ // the order's id and token together, and the number once it's read back. A
9
+ // rerun resumes; a finished run answers its order again, writing nothing.
10
+ import type { DatabaseSync } from "node:sqlite";
11
+ import { NeedsPerson } from "./cf";
12
+ import { type SessionDeps, withCliSession } from "./cli-session";
13
+ import { DeployRefused } from "./deploy";
14
+ import { ENV, identityOf, keysSaveHeld } from "./keys-save";
15
+ import { stateDb, withStoreLock } from "./local-state";
16
+ import { storeRequest } from "./store-api";
17
+ import type { StoreTarget } from "./target";
18
+
19
+ /** The product `kedge seed --sample apparel` makes first, and the currency it's priced in. */
20
+ export const SMOKE_PRODUCT = { handle: "linen-shirt", currency: "USD" } as const;
21
+
22
+ interface Run {
23
+ name: string;
24
+ store: string;
25
+ storefront_id: string;
26
+ cart_id: string | null;
27
+ cart_token: string | null;
28
+ completion: string | null;
29
+ order_id: string | null;
30
+ order_token: string | null;
31
+ number: string | null;
32
+ abandoned: string | null;
33
+ }
34
+
35
+ export interface SmokeDeps {
36
+ cwd: string;
37
+ fetch: typeof fetch;
38
+ session: SessionDeps;
39
+ log: (s: string) => void;
40
+ now?: () => number;
41
+ }
42
+
43
+ export interface SmokeResult {
44
+ run: string;
45
+ order_id: string;
46
+ number: string;
47
+ status: string;
48
+ }
49
+
50
+ const table = (db: DatabaseSync) =>
51
+ db.exec(`CREATE TABLE IF NOT EXISTS smoke_run (
52
+ name TEXT PRIMARY KEY, store TEXT NOT NULL, storefront_id TEXT NOT NULL,
53
+ cart_id TEXT, cart_token TEXT, completion TEXT, order_id TEXT, order_token TEXT, number TEXT, abandoned TEXT,
54
+ created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`);
55
+
56
+ /** The smoke run `name` on `target`: resumed, or answered again once it has its number. */
57
+ export async function smoke(target: StoreTarget, name: string, d: SmokeDeps): Promise<SmokeResult> {
58
+ return withStoreLock(d.cwd, "smoke", async () => {
59
+ const db = stateDb(d.cwd);
60
+ try {
61
+ table(db);
62
+ return await run(db, target, name, d);
63
+ } finally {
64
+ db.close();
65
+ }
66
+ });
67
+ }
68
+
69
+ async function run(db: DatabaseSync, target: StoreTarget, name: string, d: SmokeDeps): Promise<SmokeResult> {
70
+ const now = d.now ?? Date.now;
71
+ const store = identityOf(target);
72
+ const get = () => db.prepare("SELECT * FROM smoke_run WHERE name = ?").get(name) as unknown as Run | undefined;
73
+ const set = (a: Partial<Run>) => {
74
+ const cols = Object.keys(a);
75
+ db.prepare(`UPDATE smoke_run SET ${cols.map((c) => `${c} = ?`).join(", ")}, updated_at = ? WHERE name = ?`).run(
76
+ ...cols.map((c) => (a as Record<string, string | null>)[c] ?? null),
77
+ now(),
78
+ name,
79
+ );
80
+ };
81
+
82
+ let r = get();
83
+ // A finished run answers its order again, sending nothing: a retried command can't place a second one.
84
+ if (r?.number && r.order_id) {
85
+ if (r.store !== store) throw new DeployRefused(`smoke run ${name} belongs to another store (${r.store})`);
86
+ return { run: name, order_id: r.order_id, number: r.number, status: "open" };
87
+ }
88
+ if (r?.abandoned)
89
+ throw new DeployRefused(`smoke run ${name} was abandoned: ${r.abandoned}. Start another with --run <new name>`);
90
+
91
+ // The storefront's key, through the same check `keys create --save` makes every time: a saved key that's
92
+ // this store's and still works, or a replacement on its recorded storefront (codex code r13).
93
+ const saved = await keysSaveHeld(
94
+ target,
95
+ { kind: "publishable", name: "web", origins: [] },
96
+ {
97
+ cwd: d.cwd,
98
+ fetch: d.fetch,
99
+ session: d.session,
100
+ log: d.log,
101
+ },
102
+ );
103
+ const key = saved[ENV.publishable];
104
+ const storefront = saved[ENV.storefront];
105
+ if (!key || !storefront) throw new DeployRefused("no storefront key was saved for this store");
106
+
107
+ if (!r) {
108
+ const t = now();
109
+ db.prepare("INSERT INTO smoke_run (name, store, storefront_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)").run(
110
+ name,
111
+ store,
112
+ storefront,
113
+ t,
114
+ t,
115
+ );
116
+ r = get() as Run;
117
+ }
118
+ // A cart admits only its own storefront's key: a record for another store or storefront is refused, not reused.
119
+ if (r.store !== store || r.storefront_id !== storefront)
120
+ throw new DeployRefused(
121
+ `smoke run ${name} was made for ${r.store === store ? `storefront ${r.storefront_id}` : r.store}: start another with --run <new name>`,
122
+ );
123
+
124
+ const shop = { "x-kedge-key": key };
125
+ const req = async (method: string, path: string, headers: Record<string, string>, body?: unknown) => {
126
+ const res = await storeRequest(d.fetch, new URL(path, target.url), {
127
+ method,
128
+ headers: { ...headers, ...(body === undefined ? {} : { "content-type": "application/json" }) },
129
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
130
+ });
131
+ return res;
132
+ };
133
+ const refused = (what: string, res: { status: number; body: Record<string, unknown> }) =>
134
+ new DeployRefused(
135
+ `${what} answered ${res.status} ${String((res.body.error as { code?: string } | undefined)?.code ?? "")}`.trim(),
136
+ );
137
+
138
+ if (!r.order_id) {
139
+ if (!r.completion) {
140
+ // Disposable until it's marked completing: a cart from a stopped run is abandoned with its lines.
141
+ const p = await req("GET", `/store/products/${SMOKE_PRODUCT.handle}?currency=${SMOKE_PRODUCT.currency}`, shop);
142
+ if (p.status === 404)
143
+ throw new DeployRefused(`the store has no ${SMOKE_PRODUCT.handle}: run kedge seed --sample apparel first`);
144
+ if (!p.ok) throw refused("reading the sample product", p);
145
+ const variant = (p.body.variants as { id?: string }[] | undefined)?.[0]?.id;
146
+ if (!variant) throw new DeployRefused(`${SMOKE_PRODUCT.handle} has no variant to buy`);
147
+ const cart = await req("POST", "/store/carts", shop, {
148
+ currency_code: SMOKE_PRODUCT.currency,
149
+ email: smokeEmail(target.url),
150
+ });
151
+ if (!cart.ok) throw refused("creating the cart", cart);
152
+ const id = String(cart.body.id);
153
+ const token = String(cart.body.token);
154
+ const withCart = { ...shop, "x-cart-token": token };
155
+ const line = await req("POST", `/store/carts/${id}/lines`, withCart, { variant_id: variant, quantity: 1 });
156
+ if (!line.ok) throw refused("adding the line", line);
157
+ const total = (line.body.totals as { total?: { amount?: number } } | null)?.total?.amount;
158
+ if (typeof total !== "number") throw new DeployRefused("the cart answered no total to complete it with");
159
+ // Moment 1: the exact completion, before it's sent. It's what every rerun sends.
160
+ set({ cart_id: id, cart_token: token, completion: JSON.stringify({ expected_total: total }) });
161
+ r = get() as Run;
162
+ }
163
+ // Moment 2: the recorded body, unchanged, with the same token; a completed cart answers the same order.
164
+ const done = await req(
165
+ "POST",
166
+ `/store/carts/${r.cart_id}/complete`,
167
+ { ...shop, "x-cart-token": r.cart_token as string },
168
+ JSON.parse(r.completion as string),
169
+ );
170
+ const code = (done.body.error as { code?: string } | undefined)?.code;
171
+ if (code === "CART_TOTAL_CHANGED") {
172
+ // That refusal creates no order: this run ends, and another prices afresh.
173
+ set({ abandoned: "the cart's price changed before it completed (CART_TOTAL_CHANGED)" });
174
+ throw new DeployRefused(
175
+ `smoke run ${name}: the price changed before completion, so nothing was ordered. Start another with --run <new name>`,
176
+ );
177
+ }
178
+ if (!done.ok || typeof done.body.id !== "string" || typeof done.body.token !== "string")
179
+ throw refused("completing the cart", done);
180
+ // Moment 3: the order's id and token, together.
181
+ set({ order_id: done.body.id, order_token: done.body.token });
182
+ r = get() as Run;
183
+ }
184
+
185
+ // Placement stands in for payment in Phase 0 (D-035); placing an open order again changes nothing.
186
+ const orderId = r.order_id as string;
187
+ await withCliSession(target, ["order:place"], d.session, async (auth) => {
188
+ const placed = await req("POST", `/admin/orders/${orderId}/place`, auth, {});
189
+ if (!placed.ok) throw refused("placing the order", placed);
190
+ });
191
+ const read = await req("GET", `/store/orders/${orderId}`, { ...shop, "x-order-token": r.order_token as string });
192
+ if (!read.ok) throw refused("reading the order back", read);
193
+ const status = String(read.body.status);
194
+ // The API answers the number as an integer (research/29's first run): kept as text, as shown.
195
+ const raw = read.body.number;
196
+ const number = typeof raw === "number" || (typeof raw === "string" && raw) ? String(raw) : null;
197
+ if (status !== "open" || !number)
198
+ throw new DeployRefused(
199
+ `the order is ${status}${number ? ` (${number})` : ", with no number"}, so it isn't placed`,
200
+ );
201
+ set({ number });
202
+ d.log(`order ${number} is open (smoke run ${name})`);
203
+ return { run: name, order_id: orderId, number, status };
204
+ }
205
+
206
+ /** `smoke@<the store's host>`, which names it as a test in the admin; a bare host gets `.test` to be an address. */
207
+ export function smokeEmail(url: string): string {
208
+ const host = new URL(url).hostname;
209
+ return `smoke@${host.includes(".") ? host : `${host}.test`}`;
210
+ }
211
+
212
+ /** Whether `url` is a store a smoke run writes to only by someone's choice (§6.3). */
213
+ export function needsLive(target: StoreTarget, live: boolean): void {
214
+ if (target.kind === "production" && !live)
215
+ throw new NeedsPerson(
216
+ `${target.url} is the live store: a test order there is its owner's call, and stays as its first order until P1's order actions can cancel it. With their yes, run this again with --live`,
217
+ );
218
+ }
@@ -0,0 +1,333 @@
1
+ // Verifies plugin SQL against the real schema with SQLite's own authorizer.
2
+ // The token rules (plugin-sql-rules.ts) guess which tables a statement
3
+ // touches; SQLite knows. Five codex rounds found syntax the guesses missed
4
+ // (quoted names, schema qualifiers, parenthesised tables, CTEs, renames), so
5
+ // at build time `kedge generate` compiles every statement a plugin will run
6
+ // against core's and the plugins' migrations in an in-memory database, and
7
+ // checks each table SQLite reports reading or writing, through views and
8
+ // triggers included. Nothing is executed except migrations. The token rules
9
+ // still run at runtime, where Workers has no SQLite authorizer.
10
+ import { DatabaseSync } from "node:sqlite";
11
+ import {
12
+ acquireSql,
13
+ BREAK,
14
+ createEntitySql,
15
+ entityTriggersSql,
16
+ type FactTable,
17
+ factGuardSql,
18
+ factTables,
19
+ type LintIssue,
20
+ PERMANENT_IDENTITIES,
21
+ type PluginDefinition,
22
+ splitStatements,
23
+ statementTexts,
24
+ type Token,
25
+ tokenize,
26
+ } from "@kedge/core";
27
+
28
+ // SQLite authorizer action codes (sqlite.org/c3ref/c_alter_table.html).
29
+ const A = {
30
+ CREATE_INDEX: 1,
31
+ CREATE_TABLE: 2,
32
+ CREATE_TRIGGER: 7,
33
+ CREATE_VIEW: 8,
34
+ DELETE: 9,
35
+ DROP_INDEX: 10,
36
+ DROP_TABLE: 11,
37
+ DROP_TRIGGER: 16,
38
+ DROP_VIEW: 17,
39
+ INSERT: 18,
40
+ PRAGMA: 19,
41
+ READ: 20,
42
+ SELECT: 21,
43
+ UPDATE: 23,
44
+ ALTER_TABLE: 26,
45
+ REINDEX: 27,
46
+ ANALYZE: 28,
47
+ FUNCTION: 31,
48
+ RECURSIVE: 33,
49
+ } as const;
50
+
51
+ type Access = [action: number, a: string | null, b: string | null, db: string | null, source: string | null];
52
+
53
+ const SYSTEM = /^sqlite_(master|schema|sequence|temp_master|temp_schema|stat\d)$/;
54
+
55
+ /**
56
+ * Judges one statement's accesses. `own` is the owner's namespace. A source
57
+ * (the view or trigger an access happens inside) that isn't a `plg_` object
58
+ * is core's: a read view, a core trigger, or a guard core generated, fired by
59
+ * the plugin's write. Plugin triggers and views must be `plg_<name>_…`, so a
60
+ * plugin can't pass its own SQL off as core's.
61
+ */
62
+ function judge(
63
+ accesses: readonly Access[],
64
+ own: (name: string) => boolean,
65
+ ddl: boolean,
66
+ add: (rule: string, message: string) => void,
67
+ ): void {
68
+ for (const [action, a, b, , source] of accesses) {
69
+ const coreSource = source !== null && !source.startsWith("plg_");
70
+ switch (action) {
71
+ case A.SELECT:
72
+ case A.FUNCTION:
73
+ case A.RECURSIVE:
74
+ break;
75
+ case A.PRAGMA:
76
+ // SQLite runs these itself to validate an ALTER TABLE ADD COLUMN; a plugin can't write a PRAGMA (token rules).
77
+ if (
78
+ ddl &&
79
+ ["quick_check", "integrity_check", "table_info", "table_xinfo", "foreign_key_list"].includes(a ?? "")
80
+ )
81
+ break;
82
+ add("forbidden-statement", `PRAGMA ${a} isn't allowed in plugin SQL`);
83
+ break;
84
+ case A.READ:
85
+ if (ddl && (a ?? "").startsWith("pragma_")) break; // the same internal validation
86
+ // Permanent identities are read by the foreign keys a plugin row may hold to them.
87
+ if (!a || own(a) || coreSource || /^kedge_v\d+_/.test(a) || SYSTEM.test(a) || PERMANENT_IDENTITIES.has(a))
88
+ break;
89
+ add("core-table", `reads "${a}"${source ? ` (in ${source})` : ""}; read core only through kedge_vN_ views`);
90
+ break;
91
+ case A.INSERT:
92
+ case A.UPDATE:
93
+ case A.DELETE:
94
+ if (!a || own(a) || coreSource || (ddl && SYSTEM.test(a)) || a === "sqlite_sequence") break;
95
+ add("foreign-write", `writes "${a}"${source ? ` (in ${source})` : ""}; plugin SQL writes only its own tables`);
96
+ break;
97
+ case A.CREATE_TABLE:
98
+ case A.CREATE_VIEW:
99
+ case A.DROP_TABLE:
100
+ case A.DROP_VIEW:
101
+ case A.ALTER_TABLE:
102
+ if (!ddl) add("ddl-outside-migration", "DDL belongs in a migration");
103
+ // ALTER reports (db, table); the others (name, null).
104
+ if (!own((action === A.ALTER_TABLE ? b : a) ?? ""))
105
+ add("prefix", `"${action === A.ALTER_TABLE ? b : a}" isn't the owner's`);
106
+ break;
107
+ case A.CREATE_INDEX:
108
+ case A.CREATE_TRIGGER:
109
+ case A.DROP_INDEX:
110
+ case A.DROP_TRIGGER:
111
+ if (!ddl) add("ddl-outside-migration", "DDL belongs in a migration");
112
+ // (name, table): both the owner's. SQLite names a UNIQUE's own index sqlite_autoindex_<table>_n.
113
+ if (!(own(a ?? "") || (a ?? "").startsWith(`sqlite_autoindex_${b}_`)) || !own(b ?? ""))
114
+ add("core-ddl", `"${a}" on "${b}" isn't the owner's object on the owner's table`);
115
+ break;
116
+ case A.REINDEX:
117
+ case A.ANALYZE:
118
+ if (!ddl) add("ddl-outside-migration", "DDL belongs in a migration");
119
+ break;
120
+ default:
121
+ add("forbidden-statement", `SQLite action ${action} isn't allowed in plugin SQL`);
122
+ }
123
+ }
124
+ }
125
+
126
+ /** A trigger body's statements, made compilable on their own: NEW./OLD. columns and RAISE(...) become NULL. */
127
+ function triggerBodies(sql: string): string[] {
128
+ const out: string[] = [];
129
+ for (const st of splitStatements(tokenize(sql))) {
130
+ const words = st.filter((t) => t.kind === "word").map((t) => (t as { upper: string }).upper);
131
+ if (words[0] !== "CREATE" || !words.slice(0, 4).includes("TRIGGER")) continue;
132
+ const begin = st.findIndex((t) => t.kind === "word" && (t as { upper: string }).upper === "BEGIN");
133
+ const body = st.slice(begin + 1, -1); // up to the final END
134
+ out.push(...bodyStatements(sql, body));
135
+ }
136
+ return out;
137
+ }
138
+
139
+ /**
140
+ * Rebuilds a trigger body's statements from the source text, keeping what
141
+ * sits between tokens (so `!=` stays one operator), with NEW./OLD. columns
142
+ * and RAISE(...) replaced by NULL so each compiles on its own.
143
+ */
144
+ function bodyStatements(sql: string, body: readonly Token[]): string[] {
145
+ const stmts: string[] = [];
146
+ let cur = "";
147
+ let last = body[0]?.start ?? 0;
148
+ const emit = (text: string, start: number, end: number) => {
149
+ cur += sql.slice(last, start) + text;
150
+ last = end;
151
+ };
152
+ for (let i = 0; i < body.length; i++) {
153
+ const t = body[i] as Token;
154
+ const up = t.kind === "word" ? (t as { upper: string }).upper : "";
155
+ if ((up === "NEW" || up === "OLD") && body[i + 1]?.text === "." && body[i + 2]) {
156
+ emit("NULL", t.start, (body[i + 2] as Token).end);
157
+ i += 2;
158
+ continue;
159
+ }
160
+ if (up === "RAISE" && body[i + 1]?.text === "(") {
161
+ let depth = 0;
162
+ let j = i + 1;
163
+ for (; j < body.length; j++) {
164
+ if (body[j]?.text === "(") depth++;
165
+ if (body[j]?.text === ")" && --depth === 0) break;
166
+ }
167
+ emit("NULL", t.start, (body[Math.min(j, body.length - 1)] as Token).end);
168
+ i = j;
169
+ continue;
170
+ }
171
+ if (t.text === ";") {
172
+ cur += sql.slice(last, t.start);
173
+ if (cur.trim()) stmts.push(cur.trim());
174
+ cur = "";
175
+ last = t.end;
176
+ continue;
177
+ }
178
+ emit(sql.slice(t.start, t.end), t.start, t.end);
179
+ }
180
+ if (cur.trim()) stmts.push(cur.trim());
181
+ return stmts;
182
+ }
183
+
184
+ export interface VerifyInput {
185
+ /** Core's migration files, in order. */
186
+ coreSql: readonly string[];
187
+ plugins: readonly {
188
+ // biome-ignore lint/suspicious/noExplicitAny: each plugin brings its own options type
189
+ definition: PluginDefinition<any>;
190
+ /** The SQL the plugin builds at runtime, from the linter's sample inputs. */
191
+ runtime: readonly { where: string; sql: string; context?: string }[];
192
+ historicalFacts?: readonly FactTable[];
193
+ }[];
194
+ }
195
+
196
+ /** Compiles every plugin statement against the real schema and judges what SQLite says it touches. */
197
+ export function verifyWithSqlite(input: VerifyInput): (LintIssue & { plugin: string })[] {
198
+ const db = new DatabaseSync(":memory:");
199
+ const issues: (LintIssue & { plugin: string })[] = [];
200
+ // One transaction per migration, as D1 applies one: a migration's marker
201
+ // (p0.5 §7.1) is a deferred obligation checked at its commit, not per statement.
202
+ const run = (text: string) => {
203
+ db.exec("BEGIN");
204
+ for (const part of text.split(BREAK)) for (const s of statementTexts(part)) db.exec(`${s};`);
205
+ db.exec("COMMIT");
206
+ };
207
+ for (const text of input.coreSql) run(text);
208
+ // Generated entity tables are core's SQL, installed as is, so plugin SQL
209
+ // that reads one compiles (codex code r2). Fields are never removed (§4.3),
210
+ // so the current declaration's table has every column history gave it.
211
+ // Their triggers too, so SQLite compiles every generated one (codex code r7).
212
+ for (const p of input.plugins)
213
+ for (const [entity, def] of Object.entries(p.definition.entities ?? {}))
214
+ run(
215
+ [
216
+ ...createEntitySql(p.definition.name, entity, def),
217
+ ...entityTriggersSql(p.definition.name, entity, def, ""),
218
+ ].join(BREAK),
219
+ );
220
+
221
+ let accesses: Access[] = [];
222
+ const reportedKeys = new Set<string>();
223
+ const record = (action: number, a: string | null, b: string | null, dbName: string | null, source: string | null) => {
224
+ accesses.push([action, a ?? null, b ?? null, dbName ?? null, source ?? null]);
225
+ return 0;
226
+ };
227
+ db.setAuthorizer(record);
228
+ const names = () =>
229
+ new Set(
230
+ (db.prepare("SELECT name, tbl_name FROM sqlite_master").all() as { name: string; tbl_name: string }[]).map(
231
+ (r) => `${r.name}\u0000${r.tbl_name}`,
232
+ ),
233
+ );
234
+ let facts = new Set<string>();
235
+ const check = (plugin: string, where: string, text: string, ddl: boolean, execute: boolean) => {
236
+ const own = (name: string) => name.startsWith(`plg_${plugin}_`);
237
+ accesses = [];
238
+ try {
239
+ if (execute) {
240
+ // The authorizer doesn't report where a rename lands, so compare the
241
+ // schema: every object a migration statement leaves behind is the owner's.
242
+ db.setAuthorizer(null);
243
+ const before = names();
244
+ db.setAuthorizer(record);
245
+ db.exec(`${text};`);
246
+ db.setAuthorizer(null);
247
+ for (const n of names()) {
248
+ if (before.has(n)) continue;
249
+ const [name = "", table = ""] = n.split("\u0000");
250
+ if (!(own(name) || name.startsWith(`sqlite_autoindex_${table}_`)) || !own(table))
251
+ issues.push({
252
+ plugin,
253
+ rule: "prefix",
254
+ message: `leaves "${name}" on "${table}" outside ${`plg_${plugin}_`}…`,
255
+ at: where,
256
+ });
257
+ }
258
+ // Every foreign key from the owner's tables to core, however it was
259
+ // declared (CREATE TABLE or ADD COLUMN), is NO ACTION: core rebuilds its
260
+ // tables, and SQLite fires CASCADE and SET NULL on that drop.
261
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as { name: string }[];
262
+ for (const { name } of tables.filter((t) => own(t.name))) {
263
+ const fks = db.prepare(`SELECT "table", on_update, on_delete FROM pragma_foreign_key_list(?)`).all(name) as {
264
+ table: string;
265
+ on_update: string;
266
+ on_delete: string;
267
+ }[];
268
+ for (const fk of fks) {
269
+ if (own(fk.table)) continue;
270
+ const key = `${plugin}:${name}:${fk.table}`;
271
+ if ((fk.on_update !== "NO ACTION" || fk.on_delete !== "NO ACTION") && !reportedKeys.has(key)) {
272
+ reportedKeys.add(key);
273
+ issues.push({
274
+ plugin,
275
+ rule: "core-fk-action",
276
+ message: `${name} → ${fk.table} must be NO ACTION`,
277
+ at: where,
278
+ });
279
+ }
280
+ }
281
+ }
282
+ db.setAuthorizer(record);
283
+ } else db.prepare(text);
284
+ } catch (e) {
285
+ issues.push({
286
+ plugin,
287
+ rule: "invalid-sql",
288
+ message: `doesn't compile against the schema: ${(e as Error).message}`,
289
+ at: where,
290
+ });
291
+ return;
292
+ }
293
+ judge(accesses, own, ddl, (rule, message) =>
294
+ issues.push({ plugin, rule, message, at: `${where}: ${text.slice(0, 60)}` }),
295
+ );
296
+ // A fact table, current or once declared, holds facts that back money and
297
+ // pending releases. SQLite runs no DELETE trigger on DROP TABLE, so a
298
+ // rebuild that drops or renames one could lose them unseen; until core has
299
+ // a controlled way to move facts, it is refused.
300
+ for (const [action, a, b] of accesses) {
301
+ // ALTER TABLE is one action for ADD COLUMN and RENAME alike; only a rename moves the table.
302
+ const renames = /^\s*ALTER\s+TABLE\b[\s\S]*\bRENAME\b/i.test(text);
303
+ const table = action === A.DROP_TABLE ? a : action === A.ALTER_TABLE && renames ? b : null;
304
+ if (table && facts.has(table))
305
+ issues.push({
306
+ plugin,
307
+ rule: "fact-table-change",
308
+ message: `"${table}" holds plugin facts; it can't be dropped or renamed`,
309
+ at: where,
310
+ });
311
+ }
312
+ };
313
+
314
+ for (const p of input.plugins) {
315
+ const def = p.definition;
316
+ facts = new Set(factTables(def, p.historicalFacts ?? []).map((f) => f.table));
317
+ for (const m of def.migrations ?? []) {
318
+ for (const s of statementTexts(m.sql)) check(def.name, m.id, s, true, true);
319
+ for (const s of triggerBodies(m.sql)) check(def.name, `${m.id} trigger body`, s, false, false);
320
+ }
321
+ // Core-owned SQL is installed as is, then the plugin's runtime SQL is compiled against all of it.
322
+ db.setAuthorizer(null);
323
+ run([...factGuardSql(def, p.historicalFacts ?? []), ...acquireSql(def)].join(BREAK));
324
+ db.setAuthorizer(record);
325
+ for (const [key, body] of Object.entries(def.acquire ?? {})) {
326
+ for (const s of bodyStatements(body as string, tokenize(body as string)))
327
+ check(def.name, `acquire ${key}`, s, false, false);
328
+ }
329
+ // Acquire bodies were checked above, with NEW. columns made compilable.
330
+ for (const r of p.runtime) if (r.context !== "acquire") check(def.name, r.where, r.sql, false, false);
331
+ }
332
+ return issues;
333
+ }
@@ -0,0 +1,36 @@
1
+ // Requests to a store's own API, as the CLI makes them (p0.10 §5): a write
2
+ // with no answer, or one that can't be read, is an unknown outcome.
3
+ import { UnknownOutcome } from "./cf";
4
+ import { DeployRefused } from "./deploy";
5
+
6
+ /**
7
+ * One request to a store's API. A write sent with no answer back, or with an
8
+ * answer that can't be read, may have committed: its outcome is unknown, never
9
+ * a failure (p0.10 §5, codex code r8), and says so under `--json`. A readable
10
+ * refusal is the caller's to word.
11
+ */
12
+ export async function storeRequest(
13
+ f: typeof fetch,
14
+ url: URL,
15
+ init: RequestInit,
16
+ ): Promise<{ status: number; ok: boolean; body: Record<string, unknown> }> {
17
+ const what = `${init.method ?? "GET"} ${url.pathname}`;
18
+ const write = (init.method ?? "GET") !== "GET";
19
+ let r: Response;
20
+ try {
21
+ r = await f(url, init);
22
+ } catch {
23
+ if (write) throw new UnknownOutcome(what, "the store");
24
+ throw new DeployRefused(`${what}: the store didn't answer`);
25
+ }
26
+ let body: Record<string, unknown>;
27
+ try {
28
+ body = (await r.json()) as Record<string, unknown>;
29
+ } catch {
30
+ // A refusal Kedge words is JSON; an unreadable success, or a 5xx from in front of the Worker, isn't proof of either.
31
+ if (write && (r.ok || r.status >= 500))
32
+ throw new UnknownOutcome(`${what} (answered ${r.status}, unreadable)`, "the store");
33
+ throw new DeployRefused(`${what} answered ${r.status}, not JSON`);
34
+ }
35
+ return { status: r.status, ok: r.ok, body };
36
+ }