sproutboat 0.4.4 → 0.4.6
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/SURFACE.md +3 -2
- package/package.json +1 -1
- package/src/broker.ts +133 -51
- package/src/build.ts +22 -6
- package/src/config.ts +67 -16
- package/src/main.ts +62 -5
- package/src/report.ts +10 -5
- package/src/surface.ts +3 -0
package/SURFACE.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
|
|
4
4
|
> toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
|
|
5
5
|
|
|
6
|
-
**Package:** `sproutboat` 0.4.
|
|
6
|
+
**Package:** `sproutboat` 0.4.6 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
| `tail` | `[project-dir] [--sprout]` | Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead. |
|
|
19
19
|
| `domains` | `[list | add <host> | verify <host> | rm <host>] [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
|
|
20
20
|
| `secrets` | `[list | set <NAME> [value] | rm <NAME>] [project-dir]` | Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy. |
|
|
21
|
+
| `resource` | `[list [kind] | create <kind> <name> | rename <id> <name> | delete <id>]` | Manage account-level storage resources (kv | d1 | r2 | queue). `create` prints the id to reference from sproutboat.jsonc bindings. |
|
|
21
22
|
| `delete` | `[project-dir] [--name <project>] --yes` | Delete the project, every version, and its route. |
|
|
22
23
|
| `login` | `[--api-url <url>] [--token <token>]` | Device-code browser flow, or store <token> for <url> directly. |
|
|
23
24
|
|
|
24
25
|
```
|
|
25
|
-
usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] | versions list [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>]>
|
|
26
|
+
usage: sproutboat <init [name] | check [project-dir] | build [project-dir] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] | versions list [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--sprout] | domains [list | add <host> | verify <host> | rm <host>] [project-dir] | secrets [list | set <NAME> [value] | rm <NAME>] [project-dir] | resource [list [kind] | create <kind> <name> | rename <id> <name> | delete <id>] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>]>
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
## Environment variables
|
package/package.json
CHANGED
package/src/broker.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* re-validation and private-IP blocking are v2 — the exact-host allowlist is
|
|
18
18
|
* the only SSRF control today.
|
|
19
19
|
*/
|
|
20
|
-
import { Database } from "bun:sqlite";
|
|
20
|
+
import { Database, type Statement } from "bun:sqlite";
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
23
23
|
import { dirname, join, normalize, resolve } from "node:path";
|
|
@@ -36,6 +36,13 @@ export type Bindings = {
|
|
|
36
36
|
crons: string[];
|
|
37
37
|
/** Static-asset binding name, or `""` when assets are edge-only. */
|
|
38
38
|
assets: string;
|
|
39
|
+
/**
|
|
40
|
+
* #74 — binding name -> account-level resource it resolves to. A binding with
|
|
41
|
+
* an entry here stores in a per-resource SQLite file under `resourceDir`,
|
|
42
|
+
* keyed by `id`, shared across this owner's deployments; a binding without one
|
|
43
|
+
* falls back to the per-broker `db` partitioned by its own name (local dev).
|
|
44
|
+
*/
|
|
45
|
+
resources: Record<string, { kind: "kv" | "d1" | "r2" | "queue"; id: string }>;
|
|
39
46
|
};
|
|
40
47
|
export type Frame = Record<string, unknown>;
|
|
41
48
|
|
|
@@ -43,6 +50,13 @@ export type BrokerOptions = {
|
|
|
43
50
|
db?: string;
|
|
44
51
|
/** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
|
|
45
52
|
dataDir?: string;
|
|
53
|
+
/**
|
|
54
|
+
* #74 — directory holding one `<resource-id>.sqlite` per account-level KV / R2 /
|
|
55
|
+
* queue / D1 resource. Defaults to `<dirname(db)>/resources`, in-memory when
|
|
56
|
+
* `db` is `:memory:`. The supervisor points this at an owner-stable path so the
|
|
57
|
+
* data outlives a redeploy.
|
|
58
|
+
*/
|
|
59
|
+
resourceDir?: string;
|
|
46
60
|
token?: string;
|
|
47
61
|
bindings?: Partial<Bindings>;
|
|
48
62
|
secrets?: Record<string, string>;
|
|
@@ -80,7 +94,7 @@ export type Broker = {
|
|
|
80
94
|
};
|
|
81
95
|
|
|
82
96
|
export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
83
|
-
const bindings: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "", ...opts.bindings };
|
|
97
|
+
const bindings: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "", resources: {}, ...opts.bindings };
|
|
84
98
|
const secrets = opts.secrets ?? {};
|
|
85
99
|
|
|
86
100
|
// Static assets: read the manifest once. Files are read from disk per request
|
|
@@ -106,24 +120,32 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
106
120
|
const dbPath = opts.db ?? ":memory:";
|
|
107
121
|
const inMemory = dbPath === ":memory:" || dbPath === "";
|
|
108
122
|
const d1Dir = opts.dataDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "d1"));
|
|
123
|
+
const resourceDir = opts.resourceDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "resources"));
|
|
109
124
|
|
|
110
|
-
const db = new Database(dbPath);
|
|
111
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
112
125
|
// WAL + NORMAL is the standard pairing: a write no longer fsyncs the WAL, so
|
|
113
126
|
// host power loss can drop the last few committed txns, but a process crash
|
|
114
127
|
// never can and the file never corrupts. Right trade for a single-VPS
|
|
115
128
|
// KV/queue/DO store; on real block storage this is ~10-100x on writes.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
"
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
const openStore = (path: string): Database => {
|
|
130
|
+
const conn = new Database(path, { create: true });
|
|
131
|
+
conn.exec("PRAGMA journal_mode = WAL");
|
|
132
|
+
conn.exec("PRAGMA synchronous = NORMAL");
|
|
133
|
+
conn.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
|
|
134
|
+
conn.exec(
|
|
135
|
+
"CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
|
|
136
|
+
"etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
|
|
137
|
+
"PRIMARY KEY (bucket, key))",
|
|
138
|
+
);
|
|
139
|
+
conn.exec(
|
|
140
|
+
"CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
|
|
141
|
+
"visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
|
|
142
|
+
);
|
|
143
|
+
return conn;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const db = openStore(dbPath);
|
|
147
|
+
// Durable Object storage and Analytics Engine rows stay in the per-broker db —
|
|
148
|
+
// neither is an account-level resource (#74).
|
|
127
149
|
db.exec(
|
|
128
150
|
"CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
|
|
129
151
|
"PRIMARY KEY (cls, id, key))",
|
|
@@ -132,10 +154,51 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
132
154
|
"CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, " +
|
|
133
155
|
"blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
|
|
134
156
|
);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
157
|
+
|
|
158
|
+
// #74 — one SQLite file per account-level resource id, opened on first use.
|
|
159
|
+
const resourceDbs = new Map<string, Database>();
|
|
160
|
+
const resourceDb = (id: string): Database => {
|
|
161
|
+
let conn = resourceDbs.get(id);
|
|
162
|
+
if (!conn) {
|
|
163
|
+
if (resourceDir) mkdirSync(resourceDir, { recursive: true });
|
|
164
|
+
conn = openStore(resourceDir ? join(resourceDir, `${id}.sqlite`) : ":memory:");
|
|
165
|
+
resourceDbs.set(id, conn);
|
|
166
|
+
}
|
|
167
|
+
return conn;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Where a KV / R2 / queue binding stores: its resource's own file keyed by
|
|
172
|
+
* `id` when the config bound it to one, else the per-broker `db` partitioned
|
|
173
|
+
* by the binding name (bare-string bindings / local dev).
|
|
174
|
+
*/
|
|
175
|
+
const storeFor = (kind: "kv" | "r2" | "queue", binding: string): { store: Database; part: string } => {
|
|
176
|
+
const resource = bindings.resources[binding];
|
|
177
|
+
return resource && resource.kind === kind
|
|
178
|
+
? { store: resourceDb(resource.id), part: resource.id }
|
|
179
|
+
: { store: db, part: binding };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
type KvStmts = {
|
|
183
|
+
get: Statement<{ value: string }, [string, string]>;
|
|
184
|
+
put: Statement<unknown, [string, string, string]>;
|
|
185
|
+
del: Statement<unknown, [string, string]>;
|
|
186
|
+
list: Statement<{ key: string }, [string, string]>;
|
|
187
|
+
};
|
|
188
|
+
const kvStmtCache = new Map<Database, KvStmts>();
|
|
189
|
+
const kvStmts = (store: Database): KvStmts => {
|
|
190
|
+
let stmts = kvStmtCache.get(store);
|
|
191
|
+
if (!stmts) {
|
|
192
|
+
stmts = {
|
|
193
|
+
get: store.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
|
|
194
|
+
put: store.query("INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3"),
|
|
195
|
+
del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
|
|
196
|
+
list: store.query<{ key: string }, [string, string]>("SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000"),
|
|
197
|
+
};
|
|
198
|
+
kvStmtCache.set(store, stmts);
|
|
199
|
+
}
|
|
200
|
+
return stmts;
|
|
201
|
+
};
|
|
139
202
|
|
|
140
203
|
const bound = (list: string[], kind: string) => (name: unknown): string => {
|
|
141
204
|
const n = str(name);
|
|
@@ -155,16 +218,22 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
155
218
|
};
|
|
156
219
|
const newId = () => createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24);
|
|
157
220
|
|
|
158
|
-
// One SQLite database per bound D1
|
|
221
|
+
// One SQLite database per bound D1 binding — independent SQL namespaces. A
|
|
222
|
+
// binding wired to an account-level resource (#74) opens `<resource-id>.sqlite`
|
|
223
|
+
// under `resourceDir` (survives redeploys); a bare-string binding keeps its
|
|
224
|
+
// per-broker `<name>.sqlite` under `d1Dir`.
|
|
159
225
|
const d1Conns = new Map<string, Database>();
|
|
160
226
|
const d1 = (name: string): Database => {
|
|
161
|
-
|
|
227
|
+
const resource = bindings.resources[name];
|
|
228
|
+
const key = resource && resource.kind === "d1" ? resource.id : name;
|
|
229
|
+
let conn = d1Conns.get(key);
|
|
162
230
|
if (!conn) {
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
const dir = resource && resource.kind === "d1" ? resourceDir : d1Dir;
|
|
232
|
+
if (dir) mkdirSync(dir, { recursive: true });
|
|
233
|
+
conn = new Database(dir ? join(dir, `${key}.sqlite`) : ":memory:", { create: true });
|
|
165
234
|
conn.exec("PRAGMA journal_mode = WAL");
|
|
166
235
|
conn.exec("PRAGMA synchronous = NORMAL");
|
|
167
|
-
d1Conns.set(
|
|
236
|
+
d1Conns.set(key, conn);
|
|
168
237
|
}
|
|
169
238
|
return conn;
|
|
170
239
|
};
|
|
@@ -224,17 +293,24 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
224
293
|
case "ping":
|
|
225
294
|
return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
|
|
226
295
|
case "kv.get": {
|
|
227
|
-
const
|
|
296
|
+
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
297
|
+
const row = kvStmts(store).get.get(part, str(msg.key));
|
|
228
298
|
return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false, value: null };
|
|
229
299
|
}
|
|
230
|
-
case "kv.put":
|
|
231
|
-
|
|
300
|
+
case "kv.put": {
|
|
301
|
+
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
302
|
+
kvStmts(store).put.run(part, str(msg.key), str(msg.value));
|
|
232
303
|
return { ok: true };
|
|
233
|
-
|
|
234
|
-
|
|
304
|
+
}
|
|
305
|
+
case "kv.delete": {
|
|
306
|
+
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
307
|
+
kvStmts(store).del.run(part, str(msg.key));
|
|
235
308
|
return { ok: true };
|
|
236
|
-
|
|
237
|
-
|
|
309
|
+
}
|
|
310
|
+
case "kv.list": {
|
|
311
|
+
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
312
|
+
return { ok: true, keys: kvStmts(store).list.all(part, str(msg.prefix)).map((r) => r.key) };
|
|
313
|
+
}
|
|
238
314
|
case "secret.get": {
|
|
239
315
|
const name = str(msg.name);
|
|
240
316
|
if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
|
|
@@ -261,10 +337,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
261
337
|
}
|
|
262
338
|
|
|
263
339
|
case "r2.put": {
|
|
264
|
-
const bucket = requireR2(msg.bucket);
|
|
340
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
265
341
|
const body = str(msg.body);
|
|
266
342
|
const etag = createHash("sha256").update(body).digest("hex");
|
|
267
|
-
|
|
343
|
+
store.query(
|
|
268
344
|
"INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
|
|
269
345
|
"ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
|
|
270
346
|
).run(
|
|
@@ -281,22 +357,22 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
281
357
|
}
|
|
282
358
|
case "r2.get":
|
|
283
359
|
case "r2.head": {
|
|
284
|
-
const bucket = requireR2(msg.bucket);
|
|
285
|
-
const row =
|
|
360
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
361
|
+
const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, str(msg.key));
|
|
286
362
|
if (!row) return { ok: true, found: false };
|
|
287
363
|
return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? row.body : undefined };
|
|
288
364
|
}
|
|
289
365
|
case "r2.delete": {
|
|
290
|
-
const bucket = requireR2(msg.bucket);
|
|
291
|
-
|
|
366
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
367
|
+
store.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
|
|
292
368
|
return { ok: true };
|
|
293
369
|
}
|
|
294
370
|
case "r2.list": {
|
|
295
|
-
const bucket = requireR2(msg.bucket);
|
|
371
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
296
372
|
const prefix = str(msg.prefix);
|
|
297
373
|
const cursor = str(msg.cursor);
|
|
298
374
|
const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
|
|
299
|
-
const rows =
|
|
375
|
+
const rows = store.query<R2Row, [string, string, string, number]>(
|
|
300
376
|
"SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
|
|
301
377
|
).all(bucket, prefix, cursor, limit + 1);
|
|
302
378
|
const truncated = rows.length > limit;
|
|
@@ -310,16 +386,16 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
310
386
|
}
|
|
311
387
|
|
|
312
388
|
case "queue.send": {
|
|
313
|
-
const q = requireQueue(msg.queue);
|
|
389
|
+
const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
|
|
314
390
|
const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
|
|
315
|
-
|
|
391
|
+
store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)").run(q, newId(), str(msg.body), at);
|
|
316
392
|
return { ok: true };
|
|
317
393
|
}
|
|
318
394
|
case "queue.send_batch": {
|
|
319
|
-
const q = requireQueue(msg.queue);
|
|
395
|
+
const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
|
|
320
396
|
const msgs = Array.isArray(msg.messages) ? (msg.messages as Frame[]) : [];
|
|
321
|
-
const ins =
|
|
322
|
-
|
|
397
|
+
const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
|
|
398
|
+
store.transaction(() => {
|
|
323
399
|
for (const m of msgs) ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
|
|
324
400
|
})();
|
|
325
401
|
return { ok: true, count: msgs.length };
|
|
@@ -444,18 +520,21 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
444
520
|
function drainQueuesOnce(): void {
|
|
445
521
|
if (!opts.sproutUrl || bindings.queues.length === 0) return;
|
|
446
522
|
const now = Date.now();
|
|
447
|
-
for (const
|
|
448
|
-
|
|
523
|
+
for (const binding of bindings.queues) {
|
|
524
|
+
// `store`/`part` route the rows to this queue's backing file (#74); the
|
|
525
|
+
// trigger payload still carries the binding name the sprout matches on.
|
|
526
|
+
const { store, part } = storeFor("queue", binding);
|
|
527
|
+
const rows = store.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
|
|
449
528
|
"SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
|
|
450
|
-
).all(
|
|
529
|
+
).all(part, now, QUEUE_BATCH);
|
|
451
530
|
if (rows.length === 0) continue;
|
|
452
531
|
// hide the batch so the next tick doesn't re-deliver it while in flight
|
|
453
532
|
const hideUntil = now + 30_000;
|
|
454
|
-
const hide =
|
|
533
|
+
const hide = store.query("UPDATE mq SET visible_at = ? WHERE id = ?");
|
|
455
534
|
for (const r of rows) hide.run(hideUntil, r.id);
|
|
456
535
|
|
|
457
536
|
void deliverTrigger("queue", {
|
|
458
|
-
queue:
|
|
537
|
+
queue: binding,
|
|
459
538
|
messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
|
|
460
539
|
}).then(async (res) => {
|
|
461
540
|
let ack: string[] = rows.map((r) => r.id); // default: ack all if the sprout did not say
|
|
@@ -469,9 +548,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
469
548
|
} else {
|
|
470
549
|
ack = []; retry = rows.map((r) => r.id); // delivery failed → retry all
|
|
471
550
|
}
|
|
472
|
-
const del =
|
|
551
|
+
const del = store.query("DELETE FROM mq WHERE id = ?");
|
|
473
552
|
for (const id of ack) del.run(id);
|
|
474
|
-
const bump =
|
|
553
|
+
const bump = store.query(
|
|
475
554
|
"UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= ? THEN 1 ELSE 0 END WHERE id = ?",
|
|
476
555
|
);
|
|
477
556
|
for (const id of retry) bump.run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, id);
|
|
@@ -501,6 +580,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
501
580
|
close: () => {
|
|
502
581
|
for (const t of timers) clearInterval(t);
|
|
503
582
|
for (const conn of d1Conns.values()) conn.close();
|
|
583
|
+
for (const conn of resourceDbs.values()) conn.close();
|
|
504
584
|
db.close();
|
|
505
585
|
},
|
|
506
586
|
};
|
|
@@ -572,6 +652,7 @@ if (import.meta.main) {
|
|
|
572
652
|
token: { type: "string" },
|
|
573
653
|
db: { type: "string" },
|
|
574
654
|
"data-dir": { type: "string" },
|
|
655
|
+
"resource-dir": { type: "string" },
|
|
575
656
|
bindings: { type: "string" },
|
|
576
657
|
secrets: { type: "string" },
|
|
577
658
|
"sprout-url": { type: "string" },
|
|
@@ -587,6 +668,7 @@ if (import.meta.main) {
|
|
|
587
668
|
const broker = createBroker({
|
|
588
669
|
db: values.db,
|
|
589
670
|
dataDir: values["data-dir"],
|
|
671
|
+
resourceDir: values["resource-dir"],
|
|
590
672
|
token: values.token ?? process.env.SB_BROKER_TOKEN,
|
|
591
673
|
bindings,
|
|
592
674
|
secrets,
|
package/src/build.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { walkAssets, type AssetManifest } from "./assets";
|
|
5
|
-
import type
|
|
5
|
+
import { resourceRefs, type SproutboatConfig } from "./config";
|
|
6
6
|
import { compileSprout } from "./compile";
|
|
7
7
|
import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
|
|
8
8
|
import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
|
|
@@ -36,17 +36,31 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
36
36
|
const sproutPath = resolve(artifactDir, "sprout");
|
|
37
37
|
await mkdir(artifactDir, { recursive: true });
|
|
38
38
|
|
|
39
|
+
// #74 — split each storage-binding array into its binding-name list (the
|
|
40
|
+
// legacy shape the prelude/broker read) plus a `resources` map { binding ->
|
|
41
|
+
// { kind, id } } for the entries that name an account-level resource id.
|
|
42
|
+
const refsByKind = {
|
|
43
|
+
kv: resourceRefs(input.config.kv_namespaces),
|
|
44
|
+
d1: resourceRefs(input.config.d1_databases),
|
|
45
|
+
r2: resourceRefs(input.config.r2_buckets),
|
|
46
|
+
queue: resourceRefs(input.config.queues),
|
|
47
|
+
};
|
|
48
|
+
const resources: Record<string, { kind: string; id: string }> = {};
|
|
49
|
+
for (const [kind, refs] of Object.entries(refsByKind)) {
|
|
50
|
+
for (const ref of refs) if (ref.id) resources[ref.binding] = { kind, id: ref.id };
|
|
51
|
+
}
|
|
39
52
|
const bindings = {
|
|
40
|
-
kv:
|
|
53
|
+
kv: refsByKind.kv.map((ref) => ref.binding),
|
|
41
54
|
secrets: input.config.secrets ?? [],
|
|
42
55
|
outbound: input.config.outbound ?? [],
|
|
43
|
-
d1:
|
|
44
|
-
r2:
|
|
45
|
-
queues:
|
|
56
|
+
d1: refsByKind.d1.map((ref) => ref.binding),
|
|
57
|
+
r2: refsByKind.r2.map((ref) => ref.binding),
|
|
58
|
+
queues: refsByKind.queue.map((ref) => ref.binding),
|
|
46
59
|
analytics: input.config.analytics_engine_datasets ?? [],
|
|
47
60
|
do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
|
|
48
61
|
crons: input.config.triggers?.crons ?? [],
|
|
49
62
|
assets: input.config.assets?.binding ?? "",
|
|
63
|
+
resources,
|
|
50
64
|
};
|
|
51
65
|
|
|
52
66
|
const zigBin = await ensureZig();
|
|
@@ -78,7 +92,9 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
78
92
|
// frozen at v2. The control plane reads this to configure the per-deployment
|
|
79
93
|
// broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
|
|
80
94
|
// schedules, Durable Object classes).
|
|
81
|
-
|
|
95
|
+
const hasBindings = Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0)
|
|
96
|
+
|| Object.keys(bindings.resources).length > 0;
|
|
97
|
+
if (hasBindings) {
|
|
82
98
|
await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
|
|
83
99
|
}
|
|
84
100
|
|
package/src/config.ts
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A storage binding entry (#74). Either a bare `"BINDING"` — resolved to an
|
|
5
|
+
* ephemeral local resource for `sproutboat dev`, rejected by a real deploy — or
|
|
6
|
+
* `{ binding, id }` pointing at an account-level resource created with
|
|
7
|
+
* `sproutboat resource create`. The id carries its own `<kind>_` prefix.
|
|
8
|
+
*/
|
|
9
|
+
export type ResourceBinding = { binding: string; id: string };
|
|
10
|
+
export type ResourceRef = string | ResourceBinding;
|
|
11
|
+
|
|
12
|
+
/** Normalizes a storage-binding array to `{ binding, id? }` rows. */
|
|
13
|
+
export function resourceRefs(field: readonly ResourceRef[] | undefined): Array<{ binding: string; id?: string }> {
|
|
14
|
+
return (field ?? []).map((entry) => (isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id }));
|
|
15
|
+
}
|
|
16
|
+
|
|
3
17
|
export type SproutboatConfig = {
|
|
4
18
|
$schema?: string;
|
|
5
19
|
name: string;
|
|
6
20
|
main: string;
|
|
7
21
|
compatibility_date: string;
|
|
8
22
|
vars?: Record<string, string>;
|
|
9
|
-
/** KV namespace
|
|
10
|
-
kv_namespaces?:
|
|
23
|
+
/** KV namespace bindings, exposed as `env.<NAME>`. */
|
|
24
|
+
kv_namespaces?: ResourceRef[];
|
|
11
25
|
/** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
|
|
12
26
|
secrets?: string[];
|
|
13
27
|
/** Hostnames the sprout's `fetch()` may reach (exact host match). */
|
|
14
28
|
outbound?: string[];
|
|
15
|
-
/** D1 (SQLite) database
|
|
16
|
-
d1_databases?:
|
|
17
|
-
/** R2 (object storage) bucket
|
|
18
|
-
r2_buckets?:
|
|
19
|
-
/** Queue producer
|
|
20
|
-
queues?:
|
|
21
|
-
/** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. */
|
|
29
|
+
/** D1 (SQLite) database bindings, exposed as `env.<NAME>`. */
|
|
30
|
+
d1_databases?: ResourceRef[];
|
|
31
|
+
/** R2 (object storage) bucket bindings, exposed as `env.<NAME>`. */
|
|
32
|
+
r2_buckets?: ResourceRef[];
|
|
33
|
+
/** Queue producer bindings, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
|
|
34
|
+
queues?: ResourceRef[];
|
|
35
|
+
/** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. No id — the dataset is created on first write. */
|
|
22
36
|
analytics_engine_datasets?: string[];
|
|
23
37
|
/** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
|
|
24
38
|
durable_objects?: Record<string, string>;
|
|
@@ -101,7 +115,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
101
115
|
const bindingName = /^[A-Z][A-Z0-9_]*$/;
|
|
102
116
|
const hostPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
103
117
|
const stringArray = (
|
|
104
|
-
field: "
|
|
118
|
+
field: "secrets" | "outbound" | "analytics_engine_datasets",
|
|
105
119
|
item: RegExp,
|
|
106
120
|
label: string,
|
|
107
121
|
): string[] | undefined => {
|
|
@@ -118,13 +132,48 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
118
132
|
}
|
|
119
133
|
return out;
|
|
120
134
|
};
|
|
121
|
-
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A storage-binding array (#74): each entry is a bare `"BINDING"` or
|
|
138
|
+
* `{ binding: "BINDING", id: "<kind>_<24hex>" }`. `kind` is the field's own
|
|
139
|
+
* resource kind, so an r2 id can't be pasted into `kv_namespaces`.
|
|
140
|
+
*/
|
|
141
|
+
const resourceArray = (
|
|
142
|
+
field: "kv_namespaces" | "d1_databases" | "r2_buckets" | "queues",
|
|
143
|
+
kind: string,
|
|
144
|
+
): ResourceRef[] | undefined => {
|
|
145
|
+
if (value[field] === undefined) return undefined;
|
|
146
|
+
const raw = value[field];
|
|
147
|
+
if (!Array.isArray(raw)) {
|
|
148
|
+
errors.push(`${field} must be an array of binding names or { binding, id } objects`);
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const idPattern = new RegExp(`^${kind}_[0-9a-f]{24}$`);
|
|
152
|
+
const out: ResourceRef[] = [];
|
|
153
|
+
for (const entry of raw) {
|
|
154
|
+
if (isString(entry)) {
|
|
155
|
+
if (bindingName.test(entry)) out.push(entry);
|
|
156
|
+
else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
|
|
157
|
+
} else if (isRecord(entry) && isString(entry.binding) && isString(entry.id)
|
|
158
|
+
&& bindingName.test(entry.binding) && idPattern.test(entry.id)
|
|
159
|
+
&& Object.keys(entry).every((key) => key === "binding" || key === "id")) {
|
|
160
|
+
out.push({ binding: entry.binding, id: entry.id });
|
|
161
|
+
} else {
|
|
162
|
+
errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
};
|
|
167
|
+
|
|
122
168
|
const secrets = stringArray("secrets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
123
169
|
const outbound = stringArray("outbound", hostPattern, "hostnames");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const queues = stringArray("queues", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
170
|
+
// Analytics Engine datasets aren't provisioned — the dataset name springs into
|
|
171
|
+
// existence on first writeDataPoint(), so there's no resource id to bind (#74).
|
|
127
172
|
const analytics_engine_datasets = stringArray("analytics_engine_datasets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
173
|
+
const kv_namespaces = resourceArray("kv_namespaces", "kv");
|
|
174
|
+
const d1_databases = resourceArray("d1_databases", "d1");
|
|
175
|
+
const r2_buckets = resourceArray("r2_buckets", "r2");
|
|
176
|
+
const queues = resourceArray("queues", "queue");
|
|
128
177
|
|
|
129
178
|
let durable_objects: Record<string, string> | undefined;
|
|
130
179
|
if (value.durable_objects !== undefined) {
|
|
@@ -190,9 +239,11 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
190
239
|
}
|
|
191
240
|
}
|
|
192
241
|
|
|
242
|
+
const resourceNames = (refs: ResourceRef[] | undefined): string[] =>
|
|
243
|
+
resourceRefs(refs).map((ref) => ref.binding);
|
|
193
244
|
const bindingSlots = [
|
|
194
|
-
...(kv_namespaces
|
|
195
|
-
...(queues
|
|
245
|
+
...resourceNames(kv_namespaces), ...(secrets ?? []), ...resourceNames(d1_databases), ...resourceNames(r2_buckets),
|
|
246
|
+
...resourceNames(queues), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
|
|
196
247
|
...(assets?.binding ? [assets.binding] : []),
|
|
197
248
|
];
|
|
198
249
|
if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
|
package/src/main.ts
CHANGED
|
@@ -266,11 +266,10 @@ async function deploy(args: string[]) {
|
|
|
266
266
|
console.warn(dim(` The previous live version stays frozen at ${drift.from}; this one is built with ${drift.to}.`));
|
|
267
267
|
console.warn(dim(` The alpha compiler's output can differ between pins (see COMPAT.md) — roll back if this version misbehaves.`));
|
|
268
268
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
: amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
|
|
269
|
+
// Verify the edge actually answers (cert issuance + sprout boot). Say nothing
|
|
270
|
+
// on success — "Deployed" already implied that; only speak up if it doesn't.
|
|
271
|
+
if (!args.includes("--no-wait") && !(await waitForHealthy(deployed.url, 90_000))) {
|
|
272
|
+
console.warn(amber(" ! not serving after 90s — Caddy may still be issuing the cert, or the sprout is crashing (`sproutboat tail`)"));
|
|
274
273
|
}
|
|
275
274
|
}
|
|
276
275
|
|
|
@@ -463,6 +462,63 @@ async function secrets(args: string[]) {
|
|
|
463
462
|
console.log(`Set ${name} — applies on the next deploy or sprout restart`);
|
|
464
463
|
}
|
|
465
464
|
|
|
465
|
+
const RESOURCE_KINDS = ["kv", "d1", "r2", "queue"];
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* #74 — account-level storage resources. Unlike `secrets`/`domains` these are
|
|
469
|
+
* not project-scoped, so there's no `readProject` here. `create` prints the
|
|
470
|
+
* `<kind>_<id>` handle to paste into `sproutboat.jsonc` bindings.
|
|
471
|
+
*/
|
|
472
|
+
async function resource(args: string[]) {
|
|
473
|
+
const sub = args[0] && ["list", "create", "rename", "delete"].includes(args[0]) ? args.shift()! : "list";
|
|
474
|
+
const { apiUrl, token } = await apiCredentials();
|
|
475
|
+
const base = `${apiUrl}/api/resources`;
|
|
476
|
+
const auth = { "x-api-key": token };
|
|
477
|
+
|
|
478
|
+
if (sub === "list") {
|
|
479
|
+
const kindFilter = args[0];
|
|
480
|
+
const body = await responseText(await fetch(base, { headers: auth }), "could not list resources");
|
|
481
|
+
const parsed = jsonObject(parseJsonValue(body));
|
|
482
|
+
const rows = (parsed && Array.isArray(parsed.resources) ? parsed.resources : [])
|
|
483
|
+
.map((entry) => jsonObject(entry))
|
|
484
|
+
.filter((entry): entry is JsonObject => Boolean(entry) && (!kindFilter || entry!.kind === kindFilter));
|
|
485
|
+
if (rows.length === 0) { console.log(kindFilter ? `no ${kindFilter} resources` : "no resources"); return; }
|
|
486
|
+
for (const row of rows) console.log(`${String(row.kind).padEnd(9)} ${String(row.id).padEnd(30)} ${String(row.name)}`);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (sub === "create") {
|
|
491
|
+
const [kind, name] = args;
|
|
492
|
+
if (!kind || !RESOURCE_KINDS.includes(kind)) usageError(`resource create: kind must be one of ${RESOURCE_KINDS.join(", ")}`, "resource create <kind> <name>");
|
|
493
|
+
if (!name) usageError("resource create: missing <name>", "resource create <kind> <name>");
|
|
494
|
+
const body = await responseText(
|
|
495
|
+
await fetch(base, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ kind, name }) }),
|
|
496
|
+
"create rejected",
|
|
497
|
+
);
|
|
498
|
+
const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
|
|
499
|
+
if (!record || !isString(record.id)) fail("create response was not a resource");
|
|
500
|
+
console.log(ok(`created ${kind} ${bold(String(record.name))}`));
|
|
501
|
+
console.log(record.id);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (sub === "rename") {
|
|
506
|
+
const [id, name] = args;
|
|
507
|
+
if (!id || !name) usageError("resource rename: need <id> <name>", "resource rename <id> <name>");
|
|
508
|
+
await responseText(
|
|
509
|
+
await fetch(`${base}/${id}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name }) }),
|
|
510
|
+
"rename rejected",
|
|
511
|
+
);
|
|
512
|
+
console.log(ok(`renamed ${id} → ${name}`));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const id = args[0];
|
|
517
|
+
if (!id) usageError("resource delete: missing <id>", "resource delete <id>");
|
|
518
|
+
await responseText(await fetch(`${base}/${id}`, { method: "DELETE", headers: auth }), "delete rejected");
|
|
519
|
+
console.log(ok(`deleted ${id}`));
|
|
520
|
+
}
|
|
521
|
+
|
|
466
522
|
async function deleteProject(args: string[]) {
|
|
467
523
|
// `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
|
|
468
524
|
const positional: string[] = [];
|
|
@@ -522,6 +578,7 @@ switch (command) {
|
|
|
522
578
|
case "rollback": await rollback(args); break;
|
|
523
579
|
case "domains": await domains(args); break;
|
|
524
580
|
case "secrets": await secrets(args); break;
|
|
581
|
+
case "resource": await resource(args); break;
|
|
525
582
|
case "tail": await tail(args); break;
|
|
526
583
|
case "delete": await deleteProject(args); break;
|
|
527
584
|
default: usage();
|
package/src/report.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { gzipSync } from "bun";
|
|
3
|
-
import type
|
|
3
|
+
import { resourceRefs, type SproutboatConfig } from "./config";
|
|
4
4
|
import type { ArtifactManifest } from "./manifest";
|
|
5
5
|
import { bold, dim, leaf, sprout } from "./style";
|
|
6
6
|
|
|
@@ -33,11 +33,16 @@ function bindingRows(config: SproutboatConfig): string[][] {
|
|
|
33
33
|
const list = (names: string[] | undefined, type: string, detail = "") => {
|
|
34
34
|
for (const name of names ?? []) rows.push([`env.${name}`, type, detail]);
|
|
35
35
|
};
|
|
36
|
-
|
|
36
|
+
const resourceList = (refs: Parameters<typeof resourceRefs>[0], type: string) => {
|
|
37
|
+
for (const ref of resourceRefs(refs)) {
|
|
38
|
+
rows.push([`env.${ref.binding}`, type, ref.id ?? "no id — local dev only, a deploy needs `sproutboat resource create`"]);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
resourceList(config.kv_namespaces, "kv");
|
|
37
42
|
list(config.secrets, "secret", "value withheld — set with `sproutboat secrets`");
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
resourceList(config.d1_databases, "d1");
|
|
44
|
+
resourceList(config.r2_buckets, "r2");
|
|
45
|
+
resourceList(config.queues, "queue");
|
|
41
46
|
list(config.analytics_engine_datasets, "analytics");
|
|
42
47
|
for (const [name, className] of Object.entries(config.durable_objects ?? {})) rows.push([`env.${name}`, "durable object", className]);
|
|
43
48
|
for (const host of config.outbound ?? []) rows.push([`fetch()`, "outbound", host]);
|
package/src/surface.ts
CHANGED
|
@@ -46,6 +46,9 @@ export const COMMANDS: readonly Command[] = [
|
|
|
46
46
|
{ name: "secrets", group: "Configure", emoji: "🔑",
|
|
47
47
|
args: "[list | set <NAME> [value] | rm <NAME>] [project-dir]", brief: "[list | set | rm]",
|
|
48
48
|
summary: "Manage encrypted project secrets (read as env.NAME). `set` takes the value from the arg or stdin; applies on next deploy." },
|
|
49
|
+
{ name: "resource", group: "Configure", emoji: "📦",
|
|
50
|
+
args: "[list [kind] | create <kind> <name> | rename <id> <name> | delete <id>]", brief: "[list | create | rename | delete]",
|
|
51
|
+
summary: "Manage account-level storage resources (kv | d1 | r2 | queue). `create` prints the id to reference from sproutboat.jsonc bindings." },
|
|
49
52
|
{ name: "delete", group: "Configure", emoji: "🗑",
|
|
50
53
|
args: "[project-dir] [--name <project>] --yes", brief: "[project-dir] --yes",
|
|
51
54
|
summary: "Delete the project, every version, and its route." },
|