repoburg 1.3.255 → 1.3.256

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/daemon/dist/index.js +61 -3
  2. package/daemon/dist/staticServing.js +2 -2
  3. package/daemon/dist/tabtape/api/follow.js +174 -0
  4. package/daemon/dist/tabtape/api/http.js +221 -0
  5. package/daemon/dist/tabtape/api/record.js +149 -0
  6. package/daemon/dist/tabtape/api/router.js +519 -0
  7. package/daemon/dist/tabtape/api/router.spec.js +604 -0
  8. package/daemon/dist/tabtape/api/static.js +70 -0
  9. package/daemon/dist/tabtape/api/ws.js +166 -0
  10. package/daemon/dist/tabtape/buffer/buffer.js +347 -0
  11. package/daemon/dist/tabtape/buffer/buffer.spec.js +137 -0
  12. package/daemon/dist/tabtape/config.js +108 -0
  13. package/daemon/dist/tabtape/e2e-server.js +96 -0
  14. package/daemon/dist/tabtape/errors.js +77 -0
  15. package/daemon/dist/tabtape/export/export.js +331 -0
  16. package/daemon/dist/tabtape/export/export.spec.js +262 -0
  17. package/daemon/dist/tabtape/har/gostd.js +395 -0
  18. package/daemon/dist/tabtape/har/gostd.spec.js +139 -0
  19. package/daemon/dist/tabtape/har/har.js +582 -0
  20. package/daemon/dist/tabtape/har/har.spec.js +325 -0
  21. package/daemon/dist/tabtape/index.js +134 -0
  22. package/daemon/dist/tabtape/index.spec.js +104 -0
  23. package/daemon/dist/tabtape/lock.js +67 -0
  24. package/daemon/dist/tabtape/log.js +32 -0
  25. package/daemon/dist/tabtape/mcp/definitions.js +362 -0
  26. package/daemon/dist/tabtape/mcp/gofmt.js +227 -0
  27. package/daemon/dist/tabtape/mcp/params.js +345 -0
  28. package/daemon/dist/tabtape/mcp/params.spec.js +172 -0
  29. package/daemon/dist/tabtape/mcp/recordLink.js +122 -0
  30. package/daemon/dist/tabtape/mcp/server.js +411 -0
  31. package/daemon/dist/tabtape/mcp/server.spec.js +722 -0
  32. package/daemon/dist/tabtape/mcp/tools.js +1085 -0
  33. package/daemon/dist/tabtape/query/format.js +195 -0
  34. package/daemon/dist/tabtape/query/network.js +133 -0
  35. package/daemon/dist/tabtape/query/perf.js +93 -0
  36. package/daemon/dist/tabtape/query/query.spec.js +233 -0
  37. package/daemon/dist/tabtape/query/reader.js +602 -0
  38. package/daemon/dist/tabtape/query/types.js +274 -0
  39. package/daemon/dist/tabtape/redact.js +37 -0
  40. package/daemon/dist/tabtape/repro/lang.js +95 -0
  41. package/daemon/dist/tabtape/repro/repro.js +422 -0
  42. package/daemon/dist/tabtape/repro/repro.spec.js +210 -0
  43. package/daemon/dist/tabtape/sink.js +24 -0
  44. package/daemon/dist/tabtape/store/record.js +99 -0
  45. package/daemon/dist/tabtape/store/segment.js +267 -0
  46. package/daemon/dist/tabtape/store/store.js +846 -0
  47. package/daemon/dist/tabtape/store/store.spec.js +485 -0
  48. package/daemon/dist/tabtape/testing/querytest.js +119 -0
  49. package/daemon/dist/tabtape/testing/tmp.js +33 -0
  50. package/package.json +3 -2
  51. package/tabtape/extension/dist/background.js +8 -0
  52. package/tabtape/extension/dist/background.js.map +7 -0
  53. package/tabtape/extension/dist/bridge.js +3 -0
  54. package/tabtape/extension/dist/bridge.js.map +7 -0
  55. package/tabtape/extension/dist/icons/128.png +0 -0
  56. package/tabtape/extension/dist/icons/16.png +0 -0
  57. package/tabtape/extension/dist/icons/32.png +0 -0
  58. package/tabtape/extension/dist/icons/48.png +0 -0
  59. package/tabtape/extension/dist/manifest.json +28 -0
  60. package/tabtape/extension/dist/popup.css +62 -0
  61. package/tabtape/extension/dist/popup.html +34 -0
  62. package/tabtape/extension/dist/popup.js +4 -0
  63. package/tabtape/extension/dist/popup.js.map +7 -0
  64. package/tabtape/extension/dist/recorder.js +133 -0
  65. package/tabtape/extension/dist/recorder.js.map +1 -0
  66. package/tabtape/extension/dist/state.js +2 -0
  67. package/tabtape/extension/dist/state.js.map +1 -0
  68. package/tabtape/playwright/mcp.mjs +58 -0
  69. package/tabtape/playwright/tabtape.d.mts +37 -0
  70. package/tabtape/playwright/tabtape.mjs +146 -0
  71. package/tabtape/ui/dist/app/assets/index-Canv3Ljk.css +1 -0
  72. package/tabtape/ui/dist/app/assets/index-iWNSSrAE.js +145 -0
  73. package/tabtape/ui/dist/app/index.html +14 -0
  74. package/tabtape/ui/dist/export/viewer.css +2 -0
  75. package/tabtape/ui/dist/export/viewer.js +143 -0
@@ -0,0 +1,846 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Store = exports.COMPRESS_GZIP = exports.COMPRESS_OFF = void 0;
7
+ exports.validID = validID;
8
+ /**
9
+ * Persists recorded browser sessions as JSONL files.
10
+ *
11
+ * Layout on disk:
12
+ *
13
+ * <dir>/sessions/<id>/meta.json session index entry
14
+ * <dir>/sessions/<id>/<seq>.jsonl event segments, one JSON object per line
15
+ * <dir>/sessions/<id>/<seq>.jsonl.gz closed segments, with compress "gzip"
16
+ *
17
+ * Segment numbers come from one global, monotonically increasing sequence.
18
+ * The combined size of all segments on disk is capped: when an append pushes
19
+ * the total over the cap, segments are deleted lowest sequence first — the
20
+ * oldest data across all sessions goes first. The data directory therefore
21
+ * behaves like a ring buffer that never splits a line.
22
+ *
23
+ * A segment is closed when it reaches the segment size, when its session ends
24
+ * or after two minutes without writes; a closed segment is never written
25
+ * again. With compression on, one background worker gzips closed segments.
26
+ *
27
+ * State changes run synchronously (one event loop: no locks needed); reads
28
+ * and compression stream asynchronously. Appends write complete lines in one
29
+ * synchronous call, so a segment's size always ends at a line boundary.
30
+ */
31
+ const fs_1 = __importDefault(require("fs"));
32
+ const path_1 = __importDefault(require("path"));
33
+ const errors_1 = require("../errors");
34
+ const log_1 = require("../log");
35
+ const record_1 = require("./record");
36
+ const segment_1 = require("./segment");
37
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
38
+ /** Whether id is acceptable as a session id (and buffer key). */
39
+ function validID(id) {
40
+ return typeof id === 'string' && ID_PATTERN.test(id);
41
+ }
42
+ const SESSIONS_DIR = 'sessions';
43
+ const META_NAME = 'meta.json';
44
+ /** How long a session's segment stays open without writes. */
45
+ const IDLE_CLOSE_MS = 2 * 60000;
46
+ /** How often the compression worker closes idle segments. */
47
+ const IDLE_CHECK_MS = 30000;
48
+ exports.COMPRESS_OFF = 'off';
49
+ exports.COMPRESS_GZIP = 'gzip';
50
+ function emptyCounts() {
51
+ return { total: 0, rrweb: 0, network: 0, console: 0, events: 0, snapshots: 0, pages: 0, clicks: 0, errors: 0, netErrors: 0 };
52
+ }
53
+ function emptyMeta(id, startedAt = 0) {
54
+ return {
55
+ id,
56
+ startedAt,
57
+ endedAt: 0,
58
+ firstTs: 0,
59
+ lastTs: 0,
60
+ lastWriteAt: 0,
61
+ url: '',
62
+ title: '',
63
+ lastUrl: '',
64
+ userAgent: '',
65
+ source: '',
66
+ userId: '',
67
+ perf: '',
68
+ truncated: false,
69
+ counts: emptyCounts(),
70
+ };
71
+ }
72
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.trunc(v) : 0);
73
+ const str = (v) => (typeof v === 'string' ? v : '');
74
+ /** Reads a meta.json document, ignoring fields of the wrong type. */
75
+ function metaFromJSON(id, v) {
76
+ const m = emptyMeta(id);
77
+ if (typeof v !== 'object' || v === null)
78
+ return m;
79
+ const o = v;
80
+ m.startedAt = num(o.startedAt);
81
+ m.endedAt = num(o.endedAt);
82
+ m.firstTs = num(o.firstTs);
83
+ m.lastTs = num(o.lastTs);
84
+ m.lastWriteAt = num(o.lastWriteAt);
85
+ m.url = str(o.url);
86
+ m.title = str(o.title);
87
+ m.lastUrl = str(o.lastUrl);
88
+ m.userAgent = str(o.userAgent);
89
+ m.source = str(o.source);
90
+ m.userId = str(o.userId);
91
+ m.perf = str(o.perf);
92
+ m.truncated = o.truncated === true;
93
+ if (typeof o.counts === 'object' && o.counts !== null) {
94
+ const c = o.counts;
95
+ for (const key of Object.keys(m.counts))
96
+ m.counts[key] = num(c[key]);
97
+ }
98
+ return m;
99
+ }
100
+ /** The session as the API shows it: empty fields left out. */
101
+ function viewOf(sess) {
102
+ const m = sess.meta;
103
+ const out = { id: m.id, startedAt: m.startedAt };
104
+ if (m.endedAt)
105
+ out.endedAt = m.endedAt;
106
+ if (m.firstTs)
107
+ out.firstTs = m.firstTs;
108
+ if (m.lastTs)
109
+ out.lastTs = m.lastTs;
110
+ if (m.lastWriteAt)
111
+ out.lastWriteAt = m.lastWriteAt;
112
+ if (m.url)
113
+ out.url = m.url;
114
+ if (m.title)
115
+ out.title = m.title;
116
+ if (m.lastUrl)
117
+ out.lastUrl = m.lastUrl;
118
+ if (m.userAgent)
119
+ out.userAgent = m.userAgent;
120
+ if (m.source)
121
+ out.source = m.source;
122
+ if (m.userId)
123
+ out.userId = m.userId;
124
+ if (m.perf)
125
+ out.perf = m.perf;
126
+ if (m.truncated)
127
+ out.truncated = true;
128
+ out.counts = { ...m.counts };
129
+ let bytes = 0;
130
+ let raw = 0;
131
+ for (const seg of sess.segs) {
132
+ bytes += seg.disk;
133
+ raw += seg.size;
134
+ }
135
+ out.bytes = bytes;
136
+ out.rawBytes = raw;
137
+ out.segments = sess.segs.length;
138
+ return out;
139
+ }
140
+ /** Web Vitals ratings in order; unknown ratings rank lowest. */
141
+ function ratingRank(r) {
142
+ switch (r) {
143
+ case 'good':
144
+ return 1;
145
+ case 'needs-improvement':
146
+ return 2;
147
+ case 'poor':
148
+ return 3;
149
+ }
150
+ return 0;
151
+ }
152
+ function apply(m, r) {
153
+ const c = m.counts;
154
+ c.total++;
155
+ if (m.firstTs === 0 || r.ts < m.firstTs)
156
+ m.firstTs = r.ts;
157
+ m.lastTs = Math.max(m.lastTs, r.ts);
158
+ const s = r.sum;
159
+ switch (r.type) {
160
+ case 'rrweb':
161
+ c.rrweb++;
162
+ if (s.rrwebType === record_1.RRWEB_FULL_SNAPSHOT)
163
+ c.snapshots++;
164
+ break;
165
+ case 'network':
166
+ c.network++;
167
+ if (s.status >= 400 || s.err !== '')
168
+ c.netErrors++;
169
+ break;
170
+ case 'console':
171
+ c.console++;
172
+ if (s.level === 'error')
173
+ c.errors++;
174
+ break;
175
+ case 'event':
176
+ c.events++;
177
+ switch (s.name) {
178
+ case 'page_view':
179
+ c.pages++;
180
+ m.url = m.url || s.url;
181
+ m.title = m.title || s.title;
182
+ m.lastUrl = s.url;
183
+ break;
184
+ case 'click':
185
+ c.clicks++;
186
+ break;
187
+ case 'identify':
188
+ m.userId = s.userId || m.userId;
189
+ break;
190
+ }
191
+ break;
192
+ case 'perf':
193
+ if (s.kind === 'vital' && ratingRank(s.rating) > ratingRank(m.perf))
194
+ m.perf = s.rating;
195
+ break;
196
+ }
197
+ }
198
+ /**
199
+ * Renders records as lines, injecting the session id as the first field.
200
+ * ids are validated, so they never need JSON escaping.
201
+ */
202
+ function encode(id, recs) {
203
+ const prefix = `{"sid":"${id}",`;
204
+ let out = '';
205
+ for (const r of recs)
206
+ out += prefix + r.raw.slice(1) + '\n'; // raw is a non-empty object
207
+ return Buffer.from(out, 'utf8');
208
+ }
209
+ function minTS(recs) {
210
+ let m = recs[0].ts;
211
+ for (const r of recs)
212
+ m = Math.min(m, r.ts);
213
+ return m;
214
+ }
215
+ function deferred() {
216
+ let resolve;
217
+ const promise = new Promise((r) => {
218
+ resolve = r;
219
+ });
220
+ return { promise, resolve };
221
+ }
222
+ /** Writes all of buf; on failure the error carries the bytes written before it. */
223
+ function writeAll(fd, buf) {
224
+ let n = 0;
225
+ try {
226
+ while (n < buf.length)
227
+ n += fs_1.default.writeSync(fd, buf, n, buf.length - n);
228
+ return { n, err: null };
229
+ }
230
+ catch (err) {
231
+ return { n, err };
232
+ }
233
+ }
234
+ class Store {
235
+ constructor(opts) {
236
+ this.opts = opts;
237
+ this.closing = false;
238
+ this.wakeFn = null;
239
+ this.loop = null;
240
+ this.lastIdleCheck = Date.now();
241
+ this.sessions = new Map();
242
+ /** Every segment on disk, oldest (lowest seq) first. */
243
+ this.segments = [];
244
+ /** Bytes on disk. */
245
+ this.total = 0;
246
+ /** Uncompressed bytes. */
247
+ this.raw = 0;
248
+ this.nextSeq = 1;
249
+ /** Closed segments waiting for compression. */
250
+ this.queue = [];
251
+ this.root = path_1.default.join(opts.dir, SESSIONS_DIR);
252
+ this.log = opts.logger ?? (0, log_1.consoleLogger)();
253
+ this.now = opts.now ?? Date.now;
254
+ this.compress = opts.compress === exports.COMPRESS_GZIP;
255
+ this.idleCheckMs = opts.idleCheckMs ?? IDLE_CHECK_MS;
256
+ }
257
+ /**
258
+ * Loads the data directory, repairing partial trailing lines and enforcing
259
+ * the size cap, and starts the compression worker.
260
+ */
261
+ static open(options) {
262
+ if (!options.dir)
263
+ throw new Error('store: dir is required');
264
+ if (!(options.maxBytes > 0))
265
+ throw new Error('store: maxBytes must be positive');
266
+ if (!(options.segmentBytes > 0) || options.segmentBytes > options.maxBytes) {
267
+ throw new Error('store: segmentBytes must be positive and no larger than maxBytes');
268
+ }
269
+ let compress = options.compress ?? '';
270
+ if (compress === '')
271
+ compress = exports.COMPRESS_OFF;
272
+ if (compress !== exports.COMPRESS_OFF && compress !== exports.COMPRESS_GZIP) {
273
+ throw new Error(`store: unknown compression "${compress}" (use "${exports.COMPRESS_OFF}" or "${exports.COMPRESS_GZIP}")`);
274
+ }
275
+ const s = new Store({ ...options, compress });
276
+ fs_1.default.mkdirSync(s.root, { recursive: true, mode: 0o755 });
277
+ s.load();
278
+ s.enforceBudget(null);
279
+ if (s.compress) {
280
+ // No segment is open yet: every plain one is closed.
281
+ for (const seg of s.segments)
282
+ s.enqueue(seg);
283
+ s.loop = s.compressLoop();
284
+ }
285
+ return s;
286
+ }
287
+ load() {
288
+ for (const e of fs_1.default.readdirSync(this.root, { withFileTypes: true })) {
289
+ if (!e.isDirectory() || !validID(e.name))
290
+ continue;
291
+ let sess;
292
+ try {
293
+ sess = this.loadSession(e.name);
294
+ }
295
+ catch (err) {
296
+ this.log.warn('skipping unreadable session', { session: e.name, err });
297
+ continue;
298
+ }
299
+ this.sessions.set(sess.meta.id, sess);
300
+ this.segments.push(...sess.segs);
301
+ }
302
+ this.segments.sort((a, b) => a.seq - b.seq);
303
+ for (const seg of this.segments) {
304
+ this.total += seg.disk;
305
+ this.raw += seg.size;
306
+ this.nextSeq = Math.max(this.nextSeq, seg.seq + 1);
307
+ }
308
+ }
309
+ loadSession(id) {
310
+ const dir = path_1.default.join(this.root, id);
311
+ let meta = emptyMeta(id);
312
+ try {
313
+ const text = fs_1.default.readFileSync(path_1.default.join(dir, META_NAME), 'utf8');
314
+ try {
315
+ meta = metaFromJSON(id, JSON.parse(text));
316
+ }
317
+ catch (err) {
318
+ this.log.warn('ignoring corrupt session meta', { session: id, err });
319
+ }
320
+ }
321
+ catch (err) {
322
+ if (!(0, errors_1.isNotExist)(err))
323
+ throw new Error(`read meta: ${(0, errors_1.errorMessage)(err)}`);
324
+ }
325
+ const sess = { meta, dir, segs: this.loadSegments(id, dir), cur: null, fd: null, lastWrite: 0, changed: null };
326
+ if (sess.meta.startedAt === 0) {
327
+ try {
328
+ sess.meta.startedAt = Math.trunc(fs_1.default.statSync(dir).mtimeMs);
329
+ }
330
+ catch {
331
+ // keep 0
332
+ }
333
+ }
334
+ return sess;
335
+ }
336
+ /**
337
+ * Lists a session's segments. It finishes what a crash interrupted:
338
+ * leftover .tmp files are deleted, and when both forms of a segment exist
339
+ * the .gz is complete (it is renamed into place only after it is synced to
340
+ * disk), so the plain file is deleted.
341
+ */
342
+ loadSegments(id, dir) {
343
+ const gz = new Map();
344
+ const plain = new Map();
345
+ for (const e of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
346
+ const parsed = (0, segment_1.parseSegmentName)(e.name);
347
+ if (!parsed || e.isDirectory())
348
+ continue;
349
+ const p = path_1.default.join(dir, e.name);
350
+ switch (parsed.kind) {
351
+ case 'temp':
352
+ fs_1.default.unlinkSync(p);
353
+ break;
354
+ case 'gzip':
355
+ try {
356
+ const { disk, raw } = (0, segment_1.gzipSizes)(p);
357
+ gz.set(parsed.seq, newSegment(parsed.seq, id, p, raw, disk, true));
358
+ }
359
+ catch (err) {
360
+ this.log.warn('ignoring unreadable compressed segment', { path: p, err });
361
+ }
362
+ break;
363
+ case 'plain':
364
+ plain.set(parsed.seq, p);
365
+ break;
366
+ }
367
+ }
368
+ const segs = [...gz.values()];
369
+ for (const [seq, p] of plain) {
370
+ if (gz.has(seq)) {
371
+ fs_1.default.unlinkSync(p);
372
+ continue;
373
+ }
374
+ const size = (0, segment_1.repairTail)(p);
375
+ if (size === 0) {
376
+ fs_1.default.unlinkSync(p);
377
+ continue;
378
+ }
379
+ segs.push(newSegment(seq, id, p, size, size, false));
380
+ }
381
+ return segs.sort((a, b) => a.seq - b.seq);
382
+ }
383
+ /**
384
+ * Creates the session if needed and records what the recorder knows about
385
+ * it. Calling it again for the same id only fills missing fields.
386
+ */
387
+ start(info) {
388
+ if (!validID(info.id))
389
+ throw new errors_1.InvalidIDError();
390
+ const sess = this.sessionFor(info.id, info.startedAt ?? 0);
391
+ const m = sess.meta;
392
+ if (info.startedAt && info.startedAt > 0 && info.startedAt < m.startedAt)
393
+ m.startedAt = info.startedAt;
394
+ m.url = m.url || (info.url ?? '');
395
+ m.title = m.title || (info.title ?? '');
396
+ m.userAgent = m.userAgent || (info.userAgent ?? '');
397
+ m.source = m.source || (info.source ?? '');
398
+ this.saveMeta(sess);
399
+ return viewOf(sess);
400
+ }
401
+ /**
402
+ * Writes records to the session, creating it when it does not exist, then
403
+ * evicts the oldest segments if the size cap is exceeded. Returns the
404
+ * number of bytes written.
405
+ */
406
+ append(id, recs) {
407
+ if (!validID(id))
408
+ throw new errors_1.InvalidIDError();
409
+ if (recs.length === 0)
410
+ return 0;
411
+ const buf = encode(id, recs);
412
+ const sess = this.sessionFor(id, minTS(recs));
413
+ const seg = this.openSegment(sess);
414
+ const { n, err } = writeAll(sess.fd, buf);
415
+ seg.size += n;
416
+ seg.disk += n;
417
+ this.total += n;
418
+ this.raw += n;
419
+ sess.lastWrite = this.now();
420
+ if (err) {
421
+ // A short write leaves a partial line behind. Stop appending to this
422
+ // segment; open() repairs the tail on the next start.
423
+ seg.broken = true;
424
+ this.closeFile(sess);
425
+ this.notify(sess);
426
+ throw new Error(`write segment: ${(0, errors_1.errorMessage)(err)}`);
427
+ }
428
+ for (const r of recs)
429
+ apply(sess.meta, r);
430
+ sess.meta.lastWriteAt = sess.lastWrite;
431
+ this.notify(sess);
432
+ if (seg.size >= this.opts.segmentBytes)
433
+ this.closeFile(sess);
434
+ this.enforceBudget(seg);
435
+ this.closeIdle();
436
+ // seg is never evicted, so sess is still indexed here.
437
+ this.saveMeta(sess);
438
+ return n;
439
+ }
440
+ /** Marks the session as finished. */
441
+ end(id, endedAt) {
442
+ const sess = this.sessions.get(id);
443
+ if (!sess)
444
+ throw new errors_1.NotFoundError();
445
+ sess.meta.endedAt = endedAt > 0 ? endedAt : this.now();
446
+ this.closeFile(sess);
447
+ this.notify(sess);
448
+ this.saveMeta(sess);
449
+ return viewOf(sess);
450
+ }
451
+ /**
452
+ * Resolves at the session's next change: an append, its end, or the ring
453
+ * buffer or a delete removing data. Take it before reading so that no
454
+ * change is missed.
455
+ */
456
+ watch(id) {
457
+ const sess = this.sessions.get(id);
458
+ if (!sess)
459
+ throw new errors_1.NotFoundError();
460
+ if (!sess.changed)
461
+ sess.changed = deferred();
462
+ return sess.changed.promise;
463
+ }
464
+ notify(sess) {
465
+ if (sess.changed) {
466
+ sess.changed.resolve();
467
+ sess.changed = null;
468
+ }
469
+ }
470
+ /** One session. */
471
+ get(id) {
472
+ const sess = this.sessions.get(id);
473
+ if (!sess)
474
+ throw new errors_1.NotFoundError();
475
+ return viewOf(sess);
476
+ }
477
+ /** Whether the session exists. */
478
+ has(id) {
479
+ return this.sessions.has(id);
480
+ }
481
+ /** All sessions, newest first. */
482
+ list() {
483
+ const out = [...this.sessions.values()].map(viewOf);
484
+ return out.sort((a, b) => b.startedAt - a.startedAt || (b.id < a.id ? -1 : b.id > a.id ? 1 : 0));
485
+ }
486
+ /** Removes a session and its files. */
487
+ delete(id) {
488
+ const sess = this.sessions.get(id);
489
+ if (!sess)
490
+ throw new errors_1.NotFoundError();
491
+ this.closeFile(sess);
492
+ for (const seg of sess.segs) {
493
+ seg.dropped = true;
494
+ this.total -= seg.disk;
495
+ this.raw -= seg.size;
496
+ }
497
+ this.segments = this.segments.filter((seg) => seg.session !== id);
498
+ this.sessions.delete(id);
499
+ this.notify(sess);
500
+ try {
501
+ fs_1.default.rmSync(sess.dir, { recursive: true, force: true });
502
+ }
503
+ catch (err) {
504
+ throw new Error(`remove session: ${(0, errors_1.errorMessage)(err)}`);
505
+ }
506
+ }
507
+ /**
508
+ * The session's index entry and its segments, oldest first. Segments can
509
+ * be evicted after the snapshot is taken; readers treat a missing file as
510
+ * empty.
511
+ */
512
+ snapshot(id) {
513
+ const sess = this.sessions.get(id);
514
+ if (!sess)
515
+ throw new errors_1.NotFoundError();
516
+ return {
517
+ session: viewOf(sess),
518
+ segments: sess.segs.map((seg) => ({ seq: seg.seq, path: seg.path, size: seg.size })),
519
+ };
520
+ }
521
+ /**
522
+ * Streams the session's lines, oldest first, to sink. Throws NotFoundError
523
+ * before writing anything if the session does not exist. Segments evicted
524
+ * while streaming are skipped.
525
+ */
526
+ async writeSession(id, sink) {
527
+ // Sizes only cover complete writes, so reading up to them never yields
528
+ // a torn line even while appends continue.
529
+ const { segments } = this.snapshot(id);
530
+ let total = 0;
531
+ for (const p of segments) {
532
+ try {
533
+ for await (const chunk of (0, segment_1.readSegment)(p.path, 0, p.size)) {
534
+ total += chunk.length;
535
+ const pending = sink(chunk);
536
+ if (pending)
537
+ await pending;
538
+ }
539
+ }
540
+ catch (err) {
541
+ if ((0, errors_1.isNotExist)(err))
542
+ continue; // evicted after the snapshot was taken
543
+ throw err;
544
+ }
545
+ }
546
+ return total;
547
+ }
548
+ /** Ring buffer usage. */
549
+ stats() {
550
+ return {
551
+ dir: this.opts.dir,
552
+ totalBytes: this.total,
553
+ rawBytes: this.raw,
554
+ maxBytes: this.opts.maxBytes,
555
+ segmentBytes: this.opts.segmentBytes,
556
+ compress: this.opts.compress,
557
+ sessions: this.sessions.size,
558
+ segments: this.segments.length,
559
+ };
560
+ }
561
+ /**
562
+ * Stops the compression worker (a compression in progress is abandoned and
563
+ * redone on the next start) and closes open segment files.
564
+ */
565
+ async close() {
566
+ if (!this.closing) {
567
+ this.closing = true;
568
+ this.wakeFn?.();
569
+ }
570
+ await this.loop;
571
+ const errors = [];
572
+ for (const sess of this.sessions.values()) {
573
+ if (sess.fd === null)
574
+ continue;
575
+ try {
576
+ fs_1.default.closeSync(sess.fd);
577
+ }
578
+ catch (err) {
579
+ errors.push(`close ${sess.cur?.path}: ${(0, errors_1.errorMessage)(err)}`);
580
+ }
581
+ sess.fd = null;
582
+ sess.cur = null;
583
+ }
584
+ if (errors.length)
585
+ throw new Error(errors.join('; '));
586
+ }
587
+ sessionFor(id, startedAt) {
588
+ const existing = this.sessions.get(id);
589
+ if (existing)
590
+ return existing;
591
+ const dir = path_1.default.join(this.root, id);
592
+ try {
593
+ fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o755 });
594
+ }
595
+ catch (err) {
596
+ throw new Error(`create session dir: ${(0, errors_1.errorMessage)(err)}`);
597
+ }
598
+ const sess = {
599
+ meta: emptyMeta(id, startedAt > 0 ? startedAt : this.now()),
600
+ dir,
601
+ segs: [],
602
+ cur: null,
603
+ fd: null,
604
+ lastWrite: 0,
605
+ changed: null,
606
+ };
607
+ this.sessions.set(id, sess);
608
+ return sess;
609
+ }
610
+ openSegment(sess) {
611
+ if (sess.fd !== null && sess.cur)
612
+ return sess.cur;
613
+ const seq = this.nextSeq;
614
+ const p = path_1.default.join(sess.dir, (0, segment_1.segmentName)(seq));
615
+ let fd;
616
+ try {
617
+ fd = fs_1.default.openSync(p, 'ax', 0o644);
618
+ }
619
+ catch (err) {
620
+ throw new Error(`create segment: ${(0, errors_1.errorMessage)(err)}`);
621
+ }
622
+ this.nextSeq++;
623
+ const seg = newSegment(seq, sess.meta.id, p, 0, 0, false);
624
+ this.segments.push(seg); // seq only grows, order is kept
625
+ sess.segs.push(seg);
626
+ sess.cur = seg;
627
+ sess.fd = fd;
628
+ return seg;
629
+ }
630
+ /** Closes the session's open segment for good and queues it for compression. */
631
+ closeFile(sess) {
632
+ if (sess.fd === null)
633
+ return;
634
+ const seg = sess.cur;
635
+ try {
636
+ fs_1.default.closeSync(sess.fd);
637
+ }
638
+ catch (err) {
639
+ this.log.warn('close segment', { path: seg.path, err });
640
+ seg.broken = true;
641
+ }
642
+ sess.fd = null;
643
+ sess.cur = null;
644
+ this.enqueue(seg);
645
+ }
646
+ /**
647
+ * Bounds the number of open files when sessions are left running without
648
+ * an explicit end (closed tab, crashed browser).
649
+ */
650
+ closeIdle() {
651
+ const cutoff = this.now() - IDLE_CLOSE_MS;
652
+ for (const sess of this.sessions.values()) {
653
+ if (sess.fd !== null && sess.lastWrite < cutoff)
654
+ this.closeFile(sess);
655
+ }
656
+ }
657
+ /** Deletes the oldest segments until the total fits the cap. keep, the segment just written, is never deleted. */
658
+ enforceBudget(keep) {
659
+ while (this.total > this.opts.maxBytes) {
660
+ const i = this.segments.findIndex((seg) => seg !== keep);
661
+ if (i < 0)
662
+ return;
663
+ this.evict(i);
664
+ }
665
+ }
666
+ evict(i) {
667
+ const seg = this.segments[i];
668
+ this.segments.splice(i, 1);
669
+ this.total -= seg.disk;
670
+ this.raw -= seg.size;
671
+ seg.dropped = true;
672
+ const sess = this.sessions.get(seg.session);
673
+ if (sess.cur === seg)
674
+ this.closeFile(sess);
675
+ try {
676
+ fs_1.default.unlinkSync(seg.path);
677
+ }
678
+ catch (err) {
679
+ if (!(0, errors_1.isNotExist)(err))
680
+ this.log.warn('evict segment', { path: seg.path, err });
681
+ }
682
+ sess.segs = sess.segs.filter((x) => x !== seg);
683
+ this.log.debug('ring buffer evicted segment', { session: seg.session, seq: seg.seq, bytes: seg.size });
684
+ this.notify(sess);
685
+ if (sess.segs.length === 0) {
686
+ this.sessions.delete(seg.session);
687
+ try {
688
+ fs_1.default.rmSync(sess.dir, { recursive: true, force: true });
689
+ }
690
+ catch (err) {
691
+ this.log.warn('remove evicted session', { session: seg.session, err });
692
+ }
693
+ return;
694
+ }
695
+ sess.meta.truncated = true;
696
+ try {
697
+ this.saveMeta(sess);
698
+ }
699
+ catch (err) {
700
+ this.log.warn('save session meta', { session: seg.session, err });
701
+ }
702
+ }
703
+ saveMeta(sess) {
704
+ const tmp = path_1.default.join(sess.dir, META_NAME + '.tmp');
705
+ try {
706
+ fs_1.default.writeFileSync(tmp, JSON.stringify(viewOf(sess)), { mode: 0o644 });
707
+ }
708
+ catch (err) {
709
+ throw new Error(`write meta: ${(0, errors_1.errorMessage)(err)}`);
710
+ }
711
+ try {
712
+ fs_1.default.renameSync(tmp, path_1.default.join(sess.dir, META_NAME));
713
+ }
714
+ catch (err) {
715
+ throw new Error(`commit meta: ${(0, errors_1.errorMessage)(err)}`);
716
+ }
717
+ }
718
+ // ---------- compression ----------
719
+ /**
720
+ * The single compression worker. It also closes idle segments, so a
721
+ * session that stops writing is compressed even when no other session
722
+ * writes. It runs until close().
723
+ */
724
+ async compressLoop() {
725
+ while (!this.closing) {
726
+ const seg = this.nextJob();
727
+ if (seg) {
728
+ await this.compressSegment(seg);
729
+ continue;
730
+ }
731
+ await this.sleep(this.idleCheckMs);
732
+ if (this.closing)
733
+ return;
734
+ if (Date.now() - this.lastIdleCheck >= this.idleCheckMs) {
735
+ this.lastIdleCheck = Date.now();
736
+ this.closeIdle();
737
+ }
738
+ }
739
+ }
740
+ /** Waits for a wake-up (a queued segment, close) or ms. */
741
+ sleep(ms) {
742
+ return new Promise((resolve) => {
743
+ const timer = setTimeout(() => {
744
+ this.wakeFn = null;
745
+ resolve();
746
+ }, ms);
747
+ timer.unref?.();
748
+ this.wakeFn = () => {
749
+ clearTimeout(timer);
750
+ this.wakeFn = null;
751
+ resolve();
752
+ };
753
+ });
754
+ }
755
+ /** Queues a closed segment for compression. */
756
+ enqueue(seg) {
757
+ if (!this.compress || seg.gz || seg.queued || seg.broken || seg.dropped)
758
+ return;
759
+ seg.queued = true;
760
+ this.queue.push(seg);
761
+ this.wakeFn?.();
762
+ }
763
+ nextJob() {
764
+ if (this.closing)
765
+ return null;
766
+ while (this.queue.length > 0) {
767
+ const seg = this.queue.shift();
768
+ if (seg.dropped) {
769
+ seg.queued = false;
770
+ continue;
771
+ }
772
+ return seg; // stays queued until compressSegment is done
773
+ }
774
+ return null;
775
+ }
776
+ /**
777
+ * Replaces a closed plain segment with a gzip copy: write
778
+ * <seq>.jsonl.gz.tmp, sync it, rename it to <seq>.jsonl.gz, then delete the
779
+ * plain file. Appends are not blocked. A crash at any point leaves either
780
+ * the plain file, or both files (and the next open deletes the plain one).
781
+ */
782
+ async compressSegment(seg) {
783
+ const src = seg.path;
784
+ const size = seg.size;
785
+ const dst = (src.endsWith(segment_1.SEG_EXT) ? src.slice(0, -segment_1.SEG_EXT.length) : src) + segment_1.SEG_EXT + segment_1.GZ_EXT;
786
+ const tmp = dst + segment_1.TMP_EXT;
787
+ const stop = () => seg.dropped || this.closing;
788
+ let disk = 0;
789
+ let err = null;
790
+ try {
791
+ disk = await (0, segment_1.compressFile)(src, tmp, size, stop);
792
+ }
793
+ catch (e) {
794
+ err = e;
795
+ }
796
+ seg.queued = false;
797
+ if (!err && stop())
798
+ err = new segment_1.AbortedError();
799
+ if (!err) {
800
+ try {
801
+ fs_1.default.renameSync(tmp, dst);
802
+ }
803
+ catch (e) {
804
+ err = e;
805
+ }
806
+ }
807
+ if (err) {
808
+ // A deleted segment or a closing store is not a failure.
809
+ const failed = !(err instanceof segment_1.AbortedError) && !(0, errors_1.isNotExist)(err);
810
+ if (failed)
811
+ seg.broken = true; // keep the plain file; do not retry
812
+ (0, segment_1.removeQuiet)(tmp);
813
+ if (failed)
814
+ this.log.warn('compress segment', { path: src, err });
815
+ return;
816
+ }
817
+ this.total += disk - seg.disk;
818
+ seg.path = dst;
819
+ seg.disk = disk;
820
+ seg.gz = true;
821
+ const sess = this.sessions.get(seg.session);
822
+ if (sess) {
823
+ try {
824
+ this.saveMeta(sess);
825
+ }
826
+ catch (e) {
827
+ this.log.warn('save session meta', { session: seg.session, err: e });
828
+ }
829
+ }
830
+ // Readers that opened the plain file keep reading it; new readers find
831
+ // the .gz (see readSegment).
832
+ (0, segment_1.syncDir)(path_1.default.dirname(dst));
833
+ try {
834
+ fs_1.default.unlinkSync(src);
835
+ }
836
+ catch (e) {
837
+ if (!(0, errors_1.isNotExist)(e))
838
+ this.log.warn("remove compressed segment's plain copy", { path: src, err: e });
839
+ }
840
+ this.log.debug('compressed segment', { path: dst, bytes: size, disk });
841
+ }
842
+ }
843
+ exports.Store = Store;
844
+ function newSegment(seq, session, p, size, disk, gz) {
845
+ return { seq, session, path: p, size, disk, gz, broken: false, queued: false, dropped: false };
846
+ }