@indigoai-us/hq-cloud 6.15.75 → 6.15.77

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 (73) hide show
  1. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  2. package/dist/bin/sync-runner-watch-loop.js +12 -2
  3. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  4. package/dist/bin/sync-runner.d.ts +4 -0
  5. package/dist/bin/sync-runner.d.ts.map +1 -1
  6. package/dist/bin/sync-runner.js.map +1 -1
  7. package/dist/bin/sync-runner.test.js +95 -4
  8. package/dist/bin/sync-runner.test.js.map +1 -1
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.test.d.ts +2 -0
  14. package/dist/index.test.d.ts.map +1 -0
  15. package/dist/index.test.js +16 -0
  16. package/dist/index.test.js.map +1 -0
  17. package/dist/operation-lock.d.ts +31 -28
  18. package/dist/operation-lock.d.ts.map +1 -1
  19. package/dist/operation-lock.js +206 -50
  20. package/dist/operation-lock.js.map +1 -1
  21. package/dist/operation-lock.test.js +105 -1
  22. package/dist/operation-lock.test.js.map +1 -1
  23. package/dist/sync/area-resolver.d.ts +66 -0
  24. package/dist/sync/area-resolver.d.ts.map +1 -0
  25. package/dist/sync/area-resolver.js +306 -0
  26. package/dist/sync/area-resolver.js.map +1 -0
  27. package/dist/sync/area-resolver.test.d.ts +2 -0
  28. package/dist/sync/area-resolver.test.d.ts.map +1 -0
  29. package/dist/sync/area-resolver.test.js +211 -0
  30. package/dist/sync/area-resolver.test.js.map +1 -0
  31. package/dist/sync/event-sync.d.ts +5 -1
  32. package/dist/sync/event-sync.d.ts.map +1 -1
  33. package/dist/sync/event-sync.js +3 -0
  34. package/dist/sync/event-sync.js.map +1 -1
  35. package/dist/sync/event-sync.test.js +45 -0
  36. package/dist/sync/event-sync.test.js.map +1 -1
  37. package/dist/sync/index.d.ts +4 -0
  38. package/dist/sync/index.d.ts.map +1 -1
  39. package/dist/sync/index.js +2 -0
  40. package/dist/sync/index.js.map +1 -1
  41. package/dist/sync/parallel-sync-coordinator.d.ts +77 -0
  42. package/dist/sync/parallel-sync-coordinator.d.ts.map +1 -0
  43. package/dist/sync/parallel-sync-coordinator.js +287 -0
  44. package/dist/sync/parallel-sync-coordinator.js.map +1 -0
  45. package/dist/sync/parallel-sync-coordinator.test.d.ts +2 -0
  46. package/dist/sync/parallel-sync-coordinator.test.d.ts.map +1 -0
  47. package/dist/sync/parallel-sync-coordinator.test.js +281 -0
  48. package/dist/sync/parallel-sync-coordinator.test.js.map +1 -0
  49. package/dist/sync/path-leases.d.ts +31 -0
  50. package/dist/sync/path-leases.d.ts.map +1 -0
  51. package/dist/sync/path-leases.js +74 -0
  52. package/dist/sync/path-leases.js.map +1 -0
  53. package/dist/sync/path-leases.test.d.ts +2 -0
  54. package/dist/sync/path-leases.test.d.ts.map +1 -0
  55. package/dist/sync/path-leases.test.js +51 -0
  56. package/dist/sync/path-leases.test.js.map +1 -0
  57. package/dist/sync/state-store.d.ts +7 -0
  58. package/dist/sync/state-store.d.ts.map +1 -1
  59. package/dist/sync/state-store.js +104 -43
  60. package/dist/sync/state-store.js.map +1 -1
  61. package/dist/sync/state-store.test.js +62 -1
  62. package/dist/sync/state-store.test.js.map +1 -1
  63. package/dist/sync/sync-work.d.ts +64 -0
  64. package/dist/sync/sync-work.d.ts.map +1 -0
  65. package/dist/sync/sync-work.js +11 -0
  66. package/dist/sync/sync-work.js.map +1 -0
  67. package/dist/watcher.d.ts +65 -0
  68. package/dist/watcher.d.ts.map +1 -1
  69. package/dist/watcher.js +280 -24
  70. package/dist/watcher.js.map +1 -1
  71. package/dist/watcher.test.js +401 -3
  72. package/dist/watcher.test.js.map +1 -1
  73. package/package.json +1 -1
@@ -4,8 +4,9 @@ import * as os from "os";
4
4
  import * as path from "path";
5
5
  import { createHash } from "node:crypto";
6
6
  import { watch } from "chokidar";
7
- import { FakeClock, WatchPushDriver, TreeWatcher, ChokidarWatchBudget, createWatchPathFilter, toChokidarIgnored, PushEventEmitter, PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES, PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP, resolveEventDebounceConfig, summarizePublishBatchOutcome, DEFAULT_EVENT_DEBOUNCE_MS, DEFAULT_EVENT_MAX_WAIT_MS, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, } from "./watcher.js";
7
+ import { FakeClock, WatchPushDriver, TreeWatcher, ChokidarWatchBudget, createWatchPathFilter, toChokidarIgnored, PushEventEmitter, PUBLISH_OUTCOME_JOURNALD_CEILING_BYTES, PUBLISH_OUTCOME_SUCCESS_SAMPLE_CAP, resolveEventDebounceConfig, summarizePublishBatchOutcome, DEFAULT_EVENT_DEBOUNCE_MS, DEFAULT_EVENT_MAX_WAIT_MS, DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS, DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS, DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES, DEFAULT_PUBLISH_MIN_INTERVAL_MS, EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV, EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV, EVENT_SMALL_BATCH_MAX_PATHS_ENV, EVENT_SMALL_BATCH_MAX_BYTES_ENV, DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS, MAX_TREE_WATCHER_MAX_WATCHED_PATHS, TREE_WATCHER_MAX_WATCHED_PATHS_ENV, resolveMaxWatchedPaths, PUBLISH_MIN_INTERVAL_MS_ENV, resolvePublishMinIntervalMs, } from "./watcher.js";
8
8
  import { StaticFlagProvider } from "./sync/feature-flags.js";
9
+ import { StateStore, StateStoreLockError } from "./sync/state-store.js";
9
10
  import { computePersonalVaultPaths } from "./personal-vault.js";
10
11
  /**
11
12
  * US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
@@ -790,6 +791,167 @@ describe("US-002: TreeWatcher — debounce coalesce (FakeClock seam)", () => {
790
791
  clock.advance(1);
791
792
  expect(changed).toHaveBeenCalledTimes(1);
792
793
  });
794
+ it("flushes a quiet, known-small batch through the short debounce window", () => {
795
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-debounce-"));
796
+ try {
797
+ const small = path.join(dir, "small.md");
798
+ fs.writeFileSync(small, "sixteen-byte-doc");
799
+ const clock = new FakeClock();
800
+ const changed = vi.fn();
801
+ const watcher = new TreeWatcher({
802
+ hqRoot: dir,
803
+ debounceMs: 15_000,
804
+ smallBatchDebounceMs: 2_500,
805
+ smallBatchMaxPaths: 8,
806
+ smallBatchMaxBytes: 1024 * 1024,
807
+ clock,
808
+ pathFilter: () => true,
809
+ });
810
+ watcher.onChange(changed);
811
+ watcher.handleEvent(small);
812
+ clock.advance(2_499);
813
+ expect(changed).not.toHaveBeenCalled();
814
+ clock.advance(1);
815
+ expect(changed).toHaveBeenCalledTimes(1);
816
+ expect(changed.mock.calls[0][1].paths.get(small)).toBe("small.md");
817
+ watcher.dispose();
818
+ }
819
+ finally {
820
+ fs.rmSync(dir, { recursive: true, force: true });
821
+ }
822
+ });
823
+ it("keeps a burst above the small-path cap on the 15s debounce", () => {
824
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-burst-debounce-"));
825
+ try {
826
+ const clock = new FakeClock();
827
+ const changed = vi.fn();
828
+ const watcher = new TreeWatcher({
829
+ hqRoot: dir,
830
+ debounceMs: 15_000,
831
+ smallBatchDebounceMs: 2_500,
832
+ smallBatchMaxPaths: 8,
833
+ smallBatchMaxBytes: 1024 * 1024,
834
+ clock,
835
+ pathFilter: () => true,
836
+ });
837
+ watcher.onChange(changed);
838
+ for (let index = 0; index < 9; index += 1) {
839
+ const file = path.join(dir, `burst-${index}.md`);
840
+ fs.writeFileSync(file, "small");
841
+ watcher.handleEvent(file);
842
+ clock.advance(100);
843
+ }
844
+ // The final event landed at t=800ms; the loop's final 100ms advance is
845
+ // outside the quiet window, so stop one millisecond before t=15_800ms.
846
+ clock.advance(14_899);
847
+ expect(changed).not.toHaveBeenCalled();
848
+ clock.advance(1);
849
+ expect(changed).toHaveBeenCalledTimes(1);
850
+ expect(changed.mock.calls[0][1].paths.size).toBe(9);
851
+ watcher.dispose();
852
+ }
853
+ finally {
854
+ fs.rmSync(dir, { recursive: true, force: true });
855
+ }
856
+ });
857
+ it("preserves max-wait for a continuous small batch that never becomes quiet", () => {
858
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-small-max-wait-"));
859
+ try {
860
+ const file = path.join(dir, "continuous.md");
861
+ fs.writeFileSync(file, "small");
862
+ const clock = new FakeClock();
863
+ const changed = vi.fn();
864
+ const watcher = new TreeWatcher({
865
+ hqRoot: dir,
866
+ debounceMs: 15_000,
867
+ maxWaitMs: 120_000,
868
+ smallBatchDebounceMs: 2_500,
869
+ smallBatchMaxPaths: 8,
870
+ smallBatchMaxBytes: 1024 * 1024,
871
+ clock,
872
+ pathFilter: () => true,
873
+ });
874
+ watcher.onChange(changed);
875
+ for (let elapsed = 0; elapsed < 120_000; elapsed += 2_000) {
876
+ watcher.handleEvent(file);
877
+ if (elapsed < 118_000)
878
+ clock.advance(2_000);
879
+ }
880
+ expect(changed).not.toHaveBeenCalled();
881
+ clock.advance(2_000);
882
+ expect(changed).toHaveBeenCalledTimes(1);
883
+ watcher.dispose();
884
+ }
885
+ finally {
886
+ fs.rmSync(dir, { recursive: true, force: true });
887
+ }
888
+ });
889
+ it("keeps a duplicate directory delete out of the short debounce lane", () => {
890
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-delete-debounce-"));
891
+ try {
892
+ const deleted = path.join(dir, "deleted-directory");
893
+ const clock = new FakeClock();
894
+ const changed = vi.fn();
895
+ const watcher = new TreeWatcher({
896
+ hqRoot: dir,
897
+ debounceMs: 15_000,
898
+ smallBatchDebounceMs: 2_500,
899
+ smallBatchMaxPaths: 8,
900
+ smallBatchMaxBytes: 1024 * 1024,
901
+ clock,
902
+ pathFilter: () => true,
903
+ });
904
+ watcher.onChange(changed);
905
+ watcher.handleEvent(deleted, "unlinkDir");
906
+ watcher.handleEvent(deleted, "unlink");
907
+ clock.advance(2_500);
908
+ expect(changed).not.toHaveBeenCalled();
909
+ clock.advance(12_500);
910
+ expect(changed).toHaveBeenCalledTimes(1);
911
+ expect(changed.mock.calls[0][1].changes.get(deleted)).toMatchObject({
912
+ kind: "unlinkDir",
913
+ });
914
+ watcher.dispose();
915
+ }
916
+ finally {
917
+ fs.rmSync(dir, { recursive: true, force: true });
918
+ }
919
+ });
920
+ it("keeps an overflowed batch out of the short debounce lane", () => {
921
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-watcher-overflow-debounce-"));
922
+ try {
923
+ const first = path.join(dir, "first.md");
924
+ const dropped = path.join(dir, "dropped.md");
925
+ fs.writeFileSync(first, "small");
926
+ fs.writeFileSync(dropped, "small");
927
+ const clock = new FakeClock();
928
+ const changed = vi.fn();
929
+ const watcher = new TreeWatcher({
930
+ hqRoot: dir,
931
+ debounceMs: 15_000,
932
+ smallBatchDebounceMs: 2_500,
933
+ smallBatchMaxPaths: 8,
934
+ smallBatchMaxBytes: 1024 * 1024,
935
+ maxPendingPaths: 1,
936
+ maxPendingBytes: 1024 * 1024,
937
+ clock,
938
+ pathFilter: () => true,
939
+ onBacklogOverflow: () => undefined,
940
+ });
941
+ watcher.onChange(changed);
942
+ watcher.handleEvent(first);
943
+ watcher.handleEvent(dropped);
944
+ clock.advance(2_500);
945
+ expect(changed).not.toHaveBeenCalled();
946
+ clock.advance(12_500);
947
+ expect(changed).toHaveBeenCalledTimes(1);
948
+ expect(changed.mock.calls[0][1]).toMatchObject({ overflowed: true });
949
+ watcher.dispose();
950
+ }
951
+ finally {
952
+ fs.rmSync(dir, { recursive: true, force: true });
953
+ }
954
+ });
793
955
  it("does NOT emit for an ignored / excluded path", () => {
794
956
  const { clock, changed, watcher } = makeWatcher({ personalMode: true });
795
957
  watcher.handleEvent(path.join(ROOT, ".env"));
@@ -1249,9 +1411,11 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
1249
1411
  transport,
1250
1412
  flagProvider: new StaticFlagProvider(["tenant-indigo"]),
1251
1413
  now: () => new Date("2026-06-18T12:00:00.000Z"),
1414
+ clock: opts?.clock,
1252
1415
  getSequenceNumber: opts?.getSequenceNumber,
1253
1416
  onError: opts?.onError,
1254
1417
  lastPublishedContentHashMaxPaths: opts?.lastPublishedContentHashMaxPaths,
1418
+ publishMinIntervalMs: opts?.publishMinIntervalMs ?? 0,
1255
1419
  ...(opts?.lastPublishedContentHashStateDir
1256
1420
  ? { lastPublishedContentHashStateDir: opts.lastPublishedContentHashStateDir }
1257
1421
  : {}),
@@ -1371,6 +1535,109 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
1371
1535
  ]);
1372
1536
  expect(published[0].contentHash).not.toBe(published[1].contentHash);
1373
1537
  });
1538
+ it("coalesces ten rewrites of one path into an immediate and trailing latest publish", async () => {
1539
+ const clock = new FakeClock();
1540
+ const published = [];
1541
+ const rewritten = path.join(dir, "high-churn-prd.json");
1542
+ const relativePath = "personal/projects/churn/prd.json";
1543
+ const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
1544
+ const batch = { paths: new Map([[rewritten, relativePath]]) };
1545
+ for (let version = 0; version < 10; version += 1) {
1546
+ fs.writeFileSync(rewritten, `{"version":${version}}\n`);
1547
+ await emitter.emitForBatch(batch);
1548
+ if (version < 9)
1549
+ clock.advance(5_000);
1550
+ }
1551
+ expect(published).toHaveLength(1);
1552
+ clock.advance(15_000);
1553
+ await emitter.emitForBatch({ paths: new Map() });
1554
+ expect(published).toHaveLength(2);
1555
+ expect(clock.pendingTimerCount()).toBe(0);
1556
+ expect(published[1]).toMatchObject({ kind: "upsert", relativePath });
1557
+ expect(published[1].contentHash).toBe(`sha256:${createHash("sha256").update('{"version":9}\n').digest("hex")}`);
1558
+ });
1559
+ it("publishes the uploaded deferred revision when the local file changes before flush", async () => {
1560
+ const clock = new FakeClock();
1561
+ const published = [];
1562
+ const rewritten = path.join(dir, "captured-deferred-revision.json");
1563
+ const relativePath = "personal/projects/captured/prd.json";
1564
+ const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
1565
+ const batch = { paths: new Map([[rewritten, relativePath]]) };
1566
+ fs.writeFileSync(rewritten, '{"revision":"uploaded"}\n');
1567
+ await emitter.emitForBatch(batch);
1568
+ fs.writeFileSync(rewritten, '{"revision":"deferred-uploaded"}\n');
1569
+ await emitter.emitForBatch(batch);
1570
+ const expectedHash = `sha256:${createHash("sha256")
1571
+ .update('{"revision":"deferred-uploaded"}\n')
1572
+ .digest("hex")}`;
1573
+ // This local revision has not completed its scoped push yet. The trailing
1574
+ // announcement must remain bound to the previously uploaded bytes.
1575
+ fs.writeFileSync(rewritten, '{"revision":"newer-local"}\n');
1576
+ clock.advance(60_000);
1577
+ await emitter.emitForBatch({ paths: new Map() });
1578
+ expect(published).toHaveLength(2);
1579
+ expect(published[1]).toMatchObject({
1580
+ kind: "upsert",
1581
+ relativePath,
1582
+ contentHash: expectedHash,
1583
+ });
1584
+ expect(published[1].contentHash).not.toBe(`sha256:${createHash("sha256")
1585
+ .update('{"revision":"newer-local"}\n')
1586
+ .digest("hex")}`);
1587
+ });
1588
+ it("skips the trailing publish when the final rewrite restores the last published hash", async () => {
1589
+ const clock = new FakeClock();
1590
+ const published = [];
1591
+ const rewritten = path.join(dir, "reverted-prd.json");
1592
+ const relativePath = "personal/projects/reverted/prd.json";
1593
+ const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
1594
+ const batch = { paths: new Map([[rewritten, relativePath]]) };
1595
+ fs.writeFileSync(rewritten, "A\n");
1596
+ await emitter.emitForBatch(batch);
1597
+ fs.writeFileSync(rewritten, "B\n");
1598
+ await emitter.emitForBatch(batch);
1599
+ fs.writeFileSync(rewritten, "A\n");
1600
+ await emitter.emitForBatch(batch);
1601
+ clock.advance(60_000);
1602
+ await emitter.emitForBatch({ paths: new Map() });
1603
+ expect(published).toHaveLength(1);
1604
+ expect(clock.pendingTimerCount()).toBe(0);
1605
+ expect(published[0]).toMatchObject({ kind: "upsert", relativePath });
1606
+ });
1607
+ it("publishes a delete immediately while an upsert is deferred", async () => {
1608
+ const clock = new FakeClock();
1609
+ const published = [];
1610
+ const deleted = path.join(dir, "delete-during-quiet.jsonl");
1611
+ const relativePath = "workspace/.session-logs/delete-during-quiet.jsonl";
1612
+ const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000, clock });
1613
+ const batch = { paths: new Map([[deleted, relativePath]]) };
1614
+ fs.writeFileSync(deleted, "A\n");
1615
+ await emitter.emitForBatch(batch);
1616
+ fs.writeFileSync(deleted, "B\n");
1617
+ await emitter.emitForBatch(batch);
1618
+ fs.rmSync(deleted);
1619
+ await emitter.emitForBatch(batch);
1620
+ expect(published.map((event) => event.kind)).toEqual(["upsert", "delete"]);
1621
+ clock.advance(60_000);
1622
+ await emitter.emitForBatch({ paths: new Map() });
1623
+ expect(published).toHaveLength(2);
1624
+ });
1625
+ it("does not make a second path wait behind another path's quiet interval", async () => {
1626
+ const published = [];
1627
+ const first = path.join(dir, "first-high-churn.jsonl");
1628
+ const second = path.join(dir, "independent.jsonl");
1629
+ const emitter = makeEmitter({ published, publishMinIntervalMs: 60_000 });
1630
+ fs.writeFileSync(first, "A\n");
1631
+ fs.writeFileSync(second, "B\n");
1632
+ await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
1633
+ fs.writeFileSync(first, "A2\n");
1634
+ await emitter.emitForBatch({ paths: new Map([[first, "workspace/.session-logs/first.jsonl"]]) });
1635
+ await emitter.emitForBatch({ paths: new Map([[second, "workspace/.session-logs/second.jsonl"]]) });
1636
+ expect(published.map((event) => event.relativePath)).toEqual([
1637
+ "workspace/.session-logs/first.jsonl",
1638
+ "workspace/.session-logs/second.jsonl",
1639
+ ]);
1640
+ });
1374
1641
  it("publishes a content revert because it compares only with the last publication", async () => {
1375
1642
  const published = [];
1376
1643
  const reverted = path.join(dir, "reverted-session-log.jsonl");
@@ -1395,6 +1662,7 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
1395
1662
  originTenantId: "tenant-indigo",
1396
1663
  originDeviceId: "device-a",
1397
1664
  flagProvider: new StaticFlagProvider(["tenant-indigo"]),
1665
+ publishMinIntervalMs: 0,
1398
1666
  transport: {
1399
1667
  start: async () => { },
1400
1668
  dispose: async () => { },
@@ -1545,6 +1813,95 @@ describe("PushEventEmitter — directory and delete tombstone handling", () => {
1545
1813
  expect(published).toHaveLength(1);
1546
1814
  expect(published[0]).toMatchObject({ kind: "upsert", relativePath: "fresh-device.md" });
1547
1815
  });
1816
+ it("retries a durable publish-state lock timeout without rejecting the watcher announce", async () => {
1817
+ const clock = new FakeClock();
1818
+ const onError = vi.fn();
1819
+ const appendAsync = vi.spyOn(StateStore.prototype, "appendAsync")
1820
+ .mockResolvedValueOnce({})
1821
+ .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
1822
+ const appendIfAsync = vi.spyOn(StateStore.prototype, "appendIfAsync");
1823
+ const published = [];
1824
+ const changed = path.join(dir, "lock-timeout.md");
1825
+ fs.writeFileSync(changed, "lock timeout\n");
1826
+ try {
1827
+ await expect(makeEmitter({
1828
+ clock,
1829
+ onError,
1830
+ published,
1831
+ lastPublishedContentHashStateDir: path.join(dir, "lock-timeout-state"),
1832
+ }).emitForBatch({ paths: new Map([[changed, "lock-timeout.md"]]) })).resolves.toBeUndefined();
1833
+ expect(published).toHaveLength(1);
1834
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ name: "StateStoreLockError" }), { relativePath: "lock-timeout.md" });
1835
+ expect(appendAsync).toHaveBeenCalledTimes(2); // invalidate, then the failed durable set
1836
+ const conditionalAppendsBeforeRetry = appendIfAsync.mock.calls.length;
1837
+ clock.advance(250);
1838
+ await Promise.resolve();
1839
+ await Promise.resolve();
1840
+ expect(appendIfAsync).toHaveBeenCalledTimes(conditionalAppendsBeforeRetry + 1);
1841
+ }
1842
+ finally {
1843
+ appendIfAsync.mockRestore();
1844
+ appendAsync.mockRestore();
1845
+ }
1846
+ });
1847
+ it("cancels a stale durable-state retry after persisting a newer hash", async () => {
1848
+ const clock = new FakeClock();
1849
+ const appendAsync = vi.spyOn(StateStore.prototype, "appendAsync")
1850
+ .mockResolvedValueOnce({})
1851
+ .mockRejectedValueOnce(new StateStoreLockError("timed out acquiring append.lock"));
1852
+ const stateDir = path.join(dir, "superseded-retry-state");
1853
+ const changed = path.join(dir, "superseded-retry.md");
1854
+ const published = [];
1855
+ fs.writeFileSync(changed, "A\n");
1856
+ try {
1857
+ const emitter = makeEmitter({ clock, published, lastPublishedContentHashStateDir: stateDir });
1858
+ await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
1859
+ expect(clock.pendingTimerCount()).toBe(1);
1860
+ fs.writeFileSync(changed, "B\n");
1861
+ await emitter.emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
1862
+ expect(clock.pendingTimerCount()).toBe(0);
1863
+ const appendsAfterNewerSuccess = appendAsync.mock.calls.length;
1864
+ clock.advance(10_000);
1865
+ await Promise.resolve();
1866
+ expect(appendAsync).toHaveBeenCalledTimes(appendsAfterNewerSuccess);
1867
+ const afterRestart = [];
1868
+ await makeEmitter({
1869
+ published: afterRestart,
1870
+ lastPublishedContentHashStateDir: stateDir,
1871
+ }).emitForBatch({ paths: new Map([[changed, "superseded-retry.md"]]) });
1872
+ expect(afterRestart).toEqual([]);
1873
+ }
1874
+ finally {
1875
+ appendAsync.mockRestore();
1876
+ }
1877
+ });
1878
+ it("drops permanent durable-state persistence errors without scheduling retries", async () => {
1879
+ const clock = new FakeClock();
1880
+ const onError = vi.fn();
1881
+ const appendAsync = vi.spyOn(StateStore.prototype, "appendAsync")
1882
+ .mockResolvedValueOnce({})
1883
+ .mockRejectedValue(new Error("EACCES: state directory is unwritable"));
1884
+ const changed = path.join(dir, "permanent-state-error.md");
1885
+ fs.writeFileSync(changed, "cannot persist\n");
1886
+ try {
1887
+ await makeEmitter({
1888
+ clock,
1889
+ onError,
1890
+ lastPublishedContentHashStateDir: path.join(dir, "permanent-state-error"),
1891
+ }).emitForBatch({ paths: new Map([[changed, "permanent-state-error.md"]]) });
1892
+ expect(onError).toHaveBeenCalledTimes(1);
1893
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("EACCES") }), { relativePath: "permanent-state-error.md" });
1894
+ expect(clock.pendingTimerCount()).toBe(0);
1895
+ const appendsAfterFailure = appendAsync.mock.calls.length;
1896
+ clock.advance(60_000);
1897
+ await Promise.resolve();
1898
+ expect(appendAsync).toHaveBeenCalledTimes(appendsAfterFailure);
1899
+ expect(onError).toHaveBeenCalledTimes(1);
1900
+ }
1901
+ finally {
1902
+ appendAsync.mockRestore();
1903
+ }
1904
+ });
1548
1905
  it("emits push telemetry only when publication fails", async () => {
1549
1906
  const posts = [];
1550
1907
  const telemetryClient = {
@@ -1758,7 +2115,13 @@ describe("TreeWatcher deferred delete snapshots", () => {
1758
2115
  // Event-pass debounce configuration (HQ_SYNC_EVENT_DEBOUNCE_MS / _MAX_WAIT_MS)
1759
2116
  // ---------------------------------------------------------------------------
1760
2117
  describe("resolveEventDebounceConfig", () => {
1761
- const ENV_KEYS = [EVENT_DEBOUNCE_MS_ENV, EVENT_MAX_WAIT_MS_ENV];
2118
+ const ENV_KEYS = [
2119
+ EVENT_DEBOUNCE_MS_ENV,
2120
+ EVENT_MAX_WAIT_MS_ENV,
2121
+ EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV,
2122
+ EVENT_SMALL_BATCH_MAX_PATHS_ENV,
2123
+ EVENT_SMALL_BATCH_MAX_BYTES_ENV,
2124
+ ];
1762
2125
  let saved;
1763
2126
  beforeEach(() => {
1764
2127
  saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
@@ -1777,6 +2140,9 @@ describe("resolveEventDebounceConfig", () => {
1777
2140
  expect(resolveEventDebounceConfig()).toEqual({
1778
2141
  debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
1779
2142
  maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
2143
+ smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
2144
+ smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
2145
+ smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
1780
2146
  });
1781
2147
  expect(DEFAULT_EVENT_DEBOUNCE_MS).toBe(15_000);
1782
2148
  expect(DEFAULT_EVENT_MAX_WAIT_MS).toBe(120_000);
@@ -1784,9 +2150,15 @@ describe("resolveEventDebounceConfig", () => {
1784
2150
  it("honors env overrides", () => {
1785
2151
  process.env[EVENT_DEBOUNCE_MS_ENV] = "5000";
1786
2152
  process.env[EVENT_MAX_WAIT_MS_ENV] = "30000";
2153
+ process.env[EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV] = "2000";
2154
+ process.env[EVENT_SMALL_BATCH_MAX_PATHS_ENV] = "6";
2155
+ process.env[EVENT_SMALL_BATCH_MAX_BYTES_ENV] = "4096";
1787
2156
  expect(resolveEventDebounceConfig()).toEqual({
1788
2157
  debounceMs: 5000,
1789
2158
  maxWaitMs: 30_000,
2159
+ smallBatchDebounceMs: 2000,
2160
+ smallBatchMaxPaths: 6,
2161
+ smallBatchMaxBytes: 4096,
1790
2162
  });
1791
2163
  });
1792
2164
  it("treats 0, negative, and non-numeric values as unset (defaults)", () => {
@@ -1796,6 +2168,9 @@ describe("resolveEventDebounceConfig", () => {
1796
2168
  expect(resolveEventDebounceConfig()).toEqual({
1797
2169
  debounceMs: DEFAULT_EVENT_DEBOUNCE_MS,
1798
2170
  maxWaitMs: DEFAULT_EVENT_MAX_WAIT_MS,
2171
+ smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
2172
+ smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
2173
+ smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
1799
2174
  });
1800
2175
  }
1801
2176
  });
@@ -1805,13 +2180,36 @@ describe("resolveEventDebounceConfig", () => {
1805
2180
  expect(resolveEventDebounceConfig()).toEqual({
1806
2181
  debounceMs: 60_000,
1807
2182
  maxWaitMs: 60_000,
2183
+ smallBatchDebounceMs: DEFAULT_EVENT_SMALL_BATCH_DEBOUNCE_MS,
2184
+ smallBatchMaxPaths: DEFAULT_EVENT_SMALL_BATCH_MAX_PATHS,
2185
+ smallBatchMaxBytes: DEFAULT_EVENT_SMALL_BATCH_MAX_BYTES,
1808
2186
  });
1809
2187
  });
1810
2188
  it("accepts an injected env record without touching process.env", () => {
1811
2189
  expect(resolveEventDebounceConfig({
1812
2190
  [EVENT_DEBOUNCE_MS_ENV]: "1000",
1813
2191
  [EVENT_MAX_WAIT_MS_ENV]: "2000",
1814
- })).toEqual({ debounceMs: 1000, maxWaitMs: 2000 });
2192
+ [EVENT_SMALL_BATCH_DEBOUNCE_MS_ENV]: "750",
2193
+ [EVENT_SMALL_BATCH_MAX_PATHS_ENV]: "4",
2194
+ [EVENT_SMALL_BATCH_MAX_BYTES_ENV]: "1024",
2195
+ })).toEqual({
2196
+ debounceMs: 1000,
2197
+ maxWaitMs: 2000,
2198
+ smallBatchDebounceMs: 750,
2199
+ smallBatchMaxPaths: 4,
2200
+ smallBatchMaxBytes: 1024,
2201
+ });
2202
+ });
2203
+ });
2204
+ describe("resolvePublishMinIntervalMs", () => {
2205
+ it("defaults to 60 seconds, accepts zero to disable coalescing, and rejects invalid values", () => {
2206
+ expect(resolvePublishMinIntervalMs({})).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
2207
+ expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "0" })).toBe(0);
2208
+ expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "invalid" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
2209
+ expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "-1" })).toBe(DEFAULT_PUBLISH_MIN_INTERVAL_MS);
2210
+ });
2211
+ it("uses the environment override in milliseconds", () => {
2212
+ expect(resolvePublishMinIntervalMs({ [PUBLISH_MIN_INTERVAL_MS_ENV]: "1234.9" })).toBe(1234);
1815
2213
  });
1816
2214
  });
1817
2215
  //# sourceMappingURL=watcher.test.js.map