@indigoai-us/hq-cloud 6.14.34 → 6.14.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cloud",
3
- "version": "6.14.34",
3
+ "version": "6.14.35",
4
4
  "description": "HQ by Indigo cloud sync engine — bidirectional S3 sync for mobile access",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -468,6 +468,46 @@ export async function runWatchLoop(
468
468
  }
469
469
  };
470
470
 
471
+ /**
472
+ * Journal roots the bulk-intent quarantine has refused for the current
473
+ * delete episode, keyed `slug\0journalKey`.
474
+ *
475
+ * Chokidar (the Linux backend) reports every removed child through `unlink`
476
+ * BEFORE the parent `unlinkDir`, so by the time the directory expansion is
477
+ * refused each descendant has already persisted its own version-bound intent
478
+ * and handed a snapshot to the pending batch. Declining the expansion alone
479
+ * leaves those intents standing and `prepareWatcherChanges` re-stamps them
480
+ * from the snapshots, so a large `rm -rf` still propagates in full under
481
+ * `HQ_SYNC_DELETE_POLICY=all` — the path this producer-side layer exists to
482
+ * cover, because it bypasses the engine breaker entirely. The trip therefore
483
+ * revokes the descendants' intents as well, and this set keeps the refusal
484
+ * in force for the rest of the episode (the native macOS/Windows backends
485
+ * make no child-before-parent guarantee, so descendants can also arrive
486
+ * after the directory event).
487
+ */
488
+ const quarantinedDeleteRoots = new Set<string>();
489
+ const QUARANTINE_KEY_SEPARATOR = "\u0000";
490
+ const quarantineRootKey = (slug: string, journalKey: string): string =>
491
+ `${slug}${QUARANTINE_KEY_SEPARATOR}${journalKey}`;
492
+ const quarantineRootFor = (
493
+ slug: string,
494
+ journalKey: string,
495
+ ): string | null => {
496
+ for (const root of quarantinedDeleteRoots) {
497
+ const separator = root.indexOf(QUARANTINE_KEY_SEPARATOR);
498
+ if (root.slice(0, separator) !== slug) continue;
499
+ const rootKey = root.slice(separator + 1);
500
+ if (
501
+ rootKey === "" ||
502
+ journalKey === rootKey ||
503
+ journalKey.startsWith(`${rootKey}/`)
504
+ ) {
505
+ return root;
506
+ }
507
+ }
508
+ return null;
509
+ };
510
+
471
511
  const journalCoordinates = (
472
512
  relativePath: string,
473
513
  ): { slug: string; key: string } | null => {
@@ -536,6 +576,29 @@ export async function runWatchLoop(
536
576
  kind,
537
577
  );
538
578
 
579
+ /**
580
+ * Revoke every delete intent under this event, including ones a descendant
581
+ * `unlink` already persisted before the parent `unlinkDir` arrived. An
582
+ * intent-less disappearance is the safe state: the engine refuses it
583
+ * (`missing-delete-intent`) under every delete policy, nothing is deleted
584
+ * locally or remotely, and the next pull restores the subtree.
585
+ */
586
+ const revokeCoveredDeleteIntents = (): number => {
587
+ let revoked = 0;
588
+ for (const [key] of covered) {
589
+ if (clearLocalDeleteIntent(journal, key)) revoked += 1;
590
+ }
591
+ if (revoked > 0) writeJournal(coordinates.slug, journal);
592
+ return revoked;
593
+ };
594
+
595
+ // A refused directory removal covers its descendants for the rest of the
596
+ // episode, whichever order the backend reports them in.
597
+ if (quarantineRootFor(coordinates.slug, coordinates.key)) {
598
+ revokeCoveredDeleteIntents();
599
+ return [];
600
+ }
601
+
539
602
  // Bulk-intent quarantine: one directory event must not be able to mint
540
603
  // intents across a large fraction of the shard. Leaving them intent-less
541
604
  // is the safe state — the engine refuses intent-less disappearances and
@@ -552,12 +615,18 @@ export async function runWatchLoop(
552
615
  Object.keys(journal.files).length,
553
616
  )
554
617
  ) {
618
+ quarantinedDeleteRoots.add(
619
+ quarantineRootKey(coordinates.slug, coordinates.key),
620
+ );
621
+ const revoked = revokeCoveredDeleteIntents();
555
622
  process.stderr.write(
556
623
  `hq-sync-runner: refusing to record delete intent for ${stampable.length} ` +
557
624
  `of ${Object.keys(journal.files).length} journal entries under ` +
558
625
  `"${coordinates.key || "<root>"}" — a single directory removal that large ` +
559
626
  `is treated as a possible mirror loss. Nothing was deleted. ` +
560
- `Set HQ_SYNC_DELETE_BULK_OVERRIDE=1 to propagate it deliberately.\n`,
627
+ `Revoked ${revoked} intent(s) already recorded by descendant unlink ` +
628
+ `events. Set HQ_SYNC_DELETE_BULK_OVERRIDE=1 to propagate it ` +
629
+ `deliberately.\n`,
561
630
  );
562
631
  return [];
563
632
  }
@@ -589,6 +658,21 @@ export async function runWatchLoop(
589
658
  for (const [absolutePath, change] of changes.entries()) {
590
659
  if (change.kind === "unlink" || change.kind === "unlinkDir") {
591
660
  for (const snapshot of change.deleteSnapshots ?? []) {
661
+ // Snapshots captured by descendant unlinks before the directory
662
+ // expansion was refused are part of the same quarantined episode.
663
+ // Re-stamping them here would hand the engine exactly the bulk
664
+ // delete the quarantine declined.
665
+ if (quarantineRootFor(snapshot.journalSlug, snapshot.journalPath)) {
666
+ if (
667
+ clearLocalDeleteIntent(
668
+ journalFor(snapshot.journalSlug),
669
+ snapshot.journalPath,
670
+ )
671
+ ) {
672
+ dirty.add(snapshot.journalSlug);
673
+ }
674
+ continue;
675
+ }
592
676
  let absent = false;
593
677
  try {
594
678
  fs.lstatSync(snapshot.absolutePath);
@@ -930,6 +1014,11 @@ export async function runWatchLoop(
930
1014
  const result = shouldRunFull
931
1015
  ? await runGuarded(passArgv)
932
1016
  : await runScopedDrain(pending.batch);
1017
+ // The quarantine is scoped to the delete episode it refused. The batch
1018
+ // carrying that episode has now been prepared and pushed, so a later
1019
+ // removal under the same path is judged fresh — a root that never
1020
+ // expired would silently refuse ordinary deletes from here on.
1021
+ quarantinedDeleteRoots.clear();
933
1022
  const exitCode = passExitCode(result);
934
1023
  if (exitCode === runtime.authRequiredPassExit) {
935
1024
  // The pass already emitted auth-error. Stop the unattended loop but
@@ -37,6 +37,7 @@ import type {
37
37
  import {
38
38
  FakeClock,
39
39
  type LocalDeleteSnapshot,
40
+ type TreeChange,
40
41
  type TreeChangeBatch,
41
42
  } from "../watcher.js";
42
43
  import {
@@ -8038,6 +8039,136 @@ describe("watcher delete intents without pre-seeded authorization", () => {
8038
8039
  await stopHarness(h);
8039
8040
  });
8040
8041
 
8042
+ // Chokidar (the Linux backend) reports every removed child through `unlink`
8043
+ // BEFORE the parent `unlinkDir`. Each descendant therefore persists its own
8044
+ // version-bound intent and hands a snapshot to the pending batch before the
8045
+ // directory expansion is ever seen, so a quarantine that only declines the
8046
+ // expansion leaves a whole `rm -rf` intent-backed — and
8047
+ // `HQ_SYNC_DELETE_POLICY=all`, which bypasses the engine breaker entirely,
8048
+ // would still delete the subtree without HQ_SYNC_DELETE_BULK_OVERRIDE.
8049
+ it("unlink-before-unlinkDir: the trip revokes intents descendants already recorded", async () => {
8050
+ const keys = Array.from(
8051
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
8052
+ );
8053
+ const h = startDeleteHarness(keys);
8054
+ await settle();
8055
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
8056
+ const intentCount = () =>
8057
+ Object.values(readJournal("indigo").files).filter(
8058
+ (entry) => entry.localDeleteIntent,
8059
+ ).length;
8060
+
8061
+ const childChanges = new Map<string, TreeChange>();
8062
+ for (const key of keys) {
8063
+ const absolutePath = path.join(h.companyRoot, key);
8064
+ fs.unlinkSync(absolutePath);
8065
+ const snapshots = h.capture()(`companies/indigo/${key}`, "unlink");
8066
+ expect(snapshots).toHaveLength(1);
8067
+ childChanges.set(absolutePath, {
8068
+ kind: "unlink",
8069
+ deleteSnapshots: snapshots,
8070
+ });
8071
+ }
8072
+ expect(intentCount()).toBe(12);
8073
+
8074
+ fs.rmSync(subtree, { recursive: true, force: true });
8075
+ const dirSnapshots = h.capture()(
8076
+ "companies/indigo/knowledge/moved", "unlinkDir",
8077
+ );
8078
+ expect(dirSnapshots).toHaveLength(0);
8079
+ expect(intentCount()).toBe(0);
8080
+
8081
+ h.watcher.emit("companies/indigo/knowledge/moved", {
8082
+ paths: new Map<string, string>([
8083
+ ...keys.map(
8084
+ (key) =>
8085
+ [
8086
+ path.join(h.companyRoot, key),
8087
+ `companies/indigo/${key}`,
8088
+ ] as [string, string],
8089
+ ),
8090
+ [subtree, "companies/indigo/knowledge/moved"],
8091
+ ]),
8092
+ changes: new Map<string, TreeChange>([
8093
+ ...childChanges,
8094
+ [subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
8095
+ ]),
8096
+ } as TreeChangeBatch);
8097
+ h.tick();
8098
+ await settle();
8099
+
8100
+ // The batch still carries the descendants' snapshots; preparing it must
8101
+ // not re-stamp the intents the trip revoked.
8102
+ expect(intentCount()).toBe(0);
8103
+ expect(h.remote.size).toBe(12);
8104
+ expect(Object.keys(readJournal("indigo").files)).toHaveLength(12);
8105
+ await stopHarness(h);
8106
+ });
8107
+
8108
+ // The native macOS/Windows backends make no child-before-parent guarantee,
8109
+ // so the refusal has to hold for descendants that arrive after it too.
8110
+ it("a descendant unlink arriving after a refused unlinkDir records no intent", async () => {
8111
+ const keys = Array.from(
8112
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
8113
+ );
8114
+ const h = startDeleteHarness(keys);
8115
+ await settle();
8116
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
8117
+ fs.rmSync(subtree, { recursive: true, force: true });
8118
+
8119
+ expect(
8120
+ h.capture()("companies/indigo/knowledge/moved", "unlinkDir"),
8121
+ ).toHaveLength(0);
8122
+ expect(h.capture()(`companies/indigo/${keys[0]}`, "unlink")).toHaveLength(0);
8123
+ expect(
8124
+ readJournal("indigo").files[keys[0]]?.localDeleteIntent,
8125
+ ).toBeUndefined();
8126
+ await stopHarness(h);
8127
+ });
8128
+
8129
+ // The quarantine must not reach past the episode it refused: an unexpired
8130
+ // root would silently swallow every later delete under that path.
8131
+ it("an ordinary delete under a previously refused root still propagates", async () => {
8132
+ const keys = Array.from(
8133
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
8134
+ );
8135
+ const h = startDeleteHarness(keys);
8136
+ await settle();
8137
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
8138
+ fs.rmSync(subtree, { recursive: true, force: true });
8139
+ const dirSnapshots = h.capture()(
8140
+ "companies/indigo/knowledge/moved", "unlinkDir",
8141
+ );
8142
+ h.watcher.emit("companies/indigo/knowledge/moved", {
8143
+ paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
8144
+ changes: new Map([
8145
+ [subtree, { kind: "unlinkDir", deleteSnapshots: dirSnapshots }],
8146
+ ]),
8147
+ } as TreeChangeBatch);
8148
+ h.tick();
8149
+ await settle();
8150
+ expect(h.remote.size).toBe(12);
8151
+
8152
+ // Next episode: one ordinary file removal under the same directory.
8153
+ const lonePath = path.join(h.companyRoot, keys[0]);
8154
+ fs.mkdirSync(path.dirname(lonePath), { recursive: true });
8155
+ fs.writeFileSync(lonePath, `synced:${keys[0]}`);
8156
+ fs.unlinkSync(lonePath);
8157
+ const snapshots = h.capture()(`companies/indigo/${keys[0]}`, "unlink");
8158
+ expect(snapshots).toHaveLength(1);
8159
+ h.watcher.emit(`companies/indigo/${keys[0]}`, {
8160
+ paths: new Map([[lonePath, `companies/indigo/${keys[0]}`]]),
8161
+ changes: new Map([
8162
+ [lonePath, { kind: "unlink", deleteSnapshots: snapshots }],
8163
+ ]),
8164
+ } as TreeChangeBatch);
8165
+ h.tick();
8166
+ await settle();
8167
+ expect(h.remote.has(keys[0])).toBe(false);
8168
+ expect(h.remote.size).toBe(11);
8169
+ await stopHarness(h);
8170
+ });
8171
+
8041
8172
  it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
8042
8173
  process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
8043
8174
  try {