@token-dashboard/codex-usage-uploader 0.1.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.
@@ -0,0 +1,334 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import {
5
+ MAX_BATCH_BYTES,
6
+ MAX_BUFFER_AGE_SECONDS,
7
+ MAX_EVENTS_PER_BATCH,
8
+ } from './constants.js';
9
+ import { nowTs, stableStringify } from './utils.js';
10
+
11
+ export class StateDb {
12
+ constructor(dbPath) {
13
+ this.dbPath = dbPath;
14
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
15
+ this.db = new DatabaseSync(dbPath, { readBigInts: true });
16
+ this.initSchema();
17
+ }
18
+
19
+ close() {
20
+ this.db.close();
21
+ }
22
+
23
+ initSchema() {
24
+ this.db.exec(`
25
+ CREATE TABLE IF NOT EXISTS identity_config (
26
+ key TEXT PRIMARY KEY,
27
+ value TEXT NOT NULL,
28
+ updated_at REAL NOT NULL
29
+ );
30
+
31
+ CREATE TABLE IF NOT EXISTS pending_batches (
32
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
33
+ batch_key TEXT NOT NULL UNIQUE,
34
+ status TEXT NOT NULL,
35
+ payload_json TEXT NOT NULL,
36
+ payload_bytes INTEGER NOT NULL DEFAULT 0,
37
+ session_count INTEGER NOT NULL DEFAULT 0,
38
+ turn_count INTEGER NOT NULL DEFAULT 0,
39
+ event_count INTEGER NOT NULL DEFAULT 0,
40
+ attempt_count INTEGER NOT NULL DEFAULT 0,
41
+ next_retry_at REAL,
42
+ last_error TEXT,
43
+ created_at REAL NOT NULL,
44
+ updated_at REAL NOT NULL
45
+ );
46
+
47
+ CREATE TABLE IF NOT EXISTS upload_checkpoint (
48
+ key TEXT PRIMARY KEY,
49
+ value TEXT NOT NULL,
50
+ updated_at REAL NOT NULL
51
+ );
52
+ `);
53
+ this.ensureIngestionFilesTable();
54
+ }
55
+
56
+ ensureIngestionFilesTable() {
57
+ const existing = this.db
58
+ .prepare(
59
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'ingestion_files'",
60
+ )
61
+ .get();
62
+ if (!existing) {
63
+ this.createIngestionFilesTable();
64
+ return;
65
+ }
66
+
67
+ const columns = this.db.prepare('PRAGMA table_info(ingestion_files)').all();
68
+ const hasSourceRoot = columns.some((column) => column.name === 'source_root');
69
+ if (hasSourceRoot) {
70
+ return;
71
+ }
72
+
73
+ this.db.exec('BEGIN');
74
+ try {
75
+ this.db.exec('ALTER TABLE ingestion_files RENAME TO ingestion_files_legacy');
76
+ this.createIngestionFilesTable();
77
+ this.db.prepare(`
78
+ INSERT INTO ingestion_files(
79
+ source_root, relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at
80
+ )
81
+ SELECT 'sessions', relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at
82
+ FROM ingestion_files_legacy
83
+ `).run();
84
+ this.db.exec('DROP TABLE ingestion_files_legacy');
85
+ this.db.exec('COMMIT');
86
+ } catch (error) {
87
+ this.db.exec('ROLLBACK');
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ createIngestionFilesTable() {
93
+ this.db.exec(`
94
+ CREATE TABLE IF NOT EXISTS ingestion_files (
95
+ source_root TEXT NOT NULL,
96
+ relpath TEXT NOT NULL,
97
+ file_size INTEGER NOT NULL,
98
+ file_mtime_ns INTEGER NOT NULL,
99
+ last_line_no INTEGER NOT NULL,
100
+ state_json TEXT,
101
+ updated_at REAL NOT NULL,
102
+ PRIMARY KEY (source_root, relpath)
103
+ );
104
+ `);
105
+ }
106
+
107
+ getIdentity() {
108
+ const rows = this.db.prepare('SELECT key, value FROM identity_config').all();
109
+ return Object.fromEntries(rows.map((row) => [row.key, row.value]));
110
+ }
111
+
112
+ setIdentity(values) {
113
+ const ts = nowTs();
114
+ const upsert = this.db.prepare(`
115
+ INSERT INTO identity_config(key, value, updated_at)
116
+ VALUES (?, ?, ?)
117
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
118
+ `);
119
+ const del = this.db.prepare('DELETE FROM identity_config WHERE key = ?');
120
+ this.db.exec('BEGIN');
121
+ try {
122
+ for (const [key, value] of Object.entries(values)) {
123
+ if (value == null) {
124
+ del.run(key);
125
+ } else {
126
+ upsert.run(key, value, ts);
127
+ }
128
+ }
129
+ this.db.exec('COMMIT');
130
+ } catch (error) {
131
+ this.db.exec('ROLLBACK');
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ getCheckpoint(key) {
137
+ const row = this.db.prepare('SELECT value FROM upload_checkpoint WHERE key = ?').get(key);
138
+ return row?.value ?? null;
139
+ }
140
+
141
+ setCheckpoint(key, value) {
142
+ const ts = nowTs();
143
+ this.db.prepare(`
144
+ INSERT INTO upload_checkpoint(key, value, updated_at)
145
+ VALUES (?, ?, ?)
146
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
147
+ `).run(key, value, ts);
148
+ }
149
+
150
+ getFileState(sourceRoot, relpath) {
151
+ const stmt = this.db.prepare(
152
+ 'SELECT * FROM ingestion_files WHERE source_root = ? AND relpath = ?',
153
+ );
154
+ stmt.setReadBigInts(true);
155
+ return stmt.get(sourceRoot, relpath) ?? null;
156
+ }
157
+
158
+ upsertFileState(sourceRoot, relpath, fileSize, fileMtimeNs, lastLineNo, state) {
159
+ this.db.prepare(`
160
+ INSERT INTO ingestion_files(
161
+ source_root, relpath, file_size, file_mtime_ns, last_line_no, state_json, updated_at
162
+ )
163
+ VALUES (?, ?, ?, ?, ?, ?, ?)
164
+ ON CONFLICT(source_root, relpath) DO UPDATE SET
165
+ file_size = excluded.file_size,
166
+ file_mtime_ns = excluded.file_mtime_ns,
167
+ last_line_no = excluded.last_line_no,
168
+ state_json = excluded.state_json,
169
+ updated_at = excluded.updated_at
170
+ `).run(
171
+ sourceRoot,
172
+ relpath,
173
+ fileSize,
174
+ fileMtimeNs,
175
+ lastLineNo,
176
+ stableStringify(state),
177
+ nowTs(),
178
+ );
179
+ }
180
+
181
+ getBufferingBatch() {
182
+ const stmt = this.db.prepare(`
183
+ SELECT * FROM pending_batches
184
+ WHERE status = 'buffering'
185
+ ORDER BY id ASC
186
+ LIMIT 1
187
+ `);
188
+ stmt.setReadBigInts(true);
189
+ return stmt.get() ?? null;
190
+ }
191
+
192
+ saveBufferingPayload(payload) {
193
+ const existing = this.getBufferingBatch();
194
+ const payloadJson = stableStringify(payload);
195
+ const payloadBytes = Buffer.byteLength(payloadJson, 'utf8');
196
+ const sessionCount = payload.sessions?.length ?? 0;
197
+ const turnCount = payload.turns?.length ?? 0;
198
+ const eventCount = payload.events?.length ?? 0;
199
+ const ts = nowTs();
200
+ if (existing) {
201
+ this.db.prepare(`
202
+ UPDATE pending_batches
203
+ SET payload_json = ?, payload_bytes = ?, session_count = ?, turn_count = ?, event_count = ?, updated_at = ?
204
+ WHERE id = ?
205
+ `).run(payloadJson, payloadBytes, sessionCount, turnCount, eventCount, ts, existing.id);
206
+ } else {
207
+ this.db.prepare(`
208
+ INSERT INTO pending_batches(
209
+ batch_key, status, payload_json, payload_bytes, session_count, turn_count, event_count,
210
+ attempt_count, next_retry_at, created_at, updated_at
211
+ ) VALUES (?, 'buffering', ?, ?, ?, ?, ?, 0, NULL, ?, ?)
212
+ `).run(randomBatchKey(), payloadJson, payloadBytes, sessionCount, turnCount, eventCount, ts, ts);
213
+ }
214
+ return this.getBufferingBatch();
215
+ }
216
+
217
+ sealStaleBatches(force = false) {
218
+ const selectStmt = this.db.prepare(
219
+ "SELECT id, created_at FROM pending_batches WHERE status = 'buffering'",
220
+ );
221
+ selectStmt.setReadBigInts(true);
222
+ const rows = selectStmt.all();
223
+ let sealed = 0;
224
+ const ts = nowTs();
225
+ const updateStmt = this.db.prepare(`
226
+ UPDATE pending_batches
227
+ SET status = 'pending', next_retry_at = ?, updated_at = ?
228
+ WHERE id = ?
229
+ `);
230
+ for (const row of rows) {
231
+ if (force || ts - Number(row.created_at) >= MAX_BUFFER_AGE_SECONDS) {
232
+ updateStmt.run(ts, ts, row.id);
233
+ sealed += 1;
234
+ }
235
+ }
236
+ return sealed;
237
+ }
238
+
239
+ sealBufferIfThresholdHit() {
240
+ const row = this.getBufferingBatch();
241
+ if (!row) return false;
242
+ const shouldSeal =
243
+ Number(row.event_count) >= MAX_EVENTS_PER_BATCH ||
244
+ Number(row.payload_bytes) >= MAX_BATCH_BYTES ||
245
+ nowTs() - Number(row.created_at) >= MAX_BUFFER_AGE_SECONDS;
246
+ if (shouldSeal) {
247
+ const ts = nowTs();
248
+ this.db.prepare(`
249
+ UPDATE pending_batches
250
+ SET status = 'pending', next_retry_at = ?, updated_at = ?
251
+ WHERE id = ?
252
+ `).run(ts, ts, row.id);
253
+ }
254
+ return shouldSeal;
255
+ }
256
+
257
+ iterDuePendingBatches() {
258
+ const stmt = this.db.prepare(`
259
+ SELECT * FROM pending_batches
260
+ WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= ?)
261
+ ORDER BY created_at ASC, id ASC
262
+ `);
263
+ stmt.setReadBigInts(true);
264
+ return stmt.all(nowTs());
265
+ }
266
+
267
+ markAllPendingDue() {
268
+ this.db.prepare(`
269
+ UPDATE pending_batches
270
+ SET next_retry_at = 0, updated_at = ?
271
+ WHERE status = 'pending'
272
+ `).run(nowTs());
273
+ }
274
+
275
+ markBatchUploaded(batchId) {
276
+ this.db.prepare('DELETE FROM pending_batches WHERE id = ?').run(batchId);
277
+ }
278
+
279
+ markBatchFailed(batchId, attemptCount, errorMessage) {
280
+ const nextRetryAt = nowTs() + Math.min(300, 2 ** Math.min(attemptCount, 8));
281
+ this.db.prepare(`
282
+ UPDATE pending_batches
283
+ SET attempt_count = ?, next_retry_at = ?, last_error = ?, updated_at = ?
284
+ WHERE id = ?
285
+ `).run(attemptCount, nextRetryAt, String(errorMessage).slice(0, 1000), nowTs(), batchId);
286
+ }
287
+
288
+ resetBackfillState() {
289
+ this.db.exec('BEGIN');
290
+ try {
291
+ this.db.prepare('DELETE FROM ingestion_files').run();
292
+ this.db.prepare('DELETE FROM pending_batches').run();
293
+ this.db.prepare('DELETE FROM upload_checkpoint').run();
294
+ this.db.exec('COMMIT');
295
+ } catch (error) {
296
+ this.db.exec('ROLLBACK');
297
+ throw error;
298
+ }
299
+ }
300
+
301
+ getQueueStats() {
302
+ const stmt = this.db.prepare(`
303
+ SELECT
304
+ COALESCE(SUM(CASE WHEN status = 'buffering' THEN 1 ELSE 0 END), 0) AS buffering_batch_count,
305
+ COALESCE(SUM(CASE WHEN status = 'pending' AND attempt_count = 0 THEN 1 ELSE 0 END), 0) AS pending_batch_count,
306
+ COALESCE(SUM(CASE WHEN status = 'pending' AND attempt_count > 0 THEN 1 ELSE 0 END), 0) AS retrying_batch_count,
307
+ COALESCE(SUM(session_count), 0) AS queued_sessions,
308
+ COALESCE(SUM(turn_count), 0) AS queued_turns,
309
+ COALESCE(SUM(event_count), 0) AS queued_events,
310
+ MIN(created_at) AS oldest_created_at
311
+ FROM pending_batches
312
+ `);
313
+ stmt.setReadBigInts(true);
314
+ const row = stmt.get() ?? {};
315
+ const oldestCreatedAt =
316
+ row.oldest_created_at == null ? null : Number(row.oldest_created_at);
317
+ return {
318
+ bufferingBatchCount: Number(row.buffering_batch_count ?? 0),
319
+ pendingBatchCount: Number(row.pending_batch_count ?? 0),
320
+ retryingBatchCount: Number(row.retrying_batch_count ?? 0),
321
+ queuedSessions: Number(row.queued_sessions ?? 0),
322
+ queuedTurns: Number(row.queued_turns ?? 0),
323
+ queuedEvents: Number(row.queued_events ?? 0),
324
+ oldestPendingAgeSeconds:
325
+ oldestCreatedAt == null
326
+ ? null
327
+ : Math.max(0, Math.floor(nowTs() - oldestCreatedAt)),
328
+ };
329
+ }
330
+ }
331
+
332
+ function randomBatchKey() {
333
+ return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 14)}`;
334
+ }
package/src/utils.js ADDED
@@ -0,0 +1,53 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { Buffer } from 'node:buffer';
5
+ import { createHash } from 'node:crypto';
6
+
7
+ export function nowTs() {
8
+ return Date.now() / 1000;
9
+ }
10
+
11
+ export function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+
15
+ export function stableStringify(value) {
16
+ return JSON.stringify(sortForJson(value));
17
+ }
18
+
19
+ function sortForJson(value) {
20
+ if (Array.isArray(value)) {
21
+ return value.map(sortForJson);
22
+ }
23
+ if (value && typeof value === 'object') {
24
+ return Object.fromEntries(
25
+ Object.keys(value)
26
+ .sort()
27
+ .map((key) => [key, sortForJson(value[key])]),
28
+ );
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export function isoToEpochMs(value) {
34
+ if (!value) return null;
35
+ const normalized = String(value).replace('Z', '+00:00');
36
+ const timestamp = Date.parse(normalized);
37
+ return Number.isNaN(timestamp) ? null : timestamp;
38
+ }
39
+
40
+ export function deriveRepoName(repositoryUrl, cwd) {
41
+ if (repositoryUrl) {
42
+ let last = repositoryUrl.replace(/\/+$/, '').split('/').pop() ?? '';
43
+ if (last.endsWith('.git')) last = last.slice(0, -4);
44
+ if (last) return last;
45
+ }
46
+ if (cwd) return path.basename(cwd) || null;
47
+ return null;
48
+ }
49
+
50
+ export function computeEventUid(collectorId, relpath, lineNo) {
51
+ const raw = Buffer.from(`${collectorId}|${relpath}|${lineNo}`, 'utf8');
52
+ return createHash('sha1').update(raw).digest('hex');
53
+ }