@bytesbrains/cruise 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +59 -2
- package/dist/budget-FIRNSN67.mjs +132 -0
- package/dist/chunk-2FB2T3UI.mjs +122 -0
- package/dist/chunk-2NVDAK5J.mjs +76 -0
- package/dist/chunk-42ERHAJD.mjs +79 -0
- package/dist/chunk-4447IUTF.mjs +7 -0
- package/dist/chunk-5TVD3ZAI.mjs +69 -0
- package/dist/chunk-7NQHBXVZ.mjs +104 -0
- package/dist/chunk-BNP63KB2.mjs +42 -0
- package/dist/chunk-FGLMW5IY.mjs +575 -0
- package/dist/chunk-GUZNI3NS.mjs +267 -0
- package/dist/chunk-HQDQTXRX.mjs +57 -0
- package/dist/chunk-ICJVJSTC.mjs +42 -0
- package/dist/chunk-JZPUTXM7.mjs +236 -0
- package/dist/chunk-LALZUKEC.mjs +137 -0
- package/dist/chunk-N3YKWKOA.mjs +9 -0
- package/dist/chunk-O7TNRKSA.mjs +2 -0
- package/dist/chunk-RT4R2W6H.mjs +21 -0
- package/dist/chunk-SYTYOTO4.mjs +18 -0
- package/dist/chunk-T3ICD43U.mjs +15 -0
- package/dist/chunk-THEXUHQC.mjs +98 -0
- package/dist/chunk-UNXM6FSM.mjs +83 -0
- package/dist/chunk-WS6NFRIU.mjs +66 -0
- package/dist/chunk-XOH3XKM3.mjs +126 -0
- package/dist/compress-3DWG35BY.mjs +49 -0
- package/dist/conformance-NPFDYCQL.mjs +922 -0
- package/dist/cruise.mjs +693 -0
- package/dist/fallback-P5KK55M4.mjs +139 -0
- package/dist/keys-FWPQPMTW.mjs +127 -0
- package/dist/ledger-XBGWXY6C.mjs +97 -0
- package/dist/member-ZBV4YG2M.mjs +74 -0
- package/dist/report-VRYJ3W5L.mjs +414 -0
- package/dist/service-key-N2RZ7FMZ.mjs +59 -0
- package/dist/smoke-ZNYX6RO5.mjs +105 -0
- package/dist/sweep-SZX7OIDK.mjs +33 -0
- package/dist/tenant-4YDGI23Q.mjs +122 -0
- package/dist/tier-4L3SPLFP.mjs +116 -0
- package/package.json +38 -4
- package/templates/docker-compose.yml +66 -0
- 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
|
+
};
|