opencode-sessions-explorer 0.1.2 → 0.1.3

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.
@@ -173,8 +173,8 @@ function decodeModel(modelStr) {
173
173
  }
174
174
 
175
175
  // src/lib/export.ts
176
- import { mkdirSync, existsSync as existsSync2, renameSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "fs";
177
- import { join as join2 } from "path";
176
+ import { mkdirSync, existsSync as existsSync5, renameSync as renameSync2, writeFileSync as writeFileSync3, unlinkSync as unlinkSync3, readdirSync as readdirSync2 } from "fs";
177
+ import { join as join5 } from "path";
178
178
  import { homedir as homedir2 } from "os";
179
179
 
180
180
  // src/lib/channel.ts
@@ -200,55 +200,511 @@ function compactPath(path, baseDir) {
200
200
  return { path, rel_path: rel || "." };
201
201
  }
202
202
 
203
+ // src/lib/export-constants.ts
204
+ var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
205
+
206
+ // src/lib/export-lock.ts
207
+ import { closeSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
208
+ import { hostname } from "os";
209
+ import { join as join2 } from "path";
210
+ import { randomUUID } from "crypto";
211
+ var LOCK_FILE = ".export.lock";
212
+ var DEFAULT_STALE_MS = 2 * 60 * 1000;
213
+ var HEARTBEAT_MS = 15000;
214
+ function acquireExportLock(root, staleMs = DEFAULT_STALE_MS) {
215
+ const path = join2(root, LOCK_FILE);
216
+ const first = tryCreateLock(path);
217
+ if (first)
218
+ return first;
219
+ const stale = staleCandidate(path, staleMs);
220
+ if (!stale)
221
+ return null;
222
+ if (!removeStaleLock(path, stale, staleMs))
223
+ return tryCreateLock(path);
224
+ return tryCreateLock(path);
225
+ }
226
+ function tryCreateLock(path) {
227
+ let fd = null;
228
+ try {
229
+ const token = randomUUID();
230
+ const now = Date.now();
231
+ const record = { token, pid: process.pid, hostname: hostname(), created_at: now, updated_at: now };
232
+ fd = openSync(path, "wx");
233
+ writeFileSync(fd, JSON.stringify(record));
234
+ closeSync(fd);
235
+ fd = null;
236
+ let lastHeartbeat = now;
237
+ return {
238
+ token,
239
+ release: () => releaseLock(path, token),
240
+ heartbeat: () => {
241
+ const current = Date.now();
242
+ if (current - lastHeartbeat < HEARTBEAT_MS)
243
+ return;
244
+ lastHeartbeat = current;
245
+ heartbeatLock(path, token, current);
246
+ }
247
+ };
248
+ } catch {
249
+ if (fd != null)
250
+ try {
251
+ closeSync(fd);
252
+ } catch {}
253
+ return null;
254
+ }
255
+ }
256
+ function staleCandidate(path, staleMs) {
257
+ try {
258
+ const stat = statSync(path);
259
+ const now = Date.now();
260
+ if (now - stat.mtimeMs <= staleMs)
261
+ return null;
262
+ const parsed = readLockRecord(path);
263
+ if (!parsed)
264
+ return { token: null, updatedAt: null, mtimeMs: stat.mtimeMs };
265
+ if (now - parsed.updated_at <= staleMs)
266
+ return null;
267
+ if (isLiveLocalOwner(parsed))
268
+ return null;
269
+ return { token: parsed.token, updatedAt: parsed.updated_at, mtimeMs: stat.mtimeMs };
270
+ } catch {
271
+ return { token: null, updatedAt: null, mtimeMs: 0 };
272
+ }
273
+ }
274
+ function removeStaleLock(path, stale, staleMs) {
275
+ try {
276
+ const stat = statSync(path);
277
+ if (stat.mtimeMs !== stale.mtimeMs)
278
+ return false;
279
+ if (stale.token) {
280
+ const current = readLockRecord(path);
281
+ if (current?.token !== stale.token)
282
+ return false;
283
+ if (current.updated_at !== stale.updatedAt)
284
+ return false;
285
+ const now = Date.now();
286
+ if (now - current.updated_at <= staleMs)
287
+ return false;
288
+ if (now - stat.mtimeMs <= staleMs)
289
+ return false;
290
+ if (isLiveLocalOwner(current))
291
+ return false;
292
+ } else {
293
+ if (Date.now() - stat.mtimeMs <= staleMs)
294
+ return false;
295
+ }
296
+ unlinkSync(path);
297
+ return true;
298
+ } catch {
299
+ return false;
300
+ }
301
+ }
302
+ function releaseLock(path, token) {
303
+ try {
304
+ const current = readLockRecord(path);
305
+ if (current?.token === token)
306
+ unlinkSync(path);
307
+ } catch {}
308
+ }
309
+ function heartbeatLock(path, token, now) {
310
+ try {
311
+ const current = readLockRecord(path);
312
+ if (current?.token !== token)
313
+ return;
314
+ writeFileSync(path, JSON.stringify({ ...current, updated_at: now }));
315
+ } catch {}
316
+ }
317
+ function readLockRecord(path) {
318
+ try {
319
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
320
+ if (typeof parsed.token !== "string")
321
+ return null;
322
+ if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
323
+ return null;
324
+ if (typeof parsed.hostname !== "string")
325
+ return null;
326
+ if (typeof parsed.created_at !== "number" || !Number.isFinite(parsed.created_at))
327
+ return null;
328
+ const updated = typeof parsed.updated_at === "number" && Number.isFinite(parsed.updated_at) ? parsed.updated_at : parsed.created_at;
329
+ return { token: parsed.token, pid: parsed.pid, hostname: parsed.hostname, created_at: parsed.created_at, updated_at: updated };
330
+ } catch {
331
+ return null;
332
+ }
333
+ }
334
+ function isLiveLocalOwner(record) {
335
+ if (record.hostname !== hostname())
336
+ return false;
337
+ if (!Number.isSafeInteger(record.pid) || record.pid <= 0)
338
+ return false;
339
+ try {
340
+ process.kill(record.pid, 0);
341
+ return true;
342
+ } catch (error) {
343
+ return error?.code === "EPERM";
344
+ }
345
+ }
346
+
347
+ // src/lib/export-tombstones.ts
348
+ import { existsSync as existsSync2, readdirSync, rmSync, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
349
+ import { join as join3 } from "path";
350
+ var PART_FILE_RE = /^(?:\d{5}-)?(prt_[A-Za-z0-9_-]+)\.txt$/;
351
+ function reconcileTombstones(root, heartbeat = () => {}) {
352
+ const progress = {
353
+ scanned_sessions: 0,
354
+ removed_sessions: 0,
355
+ removed_parts: 0,
356
+ removed_channel_sessions: 0
357
+ };
358
+ const bySession = join3(root, "by-session");
359
+ const sessions = loadSessionIds();
360
+ if (existsSync2(bySession)) {
361
+ for (const entry of safeReadDir(bySession)) {
362
+ const dir = join3(bySession, entry);
363
+ if (!isDirectory(dir))
364
+ continue;
365
+ if (!sessions.has(entry)) {
366
+ rmSync(dir, { recursive: true, force: true });
367
+ removeChannelSessionDirs(root, entry);
368
+ progress.removed_sessions++;
369
+ heartbeat();
370
+ continue;
371
+ }
372
+ progress.scanned_sessions++;
373
+ progress.removed_parts += removeOrphanPartFiles(root, entry, dir, heartbeat);
374
+ heartbeat();
375
+ }
376
+ }
377
+ progress.removed_channel_sessions += removeOrphanChannelSessions(root, sessions, heartbeat);
378
+ return progress;
379
+ }
380
+ function removeOrphanPartFiles(root, sessionId, dir, heartbeat) {
381
+ const livePartIds = loadSearchablePartIds(sessionId);
382
+ let removed = 0;
383
+ for (const file of safeReadDir(dir)) {
384
+ heartbeat();
385
+ const partId = partIdFromFile(file);
386
+ if (!partId || livePartIds.has(partId))
387
+ continue;
388
+ try {
389
+ unlinkSync2(join3(dir, file));
390
+ removeChannelPartFiles(root, sessionId, partId);
391
+ removed++;
392
+ } catch {}
393
+ }
394
+ return removed;
395
+ }
396
+ function removeOrphanChannelSessions(root, sessions, heartbeat) {
397
+ let removed = 0;
398
+ for (const channel of CHANNELS) {
399
+ const base = join3(root, "by-channel", channel, "by-session");
400
+ if (!existsSync2(base))
401
+ continue;
402
+ for (const sessionId of safeReadDir(base)) {
403
+ heartbeat();
404
+ const dir = join3(base, sessionId);
405
+ if (!isDirectory(dir) || sessions.has(sessionId))
406
+ continue;
407
+ rmSync(dir, { recursive: true, force: true });
408
+ removed++;
409
+ }
410
+ }
411
+ return removed;
412
+ }
413
+ function removeChannelSessionDirs(root, sessionId) {
414
+ for (const channel of CHANNELS) {
415
+ rmSync(channelDir(root, channel, sessionId), { recursive: true, force: true });
416
+ }
417
+ }
418
+ function removeChannelPartFiles(root, sessionId, partId) {
419
+ for (const channel of CHANNELS) {
420
+ const dir = channelDir(root, channel, sessionId);
421
+ if (!existsSync2(dir))
422
+ continue;
423
+ for (const file of safeReadDir(dir)) {
424
+ if (file === `${partId}.txt` || file.endsWith(`-${partId}.txt`)) {
425
+ try {
426
+ unlinkSync2(join3(dir, file));
427
+ } catch {}
428
+ }
429
+ }
430
+ }
431
+ }
432
+ function channelDir(root, channel, sessionId) {
433
+ return join3(root, "by-channel", channel, "by-session", sessionId);
434
+ }
435
+ function loadSessionIds() {
436
+ const rows = stmt(`SELECT id FROM session`).all();
437
+ return new Set(rows.map((row) => row.id));
438
+ }
439
+ function loadSearchablePartIds(sessionId) {
440
+ const placeholders = SEARCHABLE_TYPES.map(() => "?").join(",");
441
+ const rows = stmt(`
442
+ SELECT id
443
+ FROM part
444
+ WHERE session_id = ?
445
+ AND json_extract(data,'$.type') IN (${placeholders})
446
+ ORDER BY id ASC`).all(sessionId, ...SEARCHABLE_TYPES);
447
+ return new Set(rows.map((row) => row.id));
448
+ }
449
+ function safeReadDir(dir) {
450
+ try {
451
+ return readdirSync(dir);
452
+ } catch {
453
+ return [];
454
+ }
455
+ }
456
+ function isDirectory(path) {
457
+ try {
458
+ return statSync2(path).isDirectory();
459
+ } catch {
460
+ return false;
461
+ }
462
+ }
463
+ function partIdFromFile(file) {
464
+ const match = PART_FILE_RE.exec(file);
465
+ return match ? match[1] : null;
466
+ }
467
+
468
+ // src/lib/export-background.ts
469
+ import { existsSync as existsSync3 } from "fs";
470
+ import { fileURLToPath } from "url";
471
+ var DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
472
+ var inFlight = false;
473
+ var lastStartedAt = 0;
474
+ function scheduleBackgroundReconcile(opts) {
475
+ const now = Date.now();
476
+ const minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
477
+ if (inFlight)
478
+ return { scheduled: false, reason: "already_running" };
479
+ if (now - lastStartedAt < minIntervalMs)
480
+ return { scheduled: false, reason: "throttled" };
481
+ const url = resolveWorkerUrl();
482
+ try {
483
+ const worker = new Worker(url, { type: "module" });
484
+ inFlight = true;
485
+ lastStartedAt = now;
486
+ const cleanup = () => {
487
+ inFlight = false;
488
+ worker.terminate();
489
+ };
490
+ worker.addEventListener("message", cleanup, { once: true });
491
+ worker.addEventListener("error", cleanup, { once: true });
492
+ const maybeUnref = worker;
493
+ maybeUnref.unref?.();
494
+ const request = { root: opts.root, batchSize: opts.batchSize ?? 2000 };
495
+ worker.postMessage(request);
496
+ return { scheduled: true };
497
+ } catch {
498
+ inFlight = false;
499
+ return { scheduled: false, reason: "worker_unavailable" };
500
+ }
501
+ }
502
+ function resolveWorkerUrl() {
503
+ const js = new URL("./export-reconcile-worker.js", import.meta.url);
504
+ if (existsSync3(fileURLToPath(js)))
505
+ return js;
506
+ const ts = new URL("./export-reconcile-worker.ts", import.meta.url);
507
+ if (existsSync3(fileURLToPath(ts)))
508
+ return ts;
509
+ const bundled = new URL("./lib/export-reconcile-worker.js", import.meta.url);
510
+ if (existsSync3(fileURLToPath(bundled)))
511
+ return bundled;
512
+ return js;
513
+ }
514
+
515
+ // src/lib/export-state.ts
516
+ import { existsSync as existsSync4, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "fs";
517
+ import { join as join4 } from "path";
518
+ var CURSOR_SCHEMA = "v3";
519
+ var LAST_SYNC_FILE = ".last_sync";
520
+ function freshSyncState(migratedFrom, legacyCursor = null) {
521
+ return {
522
+ schema: CURSOR_SCHEMA,
523
+ insert_cursor: { id: "" },
524
+ session_cursor: null,
525
+ session_dirty_hints: {},
526
+ reconcile_watermark: null,
527
+ failed_parts: {},
528
+ dead_letters: {},
529
+ last_reconcile_at: null,
530
+ legacy_cursor: legacyCursor,
531
+ migrated_from: migratedFrom
532
+ };
533
+ }
534
+ function getSyncState(root) {
535
+ const p = join4(root, LAST_SYNC_FILE);
536
+ if (!existsSync4(p))
537
+ return freshSyncState();
538
+ try {
539
+ return parseSyncState(readFileSync2(p, "utf8"));
540
+ } catch {
541
+ return freshSyncState("unreadable");
542
+ }
543
+ }
544
+ function setSyncState(state, root) {
545
+ const p = join4(root, LAST_SYNC_FILE);
546
+ const tmp = p + ".tmp";
547
+ writeFileSync2(tmp, `${CURSOR_SCHEMA} ${JSON.stringify(normalizeSyncState(state))}`);
548
+ renameSync(tmp, p);
549
+ }
550
+ function getLastSync(root) {
551
+ const p = join4(root, LAST_SYNC_FILE);
552
+ if (!existsSync4(p))
553
+ return null;
554
+ const state = getSyncState(root);
555
+ if (state.legacy_cursor)
556
+ return state.legacy_cursor;
557
+ if (!state.insert_cursor.id)
558
+ return null;
559
+ return { ts: 0, id: state.insert_cursor.id };
560
+ }
561
+ function parseSyncState(rawInput) {
562
+ const raw = rawInput.trim();
563
+ if (!raw)
564
+ return freshSyncState("empty");
565
+ if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
566
+ const parsed = JSON.parse(raw.slice(CURSOR_SCHEMA.length + 1));
567
+ return normalizeSyncState(parsed);
568
+ }
569
+ if (raw.startsWith("{")) {
570
+ return normalizeSyncState(JSON.parse(raw));
571
+ }
572
+ if (raw.startsWith("v2 ")) {
573
+ return freshSyncState("v2", parseLegacyCursor(raw.slice(3)));
574
+ }
575
+ const legacy = parseLegacyCursor(raw);
576
+ return freshSyncState(legacy ? "v1" : "unknown", legacy);
577
+ }
578
+ function normalizeSyncState(input) {
579
+ if (!isRecord(input))
580
+ return freshSyncState("invalid");
581
+ const state = freshSyncState(asString(input.migrated_from) ?? undefined, cursorOrNull(input.legacy_cursor));
582
+ const insert = isRecord(input.insert_cursor) ? input.insert_cursor : null;
583
+ state.insert_cursor.id = asString(insert?.id) ?? "";
584
+ state.session_cursor = cursorOrNull(input.session_cursor);
585
+ state.session_dirty_hints = dirtyHints(input.session_dirty_hints);
586
+ state.reconcile_watermark = reconcileWatermark(input.reconcile_watermark);
587
+ state.failed_parts = failedParts(input.failed_parts);
588
+ state.dead_letters = failedParts(input.dead_letters);
589
+ state.last_reconcile_at = finiteOrNull(input.last_reconcile_at);
590
+ return state;
591
+ }
592
+ function parseLegacyCursor(raw) {
593
+ const idx = raw.indexOf(":");
594
+ if (idx <= 0)
595
+ return null;
596
+ const ts = Number(raw.slice(0, idx));
597
+ const id = raw.slice(idx + 1);
598
+ if (!Number.isFinite(ts) || !id)
599
+ return null;
600
+ return { ts, id };
601
+ }
602
+ function cursorOrNull(value) {
603
+ if (!isRecord(value))
604
+ return null;
605
+ const ts = finiteOrNull(value.ts);
606
+ const id = asString(value.id);
607
+ if (ts == null || !id)
608
+ return null;
609
+ return { ts, id };
610
+ }
611
+ function dirtyHints(value) {
612
+ if (!isRecord(value))
613
+ return {};
614
+ const out = {};
615
+ for (const [id, raw] of Object.entries(value)) {
616
+ if (!id)
617
+ continue;
618
+ if (typeof raw === "number" && Number.isFinite(raw)) {
619
+ out[id] = { time_updated: raw, part_cursor: null };
620
+ continue;
621
+ }
622
+ if (!isRecord(raw))
623
+ continue;
624
+ const timeUpdated = finiteOrNull(raw.time_updated);
625
+ if (timeUpdated == null)
626
+ continue;
627
+ out[id] = { time_updated: timeUpdated, part_cursor: asString(raw.part_cursor) };
628
+ }
629
+ return out;
630
+ }
631
+ function reconcileWatermark(value) {
632
+ if (!isRecord(value))
633
+ return null;
634
+ const at = finiteOrNull(value.at);
635
+ if (at == null)
636
+ return null;
637
+ return {
638
+ part_id: asString(value.part_id),
639
+ session_id: asString(value.session_id),
640
+ at
641
+ };
642
+ }
643
+ function failedParts(value) {
644
+ if (!isRecord(value))
645
+ return {};
646
+ const out = {};
647
+ for (const [id, raw] of Object.entries(value)) {
648
+ if (!id || !isRecord(raw))
649
+ continue;
650
+ const attempts = finiteOrNull(raw.attempts);
651
+ const firstFailedAt = finiteOrNull(raw.first_failed_at);
652
+ const lastFailedAt = finiteOrNull(raw.last_failed_at);
653
+ if (attempts == null || firstFailedAt == null || lastFailedAt == null)
654
+ continue;
655
+ out[id] = {
656
+ id,
657
+ attempts,
658
+ first_failed_at: firstFailedAt,
659
+ last_failed_at: lastFailedAt,
660
+ last_error: asString(raw.last_error) ?? "unknown export failure"
661
+ };
662
+ }
663
+ return out;
664
+ }
665
+ function finiteOrNull(value) {
666
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
667
+ }
668
+ function asString(value) {
669
+ return typeof value === "string" && value.length > 0 ? value : null;
670
+ }
671
+ function isRecord(value) {
672
+ return typeof value === "object" && value !== null && !Array.isArray(value);
673
+ }
674
+
203
675
  // src/lib/export.ts
204
- var DEFAULT_EXPORT_ROOT = join2(homedir2(), ".local/share/opencode-sessions-explorer");
676
+ var DEFAULT_EXPORT_ROOT = join5(homedir2(), ".local/share/opencode-sessions-explorer");
205
677
  var BODY_CAP_BYTES = 256 * 1024;
206
678
  var SAFETY_PART_CAP_BYTES = 50 * 1024 * 1024;
207
679
  var CHANNEL_COMPLETE_MARKER = ".channels_v1_complete";
208
- var SEARCHABLE_TYPES = ["text", "reasoning", "tool", "file", "patch", "subtask"];
680
+ var INSERT_REWIND_MS = 3 * 60 * 1000;
681
+ var INSERT_REWIND_MAX_ROWS = 512;
682
+ var MAX_FAILED_ATTEMPTS = 5;
209
683
  var PART_CHANNELS = CHANNELS.filter((c) => c !== "session-summary" && c !== "raw");
210
684
  function exportRoot() {
211
685
  return process.env.OPENCODE_SESSIONS_EXPLORER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT;
212
686
  }
687
+ function getSyncState2(root = exportRoot()) {
688
+ return getSyncState(root);
689
+ }
690
+ function setSyncState2(state, root = exportRoot()) {
691
+ setSyncState(state, root);
692
+ }
693
+ function getLastSync2(root = exportRoot()) {
694
+ return getLastSync(root);
695
+ }
213
696
  function ensureRoot(root = exportRoot()) {
214
- mkdirSync(join2(root, "by-session"), { recursive: true });
697
+ mkdirSync(join5(root, "by-session"), { recursive: true });
215
698
  return root;
216
699
  }
217
700
  function channelExportComplete(root = exportRoot()) {
218
- return existsSync2(join2(root, CHANNEL_COMPLETE_MARKER));
701
+ return existsSync5(join5(root, CHANNEL_COMPLETE_MARKER));
219
702
  }
220
703
  function markChannelExportComplete(root = exportRoot()) {
221
- const p = join2(root, CHANNEL_COMPLETE_MARKER);
704
+ const p = join5(root, CHANNEL_COMPLETE_MARKER);
222
705
  const tmp = p + ".tmp";
223
- writeFileSync(tmp, String(Date.now()));
224
- renameSync(tmp, p);
225
- }
226
- var CURSOR_SCHEMA = "v2";
227
- function getLastSync(root = exportRoot()) {
228
- const p = join2(root, ".last_sync");
229
- if (!existsSync2(p))
230
- return null;
231
- try {
232
- const raw = readFileSync(p, "utf8").trim();
233
- if (!raw)
234
- return null;
235
- if (raw.startsWith(`${CURSOR_SCHEMA} `)) {
236
- const [tsStr, id] = raw.slice(CURSOR_SCHEMA.length + 1).split(":");
237
- const ts = Number(tsStr);
238
- if (!Number.isFinite(ts) || !id)
239
- return null;
240
- return { ts, id };
241
- }
242
- return null;
243
- } catch {
244
- return null;
245
- }
246
- }
247
- function setLastSync(c, root = exportRoot()) {
248
- const p = join2(root, ".last_sync");
249
- const tmp = p + ".tmp";
250
- writeFileSync(tmp, `${CURSOR_SCHEMA} ${c.ts}:${c.id}`);
251
- renameSync(tmp, p);
706
+ writeFileSync3(tmp, String(Date.now()));
707
+ renameSync2(tmp, p);
252
708
  }
253
709
  var sessionCache = new Map;
254
710
  function getSession(id) {
@@ -491,46 +947,46 @@ function writeMeta(s, dir) {
491
947
  time_updated: s.time_updated,
492
948
  archived: s.time_archived != null
493
949
  };
494
- const p = join2(dir, "meta.json");
950
+ const p = join5(dir, "meta.json");
495
951
  const tmp = p + ".tmp";
496
- writeFileSync(tmp, JSON.stringify(meta, null, 2));
497
- renameSync(tmp, p);
952
+ writeFileSync3(tmp, JSON.stringify(meta, null, 2));
953
+ renameSync2(tmp, p);
498
954
  }
499
955
  function writePartFile(dir, filename, content) {
500
- const p = join2(dir, filename);
501
- const tmp = join2(dir, "." + filename + ".tmp");
502
- writeFileSync(tmp, content);
503
- renameSync(tmp, p);
956
+ const p = join5(dir, filename);
957
+ const tmp = join5(dir, "." + filename + ".tmp");
958
+ writeFileSync3(tmp, content);
959
+ renameSync2(tmp, p);
504
960
  const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(filename);
505
961
  if (!m)
506
962
  return;
507
963
  const myPartId = m[2];
508
964
  try {
509
- for (const f of readdirSync(dir)) {
965
+ for (const f of readdirSync2(dir)) {
510
966
  if (f === filename || !f.endsWith(".txt") || f.startsWith("."))
511
967
  continue;
512
968
  const fm = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
513
969
  if (fm && fm[2] === myPartId) {
514
970
  try {
515
- unlinkSync(join2(dir, f));
971
+ unlinkSync3(join5(dir, f));
516
972
  } catch {}
517
973
  }
518
974
  }
519
975
  } catch {}
520
976
  }
521
- function channelDir(root, channel, sessionId) {
522
- return join2(root, "by-channel", channel, "by-session", sessionId);
977
+ function channelDir2(root, channel, sessionId) {
978
+ return join5(root, "by-channel", channel, "by-session", sessionId);
523
979
  }
524
980
  function deleteChannelPartFiles(root, sessionId, partId) {
525
981
  for (const ch of PART_CHANNELS) {
526
- const dir = channelDir(root, ch, sessionId);
527
- if (!existsSync2(dir))
982
+ const dir = channelDir2(root, ch, sessionId);
983
+ if (!existsSync5(dir))
528
984
  continue;
529
985
  try {
530
- for (const f of readdirSync(dir)) {
986
+ for (const f of readdirSync2(dir)) {
531
987
  if (f === `${partId}.txt` || f.endsWith(`-${partId}.txt`)) {
532
988
  try {
533
- unlinkSync(join2(dir, f));
989
+ unlinkSync3(join5(dir, f));
534
990
  } catch {}
535
991
  }
536
992
  }
@@ -540,20 +996,20 @@ function deleteChannelPartFiles(root, sessionId, partId) {
540
996
  function writeChannelFiles(root, sessionId, filename, partId, docs) {
541
997
  deleteChannelPartFiles(root, sessionId, partId);
542
998
  for (const doc of docs) {
543
- const dir = channelDir(root, doc.channel, sessionId);
544
- if (!existsSync2(dir))
999
+ const dir = channelDir2(root, doc.channel, sessionId);
1000
+ if (!existsSync5(dir))
545
1001
  mkdirSync(dir, { recursive: true });
546
1002
  writePartFile(dir, filename, doc.content);
547
1003
  }
548
1004
  }
549
1005
  function writeSessionSummaryChannel(s, dirRoot = exportRoot()) {
550
- const dir = channelDir(dirRoot, "session-summary", s.id);
551
- if (!existsSync2(dir))
1006
+ const dir = channelDir2(dirRoot, "session-summary", s.id);
1007
+ if (!existsSync5(dir))
552
1008
  mkdirSync(dir, { recursive: true });
553
- const p = join2(dir, "summary.txt");
1009
+ const p = join5(dir, "summary.txt");
554
1010
  const tmp = p + ".tmp";
555
- writeFileSync(tmp, buildSessionSummaryDocument(s));
556
- renameSync(tmp, p);
1011
+ writeFileSync3(tmp, buildSessionSummaryDocument(s));
1012
+ renameSync2(tmp, p);
557
1013
  }
558
1014
  function buildSessionSummaryDocument(s) {
559
1015
  const firstPrompt = firstUserPrompt(s.id, "ASC");
@@ -607,7 +1063,7 @@ function getFileIndex(sessionId, dir) {
607
1063
  return idx;
608
1064
  idx = { nextSeq: 1, byPartId: new Map };
609
1065
  try {
610
- const files = readdirSync(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
1066
+ const files = readdirSync2(dir).filter((f) => f.endsWith(".txt") && !f.startsWith("."));
611
1067
  let max = 0;
612
1068
  for (const f of files) {
613
1069
  const m = /^(\d{5})-(prt_[A-Za-z0-9_-]+)\.txt$/.exec(f);
@@ -628,112 +1084,324 @@ function getFileIndex(sessionId, dir) {
628
1084
  }
629
1085
  async function runExport(opts = {}) {
630
1086
  const root = ensureRoot(opts.root ?? exportRoot());
631
- const cursor = opts.fromCursor !== undefined ? opts.fromCursor : getLastSync(root);
632
1087
  const batchSize = opts.batchSize ?? 1000;
633
- const start = Date.now();
634
- const progress = { exported: 0, inserts: 0, updates: 0, skipped_nontext: 0, skipped_oversize: 0, failed: 0, last_cursor: cursor };
635
- let where = "";
636
- const params = [];
637
- if (cursor) {
638
- where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
639
- params.push(cursor.ts, cursor.ts, cursor.id);
640
- }
641
- let updates = 0;
642
- let inserts = 0;
643
- const touchedSessions = new Set;
644
- while (true) {
645
- if (opts.budgetMs && Date.now() - start > opts.budgetMs)
1088
+ const progress = emptyProgress(getLastSync2(root));
1089
+ const lock = acquireExportLock(root);
1090
+ if (!lock) {
1091
+ progress.lock_skipped = true;
1092
+ return progress;
1093
+ }
1094
+ try {
1095
+ const state = getSyncState2(root);
1096
+ applyCursorOverride(state, opts.fromCursor);
1097
+ const start = Date.now();
1098
+ const touchedSessions = new Set;
1099
+ retryFailedParts(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1100
+ runInsertFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1101
+ runSessionDirtyFastPath(root, state, progress, touchedSessions, start, opts.budgetMs, batchSize, opts.onProgress, lock.heartbeat);
1102
+ refreshTouchedSessions(root, touchedSessions);
1103
+ if (!opts.budgetMs) {
1104
+ lock.heartbeat();
1105
+ const tombstones = reconcileTombstones(root, lock.heartbeat);
1106
+ applyTombstoneProgress(progress, tombstones);
1107
+ state.last_reconcile_at = Date.now();
1108
+ state.reconcile_watermark = {
1109
+ part_id: state.insert_cursor.id || null,
1110
+ session_id: state.session_cursor?.id ?? null,
1111
+ at: state.last_reconcile_at
1112
+ };
1113
+ }
1114
+ progress.last_cursor = state.legacy_cursor;
1115
+ setSyncState2(state, root);
1116
+ } finally {
1117
+ lock.release();
1118
+ }
1119
+ if (opts.budgetMs && !opts.skipBackgroundReconcile) {
1120
+ scheduleBackgroundReconcile({ root });
1121
+ }
1122
+ return progress;
1123
+ }
1124
+ function emptyProgress(cursor) {
1125
+ return {
1126
+ exported: 0,
1127
+ inserts: 0,
1128
+ updates: 0,
1129
+ skipped_nontext: 0,
1130
+ skipped_oversize: 0,
1131
+ failed: 0,
1132
+ retried: 0,
1133
+ dead_lettered: 0,
1134
+ tombstones_removed_parts: 0,
1135
+ tombstones_removed_sessions: 0,
1136
+ lock_skipped: false,
1137
+ last_cursor: cursor
1138
+ };
1139
+ }
1140
+ function applyCursorOverride(state, cursor) {
1141
+ if (cursor === undefined)
1142
+ return;
1143
+ state.legacy_cursor = cursor;
1144
+ state.insert_cursor.id = cursor?.id ?? "";
1145
+ state.session_cursor = cursor && cursor.ts > 0 ? cursor : null;
1146
+ state.session_dirty_hints = {};
1147
+ }
1148
+ function retryFailedParts(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1149
+ const ids = Object.keys(state.failed_parts).sort().slice(0, batchSize);
1150
+ for (const id of ids) {
1151
+ if (timeExceeded(start, budgetMs))
646
1152
  break;
647
- const rows = stmt(`
648
- SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated, p.data, LENGTH(p.data) AS data_bytes,
649
- json_extract(m.data,'$.role') AS role
650
- FROM part p
651
- LEFT JOIN message m ON m.id = p.message_id
652
- ${where}
653
- ORDER BY p.time_updated ASC, p.id ASC
654
- LIMIT ?`).all(...params, batchSize);
1153
+ progress.retried++;
1154
+ const row = loadPartById(id);
1155
+ if (!row) {
1156
+ clearPartFailure(state, id);
1157
+ continue;
1158
+ }
1159
+ exportPartRow(root, state, row, progress, touchedSessions);
1160
+ reportProgress(progress, onProgress, heartbeat);
1161
+ }
1162
+ }
1163
+ function runInsertFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1164
+ const recentSafeRows = [];
1165
+ let scanCursor = state.insert_cursor.id;
1166
+ while (!timeExceeded(start, budgetMs)) {
1167
+ const rows = loadPartRowsAfterId(scanCursor, batchSize);
655
1168
  if (rows.length === 0)
656
1169
  break;
657
- for (const r of rows) {
658
- if (opts.budgetMs && Date.now() - start > opts.budgetMs)
1170
+ for (const row of rows) {
1171
+ if (timeExceeded(start, budgetMs))
1172
+ break;
1173
+ scanCursor = row.id;
1174
+ const safe = exportPartRow(root, state, row, progress, touchedSessions);
1175
+ if (safe)
1176
+ rememberSafeRow(recentSafeRows, row);
1177
+ reportProgress(progress, onProgress, heartbeat);
1178
+ }
1179
+ if (rows.length < batchSize)
1180
+ break;
1181
+ }
1182
+ if (recentSafeRows.length > 0) {
1183
+ state.insert_cursor.id = chooseInsertCursor(state.insert_cursor.id, recentSafeRows, budgetMs !== undefined);
1184
+ }
1185
+ }
1186
+ function runSessionDirtyFastPath(root, state, progress, touchedSessions, start, budgetMs, batchSize, onProgress, heartbeat) {
1187
+ scanDirtySessionHints(state, start, budgetMs, Math.min(batchSize, 500));
1188
+ for (const [sessionId, hint] of sortedDirtyHints(state.session_dirty_hints)) {
1189
+ while (!timeExceeded(start, budgetMs)) {
1190
+ const rows = loadSessionPartRows(sessionId, hint.part_cursor, batchSize);
1191
+ if (rows.length === 0) {
1192
+ delete state.session_dirty_hints[sessionId];
1193
+ break;
1194
+ }
1195
+ for (const row of rows) {
1196
+ if (timeExceeded(start, budgetMs))
1197
+ break;
1198
+ hint.part_cursor = row.id;
1199
+ exportPartRow(root, state, row, progress, touchedSessions);
1200
+ reportProgress(progress, onProgress, heartbeat);
1201
+ }
1202
+ if (rows.length < batchSize) {
1203
+ delete state.session_dirty_hints[sessionId];
659
1204
  break;
660
- if (r.data_bytes > SAFETY_PART_CAP_BYTES) {
661
- progress.skipped_oversize++;
662
- } else {
663
- try {
664
- const s = getSession(r.session_id);
665
- if (!s) {
666
- progress.failed++;
667
- continue;
668
- }
669
- const built = buildPartFile(r.id, r.session_id, r.message_id, r.data, s.time_archived != null);
670
- if (!built) {
671
- progress.skipped_nontext++;
672
- continue;
673
- }
674
- const channelDocs = buildChannelDocuments(r.id, r.session_id, r.message_id, r.data, s.time_archived != null, r.role ?? null, s.directory);
675
- const dir = join2(root, "by-session", r.session_id);
676
- if (!existsSync2(dir)) {
677
- mkdirSync(dir, { recursive: true });
678
- writeMeta(s, dir);
679
- }
680
- const idx = getFileIndex(r.session_id, dir);
681
- const existing = idx.byPartId.get(r.id);
682
- if (existing) {
683
- writePartFile(dir, existing, built.content);
684
- updates++;
685
- } else {
686
- const seq = idx.nextSeq++;
687
- const filename2 = safePartFilename(seq, r.id);
688
- writePartFile(dir, filename2, built.content);
689
- idx.byPartId.set(r.id, filename2);
690
- inserts++;
691
- }
692
- const filename = idx.byPartId.get(r.id);
693
- if (filename)
694
- writeChannelFiles(root, r.session_id, filename, r.id, channelDocs);
695
- touchedSessions.add(r.session_id);
696
- progress.exported++;
697
- } catch {
698
- progress.failed++;
699
- }
700
1205
  }
701
- progress.last_cursor = { ts: r.time_updated, id: r.id };
702
1206
  }
703
- const last = rows[rows.length - 1];
704
- where = "WHERE (p.time_updated > ? OR (p.time_updated = ? AND p.id > ?))";
705
- params.length = 0;
706
- params.push(last.time_updated, last.time_updated, last.id);
707
- if (opts.onProgress && progress.exported % 5000 === 0)
708
- opts.onProgress(progress);
709
- if (progress.last_cursor && progress.exported > 0 && progress.exported % 5000 === 0) {
710
- setLastSync(progress.last_cursor, root);
1207
+ if (timeExceeded(start, budgetMs))
1208
+ break;
1209
+ }
1210
+ }
1211
+ function scanDirtySessionHints(state, start, budgetMs, limit) {
1212
+ while (!timeExceeded(start, budgetMs)) {
1213
+ const rows = loadDirtySessionsAfter(state.session_cursor, limit);
1214
+ if (rows.length === 0)
1215
+ break;
1216
+ for (const row of rows) {
1217
+ state.session_dirty_hints[row.id] = { time_updated: row.time_updated, part_cursor: null };
1218
+ state.session_cursor = { ts: row.time_updated, id: row.id };
711
1219
  }
1220
+ if (rows.length < limit)
1221
+ break;
712
1222
  }
1223
+ }
1224
+ function exportPartRow(root, state, row, progress, touchedSessions) {
1225
+ if (row.data_bytes > SAFETY_PART_CAP_BYTES) {
1226
+ removeExistingPartExport(root, row.session_id, row.id);
1227
+ progress.skipped_oversize++;
1228
+ markSafeCursor(state, progress, row);
1229
+ clearPartFailure(state, row.id);
1230
+ return true;
1231
+ }
1232
+ try {
1233
+ const session = getSession(row.session_id);
1234
+ if (!session)
1235
+ throw new Error(`missing session ${row.session_id}`);
1236
+ const archived = session.time_archived != null;
1237
+ const built = buildPartFile(row.id, row.session_id, row.message_id, row.data, archived);
1238
+ if (!built) {
1239
+ removeExistingPartExport(root, row.session_id, row.id);
1240
+ progress.skipped_nontext++;
1241
+ markSafeCursor(state, progress, row);
1242
+ clearPartFailure(state, row.id);
1243
+ return true;
1244
+ }
1245
+ const channelDocs = buildChannelDocuments(row.id, row.session_id, row.message_id, row.data, archived, row.role, session.directory);
1246
+ const dir = join5(root, "by-session", row.session_id);
1247
+ if (!existsSync5(dir)) {
1248
+ mkdirSync(dir, { recursive: true });
1249
+ writeMeta(session, dir);
1250
+ }
1251
+ const idx = getFileIndex(row.session_id, dir);
1252
+ const existing = idx.byPartId.get(row.id);
1253
+ if (existing) {
1254
+ writePartFile(dir, existing, built.content);
1255
+ progress.updates++;
1256
+ } else {
1257
+ const filename2 = safePartFilename(idx.nextSeq++, row.id);
1258
+ writePartFile(dir, filename2, built.content);
1259
+ idx.byPartId.set(row.id, filename2);
1260
+ progress.inserts++;
1261
+ }
1262
+ const filename = idx.byPartId.get(row.id);
1263
+ if (filename)
1264
+ writeChannelFiles(root, row.session_id, filename, row.id, channelDocs);
1265
+ touchedSessions.add(row.session_id);
1266
+ progress.exported++;
1267
+ markSafeCursor(state, progress, row);
1268
+ clearPartFailure(state, row.id);
1269
+ return true;
1270
+ } catch (error) {
1271
+ progress.failed++;
1272
+ markPartFailure(state, row.id, errorMessage(error), progress);
1273
+ return false;
1274
+ }
1275
+ }
1276
+ function removeExistingPartExport(root, sessionId, partId) {
1277
+ const dir = join5(root, "by-session", sessionId);
1278
+ try {
1279
+ if (existsSync5(dir)) {
1280
+ const idx = getFileIndex(sessionId, dir);
1281
+ const existing = idx.byPartId.get(partId);
1282
+ if (existing)
1283
+ unlinkSync3(join5(dir, existing));
1284
+ idx.byPartId.delete(partId);
1285
+ }
1286
+ deleteChannelPartFiles(root, sessionId, partId);
1287
+ } catch {}
1288
+ }
1289
+ function markSafeCursor(state, progress, row) {
1290
+ const cursor = { ts: row.time_updated, id: row.id };
1291
+ state.legacy_cursor = cursor;
1292
+ progress.last_cursor = cursor;
1293
+ }
1294
+ function markPartFailure(state, partId, message, progress) {
1295
+ if (state.dead_letters[partId])
1296
+ return;
1297
+ const now = Date.now();
1298
+ const existing = state.failed_parts[partId];
1299
+ const failure = {
1300
+ id: partId,
1301
+ attempts: (existing?.attempts ?? 0) + 1,
1302
+ first_failed_at: existing?.first_failed_at ?? now,
1303
+ last_failed_at: now,
1304
+ last_error: message
1305
+ };
1306
+ if (failure.attempts >= MAX_FAILED_ATTEMPTS) {
1307
+ state.dead_letters[partId] = failure;
1308
+ delete state.failed_parts[partId];
1309
+ progress.dead_lettered++;
1310
+ } else {
1311
+ state.failed_parts[partId] = failure;
1312
+ }
1313
+ }
1314
+ function clearPartFailure(state, partId) {
1315
+ delete state.failed_parts[partId];
1316
+ }
1317
+ function rememberSafeRow(rows, row) {
1318
+ rows.push(row);
1319
+ const maxRows = INSERT_REWIND_MAX_ROWS * 4;
1320
+ if (rows.length > maxRows)
1321
+ rows.splice(0, rows.length - maxRows);
1322
+ }
1323
+ function chooseInsertCursor(previousId, rows, useRewind) {
1324
+ const last = rows[rows.length - 1];
1325
+ if (!last || !useRewind || rows.length <= INSERT_REWIND_MAX_ROWS)
1326
+ return last?.id ?? previousId;
1327
+ const maxCreated = rows.reduce((max, row) => Math.max(max, row.time_created), 0);
1328
+ const cutoff = maxCreated - INSERT_REWIND_MS;
1329
+ const timeIndex = rows.findIndex((row) => row.time_created >= cutoff);
1330
+ const rewindIndex = Math.max(timeIndex <= 0 ? rows.length - INSERT_REWIND_MAX_ROWS : timeIndex - 1, rows.length - INSERT_REWIND_MAX_ROWS);
1331
+ return rows[Math.max(0, rewindIndex)]?.id ?? last.id;
1332
+ }
1333
+ function sortedDirtyHints(hints) {
1334
+ return Object.entries(hints).sort((a, b) => a[1].time_updated - b[1].time_updated || a[0].localeCompare(b[0]));
1335
+ }
1336
+ function refreshTouchedSessions(root, touchedSessions) {
713
1337
  for (const sid of touchedSessions) {
714
- const s = getSession(sid);
715
- if (s) {
716
- const dir = join2(root, "by-session", sid);
717
- const fresh = stmt(`
718
- SELECT id, title, project_id, directory, agent, model, cost,
719
- time_created, time_updated, time_archived, parent_id
720
- FROM session WHERE id = ?`).get(sid);
721
- if (fresh)
722
- writeMeta(fresh, dir);
723
- if (fresh)
724
- writeSessionSummaryChannel(fresh, root);
1338
+ const dir = join5(root, "by-session", sid);
1339
+ const fresh = stmt(`
1340
+ SELECT id, title, project_id, directory, agent, model, cost,
1341
+ time_created, time_updated, time_archived, parent_id
1342
+ FROM session WHERE id = ?`).get(sid);
1343
+ if (fresh) {
1344
+ writeMeta(fresh, dir);
1345
+ writeSessionSummaryChannel(fresh, root);
725
1346
  }
726
1347
  }
727
- if (progress.last_cursor)
728
- setLastSync(progress.last_cursor, root);
729
- progress.updates = updates;
730
- progress.inserts = inserts;
731
- return progress;
1348
+ }
1349
+ function applyTombstoneProgress(progress, tombstones) {
1350
+ progress.tombstones_removed_parts = tombstones.removed_parts;
1351
+ progress.tombstones_removed_sessions = tombstones.removed_sessions;
1352
+ }
1353
+ function loadPartById(partId) {
1354
+ return stmt(partSelectSql("WHERE p.id = ?")).get(partId);
1355
+ }
1356
+ function loadPartRowsAfterId(afterId, limit) {
1357
+ if (!afterId)
1358
+ return stmt(`${partSelectSql("")} ORDER BY p.id ASC LIMIT ?`).all(limit);
1359
+ return stmt(`${partSelectSql("WHERE p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(afterId, limit);
1360
+ }
1361
+ function loadSessionPartRows(sessionId, afterId, limit) {
1362
+ if (!afterId) {
1363
+ return stmt(`${partSelectSql("WHERE p.session_id = ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, limit);
1364
+ }
1365
+ return stmt(`${partSelectSql("WHERE p.session_id = ? AND p.id > ?")} ORDER BY p.id ASC LIMIT ?`).all(sessionId, afterId, limit);
1366
+ }
1367
+ function loadDirtySessionsAfter(cursor, limit) {
1368
+ if (!cursor) {
1369
+ return stmt(`SELECT id, time_updated FROM session ORDER BY time_updated ASC, id ASC LIMIT ?`).all(limit);
1370
+ }
1371
+ return stmt(`
1372
+ SELECT id, time_updated
1373
+ FROM session
1374
+ WHERE (time_updated > ? OR (time_updated = ? AND id > ?))
1375
+ ORDER BY time_updated ASC, id ASC
1376
+ LIMIT ?`).all(cursor.ts, cursor.ts, cursor.id, limit);
1377
+ }
1378
+ function partSelectSql(where) {
1379
+ return `
1380
+ SELECT p.id, p.session_id, p.message_id, p.time_created, p.time_updated,
1381
+ p.data, LENGTH(p.data) AS data_bytes,
1382
+ json_extract(m.data,'$.role') AS role
1383
+ FROM part p
1384
+ LEFT JOIN message m ON m.id = p.message_id
1385
+ ${where}`;
1386
+ }
1387
+ function timeExceeded(start, budgetMs) {
1388
+ return budgetMs !== undefined && Date.now() - start > budgetMs;
1389
+ }
1390
+ function reportProgress(progress, onProgress, heartbeat) {
1391
+ heartbeat();
1392
+ if (!onProgress)
1393
+ return;
1394
+ const processed = progress.exported + progress.skipped_nontext + progress.skipped_oversize + progress.failed;
1395
+ if (processed > 0 && processed % 5000 === 0)
1396
+ onProgress(progress);
1397
+ }
1398
+ function errorMessage(error) {
1399
+ return error instanceof Error ? error.message : String(error);
732
1400
  }
733
1401
 
734
1402
  // src/bin/bulk-export.ts
735
- import { existsSync as existsSync3, rmSync } from "fs";
736
- import { join as join3 } from "path";
1403
+ import { existsSync as existsSync6, rmSync as rmSync2 } from "fs";
1404
+ import { join as join6 } from "path";
737
1405
  var argv = process.argv.slice(2);
738
1406
  var reset = argv.includes("--reset");
739
1407
  var rootIdx = argv.indexOf("--root");
@@ -741,15 +1409,15 @@ var root = rootIdx >= 0 ? argv[rootIdx + 1] : exportRoot();
741
1409
  console.log(`[bulk-export] root: ${root}`);
742
1410
  ensureRoot(root);
743
1411
  if (reset) {
744
- const lastSync = join3(root, ".last_sync");
745
- if (existsSync3(lastSync))
746
- rmSync(lastSync);
747
- const channelRoot = join3(root, "by-channel");
748
- if (existsSync3(channelRoot))
749
- rmSync(channelRoot, { recursive: true, force: true });
750
- const marker = join3(root, ".channels_v1_complete");
751
- if (existsSync3(marker))
752
- rmSync(marker);
1412
+ const lastSync = join6(root, ".last_sync");
1413
+ if (existsSync6(lastSync))
1414
+ rmSync2(lastSync);
1415
+ const channelRoot = join6(root, "by-channel");
1416
+ if (existsSync6(channelRoot))
1417
+ rmSync2(channelRoot, { recursive: true, force: true });
1418
+ const marker = join6(root, ".channels_v1_complete");
1419
+ if (existsSync6(marker))
1420
+ rmSync2(marker);
753
1421
  console.log(`[bulk-export] --reset: removed .last_sync + by-channel/, starting from scratch`);
754
1422
  }
755
1423
  var start = Date.now();