@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/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
|
+
}
|
|
@@ -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
|
+
}
|
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
|
+
}
|