@indigoai-us/hq-cloud 6.14.33 → 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.33",
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",
@@ -136,6 +136,46 @@ export function journalEntriesForWatcherDelete(
136
136
  );
137
137
  }
138
138
 
139
+ /**
140
+ * Bulk-intent quarantine thresholds. Deliberately the same shape and values as
141
+ * the engine's `BULK_ASYMMETRY_*` constants (`src/cli/share.ts`) so operators
142
+ * learn one rule and one knob: a disappearance covering >= 10 journal entries
143
+ * AND >= 10% of the shard is treated as suspect rather than deliberate.
144
+ */
145
+ const WATCHER_BULK_INTENT_MIN_ABS = 10;
146
+ const WATCHER_BULK_INTENT_RATIO = 0.1;
147
+
148
+ /**
149
+ * Should a single `unlinkDir` expansion be quarantined instead of stamping a
150
+ * version-bound delete intent on every descendant journal entry?
151
+ *
152
+ * One filesystem event can cover an arbitrary subtree, so the producer is
153
+ * capable of minting thousands of individually-valid intents from a checkout
154
+ * swap, an unmount, a partial restore or an `rm -rf` — the 2026-07-30 mass
155
+ * deletion did exactly that. Quarantined entries are simply left intent-less,
156
+ * which the engine already refuses (`missing-delete-intent`); nothing is
157
+ * deleted locally or remotely and the next pull restores the tree.
158
+ *
159
+ * Pure so the threshold is unit-testable without a watcher or a journal.
160
+ */
161
+ export function shouldQuarantineWatcherDeleteIntents(
162
+ expandedEntries: number,
163
+ journalEntries: number,
164
+ ): boolean {
165
+ if (expandedEntries < WATCHER_BULK_INTENT_MIN_ABS) return false;
166
+ if (journalEntries <= 0) return false;
167
+ return expandedEntries / journalEntries >= WATCHER_BULK_INTENT_RATIO;
168
+ }
169
+
170
+ /**
171
+ * Same operator escape hatch as the engine breaker — one knob for both layers.
172
+ * Set when a genuinely intentional large folder removal must propagate.
173
+ */
174
+ function isBulkIntentOverride(): boolean {
175
+ const v = (process.env.HQ_SYNC_DELETE_BULK_OVERRIDE ?? "").toLowerCase();
176
+ return v === "1" || v === "true" || v === "yes";
177
+ }
178
+
139
179
  function passExitCode(outcome: number | RunnerPassOutcome): number {
140
180
  return typeof outcome === "number" ? outcome : outcome.exitCode;
141
181
  }
@@ -428,6 +468,46 @@ export async function runWatchLoop(
428
468
  }
429
469
  };
430
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
+
431
511
  const journalCoordinates = (
432
512
  relativePath: string,
433
513
  ): { slug: string; key: string } | null => {
@@ -490,11 +570,68 @@ export async function runWatchLoop(
490
570
  // Chokidar emits one unlink per file during rm -rf. Avoid walking the
491
571
  // whole shard for every exact-key event; directory unlinks alone need
492
572
  // descendant expansion.
493
- for (const [key, entry] of journalEntriesForWatcherDelete(
573
+ const covered = journalEntriesForWatcherDelete(
494
574
  journal.files,
495
575
  coordinates.key,
496
576
  kind,
497
- )) {
577
+ );
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
+
602
+ // Bulk-intent quarantine: one directory event must not be able to mint
603
+ // intents across a large fraction of the shard. Leaving them intent-less
604
+ // is the safe state — the engine refuses intent-less disappearances and
605
+ // the next pull restores the subtree.
606
+ const stampable = covered.filter(
607
+ ([, entry]) =>
608
+ entry?.remoteEtag && entry.kind && entry.removedAt === undefined,
609
+ );
610
+ if (
611
+ kind === "unlinkDir" &&
612
+ !isBulkIntentOverride() &&
613
+ shouldQuarantineWatcherDeleteIntents(
614
+ stampable.length,
615
+ Object.keys(journal.files).length,
616
+ )
617
+ ) {
618
+ quarantinedDeleteRoots.add(
619
+ quarantineRootKey(coordinates.slug, coordinates.key),
620
+ );
621
+ const revoked = revokeCoveredDeleteIntents();
622
+ process.stderr.write(
623
+ `hq-sync-runner: refusing to record delete intent for ${stampable.length} ` +
624
+ `of ${Object.keys(journal.files).length} journal entries under ` +
625
+ `"${coordinates.key || "<root>"}" — a single directory removal that large ` +
626
+ `is treated as a possible mirror loss. Nothing was deleted. ` +
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`,
630
+ );
631
+ return [];
632
+ }
633
+
634
+ for (const [key, entry] of stampable) {
498
635
  captureEntry(key, entry);
499
636
  }
500
637
 
@@ -521,6 +658,21 @@ export async function runWatchLoop(
521
658
  for (const [absolutePath, change] of changes.entries()) {
522
659
  if (change.kind === "unlink" || change.kind === "unlinkDir") {
523
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
+ }
524
676
  let absent = false;
525
677
  try {
526
678
  fs.lstatSync(snapshot.absolutePath);
@@ -862,6 +1014,11 @@ export async function runWatchLoop(
862
1014
  const result = shouldRunFull
863
1015
  ? await runGuarded(passArgv)
864
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();
865
1022
  const exitCode = passExitCode(result);
866
1023
  if (exitCode === runtime.authRequiredPassExit) {
867
1024
  // The pass already emitted auth-error. Stop the unattended loop but
@@ -37,11 +37,13 @@ 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 {
43
44
  adaptivePollMs,
44
45
  journalEntriesForWatcherDelete,
46
+ shouldQuarantineWatcherDeleteIntents,
45
47
  } from "./sync-runner-watch-loop.js";
46
48
  import {
47
49
  PERSONAL_VAULT_JOURNAL_SLUG,
@@ -7800,6 +7802,32 @@ describe("watcher delete intents without pre-seeded authorization", () => {
7800
7802
  ).toEqual([["knowledge/exact.md", exactEntry]]);
7801
7803
  });
7802
7804
 
7805
+ // Bulk-intent quarantine. A single `unlinkDir` expands to every descendant
7806
+ // journal entry, so without a ceiling one filesystem event — a checkout
7807
+ // swap, an unmount, a partial restore — mints thousands of individually
7808
+ // valid delete intents. Same thresholds as the engine's bulk-asymmetry
7809
+ // breaker so there is one rule and one override knob across both layers.
7810
+ it.each([
7811
+ // [expanded, journalEntries, quarantined, why]
7812
+ [11, 100, true, "11% of the shard, above both thresholds"],
7813
+ [10, 100, true, "exactly at MIN_ABS and RATIO"],
7814
+ [9, 100, false, "below MIN_ABS — ordinary cleanup must propagate"],
7815
+ [9, 9, false, "100% ratio but below MIN_ABS floor"],
7816
+ [10, 1000, false, "at MIN_ABS but only 1% of the shard"],
7817
+ [50, 50, true, "the whole shard vanished at once"],
7818
+ [0, 0, false, "empty journal never quarantines"],
7819
+ ])(
7820
+ "bulk-intent quarantine for %i of %i journal entries → %s (%s)",
7821
+ (expanded, journalEntries, quarantined) => {
7822
+ expect(
7823
+ shouldQuarantineWatcherDeleteIntents(
7824
+ expanded as number,
7825
+ journalEntries as number,
7826
+ ),
7827
+ ).toBe(quarantined);
7828
+ },
7829
+ );
7830
+
7803
7831
  it("keeps a deleted top-level personal file in the scoped push delete roots", async () => {
7804
7832
  const hqRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-personal-delete-"));
7805
7833
  const key = "personal-note.md";
@@ -7974,17 +8002,219 @@ describe("watcher delete intents without pre-seeded authorization", () => {
7974
8002
  await stopHarness(h);
7975
8003
  });
7976
8004
 
7977
- it("unlinkDir records every tracked descendant across a 12-file breaker-sized subtree", async () => {
8005
+ // Replaces #227's "unlinkDir records every tracked descendant across a
8006
+ // 12-file breaker-sized subtree", which asserted that one directory event
8007
+ // may mint an intent for the entire shard and wipe it remotely. That is the
8008
+ // shape of the 2026-07-30 incident (1,108 files in one push), and the test
8009
+ // name conceded it was breaker-sized. Quarantining is now the contract; the
8010
+ // three tests below cover the refusal, the operator escape hatch, and the
8011
+ // ordinary folder removal that must keep working.
8012
+ it("unlinkDir covering the whole shard is quarantined: no intents, nothing deleted", async () => {
8013
+ const keys = Array.from(
8014
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
8015
+ );
8016
+ const h = startDeleteHarness(keys);
8017
+ await settle();
8018
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
8019
+ const snapshots = h.capture()(
8020
+ "companies/indigo/knowledge/moved", "unlinkDir",
8021
+ );
8022
+ expect(snapshots).toHaveLength(0);
8023
+ expect(
8024
+ Object.values(readJournal("indigo").files).filter(
8025
+ (entry) => entry.localDeleteIntent,
8026
+ ),
8027
+ ).toHaveLength(0);
8028
+ fs.rmSync(subtree, { recursive: true, force: true });
8029
+ h.watcher.emit("companies/indigo/knowledge/moved", {
8030
+ paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
8031
+ changes: new Map([
8032
+ [subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
8033
+ ]),
8034
+ } as TreeChangeBatch);
8035
+ h.tick();
8036
+ await settle();
8037
+ expect(h.remote.size).toBe(12);
8038
+ expect(Object.keys(readJournal("indigo").files)).toHaveLength(12);
8039
+ await stopHarness(h);
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 () => {
7978
8132
  const keys = Array.from(
7979
8133
  { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
7980
8134
  );
7981
8135
  const h = startDeleteHarness(keys);
7982
8136
  await settle();
7983
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
+
8172
+ it("HQ_SYNC_DELETE_BULK_OVERRIDE=1 lets a whole-shard unlinkDir propagate", async () => {
8173
+ process.env.HQ_SYNC_DELETE_BULK_OVERRIDE = "1";
8174
+ try {
8175
+ const keys = Array.from(
8176
+ { length: 12 }, (_, i) => `knowledge/moved/f-${i}.md`,
8177
+ );
8178
+ const h = startDeleteHarness(keys);
8179
+ await settle();
8180
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
8181
+ const snapshots = h.capture()(
8182
+ "companies/indigo/knowledge/moved", "unlinkDir",
8183
+ );
8184
+ expect(snapshots).toHaveLength(12);
8185
+ fs.rmSync(subtree, { recursive: true, force: true });
8186
+ h.watcher.emit("companies/indigo/knowledge/moved", {
8187
+ paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
8188
+ changes: new Map([
8189
+ [subtree, { kind: "unlinkDir", deleteSnapshots: snapshots }],
8190
+ ]),
8191
+ } as TreeChangeBatch);
8192
+ h.tick();
8193
+ await settle();
8194
+ expect(h.remote.size).toBe(0);
8195
+ expect(Object.keys(readJournal("indigo").files)).toHaveLength(0);
8196
+ await stopHarness(h);
8197
+ } finally {
8198
+ delete process.env.HQ_SYNC_DELETE_BULK_OVERRIDE;
8199
+ }
8200
+ });
8201
+
8202
+ // The quarantine must not touch ordinary folder cleanup — that is the
8203
+ // delete-resurrection bug #227 was written to fix, and it stays fixed.
8204
+ it("an ordinary folder removal (10 of 120 entries) still records intents and propagates", async () => {
8205
+ const removed = Array.from(
8206
+ { length: 10 }, (_, i) => `knowledge/moved/f-${i}.md`,
8207
+ );
8208
+ const kept = Array.from(
8209
+ { length: 110 }, (_, i) => `knowledge/keep/k-${i}.md`,
8210
+ );
8211
+ const h = startDeleteHarness([...removed, ...kept]);
8212
+ await settle();
8213
+ const subtree = path.join(h.companyRoot, "knowledge/moved");
7984
8214
  const snapshots = h.capture()(
7985
8215
  "companies/indigo/knowledge/moved", "unlinkDir",
7986
8216
  );
7987
- expect(snapshots).toHaveLength(12);
8217
+ expect(snapshots).toHaveLength(10);
7988
8218
  fs.rmSync(subtree, { recursive: true, force: true });
7989
8219
  h.watcher.emit("companies/indigo/knowledge/moved", {
7990
8220
  paths: new Map([[subtree, "companies/indigo/knowledge/moved"]]),
@@ -7994,8 +8224,8 @@ describe("watcher delete intents without pre-seeded authorization", () => {
7994
8224
  } as TreeChangeBatch);
7995
8225
  h.tick();
7996
8226
  await settle();
7997
- expect(h.remote.size).toBe(0);
7998
- expect(Object.keys(readJournal("indigo").files)).toHaveLength(0);
8227
+ for (const key of removed) expect(h.remote.has(key)).toBe(false);
8228
+ expect(h.remote.size).toBe(110);
7999
8229
  await stopHarness(h);
8000
8230
  });
8001
8231