@indigoai-us/hq-cloud 6.11.8 → 6.11.9
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/personal-vault.d.ts +24 -0
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +36 -1
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.js +46 -1
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/sync/event-sync.test.js +18 -0
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/feature-flags.test.js +37 -4
- package/dist/sync/feature-flags.test.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/logger.test.js +1 -0
- package/dist/sync/logger.test.js.map +1 -1
- package/dist/sync/metrics.test.js +1 -0
- package/dist/sync/metrics.test.js.map +1 -1
- package/dist/sync/push-event.d.ts +23 -11
- package/dist/sync/push-event.d.ts.map +1 -1
- package/dist/sync/push-event.js +15 -8
- package/dist/sync/push-event.js.map +1 -1
- package/dist/sync/push-event.test.js +39 -3
- package/dist/sync/push-event.test.js.map +1 -1
- package/dist/sync/push-receiver.test.js +1 -0
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +25 -9
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +65 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/personal-vault.test.ts +56 -0
- package/src/personal-vault.ts +36 -1
- package/src/sync/event-sync.test.ts +21 -0
- package/src/sync/feature-flags.test.ts +40 -4
- package/src/sync/index.ts +5 -1
- package/src/sync/logger.test.ts +1 -0
- package/src/sync/metrics.test.ts +1 -0
- package/src/sync/push-event.test.ts +45 -3
- package/src/sync/push-event.ts +28 -12
- package/src/sync/push-receiver.test.ts +1 -0
- package/src/watcher.test.ts +81 -0
- package/src/watcher.ts +27 -9
- package/test/e2e/sync/cross-tenant-isolation.test.ts +2 -0
package/src/sync/push-event.ts
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
* PushEvent — the wire-shared payload exchanged between every link in the
|
|
3
3
|
* event-driven-hq-cloud-sync pipeline.
|
|
4
4
|
*
|
|
5
|
-
* Producers (the watcher) emit one PushEvent per local content change
|
|
5
|
+
* Producers (the watcher) emit one PushEvent per local content change or
|
|
6
|
+
* delete tombstone.
|
|
6
7
|
* Consumers (the push endpoint / receiver / coalescer) decode the payload,
|
|
7
8
|
* validate it, and act on it. Because the same shape crosses a network
|
|
8
9
|
* boundary, it has its own dedicated module.
|
|
9
10
|
*
|
|
10
11
|
* Conventions
|
|
11
12
|
* ───────────
|
|
12
|
-
* - `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - `kind` is `"upsert"` for content changes and `"delete"` for delete
|
|
14
|
+
* tombstones. Missing `kind` defaults to `"upsert"` for older producers.
|
|
15
|
+
* - `contentHash` is required for upserts and absent for delete tombstones.
|
|
16
|
+
* It is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>` prefix lets a
|
|
17
|
+
* future hash migration ship without breaking the wire format — consumers
|
|
18
|
+
* can branch on the prefix and fall back to refusing unknown algorithms.
|
|
16
19
|
* - `mtime` and `eventTimestamp` are strict ISO-8601 datetime strings.
|
|
17
20
|
* `mtime` is the filesystem modification time of the source file at the
|
|
18
21
|
* moment of capture; `eventTimestamp` is when the watcher emitted the
|
|
@@ -59,15 +62,18 @@ export const PushEventSchema = z
|
|
|
59
62
|
relativePath: z
|
|
60
63
|
.string()
|
|
61
64
|
.min(1, "relativePath must be a non-empty string"),
|
|
65
|
+
kind: z.enum(["upsert", "delete"]).optional().default("upsert"),
|
|
62
66
|
contentHash: z
|
|
63
67
|
.string()
|
|
64
68
|
.regex(
|
|
65
69
|
CONTENT_HASH_PATTERN,
|
|
66
70
|
"contentHash must match `sha256:<64 lowercase hex>`",
|
|
67
|
-
)
|
|
71
|
+
)
|
|
72
|
+
.optional(),
|
|
68
73
|
mtime: z
|
|
69
74
|
.string()
|
|
70
|
-
.regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime")
|
|
75
|
+
.regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime")
|
|
76
|
+
.optional(),
|
|
71
77
|
originDeviceId: z
|
|
72
78
|
.string()
|
|
73
79
|
.min(1, "originDeviceId must be a non-empty string"),
|
|
@@ -91,13 +97,23 @@ export const PushEventSchema = z
|
|
|
91
97
|
})
|
|
92
98
|
// `.strip()` is the zod 4 default; called explicitly here so the intent is
|
|
93
99
|
// obvious to future readers. Unknown keys MUST NOT throw — see module JSDoc.
|
|
94
|
-
.strip()
|
|
100
|
+
.strip()
|
|
101
|
+
.refine(
|
|
102
|
+
(e) => e.kind === "delete" || (e.contentHash != null && e.mtime != null),
|
|
103
|
+
{ message: "upsert requires contentHash and mtime" },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The canonical decoded PushEvent type. `kind` is always present after decode;
|
|
108
|
+
* `contentHash` and `mtime` are present for upserts and absent for deletes.
|
|
109
|
+
*/
|
|
110
|
+
export type PushEvent = z.output<typeof PushEventSchema>;
|
|
95
111
|
|
|
96
112
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
113
|
+
* Input accepted by the schema. Kept public so encode callers can hand in
|
|
114
|
+
* legacy upsert payloads where `kind` is absent.
|
|
99
115
|
*/
|
|
100
|
-
export type
|
|
116
|
+
export type PushEventInput = z.input<typeof PushEventSchema>;
|
|
101
117
|
|
|
102
118
|
// ─── Errors ────────────────────────────────────────────────────────────────
|
|
103
119
|
|
|
@@ -145,7 +161,7 @@ export class PushEventDecodeError extends Error {
|
|
|
145
161
|
* Extra keys on `event` are dropped — the returned JSON string contains
|
|
146
162
|
* only the declared PushEvent fields.
|
|
147
163
|
*/
|
|
148
|
-
export function encodePushEvent(event:
|
|
164
|
+
export function encodePushEvent(event: PushEventInput): string {
|
|
149
165
|
const parsed = PushEventSchema.safeParse(event);
|
|
150
166
|
if (!parsed.success) {
|
|
151
167
|
throw new PushEventDecodeError(
|
|
@@ -45,6 +45,7 @@ const QUEUE_URL = "https://sqs.us-east-1.amazonaws.com/123456789012/sync-push-in
|
|
|
45
45
|
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
46
46
|
return {
|
|
47
47
|
relativePath: "companies/indigo/notes.md",
|
|
48
|
+
kind: "upsert",
|
|
48
49
|
contentHash:
|
|
49
50
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
50
51
|
mtime: "2026-05-21T12:34:56.000Z",
|
package/src/watcher.test.ts
CHANGED
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
WatchPushDriver,
|
|
8
8
|
TreeWatcher,
|
|
9
9
|
createWatchPathFilter,
|
|
10
|
+
PushEventEmitter,
|
|
10
11
|
} from "./watcher.js";
|
|
12
|
+
import { StaticFlagProvider } from "./sync/feature-flags.js";
|
|
13
|
+
import type { PushEvent } from "./sync/push-event.js";
|
|
14
|
+
import type { PushTransport } from "./sync/push-transport.js";
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
17
|
* US-001 — Phase 1 test harness: watch-triggered push seam + latency assertion.
|
|
@@ -427,3 +431,80 @@ describe("US-002: TreeWatcher — lifecycle (real chokidar over a temp dir)", ()
|
|
|
427
431
|
w.dispose();
|
|
428
432
|
}, 10_000);
|
|
429
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("PushEventEmitter — directory and delete tombstone handling", () => {
|
|
436
|
+
let dir: string;
|
|
437
|
+
|
|
438
|
+
beforeEach(() => {
|
|
439
|
+
dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "pushemit-")));
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
afterEach(() => {
|
|
443
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
function makeEmitter(opts?: {
|
|
447
|
+
published?: PushEvent[];
|
|
448
|
+
onError?: ReturnType<typeof vi.fn>;
|
|
449
|
+
getSequenceNumber?: () => number;
|
|
450
|
+
}): PushEventEmitter {
|
|
451
|
+
const published = opts?.published ?? [];
|
|
452
|
+
const transport: PushTransport = {
|
|
453
|
+
start: async () => {},
|
|
454
|
+
dispose: async () => {},
|
|
455
|
+
connected: true,
|
|
456
|
+
publish: async (event) => {
|
|
457
|
+
published.push(event);
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
return new PushEventEmitter({
|
|
461
|
+
originTenantId: "tenant-indigo",
|
|
462
|
+
originDeviceId: "device-a",
|
|
463
|
+
transport,
|
|
464
|
+
flagProvider: new StaticFlagProvider(["tenant-indigo"]),
|
|
465
|
+
now: () => new Date("2026-06-18T12:00:00.000Z"),
|
|
466
|
+
getSequenceNumber: opts?.getSequenceNumber,
|
|
467
|
+
onError: opts?.onError,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
it("skips directory changes silently without publishing or reporting an error", async () => {
|
|
472
|
+
const published: PushEvent[] = [];
|
|
473
|
+
const onError = vi.fn();
|
|
474
|
+
const subdir = path.join(dir, "folder");
|
|
475
|
+
fs.mkdirSync(subdir);
|
|
476
|
+
const emitter = makeEmitter({ published, onError });
|
|
477
|
+
|
|
478
|
+
await emitter.emitForBatch({ paths: new Map([[subdir, "folder"]]) });
|
|
479
|
+
|
|
480
|
+
expect(published).toEqual([]);
|
|
481
|
+
expect(onError).not.toHaveBeenCalled();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("emits and publishes a delete tombstone when the changed file is gone", async () => {
|
|
485
|
+
const published: PushEvent[] = [];
|
|
486
|
+
const onError = vi.fn();
|
|
487
|
+
const emitter = makeEmitter({
|
|
488
|
+
published,
|
|
489
|
+
onError,
|
|
490
|
+
getSequenceNumber: () => 99,
|
|
491
|
+
});
|
|
492
|
+
const missing = path.join(dir, "deleted.md");
|
|
493
|
+
|
|
494
|
+
await emitter.emitForBatch({ paths: new Map([[missing, "deleted.md"]]) });
|
|
495
|
+
|
|
496
|
+
expect(onError).not.toHaveBeenCalled();
|
|
497
|
+
expect(published).toEqual([
|
|
498
|
+
{
|
|
499
|
+
kind: "delete",
|
|
500
|
+
relativePath: "deleted.md",
|
|
501
|
+
originDeviceId: "device-a",
|
|
502
|
+
originTenantId: "tenant-indigo",
|
|
503
|
+
sequenceNumber: 99,
|
|
504
|
+
eventTimestamp: "2026-06-18T12:00:00.000Z",
|
|
505
|
+
},
|
|
506
|
+
]);
|
|
507
|
+
expect(published[0]).not.toHaveProperty("contentHash");
|
|
508
|
+
expect(published[0]).not.toHaveProperty("mtime");
|
|
509
|
+
});
|
|
510
|
+
});
|
package/src/watcher.ts
CHANGED
|
@@ -862,11 +862,12 @@ export class PushEventEmitter {
|
|
|
862
862
|
): Promise<void> {
|
|
863
863
|
let event: PushEvent | undefined;
|
|
864
864
|
try {
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
865
|
+
const st = await stat(absolutePath);
|
|
866
|
+
if (st.isDirectory()) return;
|
|
867
|
+
|
|
868
|
+
const contentHash = await computeContentHash(absolutePath);
|
|
869
869
|
event = {
|
|
870
|
+
kind: "upsert",
|
|
870
871
|
relativePath,
|
|
871
872
|
contentHash,
|
|
872
873
|
mtime: st.mtime.toISOString(),
|
|
@@ -876,12 +877,28 @@ export class PushEventEmitter {
|
|
|
876
877
|
eventTimestamp: this.now().toISOString(),
|
|
877
878
|
};
|
|
878
879
|
} catch (err) {
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
880
|
+
const code =
|
|
881
|
+
err && typeof err === "object" && "code" in err
|
|
882
|
+
? (err as { code?: string }).code
|
|
883
|
+
: undefined;
|
|
884
|
+
if (code !== "ENOENT") {
|
|
885
|
+
// Genuine stat/read failure. Surface, don't crash.
|
|
886
|
+
this.onError(err instanceof Error ? err : new Error(String(err)), {
|
|
887
|
+
relativePath,
|
|
888
|
+
});
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Deleted before/during capture: publish a tombstone so peers run a
|
|
893
|
+
// targeted pull and let the vault-confirmed tombstone path remove it.
|
|
894
|
+
event = {
|
|
895
|
+
kind: "delete",
|
|
882
896
|
relativePath,
|
|
883
|
-
|
|
884
|
-
|
|
897
|
+
originDeviceId: this.originDeviceId,
|
|
898
|
+
originTenantId: this.originTenantId,
|
|
899
|
+
sequenceNumber: this.nextSeq(),
|
|
900
|
+
eventTimestamp: this.now().toISOString(),
|
|
901
|
+
};
|
|
885
902
|
}
|
|
886
903
|
|
|
887
904
|
// US-011: 1st link of the 3-log diagnostic chain. Stamps the same
|
|
@@ -891,6 +908,7 @@ export class PushEventEmitter {
|
|
|
891
908
|
this.logger?.info(
|
|
892
909
|
{
|
|
893
910
|
event: "watcher.emit",
|
|
911
|
+
kind: event.kind,
|
|
894
912
|
originTenantId: event.originTenantId,
|
|
895
913
|
originDeviceId: event.originDeviceId,
|
|
896
914
|
relativePath: event.relativePath,
|
|
@@ -215,6 +215,7 @@ function makeEvent(opts: {
|
|
|
215
215
|
}): PushEvent {
|
|
216
216
|
return {
|
|
217
217
|
relativePath: opts.relativePath,
|
|
218
|
+
kind: "upsert",
|
|
218
219
|
contentHash: ZERO_HASH,
|
|
219
220
|
mtime: "2026-05-21T12:00:00.000Z",
|
|
220
221
|
originDeviceId: opts.deviceId,
|
|
@@ -540,6 +541,7 @@ describe("US-018: receive path with vended credentials (isolation extension)", (
|
|
|
540
541
|
function phase3Event(overrides: Partial<PushEvent>): PushEvent {
|
|
541
542
|
return {
|
|
542
543
|
relativePath: "companies/indigo/docs/x.md",
|
|
544
|
+
kind: "upsert",
|
|
543
545
|
contentHash: `sha256:${"b".repeat(64)}`,
|
|
544
546
|
mtime: "2026-06-10T12:00:00.000Z",
|
|
545
547
|
originDeviceId: "peer",
|