sproutboat 0.4.5 → 0.4.7
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 +4 -3
- package/package.json +1 -1
- package/src/broker.ts +133 -51
- package/src/build.ts +22 -6
- package/src/config.ts +87 -16
- package/src/main.ts +108 -1
- package/src/report.ts +10 -5
- package/src/surface.ts +5 -2
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.7 · runs on Bun (use `bunx`, not `npx`)
|
|
7
7
|
|
|
8
8
|
## Commands
|
|
9
9
|
|
|
@@ -12,17 +12,18 @@
|
|
|
12
12
|
| `init` | `[name]` | Scaffold sproutboat.jsonc + src/index.js in ./<name>. |
|
|
13
13
|
| `check` | `[project-dir]` | Validate the config and entry point without building. |
|
|
14
14
|
| `build` | `[project-dir]` | Cross-compile the native-fetch sprout (Porffor + Zig). |
|
|
15
|
-
| `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]` | Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check. |
|
|
15
|
+
| `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]` | Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores. |
|
|
16
16
|
| `versions` | `list [project-dir]` | List the project's deployed versions. |
|
|
17
17
|
| `rollback` | `<version-id> [project-dir]` | Re-activate a previous version. |
|
|
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] [--no-provision] | 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,58 @@
|
|
|
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
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rewrite a bare `"BINDING"` token inside `<field>: [ … ]` to
|
|
19
|
+
* `{ "binding": "BINDING", "id": "<id>" }`, leaving the rest of the source
|
|
20
|
+
* (comments, spacing) untouched — used by `deploy`'s auto-provisioner to pin an
|
|
21
|
+
* id back into `sproutboat.jsonc`. Binding names are UPPER_SNAKE, so a plain
|
|
22
|
+
* `"BINDING"` match inside the array is unambiguous. No-op if not found.
|
|
23
|
+
*/
|
|
24
|
+
export function pinBindingId(source: string, field: string, binding: string, id: string): string {
|
|
25
|
+
const array = new RegExp(`("${field}"\\s*:\\s*\\[)([\\s\\S]*?)(\\])`);
|
|
26
|
+
// the bare token must be a whole array element — at the start of the array or
|
|
27
|
+
// right after a comma — never `"binding": "NAME"` inside an already-pinned
|
|
28
|
+
// { … } object.
|
|
29
|
+
const element = new RegExp(`(^\\s*|,\\s*)"${binding}"(\\s*,|\\s*$)`);
|
|
30
|
+
return source.replace(array, (whole, open: string, inner: string, close: string) =>
|
|
31
|
+
element.test(inner)
|
|
32
|
+
? open + inner.replace(element, `$1{ "binding": "${binding}", "id": "${id}" }$2`) + close
|
|
33
|
+
: whole,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
3
37
|
export type SproutboatConfig = {
|
|
4
38
|
$schema?: string;
|
|
5
39
|
name: string;
|
|
6
40
|
main: string;
|
|
7
41
|
compatibility_date: string;
|
|
8
42
|
vars?: Record<string, string>;
|
|
9
|
-
/** KV namespace
|
|
10
|
-
kv_namespaces?:
|
|
43
|
+
/** KV namespace bindings, exposed as `env.<NAME>`. */
|
|
44
|
+
kv_namespaces?: ResourceRef[];
|
|
11
45
|
/** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
|
|
12
46
|
secrets?: string[];
|
|
13
47
|
/** Hostnames the sprout's `fetch()` may reach (exact host match). */
|
|
14
48
|
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()`. */
|
|
49
|
+
/** D1 (SQLite) database bindings, exposed as `env.<NAME>`. */
|
|
50
|
+
d1_databases?: ResourceRef[];
|
|
51
|
+
/** R2 (object storage) bucket bindings, exposed as `env.<NAME>`. */
|
|
52
|
+
r2_buckets?: ResourceRef[];
|
|
53
|
+
/** Queue producer bindings, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
|
|
54
|
+
queues?: ResourceRef[];
|
|
55
|
+
/** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. No id — the dataset is created on first write. */
|
|
22
56
|
analytics_engine_datasets?: string[];
|
|
23
57
|
/** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
|
|
24
58
|
durable_objects?: Record<string, string>;
|
|
@@ -101,7 +135,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
101
135
|
const bindingName = /^[A-Z][A-Z0-9_]*$/;
|
|
102
136
|
const hostPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
103
137
|
const stringArray = (
|
|
104
|
-
field: "
|
|
138
|
+
field: "secrets" | "outbound" | "analytics_engine_datasets",
|
|
105
139
|
item: RegExp,
|
|
106
140
|
label: string,
|
|
107
141
|
): string[] | undefined => {
|
|
@@ -118,13 +152,48 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
118
152
|
}
|
|
119
153
|
return out;
|
|
120
154
|
};
|
|
121
|
-
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A storage-binding array (#74): each entry is a bare `"BINDING"` or
|
|
158
|
+
* `{ binding: "BINDING", id: "<kind>_<24hex>" }`. `kind` is the field's own
|
|
159
|
+
* resource kind, so an r2 id can't be pasted into `kv_namespaces`.
|
|
160
|
+
*/
|
|
161
|
+
const resourceArray = (
|
|
162
|
+
field: "kv_namespaces" | "d1_databases" | "r2_buckets" | "queues",
|
|
163
|
+
kind: string,
|
|
164
|
+
): ResourceRef[] | undefined => {
|
|
165
|
+
if (value[field] === undefined) return undefined;
|
|
166
|
+
const raw = value[field];
|
|
167
|
+
if (!Array.isArray(raw)) {
|
|
168
|
+
errors.push(`${field} must be an array of binding names or { binding, id } objects`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const idPattern = new RegExp(`^${kind}_[0-9a-f]{24}$`);
|
|
172
|
+
const out: ResourceRef[] = [];
|
|
173
|
+
for (const entry of raw) {
|
|
174
|
+
if (isString(entry)) {
|
|
175
|
+
if (bindingName.test(entry)) out.push(entry);
|
|
176
|
+
else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
|
|
177
|
+
} else if (isRecord(entry) && isString(entry.binding) && isString(entry.id)
|
|
178
|
+
&& bindingName.test(entry.binding) && idPattern.test(entry.id)
|
|
179
|
+
&& Object.keys(entry).every((key) => key === "binding" || key === "id")) {
|
|
180
|
+
out.push({ binding: entry.binding, id: entry.id });
|
|
181
|
+
} else {
|
|
182
|
+
errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
};
|
|
187
|
+
|
|
122
188
|
const secrets = stringArray("secrets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
123
189
|
const outbound = stringArray("outbound", hostPattern, "hostnames");
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const queues = stringArray("queues", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
190
|
+
// Analytics Engine datasets aren't provisioned — the dataset name springs into
|
|
191
|
+
// existence on first writeDataPoint(), so there's no resource id to bind (#74).
|
|
127
192
|
const analytics_engine_datasets = stringArray("analytics_engine_datasets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
193
|
+
const kv_namespaces = resourceArray("kv_namespaces", "kv");
|
|
194
|
+
const d1_databases = resourceArray("d1_databases", "d1");
|
|
195
|
+
const r2_buckets = resourceArray("r2_buckets", "r2");
|
|
196
|
+
const queues = resourceArray("queues", "queue");
|
|
128
197
|
|
|
129
198
|
let durable_objects: Record<string, string> | undefined;
|
|
130
199
|
if (value.durable_objects !== undefined) {
|
|
@@ -190,9 +259,11 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
190
259
|
}
|
|
191
260
|
}
|
|
192
261
|
|
|
262
|
+
const resourceNames = (refs: ResourceRef[] | undefined): string[] =>
|
|
263
|
+
resourceRefs(refs).map((ref) => ref.binding);
|
|
193
264
|
const bindingSlots = [
|
|
194
|
-
...(kv_namespaces
|
|
195
|
-
...(queues
|
|
265
|
+
...resourceNames(kv_namespaces), ...(secrets ?? []), ...resourceNames(d1_databases), ...resourceNames(r2_buckets),
|
|
266
|
+
...resourceNames(queues), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
|
|
196
267
|
...(assets?.binding ? [assets.binding] : []),
|
|
197
268
|
];
|
|
198
269
|
if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
|
package/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
|
-
import { parseConfig, type SproutboatConfig } from "./config";
|
|
4
|
+
import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
|
|
5
5
|
import { validateHttpSyncSource } from "./source";
|
|
6
6
|
import { buildArtifact } from "./build";
|
|
7
7
|
import { validateManifest, type ArtifactManifest } from "./manifest";
|
|
@@ -177,6 +177,52 @@ async function build(directory?: string) {
|
|
|
177
177
|
return { project, artifact };
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
const PROVISION_FIELDS = [
|
|
181
|
+
["kv_namespaces", "kv"],
|
|
182
|
+
["d1_databases", "d1"],
|
|
183
|
+
["r2_buckets", "r2"],
|
|
184
|
+
["queues", "queue"],
|
|
185
|
+
] as const;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* #74 auto-provisioning, wrangler-style. A bare-string KV/D1/R2/queue binding
|
|
189
|
+
* with no id gets an account-level resource created (`<project>-<binding>`) on
|
|
190
|
+
* deploy, and the id is written back into `sproutboat.jsonc`. `--no-provision`
|
|
191
|
+
* skips this — those bindings then get an ephemeral deploy-scoped store.
|
|
192
|
+
*/
|
|
193
|
+
async function provisionBindings(directory = process.cwd()): Promise<void> {
|
|
194
|
+
const configPath = resolve(directory, "sproutboat.jsonc");
|
|
195
|
+
let source = await readFile(configPath, "utf8");
|
|
196
|
+
const parsed = parseConfig(source);
|
|
197
|
+
if (!parsed.ok) return; // build() re-reads and reports the config error
|
|
198
|
+
|
|
199
|
+
const bare: Array<{ field: string; kind: string; binding: string }> = [];
|
|
200
|
+
for (const [field, kind] of PROVISION_FIELDS) {
|
|
201
|
+
for (const ref of resourceRefs(parsed.value[field])) {
|
|
202
|
+
if (!ref.id) bare.push({ field, kind, binding: ref.binding });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (bare.length === 0) return;
|
|
206
|
+
|
|
207
|
+
const { apiUrl, token } = await apiCredentials();
|
|
208
|
+
for (const { field, kind, binding } of bare) {
|
|
209
|
+
const name = `${parsed.value.name}-${binding.toLowerCase().replace(/_/g, "-")}`;
|
|
210
|
+
const body = await responseText(
|
|
211
|
+
await fetch(`${apiUrl}/api/resources`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: { "x-api-key": token, "content-type": "application/json" },
|
|
214
|
+
body: JSON.stringify({ kind, name, ifExists: "return" }),
|
|
215
|
+
}),
|
|
216
|
+
`could not provision a ${kind} resource for env.${binding}`,
|
|
217
|
+
);
|
|
218
|
+
const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
|
|
219
|
+
if (!record || !isString(record.id)) fail(`provision response for env.${binding} was not a resource`);
|
|
220
|
+
source = pinBindingId(source, field, binding, record.id);
|
|
221
|
+
console.log(ok(`provisioned ${kind} ${bold(name)} → ${record.id}`));
|
|
222
|
+
}
|
|
223
|
+
await writeFile(configPath, source);
|
|
224
|
+
}
|
|
225
|
+
|
|
180
226
|
async function deploy(args: string[]) {
|
|
181
227
|
const artifactIndex = args.indexOf("--artifact");
|
|
182
228
|
const dryRun = args.includes("--dry-run");
|
|
@@ -190,6 +236,9 @@ async function deploy(args: string[]) {
|
|
|
190
236
|
: usageError("deploy: --artifact needs a directory", "deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait]");
|
|
191
237
|
projectName = "";
|
|
192
238
|
} else {
|
|
239
|
+
// wrangler-style: create resources for id-less bindings and pin the ids
|
|
240
|
+
// back into sproutboat.jsonc before the build bakes them into the artifact.
|
|
241
|
+
if (!args.includes("--no-provision") && !dryRun) await provisionBindings(directory);
|
|
193
242
|
const built = await build(directory);
|
|
194
243
|
projectName = built.project.config.name;
|
|
195
244
|
artifactDir = built.artifact.artifactDir;
|
|
@@ -462,6 +511,63 @@ async function secrets(args: string[]) {
|
|
|
462
511
|
console.log(`Set ${name} — applies on the next deploy or sprout restart`);
|
|
463
512
|
}
|
|
464
513
|
|
|
514
|
+
const RESOURCE_KINDS = ["kv", "d1", "r2", "queue"];
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* #74 — account-level storage resources. Unlike `secrets`/`domains` these are
|
|
518
|
+
* not project-scoped, so there's no `readProject` here. `create` prints the
|
|
519
|
+
* `<kind>_<id>` handle to paste into `sproutboat.jsonc` bindings.
|
|
520
|
+
*/
|
|
521
|
+
async function resource(args: string[]) {
|
|
522
|
+
const sub = args[0] && ["list", "create", "rename", "delete"].includes(args[0]) ? args.shift()! : "list";
|
|
523
|
+
const { apiUrl, token } = await apiCredentials();
|
|
524
|
+
const base = `${apiUrl}/api/resources`;
|
|
525
|
+
const auth = { "x-api-key": token };
|
|
526
|
+
|
|
527
|
+
if (sub === "list") {
|
|
528
|
+
const kindFilter = args[0];
|
|
529
|
+
const body = await responseText(await fetch(base, { headers: auth }), "could not list resources");
|
|
530
|
+
const parsed = jsonObject(parseJsonValue(body));
|
|
531
|
+
const rows = (parsed && Array.isArray(parsed.resources) ? parsed.resources : [])
|
|
532
|
+
.map((entry) => jsonObject(entry))
|
|
533
|
+
.filter((entry): entry is JsonObject => Boolean(entry) && (!kindFilter || entry!.kind === kindFilter));
|
|
534
|
+
if (rows.length === 0) { console.log(kindFilter ? `no ${kindFilter} resources` : "no resources"); return; }
|
|
535
|
+
for (const row of rows) console.log(`${String(row.kind).padEnd(9)} ${String(row.id).padEnd(30)} ${String(row.name)}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (sub === "create") {
|
|
540
|
+
const [kind, name] = args;
|
|
541
|
+
if (!kind || !RESOURCE_KINDS.includes(kind)) usageError(`resource create: kind must be one of ${RESOURCE_KINDS.join(", ")}`, "resource create <kind> <name>");
|
|
542
|
+
if (!name) usageError("resource create: missing <name>", "resource create <kind> <name>");
|
|
543
|
+
const body = await responseText(
|
|
544
|
+
await fetch(base, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ kind, name }) }),
|
|
545
|
+
"create rejected",
|
|
546
|
+
);
|
|
547
|
+
const record = jsonObject(jsonObject(parseJsonValue(body))?.resource ?? null);
|
|
548
|
+
if (!record || !isString(record.id)) fail("create response was not a resource");
|
|
549
|
+
console.log(ok(`created ${kind} ${bold(String(record.name))}`));
|
|
550
|
+
console.log(record.id);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (sub === "rename") {
|
|
555
|
+
const [id, name] = args;
|
|
556
|
+
if (!id || !name) usageError("resource rename: need <id> <name>", "resource rename <id> <name>");
|
|
557
|
+
await responseText(
|
|
558
|
+
await fetch(`${base}/${id}`, { method: "PATCH", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ name }) }),
|
|
559
|
+
"rename rejected",
|
|
560
|
+
);
|
|
561
|
+
console.log(ok(`renamed ${id} → ${name}`));
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const id = args[0];
|
|
566
|
+
if (!id) usageError("resource delete: missing <id>", "resource delete <id>");
|
|
567
|
+
await responseText(await fetch(`${base}/${id}`, { method: "DELETE", headers: auth }), "delete rejected");
|
|
568
|
+
console.log(ok(`deleted ${id}`));
|
|
569
|
+
}
|
|
570
|
+
|
|
465
571
|
async function deleteProject(args: string[]) {
|
|
466
572
|
// `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
|
|
467
573
|
const positional: string[] = [];
|
|
@@ -521,6 +627,7 @@ switch (command) {
|
|
|
521
627
|
case "rollback": await rollback(args); break;
|
|
522
628
|
case "domains": await domains(args); break;
|
|
523
629
|
case "secrets": await secrets(args); break;
|
|
630
|
+
case "resource": await resource(args); break;
|
|
524
631
|
case "tail": await tail(args); break;
|
|
525
632
|
case "delete": await deleteProject(args); break;
|
|
526
633
|
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 ?? "deploy-scoped store (--no-provision) — drop the flag to auto-provision a persistent resource"]);
|
|
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
|
@@ -31,8 +31,8 @@ export const COMMANDS: readonly Command[] = [
|
|
|
31
31
|
summary: "Cross-compile the native-fetch sprout (Porffor + Zig)." },
|
|
32
32
|
|
|
33
33
|
{ name: "deploy", group: "Ship", emoji: "🚀",
|
|
34
|
-
args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait]", brief: "[project-dir] [--dry-run]",
|
|
35
|
-
summary: "Build (unless --artifact), print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check." },
|
|
34
|
+
args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]", brief: "[project-dir] [--dry-run]",
|
|
35
|
+
summary: "Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores." },
|
|
36
36
|
{ name: "versions", group: "Ship", emoji: "📜", args: "list [project-dir]",
|
|
37
37
|
summary: "List the project's deployed versions." },
|
|
38
38
|
{ name: "rollback", group: "Ship", emoji: "⏮", args: "<version-id> [project-dir]", brief: "<version-id>",
|
|
@@ -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." },
|