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 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.4 · runs on Bun (use `bunx`, not `npx`)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- db.exec("PRAGMA synchronous = NORMAL");
117
- db.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
118
- db.exec(
119
- "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
120
- "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
121
- "PRIMARY KEY (bucket, key))",
122
- );
123
- db.exec(
124
- "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
125
- "visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
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
- const kvGet = db.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?");
136
- const kvPut = db.query("INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3");
137
- const kvDel = db.query("DELETE FROM kv WHERE ns = ? AND key = ?");
138
- const kvList = db.query<{ key: string }, [string, string]>("SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000");
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 namethey are independent SQL namespaces.
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
- let conn = d1Conns.get(name);
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
- if (d1Dir) mkdirSync(d1Dir, { recursive: true });
164
- conn = new Database(d1Dir ? join(d1Dir, `${name}.sqlite`) : ":memory:", { create: true });
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(name, conn);
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 row = kvGet.get(requireKv(msg.ns), str(msg.key));
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
- kvPut.run(requireKv(msg.ns), str(msg.key), str(msg.value));
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
- case "kv.delete":
234
- kvDel.run(requireKv(msg.ns), str(msg.key));
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
- case "kv.list":
237
- return { ok: true, keys: kvList.all(requireKv(msg.ns), str(msg.prefix)).map((r) => r.key) };
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
- db.query(
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 = db.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, str(msg.key));
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
- db.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
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 = db.query<R2Row, [string, string, string, number]>(
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
- db.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)").run(q, newId(), str(msg.body), at);
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 = db.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
322
- db.transaction(() => {
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 q of bindings.queues) {
448
- const rows = db.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
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(q, now, QUEUE_BATCH);
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 = db.query("UPDATE mq SET visible_at = ? WHERE id = ?");
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: q,
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 = db.query("DELETE FROM mq WHERE id = ?");
551
+ const del = store.query("DELETE FROM mq WHERE id = ?");
473
552
  for (const id of ack) del.run(id);
474
- const bump = db.query(
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 { SproutboatConfig } from "./config";
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: input.config.kv_namespaces ?? [],
53
+ kv: refsByKind.kv.map((ref) => ref.binding),
41
54
  secrets: input.config.secrets ?? [],
42
55
  outbound: input.config.outbound ?? [],
43
- d1: input.config.d1_databases ?? [],
44
- r2: input.config.r2_buckets ?? [],
45
- queues: input.config.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
- if (Object.values(bindings).some((names) => names.length > 0)) {
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 binding names, exposed as `env.<NAME>`. */
10
- kv_namespaces?: string[];
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 binding names, exposed as `env.<NAME>`. */
16
- d1_databases?: string[];
17
- /** R2 (object storage) bucket binding names, exposed as `env.<NAME>`. */
18
- r2_buckets?: string[];
19
- /** Queue producer binding names, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
20
- queues?: string[];
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: "kv_namespaces" | "secrets" | "outbound" | "d1_databases" | "r2_buckets" | "queues" | "analytics_engine_datasets",
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
- const kv_namespaces = stringArray("kv_namespaces", bindingName, "binding names (UPPER_SNAKE_CASE)");
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
- const d1_databases = stringArray("d1_databases", bindingName, "binding names (UPPER_SNAKE_CASE)");
125
- const r2_buckets = stringArray("r2_buckets", bindingName, "binding names (UPPER_SNAKE_CASE)");
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 ?? []), ...(secrets ?? []), ...(d1_databases ?? []), ...(r2_buckets ?? []),
195
- ...(queues ?? []), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
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
- if (!args.includes("--no-wait")) {
270
- const healthy = await waitForHealthy(deployed.url, 90_000);
271
- console.log(healthy
272
- ? ` ${ok("serving")}`
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 { SproutboatConfig } from "./config";
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
- list(config.kv_namespaces, "kv");
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
- list(config.d1_databases, "d1");
39
- list(config.r2_buckets, "r2");
40
- list(config.queues, "queue");
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." },