@indigoai-us/hq-cloud 6.11.7 → 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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +29 -15
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +56 -7
- package/dist/cli/share.test.js.map +1 -1
- 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/cli/share.test.ts +59 -7
- package/src/cli/share.ts +39 -24
- 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
|
@@ -49,6 +49,7 @@ import type { TreeChangeBatch } from "../watcher.js";
|
|
|
49
49
|
function fakePushEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
50
50
|
return {
|
|
51
51
|
relativePath: "personal/notes/a.md",
|
|
52
|
+
kind: "upsert",
|
|
52
53
|
contentHash: `sha256:${"a".repeat(64)}`,
|
|
53
54
|
mtime: "2026-05-21T12:00:00.000Z",
|
|
54
55
|
originDeviceId: "device-1",
|
|
@@ -178,6 +179,34 @@ describe("HttpPushTransport — POST /sync/push", () => {
|
|
|
178
179
|
expect(decodePushEvent(init.body)).toEqual(event);
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
it("POSTs a delete tombstone without contentHash or mtime", async () => {
|
|
183
|
+
const { fetch, calls } = makeFetch({ ok: true, status: 200 });
|
|
184
|
+
const transport = new HttpPushTransport({
|
|
185
|
+
apiUrl: "https://vault-api.example.com/",
|
|
186
|
+
authToken: "tok-123",
|
|
187
|
+
fetchImpl: fetch,
|
|
188
|
+
});
|
|
189
|
+
const event = fakePushEvent({
|
|
190
|
+
kind: "delete",
|
|
191
|
+
contentHash: undefined,
|
|
192
|
+
mtime: undefined,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await transport.publish(event);
|
|
196
|
+
|
|
197
|
+
expect(calls).toHaveLength(1);
|
|
198
|
+
const body = (calls[0].init as { body: string }).body;
|
|
199
|
+
expect(JSON.parse(body)).toEqual({
|
|
200
|
+
relativePath: "personal/notes/a.md",
|
|
201
|
+
kind: "delete",
|
|
202
|
+
originDeviceId: "device-1",
|
|
203
|
+
originTenantId: "indigo",
|
|
204
|
+
sequenceNumber: 0,
|
|
205
|
+
eventTimestamp: "2026-05-21T12:00:01.000Z",
|
|
206
|
+
});
|
|
207
|
+
expect(decodePushEvent(body)).toEqual(JSON.parse(body));
|
|
208
|
+
});
|
|
209
|
+
|
|
181
210
|
it("resolves the auth token per-request via an async getter (self-heals across refresh)", async () => {
|
|
182
211
|
const { fetch, calls } = makeFetch();
|
|
183
212
|
let n = 0;
|
|
@@ -287,6 +316,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
287
316
|
expect(published).toHaveLength(1);
|
|
288
317
|
const e = published[0];
|
|
289
318
|
expect(e.relativePath).toBe(REL);
|
|
319
|
+
expect(e.kind).toBe("upsert");
|
|
290
320
|
expect(e.originTenantId).toBe("indigo");
|
|
291
321
|
expect(e.originDeviceId).toBe("dev-1");
|
|
292
322
|
const expectedHex = createHash("sha256").update("hello world").digest("hex");
|
|
@@ -350,7 +380,7 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
350
380
|
expect(onError.mock.calls[0][1]).toMatchObject({ relativePath: REL });
|
|
351
381
|
});
|
|
352
382
|
|
|
353
|
-
it("a missing file
|
|
383
|
+
it("a missing file emits a delete tombstone, and other paths still ship", async () => {
|
|
354
384
|
const published: PushEvent[] = [];
|
|
355
385
|
const onError = vi.fn();
|
|
356
386
|
const emitter = new PushEventEmitter({
|
|
@@ -362,9 +392,15 @@ describe("PushEventEmitter — emit, gating, failure handling", () => {
|
|
|
362
392
|
});
|
|
363
393
|
// REL exists; "ghost.md" does not.
|
|
364
394
|
await emitter.emitForBatch(batchFor(REL, "ghost.md"));
|
|
365
|
-
expect(published.map((e) => e.relativePath)).toEqual([
|
|
366
|
-
|
|
367
|
-
|
|
395
|
+
expect(published.map((e) => e.relativePath).sort()).toEqual([
|
|
396
|
+
REL,
|
|
397
|
+
"ghost.md",
|
|
398
|
+
].sort());
|
|
399
|
+
const tombstone = published.find((e) => e.relativePath === "ghost.md");
|
|
400
|
+
expect(tombstone).toMatchObject({ kind: "delete" });
|
|
401
|
+
expect(tombstone).not.toHaveProperty("contentHash");
|
|
402
|
+
expect(tombstone).not.toHaveProperty("mtime");
|
|
403
|
+
expect(onError).not.toHaveBeenCalled();
|
|
368
404
|
});
|
|
369
405
|
|
|
370
406
|
it("end-to-end: emit → HttpPushTransport POST with a valid schema (mocked fetch)", async () => {
|
package/src/sync/index.ts
CHANGED
|
@@ -13,7 +13,11 @@ export {
|
|
|
13
13
|
encodePushEvent,
|
|
14
14
|
decodePushEvent,
|
|
15
15
|
} from "./push-event.js";
|
|
16
|
-
export type {
|
|
16
|
+
export type {
|
|
17
|
+
PushEvent,
|
|
18
|
+
PushEventInput,
|
|
19
|
+
PushEventDecodeIssue,
|
|
20
|
+
} from "./push-event.js";
|
|
17
21
|
|
|
18
22
|
export { NoopPushTransport, HttpPushTransport } from "./push-transport.js";
|
|
19
23
|
export type {
|
package/src/sync/logger.test.ts
CHANGED
|
@@ -70,6 +70,7 @@ async function until(predicate: () => boolean, timeoutMs = 1000): Promise<void>
|
|
|
70
70
|
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
71
71
|
return {
|
|
72
72
|
relativePath: "companies/indigo/notes.md",
|
|
73
|
+
kind: "upsert",
|
|
73
74
|
contentHash:
|
|
74
75
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
75
76
|
mtime: "2026-05-21T12:34:56.000Z",
|
package/src/sync/metrics.test.ts
CHANGED
|
@@ -82,6 +82,7 @@ const QUEUE_URL =
|
|
|
82
82
|
function makeEvent(overrides: Partial<PushEvent> = {}): PushEvent {
|
|
83
83
|
return {
|
|
84
84
|
relativePath: "companies/indigo/notes.md",
|
|
85
|
+
kind: "upsert",
|
|
85
86
|
contentHash:
|
|
86
87
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
87
88
|
mtime: "2026-05-21T12:34:56.000Z",
|
|
@@ -19,12 +19,14 @@ import {
|
|
|
19
19
|
decodePushEvent,
|
|
20
20
|
encodePushEvent,
|
|
21
21
|
type PushEvent,
|
|
22
|
+
type PushEventInput,
|
|
22
23
|
} from "../../src/sync/index.js";
|
|
23
24
|
|
|
24
25
|
// A canonical, valid PushEvent. All other tests derive from this fixture so
|
|
25
26
|
// any single field mutation can't accidentally pass for the wrong reason.
|
|
26
27
|
const validFixture: PushEvent = {
|
|
27
28
|
relativePath: "docs/architecture/overview.md",
|
|
29
|
+
kind: "upsert",
|
|
28
30
|
contentHash:
|
|
29
31
|
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
30
32
|
mtime: "2026-05-18T12:34:56.789Z",
|
|
@@ -34,6 +36,15 @@ const validFixture: PushEvent = {
|
|
|
34
36
|
eventTimestamp: "2026-05-18T12:35:00.000Z",
|
|
35
37
|
};
|
|
36
38
|
|
|
39
|
+
const deleteFixture: PushEvent = {
|
|
40
|
+
relativePath: "docs/architecture/overview.md",
|
|
41
|
+
kind: "delete",
|
|
42
|
+
originDeviceId: "device-laptop-a",
|
|
43
|
+
originTenantId: "tenant-indigo",
|
|
44
|
+
sequenceNumber: 43,
|
|
45
|
+
eventTimestamp: "2026-05-18T12:36:00.000Z",
|
|
46
|
+
};
|
|
47
|
+
|
|
37
48
|
describe("PushEvent encode/decode", () => {
|
|
38
49
|
// ── Acceptance #1: round-trip ──────────────────────────────────────────
|
|
39
50
|
it("round-trips a known-good fixture through encode → decode", () => {
|
|
@@ -55,6 +66,21 @@ describe("PushEvent encode/decode", () => {
|
|
|
55
66
|
expect(fromString).toEqual(validFixture);
|
|
56
67
|
});
|
|
57
68
|
|
|
69
|
+
it("round-trips a delete tombstone without contentHash or mtime", () => {
|
|
70
|
+
const encoded = encodePushEvent(deleteFixture);
|
|
71
|
+
expect(JSON.parse(encoded)).toEqual(deleteFixture);
|
|
72
|
+
expect(decodePushEvent(encoded)).toEqual(deleteFixture);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("defaults an absent kind to upsert for backward compatibility", () => {
|
|
76
|
+
const legacy = { ...validFixture } as PushEventInput;
|
|
77
|
+
delete legacy.kind;
|
|
78
|
+
|
|
79
|
+
const decoded = decodePushEvent(legacy);
|
|
80
|
+
expect(decoded).toEqual(validFixture);
|
|
81
|
+
expect(JSON.parse(encodePushEvent(legacy))).toEqual(validFixture);
|
|
82
|
+
});
|
|
83
|
+
|
|
58
84
|
// ── Acceptance #2: unknown fields dropped ──────────────────────────────
|
|
59
85
|
it("drops unknown extra fields silently (does not throw)", () => {
|
|
60
86
|
const withExtras = {
|
|
@@ -80,8 +106,6 @@ describe("PushEvent encode/decode", () => {
|
|
|
80
106
|
// ── Acceptance #3: missing required fields throw typed error ───────────
|
|
81
107
|
it.each([
|
|
82
108
|
"relativePath",
|
|
83
|
-
"contentHash",
|
|
84
|
-
"mtime",
|
|
85
109
|
"originDeviceId",
|
|
86
110
|
"originTenantId",
|
|
87
111
|
"sequenceNumber",
|
|
@@ -106,6 +130,24 @@ describe("PushEvent encode/decode", () => {
|
|
|
106
130
|
expect(error.issues.some((issue) => issue.path.includes(field))).toBe(true);
|
|
107
131
|
});
|
|
108
132
|
|
|
133
|
+
it("rejects an upsert without contentHash and mtime via the schema refinement", () => {
|
|
134
|
+
let caught: unknown;
|
|
135
|
+
try {
|
|
136
|
+
decodePushEvent({
|
|
137
|
+
...validFixture,
|
|
138
|
+
contentHash: undefined,
|
|
139
|
+
mtime: undefined,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
caught = err;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
146
|
+
const error = caught as PushEventDecodeError;
|
|
147
|
+
expect(error.stage).toBe("schema-validation");
|
|
148
|
+
expect(error.issues.some((issue) => issue.message === "upsert requires contentHash and mtime")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
109
151
|
it("PushEventDecodeError carries the underlying zod issues array", () => {
|
|
110
152
|
// Multiple missing fields → multiple issues, all reachable via `.issues`.
|
|
111
153
|
const sparse = { relativePath: "x.md" } as unknown;
|
|
@@ -117,7 +159,7 @@ describe("PushEvent encode/decode", () => {
|
|
|
117
159
|
}
|
|
118
160
|
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
119
161
|
const error = caught as PushEventDecodeError;
|
|
120
|
-
expect(error.issues.length).toBeGreaterThanOrEqual(
|
|
162
|
+
expect(error.issues.length).toBeGreaterThanOrEqual(4);
|
|
121
163
|
});
|
|
122
164
|
|
|
123
165
|
// ── Supporting wire-contract invariants ────────────────────────────────
|
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",
|