ccpool-server 0.0.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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/index.js +1838 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1838 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
|
|
6
|
+
// src/app.ts
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
|
|
9
|
+
// ../../packages/core/dist/types.js
|
|
10
|
+
var CAP_KINDS = ["five_hour", "seven_day", "seven_day_opus"];
|
|
11
|
+
var UNKNOWN_USER = "unknown";
|
|
12
|
+
var NAME_DISALLOWED = /[^A-Za-z0-9-]/;
|
|
13
|
+
var MAX_NAME_LENGTH = 32;
|
|
14
|
+
function isValidName(name) {
|
|
15
|
+
if (name.length === 0 || name.length > MAX_NAME_LENGTH)
|
|
16
|
+
return false;
|
|
17
|
+
if (NAME_DISALLOWED.test(name))
|
|
18
|
+
return false;
|
|
19
|
+
return name.toLowerCase() !== UNKNOWN_USER;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ../../packages/core/dist/storage/storage.js
|
|
23
|
+
var SCHEMA_VERSION = 1;
|
|
24
|
+
var DEFAULT_GROUP_ID = "default";
|
|
25
|
+
function isEmptyBatch(b) {
|
|
26
|
+
return b.samples.length === 0 && b.resets.length === 0 && b.messages.length === 0 && b.markers.length === 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ../../packages/core/dist/registry/registry.js
|
|
30
|
+
var RegistryConflictError = class extends Error {
|
|
31
|
+
kind;
|
|
32
|
+
constructor(kind) {
|
|
33
|
+
super(kind === "group-exists" ? "a group for this account already exists" : "member name is taken");
|
|
34
|
+
this.kind = kind;
|
|
35
|
+
this.name = "RegistryConflictError";
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ../../packages/core/dist/state/shares.js
|
|
40
|
+
var HOUR = 60 * 60 * 1e3;
|
|
41
|
+
var CAP_WINDOW_MS = {
|
|
42
|
+
five_hour: 5 * HOUR,
|
|
43
|
+
seven_day: 7 * 24 * HOUR,
|
|
44
|
+
seven_day_opus: 7 * 24 * HOUR
|
|
45
|
+
};
|
|
46
|
+
function tokenWeight(m) {
|
|
47
|
+
return Math.max(0, m.cacheCreationTokens + m.cacheReadTokens + m.outputTokens);
|
|
48
|
+
}
|
|
49
|
+
function isOpus(model) {
|
|
50
|
+
return !!model && model.toLowerCase().includes("opus");
|
|
51
|
+
}
|
|
52
|
+
function attributeShares(samples, messages, now = Date.now(), resets = [], markers = []) {
|
|
53
|
+
const msgs = messages.map((m) => ({ t: Date.parse(m.timestamp), user: m.user, model: m.model, w: tokenWeight(m) })).filter((m) => Number.isFinite(m.t)).sort((a, b) => a.t - b.t);
|
|
54
|
+
const mks = markers.map((m) => ({ t: Date.parse(m.at), user: m.user, model: m.model, w: Math.max(1, m.weight) })).filter((m) => Number.isFinite(m.t)).sort((a, b) => a.t - b.t);
|
|
55
|
+
const out = [];
|
|
56
|
+
for (const cap of CAP_KINDS) {
|
|
57
|
+
const capSamples = samples.filter((s) => s.cap === cap).map((s) => ({ t: Date.parse(s.capturedAt), pct: s.pct })).filter((s) => Number.isFinite(s.t)).sort((a, b) => a.t - b.t);
|
|
58
|
+
if (capSamples.length === 0)
|
|
59
|
+
continue;
|
|
60
|
+
const resetTimes = resets.filter((r) => r.cap === cap).map((r) => Date.parse(r.at)).filter((t) => Number.isFinite(t) && t <= now);
|
|
61
|
+
out.push(...attributeCap(cap, capSamples, msgs, mks, now, resetTimes));
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function attributeCap(cap, capSamples, msgs, markers, now, resetTimes) {
|
|
66
|
+
const cutoff = now - CAP_WINDOW_MS[cap];
|
|
67
|
+
let start = 0;
|
|
68
|
+
for (let i = 1; i < capSamples.length; i++) {
|
|
69
|
+
if (capSamples[i].t < cutoff)
|
|
70
|
+
start = i;
|
|
71
|
+
}
|
|
72
|
+
const lastReset = resetTimes.length ? Math.max(...resetTimes) : -Infinity;
|
|
73
|
+
if (lastReset > -Infinity) {
|
|
74
|
+
let firstAfter = capSamples.length - 1;
|
|
75
|
+
for (let i = 0; i < capSamples.length; i++) {
|
|
76
|
+
if (capSamples[i].t >= lastReset) {
|
|
77
|
+
firstAfter = i;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
start = Math.max(start, firstAfter);
|
|
82
|
+
}
|
|
83
|
+
const win = capSamples.slice(start);
|
|
84
|
+
const opusOnly = cap === "seven_day_opus";
|
|
85
|
+
const attributed = /* @__PURE__ */ new Map();
|
|
86
|
+
attributed.set(UNKNOWN_USER, win[0].pct);
|
|
87
|
+
let mi = 0;
|
|
88
|
+
let ki = 0;
|
|
89
|
+
while (mi < msgs.length && msgs[mi].t <= win[0].t)
|
|
90
|
+
mi++;
|
|
91
|
+
while (ki < markers.length && markers[ki].t <= win[0].t)
|
|
92
|
+
ki++;
|
|
93
|
+
let envMax = win[0].pct;
|
|
94
|
+
for (let i = 1; i < win.length; i++) {
|
|
95
|
+
const cur = win[i];
|
|
96
|
+
const weights = /* @__PURE__ */ new Map();
|
|
97
|
+
let total = 0;
|
|
98
|
+
while (mi < msgs.length && msgs[mi].t <= cur.t) {
|
|
99
|
+
const m = msgs[mi];
|
|
100
|
+
mi++;
|
|
101
|
+
if (opusOnly && !isOpus(m.model))
|
|
102
|
+
continue;
|
|
103
|
+
weights.set(m.user, (weights.get(m.user) ?? 0) + m.w);
|
|
104
|
+
total += m.w;
|
|
105
|
+
}
|
|
106
|
+
const markerWeights = /* @__PURE__ */ new Map();
|
|
107
|
+
let markerTotal = 0;
|
|
108
|
+
while (ki < markers.length && markers[ki].t <= cur.t) {
|
|
109
|
+
const m = markers[ki];
|
|
110
|
+
ki++;
|
|
111
|
+
if (opusOnly && !isOpus(m.model))
|
|
112
|
+
continue;
|
|
113
|
+
markerWeights.set(m.user, (markerWeights.get(m.user) ?? 0) + m.w);
|
|
114
|
+
markerTotal += m.w;
|
|
115
|
+
}
|
|
116
|
+
const newMax = cur.pct > envMax ? cur.pct : envMax;
|
|
117
|
+
const delta = newMax - envMax;
|
|
118
|
+
envMax = newMax;
|
|
119
|
+
if (delta <= 0)
|
|
120
|
+
continue;
|
|
121
|
+
if (total > 0) {
|
|
122
|
+
for (const [u, w] of weights) {
|
|
123
|
+
attributed.set(u, (attributed.get(u) ?? 0) + delta * w / total);
|
|
124
|
+
}
|
|
125
|
+
} else if (markerTotal > 0) {
|
|
126
|
+
for (const [u, w] of markerWeights) {
|
|
127
|
+
attributed.set(u, (attributed.get(u) ?? 0) + delta * w / markerTotal);
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
attributed.set(UNKNOWN_USER, (attributed.get(UNKNOWN_USER) ?? 0) + delta);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const target = win[win.length - 1].pct;
|
|
134
|
+
let nonUnknown = 0;
|
|
135
|
+
for (const [u, p] of attributed)
|
|
136
|
+
if (u !== UNKNOWN_USER)
|
|
137
|
+
nonUnknown += p;
|
|
138
|
+
const rows = [];
|
|
139
|
+
if (nonUnknown > target && nonUnknown > 0) {
|
|
140
|
+
const scale = target / nonUnknown;
|
|
141
|
+
for (const [u, p] of attributed) {
|
|
142
|
+
if (u !== UNKNOWN_USER)
|
|
143
|
+
rows.push({ user: u, cap, pct: p * scale });
|
|
144
|
+
}
|
|
145
|
+
rows.push({ user: UNKNOWN_USER, cap, pct: 0 });
|
|
146
|
+
} else {
|
|
147
|
+
for (const [u, p] of attributed) {
|
|
148
|
+
if (u !== UNKNOWN_USER)
|
|
149
|
+
rows.push({ user: u, cap, pct: p });
|
|
150
|
+
}
|
|
151
|
+
rows.push({ user: UNKNOWN_USER, cap, pct: Math.max(0, target - nonUnknown) });
|
|
152
|
+
}
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ../../packages/core/dist/state/members.js
|
|
157
|
+
var ACTIVE_WINDOW_MS = 10 * 60 * 1e3;
|
|
158
|
+
function summarizeMembers(messages) {
|
|
159
|
+
const acc = /* @__PURE__ */ new Map();
|
|
160
|
+
for (const m of messages) {
|
|
161
|
+
const tokens = m.inputTokens + m.outputTokens + m.cacheCreationTokens + m.cacheReadTokens;
|
|
162
|
+
const t = Date.parse(m.timestamp);
|
|
163
|
+
const cur = acc.get(m.user) ?? { tokens: 0, last: 0 };
|
|
164
|
+
cur.tokens += tokens;
|
|
165
|
+
if (Number.isFinite(t) && t > cur.last)
|
|
166
|
+
cur.last = t;
|
|
167
|
+
acc.set(m.user, cur);
|
|
168
|
+
}
|
|
169
|
+
return [...acc].map(([user, v]) => ({
|
|
170
|
+
user,
|
|
171
|
+
tokens: v.tokens,
|
|
172
|
+
lastActivityAt: v.last > 0 ? new Date(v.last).toISOString() : null
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ../../packages/core/dist/state/view.js
|
|
177
|
+
var RETENTION_MS = CAP_WINDOW_MS.seven_day + 24 * 36e5;
|
|
178
|
+
function viewCacheKey(changeToken, now) {
|
|
179
|
+
return `${changeToken}.${Math.floor(now / 6e4)}`;
|
|
180
|
+
}
|
|
181
|
+
function assembleSharedView(rows, now = Date.now()) {
|
|
182
|
+
const allSamples = [...rows.samplesSince];
|
|
183
|
+
const seen = new Set(rows.samplesSince.map((s) => `${s.cap}:${s.capturedAt}`));
|
|
184
|
+
for (const s of rows.latest) {
|
|
185
|
+
const key = `${s.cap}:${s.capturedAt}`;
|
|
186
|
+
if (!seen.has(key)) {
|
|
187
|
+
allSamples.push(s);
|
|
188
|
+
seen.add(key);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
generatedAt: new Date(now).toISOString(),
|
|
193
|
+
samples: rows.latest,
|
|
194
|
+
shares: attributeShares(allSamples, rows.messagesSince, now, rows.resetsSince, rows.markersSince),
|
|
195
|
+
members: summarizeMembers(rows.messagesSince),
|
|
196
|
+
users: rows.users
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function computeSharedView(storage, now = Date.now()) {
|
|
200
|
+
const since = new Date(now - CAP_WINDOW_MS.seven_day).toISOString();
|
|
201
|
+
const [latest, samplesSince, messagesSince, resetsSince, users] = await Promise.all([
|
|
202
|
+
storage.getLatestSamples(),
|
|
203
|
+
storage.getUsageSamplesSince(since),
|
|
204
|
+
storage.getMessageUsageSince(since),
|
|
205
|
+
storage.getResetsSince(since),
|
|
206
|
+
storage.getUsers()
|
|
207
|
+
]);
|
|
208
|
+
let markersSince = [];
|
|
209
|
+
try {
|
|
210
|
+
markersSince = await storage.getUsageMarkersSince(since);
|
|
211
|
+
} catch {
|
|
212
|
+
}
|
|
213
|
+
return assembleSharedView({ latest, samplesSince, messagesSince, resetsSince, markersSince, users }, now);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ../../packages/core/dist/backend/sink.js
|
|
217
|
+
var AccountConflictError = class extends Error {
|
|
218
|
+
boundAccountId;
|
|
219
|
+
constructor(boundAccountId) {
|
|
220
|
+
super(`account mismatch: the ledger is bound to a different Claude account` + (boundAccountId ? ` (${boundAccountId})` : ""));
|
|
221
|
+
this.boundAccountId = boundAccountId;
|
|
222
|
+
this.name = "AccountConflictError";
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// ../../packages/core/dist/remote/api.js
|
|
227
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
228
|
+
|
|
229
|
+
// ../../packages/core/dist/backend/storage.js
|
|
230
|
+
var DEFAULT_PRUNE_INTERVAL_MS = 6 * 36e5;
|
|
231
|
+
var DEFAULT_GRACE_MS = 30 * 6e4;
|
|
232
|
+
var DEFAULT_FINALIZE_INTERVAL_MS = 6e4;
|
|
233
|
+
var EnvelopeFilter = class {
|
|
234
|
+
envMax = /* @__PURE__ */ new Map();
|
|
235
|
+
winStart = /* @__PURE__ */ new Map();
|
|
236
|
+
/** Seed from the stored envelope tops so a reopened sink doesn't re-emit points. */
|
|
237
|
+
seed(latest) {
|
|
238
|
+
for (const s of latest)
|
|
239
|
+
this.envMax.set(s.cap, s.pct);
|
|
240
|
+
}
|
|
241
|
+
/** A copy of `batch` whose `samples` are only the envelope-raising ones. */
|
|
242
|
+
filter(batch) {
|
|
243
|
+
for (const r of batch.resets) {
|
|
244
|
+
const cur = this.winStart.get(r.cap);
|
|
245
|
+
if (cur === void 0 || r.at > cur) {
|
|
246
|
+
this.winStart.set(r.cap, r.at);
|
|
247
|
+
this.envMax.set(r.cap, -Infinity);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const kept = [];
|
|
251
|
+
for (const s of [...batch.samples].sort((a, b) => a.capturedAt.localeCompare(b.capturedAt))) {
|
|
252
|
+
const ws = this.winStart.get(s.cap);
|
|
253
|
+
if (ws !== void 0 && s.capturedAt < ws)
|
|
254
|
+
continue;
|
|
255
|
+
const max = this.envMax.get(s.cap) ?? -Infinity;
|
|
256
|
+
if (s.pct > max) {
|
|
257
|
+
this.envMax.set(s.cap, s.pct);
|
|
258
|
+
kept.push(s);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { ...batch, samples: kept };
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
var StorageIngestSink = class {
|
|
265
|
+
storage;
|
|
266
|
+
boundAccountId = null;
|
|
267
|
+
/** False until bootstrap read the binding — we never enforce blind. */
|
|
268
|
+
bindingKnown = false;
|
|
269
|
+
lastPruneMs;
|
|
270
|
+
retentionMs;
|
|
271
|
+
pruneIntervalMs;
|
|
272
|
+
graceMs;
|
|
273
|
+
finalizeIntervalMs;
|
|
274
|
+
lastFinalizeMs = 0;
|
|
275
|
+
/** `${cap} ${windowStart}` of windows already frozen — skip recompute. */
|
|
276
|
+
frozen = /* @__PURE__ */ new Set();
|
|
277
|
+
now;
|
|
278
|
+
window;
|
|
279
|
+
envelope = new EnvelopeFilter();
|
|
280
|
+
constructor(storage, opts = {}) {
|
|
281
|
+
this.storage = storage;
|
|
282
|
+
this.retentionMs = opts.retentionMs ?? RETENTION_MS;
|
|
283
|
+
this.pruneIntervalMs = opts.pruneIntervalMs ?? DEFAULT_PRUNE_INTERVAL_MS;
|
|
284
|
+
this.graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
|
|
285
|
+
this.finalizeIntervalMs = opts.finalizeIntervalMs ?? DEFAULT_FINALIZE_INTERVAL_MS;
|
|
286
|
+
this.now = opts.now ?? Date.now;
|
|
287
|
+
this.window = opts.window;
|
|
288
|
+
this.lastPruneMs = this.now();
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Heal the schema to the version this build understands (an update must never
|
|
292
|
+
* require a manual re-init), then report the binding + reset-detection seed.
|
|
293
|
+
* Throws when the DB is unreachable — callers decide how to degrade.
|
|
294
|
+
*/
|
|
295
|
+
async bootstrap() {
|
|
296
|
+
const info = await this.storage.inspect();
|
|
297
|
+
if (info.kind !== "ccpool") {
|
|
298
|
+
throw new Error(`not a ccpool database (${info.kind}) \u2014 run \`ccpool init\``);
|
|
299
|
+
}
|
|
300
|
+
if (info.schemaVersion < SCHEMA_VERSION) {
|
|
301
|
+
await this.storage.migrate(SCHEMA_VERSION);
|
|
302
|
+
}
|
|
303
|
+
this.boundAccountId = info.accountId;
|
|
304
|
+
this.bindingKnown = true;
|
|
305
|
+
const latest = await this.storage.getLatestSamples();
|
|
306
|
+
this.envelope.seed(latest);
|
|
307
|
+
return { accountId: info.accountId, samples: latest };
|
|
308
|
+
}
|
|
309
|
+
async ingest(batch, meta) {
|
|
310
|
+
if (this.bindingKnown && this.boundAccountId !== null && meta.accountId !== null && meta.accountId !== this.boundAccountId) {
|
|
311
|
+
throw new AccountConflictError(this.boundAccountId);
|
|
312
|
+
}
|
|
313
|
+
const reduced = this.envelope.filter(batch);
|
|
314
|
+
if (!isEmptyBatch(reduced)) {
|
|
315
|
+
await this.storage.recordBatch(reduced);
|
|
316
|
+
this.window?.append(reduced);
|
|
317
|
+
}
|
|
318
|
+
const now = this.now();
|
|
319
|
+
await this.maybeFinalize(now);
|
|
320
|
+
if (now - this.lastPruneMs >= this.pruneIntervalMs) {
|
|
321
|
+
this.lastPruneMs = now;
|
|
322
|
+
const before = new Date(now - this.retentionMs).toISOString();
|
|
323
|
+
await this.storage.prune(before);
|
|
324
|
+
this.window?.applyPrune(before);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Freeze any window whose closing reset is now past the grace period into an
|
|
329
|
+
* immutable {@link HistoryWindow} + shares (ADR-0002/0008). Throttled (grace is 30
|
|
330
|
+
* min). A window spans two consecutive resets of the cap (the first opens at the
|
|
331
|
+
* earliest retained sample); shares come from the same `attributeShares` the live
|
|
332
|
+
* view uses. `recordHistoryWindow` is idempotent and `frozen` skips recompute.
|
|
333
|
+
*/
|
|
334
|
+
async maybeFinalize(now) {
|
|
335
|
+
if (now - this.lastFinalizeMs < this.finalizeIntervalMs)
|
|
336
|
+
return;
|
|
337
|
+
this.lastFinalizeMs = now;
|
|
338
|
+
const since = new Date(now - this.retentionMs).toISOString();
|
|
339
|
+
const resets = await this.storage.getResetsSince(since);
|
|
340
|
+
if (resets.length === 0)
|
|
341
|
+
return;
|
|
342
|
+
const capResets = /* @__PURE__ */ new Map();
|
|
343
|
+
for (const r of resets) {
|
|
344
|
+
const t = Date.parse(r.at);
|
|
345
|
+
if (!Number.isFinite(t))
|
|
346
|
+
continue;
|
|
347
|
+
const arr = capResets.get(r.cap);
|
|
348
|
+
if (arr)
|
|
349
|
+
arr.push(t);
|
|
350
|
+
else
|
|
351
|
+
capResets.set(r.cap, [t]);
|
|
352
|
+
}
|
|
353
|
+
let samples = null;
|
|
354
|
+
let messages = null;
|
|
355
|
+
let markers = null;
|
|
356
|
+
for (const [cap, times] of capResets) {
|
|
357
|
+
times.sort((a, b) => a - b);
|
|
358
|
+
for (let i = 0; i < times.length; i++) {
|
|
359
|
+
const endMs = times[i];
|
|
360
|
+
if (endMs + this.graceMs > now)
|
|
361
|
+
continue;
|
|
362
|
+
if (samples === null) {
|
|
363
|
+
[samples, messages, markers] = await Promise.all([
|
|
364
|
+
this.storage.getUsageSamplesSince(since),
|
|
365
|
+
this.storage.getMessageUsageSince(since),
|
|
366
|
+
this.storage.getUsageMarkersSince(since).catch(() => [])
|
|
367
|
+
]);
|
|
368
|
+
}
|
|
369
|
+
const capSamples = samples.filter((s) => s.cap === cap);
|
|
370
|
+
const startMs = i > 0 ? times[i - 1] : Math.min(...capSamples.map((s) => Date.parse(s.capturedAt)).filter(Number.isFinite));
|
|
371
|
+
if (!Number.isFinite(startMs) || startMs >= endMs)
|
|
372
|
+
continue;
|
|
373
|
+
const windowStart = new Date(startMs).toISOString();
|
|
374
|
+
const key = `${cap} ${windowStart}`;
|
|
375
|
+
if (this.frozen.has(key))
|
|
376
|
+
continue;
|
|
377
|
+
const windowEnd = new Date(endMs).toISOString();
|
|
378
|
+
const inWin = (iso) => {
|
|
379
|
+
const t = Date.parse(iso);
|
|
380
|
+
return t >= startMs && t < endMs;
|
|
381
|
+
};
|
|
382
|
+
const winSamples = capSamples.filter((s) => inWin(s.capturedAt));
|
|
383
|
+
if (winSamples.length === 0) {
|
|
384
|
+
this.frozen.add(key);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const winMsgs = messages.filter((m) => inWin(m.timestamp));
|
|
388
|
+
const winMarkers = markers.filter((m) => inWin(m.at));
|
|
389
|
+
const openingReset = [{ cap, at: windowStart, previousPct: 0 }];
|
|
390
|
+
const shares = attributeShares(winSamples, winMsgs, endMs, openingReset, winMarkers).filter((sh) => sh.cap === cap);
|
|
391
|
+
const overall = Math.max(...winSamples.map((s) => s.pct));
|
|
392
|
+
const historyShares = shares.map((sh) => ({
|
|
393
|
+
cap,
|
|
394
|
+
windowStart,
|
|
395
|
+
user: sh.user,
|
|
396
|
+
pct: sh.pct
|
|
397
|
+
}));
|
|
398
|
+
await this.storage.recordHistoryWindow({ cap, windowStart, windowEnd, overall, closedAt: new Date(now).toISOString() }, historyShares);
|
|
399
|
+
this.frozen.add(key);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
async close() {
|
|
404
|
+
await this.storage.close();
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
var StorageViewSource = class {
|
|
408
|
+
storage;
|
|
409
|
+
cache = null;
|
|
410
|
+
window;
|
|
411
|
+
constructor(storage, opts = {}) {
|
|
412
|
+
this.storage = storage;
|
|
413
|
+
this.window = opts.window;
|
|
414
|
+
}
|
|
415
|
+
/** The key the current view was computed under (the server's ETag). */
|
|
416
|
+
async currentKey(now = Date.now()) {
|
|
417
|
+
return viewCacheKey(await this.storage.getChangeToken(), now);
|
|
418
|
+
}
|
|
419
|
+
async history(query) {
|
|
420
|
+
const limit = query.limit ?? 100;
|
|
421
|
+
const windows = await this.storage.getHistoryWindows(query.cap, {
|
|
422
|
+
before: query.before,
|
|
423
|
+
limit
|
|
424
|
+
});
|
|
425
|
+
const view = await Promise.all(windows.map(async (w) => ({
|
|
426
|
+
cap: w.cap,
|
|
427
|
+
windowStart: w.windowStart,
|
|
428
|
+
windowEnd: w.windowEnd,
|
|
429
|
+
overall: w.overall,
|
|
430
|
+
shares: (await this.storage.getHistoryShares(query.cap, w.windowStart)).map((s) => ({
|
|
431
|
+
user: s.user,
|
|
432
|
+
pct: s.pct
|
|
433
|
+
}))
|
|
434
|
+
})));
|
|
435
|
+
const nextBefore = windows.length === limit && windows.length > 0 ? windows[windows.length - 1].windowStart : null;
|
|
436
|
+
return { windows: view, nextBefore };
|
|
437
|
+
}
|
|
438
|
+
async fetchView(now = Date.now()) {
|
|
439
|
+
const key = await this.currentKey(now);
|
|
440
|
+
if (this.cache?.key === key)
|
|
441
|
+
return this.cache.view;
|
|
442
|
+
const view = this.window ? assembleSharedView({ ...await this.window.rows(now), users: await this.storage.getUsers() }, now) : await computeSharedView(this.storage, now);
|
|
443
|
+
this.cache = { key, view };
|
|
444
|
+
return view;
|
|
445
|
+
}
|
|
446
|
+
async close() {
|
|
447
|
+
await this.storage.close();
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// ../../packages/core/dist/backend/window.js
|
|
452
|
+
var LedgerWindow = class {
|
|
453
|
+
storage;
|
|
454
|
+
// Keyed by the DB's natural keys (cap/timestamps never contain ``).
|
|
455
|
+
samples = /* @__PURE__ */ new Map();
|
|
456
|
+
resets = /* @__PURE__ */ new Map();
|
|
457
|
+
messages = /* @__PURE__ */ new Map();
|
|
458
|
+
// by uuid
|
|
459
|
+
markers = /* @__PURE__ */ new Map();
|
|
460
|
+
// by id
|
|
461
|
+
state = "idle";
|
|
462
|
+
hydration = null;
|
|
463
|
+
/** Appends that raced an in-flight hydration, replayed after it commits. */
|
|
464
|
+
pending = [];
|
|
465
|
+
/** Bumped by invalidate() so a mid-flight hydration can't commit stale data. */
|
|
466
|
+
epoch = 0;
|
|
467
|
+
constructor(storage) {
|
|
468
|
+
this.storage = storage;
|
|
469
|
+
}
|
|
470
|
+
/** Sink calls this after `recordBatch` COMMITS (rows already member-stamped). */
|
|
471
|
+
append(batch) {
|
|
472
|
+
if (this.state === "idle")
|
|
473
|
+
return;
|
|
474
|
+
if (this.state === "hydrating") {
|
|
475
|
+
this.pending.push(batch);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.apply(batch);
|
|
479
|
+
}
|
|
480
|
+
/** Sink calls this after `storage.prune(before)` — the exact mirror of its DELETEs. */
|
|
481
|
+
applyPrune(before) {
|
|
482
|
+
if (this.state !== "ready") {
|
|
483
|
+
this.invalidate();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
for (const [k, s] of this.samples)
|
|
487
|
+
if (s.capturedAt < before)
|
|
488
|
+
this.samples.delete(k);
|
|
489
|
+
for (const [k, e] of this.resets)
|
|
490
|
+
if (e.at < before)
|
|
491
|
+
this.resets.delete(k);
|
|
492
|
+
for (const [k, m] of this.messages)
|
|
493
|
+
if (m.timestamp < before)
|
|
494
|
+
this.messages.delete(k);
|
|
495
|
+
for (const [k, m] of this.markers)
|
|
496
|
+
if (m.at < before)
|
|
497
|
+
this.markers.delete(k);
|
|
498
|
+
}
|
|
499
|
+
/** Safety hatch: drop everything -> idle (re-hydrate on the next read). */
|
|
500
|
+
invalidate() {
|
|
501
|
+
this.epoch++;
|
|
502
|
+
this.samples.clear();
|
|
503
|
+
this.resets.clear();
|
|
504
|
+
this.messages.clear();
|
|
505
|
+
this.markers.clear();
|
|
506
|
+
this.pending = [];
|
|
507
|
+
this.state = "idle";
|
|
508
|
+
}
|
|
509
|
+
/** Ensure hydrated, then a filtered, deterministically sorted snapshot. */
|
|
510
|
+
async rows(now) {
|
|
511
|
+
while (this.state !== "ready")
|
|
512
|
+
await this.ensureHydrating(now);
|
|
513
|
+
const since = new Date(now - CAP_WINDOW_MS.seven_day).toISOString();
|
|
514
|
+
const byCap = /* @__PURE__ */ new Map();
|
|
515
|
+
for (const s of this.samples.values()) {
|
|
516
|
+
const cur = byCap.get(s.cap);
|
|
517
|
+
if (!cur || s.capturedAt >= cur.capturedAt)
|
|
518
|
+
byCap.set(s.cap, s);
|
|
519
|
+
}
|
|
520
|
+
const latest = CAP_KINDS.map((c) => byCap.get(c)).filter((s) => !!s);
|
|
521
|
+
const samplesSince = [...this.samples.values()].filter((s) => s.capturedAt >= since).sort((a, b) => a.capturedAt.localeCompare(b.capturedAt) || a.cap.localeCompare(b.cap));
|
|
522
|
+
const resetsSince = [...this.resets.values()].filter((e) => e.at >= since).sort((a, b) => a.at.localeCompare(b.at) || a.cap.localeCompare(b.cap));
|
|
523
|
+
const messagesSince = [...this.messages.values()].filter((m) => m.timestamp >= since).sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.uuid.localeCompare(b.uuid));
|
|
524
|
+
const markersSince = [...this.markers.values()].filter((m) => m.at >= since).sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
525
|
+
return { latest, samplesSince, messagesSince, resetsSince, markersSince };
|
|
526
|
+
}
|
|
527
|
+
ensureHydrating(now) {
|
|
528
|
+
if (this.state === "ready")
|
|
529
|
+
return Promise.resolve();
|
|
530
|
+
if (!this.hydration) {
|
|
531
|
+
this.state = "hydrating";
|
|
532
|
+
this.hydration = this.hydrate(now).finally(() => {
|
|
533
|
+
this.hydration = null;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
return this.hydration;
|
|
537
|
+
}
|
|
538
|
+
async hydrate(now) {
|
|
539
|
+
const epoch = this.epoch;
|
|
540
|
+
try {
|
|
541
|
+
const since = new Date(now - CAP_WINDOW_MS.seven_day).toISOString();
|
|
542
|
+
const [latest, samples, messages, resets] = await Promise.all([
|
|
543
|
+
this.storage.getLatestSamples(),
|
|
544
|
+
this.storage.getUsageSamplesSince(since),
|
|
545
|
+
this.storage.getMessageUsageSince(since),
|
|
546
|
+
this.storage.getResetsSince(since)
|
|
547
|
+
]);
|
|
548
|
+
let markers = [];
|
|
549
|
+
try {
|
|
550
|
+
markers = await this.storage.getUsageMarkersSince(since);
|
|
551
|
+
} catch {
|
|
552
|
+
}
|
|
553
|
+
if (this.epoch !== epoch)
|
|
554
|
+
return;
|
|
555
|
+
for (const s of [...samples, ...latest]) {
|
|
556
|
+
const k = `${s.cap}\0${s.capturedAt}`;
|
|
557
|
+
if (!this.samples.has(k))
|
|
558
|
+
this.samples.set(k, s);
|
|
559
|
+
}
|
|
560
|
+
for (const e of resets) {
|
|
561
|
+
const k = `${e.cap}\0${e.at}`;
|
|
562
|
+
if (!this.resets.has(k))
|
|
563
|
+
this.resets.set(k, e);
|
|
564
|
+
}
|
|
565
|
+
for (const m of messages)
|
|
566
|
+
if (!this.messages.has(m.uuid))
|
|
567
|
+
this.messages.set(m.uuid, m);
|
|
568
|
+
for (const m of markers)
|
|
569
|
+
if (!this.markers.has(m.id))
|
|
570
|
+
this.markers.set(m.id, m);
|
|
571
|
+
const buffered = this.pending;
|
|
572
|
+
this.pending = [];
|
|
573
|
+
for (const b of buffered)
|
|
574
|
+
this.apply(b);
|
|
575
|
+
this.state = "ready";
|
|
576
|
+
} catch (err2) {
|
|
577
|
+
if (this.epoch === epoch)
|
|
578
|
+
this.invalidate();
|
|
579
|
+
throw err2;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/** Insert-if-absent per natural key — the in-memory `ON CONFLICT DO NOTHING`. */
|
|
583
|
+
apply(batch) {
|
|
584
|
+
for (const s of batch.samples) {
|
|
585
|
+
const k = `${s.cap}\0${s.capturedAt}`;
|
|
586
|
+
if (!this.samples.has(k))
|
|
587
|
+
this.samples.set(k, s);
|
|
588
|
+
}
|
|
589
|
+
for (const e of batch.resets) {
|
|
590
|
+
const k = `${e.cap}\0${e.at}`;
|
|
591
|
+
if (!this.resets.has(k))
|
|
592
|
+
this.resets.set(k, e);
|
|
593
|
+
}
|
|
594
|
+
for (const m of batch.messages) {
|
|
595
|
+
if (!this.messages.has(m.uuid))
|
|
596
|
+
this.messages.set(m.uuid, m);
|
|
597
|
+
}
|
|
598
|
+
for (const m of batch.markers) {
|
|
599
|
+
if (!this.markers.has(m.id))
|
|
600
|
+
this.markers.set(m.id, m);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// ../../packages/core/dist/identity/paths.js
|
|
606
|
+
import { homedir } from "os";
|
|
607
|
+
import { join } from "path";
|
|
608
|
+
|
|
609
|
+
// ../../packages/core/dist/identity/credentials.js
|
|
610
|
+
import { createHash } from "crypto";
|
|
611
|
+
import { readFile } from "fs/promises";
|
|
612
|
+
import { join as join2 } from "path";
|
|
613
|
+
import { execFile } from "child_process";
|
|
614
|
+
import { promisify } from "util";
|
|
615
|
+
var pExecFile = promisify(execFile);
|
|
616
|
+
|
|
617
|
+
// ../../packages/core/dist/identity/resolver.js
|
|
618
|
+
import { createHash as createHash2 } from "crypto";
|
|
619
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
620
|
+
|
|
621
|
+
// ../../packages/core/dist/state/snapshot.js
|
|
622
|
+
import { writeFile, rename, mkdir } from "fs/promises";
|
|
623
|
+
import { dirname } from "path";
|
|
624
|
+
|
|
625
|
+
// ../../packages/core/dist/jsonl/reader.js
|
|
626
|
+
import { open, readdir, stat } from "fs/promises";
|
|
627
|
+
import { join as join3 } from "path";
|
|
628
|
+
|
|
629
|
+
// src/auth.ts
|
|
630
|
+
import { createHash as createHash3, randomBytes, scrypt as scryptCb, timingSafeEqual } from "crypto";
|
|
631
|
+
import { promisify as promisify2 } from "util";
|
|
632
|
+
var scrypt = promisify2(scryptCb);
|
|
633
|
+
var SCRYPT_N = 16384;
|
|
634
|
+
var SCRYPT_R = 8;
|
|
635
|
+
var SCRYPT_P = 1;
|
|
636
|
+
var KEY_LEN = 32;
|
|
637
|
+
var SALT_LEN = 16;
|
|
638
|
+
var MAX_MEM = 128 * 1024 * 1024;
|
|
639
|
+
async function hashPassword(password) {
|
|
640
|
+
const salt = randomBytes(SALT_LEN);
|
|
641
|
+
const hash = await scrypt(password, salt, KEY_LEN, {
|
|
642
|
+
N: SCRYPT_N,
|
|
643
|
+
r: SCRYPT_R,
|
|
644
|
+
p: SCRYPT_P,
|
|
645
|
+
maxmem: MAX_MEM
|
|
646
|
+
});
|
|
647
|
+
return [
|
|
648
|
+
"scrypt",
|
|
649
|
+
SCRYPT_N,
|
|
650
|
+
SCRYPT_R,
|
|
651
|
+
SCRYPT_P,
|
|
652
|
+
salt.toString("base64url"),
|
|
653
|
+
hash.toString("base64url")
|
|
654
|
+
].join(":");
|
|
655
|
+
}
|
|
656
|
+
async function verifyPassword(password, stored) {
|
|
657
|
+
const parts = stored.split(":");
|
|
658
|
+
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
|
659
|
+
const [, nStr, rStr, pStr, saltB64, hashB64] = parts;
|
|
660
|
+
const N = Number(nStr);
|
|
661
|
+
const r = Number(rStr);
|
|
662
|
+
const p = Number(pStr);
|
|
663
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
|
664
|
+
const salt = Buffer.from(saltB64, "base64url");
|
|
665
|
+
const expected = Buffer.from(hashB64, "base64url");
|
|
666
|
+
try {
|
|
667
|
+
const actual = await scrypt(password, salt, expected.length, { N, r, p, maxmem: MAX_MEM });
|
|
668
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function mintToken() {
|
|
674
|
+
const token = "ccs_" + randomBytes(32).toString("base64url");
|
|
675
|
+
return { token, tokenHash: hashToken(token) };
|
|
676
|
+
}
|
|
677
|
+
function hashToken(token) {
|
|
678
|
+
return createHash3("sha256").update(token).digest("hex");
|
|
679
|
+
}
|
|
680
|
+
var FailureDamper = class {
|
|
681
|
+
constructor(freeAttempts = 5, baseBlockMs = 1e3, maxBlockMs = 15 * 6e4, now = Date.now, maxTracked = 1e5) {
|
|
682
|
+
this.freeAttempts = freeAttempts;
|
|
683
|
+
this.baseBlockMs = baseBlockMs;
|
|
684
|
+
this.maxBlockMs = maxBlockMs;
|
|
685
|
+
this.now = now;
|
|
686
|
+
this.maxTracked = maxTracked;
|
|
687
|
+
}
|
|
688
|
+
freeAttempts;
|
|
689
|
+
baseBlockMs;
|
|
690
|
+
maxBlockMs;
|
|
691
|
+
now;
|
|
692
|
+
maxTracked;
|
|
693
|
+
failures = /* @__PURE__ */ new Map();
|
|
694
|
+
/** True when this key must wait before another attempt. */
|
|
695
|
+
isBlocked(key) {
|
|
696
|
+
const e = this.failures.get(key);
|
|
697
|
+
return !!e && this.now() < e.blockedUntil;
|
|
698
|
+
}
|
|
699
|
+
recordFailure(key) {
|
|
700
|
+
const e = this.failures.get(key) ?? { count: 0, blockedUntil: 0 };
|
|
701
|
+
e.count += 1;
|
|
702
|
+
if (e.count > this.freeAttempts) {
|
|
703
|
+
const exp = Math.min(
|
|
704
|
+
this.maxBlockMs,
|
|
705
|
+
this.baseBlockMs * 2 ** (e.count - this.freeAttempts - 1)
|
|
706
|
+
);
|
|
707
|
+
e.blockedUntil = this.now() + exp;
|
|
708
|
+
}
|
|
709
|
+
this.failures.delete(key);
|
|
710
|
+
this.failures.set(key, e);
|
|
711
|
+
if (this.failures.size > this.maxTracked) {
|
|
712
|
+
const oldest = this.failures.keys().next().value;
|
|
713
|
+
if (oldest !== void 0) this.failures.delete(oldest);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
recordSuccess(key) {
|
|
717
|
+
this.failures.delete(key);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
// src/validate.ts
|
|
722
|
+
var bad = (error) => ({ ok: false, error });
|
|
723
|
+
function isRecord(v) {
|
|
724
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
725
|
+
}
|
|
726
|
+
var isStr = (v) => typeof v === "string";
|
|
727
|
+
var isOptStr = (v) => v === null || typeof v === "string";
|
|
728
|
+
var isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
729
|
+
var isCap = (v) => isStr(v) && CAP_KINDS.includes(v);
|
|
730
|
+
function checkPassword(v, label) {
|
|
731
|
+
if (!isStr(v)) return `${label} is required`;
|
|
732
|
+
if (v.length < MIN_PASSWORD_LENGTH) {
|
|
733
|
+
return `${label} must be at least ${MIN_PASSWORD_LENGTH} characters`;
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
function checkMemberName(v) {
|
|
738
|
+
if (isStr(v) && v.length > MAX_NAME_LENGTH) {
|
|
739
|
+
return `memberName must be at most ${MAX_NAME_LENGTH} characters`;
|
|
740
|
+
}
|
|
741
|
+
if (!isStr(v) || !isValidName(v)) {
|
|
742
|
+
return "memberName must be letters, digits, and hyphens (and not 'unknown')";
|
|
743
|
+
}
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
function parseCreateGroup(body) {
|
|
747
|
+
if (!isRecord(body)) return bad("expected a JSON object");
|
|
748
|
+
if (!isStr(body.accountId) || body.accountId.length === 0) return bad("accountId is required");
|
|
749
|
+
const pw = checkPassword(body.groupPassword, "groupPassword");
|
|
750
|
+
if (pw) return bad(pw);
|
|
751
|
+
const name = checkMemberName(body.memberName);
|
|
752
|
+
if (name) return bad(name);
|
|
753
|
+
const mpw = checkPassword(body.memberPassword, "memberPassword");
|
|
754
|
+
if (mpw) return bad(mpw);
|
|
755
|
+
return {
|
|
756
|
+
ok: true,
|
|
757
|
+
value: {
|
|
758
|
+
accountId: body.accountId,
|
|
759
|
+
groupPassword: body.groupPassword,
|
|
760
|
+
memberName: body.memberName,
|
|
761
|
+
memberPassword: body.memberPassword
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
function parseJoinGroup(body) {
|
|
766
|
+
return parseCreateGroup(body);
|
|
767
|
+
}
|
|
768
|
+
function parseLogin(body) {
|
|
769
|
+
if (!isRecord(body)) return bad("expected a JSON object");
|
|
770
|
+
if (!isStr(body.accountId) || body.accountId.length === 0) return bad("accountId is required");
|
|
771
|
+
const name = checkMemberName(body.memberName);
|
|
772
|
+
if (name) return bad(name);
|
|
773
|
+
const mpw = checkPassword(body.memberPassword, "memberPassword");
|
|
774
|
+
if (mpw) return bad(mpw);
|
|
775
|
+
return {
|
|
776
|
+
ok: true,
|
|
777
|
+
value: {
|
|
778
|
+
accountId: body.accountId,
|
|
779
|
+
memberName: body.memberName,
|
|
780
|
+
memberPassword: body.memberPassword
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function parseSample(v) {
|
|
785
|
+
if (!isRecord(v) || !isCap(v.cap) || !isNum(v.pct)) return null;
|
|
786
|
+
if (!isOptStr(v.resetsAt ?? null) || !isStr(v.capturedAt)) return null;
|
|
787
|
+
return {
|
|
788
|
+
cap: v.cap,
|
|
789
|
+
pct: v.pct,
|
|
790
|
+
resetsAt: v.resetsAt ?? null,
|
|
791
|
+
capturedAt: v.capturedAt
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
function parseReset(v) {
|
|
795
|
+
if (!isRecord(v) || !isCap(v.cap) || !isStr(v.at) || !isNum(v.previousPct)) return null;
|
|
796
|
+
return { cap: v.cap, at: v.at, previousPct: v.previousPct };
|
|
797
|
+
}
|
|
798
|
+
function parseMessage(v) {
|
|
799
|
+
if (!isRecord(v) || !isStr(v.uuid) || !isStr(v.timestamp)) return null;
|
|
800
|
+
if (!isOptStr(v.model ?? null)) return null;
|
|
801
|
+
if (!isNum(v.inputTokens) || !isNum(v.outputTokens) || !isNum(v.cacheCreationTokens) || !isNum(v.cacheReadTokens)) {
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
return {
|
|
805
|
+
uuid: v.uuid,
|
|
806
|
+
user: isStr(v.user) ? v.user : "",
|
|
807
|
+
// overwritten with the authed member anyway
|
|
808
|
+
timestamp: v.timestamp,
|
|
809
|
+
model: v.model ?? null,
|
|
810
|
+
inputTokens: v.inputTokens,
|
|
811
|
+
outputTokens: v.outputTokens,
|
|
812
|
+
cacheCreationTokens: v.cacheCreationTokens,
|
|
813
|
+
cacheReadTokens: v.cacheReadTokens
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
function parseMarker(v) {
|
|
817
|
+
if (!isRecord(v) || !isStr(v.id) || !isStr(v.at) || !isNum(v.weight)) return null;
|
|
818
|
+
if (!isOptStr(v.model ?? null)) return null;
|
|
819
|
+
return {
|
|
820
|
+
id: v.id,
|
|
821
|
+
user: isStr(v.user) ? v.user : "",
|
|
822
|
+
// overwritten with the authed member anyway
|
|
823
|
+
at: v.at,
|
|
824
|
+
model: v.model ?? null,
|
|
825
|
+
weight: v.weight
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function parseArray(v, parse, label) {
|
|
829
|
+
if (v === void 0) return { ok: true, value: [] };
|
|
830
|
+
if (!Array.isArray(v)) return bad(`${label} must be an array`);
|
|
831
|
+
const out = [];
|
|
832
|
+
for (const item of v) {
|
|
833
|
+
const parsed = parse(item);
|
|
834
|
+
if (parsed === null) return bad(`${label} contains an invalid row`);
|
|
835
|
+
out.push(parsed);
|
|
836
|
+
}
|
|
837
|
+
return { ok: true, value: out };
|
|
838
|
+
}
|
|
839
|
+
function parseIngest(body) {
|
|
840
|
+
if (!isRecord(body)) return bad("expected a JSON object");
|
|
841
|
+
if (!isStr(body.at)) return bad("at (ISO timestamp) is required");
|
|
842
|
+
if (body.accountId !== null && !isStr(body.accountId)) {
|
|
843
|
+
return bad("accountId must be a string or null");
|
|
844
|
+
}
|
|
845
|
+
const samples = parseArray(body.samples, parseSample, "samples");
|
|
846
|
+
if (!samples.ok) return samples;
|
|
847
|
+
const resets = parseArray(body.resets, parseReset, "resets");
|
|
848
|
+
if (!resets.ok) return resets;
|
|
849
|
+
const messages = parseArray(body.messages, parseMessage, "messages");
|
|
850
|
+
if (!messages.ok) return messages;
|
|
851
|
+
const markers = parseArray(body.markers, parseMarker, "markers");
|
|
852
|
+
if (!markers.ok) return markers;
|
|
853
|
+
return {
|
|
854
|
+
ok: true,
|
|
855
|
+
value: {
|
|
856
|
+
at: body.at,
|
|
857
|
+
accountId: body.accountId ?? null,
|
|
858
|
+
samples: samples.value,
|
|
859
|
+
resets: resets.value,
|
|
860
|
+
messages: messages.value,
|
|
861
|
+
markers: markers.value
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/app.ts
|
|
867
|
+
var MAX_INGEST_BYTES = 1024 * 1024;
|
|
868
|
+
var TOKEN_TOUCH_INTERVAL_MS = 6e4;
|
|
869
|
+
var TOKEN_CACHE_TTL_MS = 6e4;
|
|
870
|
+
var TOKEN_CACHE_MAX = 2e4;
|
|
871
|
+
function err(c, status, code, error) {
|
|
872
|
+
return c.json({ error, code }, status);
|
|
873
|
+
}
|
|
874
|
+
async function readCappedText(req, max) {
|
|
875
|
+
const body = req.body;
|
|
876
|
+
if (!body) return "";
|
|
877
|
+
const reader = body.getReader();
|
|
878
|
+
const decoder = new TextDecoder();
|
|
879
|
+
let total = 0;
|
|
880
|
+
let out = "";
|
|
881
|
+
for (; ; ) {
|
|
882
|
+
const { done, value } = await reader.read();
|
|
883
|
+
if (done) break;
|
|
884
|
+
total += value.byteLength;
|
|
885
|
+
if (total > max) {
|
|
886
|
+
await reader.cancel().catch(() => {
|
|
887
|
+
});
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
out += decoder.decode(value, { stream: true });
|
|
891
|
+
}
|
|
892
|
+
return out + decoder.decode();
|
|
893
|
+
}
|
|
894
|
+
function makeApp(deps2) {
|
|
895
|
+
const { registry, tenants } = deps2;
|
|
896
|
+
const app2 = new Hono();
|
|
897
|
+
const damper = new FailureDamper();
|
|
898
|
+
const lastTouch = /* @__PURE__ */ new Map();
|
|
899
|
+
const tokenCache = /* @__PURE__ */ new Map();
|
|
900
|
+
const damperKey = (accountId) => accountId;
|
|
901
|
+
app2.get("/healthz", (c) => c.json({ ok: true }));
|
|
902
|
+
app2.use("/v1/ingest", bearer);
|
|
903
|
+
app2.use("/v1/bootstrap", bearer);
|
|
904
|
+
app2.use("/v1/view", bearer);
|
|
905
|
+
app2.use("/v1/history", bearer);
|
|
906
|
+
async function bearer(c, next) {
|
|
907
|
+
const header = c.req.header("authorization") ?? "";
|
|
908
|
+
const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : null;
|
|
909
|
+
if (!token) return err(c, 401, "auth", "missing bearer token");
|
|
910
|
+
const tokenHash = hashToken(token);
|
|
911
|
+
const now = Date.now();
|
|
912
|
+
let hit = tokenCache.get(tokenHash);
|
|
913
|
+
if (hit && hit.exp <= now) {
|
|
914
|
+
tokenCache.delete(tokenHash);
|
|
915
|
+
hit = void 0;
|
|
916
|
+
}
|
|
917
|
+
if (!hit) {
|
|
918
|
+
const resolved = await registry.resolveToken(tokenHash);
|
|
919
|
+
if (!resolved) return err(c, 401, "auth", "unknown or revoked token");
|
|
920
|
+
hit = { member: resolved.member, group: resolved.group, exp: now + TOKEN_CACHE_TTL_MS };
|
|
921
|
+
if (tokenCache.size >= TOKEN_CACHE_MAX) {
|
|
922
|
+
const oldest = tokenCache.keys().next().value;
|
|
923
|
+
if (oldest !== void 0) tokenCache.delete(oldest);
|
|
924
|
+
}
|
|
925
|
+
tokenCache.set(tokenHash, hit);
|
|
926
|
+
}
|
|
927
|
+
if (now - (lastTouch.get(tokenHash) ?? 0) >= TOKEN_TOUCH_INTERVAL_MS) {
|
|
928
|
+
lastTouch.set(tokenHash, now);
|
|
929
|
+
await registry.touchToken(tokenHash).catch(() => {
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
c.set("member", hit.member);
|
|
933
|
+
c.set("group", hit.group);
|
|
934
|
+
await next();
|
|
935
|
+
}
|
|
936
|
+
app2.get("/v1/groups/lookup", async (c) => {
|
|
937
|
+
const accountId = c.req.query("accountId");
|
|
938
|
+
if (!accountId) return err(c, 400, "invalid", "accountId is required");
|
|
939
|
+
const group = await registry.getGroupByAccount(accountId);
|
|
940
|
+
if (!group) return c.json({ exists: false });
|
|
941
|
+
const memberName = c.req.query("memberName");
|
|
942
|
+
if (memberName) {
|
|
943
|
+
const member = await registry.getMember(group.id, memberName);
|
|
944
|
+
return c.json({ exists: true, memberExists: !!member });
|
|
945
|
+
}
|
|
946
|
+
return c.json({ exists: true });
|
|
947
|
+
});
|
|
948
|
+
app2.post("/v1/groups", async (c) => {
|
|
949
|
+
const parsed = parseCreateGroup(await c.req.json().catch(() => null));
|
|
950
|
+
if (!parsed.ok) return err(c, 400, "invalid", parsed.error);
|
|
951
|
+
const body = parsed.value;
|
|
952
|
+
if (damper.isBlocked(damperKey(body.accountId))) {
|
|
953
|
+
return err(c, 429, "rate-limited", "too many attempts \u2014 wait a moment");
|
|
954
|
+
}
|
|
955
|
+
if (await registry.getGroupByAccount(body.accountId)) {
|
|
956
|
+
return err(c, 409, "conflict", "a group for this Claude account already exists \u2014 join it");
|
|
957
|
+
}
|
|
958
|
+
const { token, tokenHash } = mintToken();
|
|
959
|
+
try {
|
|
960
|
+
const { group, member } = await registry.createGroupWithMember({
|
|
961
|
+
accountId: body.accountId,
|
|
962
|
+
groupPasswordHash: await hashPassword(body.groupPassword),
|
|
963
|
+
memberName: body.memberName,
|
|
964
|
+
memberPasswordHash: await hashPassword(body.memberPassword),
|
|
965
|
+
tokenHash
|
|
966
|
+
});
|
|
967
|
+
return c.json({ token, groupId: group.id, memberName: member.name }, 201);
|
|
968
|
+
} catch (e) {
|
|
969
|
+
if (e instanceof RegistryConflictError) {
|
|
970
|
+
return err(c, 409, "conflict", "a group for this Claude account already exists \u2014 join it");
|
|
971
|
+
}
|
|
972
|
+
return err(c, 500, "invalid", `could not provision the group: ${e.message}`);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
app2.post("/v1/groups/join", async (c) => {
|
|
976
|
+
const parsed = parseJoinGroup(await c.req.json().catch(() => null));
|
|
977
|
+
if (!parsed.ok) return err(c, 400, "invalid", parsed.error);
|
|
978
|
+
const body = parsed.value;
|
|
979
|
+
const key = damperKey(body.accountId);
|
|
980
|
+
if (damper.isBlocked(key)) {
|
|
981
|
+
return err(c, 429, "rate-limited", "too many attempts \u2014 wait a moment");
|
|
982
|
+
}
|
|
983
|
+
const group = await registry.getGroupByAccount(body.accountId);
|
|
984
|
+
if (!group) return err(c, 404, "not-found", "no group exists for this Claude account yet");
|
|
985
|
+
if (!await verifyPassword(body.groupPassword, group.passwordHash)) {
|
|
986
|
+
damper.recordFailure(key);
|
|
987
|
+
return err(c, 401, "auth", "wrong group password");
|
|
988
|
+
}
|
|
989
|
+
const { token, tokenHash } = mintToken();
|
|
990
|
+
let member = await registry.getMember(group.id, body.memberName);
|
|
991
|
+
if (member) {
|
|
992
|
+
if (!await verifyPassword(body.memberPassword, member.passwordHash)) {
|
|
993
|
+
damper.recordFailure(key);
|
|
994
|
+
return err(c, 401, "auth", `"${body.memberName}" exists and this isn't its password`);
|
|
995
|
+
}
|
|
996
|
+
await registry.insertToken(tokenHash, member.id);
|
|
997
|
+
} else {
|
|
998
|
+
try {
|
|
999
|
+
member = await registry.addMemberWithToken(
|
|
1000
|
+
group.id,
|
|
1001
|
+
body.memberName,
|
|
1002
|
+
await hashPassword(body.memberPassword),
|
|
1003
|
+
tokenHash
|
|
1004
|
+
);
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
if (!(e instanceof RegistryConflictError)) throw e;
|
|
1007
|
+
member = await registry.getMember(group.id, body.memberName);
|
|
1008
|
+
if (!member || !await verifyPassword(body.memberPassword, member.passwordHash)) {
|
|
1009
|
+
damper.recordFailure(key);
|
|
1010
|
+
return err(c, 401, "auth", `"${body.memberName}" exists and this isn't its password`);
|
|
1011
|
+
}
|
|
1012
|
+
await registry.insertToken(tokenHash, member.id);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
damper.recordSuccess(key);
|
|
1016
|
+
return c.json({ token, groupId: group.id, memberName: member.name }, 200);
|
|
1017
|
+
});
|
|
1018
|
+
app2.post("/v1/login", async (c) => {
|
|
1019
|
+
const parsed = parseLogin(await c.req.json().catch(() => null));
|
|
1020
|
+
if (!parsed.ok) return err(c, 400, "invalid", parsed.error);
|
|
1021
|
+
const body = parsed.value;
|
|
1022
|
+
const key = damperKey(body.accountId);
|
|
1023
|
+
if (damper.isBlocked(key)) {
|
|
1024
|
+
return err(c, 429, "rate-limited", "too many attempts \u2014 wait a moment");
|
|
1025
|
+
}
|
|
1026
|
+
const group = await registry.getGroupByAccount(body.accountId);
|
|
1027
|
+
if (!group) return err(c, 404, "not-found", "no group exists for this Claude account yet");
|
|
1028
|
+
const member = await registry.getMember(group.id, body.memberName);
|
|
1029
|
+
if (!member || !await verifyPassword(body.memberPassword, member.passwordHash)) {
|
|
1030
|
+
damper.recordFailure(key);
|
|
1031
|
+
return err(c, 401, "auth", "wrong member name or password");
|
|
1032
|
+
}
|
|
1033
|
+
damper.recordSuccess(key);
|
|
1034
|
+
const { token, tokenHash } = mintToken();
|
|
1035
|
+
await registry.insertToken(tokenHash, member.id);
|
|
1036
|
+
return c.json({ token, groupId: group.id, memberName: member.name }, 200);
|
|
1037
|
+
});
|
|
1038
|
+
app2.post("/v1/ingest", async (c) => {
|
|
1039
|
+
const raw = await readCappedText(c.req.raw, MAX_INGEST_BYTES);
|
|
1040
|
+
if (raw === null) return err(c, 413, "invalid", "ingest body too large");
|
|
1041
|
+
let json = null;
|
|
1042
|
+
try {
|
|
1043
|
+
json = JSON.parse(raw);
|
|
1044
|
+
} catch {
|
|
1045
|
+
}
|
|
1046
|
+
const parsed = parseIngest(json);
|
|
1047
|
+
if (!parsed.ok) return err(c, 400, "invalid", parsed.error);
|
|
1048
|
+
const body = parsed.value;
|
|
1049
|
+
const group = c.get("group");
|
|
1050
|
+
const member = c.get("member");
|
|
1051
|
+
if (body.accountId !== null && body.accountId !== group.accountId) {
|
|
1052
|
+
return err(c, 409, "account-conflict", "this group is bound to a different Claude account");
|
|
1053
|
+
}
|
|
1054
|
+
const batch = {
|
|
1055
|
+
samples: body.samples,
|
|
1056
|
+
resets: body.resets,
|
|
1057
|
+
messages: body.messages.map((m) => ({ ...m, user: member.name })),
|
|
1058
|
+
markers: body.markers.map((m) => ({ ...m, user: member.name }))
|
|
1059
|
+
};
|
|
1060
|
+
try {
|
|
1061
|
+
const tenant = await tenants.get(group);
|
|
1062
|
+
await tenant.sink.ingest(batch, { at: body.at, accountId: body.accountId });
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
if (e instanceof AccountConflictError) {
|
|
1065
|
+
return err(c, 409, "account-conflict", e.message);
|
|
1066
|
+
}
|
|
1067
|
+
return err(c, 500, "invalid", e.message);
|
|
1068
|
+
}
|
|
1069
|
+
return c.body(null, 204);
|
|
1070
|
+
});
|
|
1071
|
+
app2.get("/v1/bootstrap", async (c) => {
|
|
1072
|
+
const group = c.get("group");
|
|
1073
|
+
const tenant = await tenants.get(group);
|
|
1074
|
+
const boot = await tenant.sink.bootstrap();
|
|
1075
|
+
return c.json({ accountId: group.accountId, samples: boot.samples });
|
|
1076
|
+
});
|
|
1077
|
+
app2.get("/v1/view", async (c) => {
|
|
1078
|
+
const group = c.get("group");
|
|
1079
|
+
const tenant = await tenants.get(group);
|
|
1080
|
+
const now = Date.now();
|
|
1081
|
+
const etag = `"${await tenant.view.currentKey(now)}"`;
|
|
1082
|
+
if (c.req.header("if-none-match") === etag) {
|
|
1083
|
+
return c.body(null, 304, { ETag: etag });
|
|
1084
|
+
}
|
|
1085
|
+
const view = await tenant.view.fetchView(now);
|
|
1086
|
+
return c.json(view, 200, { ETag: etag });
|
|
1087
|
+
});
|
|
1088
|
+
app2.get("/v1/history", async (c) => {
|
|
1089
|
+
const capQ = c.req.query("cap") ?? "five_hour";
|
|
1090
|
+
if (!CAP_KINDS.includes(capQ)) return err(c, 400, "invalid", "unknown cap");
|
|
1091
|
+
const before = c.req.query("before") || void 0;
|
|
1092
|
+
const limitRaw = Number(c.req.query("limit"));
|
|
1093
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(200, Math.floor(limitRaw)) : 50;
|
|
1094
|
+
const tenant = await tenants.get(c.get("group"));
|
|
1095
|
+
const page = await tenant.view.history({ cap: capQ, before, limit });
|
|
1096
|
+
return c.json(page);
|
|
1097
|
+
});
|
|
1098
|
+
return app2;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ../../packages/storage-libsql/dist/database.js
|
|
1102
|
+
import { createClient } from "@libsql/client";
|
|
1103
|
+
|
|
1104
|
+
// ../../packages/storage-libsql/dist/ddl.js
|
|
1105
|
+
var LEDGER_DDL = [
|
|
1106
|
+
`CREATE TABLE IF NOT EXISTS ccpool_meta (
|
|
1107
|
+
group_id TEXT PRIMARY KEY,
|
|
1108
|
+
app TEXT NOT NULL,
|
|
1109
|
+
schemaVersion INTEGER NOT NULL,
|
|
1110
|
+
projectId TEXT NOT NULL,
|
|
1111
|
+
createdAt TEXT NOT NULL,
|
|
1112
|
+
accountId TEXT,
|
|
1113
|
+
writeSeq INTEGER NOT NULL DEFAULT 0
|
|
1114
|
+
)`,
|
|
1115
|
+
`CREATE TABLE IF NOT EXISTS users (
|
|
1116
|
+
group_id TEXT NOT NULL,
|
|
1117
|
+
name TEXT NOT NULL,
|
|
1118
|
+
createdAt TEXT NOT NULL,
|
|
1119
|
+
PRIMARY KEY (group_id, name)
|
|
1120
|
+
)`,
|
|
1121
|
+
`CREATE TABLE IF NOT EXISTS usage_samples (
|
|
1122
|
+
group_id TEXT NOT NULL,
|
|
1123
|
+
cap TEXT NOT NULL,
|
|
1124
|
+
pct REAL NOT NULL,
|
|
1125
|
+
resetsAt TEXT,
|
|
1126
|
+
capturedAt TEXT NOT NULL
|
|
1127
|
+
)`,
|
|
1128
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_samples_cap ON usage_samples (group_id, cap, capturedAt)`,
|
|
1129
|
+
`CREATE TABLE IF NOT EXISTS message_usage (
|
|
1130
|
+
group_id TEXT NOT NULL,
|
|
1131
|
+
uuid TEXT NOT NULL,
|
|
1132
|
+
user TEXT NOT NULL,
|
|
1133
|
+
timestamp TEXT NOT NULL,
|
|
1134
|
+
model TEXT,
|
|
1135
|
+
inputTokens INTEGER NOT NULL,
|
|
1136
|
+
outputTokens INTEGER NOT NULL,
|
|
1137
|
+
cacheCreationTokens INTEGER NOT NULL,
|
|
1138
|
+
cacheReadTokens INTEGER NOT NULL,
|
|
1139
|
+
PRIMARY KEY (group_id, uuid)
|
|
1140
|
+
)`,
|
|
1141
|
+
`CREATE INDEX IF NOT EXISTS idx_message_usage_ts ON message_usage (group_id, timestamp)`,
|
|
1142
|
+
`CREATE TABLE IF NOT EXISTS usage_markers (
|
|
1143
|
+
group_id TEXT NOT NULL,
|
|
1144
|
+
id TEXT NOT NULL,
|
|
1145
|
+
user TEXT NOT NULL,
|
|
1146
|
+
at TEXT NOT NULL,
|
|
1147
|
+
model TEXT,
|
|
1148
|
+
weight REAL NOT NULL,
|
|
1149
|
+
PRIMARY KEY (group_id, id)
|
|
1150
|
+
)`,
|
|
1151
|
+
`CREATE INDEX IF NOT EXISTS idx_usage_markers_at ON usage_markers (group_id, at)`,
|
|
1152
|
+
`CREATE TABLE IF NOT EXISTS reset_events (
|
|
1153
|
+
group_id TEXT NOT NULL,
|
|
1154
|
+
cap TEXT NOT NULL,
|
|
1155
|
+
at TEXT NOT NULL,
|
|
1156
|
+
previousPct REAL NOT NULL
|
|
1157
|
+
)`,
|
|
1158
|
+
`CREATE INDEX IF NOT EXISTS idx_reset_events_at ON reset_events (group_id, at)`,
|
|
1159
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_reset_events_uniq ON reset_events (group_id, cap, at)`,
|
|
1160
|
+
// Immutable history of completed cap cycles (ADR-0002/0005). Retained unbounded.
|
|
1161
|
+
`CREATE TABLE IF NOT EXISTS history_windows (
|
|
1162
|
+
group_id TEXT NOT NULL,
|
|
1163
|
+
cap TEXT NOT NULL,
|
|
1164
|
+
windowStart TEXT NOT NULL,
|
|
1165
|
+
windowEnd TEXT NOT NULL,
|
|
1166
|
+
overall REAL NOT NULL,
|
|
1167
|
+
closedAt TEXT NOT NULL,
|
|
1168
|
+
PRIMARY KEY (group_id, cap, windowStart)
|
|
1169
|
+
)`,
|
|
1170
|
+
`CREATE TABLE IF NOT EXISTS history_shares (
|
|
1171
|
+
group_id TEXT NOT NULL,
|
|
1172
|
+
cap TEXT NOT NULL,
|
|
1173
|
+
windowStart TEXT NOT NULL,
|
|
1174
|
+
user TEXT NOT NULL,
|
|
1175
|
+
pct REAL NOT NULL,
|
|
1176
|
+
PRIMARY KEY (group_id, cap, windowStart, user)
|
|
1177
|
+
)`
|
|
1178
|
+
];
|
|
1179
|
+
var REGISTRY_DDL = [
|
|
1180
|
+
`CREATE TABLE IF NOT EXISTS groups (
|
|
1181
|
+
id TEXT PRIMARY KEY,
|
|
1182
|
+
accountId TEXT UNIQUE NOT NULL,
|
|
1183
|
+
passwordHash TEXT NOT NULL,
|
|
1184
|
+
createdAt TEXT NOT NULL
|
|
1185
|
+
)`,
|
|
1186
|
+
`CREATE TABLE IF NOT EXISTS members (
|
|
1187
|
+
id TEXT PRIMARY KEY,
|
|
1188
|
+
groupId TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
|
1189
|
+
name TEXT NOT NULL,
|
|
1190
|
+
passwordHash TEXT NOT NULL,
|
|
1191
|
+
createdAt TEXT NOT NULL,
|
|
1192
|
+
UNIQUE (groupId, name)
|
|
1193
|
+
)`,
|
|
1194
|
+
`CREATE TABLE IF NOT EXISTS tokens (
|
|
1195
|
+
tokenHash TEXT PRIMARY KEY,
|
|
1196
|
+
memberId TEXT NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
|
1197
|
+
createdAt TEXT NOT NULL,
|
|
1198
|
+
lastUsedAt TEXT
|
|
1199
|
+
)`,
|
|
1200
|
+
`CREATE INDEX IF NOT EXISTS idx_tokens_member ON tokens (memberId)`
|
|
1201
|
+
];
|
|
1202
|
+
|
|
1203
|
+
// ../../packages/storage-libsql/dist/registry.js
|
|
1204
|
+
import { randomUUID } from "crypto";
|
|
1205
|
+
function isConstraintViolation(err2) {
|
|
1206
|
+
const code = err2?.code;
|
|
1207
|
+
return typeof code === "string" && code.startsWith("SQLITE_CONSTRAINT");
|
|
1208
|
+
}
|
|
1209
|
+
var LibsqlRegistry = class {
|
|
1210
|
+
client;
|
|
1211
|
+
constructor(client) {
|
|
1212
|
+
this.client = client;
|
|
1213
|
+
}
|
|
1214
|
+
async getGroupByAccount(accountId) {
|
|
1215
|
+
const { rows } = await this.client.execute({
|
|
1216
|
+
sql: `SELECT id, accountId, passwordHash, createdAt FROM groups WHERE accountId = ?`,
|
|
1217
|
+
args: [accountId]
|
|
1218
|
+
});
|
|
1219
|
+
return rows[0] ? toGroup(rows[0]) : null;
|
|
1220
|
+
}
|
|
1221
|
+
async getMember(groupId, name) {
|
|
1222
|
+
const { rows } = await this.client.execute({
|
|
1223
|
+
sql: `SELECT id, groupId, name, passwordHash, createdAt
|
|
1224
|
+
FROM members WHERE groupId = ? AND name = ?`,
|
|
1225
|
+
args: [groupId, name]
|
|
1226
|
+
});
|
|
1227
|
+
return rows[0] ? toMember(rows[0]) : null;
|
|
1228
|
+
}
|
|
1229
|
+
async resolveToken(tokenHash) {
|
|
1230
|
+
const { rows } = await this.client.execute({
|
|
1231
|
+
sql: `SELECT m.id AS memberId, m.groupId AS memberGroupId, m.name AS name,
|
|
1232
|
+
m.passwordHash AS memberHash, m.createdAt AS memberCreatedAt,
|
|
1233
|
+
g.id AS gid, g.accountId AS accountId, g.passwordHash AS groupHash,
|
|
1234
|
+
g.createdAt AS groupCreatedAt
|
|
1235
|
+
FROM tokens t
|
|
1236
|
+
JOIN members m ON m.id = t.memberId
|
|
1237
|
+
JOIN groups g ON g.id = m.groupId
|
|
1238
|
+
WHERE t.tokenHash = ?`,
|
|
1239
|
+
args: [tokenHash]
|
|
1240
|
+
});
|
|
1241
|
+
const r = rows[0];
|
|
1242
|
+
if (!r)
|
|
1243
|
+
return null;
|
|
1244
|
+
return {
|
|
1245
|
+
member: {
|
|
1246
|
+
id: String(r.memberId),
|
|
1247
|
+
groupId: String(r.memberGroupId),
|
|
1248
|
+
name: String(r.name),
|
|
1249
|
+
passwordHash: String(r.memberHash),
|
|
1250
|
+
createdAt: String(r.memberCreatedAt)
|
|
1251
|
+
},
|
|
1252
|
+
group: {
|
|
1253
|
+
id: String(r.gid),
|
|
1254
|
+
accountId: String(r.accountId),
|
|
1255
|
+
passwordHash: String(r.groupHash),
|
|
1256
|
+
createdAt: String(r.groupCreatedAt)
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
async insertToken(tokenHash, memberId) {
|
|
1261
|
+
await this.client.execute({
|
|
1262
|
+
sql: `INSERT INTO tokens (tokenHash, memberId, createdAt) VALUES (?, ?, ?)`,
|
|
1263
|
+
args: [tokenHash, memberId, (/* @__PURE__ */ new Date()).toISOString()]
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
async touchToken(tokenHash) {
|
|
1267
|
+
await this.client.execute({
|
|
1268
|
+
sql: `UPDATE tokens SET lastUsedAt = ? WHERE tokenHash = ?`,
|
|
1269
|
+
args: [(/* @__PURE__ */ new Date()).toISOString(), tokenHash]
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
async createGroupWithMember(input) {
|
|
1273
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1274
|
+
const group = {
|
|
1275
|
+
id: randomUUID(),
|
|
1276
|
+
accountId: input.accountId,
|
|
1277
|
+
passwordHash: input.groupPasswordHash,
|
|
1278
|
+
createdAt: now
|
|
1279
|
+
};
|
|
1280
|
+
const member = {
|
|
1281
|
+
id: randomUUID(),
|
|
1282
|
+
groupId: group.id,
|
|
1283
|
+
name: input.memberName,
|
|
1284
|
+
passwordHash: input.memberPasswordHash,
|
|
1285
|
+
createdAt: now
|
|
1286
|
+
};
|
|
1287
|
+
const stmts = [
|
|
1288
|
+
// The UNIQUE(accountId) constraint is the create-race arbiter.
|
|
1289
|
+
{
|
|
1290
|
+
sql: `INSERT INTO groups (id, accountId, passwordHash, createdAt) VALUES (?, ?, ?, ?)`,
|
|
1291
|
+
args: [group.id, group.accountId, group.passwordHash, group.createdAt]
|
|
1292
|
+
},
|
|
1293
|
+
// Provision the group's ledger in the same transaction. writeSeq starts
|
|
1294
|
+
// at 1: the roster insert below is this ledger's first observable write.
|
|
1295
|
+
{
|
|
1296
|
+
sql: `INSERT INTO ccpool_meta (group_id, app, schemaVersion, projectId, createdAt, accountId, writeSeq)
|
|
1297
|
+
VALUES (?, 'ccpool', ?, ?, ?, ?, 1)`,
|
|
1298
|
+
args: [group.id, SCHEMA_VERSION, randomUUID(), now, input.accountId]
|
|
1299
|
+
},
|
|
1300
|
+
{
|
|
1301
|
+
sql: `INSERT INTO users (group_id, name, createdAt) VALUES (?, ?, ?)`,
|
|
1302
|
+
args: [group.id, member.name, now]
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
sql: `INSERT INTO members (id, groupId, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)`,
|
|
1306
|
+
args: [member.id, member.groupId, member.name, member.passwordHash, member.createdAt]
|
|
1307
|
+
},
|
|
1308
|
+
{
|
|
1309
|
+
sql: `INSERT INTO tokens (tokenHash, memberId, createdAt) VALUES (?, ?, ?)`,
|
|
1310
|
+
args: [input.tokenHash, member.id, now]
|
|
1311
|
+
}
|
|
1312
|
+
];
|
|
1313
|
+
try {
|
|
1314
|
+
await this.client.batch(stmts, "write");
|
|
1315
|
+
} catch (err2) {
|
|
1316
|
+
if (isConstraintViolation(err2))
|
|
1317
|
+
throw new RegistryConflictError("group-exists");
|
|
1318
|
+
throw err2;
|
|
1319
|
+
}
|
|
1320
|
+
return { group, member };
|
|
1321
|
+
}
|
|
1322
|
+
async addMemberWithToken(groupId, name, passwordHash, tokenHash) {
|
|
1323
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1324
|
+
const member = {
|
|
1325
|
+
id: randomUUID(),
|
|
1326
|
+
groupId,
|
|
1327
|
+
name,
|
|
1328
|
+
passwordHash,
|
|
1329
|
+
createdAt: now
|
|
1330
|
+
};
|
|
1331
|
+
const stmts = [
|
|
1332
|
+
// UNIQUE(groupId, name) is the same-name-race arbiter.
|
|
1333
|
+
{
|
|
1334
|
+
sql: `INSERT INTO members (id, groupId, name, passwordHash, createdAt) VALUES (?, ?, ?, ?, ?)`,
|
|
1335
|
+
args: [member.id, member.groupId, member.name, member.passwordHash, member.createdAt]
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
sql: `INSERT INTO users (group_id, name, createdAt) VALUES (?, ?, ?)
|
|
1339
|
+
ON CONFLICT(group_id, name) DO NOTHING`,
|
|
1340
|
+
args: [groupId, name, now]
|
|
1341
|
+
},
|
|
1342
|
+
{
|
|
1343
|
+
sql: `UPDATE ccpool_meta SET writeSeq = writeSeq + 1 WHERE group_id = ?`,
|
|
1344
|
+
args: [groupId]
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
sql: `INSERT INTO tokens (tokenHash, memberId, createdAt) VALUES (?, ?, ?)`,
|
|
1348
|
+
args: [tokenHash, member.id, now]
|
|
1349
|
+
}
|
|
1350
|
+
];
|
|
1351
|
+
try {
|
|
1352
|
+
await this.client.batch(stmts, "write");
|
|
1353
|
+
} catch (err2) {
|
|
1354
|
+
if (isConstraintViolation(err2))
|
|
1355
|
+
throw new RegistryConflictError("member-exists");
|
|
1356
|
+
throw err2;
|
|
1357
|
+
}
|
|
1358
|
+
return member;
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
function toGroup(r) {
|
|
1362
|
+
return {
|
|
1363
|
+
id: String(r.id),
|
|
1364
|
+
accountId: String(r.accountId),
|
|
1365
|
+
passwordHash: String(r.passwordHash),
|
|
1366
|
+
createdAt: String(r.createdAt)
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
function toMember(r) {
|
|
1370
|
+
return {
|
|
1371
|
+
id: String(r.id),
|
|
1372
|
+
groupId: String(r.groupId),
|
|
1373
|
+
name: String(r.name),
|
|
1374
|
+
passwordHash: String(r.passwordHash),
|
|
1375
|
+
createdAt: String(r.createdAt)
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// ../../packages/storage-libsql/dist/storage.js
|
|
1380
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1381
|
+
var LibsqlStorage = class {
|
|
1382
|
+
client;
|
|
1383
|
+
groupId;
|
|
1384
|
+
constructor(client, groupId = DEFAULT_GROUP_ID) {
|
|
1385
|
+
this.client = client;
|
|
1386
|
+
this.groupId = groupId;
|
|
1387
|
+
}
|
|
1388
|
+
async inspect() {
|
|
1389
|
+
const { rows } = await this.client.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_litestream%'");
|
|
1390
|
+
const tables = new Set(rows.map((r) => String(r.name)));
|
|
1391
|
+
if (!tables.has("ccpool_meta"))
|
|
1392
|
+
return { kind: "empty" };
|
|
1393
|
+
const meta = await this.client.execute({
|
|
1394
|
+
sql: "SELECT * FROM ccpool_meta WHERE group_id = ? LIMIT 1",
|
|
1395
|
+
args: [this.groupId]
|
|
1396
|
+
});
|
|
1397
|
+
const row = meta.rows[0];
|
|
1398
|
+
if (!row)
|
|
1399
|
+
return { kind: "empty" };
|
|
1400
|
+
return {
|
|
1401
|
+
kind: "ccpool",
|
|
1402
|
+
schemaVersion: Number(row.schemaVersion ?? SCHEMA_VERSION),
|
|
1403
|
+
accountId: row.accountId == null ? null : String(row.accountId)
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
async initializeSchema(accountId = null) {
|
|
1407
|
+
await this.client.batch([
|
|
1408
|
+
...LEDGER_DDL,
|
|
1409
|
+
{
|
|
1410
|
+
sql: `INSERT INTO ccpool_meta (group_id, app, schemaVersion, projectId, createdAt, accountId, writeSeq)
|
|
1411
|
+
VALUES (?, 'ccpool', ?, ?, ?, ?, 0)
|
|
1412
|
+
ON CONFLICT(group_id) DO NOTHING`,
|
|
1413
|
+
args: [this.groupId, SCHEMA_VERSION, randomUUID2(), (/* @__PURE__ */ new Date()).toISOString(), accountId]
|
|
1414
|
+
}
|
|
1415
|
+
], "write");
|
|
1416
|
+
}
|
|
1417
|
+
async bindAccount(accountId) {
|
|
1418
|
+
await this.client.execute({
|
|
1419
|
+
sql: "UPDATE ccpool_meta SET accountId = ? WHERE group_id = ? AND accountId IS NULL",
|
|
1420
|
+
args: [accountId, this.groupId]
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
async migrate(toVersion) {
|
|
1424
|
+
await this.client.batch([
|
|
1425
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_samples_cap ON usage_samples (group_id, cap, capturedAt)`,
|
|
1426
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_reset_events_uniq ON reset_events (group_id, cap, at)`,
|
|
1427
|
+
// Additive: an older DB gains the history tables here (fresh DBs get them
|
|
1428
|
+
// from LEDGER_DDL at init). Kept in sync with ddl.ts.
|
|
1429
|
+
`CREATE TABLE IF NOT EXISTS history_windows (
|
|
1430
|
+
group_id TEXT NOT NULL, cap TEXT NOT NULL, windowStart TEXT NOT NULL,
|
|
1431
|
+
windowEnd TEXT NOT NULL, overall REAL NOT NULL, closedAt TEXT NOT NULL,
|
|
1432
|
+
PRIMARY KEY (group_id, cap, windowStart))`,
|
|
1433
|
+
`CREATE TABLE IF NOT EXISTS history_shares (
|
|
1434
|
+
group_id TEXT NOT NULL, cap TEXT NOT NULL, windowStart TEXT NOT NULL,
|
|
1435
|
+
user TEXT NOT NULL, pct REAL NOT NULL,
|
|
1436
|
+
PRIMARY KEY (group_id, cap, windowStart, user))`,
|
|
1437
|
+
{
|
|
1438
|
+
sql: "UPDATE ccpool_meta SET schemaVersion = ? WHERE group_id = ?",
|
|
1439
|
+
args: [toVersion, this.groupId]
|
|
1440
|
+
}
|
|
1441
|
+
], "write");
|
|
1442
|
+
}
|
|
1443
|
+
/** No-op: the client belongs to the `LibsqlDatabase`; only it tears down. */
|
|
1444
|
+
async close() {
|
|
1445
|
+
}
|
|
1446
|
+
async upsertUser(name) {
|
|
1447
|
+
await this.client.batch([
|
|
1448
|
+
{
|
|
1449
|
+
sql: `INSERT INTO users (group_id, name, createdAt) VALUES (?, ?, ?)
|
|
1450
|
+
ON CONFLICT(group_id, name) DO NOTHING`,
|
|
1451
|
+
args: [this.groupId, name, (/* @__PURE__ */ new Date()).toISOString()]
|
|
1452
|
+
},
|
|
1453
|
+
this.bumpWriteSeq()
|
|
1454
|
+
], "write");
|
|
1455
|
+
}
|
|
1456
|
+
async getUsers() {
|
|
1457
|
+
const { rows } = await this.client.execute({
|
|
1458
|
+
sql: "SELECT name, createdAt FROM users WHERE group_id = ? ORDER BY name",
|
|
1459
|
+
args: [this.groupId]
|
|
1460
|
+
});
|
|
1461
|
+
return rows.map((r) => ({ name: String(r.name), createdAt: String(r.createdAt) }));
|
|
1462
|
+
}
|
|
1463
|
+
async recordBatch(batch) {
|
|
1464
|
+
if (isEmptyBatch(batch))
|
|
1465
|
+
return;
|
|
1466
|
+
const g = this.groupId;
|
|
1467
|
+
const stmts = [];
|
|
1468
|
+
for (const s of batch.samples) {
|
|
1469
|
+
stmts.push({
|
|
1470
|
+
sql: `INSERT INTO usage_samples (group_id, cap, pct, resetsAt, capturedAt) VALUES (?, ?, ?, ?, ?)
|
|
1471
|
+
ON CONFLICT(group_id, cap, capturedAt) DO NOTHING`,
|
|
1472
|
+
args: [g, s.cap, s.pct, s.resetsAt, s.capturedAt]
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
for (const e of batch.resets) {
|
|
1476
|
+
stmts.push({
|
|
1477
|
+
sql: `INSERT INTO reset_events (group_id, cap, at, previousPct) VALUES (?, ?, ?, ?)
|
|
1478
|
+
ON CONFLICT(group_id, cap, at) DO NOTHING`,
|
|
1479
|
+
args: [g, e.cap, e.at, e.previousPct]
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
for (const m of batch.messages) {
|
|
1483
|
+
stmts.push({
|
|
1484
|
+
sql: `INSERT INTO message_usage
|
|
1485
|
+
(group_id, uuid, user, timestamp, model, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens)
|
|
1486
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1487
|
+
ON CONFLICT(group_id, uuid) DO NOTHING`,
|
|
1488
|
+
args: [
|
|
1489
|
+
g,
|
|
1490
|
+
m.uuid,
|
|
1491
|
+
m.user,
|
|
1492
|
+
m.timestamp,
|
|
1493
|
+
m.model,
|
|
1494
|
+
m.inputTokens,
|
|
1495
|
+
m.outputTokens,
|
|
1496
|
+
m.cacheCreationTokens,
|
|
1497
|
+
m.cacheReadTokens
|
|
1498
|
+
]
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
for (const m of batch.markers) {
|
|
1502
|
+
stmts.push({
|
|
1503
|
+
sql: `INSERT INTO usage_markers (group_id, id, user, at, model, weight)
|
|
1504
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1505
|
+
ON CONFLICT(group_id, id) DO NOTHING`,
|
|
1506
|
+
args: [g, m.id, m.user, m.at, m.model, m.weight]
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
stmts.push(this.bumpWriteSeq());
|
|
1510
|
+
await this.client.batch(stmts, "write");
|
|
1511
|
+
}
|
|
1512
|
+
async prune(before) {
|
|
1513
|
+
const g = this.groupId;
|
|
1514
|
+
await this.client.batch([
|
|
1515
|
+
{
|
|
1516
|
+
sql: `DELETE FROM usage_samples WHERE group_id = ? AND capturedAt < ?`,
|
|
1517
|
+
args: [g, before]
|
|
1518
|
+
},
|
|
1519
|
+
{ sql: `DELETE FROM reset_events WHERE group_id = ? AND at < ?`, args: [g, before] },
|
|
1520
|
+
{
|
|
1521
|
+
sql: `DELETE FROM message_usage WHERE group_id = ? AND timestamp < ?`,
|
|
1522
|
+
args: [g, before]
|
|
1523
|
+
},
|
|
1524
|
+
{ sql: `DELETE FROM usage_markers WHERE group_id = ? AND at < ?`, args: [g, before] },
|
|
1525
|
+
this.bumpWriteSeq()
|
|
1526
|
+
], "write");
|
|
1527
|
+
}
|
|
1528
|
+
async getChangeToken() {
|
|
1529
|
+
try {
|
|
1530
|
+
const { rows } = await this.client.execute({
|
|
1531
|
+
sql: "SELECT writeSeq FROM ccpool_meta WHERE group_id = ? LIMIT 1",
|
|
1532
|
+
args: [this.groupId]
|
|
1533
|
+
});
|
|
1534
|
+
return String(rows[0]?.writeSeq ?? 0);
|
|
1535
|
+
} catch (err2) {
|
|
1536
|
+
throw new Error("this database predates the current schema (no writeSeq) \u2014 re-run `ccpool init`", { cause: err2 });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
async getLatestSamples() {
|
|
1540
|
+
const { rows } = await this.client.execute({
|
|
1541
|
+
sql: `SELECT cap, pct, resetsAt, capturedAt FROM usage_samples s
|
|
1542
|
+
WHERE group_id = ?1
|
|
1543
|
+
AND capturedAt = (SELECT MAX(capturedAt) FROM usage_samples
|
|
1544
|
+
WHERE cap = s.cap AND group_id = ?1)`,
|
|
1545
|
+
args: [this.groupId]
|
|
1546
|
+
});
|
|
1547
|
+
const byCap = /* @__PURE__ */ new Map();
|
|
1548
|
+
for (const r of rows) {
|
|
1549
|
+
byCap.set(r.cap, {
|
|
1550
|
+
cap: r.cap,
|
|
1551
|
+
pct: Number(r.pct),
|
|
1552
|
+
resetsAt: r.resetsAt == null ? null : String(r.resetsAt),
|
|
1553
|
+
capturedAt: String(r.capturedAt)
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
return CAP_KINDS.map((c) => byCap.get(c)).filter((s) => !!s);
|
|
1557
|
+
}
|
|
1558
|
+
async getUsageSamplesSince(since) {
|
|
1559
|
+
const { rows } = await this.client.execute({
|
|
1560
|
+
sql: `SELECT cap, pct, resetsAt, capturedAt FROM usage_samples
|
|
1561
|
+
WHERE group_id = ? AND capturedAt >= ? ORDER BY capturedAt ASC`,
|
|
1562
|
+
args: [this.groupId, since]
|
|
1563
|
+
});
|
|
1564
|
+
return rows.map((r) => ({
|
|
1565
|
+
cap: r.cap,
|
|
1566
|
+
pct: Number(r.pct),
|
|
1567
|
+
resetsAt: r.resetsAt == null ? null : String(r.resetsAt),
|
|
1568
|
+
capturedAt: String(r.capturedAt)
|
|
1569
|
+
}));
|
|
1570
|
+
}
|
|
1571
|
+
async getResetsSince(since) {
|
|
1572
|
+
const { rows } = await this.client.execute({
|
|
1573
|
+
sql: `SELECT cap, at, previousPct FROM reset_events
|
|
1574
|
+
WHERE group_id = ? AND at >= ? ORDER BY at ASC`,
|
|
1575
|
+
args: [this.groupId, since]
|
|
1576
|
+
});
|
|
1577
|
+
return rows.map((r) => ({
|
|
1578
|
+
cap: r.cap,
|
|
1579
|
+
at: String(r.at),
|
|
1580
|
+
previousPct: Number(r.previousPct)
|
|
1581
|
+
}));
|
|
1582
|
+
}
|
|
1583
|
+
async getMessageUsageSince(since) {
|
|
1584
|
+
const { rows } = await this.client.execute({
|
|
1585
|
+
sql: `SELECT uuid, user, timestamp, model, inputTokens, outputTokens,
|
|
1586
|
+
cacheCreationTokens, cacheReadTokens
|
|
1587
|
+
FROM message_usage WHERE group_id = ? AND timestamp >= ?`,
|
|
1588
|
+
args: [this.groupId, since]
|
|
1589
|
+
});
|
|
1590
|
+
return rows.map((r) => ({
|
|
1591
|
+
uuid: String(r.uuid),
|
|
1592
|
+
user: String(r.user),
|
|
1593
|
+
timestamp: String(r.timestamp),
|
|
1594
|
+
model: r.model == null ? null : String(r.model),
|
|
1595
|
+
inputTokens: Number(r.inputTokens),
|
|
1596
|
+
outputTokens: Number(r.outputTokens),
|
|
1597
|
+
cacheCreationTokens: Number(r.cacheCreationTokens),
|
|
1598
|
+
cacheReadTokens: Number(r.cacheReadTokens)
|
|
1599
|
+
}));
|
|
1600
|
+
}
|
|
1601
|
+
async getUsageMarkersSince(since) {
|
|
1602
|
+
const { rows } = await this.client.execute({
|
|
1603
|
+
sql: `SELECT id, user, at, model, weight FROM usage_markers
|
|
1604
|
+
WHERE group_id = ? AND at >= ?`,
|
|
1605
|
+
args: [this.groupId, since]
|
|
1606
|
+
});
|
|
1607
|
+
return rows.map((r) => ({
|
|
1608
|
+
id: String(r.id),
|
|
1609
|
+
user: String(r.user),
|
|
1610
|
+
at: String(r.at),
|
|
1611
|
+
model: r.model == null ? null : String(r.model),
|
|
1612
|
+
weight: Number(r.weight)
|
|
1613
|
+
}));
|
|
1614
|
+
}
|
|
1615
|
+
async recordHistoryWindow(window, shares) {
|
|
1616
|
+
const g = this.groupId;
|
|
1617
|
+
const stmts = [
|
|
1618
|
+
{
|
|
1619
|
+
sql: `INSERT INTO history_windows (group_id, cap, windowStart, windowEnd, overall, closedAt)
|
|
1620
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1621
|
+
ON CONFLICT(group_id, cap, windowStart) DO NOTHING`,
|
|
1622
|
+
args: [
|
|
1623
|
+
g,
|
|
1624
|
+
window.cap,
|
|
1625
|
+
window.windowStart,
|
|
1626
|
+
window.windowEnd,
|
|
1627
|
+
window.overall,
|
|
1628
|
+
window.closedAt
|
|
1629
|
+
]
|
|
1630
|
+
}
|
|
1631
|
+
];
|
|
1632
|
+
for (const sh of shares) {
|
|
1633
|
+
stmts.push({
|
|
1634
|
+
sql: `INSERT INTO history_shares (group_id, cap, windowStart, user, pct)
|
|
1635
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1636
|
+
ON CONFLICT(group_id, cap, windowStart, user) DO NOTHING`,
|
|
1637
|
+
args: [g, sh.cap, sh.windowStart, sh.user, sh.pct]
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
stmts.push(this.bumpWriteSeq());
|
|
1641
|
+
await this.client.batch(stmts, "write");
|
|
1642
|
+
}
|
|
1643
|
+
async getHistoryWindows(cap, opts = {}) {
|
|
1644
|
+
const limit = opts.limit ?? 100;
|
|
1645
|
+
const before = opts.before ?? null;
|
|
1646
|
+
const { rows } = await this.client.execute({
|
|
1647
|
+
sql: `SELECT cap, windowStart, windowEnd, overall, closedAt FROM history_windows
|
|
1648
|
+
WHERE group_id = ?1 AND cap = ?2 AND (?3 IS NULL OR windowStart < ?3)
|
|
1649
|
+
ORDER BY windowStart DESC LIMIT ?4`,
|
|
1650
|
+
args: [this.groupId, cap, before, limit]
|
|
1651
|
+
});
|
|
1652
|
+
return rows.map((r) => ({
|
|
1653
|
+
cap: r.cap,
|
|
1654
|
+
windowStart: String(r.windowStart),
|
|
1655
|
+
windowEnd: String(r.windowEnd),
|
|
1656
|
+
overall: Number(r.overall),
|
|
1657
|
+
closedAt: String(r.closedAt)
|
|
1658
|
+
}));
|
|
1659
|
+
}
|
|
1660
|
+
async getHistoryShares(cap, windowStart) {
|
|
1661
|
+
const { rows } = await this.client.execute({
|
|
1662
|
+
sql: `SELECT cap, windowStart, user, pct FROM history_shares
|
|
1663
|
+
WHERE group_id = ? AND cap = ? AND windowStart = ?`,
|
|
1664
|
+
args: [this.groupId, cap, windowStart]
|
|
1665
|
+
});
|
|
1666
|
+
return rows.map((r) => ({
|
|
1667
|
+
cap: r.cap,
|
|
1668
|
+
windowStart: String(r.windowStart),
|
|
1669
|
+
user: String(r.user),
|
|
1670
|
+
pct: Number(r.pct)
|
|
1671
|
+
}));
|
|
1672
|
+
}
|
|
1673
|
+
/** Every write batch ends with this so one tick bumps this group's token once. */
|
|
1674
|
+
bumpWriteSeq() {
|
|
1675
|
+
return {
|
|
1676
|
+
sql: "UPDATE ccpool_meta SET writeSeq = writeSeq + 1 WHERE group_id = ?",
|
|
1677
|
+
args: [this.groupId]
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
// ../../packages/storage-libsql/dist/url.js
|
|
1683
|
+
import { mkdirSync } from "fs";
|
|
1684
|
+
import { homedir as homedir2 } from "os";
|
|
1685
|
+
import { dirname as dirname2 } from "path";
|
|
1686
|
+
function normalizeUrl(url) {
|
|
1687
|
+
if (url === ":memory:")
|
|
1688
|
+
return url;
|
|
1689
|
+
if (!url.includes("://") && !url.startsWith("file:")) {
|
|
1690
|
+
url = "file:" + url;
|
|
1691
|
+
}
|
|
1692
|
+
if (url.startsWith("file:")) {
|
|
1693
|
+
let path = url.slice("file:".length);
|
|
1694
|
+
if (path.startsWith("//"))
|
|
1695
|
+
path = path.slice(2);
|
|
1696
|
+
if (path.startsWith("~"))
|
|
1697
|
+
path = homedir2() + path.slice(1);
|
|
1698
|
+
return "file:" + path;
|
|
1699
|
+
}
|
|
1700
|
+
return url;
|
|
1701
|
+
}
|
|
1702
|
+
function ensureFileDir(url) {
|
|
1703
|
+
if (!url.startsWith("file:"))
|
|
1704
|
+
return;
|
|
1705
|
+
let path = url.slice("file:".length);
|
|
1706
|
+
if (path.startsWith("//"))
|
|
1707
|
+
path = path.slice(2);
|
|
1708
|
+
if (path.length === 0 || path === ":memory:")
|
|
1709
|
+
return;
|
|
1710
|
+
const dir = dirname2(path);
|
|
1711
|
+
if (dir && dir !== ".")
|
|
1712
|
+
mkdirSync(dir, { recursive: true });
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// ../../packages/storage-libsql/dist/database.js
|
|
1716
|
+
var LibsqlDatabase = class {
|
|
1717
|
+
client;
|
|
1718
|
+
registry;
|
|
1719
|
+
constructor(url, opts = {}) {
|
|
1720
|
+
const normalized = normalizeUrl(url);
|
|
1721
|
+
ensureFileDir(normalized);
|
|
1722
|
+
this.client = createClient(opts.authToken ? { url: normalized, authToken: opts.authToken } : { url: normalized });
|
|
1723
|
+
this.registry = new LibsqlRegistry(this.client);
|
|
1724
|
+
}
|
|
1725
|
+
async init() {
|
|
1726
|
+
await this.client.batch([...LEDGER_DDL, ...REGISTRY_DDL], "write");
|
|
1727
|
+
}
|
|
1728
|
+
forGroup(groupId) {
|
|
1729
|
+
return new LibsqlStorage(this.client, groupId);
|
|
1730
|
+
}
|
|
1731
|
+
async close() {
|
|
1732
|
+
this.client.close();
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
|
|
1736
|
+
// src/tenants.ts
|
|
1737
|
+
var MAX_LIVE_TENANTS = 50;
|
|
1738
|
+
var TenantCache = class {
|
|
1739
|
+
constructor(db) {
|
|
1740
|
+
this.db = db;
|
|
1741
|
+
}
|
|
1742
|
+
db;
|
|
1743
|
+
tenants = /* @__PURE__ */ new Map();
|
|
1744
|
+
async get(group) {
|
|
1745
|
+
const entry = this.open(group);
|
|
1746
|
+
await this.ready(entry);
|
|
1747
|
+
return entry.tenant;
|
|
1748
|
+
}
|
|
1749
|
+
/** Bootstrap (migrate) a tenant's ledger once per open; don't cache a failure. */
|
|
1750
|
+
ready(entry) {
|
|
1751
|
+
if (!entry.ready) {
|
|
1752
|
+
entry.ready = entry.tenant.sink.bootstrap().then(
|
|
1753
|
+
() => void 0,
|
|
1754
|
+
(err2) => {
|
|
1755
|
+
entry.ready = void 0;
|
|
1756
|
+
throw err2;
|
|
1757
|
+
}
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
return entry.ready;
|
|
1761
|
+
}
|
|
1762
|
+
/** Cached tenant (LRU refresh on access), composing over the shared Database. */
|
|
1763
|
+
open(group) {
|
|
1764
|
+
const hit = this.tenants.get(group.id);
|
|
1765
|
+
if (hit) {
|
|
1766
|
+
this.tenants.delete(group.id);
|
|
1767
|
+
this.tenants.set(group.id, hit);
|
|
1768
|
+
return hit;
|
|
1769
|
+
}
|
|
1770
|
+
const storage = this.db.forGroup(group.id);
|
|
1771
|
+
const window = new LedgerWindow(storage);
|
|
1772
|
+
const entry = {
|
|
1773
|
+
tenant: {
|
|
1774
|
+
sink: new StorageIngestSink(storage, { window }),
|
|
1775
|
+
view: new StorageViewSource(storage, { window })
|
|
1776
|
+
}
|
|
1777
|
+
};
|
|
1778
|
+
this.tenants.set(group.id, entry);
|
|
1779
|
+
if (this.tenants.size > MAX_LIVE_TENANTS) {
|
|
1780
|
+
const oldestId = this.tenants.keys().next().value;
|
|
1781
|
+
this.tenants.delete(oldestId);
|
|
1782
|
+
}
|
|
1783
|
+
return entry;
|
|
1784
|
+
}
|
|
1785
|
+
async close() {
|
|
1786
|
+
this.tenants.clear();
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
|
|
1790
|
+
// src/backend.ts
|
|
1791
|
+
function resolveServerBackend(env = process.env) {
|
|
1792
|
+
const url = env.DATABASE_URL?.trim();
|
|
1793
|
+
if (!url) {
|
|
1794
|
+
throw new Error("DATABASE_URL is required \u2014 a file: path or a libsql://\u2026 (Turso) URL");
|
|
1795
|
+
}
|
|
1796
|
+
return { url, authToken: env.CCPOOL_DB_AUTH_TOKEN?.trim() || void 0 };
|
|
1797
|
+
}
|
|
1798
|
+
function makeServerDeps(cfg) {
|
|
1799
|
+
const db = new LibsqlDatabase(cfg.url, { authToken: cfg.authToken });
|
|
1800
|
+
return { db, registry: db.registry, tenants: new TenantCache(db) };
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// src/index.ts
|
|
1804
|
+
var backend;
|
|
1805
|
+
try {
|
|
1806
|
+
backend = resolveServerBackend();
|
|
1807
|
+
} catch (err2) {
|
|
1808
|
+
console.error(
|
|
1809
|
+
`${err2.message}. For local dev, set DATABASE_URL=file:./ccpool-dev.db and run \`pnpm dev\`.`
|
|
1810
|
+
);
|
|
1811
|
+
process.exit(1);
|
|
1812
|
+
}
|
|
1813
|
+
var port = Number(process.env.PORT ?? 8787);
|
|
1814
|
+
var deps = makeServerDeps(backend);
|
|
1815
|
+
async function ensureDatabase() {
|
|
1816
|
+
for (; ; ) {
|
|
1817
|
+
try {
|
|
1818
|
+
await deps.db.init();
|
|
1819
|
+
return;
|
|
1820
|
+
} catch {
|
|
1821
|
+
console.warn(`waiting for the libSQL database at ${backend.url} (retrying in 3s)`);
|
|
1822
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
await ensureDatabase();
|
|
1827
|
+
var app = makeApp(deps);
|
|
1828
|
+
var server = serve({ fetch: app.fetch, port }, (info) => {
|
|
1829
|
+
console.log(`ccpool server (libSQL) listening on :${info.port}`);
|
|
1830
|
+
});
|
|
1831
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1832
|
+
process.on(sig, () => {
|
|
1833
|
+
console.log(`received ${sig}, shutting down`);
|
|
1834
|
+
server.close(() => {
|
|
1835
|
+
void Promise.all([deps.tenants.close(), deps.db.close()]).then(() => process.exit(0));
|
|
1836
|
+
});
|
|
1837
|
+
});
|
|
1838
|
+
}
|