sproutboat 0.53.1 → 0.55.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/CHANGELOG.md +69 -1
- package/README.md +122 -9
- package/SURFACE.md +42 -22
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +100 -0
- package/src/build.ts +13 -2
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +794 -0
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +719 -0
- package/src/cloud-secrets.ts +121 -0
- package/src/cloud.ts +375 -0
- package/src/compile.ts +34 -18
- package/src/credentials.ts +455 -52
- package/src/d1-cli.ts +455 -0
- package/src/data-errors.ts +139 -0
- package/src/deployments-cli.ts +184 -0
- package/src/dev.ts +67 -4
- package/src/fake-login.ts +28 -0
- package/src/global-flags.ts +113 -0
- package/src/init-package.ts +2 -1
- package/src/init.ts +328 -0
- package/src/main.ts +1247 -193
- package/src/options.ts +242 -0
- package/src/r2-objects.ts +365 -0
- package/src/run.ts +12 -6
- package/src/secrets-bulk.ts +51 -0
- package/src/sql-split.ts +68 -0
- package/src/standalone-build.ts +21 -3
- package/src/surface.ts +192 -64
- package/src/target.ts +95 -0
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
package/src/d1-cli.ts
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat d1 execute` and `d1 migrations create|list|apply`, with Wrangler's semantics: the target is the local
|
|
3
|
+
* database `dev` uses unless --remote is given, a migration is a numbered .sql file in `migrations/` (or `migrations_dir`),
|
|
4
|
+
* and applied ones are recorded in a `d1_migrations` table, each migration and its tracking row in one transaction.
|
|
5
|
+
*
|
|
6
|
+
* Both targets run the same `Runner`: the local one drives bun:sqlite on the dev database file, the remote one posts
|
|
7
|
+
* to the Cloud's `POST /v1/apps/<app>/d1/<BINDING>/query` (docs/host-data.md in the cloud repo).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import type { SproutboatConfig } from "./config";
|
|
12
|
+
import { CloudError } from "./cloud";
|
|
13
|
+
import { isBoolean, isSafeInteger, isString, jsonObject, type JsonObject, type JsonValue } from "./json";
|
|
14
|
+
import { dataErrorMessage } from "./data-errors";
|
|
15
|
+
import { splitSql } from "./sql-split";
|
|
16
|
+
import { bold, dim, ok, rose } from "./style";
|
|
17
|
+
|
|
18
|
+
export type Statement = { sql: string; params?: (string | number | boolean | null)[] };
|
|
19
|
+
export type StatementResult = { results: JsonObject[]; success: boolean; meta: JsonObject };
|
|
20
|
+
|
|
21
|
+
/** Runs statements in one transaction: all of them take effect, or none. A `sql` may be a whole script. */
|
|
22
|
+
export type Runner = { label: string; run(statements: Statement[]): Promise<StatementResult[]> };
|
|
23
|
+
|
|
24
|
+
/** A statement the database refused; `statement` is the index of the entry that failed. */
|
|
25
|
+
export class D1QueryError extends CloudError {
|
|
26
|
+
constructor(
|
|
27
|
+
message: string,
|
|
28
|
+
readonly statement?: number,
|
|
29
|
+
) {
|
|
30
|
+
super(message);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const D1_SQL_MAX_BYTES = 4 * 1024 * 1024;
|
|
35
|
+
export const MIGRATIONS_TABLE = "d1_migrations";
|
|
36
|
+
export const DEFAULT_MIGRATIONS_DIR = "migrations";
|
|
37
|
+
const CREATE_MIGRATIONS_TABLE = `CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
38
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
39
|
+
name TEXT UNIQUE,
|
|
40
|
+
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
41
|
+
)`;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- arguments
|
|
44
|
+
|
|
45
|
+
export type D1Flags = {
|
|
46
|
+
command?: string;
|
|
47
|
+
file?: string;
|
|
48
|
+
json: boolean;
|
|
49
|
+
local: boolean;
|
|
50
|
+
remote: boolean;
|
|
51
|
+
persistTo?: string;
|
|
52
|
+
yes: boolean;
|
|
53
|
+
account?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export class D1UsageError extends Error {
|
|
57
|
+
constructor(
|
|
58
|
+
message: string,
|
|
59
|
+
readonly usage: string,
|
|
60
|
+
) {
|
|
61
|
+
super(message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Split the arguments after `d1 <sub>` into positionals and flags (`--flag value` and `--flag=value`). */
|
|
66
|
+
export function parseD1Flags(args: readonly string[], usage: string) {
|
|
67
|
+
const flags: D1Flags = { json: false, local: false, remote: false, yes: false };
|
|
68
|
+
const positional: string[] = [];
|
|
69
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
70
|
+
const arg = args[index]!;
|
|
71
|
+
const equals = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
72
|
+
const name = equals > 0 ? arg.slice(0, equals) : arg;
|
|
73
|
+
const inline = equals > 0 ? arg.slice(equals + 1) : undefined;
|
|
74
|
+
const noValue = () => {
|
|
75
|
+
if (inline !== undefined) throw new D1UsageError(`${name} takes no value`, usage);
|
|
76
|
+
};
|
|
77
|
+
const value = () => {
|
|
78
|
+
const next = inline ?? args[(index += 1)];
|
|
79
|
+
if (next === undefined || (inline === undefined && next.startsWith("--")))
|
|
80
|
+
throw new D1UsageError(`${name} needs a value`, usage);
|
|
81
|
+
return next;
|
|
82
|
+
};
|
|
83
|
+
switch (name) {
|
|
84
|
+
case "--json":
|
|
85
|
+
noValue();
|
|
86
|
+
flags.json = true;
|
|
87
|
+
break;
|
|
88
|
+
case "--local":
|
|
89
|
+
noValue();
|
|
90
|
+
flags.local = true;
|
|
91
|
+
break;
|
|
92
|
+
case "--remote":
|
|
93
|
+
noValue();
|
|
94
|
+
flags.remote = true;
|
|
95
|
+
break;
|
|
96
|
+
case "--yes":
|
|
97
|
+
case "-y":
|
|
98
|
+
noValue();
|
|
99
|
+
flags.yes = true;
|
|
100
|
+
break;
|
|
101
|
+
case "--command":
|
|
102
|
+
flags.command = value();
|
|
103
|
+
break;
|
|
104
|
+
case "--file":
|
|
105
|
+
flags.file = value();
|
|
106
|
+
break;
|
|
107
|
+
case "--persist-to":
|
|
108
|
+
flags.persistTo = value();
|
|
109
|
+
break;
|
|
110
|
+
case "--account":
|
|
111
|
+
flags.account = value();
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
if (arg.startsWith("-")) throw new D1UsageError(`unknown option ${name}`, usage);
|
|
115
|
+
positional.push(arg);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (flags.local && flags.remote) throw new D1UsageError("pass --local or --remote, not both", usage);
|
|
119
|
+
if (flags.persistTo !== undefined && flags.remote)
|
|
120
|
+
throw new D1UsageError("--persist-to is for the local database; drop --remote", usage);
|
|
121
|
+
return { positional, flags };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------- the project's databases
|
|
125
|
+
|
|
126
|
+
export type D1Binding = { binding: string; resourceId?: string };
|
|
127
|
+
|
|
128
|
+
/** The D1 bindings of sproutboat.jsonc: a bare "DB" or { binding, id }. */
|
|
129
|
+
export function d1Bindings(config: SproutboatConfig): D1Binding[] {
|
|
130
|
+
return (config.d1_databases ?? []).map((entry) =>
|
|
131
|
+
isString(entry) ? { binding: entry } : { binding: entry.binding, resourceId: entry.id },
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `<database>` is the binding name; a project without that binding says which it has. */
|
|
136
|
+
export function pickBinding(config: SproutboatConfig, database: string): D1Binding {
|
|
137
|
+
const all = d1Bindings(config);
|
|
138
|
+
const found = all.find((entry) => entry.binding === database);
|
|
139
|
+
if (found) return found;
|
|
140
|
+
throw new CloudError(
|
|
141
|
+
all.length === 0
|
|
142
|
+
? `sproutboat.jsonc has no d1_databases, so there is no database "${database}" (add "d1_databases": ["${database}"])`
|
|
143
|
+
: `no D1 binding "${database}" in sproutboat.jsonc (it has: ${all.map((entry) => entry.binding).join(", ")})`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Where `dev` keeps a binding's database: `<state>/d1/<BINDING>.sqlite`, or `<state>/resources/<id>.sqlite` for a pinned one. */
|
|
148
|
+
export function localDatabasePath(projectDir: string, binding: D1Binding, persistTo?: string): string {
|
|
149
|
+
const state = resolve(projectDir, persistTo ?? ".sproutboat/dev");
|
|
150
|
+
return binding.resourceId
|
|
151
|
+
? resolve(state, "resources", `${binding.resourceId}.sqlite`)
|
|
152
|
+
: resolve(state, "d1", `${binding.binding}.sqlite`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------- runners
|
|
156
|
+
|
|
157
|
+
/** The dev database file, through bun:sqlite. Created when absent, as Wrangler's local mode does. */
|
|
158
|
+
export function localRunner(path: string): Runner {
|
|
159
|
+
return {
|
|
160
|
+
label: `local database ${path}`,
|
|
161
|
+
async run(statements) {
|
|
162
|
+
const { Database } = await import("bun:sqlite");
|
|
163
|
+
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
164
|
+
const db = new Database(path, { create: true });
|
|
165
|
+
const results: StatementResult[] = [];
|
|
166
|
+
let index = 0;
|
|
167
|
+
try {
|
|
168
|
+
db.run("PRAGMA busy_timeout = 5000");
|
|
169
|
+
db.run("BEGIN IMMEDIATE");
|
|
170
|
+
for (const [at, entry] of statements.entries()) {
|
|
171
|
+
index = at;
|
|
172
|
+
const parts = entry.params === undefined ? splitSql(entry.sql) : [entry.sql];
|
|
173
|
+
for (const sql of parts) {
|
|
174
|
+
const started = performance.now();
|
|
175
|
+
const query = db.query(sql);
|
|
176
|
+
const bound = (entry.params ?? []).map((param) => (isBoolean(param) ? Number(param) : param));
|
|
177
|
+
// SAFETY: bun:sqlite returns each row as a plain object keyed by column name, holding SQLite values (numbers, strings, null).
|
|
178
|
+
const rows = query.columnNames.length > 0 ? (query.all(...bound) as JsonObject[]) : [];
|
|
179
|
+
const info = query.columnNames.length > 0 ? { changes: 0, lastInsertRowid: 0 } : query.run(...bound);
|
|
180
|
+
results.push({
|
|
181
|
+
results: rows,
|
|
182
|
+
success: true,
|
|
183
|
+
meta: {
|
|
184
|
+
duration: Math.round((performance.now() - started) * 100) / 100,
|
|
185
|
+
changes: info.changes,
|
|
186
|
+
last_row_id: Number(info.lastInsertRowid),
|
|
187
|
+
rows_read: rows.length,
|
|
188
|
+
rows_written: info.changes,
|
|
189
|
+
changed_db: info.changes > 0,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
db.run("COMMIT");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
try {
|
|
197
|
+
db.run("ROLLBACK");
|
|
198
|
+
} catch {
|
|
199
|
+
// no transaction left to roll back
|
|
200
|
+
}
|
|
201
|
+
throw new D1QueryError(error instanceof Error ? error.message : String(error), index);
|
|
202
|
+
} finally {
|
|
203
|
+
db.close();
|
|
204
|
+
}
|
|
205
|
+
return results;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type Reply = { status: number; body?: JsonObject; headers?: Headers };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A remote database through `send`, which posts the request body and returns the status and JSON. `endpoint` names it in
|
|
214
|
+
* messages. A Community control plane has no such route: its 404 becomes "not supported on self-hosted Community yet".
|
|
215
|
+
*/
|
|
216
|
+
export function remoteRunner(options: {
|
|
217
|
+
label: string;
|
|
218
|
+
project: string;
|
|
219
|
+
binding: string;
|
|
220
|
+
community: boolean;
|
|
221
|
+
send: (body: { statements: Statement[]; transaction: true }) => Promise<Reply>;
|
|
222
|
+
}): Runner {
|
|
223
|
+
const { project, binding } = options;
|
|
224
|
+
return {
|
|
225
|
+
label: options.label,
|
|
226
|
+
async run(statements) {
|
|
227
|
+
const reply = await options.send({ statements, transaction: true });
|
|
228
|
+
const body = reply.body;
|
|
229
|
+
const message = isString(body?.error) ? body.error : `HTTP ${reply.status}`;
|
|
230
|
+
if (reply.status === 404) {
|
|
231
|
+
if (options.community)
|
|
232
|
+
throw new CloudError(
|
|
233
|
+
"remote D1 queries are not supported on self-hosted Community yet (this endpoint has no D1 route); use --local, or a Sproutboat Cloud endpoint",
|
|
234
|
+
);
|
|
235
|
+
if (body?.reason === "no-database")
|
|
236
|
+
throw new CloudError(
|
|
237
|
+
`${project} has no D1 database "${binding}" on its host yet; a database exists once the deployed app has used it`,
|
|
238
|
+
);
|
|
239
|
+
throw new CloudError(
|
|
240
|
+
`${project} is not deployed yet, so it has no database: run \`sproutboat deploy\` first, or use --local`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (reply.status === 400 && body?.reason === "sql-error")
|
|
244
|
+
throw new D1QueryError(message, isSafeInteger(body.statement) ? body.statement : undefined);
|
|
245
|
+
if (reply.status !== 200) {
|
|
246
|
+
const explained = dataErrorMessage(reply, {
|
|
247
|
+
project,
|
|
248
|
+
product: "D1",
|
|
249
|
+
noun: "database",
|
|
250
|
+
name: binding,
|
|
251
|
+
write: true,
|
|
252
|
+
});
|
|
253
|
+
throw new CloudError(explained ?? message);
|
|
254
|
+
}
|
|
255
|
+
const list = Array.isArray(body?.results) ? body.results : [];
|
|
256
|
+
return list.map((entry) => {
|
|
257
|
+
const item = jsonObject(entry);
|
|
258
|
+
return {
|
|
259
|
+
results: Array.isArray(item?.results)
|
|
260
|
+
? item.results.flatMap((row) => {
|
|
261
|
+
const parsed = jsonObject(row);
|
|
262
|
+
return parsed ? [parsed] : [];
|
|
263
|
+
})
|
|
264
|
+
: [],
|
|
265
|
+
success: item?.success !== false,
|
|
266
|
+
meta: jsonObject(item?.meta ?? null) ?? {},
|
|
267
|
+
};
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------- output
|
|
274
|
+
|
|
275
|
+
export type D1Io = {
|
|
276
|
+
log(line: string): void;
|
|
277
|
+
/** Progress and notices; stderr when stdout carries JSON. */
|
|
278
|
+
note(line: string): void;
|
|
279
|
+
interactive: boolean;
|
|
280
|
+
confirm(question: string): Promise<boolean>;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
/** A plain table: header, rule, rows. Objects print their JSON; null prints as NULL. */
|
|
284
|
+
export function formatTable(rows: JsonObject[]): string {
|
|
285
|
+
if (rows.length === 0) return dim("(no rows)");
|
|
286
|
+
const columns = [...new Set(rows.flatMap((row) => Object.keys(row)))];
|
|
287
|
+
const cell = (value: JsonValue | undefined) =>
|
|
288
|
+
value === null || value === undefined ? "NULL" : isString(value) ? value : JSON.stringify(value);
|
|
289
|
+
const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => cell(row[column]).length)));
|
|
290
|
+
const line = (values: string[]) =>
|
|
291
|
+
values
|
|
292
|
+
.map((value, at) => value.padEnd(widths[at]!))
|
|
293
|
+
.join(" ")
|
|
294
|
+
.trimEnd();
|
|
295
|
+
return [
|
|
296
|
+
bold(line(columns)),
|
|
297
|
+
dim(widths.map((width) => "─".repeat(width)).join(" ")),
|
|
298
|
+
...rows.map((row) => line(columns.map((column) => cell(row[column])))),
|
|
299
|
+
].join("\n");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------- d1 execute
|
|
305
|
+
|
|
306
|
+
export const EXECUTE_USAGE =
|
|
307
|
+
"d1 execute <database> (--command <sql> | --file <path>) [--json] [--local | --remote] [--persist-to <dir>] [--yes]";
|
|
308
|
+
|
|
309
|
+
export type ExecuteInput = { flags: D1Flags; runner: Runner; remote: boolean; io: D1Io; cwd: string };
|
|
310
|
+
|
|
311
|
+
export async function executeSql(input: ExecuteInput): Promise<StatementResult[]> {
|
|
312
|
+
const { flags, runner, io, remote } = input;
|
|
313
|
+
if ((flags.command === undefined) === (flags.file === undefined))
|
|
314
|
+
throw new D1UsageError("you must provide either --command or --file", EXECUTE_USAGE);
|
|
315
|
+
let sql = flags.command ?? "";
|
|
316
|
+
if (flags.file !== undefined) {
|
|
317
|
+
const path = resolve(input.cwd, flags.file);
|
|
318
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new CloudError(`${flags.file}: no such file`);
|
|
319
|
+
sql = readFileSync(path, "utf8");
|
|
320
|
+
}
|
|
321
|
+
if (splitSql(sql).length === 0)
|
|
322
|
+
throw new CloudError(flags.file !== undefined ? `${flags.file} holds no SQL` : "--command holds no SQL");
|
|
323
|
+
if (remote && Buffer.byteLength(sql) > D1_SQL_MAX_BYTES)
|
|
324
|
+
throw new CloudError(
|
|
325
|
+
`the SQL is ${(Buffer.byteLength(sql) / 1048576).toFixed(1)} MiB; a remote request may carry at most 4 MiB, so split the file`,
|
|
326
|
+
);
|
|
327
|
+
if (remote && flags.file !== undefined && io.interactive && !flags.yes) {
|
|
328
|
+
if (!(await io.confirm(`About to run ${flags.file} on ${runner.label} as one transaction. Ok to proceed?`)))
|
|
329
|
+
throw new CloudError("cancelled");
|
|
330
|
+
}
|
|
331
|
+
const asJson = flags.json || !io.interactive;
|
|
332
|
+
io.note(`${dim("🌀")} Executing on ${runner.label}${remote ? "" : dim(" (pass --remote for the deployed app)")}`);
|
|
333
|
+
const started = performance.now();
|
|
334
|
+
const results = await runner.run([{ sql }]);
|
|
335
|
+
const elapsed = Math.round(performance.now() - started);
|
|
336
|
+
io.note(ok(`${plural(results.length, "command")} executed successfully in ${elapsed} ms`));
|
|
337
|
+
if (asJson) {
|
|
338
|
+
io.log(JSON.stringify(results, null, 2));
|
|
339
|
+
} else {
|
|
340
|
+
for (const result of results) if (result.results.length > 0) io.log(formatTable(result.results));
|
|
341
|
+
}
|
|
342
|
+
return results;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------- migrations
|
|
346
|
+
|
|
347
|
+
export function migrationFiles(dir: string): string[] {
|
|
348
|
+
if (!existsSync(dir)) return [];
|
|
349
|
+
return readdirSync(dir)
|
|
350
|
+
.filter((name) => name.endsWith(".sql"))
|
|
351
|
+
.sort((a, b) => a.localeCompare(b));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The next number: one past the highest `NNNN_` prefix in the folder, four digits (Wrangler starts at 0001). */
|
|
355
|
+
export function nextMigrationNumber(dir: string): string {
|
|
356
|
+
const highest = migrationFiles(dir).reduce((max, name) => {
|
|
357
|
+
const match = /^(\d+)_/.exec(name);
|
|
358
|
+
return match ? Math.max(max, Number(match[1])) : max;
|
|
359
|
+
}, 0);
|
|
360
|
+
return String(highest + 1).padStart(4, "0");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export const CREATE_USAGE = "d1 migrations create <database> <name>";
|
|
364
|
+
export const LIST_USAGE = "d1 migrations list <database> [--local | --remote] [--persist-to <dir>]";
|
|
365
|
+
export const APPLY_USAGE = "d1 migrations apply <database> [--local | --remote] [--persist-to <dir>]";
|
|
366
|
+
|
|
367
|
+
/** Create `NNNN_<name>.sql` in the migrations folder, asking before creating the folder itself. */
|
|
368
|
+
export async function createMigration(dir: string, message: string, io: D1Io, now = new Date()): Promise<string> {
|
|
369
|
+
if (message.trim() === "") throw new D1UsageError("the migration needs a name", CREATE_USAGE);
|
|
370
|
+
if (!existsSync(dir)) {
|
|
371
|
+
if (io.interactive && !(await io.confirm(`No migrations folder found. Ok to create ${dir}?`)))
|
|
372
|
+
throw new CloudError("cancelled");
|
|
373
|
+
mkdirSync(dir, { recursive: true });
|
|
374
|
+
}
|
|
375
|
+
const number = nextMigrationNumber(dir);
|
|
376
|
+
const name = `${number}_${message.trim().replace(/\s+/g, "_")}.sql`;
|
|
377
|
+
const path = resolve(dir, name);
|
|
378
|
+
if (existsSync(path)) throw new CloudError(`${name} already exists`);
|
|
379
|
+
writeFileSync(path, `-- Migration number: ${number} \t ${now.toISOString()}\n`);
|
|
380
|
+
io.log(ok(`Created migration ${bold(name)}`));
|
|
381
|
+
io.log(`${dim("edit it here:")} ${path}`);
|
|
382
|
+
return name;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The names recorded in d1_migrations, in order; none when the table does not exist yet (nothing was ever applied). */
|
|
386
|
+
export async function appliedMigrations(runner: Runner): Promise<string[]> {
|
|
387
|
+
try {
|
|
388
|
+
const [result] = await runner.run([{ sql: `SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY id` }]);
|
|
389
|
+
return (result?.results ?? []).map((row) => String(row.name));
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (error instanceof D1QueryError && /no such table/i.test(error.message)) return [];
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function pendingMigrations(runner: Runner, dir: string): Promise<string[]> {
|
|
397
|
+
const applied = new Set(await appliedMigrations(runner));
|
|
398
|
+
return migrationFiles(dir).filter((name) => !applied.has(name));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const nameTable = (rows: { name: string; status: string }[]) => formatTable(rows);
|
|
402
|
+
|
|
403
|
+
export async function listMigrations(runner: Runner, dir: string, io: D1Io): Promise<string[]> {
|
|
404
|
+
const pending = await pendingMigrations(runner, dir);
|
|
405
|
+
if (pending.length === 0) io.log(ok("No migrations to apply"));
|
|
406
|
+
else {
|
|
407
|
+
io.log(`Migrations to be applied:`);
|
|
408
|
+
io.log(nameTable(pending.map((name) => ({ name, status: "pending" }))));
|
|
409
|
+
}
|
|
410
|
+
return pending;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply the pending migrations in order. Each runs together with its tracking row in one transaction (so a failure leaves
|
|
415
|
+
* the database as it was before that migration, and the earlier ones applied), and the first failure stops the run.
|
|
416
|
+
*/
|
|
417
|
+
export async function applyMigrations(runner: Runner, dir: string, io: D1Io): Promise<string[]> {
|
|
418
|
+
await runner.run([{ sql: CREATE_MIGRATIONS_TABLE }]);
|
|
419
|
+
const pending = await pendingMigrations(runner, dir);
|
|
420
|
+
if (pending.length === 0) {
|
|
421
|
+
io.log(ok("No migrations to apply"));
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
io.log(`Migrations to be applied:`);
|
|
425
|
+
io.log(nameTable(pending.map((name) => ({ name, status: "pending" }))));
|
|
426
|
+
if (
|
|
427
|
+
io.interactive &&
|
|
428
|
+
!(await io.confirm(`About to apply ${plural(pending.length, "migration")} to ${runner.label}. Continue?`))
|
|
429
|
+
)
|
|
430
|
+
throw new CloudError("cancelled");
|
|
431
|
+
const done: string[] = [];
|
|
432
|
+
for (const name of pending) {
|
|
433
|
+
const sql = readFileSync(resolve(dir, name), "utf8");
|
|
434
|
+
if (Buffer.byteLength(sql) > D1_SQL_MAX_BYTES)
|
|
435
|
+
throw new CloudError(`${name} is larger than 4 MiB; split it into smaller migrations`);
|
|
436
|
+
try {
|
|
437
|
+
await runner.run([
|
|
438
|
+
{ sql: `PRAGMA defer_foreign_keys = true;\n${sql}` },
|
|
439
|
+
{ sql: `INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, params: [name] },
|
|
440
|
+
]);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (done.length > 0) io.log(nameTable(done.map((applied) => ({ name: applied, status: "applied" }))));
|
|
443
|
+
io.log(`${rose("✗")} ${name} ${rose("failed and was rolled back")}`);
|
|
444
|
+
throw error instanceof D1QueryError
|
|
445
|
+
? new CloudError(
|
|
446
|
+
`${name}: ${error.message}${done.length ? `\n${plural(done.length, "earlier migration")} stay applied` : ""}`,
|
|
447
|
+
)
|
|
448
|
+
: error;
|
|
449
|
+
}
|
|
450
|
+
done.push(name);
|
|
451
|
+
io.log(`${ok(name)}`);
|
|
452
|
+
}
|
|
453
|
+
io.log(ok(`${plural(done.length, "migration")} applied to ${runner.label}`));
|
|
454
|
+
return done;
|
|
455
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the Cloud's data routes (`d1 execute`, `r2 object`) answer when something goes wrong, in words a person can act on.
|
|
3
|
+
* The contract is the "Errors" table of docs/host-data.md in sproutboat-cloud: bodies are `{error, reason, ...}`, and
|
|
4
|
+
* `reason` (never the text) decides what happened.
|
|
5
|
+
*/
|
|
6
|
+
import { isSafeInteger, isString, type JsonObject } from "./json";
|
|
7
|
+
|
|
8
|
+
/** Every status a data route can answer with besides success, so `makeCall` hands them back instead of throwing. */
|
|
9
|
+
export const DATA_ERROR_STATUSES = [400, 403, 404, 409, 411, 413, 422, 429, 501, 502, 503, 504, 507];
|
|
10
|
+
|
|
11
|
+
/** What the caller was doing, for the messages. */
|
|
12
|
+
export type DataContext = {
|
|
13
|
+
project: string;
|
|
14
|
+
/** "D1" or "R2". */
|
|
15
|
+
product: "D1" | "R2";
|
|
16
|
+
/** "database" or "bucket". */
|
|
17
|
+
noun: "database" | "bucket";
|
|
18
|
+
name: string;
|
|
19
|
+
/** What the action is, for a missing grant: "read data", "change data". */
|
|
20
|
+
write: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type DataReply = { status: number; body?: JsonObject; headers?: Headers };
|
|
24
|
+
|
|
25
|
+
/** Not temporary: the host refuses remote writes to any database with backup capture on. */
|
|
26
|
+
export const CAPTURE_ACTIVE =
|
|
27
|
+
"remote writes are not available for a database with backup capture on; write through your app";
|
|
28
|
+
|
|
29
|
+
const NOT_YET = "remote D1/R2 isn't available on this Cloud host yet";
|
|
30
|
+
|
|
31
|
+
/** Seconds to wait, from the body's `retry_after` or the `retry-after` header. */
|
|
32
|
+
function retryAfter(reply: DataReply): number | undefined {
|
|
33
|
+
const fromBody = reply.body?.retry_after;
|
|
34
|
+
if (isSafeInteger(fromBody) && fromBody > 0) return fromBody;
|
|
35
|
+
const header = Number(reply.headers?.get("retry-after"));
|
|
36
|
+
return Number.isSafeInteger(header) && header > 0 ? header : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const wait = (reply: DataReply) => {
|
|
40
|
+
const seconds = retryAfter(reply);
|
|
41
|
+
return seconds === undefined ? "try again shortly" : `try again in ${seconds} s`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The grant a 403 says is missing, and how to get it. */
|
|
45
|
+
function missingGrant(need: string | undefined, product: DataContext["product"], write: boolean): string {
|
|
46
|
+
const grant = need === "data:read" || need === "data:write" ? need : write ? "data:write" : "data:read";
|
|
47
|
+
const what =
|
|
48
|
+
grant === "data:write"
|
|
49
|
+
? product === "D1"
|
|
50
|
+
? "change data (SQL other than SELECT)"
|
|
51
|
+
: "upload or delete objects"
|
|
52
|
+
: product === "D1"
|
|
53
|
+
? "read data"
|
|
54
|
+
: "download objects";
|
|
55
|
+
const preset = grant === "data:write" ? "Deploy and data" : "Read data";
|
|
56
|
+
return (
|
|
57
|
+
`this key lacks the ${grant} grant, which ${what} needs. Ask a team owner to give it: Settings, API keys, ` +
|
|
58
|
+
`the "${preset}" preset (or an API call that names \`data\`). A key made by \`sproutboat login\` has it for ` +
|
|
59
|
+
`an owner, admin or developer; viewers never do. \`sproutboat whoami\` shows this key's access`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The message for a data-route answer that is not a success, or undefined when the status is not one this knows
|
|
65
|
+
* (the caller then says what it sent). `sql-error` is not handled here: D1 keeps its statement index.
|
|
66
|
+
*/
|
|
67
|
+
export function dataErrorMessage(reply: DataReply, context: DataContext): string | undefined {
|
|
68
|
+
const { status, body } = reply;
|
|
69
|
+
const reason = isString(body?.reason) ? body.reason : undefined;
|
|
70
|
+
const text = isString(body?.error) ? body.error : undefined;
|
|
71
|
+
const { project, product, noun, name } = context;
|
|
72
|
+
switch (status) {
|
|
73
|
+
case 400:
|
|
74
|
+
if (reason === "refused") return `the Cloud refused this SQL${text ? `: ${text}` : ""}`;
|
|
75
|
+
if (reason === "invalid") {
|
|
76
|
+
const path = isString(body?.path) ? body.path : undefined;
|
|
77
|
+
const detail = text ? `: ${text}` : "";
|
|
78
|
+
// The Cloud names the field: the object's "key", or the "database" or "bucket" (the binding name).
|
|
79
|
+
if (path === "key") return `the Cloud rejected the object key${detail}`;
|
|
80
|
+
if (path === "database" || path === "bucket") return `the Cloud rejected the ${path} name "${name}"${detail}`;
|
|
81
|
+
return `the Cloud rejected the request${path ? ` (${path})` : ""}${detail}`;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
case 403:
|
|
85
|
+
if (reason === "forbidden" || body?.need !== undefined)
|
|
86
|
+
return missingGrant(isString(body?.need) ? body.need : undefined, product, context.write);
|
|
87
|
+
return undefined;
|
|
88
|
+
case 404:
|
|
89
|
+
if (reason === "no-database")
|
|
90
|
+
return `${project} has no ${product} ${noun} "${name}" on its host yet; a database exists once the deployed app has used it`;
|
|
91
|
+
if (reason === "no-bucket") return `${project} has no ${product} ${noun} "${name}" on its host yet`;
|
|
92
|
+
if (reason === "no-object") return "The specified key does not exist.";
|
|
93
|
+
return `${project} is not deployed yet, so it has no ${noun}: run \`sproutboat deploy\` first`;
|
|
94
|
+
case 409:
|
|
95
|
+
if (reason === "capture-active") return CAPTURE_ACTIVE;
|
|
96
|
+
if (reason === "bad-database") return `the ${noun} file on the host is not a valid database; ask the operator`;
|
|
97
|
+
return undefined;
|
|
98
|
+
case 411:
|
|
99
|
+
return "the Cloud needs the upload's length up front (content-length, or x-declared-length for a chunked body)";
|
|
100
|
+
case 413:
|
|
101
|
+
if (product === "D1")
|
|
102
|
+
return `${text ?? "too large"} (a request may carry at most 4 MiB of SQL and return at most 8 MiB)`;
|
|
103
|
+
return `${text ?? "too large"} (an object may be at most 64 MiB through this route)`;
|
|
104
|
+
case 422:
|
|
105
|
+
if (reason === "memory-limit") return `the query needs more memory than the host allows; make it smaller`;
|
|
106
|
+
return undefined;
|
|
107
|
+
case 429:
|
|
108
|
+
return `too many data requests for this team; ${wait(reply)}`;
|
|
109
|
+
case 501:
|
|
110
|
+
return `${NOT_YET} (this Cloud has no data routes)`;
|
|
111
|
+
case 502: {
|
|
112
|
+
// Only an answer with no reason (an older Cloud, a proxy) means the routes are missing. A Cloud that has them says what failed.
|
|
113
|
+
if (reason === undefined)
|
|
114
|
+
return `${NOT_YET} (the host's agent answered something unexpected: ask the operator to update it)`;
|
|
115
|
+
return `the host's agent failed (${reason})${text ? `: ${text}` : ""}. Nothing here says the request was applied; ${wait(reply)}, and ask the operator if it keeps happening`;
|
|
116
|
+
}
|
|
117
|
+
case 503:
|
|
118
|
+
if (reason === "busy") return `the host is busy with other data requests; ${wait(reply)}`;
|
|
119
|
+
if (reason === "audit-unavailable")
|
|
120
|
+
return `the Cloud could not record this request in its audit log, so it was not served. A write may already have been applied: read before retrying`;
|
|
121
|
+
if (reason === "host-unavailable")
|
|
122
|
+
return `${NOT_YET}: the host that runs ${project} is down, unreachable or has no D1/R2 service; ${wait(reply)}`;
|
|
123
|
+
return NOT_YET;
|
|
124
|
+
case 504: {
|
|
125
|
+
// `committed` is the Cloud's own word: "no" is the agent's 25 s deadline (rolled back), "unknown" is the Cloud's 35 s
|
|
126
|
+
// timeout, after which the host may have applied the request. No `committed` (an older Cloud) is treated as unknown.
|
|
127
|
+
if (body?.committed === "no")
|
|
128
|
+
return product === "D1"
|
|
129
|
+
? "the query took longer than the 25 s limit and was rolled back: nothing was committed"
|
|
130
|
+
: "the host took longer than 25 s: nothing was changed";
|
|
131
|
+
if (!context.write) return "the host did not answer in time (the Cloud waits 35 s); try again shortly";
|
|
132
|
+
return "the host did not answer in time (the Cloud waits 35 s); the write may or may not have been applied: read the data before you retry";
|
|
133
|
+
}
|
|
134
|
+
case 507:
|
|
135
|
+
return `the host has no room left for this app's data (quota exceeded)`;
|
|
136
|
+
default:
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|