@bytesbrains/cruise 0.0.0-stage → 0.12.1

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.
Files changed (41) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +59 -2
  3. package/dist/budget-FIRNSN67.mjs +132 -0
  4. package/dist/chunk-2FB2T3UI.mjs +122 -0
  5. package/dist/chunk-2NVDAK5J.mjs +76 -0
  6. package/dist/chunk-42ERHAJD.mjs +79 -0
  7. package/dist/chunk-4447IUTF.mjs +7 -0
  8. package/dist/chunk-5TVD3ZAI.mjs +69 -0
  9. package/dist/chunk-7NQHBXVZ.mjs +104 -0
  10. package/dist/chunk-BNP63KB2.mjs +42 -0
  11. package/dist/chunk-FGLMW5IY.mjs +575 -0
  12. package/dist/chunk-GUZNI3NS.mjs +267 -0
  13. package/dist/chunk-HQDQTXRX.mjs +57 -0
  14. package/dist/chunk-ICJVJSTC.mjs +42 -0
  15. package/dist/chunk-JZPUTXM7.mjs +236 -0
  16. package/dist/chunk-LALZUKEC.mjs +137 -0
  17. package/dist/chunk-N3YKWKOA.mjs +9 -0
  18. package/dist/chunk-O7TNRKSA.mjs +2 -0
  19. package/dist/chunk-RT4R2W6H.mjs +21 -0
  20. package/dist/chunk-SYTYOTO4.mjs +18 -0
  21. package/dist/chunk-T3ICD43U.mjs +15 -0
  22. package/dist/chunk-THEXUHQC.mjs +98 -0
  23. package/dist/chunk-UNXM6FSM.mjs +83 -0
  24. package/dist/chunk-WS6NFRIU.mjs +66 -0
  25. package/dist/chunk-XOH3XKM3.mjs +126 -0
  26. package/dist/compress-3DWG35BY.mjs +49 -0
  27. package/dist/conformance-NPFDYCQL.mjs +922 -0
  28. package/dist/cruise.mjs +693 -0
  29. package/dist/fallback-P5KK55M4.mjs +139 -0
  30. package/dist/keys-FWPQPMTW.mjs +127 -0
  31. package/dist/ledger-XBGWXY6C.mjs +97 -0
  32. package/dist/member-ZBV4YG2M.mjs +74 -0
  33. package/dist/report-VRYJ3W5L.mjs +414 -0
  34. package/dist/service-key-N2RZ7FMZ.mjs +59 -0
  35. package/dist/smoke-ZNYX6RO5.mjs +105 -0
  36. package/dist/sweep-SZX7OIDK.mjs +33 -0
  37. package/dist/tenant-4YDGI23Q.mjs +122 -0
  38. package/dist/tier-4L3SPLFP.mjs +116 -0
  39. package/package.json +38 -4
  40. package/templates/docker-compose.yml +66 -0
  41. package/templates/env.example +44 -0
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+ import {
4
+ DEMO_ENVIRONMENT
5
+ } from "./chunk-N3YKWKOA.mjs";
6
+ import {
7
+ assertUid
8
+ } from "./chunk-2NVDAK5J.mjs";
9
+ import {
10
+ CONTRIBUTOR_LANE_PREFIX,
11
+ LANE_PREFIX,
12
+ SLUG
13
+ } from "./chunk-FGLMW5IY.mjs";
14
+
15
+ // src/tenants-prepaid.ts
16
+ var STARTING_WALLET_SOFT_MICROS = 1e6;
17
+ function prepaidOn(environment) {
18
+ return environment !== DEMO_ENVIRONMENT;
19
+ }
20
+ function walletInsert(db, tenantId, actor) {
21
+ return db.prepare(
22
+ `INSERT INTO budgets (scope, subject, period, soft_micros, hard_micros, updated_by)
23
+ VALUES ('tenant', ?1, 'lifetime', ?2, (SELECT COALESCE(sum(micros), 0) FROM credits WHERE tenant_id = ?1), ?3)`
24
+ ).bind(tenantId, STARTING_WALLET_SOFT_MICROS, actor);
25
+ }
26
+ var SIGNUP_MADE = `EXISTS (SELECT 1 FROM audit_events a
27
+ WHERE a.action = 'tenant.provision' AND a.subject = t.id
28
+ AND substr(a.actor, 1, 12) = 'key:cru_svc_')`;
29
+ async function unprepaidSignups(db) {
30
+ const { results } = await db.prepare(
31
+ `SELECT t.id, t.billable
32
+ FROM tenants t
33
+ WHERE ${SIGNUP_MADE}
34
+ AND NOT EXISTS (SELECT 1 FROM budgets b WHERE b.scope = 'tenant' AND b.subject = t.id)
35
+ ORDER BY t.created_at, t.id`
36
+ ).all();
37
+ return results.map((row) => ({ id: row.id, billable: row.billable === 1 }));
38
+ }
39
+ async function prepaySignups(db, environment, actor) {
40
+ if (!prepaidOn(environment)) {
41
+ throw new Error(
42
+ "Signup on the demo is not prepaid: every price there is zero, and a zero wallet would refuse every request. Nothing was changed."
43
+ );
44
+ }
45
+ const pending = await unprepaidSignups(db);
46
+ for (const tenant of pending) {
47
+ await db.batch([
48
+ walletInsert(db, tenant.id, actor),
49
+ ...tenant.billable ? [] : [db.prepare(`UPDATE tenants SET billable = 1 WHERE id = ?`).bind(tenant.id)],
50
+ db.prepare(
51
+ `INSERT INTO audit_events (id, actor, action, subject, detail, tenant_id)
52
+ VALUES (?, ?, 'tenant.prepay', ?, ?, ?)`
53
+ ).bind(
54
+ crypto.randomUUID(),
55
+ actor,
56
+ tenant.id,
57
+ JSON.stringify({ wallet_written: true, billable_set: !tenant.billable }),
58
+ tenant.id
59
+ )
60
+ ]);
61
+ }
62
+ return pending;
63
+ }
64
+
65
+ // src/tenants-validate.ts
66
+ var GLOBAL_NAMESPACES = [LANE_PREFIX, CONTRIBUTOR_LANE_PREFIX].map((prefix) => prefix.slice(0, -1));
67
+ var MAX_NAME_LENGTH = 128;
68
+ function softFor(hardMicros) {
69
+ return Math.round(hardMicros * 0.8);
70
+ }
71
+ async function assertTenantId(db, rawId) {
72
+ const id = rawId.trim();
73
+ if (!SLUG.test(id)) {
74
+ throw new Error(
75
+ `'${rawId}' is not a valid tenant id: lowercase letters, digits and dashes, up to 64 characters. It is the lane namespace \u2014 lane ids are '<tenant>/<slug>' \u2014 and cannot be changed afterwards.`
76
+ );
77
+ }
78
+ if (GLOBAL_NAMESPACES.includes(id)) {
79
+ throw new Error(
80
+ `'${id}' is a global lane namespace and cannot be a tenant. Every tenant can already see '${id}/\u2026' lanes.`
81
+ );
82
+ }
83
+ const upstream = await db.prepare(`SELECT id FROM upstreams WHERE id = ?`).bind(id).first();
84
+ if (upstream !== null) {
85
+ throw new Error(
86
+ `'${id}' is an upstream id, so '${id}/<model>' would be ambiguous between a lane and a model. Choose another.`
87
+ );
88
+ }
89
+ return id;
90
+ }
91
+ function assertGateway(value) {
92
+ if (value !== "gw-sensitive" && value !== "gw-internal") {
93
+ throw new Error(
94
+ `Unknown gateway: ${value}. Use gw-sensitive (retention off) or gw-internal (logs payloads).`
95
+ );
96
+ }
97
+ return value;
98
+ }
99
+ function gatewayRefusal(gateway, operator) {
100
+ return gateway === "gw-internal" && !operator ? "gw-internal logs payloads and is reserved for the tenant that operates Cruise. Omit 'gateway' for gw-sensitive, which logs none." : null;
101
+ }
102
+ function assertName(rawName) {
103
+ const name = rawName.trim();
104
+ if (name === "") throw new Error("A tenant name is required.");
105
+ if (name.length > MAX_NAME_LENGTH) throw new Error(`A tenant name cannot exceed ${MAX_NAME_LENGTH} characters.`);
106
+ return name;
107
+ }
108
+ function assertCap(micros, label) {
109
+ if (!Number.isInteger(micros)) throw new Error(`${label} must be an integer count of micros, got ${micros}`);
110
+ if (micros < 0) throw new Error(`${label} cannot be negative.`);
111
+ return micros;
112
+ }
113
+
114
+ // src/tenants.ts
115
+ var STARTING_HARD_MICROS = 5e6;
116
+ var STARTING_PROJECT_SLUG = "default";
117
+ var STARTING_TIER = "free";
118
+ async function readTenant(db, tenantId) {
119
+ const tenant = await db.prepare(`SELECT id, name, tier, billable, created_at FROM tenants WHERE id = ?`).bind(tenantId).first();
120
+ if (tenant === null) return null;
121
+ const { results: projects } = await db.prepare(
122
+ `SELECT p.id, p.slug, p.gateway, b.period, b.soft_micros, b.hard_micros
123
+ FROM projects p
124
+ LEFT JOIN budgets b ON b.scope = 'project' AND b.subject = p.id
125
+ WHERE p.tenant_id = ?
126
+ ORDER BY p.slug`
127
+ ).bind(tenantId).all();
128
+ const { results: members } = await db.prepare(`SELECT uid, role FROM memberships WHERE tenant_id = ? ORDER BY created_at, uid`).bind(tenantId).all();
129
+ return {
130
+ id: tenant.id,
131
+ name: tenant.name,
132
+ tier: tenant.tier,
133
+ billable: tenant.billable === 1,
134
+ createdAt: tenant.created_at,
135
+ projects: projects.map(projectView),
136
+ members: members.map(memberView)
137
+ };
138
+ }
139
+ function projectView(row) {
140
+ return {
141
+ id: row.id,
142
+ slug: row.slug,
143
+ gateway: row.gateway,
144
+ period: row.period,
145
+ softMicros: row.soft_micros,
146
+ hardMicros: row.hard_micros
147
+ };
148
+ }
149
+ function memberView(row) {
150
+ return { uid: row.uid, role: row.role };
151
+ }
152
+ function alreadyThere(tenant, ownerUid) {
153
+ const owners = tenant.members.filter((member) => member.role === "owner").map((member) => member.uid);
154
+ return owners.includes(ownerUid) ? { created: false, tenant } : { created: false, tenant, ownerConflict: { given: ownerUid, owners } };
155
+ }
156
+ async function provisionTenant(db, request, actor) {
157
+ const id = await assertTenantId(db, request.id);
158
+ const name = assertName(request.name);
159
+ const ownerUid = assertUid(request.ownerUid);
160
+ const gateway = assertGateway(request.gateway ?? "gw-sensitive");
161
+ const gatewayRefused = gatewayRefusal(gateway, false);
162
+ if (gatewayRefused !== null) throw new Error(gatewayRefused);
163
+ const tier = request.tier ?? STARTING_TIER;
164
+ const known = await db.prepare(`SELECT id FROM tiers WHERE id = ?`).bind(tier).first();
165
+ if (known === null) throw new Error(`No such tier: ${tier}`);
166
+ const hardMicros = assertCap(request.hardMicros ?? STARTING_HARD_MICROS, "--hard");
167
+ if (hardMicros === 0) {
168
+ throw new Error(
169
+ "A hard cap of zero is an account that cannot serve a single request. Leave --hard out for the starting cap, or give the figure you mean."
170
+ );
171
+ }
172
+ const softMicros = request.softMicros === void 0 ? softFor(hardMicros) : request.softMicros === null ? null : assertCap(request.softMicros, "--soft");
173
+ if (softMicros !== null && softMicros > hardMicros) {
174
+ throw new Error(`The soft threshold (${softMicros}) cannot exceed the hard cap (${hardMicros}).`);
175
+ }
176
+ const prepaid = request.prepaid === true;
177
+ const existing = await readTenant(db, id);
178
+ if (existing !== null) return alreadyThere(existing, ownerUid);
179
+ const projectId = `${id}/${STARTING_PROJECT_SLUG}`;
180
+ const taken = await db.prepare(`SELECT tenant_id FROM projects WHERE id = ?`).bind(projectId).first();
181
+ if (taken !== null) {
182
+ throw new Error(`Project id '${projectId}' already belongs to tenant '${taken.tenant_id}'.`);
183
+ }
184
+ try {
185
+ await db.batch([
186
+ db.prepare(`INSERT INTO tenants (id, name, tier, billable) VALUES (?1, ?2, ?3, ?4)`).bind(id, name, tier, prepaid ? 1 : 0),
187
+ // `budget_behaviour` is left at its schema default of `refuse`. v0 ships
188
+ // only the refusal path — `degrade` waits on auto mode (#19) — so recording
189
+ // an intent nothing honours would be a promise to a customer we cannot keep.
190
+ db.prepare(`INSERT INTO projects (id, tenant_id, slug, name, gateway) VALUES (?1, ?2, ?3, ?4, ?5)`).bind(projectId, id, STARTING_PROJECT_SLUG, name, gateway),
191
+ db.prepare(
192
+ `INSERT INTO budgets (scope, subject, period, soft_micros, hard_micros, updated_by)
193
+ VALUES ('project', ?1, 'day', ?2, ?3, ?4)`
194
+ ).bind(projectId, softMicros, hardMicros, actor),
195
+ db.prepare(`INSERT INTO memberships (uid, tenant_id, role, created_by) VALUES (?1, ?2, 'owner', ?3)`).bind(ownerUid, id, actor),
196
+ // In this batch and not after it, for the budget's reason one row up:
197
+ // a prepaid tenant without its wallet is served uncapped at tenant scope
198
+ // — the #376 incident — so the account exists with it or not at all.
199
+ ...prepaid ? [walletInsert(db, id, actor)] : [],
200
+ db.prepare(
201
+ `INSERT INTO audit_events (id, actor, action, subject, detail, tenant_id)
202
+ VALUES (?, ?, 'tenant.provision', ?, ?, ?)`
203
+ ).bind(
204
+ crypto.randomUUID(),
205
+ actor,
206
+ id,
207
+ JSON.stringify({
208
+ name,
209
+ tier,
210
+ project: projectId,
211
+ gateway,
212
+ soft_micros: softMicros,
213
+ hard_micros: hardMicros,
214
+ owner: ownerUid,
215
+ prepaid
216
+ }),
217
+ // The tenant this event is about, so the trail can be scoped
218
+ // without guessing at `subject` (0044).
219
+ id
220
+ )
221
+ ]);
222
+ } catch (error) {
223
+ if (error instanceof Error && /partial batch: [1-9]\d* of/.test(error.message)) throw error;
224
+ if (!(error instanceof Error) || !/UNIQUE constraint failed/i.test(error.message)) throw error;
225
+ const raced = await readTenant(db, id);
226
+ if (raced === null) throw error;
227
+ return alreadyThere(raced, ownerUid);
228
+ }
229
+ const tenant = await readTenant(db, id);
230
+ if (tenant === null) throw new Error(`Provisioning ${id} wrote nothing. No part of the account was created.`);
231
+ return { created: true, tenant };
232
+ }
233
+ async function listTenants(db) {
234
+ const { results: tenants } = await db.prepare(`SELECT id, name, tier, billable, created_at FROM tenants ORDER BY created_at, id`).all();
235
+ const { results: projects } = await db.prepare(
236
+ `SELECT p.tenant_id, p.id, p.slug, p.gateway, b.period, b.soft_micros, b.hard_micros
237
+ FROM projects p
238
+ LEFT JOIN budgets b ON b.scope = 'project' AND b.subject = p.id
239
+ ORDER BY p.tenant_id, p.slug`
240
+ ).all();
241
+ const { results: members } = await db.prepare(`SELECT tenant_id, uid, role FROM memberships ORDER BY tenant_id, created_at, uid`).all();
242
+ const byTenant = (rows) => {
243
+ const grouped = /* @__PURE__ */ new Map();
244
+ for (const row of rows) grouped.set(row.tenant_id, [...grouped.get(row.tenant_id) ?? [], row]);
245
+ return grouped;
246
+ };
247
+ const projectsFor = byTenant(projects);
248
+ const membersFor = byTenant(members);
249
+ return tenants.map((tenant) => ({
250
+ id: tenant.id,
251
+ name: tenant.name,
252
+ tier: tenant.tier,
253
+ billable: tenant.billable === 1,
254
+ createdAt: tenant.created_at,
255
+ projects: (projectsFor.get(tenant.id) ?? []).map(projectView),
256
+ members: (membersFor.get(tenant.id) ?? []).map(memberView)
257
+ }));
258
+ }
259
+
260
+ export {
261
+ prepaidOn,
262
+ unprepaidSignups,
263
+ prepaySignups,
264
+ assertGateway,
265
+ provisionTenant,
266
+ listTenants
267
+ };
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+ import {
4
+ isSessionId,
5
+ sessionDigest
6
+ } from "./chunk-SYTYOTO4.mjs";
7
+
8
+ // src/report-compression.ts
9
+ async function compressionSavings(db, month = null, tenantId) {
10
+ const scope = tenantId === void 0 ? "" : "AND tenant_id = ?2";
11
+ const statement = db.prepare(
12
+ `SELECT project_id AS projectId,
13
+ strftime('%Y-%m', at) AS month,
14
+ count(*) AS requests,
15
+ sum(CASE WHEN compress_bytes_after < compress_bytes_before THEN 1 ELSE 0 END) AS applied,
16
+ COALESCE(sum(compress_bytes_before), 0) AS bytesBefore,
17
+ COALESCE(sum(compress_bytes_after), 0) AS bytesAfter,
18
+ COALESCE(sum(prompt_tokens), 0) AS promptTokens
19
+ FROM ledger
20
+ WHERE compress_bytes_before IS NOT NULL
21
+ AND (?1 IS NULL OR strftime('%Y-%m', at) = ?1) ${scope}
22
+ GROUP BY project_id, month
23
+ ORDER BY month DESC, project_id`
24
+ );
25
+ const { results } = await (tenantId === void 0 ? statement.bind(month) : statement.bind(month, tenantId)).all();
26
+ return results;
27
+ }
28
+ async function compressionBySession(db, session, tenantId) {
29
+ if (!isSessionId(session)) throw new Error(`'${session}' is not a session id Cruise accepts: 1-128 of letters, digits, '.', '_', ':' or '-'.`);
30
+ const scope = tenantId === void 0 ? "" : "AND tenant_id = ?2";
31
+ const statement = db.prepare(
32
+ `SELECT project_id AS projectId,
33
+ count(*) AS requests,
34
+ count(compress_bytes_before) AS ran,
35
+ sum(CASE WHEN compress_bytes_after < compress_bytes_before THEN 1 ELSE 0 END) AS applied,
36
+ COALESCE(sum(compress_bytes_before), 0) AS bytesBefore,
37
+ COALESCE(sum(compress_bytes_after), 0) AS bytesAfter,
38
+ COALESCE(sum(CASE WHEN compress_bytes_before IS NOT NULL THEN prompt_tokens END), 0) AS promptTokens,
39
+ COALESCE(sum(cost_micros), 0) AS micros,
40
+ min(at) AS first,
41
+ max(at) AS last
42
+ FROM ledger
43
+ WHERE session_digest = ?1 ${scope}
44
+ GROUP BY project_id
45
+ ORDER BY first`
46
+ );
47
+ const digest = await sessionDigest(session);
48
+ const { results } = await (tenantId === void 0 ? statement.bind(digest) : statement.bind(digest, tenantId)).all();
49
+ return results;
50
+ }
51
+ var savedShare = (row) => row.bytesBefore > 0 ? (row.bytesBefore - row.bytesAfter) / row.bytesBefore : null;
52
+
53
+ export {
54
+ compressionSavings,
55
+ compressionBySession,
56
+ savedShare
57
+ };
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+
4
+ // src/pricing-usage.ts
5
+ var IMPLAUSIBLE_TOKENS = 1e8;
6
+ function tokenCount(value) {
7
+ if (typeof value !== "number") return null;
8
+ if (!Number.isSafeInteger(value)) return null;
9
+ if (value < 0 || value > IMPLAUSIBLE_TOKENS) return null;
10
+ return value;
11
+ }
12
+ function usageFromJson(body) {
13
+ const usage = body?.usage;
14
+ if (!usage) return null;
15
+ const prompt = tokenCount(usage.prompt_tokens);
16
+ const completion = tokenCount(usage.completion_tokens);
17
+ if (prompt === null || completion === null) {
18
+ console.warn(
19
+ `Refusing implausible usage from upstream: prompt=${String(usage.prompt_tokens)} completion=${String(usage.completion_tokens)}. Recorded as no_usage and charged the reservation.`
20
+ );
21
+ return null;
22
+ }
23
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
24
+ const cached = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens;
25
+ const searches = tokenCount(usage.server_tool_use?.web_search_requests ?? usage.num_search_queries);
26
+ const contextSize = typeof usage.search_context_size === "string" ? usage.search_context_size : void 0;
27
+ return {
28
+ prompt_tokens: prompt,
29
+ completion_tokens: completion,
30
+ // These two only ever refine a figure already accepted, so an implausible
31
+ // value drops to zero rather than discarding the whole reading.
32
+ reasoning_tokens: tokenCount(reasoning) ?? 0,
33
+ cached_input_tokens: tokenCount(cached) ?? 0,
34
+ ...searches === null ? {} : { web_search_requests: searches },
35
+ ...contextSize === void 0 ? {} : { search_context_size: contextSize }
36
+ };
37
+ }
38
+
39
+ export {
40
+ tokenCount,
41
+ usageFromJson
42
+ };
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ // Built by packages/cli/build.ts.
3
+
4
+ // scripts/d1-http.ts
5
+ var NonRetryable = class extends Error {
6
+ };
7
+ var PartialBatchError = class extends Error {
8
+ applied;
9
+ total;
10
+ cause;
11
+ constructor(applied, total, cause) {
12
+ const detail = cause instanceof Error ? cause.message : String(cause);
13
+ super(
14
+ applied === 0 ? `partial batch: 0 of ${total} statements applied: ${detail}` : `partial batch: ${applied} of ${total} statements applied; preceding statements stand: ${detail}`
15
+ );
16
+ this.name = "PartialBatchError";
17
+ this.applied = applied;
18
+ this.total = total;
19
+ this.cause = cause;
20
+ }
21
+ };
22
+ var HttpD1 = class {
23
+ #accountId;
24
+ #databaseId;
25
+ #token;
26
+ constructor(accountId, databaseId, token) {
27
+ this.#accountId = accountId;
28
+ this.#databaseId = databaseId;
29
+ this.#token = token;
30
+ }
31
+ /**
32
+ * Run a statement, retrying transport failures.
33
+ *
34
+ * Every CLI reaches D1 through here, and a single `fetch failed` used to end
35
+ * whatever was being done. That matters more than convenience for the ones that
36
+ * batch: `batch()` here is sequential, so a blip partway through a grant could
37
+ * leave a membership with no audit row beside it.
38
+ *
39
+ * Seven attempts on a quadratic backoff — about 45 seconds in all — since
40
+ * #549. Three at 0.5s and 1s gave a stall 1.5 seconds to clear, and on
41
+ * 2026-09-30 four local conformance runs in a row died of `fetch failed`
42
+ * partway through a forty-minute catalogue, on a connection where one request
43
+ * in thirty took six seconds. The fifth, with this, ran to the end.
44
+ *
45
+ * Only transport failures retry. A statement the API *rejected* is deterministic
46
+ * and repeating it would just fail again more slowly.
47
+ */
48
+ async query(sql, params, attempts = 7) {
49
+ let lastError;
50
+ for (let attempt = 1; attempt <= attempts; attempt++) {
51
+ try {
52
+ const response = await fetch(
53
+ `https://api.cloudflare.com/client/v4/accounts/${this.#accountId}/d1/database/${this.#databaseId}/query`,
54
+ {
55
+ method: "POST",
56
+ headers: { authorization: `Bearer ${this.#token}`, "content-type": "application/json" },
57
+ body: JSON.stringify({ sql, params })
58
+ }
59
+ );
60
+ const body = await response.json();
61
+ if (!body.success) {
62
+ throw new NonRetryable(
63
+ `D1 query failed: ${body.errors?.map((e) => e.message).join("; ") ?? response.status}`
64
+ );
65
+ }
66
+ const first = body.result[0];
67
+ return { results: first?.results ?? [], changes: first?.meta?.changes ?? 0 };
68
+ } catch (cause) {
69
+ if (cause instanceof NonRetryable) throw cause;
70
+ lastError = cause;
71
+ if (attempt < attempts) await new Promise((r) => setTimeout(r, attempt * attempt * 500));
72
+ }
73
+ }
74
+ throw new Error(`D1 unreachable after ${attempts} attempts: ${String(lastError)}`);
75
+ }
76
+ prepare(sql) {
77
+ return new HttpStatement(this, sql, []);
78
+ }
79
+ /**
80
+ * Sequential, not atomic — the REST API has no batch equivalent.
81
+ *
82
+ * `issueKey` is written to tolerate exactly this: the key is inserted revoked
83
+ * and activated by the final statement, so a partial run leaves a dead key
84
+ * rather than an unrestricted live one.
85
+ *
86
+ * **It returns what D1's own `batch` returns**: one result per statement, in
87
+ * order, each carrying `meta.changes`. It returned `void` until the first
88
+ * live `demo:seed`, which reads its results positionally to say which models
89
+ * it actually measured — and got `Cannot read properties of undefined
90
+ * (reading '5')`, the boundary between the five fixed statements and the
91
+ * per-model ones (`src/demo-catalogue.ts`). Nothing in the suite could catch
92
+ * it: tests run against workerd's real binding, which returns the array, so
93
+ * this shim is the one D1 implementation no test exercises. Anything reading
94
+ * a batch's results through the CLI was broken and this is the only caller
95
+ * that does.
96
+ *
97
+ * Returning results does not make it atomic, and review of PR #344 was right
98
+ * to ask what a failure partway through `demo:seed` leaves: a catalogue with
99
+ * its measurements cleared and not yet re-stamped, which is a demo that
100
+ * routes nothing. The recovery is to run the seeder again — every statement
101
+ * it issues is an `UPDATE` with no read-modify-write, so a second run
102
+ * converges on the same rows whatever the first one got through
103
+ * (`src/demo-catalogue.ts`). That is a property of that caller, not of this
104
+ * method, and the reason it is written that way. A caller that needs the
105
+ * transaction has to have it inside the Worker, where `db.batch` is real; the
106
+ * REST API this speaks has no batch endpoint to borrow one from.
107
+ *
108
+ * A mid-loop failure is thrown as `PartialBatchError`, naming how many
109
+ * statements already landed (#357). Silent rethrow of the inner cause is how
110
+ * `provisionTenant`'s race catch mistook a partial write for "already there".
111
+ */
112
+ async batch(statements) {
113
+ const results = [];
114
+ for (const statement of statements) {
115
+ try {
116
+ results.push(await statement.run());
117
+ } catch (cause) {
118
+ throw new PartialBatchError(results.length, statements.length, cause);
119
+ }
120
+ }
121
+ return results;
122
+ }
123
+ };
124
+ var HttpStatement = class _HttpStatement {
125
+ #db;
126
+ #sql;
127
+ #params;
128
+ constructor(db, sql, params) {
129
+ this.#db = db;
130
+ this.#sql = sql;
131
+ this.#params = params;
132
+ }
133
+ bind(...params) {
134
+ return new _HttpStatement(this.#db, this.#sql, params);
135
+ }
136
+ async first() {
137
+ const { results } = await this.#db.query(this.#sql, this.#params);
138
+ return results[0] ?? null;
139
+ }
140
+ async all() {
141
+ const { results } = await this.#db.query(this.#sql, this.#params);
142
+ return { results };
143
+ }
144
+ async run() {
145
+ const { changes } = await this.#db.query(this.#sql, this.#params);
146
+ return { meta: { changes } };
147
+ }
148
+ };
149
+
150
+ // scripts/d1-local.ts
151
+ import { readFileSync } from "node:fs";
152
+ import path from "node:path";
153
+ function adminToken() {
154
+ if (process.env.CRUISE_ADMIN_TOKEN) return process.env.CRUISE_ADMIN_TOKEN;
155
+ const file = path.join(process.env.CRUISE_DATA_DIR || "/data", "secrets.json");
156
+ try {
157
+ return JSON.parse(readFileSync(file, "utf8")).ADMIN_TOKEN;
158
+ } catch {
159
+ throw new Error(
160
+ `No admin token: set CRUISE_ADMIN_TOKEN, or run this where ${file} exists (inside the container: docker compose exec cruise \u2026).`
161
+ );
162
+ }
163
+ }
164
+ var LocalD1 = class {
165
+ #url;
166
+ #token;
167
+ constructor(url = process.env.CRUISE_ADMIN_URL || "http://127.0.0.1:8788", token = adminToken()) {
168
+ this.#url = url.replace(/\/$/, "");
169
+ this.#token = token;
170
+ }
171
+ async post(route, body) {
172
+ let response;
173
+ try {
174
+ response = await fetch(`${this.#url}${route}`, {
175
+ method: "POST",
176
+ headers: { authorization: `Bearer ${this.#token}`, "content-type": "application/json" },
177
+ body: JSON.stringify(body)
178
+ });
179
+ } catch (cause) {
180
+ throw new Error(`Cannot reach the self-hosted admin endpoint at ${this.#url}: is Cruise running? (${String(cause)})`);
181
+ }
182
+ const answer = await response.json();
183
+ if (!answer.success) throw new Error(`D1 query failed: ${answer.error ?? response.status}`);
184
+ return answer;
185
+ }
186
+ async query(sql, params) {
187
+ const answer = await this.post("/d1/query", { sql, params });
188
+ return { results: answer.results ?? [], changes: answer.changes ?? 0 };
189
+ }
190
+ prepare(sql) {
191
+ return new LocalStatement(this, sql, []);
192
+ }
193
+ /** One transaction: every statement applies or none does. */
194
+ async batch(statements) {
195
+ const answer = await this.post(
196
+ "/d1/batch",
197
+ statements.map((s) => s.toJSON())
198
+ );
199
+ return answer.results.map((r) => ({ results: r.results, meta: { changes: r.changes } }));
200
+ }
201
+ };
202
+ var LocalStatement = class _LocalStatement {
203
+ #db;
204
+ #sql;
205
+ #params;
206
+ constructor(db, sql, params) {
207
+ this.#db = db;
208
+ this.#sql = sql;
209
+ this.#params = params;
210
+ }
211
+ bind(...params) {
212
+ return new _LocalStatement(this.#db, this.#sql, params);
213
+ }
214
+ toJSON() {
215
+ return { sql: this.#sql, params: this.#params };
216
+ }
217
+ async first(column) {
218
+ const { results } = await this.#db.query(this.#sql, this.#params);
219
+ const row = results[0];
220
+ if (row === void 0) return null;
221
+ return column === void 0 ? row : row[column];
222
+ }
223
+ async all() {
224
+ const { results } = await this.#db.query(this.#sql, this.#params);
225
+ return { results };
226
+ }
227
+ async run() {
228
+ const { changes } = await this.#db.query(this.#sql, this.#params);
229
+ return { meta: { changes } };
230
+ }
231
+ };
232
+
233
+ export {
234
+ HttpD1,
235
+ LocalD1
236
+ };