@indigoai-us/hq-cloud 5.22.0 → 5.24.0

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 (78) hide show
  1. package/dist/bin/sync-runner.d.ts +20 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +18 -0
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +46 -2
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/share.d.ts +77 -20
  8. package/dist/cli/share.d.ts.map +1 -1
  9. package/dist/cli/share.js +278 -61
  10. package/dist/cli/share.js.map +1 -1
  11. package/dist/cli/share.test.js +484 -3
  12. package/dist/cli/share.test.js.map +1 -1
  13. package/dist/cli/sync.d.ts +27 -0
  14. package/dist/cli/sync.d.ts.map +1 -1
  15. package/dist/cli/sync.js.map +1 -1
  16. package/dist/index.d.ts +9 -3
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +9 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/journal.d.ts +76 -1
  21. package/dist/journal.d.ts.map +1 -1
  22. package/dist/journal.js +148 -1
  23. package/dist/journal.js.map +1 -1
  24. package/dist/journal.test.js +251 -5
  25. package/dist/journal.test.js.map +1 -1
  26. package/dist/prefix-coalesce.d.ts +38 -0
  27. package/dist/prefix-coalesce.d.ts.map +1 -0
  28. package/dist/prefix-coalesce.js +69 -0
  29. package/dist/prefix-coalesce.js.map +1 -0
  30. package/dist/prefix-coalesce.test.d.ts +2 -0
  31. package/dist/prefix-coalesce.test.d.ts.map +1 -0
  32. package/dist/prefix-coalesce.test.js +77 -0
  33. package/dist/prefix-coalesce.test.js.map +1 -0
  34. package/dist/public-surface.test.d.ts +15 -0
  35. package/dist/public-surface.test.d.ts.map +1 -0
  36. package/dist/public-surface.test.js +105 -0
  37. package/dist/public-surface.test.js.map +1 -0
  38. package/dist/remote-pull.d.ts +145 -1
  39. package/dist/remote-pull.d.ts.map +1 -1
  40. package/dist/remote-pull.js +258 -1
  41. package/dist/remote-pull.js.map +1 -1
  42. package/dist/remote-pull.test.js +470 -2
  43. package/dist/remote-pull.test.js.map +1 -1
  44. package/dist/scope-shrink.d.ts +109 -0
  45. package/dist/scope-shrink.d.ts.map +1 -0
  46. package/dist/scope-shrink.js +196 -0
  47. package/dist/scope-shrink.js.map +1 -0
  48. package/dist/scope-shrink.test.d.ts +13 -0
  49. package/dist/scope-shrink.test.d.ts.map +1 -0
  50. package/dist/scope-shrink.test.js +342 -0
  51. package/dist/scope-shrink.test.js.map +1 -0
  52. package/dist/types.d.ts +48 -1
  53. package/dist/types.d.ts.map +1 -1
  54. package/dist/vault-client.d.ts +178 -0
  55. package/dist/vault-client.d.ts.map +1 -1
  56. package/dist/vault-client.js +73 -0
  57. package/dist/vault-client.js.map +1 -1
  58. package/dist/vault-client.test.js +226 -0
  59. package/dist/vault-client.test.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/bin/sync-runner.test.ts +56 -2
  62. package/src/bin/sync-runner.ts +39 -0
  63. package/src/cli/share.test.ts +577 -3
  64. package/src/cli/share.ts +395 -85
  65. package/src/cli/sync.ts +28 -0
  66. package/src/index.ts +67 -0
  67. package/src/journal.test.ts +284 -5
  68. package/src/journal.ts +167 -2
  69. package/src/prefix-coalesce.test.ts +95 -0
  70. package/src/prefix-coalesce.ts +72 -0
  71. package/src/public-surface.test.ts +112 -0
  72. package/src/remote-pull.test.ts +540 -3
  73. package/src/remote-pull.ts +419 -2
  74. package/src/scope-shrink.test.ts +402 -0
  75. package/src/scope-shrink.ts +264 -0
  76. package/src/types.ts +49 -1
  77. package/src/vault-client.test.ts +335 -0
  78. package/src/vault-client.ts +223 -0
package/src/index.ts CHANGED
@@ -30,8 +30,60 @@ export {
30
30
  getEntry,
31
31
  removeEntry,
32
32
  getJournalPath,
33
+ // Journal v2 (US-005)
34
+ migrateToV2,
35
+ generatePullId,
36
+ appendPullRecord,
37
+ lastPullRecord,
38
+ tombstoneEntry,
39
+ isTombstone,
40
+ gcTombstones,
41
+ TOMBSTONE_TTL_MS,
42
+ JOURNAL_VERSION_CURRENT,
33
43
  } from "./journal.js";
34
44
 
45
+ // Prefix coalescing helper (US-005)
46
+ export {
47
+ coalescePrefixes,
48
+ isCoveredByAny,
49
+ } from "./prefix-coalesce.js";
50
+
51
+ // Scope-shrink detection + application (US-005)
52
+ export {
53
+ buildScopeShrinkPlan,
54
+ applyScopeShrink,
55
+ ScopeShrinkBlockedError,
56
+ } from "./scope-shrink.js";
57
+ export type {
58
+ OrphanClassification,
59
+ ScopeShrinkPlan,
60
+ BuildScopeShrinkPlanInput,
61
+ ApplyScopeShrinkInput,
62
+ ApplyScopeShrinkResult,
63
+ } from "./scope-shrink.js";
64
+
65
+ // Engine-layer ACL-aware pull orchestration (US-005)
66
+ export {
67
+ resolveCompanyScope,
68
+ batchPrefixesForVend,
69
+ listRemoteForScope,
70
+ pullCompany,
71
+ decideRemotePulls,
72
+ VEND_PATH_CAP,
73
+ POST_FILTER_THRESHOLD,
74
+ VEND_FANOUT_CONCURRENCY,
75
+ } from "./remote-pull.js";
76
+ export type {
77
+ CompanyScope,
78
+ ResolveCompanyScopeInput,
79
+ ListRemoteForScopeInput,
80
+ PullCompanyInput,
81
+ PullCompanyResult,
82
+ RemotePullDecision,
83
+ DecideRemotePullsInput,
84
+ SkippedKey,
85
+ } from "./remote-pull.js";
86
+
35
87
  export {
36
88
  createIgnoreFilter,
37
89
  isWithinSizeLimit,
@@ -80,6 +132,11 @@ export type {
80
132
  TelemetryOptInResponse,
81
133
  UsageBatch,
82
134
  UsageIngestResult,
135
+ // US-004 — browse-vs-sync membership sync-mode + ACL surface
136
+ SyncMode,
137
+ MembershipSyncConfig,
138
+ SetMembershipSyncConfigInput,
139
+ ExplicitGrant,
83
140
  } from "./vault-client.js";
84
141
 
85
142
  // Usage telemetry collector (`/v1/usage`). Re-exported so wrappers other than
@@ -101,6 +158,15 @@ export type {
101
158
  StsChildCredentials,
102
159
  } from "./vault-client.js";
103
160
 
161
+ // Raw vend (POST /vend, purpose-aware after US-009)
162
+ export type {
163
+ VendPurpose,
164
+ VaultOperation,
165
+ VendInput,
166
+ VendResult,
167
+ VendCredentials,
168
+ } from "./vault-client.js";
169
+
104
170
  // CLI commands
105
171
  export { share, sync } from "./cli/index.js";
106
172
  export type { ShareOptions, ShareResult, SyncOptions, SyncResult, SyncProgressEvent } from "./cli/index.js";
@@ -124,6 +190,7 @@ export type {
124
190
  Credentials,
125
191
  JournalEntry,
126
192
  SyncJournal,
193
+ PullRecord,
127
194
  SyncStatus,
128
195
  PushResult,
129
196
  PullResult,
@@ -16,8 +16,16 @@ import {
16
16
  readJournal,
17
17
  writeJournal,
18
18
  updateEntry,
19
+ migrateToV2,
20
+ appendPullRecord,
21
+ lastPullRecord,
22
+ tombstoneEntry,
23
+ isTombstone,
24
+ gcTombstones,
25
+ generatePullId,
26
+ TOMBSTONE_TTL_MS,
19
27
  } from "./journal.js";
20
- import type { SyncJournal } from "./types.js";
28
+ import type { SyncJournal, PullRecord } from "./types.js";
21
29
 
22
30
  describe("journal", () => {
23
31
  let stateDir: string;
@@ -73,14 +81,17 @@ describe("journal", () => {
73
81
  });
74
82
 
75
83
  describe("readJournal", () => {
76
- it("returns an empty journal when the file doesn't exist", () => {
84
+ it("returns an empty v2 journal when the file doesn't exist", () => {
77
85
  const j = readJournal("indigo");
78
- expect(j.version).toBe("1");
86
+ expect(j.version).toBe("2");
79
87
  expect(j.files).toEqual({});
80
88
  expect(j.lastSync).toBe("");
89
+ expect(j.pulls).toEqual([]);
81
90
  });
82
91
 
83
- it("reads a journal written with writeJournal", () => {
92
+ it("reads a journal written with writeJournal (round-trips through v2 migration)", () => {
93
+ // writeJournal upgrades the schema in-place to v2, so the read side
94
+ // sees v2 even if the caller handed a v1 shape.
84
95
  const original: SyncJournal = {
85
96
  version: "1",
86
97
  lastSync: "2026-04-19T00:00:00.000Z",
@@ -95,7 +106,10 @@ describe("journal", () => {
95
106
  };
96
107
  writeJournal("indigo", original);
97
108
  const roundTripped = readJournal("indigo");
98
- expect(roundTripped).toEqual(original);
109
+ expect(roundTripped.version).toBe("2");
110
+ expect(roundTripped.lastSync).toBe("2026-04-19T00:00:00.000Z");
111
+ expect(roundTripped.files).toEqual(original.files);
112
+ expect(roundTripped.pulls).toEqual([]);
99
113
  });
100
114
  });
101
115
 
@@ -143,4 +157,269 @@ describe("journal", () => {
143
157
  expect(j.files["foo.md"]?.syncedAt).not.toBe("");
144
158
  });
145
159
  });
160
+
161
+ // ── Journal v2 (US-005): pulls, tombstones, GC, migration ─────────────────
162
+
163
+ describe("migrateToV2", () => {
164
+ it("upgrades a v1 journal in place", () => {
165
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
166
+ migrateToV2(j);
167
+ expect(j.version).toBe("2");
168
+ expect(j.pulls).toEqual([]);
169
+ });
170
+
171
+ it("is idempotent on a v2 journal", () => {
172
+ const j: SyncJournal = {
173
+ version: "2",
174
+ lastSync: "",
175
+ files: {},
176
+ pulls: [],
177
+ };
178
+ const before = JSON.stringify(j);
179
+ migrateToV2(j);
180
+ expect(JSON.stringify(j)).toBe(before);
181
+ });
182
+
183
+ it("preserves all files when migrating v1 → v2", () => {
184
+ const j: SyncJournal = {
185
+ version: "1",
186
+ lastSync: "2026-05-01",
187
+ files: {
188
+ "a.md": {
189
+ hash: "h",
190
+ size: 1,
191
+ syncedAt: "2026-05-01",
192
+ direction: "down",
193
+ },
194
+ },
195
+ };
196
+ migrateToV2(j);
197
+ expect(j.files["a.md"]).toBeDefined();
198
+ expect(j.files["a.md"]?.removedAt).toBeUndefined(); // not a tombstone
199
+ });
200
+
201
+ it("writeJournal upgrades v1 → v2 on disk", () => {
202
+ writeJournal("indigo", { version: "1", lastSync: "", files: {} });
203
+ const raw = JSON.parse(
204
+ fs.readFileSync(getJournalPath("indigo"), "utf-8"),
205
+ ) as SyncJournal;
206
+ expect(raw.version).toBe("2");
207
+ expect(raw.pulls).toEqual([]);
208
+ });
209
+ });
210
+
211
+ describe("generatePullId", () => {
212
+ it("produces a 26-char identifier", () => {
213
+ expect(generatePullId()).toHaveLength(26);
214
+ });
215
+
216
+ it("is time-prefixed and lexically sortable", () => {
217
+ const t0 = Date.parse("2026-01-01T00:00:00.000Z");
218
+ const t1 = Date.parse("2026-06-01T00:00:00.000Z");
219
+ const id0 = generatePullId(t0);
220
+ const id1 = generatePullId(t1);
221
+ expect(id0 < id1).toBe(true);
222
+ });
223
+
224
+ it("uses crockford base32 alphabet (no I, L, O, U)", () => {
225
+ const id = generatePullId();
226
+ expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]+$/);
227
+ });
228
+ });
229
+
230
+ describe("appendPullRecord + lastPullRecord", () => {
231
+ function pull(partial: Partial<PullRecord>): PullRecord {
232
+ return {
233
+ pullId: generatePullId(),
234
+ companyUid: "cmp_indigo",
235
+ startedAt: "2026-05-20T00:00:00.000Z",
236
+ completedAt: "2026-05-20T00:00:05.000Z",
237
+ syncMode: "all",
238
+ prefixSet: [],
239
+ scopeChangeDetected: false,
240
+ orphansRemoved: 0,
241
+ orphansBlocked: 0,
242
+ ...partial,
243
+ };
244
+ }
245
+
246
+ it("appends to pulls[] (auto-migrates v1)", () => {
247
+ const j: SyncJournal = { version: "1", lastSync: "", files: {} };
248
+ appendPullRecord(j, pull({}));
249
+ expect(j.version).toBe("2");
250
+ expect(j.pulls).toHaveLength(1);
251
+ });
252
+
253
+ it("lastPullRecord returns undefined when no records exist", () => {
254
+ const j: SyncJournal = {
255
+ version: "2",
256
+ lastSync: "",
257
+ files: {},
258
+ pulls: [],
259
+ };
260
+ expect(lastPullRecord(j, "cmp_indigo")).toBeUndefined();
261
+ });
262
+
263
+ it("lastPullRecord returns the most-recent by completedAt", () => {
264
+ const j: SyncJournal = {
265
+ version: "2",
266
+ lastSync: "",
267
+ files: {},
268
+ pulls: [
269
+ pull({ completedAt: "2026-05-19T00:00:00.000Z", syncMode: "all" }),
270
+ pull({ completedAt: "2026-05-20T00:00:00.000Z", syncMode: "shared" }),
271
+ pull({ completedAt: "2026-05-18T00:00:00.000Z", syncMode: "all" }),
272
+ ],
273
+ };
274
+ expect(lastPullRecord(j, "cmp_indigo")?.syncMode).toBe("shared");
275
+ });
276
+
277
+ it("lastPullRecord filters by companyUid", () => {
278
+ const j: SyncJournal = {
279
+ version: "2",
280
+ lastSync: "",
281
+ files: {},
282
+ pulls: [
283
+ pull({ companyUid: "cmp_a", completedAt: "2026-05-20T00:00:00.000Z" }),
284
+ pull({ companyUid: "cmp_b", completedAt: "2026-05-21T00:00:00.000Z" }),
285
+ ],
286
+ };
287
+ expect(lastPullRecord(j, "cmp_a")?.completedAt).toBe(
288
+ "2026-05-20T00:00:00.000Z",
289
+ );
290
+ });
291
+ });
292
+
293
+ describe("tombstoneEntry + isTombstone", () => {
294
+ it("marks an entry with removedAt + removedReason", () => {
295
+ const j: SyncJournal = {
296
+ version: "2",
297
+ lastSync: "",
298
+ files: {
299
+ "a.md": {
300
+ hash: "h",
301
+ size: 1,
302
+ syncedAt: "2026-05-01T00:00:00.000Z",
303
+ direction: "down",
304
+ },
305
+ },
306
+ pulls: [],
307
+ };
308
+ tombstoneEntry(j, "a.md", "scope_shrink", "2026-05-20T00:00:00.000Z");
309
+ expect(j.files["a.md"]?.removedAt).toBe("2026-05-20T00:00:00.000Z");
310
+ expect(j.files["a.md"]?.removedReason).toBe("scope_shrink");
311
+ expect(isTombstone(j.files["a.md"])).toBe(true);
312
+ });
313
+
314
+ it("is a no-op for missing entries", () => {
315
+ const j: SyncJournal = {
316
+ version: "2",
317
+ lastSync: "",
318
+ files: {},
319
+ pulls: [],
320
+ };
321
+ expect(() =>
322
+ tombstoneEntry(j, "missing.md", "scope_shrink"),
323
+ ).not.toThrow();
324
+ });
325
+
326
+ it("preserves the original hash/size/syncedAt (forensic trail)", () => {
327
+ const j: SyncJournal = {
328
+ version: "2",
329
+ lastSync: "",
330
+ files: {
331
+ "a.md": {
332
+ hash: "old-hash",
333
+ size: 42,
334
+ syncedAt: "2026-05-01T00:00:00.000Z",
335
+ direction: "down",
336
+ },
337
+ },
338
+ pulls: [],
339
+ };
340
+ tombstoneEntry(j, "a.md", "scope_shrink");
341
+ expect(j.files["a.md"]?.hash).toBe("old-hash");
342
+ expect(j.files["a.md"]?.size).toBe(42);
343
+ });
344
+ });
345
+
346
+ describe("gcTombstones", () => {
347
+ it("removes tombstones older than TOMBSTONE_TTL_MS", () => {
348
+ const now = Date.parse("2026-06-01T00:00:00.000Z");
349
+ const old = new Date(now - TOMBSTONE_TTL_MS - 1000).toISOString();
350
+ const fresh = new Date(now - 1000).toISOString();
351
+ const j: SyncJournal = {
352
+ version: "2",
353
+ lastSync: "",
354
+ files: {
355
+ "old-tombstone.md": {
356
+ hash: "h",
357
+ size: 1,
358
+ syncedAt: "2026-04-01T00:00:00.000Z",
359
+ direction: "down",
360
+ removedAt: old,
361
+ removedReason: "scope_shrink",
362
+ },
363
+ "fresh-tombstone.md": {
364
+ hash: "h",
365
+ size: 1,
366
+ syncedAt: "2026-05-01T00:00:00.000Z",
367
+ direction: "down",
368
+ removedAt: fresh,
369
+ removedReason: "scope_shrink",
370
+ },
371
+ "live.md": {
372
+ hash: "h",
373
+ size: 1,
374
+ syncedAt: "2026-05-01T00:00:00.000Z",
375
+ direction: "down",
376
+ },
377
+ },
378
+ pulls: [],
379
+ };
380
+ const removed = gcTombstones(j, now);
381
+ expect(removed).toBe(1);
382
+ expect(j.files["old-tombstone.md"]).toBeUndefined();
383
+ expect(j.files["fresh-tombstone.md"]).toBeDefined();
384
+ expect(j.files["live.md"]).toBeDefined();
385
+ });
386
+
387
+ it("never removes non-tombstone entries", () => {
388
+ const j: SyncJournal = {
389
+ version: "2",
390
+ lastSync: "",
391
+ files: {
392
+ "live.md": {
393
+ hash: "h",
394
+ size: 1,
395
+ syncedAt: "1970-01-01T00:00:00.000Z",
396
+ direction: "down",
397
+ },
398
+ },
399
+ pulls: [],
400
+ };
401
+ expect(gcTombstones(j, Date.now())).toBe(0);
402
+ expect(j.files["live.md"]).toBeDefined();
403
+ });
404
+
405
+ it("ignores tombstones with un-parseable removedAt", () => {
406
+ const j: SyncJournal = {
407
+ version: "2",
408
+ lastSync: "",
409
+ files: {
410
+ "bogus.md": {
411
+ hash: "h",
412
+ size: 1,
413
+ syncedAt: "",
414
+ direction: "down",
415
+ removedAt: "not-a-date",
416
+ removedReason: "scope_shrink",
417
+ },
418
+ },
419
+ pulls: [],
420
+ };
421
+ expect(gcTombstones(j, Date.now())).toBe(0);
422
+ expect(j.files["bogus.md"]).toBeDefined();
423
+ });
424
+ });
146
425
  });
package/src/journal.ts CHANGED
@@ -17,7 +17,13 @@ import * as fs from "fs";
17
17
  import * as os from "os";
18
18
  import * as path from "path";
19
19
  import * as crypto from "crypto";
20
- import type { SyncJournal, JournalEntry } from "./types.js";
20
+ import type { SyncJournal, JournalEntry, PullRecord } from "./types.js";
21
+
22
+ /** Tombstone retention. 30 days in milliseconds — roughly two release cycles. */
23
+ export const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
24
+
25
+ /** Current journal schema version written by all v2-aware writers. */
26
+ export const JOURNAL_VERSION_CURRENT = "2" as const;
21
27
 
22
28
  const JOURNAL_FILE_PREFIX = "sync-journal.";
23
29
  const JOURNAL_FILE_SUFFIX = ".json";
@@ -54,16 +60,54 @@ export function getJournalPath(slug: string): string {
54
60
  );
55
61
  }
56
62
 
63
+ /**
64
+ * Read a per-company journal from disk.
65
+ *
66
+ * Back-compat (US-005, v1 → v2): a v1 file on disk is returned as-is with
67
+ * `version: "1"` and no `pulls` field. The in-place migration to v2 happens
68
+ * the first time `writeJournal` runs — `migrateToV2` ensures any journal
69
+ * passed to the writer carries `version: "2"`, `pulls: []`, and the rest of
70
+ * the v2 shape. This keeps `readJournal` deterministic + cheap and confines
71
+ * the side effect (schema bump on disk) to writes.
72
+ *
73
+ * When the file doesn't exist, we return a fresh v2 journal directly — new
74
+ * installs never pass through v1 on disk.
75
+ */
57
76
  export function readJournal(slug: string): SyncJournal {
58
77
  const journalPath = getJournalPath(slug);
59
78
  if (fs.existsSync(journalPath)) {
60
79
  const content = fs.readFileSync(journalPath, "utf-8");
61
80
  return JSON.parse(content) as SyncJournal;
62
81
  }
63
- return { version: "1", lastSync: "", files: {} };
82
+ return { version: JOURNAL_VERSION_CURRENT, lastSync: "", files: {}, pulls: [] };
64
83
  }
65
84
 
85
+ /**
86
+ * Coerce any-version journal into a v2 shape. Idempotent for v2 inputs.
87
+ * Mutates the input and returns it for chainable use. Call this immediately
88
+ * after `readJournal` if your code-path needs the v2 fields.
89
+ *
90
+ * v1 → v2 contract: every existing `files[]` entry is preserved as-is; no
91
+ * tombstone fields are inserted (legacy entries are NOT scope-shrink
92
+ * tombstones). `pulls` becomes `[]` (empty history → treat last scope as
93
+ * "all" in the scope-shrink algorithm).
94
+ */
95
+ export function migrateToV2(journal: SyncJournal): SyncJournal {
96
+ if (journal.version === "2" && Array.isArray(journal.pulls)) {
97
+ return journal;
98
+ }
99
+ journal.version = JOURNAL_VERSION_CURRENT;
100
+ if (!Array.isArray(journal.pulls)) journal.pulls = [];
101
+ return journal;
102
+ }
103
+
104
+ /**
105
+ * Write a journal to disk, migrating to the current schema version in-place.
106
+ * `migrateToV2` mutates the passed-in object — callers that hold a reference
107
+ * after the write will see the v2 shape.
108
+ */
66
109
  export function writeJournal(slug: string, journal: SyncJournal): void {
110
+ migrateToV2(journal);
67
111
  const journalPath = getJournalPath(slug);
68
112
  fs.mkdirSync(path.dirname(journalPath), { recursive: true });
69
113
  fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
@@ -151,3 +195,124 @@ export function removeEntry(
151
195
  ): void {
152
196
  delete journal.files[relativePath];
153
197
  }
198
+
199
+ // ─── Journal v2 (US-005): pulls, tombstones, GC ─────────────────────────────
200
+
201
+ /** Crockford base32 alphabet (ULID-compatible). */
202
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
203
+
204
+ /**
205
+ * Generate a ULID-shaped 26-char identifier without adding a runtime dep.
206
+ * Format: 10-char base32 of the current millisecond timestamp + 16-char
207
+ * base32 of random bytes. Lexically sortable, time-prefixed — same property
208
+ * that makes ULIDs useful for `pulls[]` ordering.
209
+ *
210
+ * We don't need full ULID spec compliance (monotonic counter, randomness
211
+ * spec) — just sortable + collision-resistant enough that two pulls
212
+ * issued in the same millisecond by different processes don't clash.
213
+ * 80 bits of randomness is plenty.
214
+ */
215
+ export function generatePullId(now: number = Date.now()): string {
216
+ let time = now;
217
+ const timeChars: string[] = [];
218
+ for (let i = 0; i < 10; i++) {
219
+ timeChars.unshift(CROCKFORD[time % 32]!);
220
+ time = Math.floor(time / 32);
221
+ }
222
+ const randBytes = crypto.randomBytes(10); // 80 bits
223
+ const randChars: string[] = [];
224
+ // Encode 10 random bytes (80 bits) into 16 base32 chars.
225
+ let buf = 0;
226
+ let bits = 0;
227
+ for (let i = 0; i < randBytes.length; i++) {
228
+ buf = (buf << 8) | randBytes[i]!;
229
+ bits += 8;
230
+ while (bits >= 5) {
231
+ bits -= 5;
232
+ randChars.push(CROCKFORD[(buf >> bits) & 0x1f]!);
233
+ }
234
+ }
235
+ if (bits > 0) {
236
+ randChars.push(CROCKFORD[(buf << (5 - bits)) & 0x1f]!);
237
+ }
238
+ return timeChars.join("") + randChars.slice(0, 16).join("");
239
+ }
240
+
241
+ /**
242
+ * Find the most-recent `PullRecord` for a company in the journal. Returns
243
+ * `undefined` when no record exists — scope-shrink callers treat that as
244
+ * "no prior scope; nothing to shrink".
245
+ *
246
+ * Order by `completedAt` descending — `pullId` is lexically sortable but
247
+ * `completedAt` is what semantically represents "most recent successful
248
+ * pull state at last close".
249
+ */
250
+ export function lastPullRecord(
251
+ journal: SyncJournal,
252
+ companyUid: string,
253
+ ): PullRecord | undefined {
254
+ if (!journal.pulls || journal.pulls.length === 0) return undefined;
255
+ let best: PullRecord | undefined;
256
+ for (const p of journal.pulls) {
257
+ if (p.companyUid !== companyUid) continue;
258
+ if (!best || p.completedAt > best.completedAt) best = p;
259
+ }
260
+ return best;
261
+ }
262
+
263
+ /** Append a `PullRecord` (mutates `journal.pulls`). */
264
+ export function appendPullRecord(
265
+ journal: SyncJournal,
266
+ record: PullRecord,
267
+ ): void {
268
+ migrateToV2(journal);
269
+ journal.pulls!.push(record);
270
+ }
271
+
272
+ /**
273
+ * Write a journal tombstone entry for `relativePath`. Used by the scope-
274
+ * shrink algorithm (US-005) and by `hq sync narrow --apply` (US-007).
275
+ *
276
+ * Tombstones intentionally keep the old `hash` / `size` / `syncedAt` /
277
+ * `direction` so a recovery flow could see what was there before pruning.
278
+ * They are GC'd after `TOMBSTONE_TTL_MS` via `gcTombstones`.
279
+ */
280
+ export function tombstoneEntry(
281
+ journal: SyncJournal,
282
+ relativePath: string,
283
+ reason: "scope_shrink" | "narrow_apply" | "manual",
284
+ now: string = new Date().toISOString(),
285
+ ): void {
286
+ const entry = journal.files[relativePath];
287
+ if (!entry) return;
288
+ entry.removedAt = now;
289
+ entry.removedReason = reason;
290
+ }
291
+
292
+ /** True if the entry is a tombstone (set by `tombstoneEntry`). */
293
+ export function isTombstone(entry: JournalEntry | undefined): boolean {
294
+ return !!entry && typeof entry.removedAt === "string";
295
+ }
296
+
297
+ /**
298
+ * Garbage-collect tombstones older than `TOMBSTONE_TTL_MS` from
299
+ * `journal.files`. Returns the number removed. Cheap — single pass over
300
+ * the files map, no I/O. Safe to call at the start AND end of every
301
+ * `pullAll` per-company leg; both runs are idempotent.
302
+ */
303
+ export function gcTombstones(
304
+ journal: SyncJournal,
305
+ now: number = Date.now(),
306
+ ): number {
307
+ let removed = 0;
308
+ for (const [path, entry] of Object.entries(journal.files)) {
309
+ if (!entry.removedAt) continue;
310
+ const removedTime = Date.parse(entry.removedAt);
311
+ if (Number.isNaN(removedTime)) continue;
312
+ if (now - removedTime > TOMBSTONE_TTL_MS) {
313
+ delete journal.files[path];
314
+ removed++;
315
+ }
316
+ }
317
+ return removed;
318
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { coalescePrefixes, isCoveredByAny } from "./prefix-coalesce.js";
3
+
4
+ describe("coalescePrefixes", () => {
5
+ it("returns empty for empty input", () => {
6
+ expect(coalescePrefixes([])).toEqual([]);
7
+ });
8
+
9
+ it("dedupes identical inputs", () => {
10
+ expect(coalescePrefixes(["a/", "a/"])).toEqual(["a/"]);
11
+ });
12
+
13
+ it("collapses nested prefixes into the broader one", () => {
14
+ expect(coalescePrefixes(["a/", "a/b/"])).toEqual(["a/"]);
15
+ });
16
+
17
+ it("collapses deeply nested chains", () => {
18
+ expect(coalescePrefixes(["a/", "a/b/", "a/b/c/", "a/b/c/d/"])).toEqual([
19
+ "a/",
20
+ ]);
21
+ });
22
+
23
+ it("keeps sibling (overlapping but non-nested) prefixes", () => {
24
+ expect(coalescePrefixes(["a/b/", "a/c/"])).toEqual(["a/b/", "a/c/"]);
25
+ });
26
+
27
+ it("mixes nested + sibling correctly", () => {
28
+ expect(
29
+ coalescePrefixes([
30
+ "companies/indigo/meetings/",
31
+ "companies/indigo/meetings/2026/",
32
+ "companies/indigo/scratch/jacob/",
33
+ ]),
34
+ ).toEqual([
35
+ "companies/indigo/meetings/",
36
+ "companies/indigo/scratch/jacob/",
37
+ ]);
38
+ });
39
+
40
+ it("is case-sensitive (S3 keys are case-sensitive)", () => {
41
+ // 'A/' and 'a/' do not cover each other — both must be kept.
42
+ const result = coalescePrefixes(["A/", "a/"]);
43
+ expect(result.sort()).toEqual(["A/", "a/"]);
44
+ });
45
+
46
+ it("returns prefixes sorted lexicographically (deterministic for journal)", () => {
47
+ expect(coalescePrefixes(["c/", "a/", "b/"])).toEqual(["a/", "b/", "c/"]);
48
+ });
49
+
50
+ it("drops empty-string entries (would otherwise cover everything)", () => {
51
+ expect(coalescePrefixes(["", "a/", ""])).toEqual(["a/"]);
52
+ });
53
+
54
+ it("is a pure function — input array is not mutated", () => {
55
+ const input = ["a/b/", "a/"];
56
+ const snapshot = [...input];
57
+ coalescePrefixes(input);
58
+ expect(input).toEqual(snapshot);
59
+ });
60
+
61
+ it("handles a single prefix unchanged", () => {
62
+ expect(coalescePrefixes(["companies/indigo/"])).toEqual([
63
+ "companies/indigo/",
64
+ ]);
65
+ });
66
+
67
+ it("does not treat 'ab/' as nested under 'a/' lexically (literal startsWith)", () => {
68
+ // `'ab/'.startsWith('a/')` is FALSE — `a/` would have to be a true
69
+ // path-segment ancestor. (Callers should pass trailing-slash-bounded
70
+ // prefixes — the grants endpoint always does.)
71
+ expect(coalescePrefixes(["a/", "ab/"]).sort()).toEqual(["a/", "ab/"]);
72
+ });
73
+ });
74
+
75
+ describe("isCoveredByAny", () => {
76
+ it("returns true when a prefix covers the path", () => {
77
+ expect(isCoveredByAny("a/b/c.md", ["a/"])).toBe(true);
78
+ });
79
+
80
+ it("returns false when no prefix covers the path", () => {
81
+ expect(isCoveredByAny("a/b/c.md", ["x/", "y/"])).toBe(false);
82
+ });
83
+
84
+ it("returns false on empty prefix set", () => {
85
+ expect(isCoveredByAny("a/b/c.md", [])).toBe(false);
86
+ });
87
+
88
+ it("treats exact match as covered", () => {
89
+ expect(isCoveredByAny("a/b/", ["a/b/"])).toBe(true);
90
+ });
91
+
92
+ it("is case-sensitive", () => {
93
+ expect(isCoveredByAny("A/b/c.md", ["a/"])).toBe(false);
94
+ });
95
+ });