@indigoai-us/hq-cloud 5.24.0 → 5.26.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.
- package/dist/bin/sync-runner.d.ts +151 -17
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +280 -18
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +429 -15
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +9 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +54 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +6 -3
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +21 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/personal-vault-exclusions.d.ts +128 -0
- package/dist/personal-vault-exclusions.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.js +231 -0
- package/dist/personal-vault-exclusions.js.map +1 -0
- package/dist/personal-vault-exclusions.test.d.ts +22 -0
- package/dist/personal-vault-exclusions.test.d.ts.map +1 -0
- package/dist/personal-vault-exclusions.test.js +198 -0
- package/dist/personal-vault-exclusions.test.js.map +1 -0
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +9 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/push-event.d.ts +110 -0
- package/dist/sync/push-event.d.ts.map +1 -0
- package/dist/sync/push-event.js +153 -0
- package/dist/sync/push-event.js.map +1 -0
- package/dist/sync/push-event.test.d.ts +15 -0
- package/dist/sync/push-event.test.d.ts.map +1 -0
- package/dist/sync/push-event.test.js +188 -0
- package/dist/sync/push-event.test.js.map +1 -0
- package/dist/sync/push-transport.d.ts +67 -0
- package/dist/sync/push-transport.d.ts.map +1 -0
- package/dist/sync/push-transport.js +66 -0
- package/dist/sync/push-transport.js.map +1 -0
- package/dist/watcher.d.ts +160 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +298 -0
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.d.ts +2 -0
- package/dist/watcher.test.d.ts.map +1 -0
- package/dist/watcher.test.js +334 -0
- package/dist/watcher.test.js.map +1 -0
- package/package.json +3 -2
- package/src/bin/sync-runner.test.ts +557 -15
- package/src/bin/sync-runner.ts +404 -27
- package/src/cli/share.test.ts +8 -3
- package/src/cli/share.ts +66 -1
- package/src/cli/sync.ts +22 -0
- package/src/index.ts +27 -0
- package/src/personal-vault-exclusions.test.ts +256 -0
- package/src/personal-vault-exclusions.ts +277 -0
- package/src/sync/index.ts +19 -0
- package/src/sync/push-event.test.ts +224 -0
- package/src/sync/push-event.ts +208 -0
- package/src/sync/push-transport.ts +84 -0
- package/src/watcher.test.ts +388 -0
- package/src/watcher.ts +386 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `src/sync/push-event.ts` (US-007 port).
|
|
3
|
+
*
|
|
4
|
+
* Covers the three required acceptance assertions:
|
|
5
|
+
* 1. A known-good fixture round-trips through encode → decode unchanged.
|
|
6
|
+
* 2. Unknown extra fields on the input are dropped silently (no throw).
|
|
7
|
+
* 3. Missing required fields throw a typed `PushEventDecodeError` whose
|
|
8
|
+
* `.issues` exposes the underlying zod issues.
|
|
9
|
+
*
|
|
10
|
+
* Also covers the supporting invariants needed to keep the wire contract
|
|
11
|
+
* stable across the watcher → server → receiver hop: malformed JSON, bad
|
|
12
|
+
* hash/timestamp formats, and the integer/range bounds on `sequenceNumber`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from "vitest";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
PushEventDecodeError,
|
|
19
|
+
decodePushEvent,
|
|
20
|
+
encodePushEvent,
|
|
21
|
+
type PushEvent,
|
|
22
|
+
} from "../../src/sync/index.js";
|
|
23
|
+
|
|
24
|
+
// A canonical, valid PushEvent. All other tests derive from this fixture so
|
|
25
|
+
// any single field mutation can't accidentally pass for the wrong reason.
|
|
26
|
+
const validFixture: PushEvent = {
|
|
27
|
+
relativePath: "docs/architecture/overview.md",
|
|
28
|
+
contentHash:
|
|
29
|
+
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
|
30
|
+
mtime: "2026-05-18T12:34:56.789Z",
|
|
31
|
+
originDeviceId: "device-laptop-a",
|
|
32
|
+
originTenantId: "tenant-indigo",
|
|
33
|
+
sequenceNumber: 42,
|
|
34
|
+
eventTimestamp: "2026-05-18T12:35:00.000Z",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("PushEvent encode/decode", () => {
|
|
38
|
+
// ── Acceptance #1: round-trip ──────────────────────────────────────────
|
|
39
|
+
it("round-trips a known-good fixture through encode → decode", () => {
|
|
40
|
+
const encoded = encodePushEvent(validFixture);
|
|
41
|
+
expect(typeof encoded).toBe("string");
|
|
42
|
+
|
|
43
|
+
const decoded = decodePushEvent(encoded);
|
|
44
|
+
expect(decoded).toEqual(validFixture);
|
|
45
|
+
|
|
46
|
+
// Re-encoding the decoded value must produce byte-identical output —
|
|
47
|
+
// catches accidental field re-ordering or coercion drift.
|
|
48
|
+
expect(encodePushEvent(decoded)).toBe(encoded);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("decodes from a pre-parsed object as well as from a JSON string", () => {
|
|
52
|
+
const fromObject = decodePushEvent({ ...validFixture });
|
|
53
|
+
const fromString = decodePushEvent(JSON.stringify(validFixture));
|
|
54
|
+
expect(fromObject).toEqual(validFixture);
|
|
55
|
+
expect(fromString).toEqual(validFixture);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── Acceptance #2: unknown fields dropped ──────────────────────────────
|
|
59
|
+
it("drops unknown extra fields silently (does not throw)", () => {
|
|
60
|
+
const withExtras = {
|
|
61
|
+
...validFixture,
|
|
62
|
+
// Future producers may add fields like `originAppVersion`; today's
|
|
63
|
+
// consumers must not crash on them.
|
|
64
|
+
originAppVersion: "1.2.3",
|
|
65
|
+
experimentalFlag: true,
|
|
66
|
+
nested: { ignored: "yes" },
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const decoded = decodePushEvent(withExtras);
|
|
70
|
+
expect(decoded).toEqual(validFixture);
|
|
71
|
+
expect(decoded).not.toHaveProperty("originAppVersion");
|
|
72
|
+
expect(decoded).not.toHaveProperty("experimentalFlag");
|
|
73
|
+
expect(decoded).not.toHaveProperty("nested");
|
|
74
|
+
|
|
75
|
+
// Round-tripping through JSON behaves the same way.
|
|
76
|
+
const decodedFromJson = decodePushEvent(JSON.stringify(withExtras));
|
|
77
|
+
expect(decodedFromJson).toEqual(validFixture);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Acceptance #3: missing required fields throw typed error ───────────
|
|
81
|
+
it.each([
|
|
82
|
+
"relativePath",
|
|
83
|
+
"contentHash",
|
|
84
|
+
"mtime",
|
|
85
|
+
"originDeviceId",
|
|
86
|
+
"originTenantId",
|
|
87
|
+
"sequenceNumber",
|
|
88
|
+
"eventTimestamp",
|
|
89
|
+
] as const)("throws PushEventDecodeError when %s is missing", (field) => {
|
|
90
|
+
const partial: Record<string, unknown> = { ...validFixture };
|
|
91
|
+
delete partial[field];
|
|
92
|
+
|
|
93
|
+
let caught: unknown;
|
|
94
|
+
try {
|
|
95
|
+
decodePushEvent(partial);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
caught = err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
101
|
+
const error = caught as PushEventDecodeError;
|
|
102
|
+
expect(error.stage).toBe("schema-validation");
|
|
103
|
+
expect(error.issues.length).toBeGreaterThan(0);
|
|
104
|
+
// The zod issue path must point at the missing field — that's what
|
|
105
|
+
// downstream callers rely on to render structured diagnostics.
|
|
106
|
+
expect(error.issues.some((issue) => issue.path.includes(field))).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("PushEventDecodeError carries the underlying zod issues array", () => {
|
|
110
|
+
// Multiple missing fields → multiple issues, all reachable via `.issues`.
|
|
111
|
+
const sparse = { relativePath: "x.md" } as unknown;
|
|
112
|
+
let caught: unknown;
|
|
113
|
+
try {
|
|
114
|
+
decodePushEvent(sparse);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
caught = err;
|
|
117
|
+
}
|
|
118
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
119
|
+
const error = caught as PushEventDecodeError;
|
|
120
|
+
expect(error.issues.length).toBeGreaterThanOrEqual(6);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── Supporting wire-contract invariants ────────────────────────────────
|
|
124
|
+
it("throws PushEventDecodeError on malformed JSON input", () => {
|
|
125
|
+
let caught: unknown;
|
|
126
|
+
try {
|
|
127
|
+
decodePushEvent("{not valid json");
|
|
128
|
+
} catch (err) {
|
|
129
|
+
caught = err;
|
|
130
|
+
}
|
|
131
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
132
|
+
const error = caught as PushEventDecodeError;
|
|
133
|
+
expect(error.stage).toBe("json-parse");
|
|
134
|
+
expect(error.issues.length).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("surfaces a JSON-parseable non-object payload as schema-validation, not json-parse", () => {
|
|
138
|
+
// `'42'` is syntactically valid JSON, so the parse stage clears; the
|
|
139
|
+
// object-shape check is what fails. Pinning this here keeps the JSDoc
|
|
140
|
+
// contract (see decodePushEvent docs) honest.
|
|
141
|
+
let caught: unknown;
|
|
142
|
+
try {
|
|
143
|
+
decodePushEvent("42");
|
|
144
|
+
} catch (err) {
|
|
145
|
+
caught = err;
|
|
146
|
+
}
|
|
147
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
148
|
+
const error = caught as PushEventDecodeError;
|
|
149
|
+
expect(error.stage).toBe("schema-validation");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it.each([
|
|
153
|
+
["raw hex without `sha256:` prefix", "e".repeat(64)],
|
|
154
|
+
["wrong algorithm prefix", `md5:${"a".repeat(64)}`],
|
|
155
|
+
["uppercase hex", `sha256:${"A".repeat(64)}`],
|
|
156
|
+
["too few hex chars", `sha256:${"a".repeat(63)}`],
|
|
157
|
+
])("rejects contentHash: %s", (_label, badHash) => {
|
|
158
|
+
expect(() =>
|
|
159
|
+
decodePushEvent({ ...validFixture, contentHash: badHash }),
|
|
160
|
+
).toThrow(PushEventDecodeError);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it.each([
|
|
164
|
+
["missing timezone", "2026-05-18T12:34:56.789"],
|
|
165
|
+
["space separator", "2026-05-18 12:34:56Z"],
|
|
166
|
+
["date only", "2026-05-18"],
|
|
167
|
+
])("rejects ISO-8601 timestamps: %s", (_label, badTimestamp) => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
decodePushEvent({ ...validFixture, mtime: badTimestamp }),
|
|
170
|
+
).toThrow(PushEventDecodeError);
|
|
171
|
+
expect(() =>
|
|
172
|
+
decodePushEvent({ ...validFixture, eventTimestamp: badTimestamp }),
|
|
173
|
+
).toThrow(PushEventDecodeError);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("accepts a sequenceNumber of 0 and rejects negative / fractional / oversized values", () => {
|
|
177
|
+
// 0 is allowed — sequence numbers are non-negative, not strictly positive.
|
|
178
|
+
expect(decodePushEvent({ ...validFixture, sequenceNumber: 0 })).toEqual({
|
|
179
|
+
...validFixture,
|
|
180
|
+
sequenceNumber: 0,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(() =>
|
|
184
|
+
decodePushEvent({ ...validFixture, sequenceNumber: -1 }),
|
|
185
|
+
).toThrow(PushEventDecodeError);
|
|
186
|
+
expect(() =>
|
|
187
|
+
decodePushEvent({ ...validFixture, sequenceNumber: 1.5 }),
|
|
188
|
+
).toThrow(PushEventDecodeError);
|
|
189
|
+
expect(() =>
|
|
190
|
+
decodePushEvent({
|
|
191
|
+
...validFixture,
|
|
192
|
+
sequenceNumber: Number.MAX_SAFE_INTEGER + 1,
|
|
193
|
+
}),
|
|
194
|
+
).toThrow(PushEventDecodeError);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("encodePushEvent validates and drops unknown fields from the output", () => {
|
|
198
|
+
// We cast through `unknown` because the public type forbids extra keys —
|
|
199
|
+
// this models a producer that hands us a wider object by mistake.
|
|
200
|
+
const wider = {
|
|
201
|
+
...validFixture,
|
|
202
|
+
stray: "field",
|
|
203
|
+
} as unknown as PushEvent;
|
|
204
|
+
const encoded = encodePushEvent(wider);
|
|
205
|
+
expect(encoded.includes("stray")).toBe(false);
|
|
206
|
+
expect(JSON.parse(encoded)).toEqual(validFixture);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("encodePushEvent throws PushEventDecodeError when input is invalid", () => {
|
|
210
|
+
const bad = { ...validFixture, contentHash: "not-a-hash" } as PushEvent;
|
|
211
|
+
let caught: unknown;
|
|
212
|
+
try {
|
|
213
|
+
encodePushEvent(bad);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
caught = err;
|
|
216
|
+
}
|
|
217
|
+
expect(caught).toBeInstanceOf(PushEventDecodeError);
|
|
218
|
+
const error = caught as PushEventDecodeError;
|
|
219
|
+
expect(error.stage).toBe("schema-validation");
|
|
220
|
+
expect(
|
|
221
|
+
error.issues.some((issue) => issue.path.includes("contentHash")),
|
|
222
|
+
).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PushEvent — the wire-shared payload exchanged between every link in the
|
|
3
|
+
* event-driven-hq-cloud-sync pipeline.
|
|
4
|
+
*
|
|
5
|
+
* Producers (the watcher) emit one PushEvent per local content change.
|
|
6
|
+
* Consumers (the push endpoint / receiver / coalescer) decode the payload,
|
|
7
|
+
* validate it, and act on it. Because the same shape crosses a network
|
|
8
|
+
* boundary, it has its own dedicated module.
|
|
9
|
+
*
|
|
10
|
+
* Conventions
|
|
11
|
+
* ───────────
|
|
12
|
+
* - `contentHash` is `sha256:<64-lowercase-hex>`. The `<algorithm>:<hex>`
|
|
13
|
+
* prefix lets a future hash migration ship without breaking the wire
|
|
14
|
+
* format — consumers can branch on the prefix and fall back to refusing
|
|
15
|
+
* unknown algorithms.
|
|
16
|
+
* - `mtime` and `eventTimestamp` are strict ISO-8601 datetime strings.
|
|
17
|
+
* `mtime` is the filesystem modification time of the source file at the
|
|
18
|
+
* moment of capture; `eventTimestamp` is when the watcher emitted the
|
|
19
|
+
* event. They diverge whenever the watcher coalesces or retries.
|
|
20
|
+
* - `sequenceNumber` is a non-negative safe integer. Monotonicity per
|
|
21
|
+
* `originDeviceId` is a producer-side invariant — a single PushEvent
|
|
22
|
+
* can't be self-monotonic, so the schema only validates the bounds.
|
|
23
|
+
* - Unknown extra fields are dropped silently by `decodePushEvent` to
|
|
24
|
+
* permit forwards-compatible additions on the producer side without
|
|
25
|
+
* forcing every consumer to upgrade in lockstep.
|
|
26
|
+
*
|
|
27
|
+
* Ported from indigoai-us/hq-pro PR #112 (src/sync/push-event.ts) into
|
|
28
|
+
* @indigoai-us/hq-cloud (Path B) per project event-driven-sync-menubar US-007.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
// ─── Constants ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `sha256:` + 64 lowercase hex chars. The algorithm prefix is mandatory so
|
|
37
|
+
* future hash migrations stay non-breaking; consumers can switch on the
|
|
38
|
+
* prefix and reject unknown algorithms explicitly.
|
|
39
|
+
*/
|
|
40
|
+
export const CONTENT_HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strict ISO-8601 datetime regex matching what `z.iso.datetime()` produces.
|
|
44
|
+
*
|
|
45
|
+
* Format: `YYYY-MM-DDTHH:MM:SS(.fff)?(Z|±HH:MM)`
|
|
46
|
+
*/
|
|
47
|
+
export const ISO8601_DATETIME_PATTERN =
|
|
48
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
49
|
+
|
|
50
|
+
// ─── Zod schema ────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Runtime schema for PushEvent. Unknown extra keys are dropped via the zod
|
|
54
|
+
* default `.strip()` behavior — that is load-bearing for forwards
|
|
55
|
+
* compatibility (see module JSDoc).
|
|
56
|
+
*/
|
|
57
|
+
export const PushEventSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
relativePath: z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1, "relativePath must be a non-empty string"),
|
|
62
|
+
contentHash: z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(
|
|
65
|
+
CONTENT_HASH_PATTERN,
|
|
66
|
+
"contentHash must match `sha256:<64 lowercase hex>`",
|
|
67
|
+
),
|
|
68
|
+
mtime: z
|
|
69
|
+
.string()
|
|
70
|
+
.regex(ISO8601_DATETIME_PATTERN, "mtime must be an ISO-8601 datetime"),
|
|
71
|
+
originDeviceId: z
|
|
72
|
+
.string()
|
|
73
|
+
.min(1, "originDeviceId must be a non-empty string"),
|
|
74
|
+
originTenantId: z
|
|
75
|
+
.string()
|
|
76
|
+
.min(1, "originTenantId must be a non-empty string"),
|
|
77
|
+
sequenceNumber: z
|
|
78
|
+
.number()
|
|
79
|
+
.int("sequenceNumber must be an integer")
|
|
80
|
+
.min(0, "sequenceNumber must be non-negative")
|
|
81
|
+
.max(
|
|
82
|
+
Number.MAX_SAFE_INTEGER,
|
|
83
|
+
"sequenceNumber must be <= Number.MAX_SAFE_INTEGER",
|
|
84
|
+
),
|
|
85
|
+
eventTimestamp: z
|
|
86
|
+
.string()
|
|
87
|
+
.regex(
|
|
88
|
+
ISO8601_DATETIME_PATTERN,
|
|
89
|
+
"eventTimestamp must be an ISO-8601 datetime",
|
|
90
|
+
),
|
|
91
|
+
})
|
|
92
|
+
// `.strip()` is the zod 4 default; called explicitly here so the intent is
|
|
93
|
+
// obvious to future readers. Unknown keys MUST NOT throw — see module JSDoc.
|
|
94
|
+
.strip();
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The canonical PushEvent type. All fields are required; producers and
|
|
98
|
+
* consumers share this exact shape.
|
|
99
|
+
*/
|
|
100
|
+
export type PushEvent = z.infer<typeof PushEventSchema>;
|
|
101
|
+
|
|
102
|
+
// ─── Errors ────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Public alias for a single decode issue. Aliased here (rather than re-exporting
|
|
106
|
+
* the `$`-prefixed zod internal symbol directly) so consumers can annotate
|
|
107
|
+
* against `PushEventDecodeIssue` without importing zod internals. The alias is
|
|
108
|
+
* structurally identical to `z.core.$ZodIssue`, so this is a non-breaking
|
|
109
|
+
* narrowing of the public surface.
|
|
110
|
+
*/
|
|
111
|
+
export type PushEventDecodeIssue = z.core.$ZodIssue;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Thrown by `decodePushEvent` (and `encodePushEvent` on invalid input) when
|
|
115
|
+
* the payload fails validation. `.issues` carries the underlying zod issues
|
|
116
|
+
* so callers can render structured diagnostics — see the test suite for an
|
|
117
|
+
* example of asserting on the issue path.
|
|
118
|
+
*/
|
|
119
|
+
export class PushEventDecodeError extends Error {
|
|
120
|
+
readonly issues: readonly PushEventDecodeIssue[];
|
|
121
|
+
readonly stage: "json-parse" | "schema-validation";
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
message: string,
|
|
125
|
+
args: {
|
|
126
|
+
issues: readonly PushEventDecodeIssue[];
|
|
127
|
+
stage: "json-parse" | "schema-validation";
|
|
128
|
+
cause?: unknown;
|
|
129
|
+
},
|
|
130
|
+
) {
|
|
131
|
+
super(message, args.cause === undefined ? undefined : { cause: args.cause });
|
|
132
|
+
this.name = "PushEventDecodeError";
|
|
133
|
+
this.issues = args.issues;
|
|
134
|
+
this.stage = args.stage;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Encode / Decode ───────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate `event` against `PushEventSchema` and return the canonical JSON
|
|
142
|
+
* serialization. Validating on encode catches producer-side mistakes early
|
|
143
|
+
* (and ensures the output always round-trips through `decodePushEvent`).
|
|
144
|
+
*
|
|
145
|
+
* Extra keys on `event` are dropped — the returned JSON string contains
|
|
146
|
+
* only the declared PushEvent fields.
|
|
147
|
+
*/
|
|
148
|
+
export function encodePushEvent(event: PushEvent): string {
|
|
149
|
+
const parsed = PushEventSchema.safeParse(event);
|
|
150
|
+
if (!parsed.success) {
|
|
151
|
+
throw new PushEventDecodeError(
|
|
152
|
+
"encodePushEvent: input failed PushEvent schema validation",
|
|
153
|
+
{ issues: parsed.error.issues, stage: "schema-validation" },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return JSON.stringify(parsed.data);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Parse and validate an incoming PushEvent. Accepts either a raw JSON string
|
|
161
|
+
* (the on-the-wire form) or an already-parsed object (handy for in-process
|
|
162
|
+
* wiring and tests).
|
|
163
|
+
*
|
|
164
|
+
* - Unknown extra fields are dropped silently — see module JSDoc.
|
|
165
|
+
* - Missing required fields throw `PushEventDecodeError` whose `.issues`
|
|
166
|
+
* exposes the underlying zod issues.
|
|
167
|
+
* - Malformed JSON throws `PushEventDecodeError` with `stage:"json-parse"`
|
|
168
|
+
* and a synthetic issue at the root path.
|
|
169
|
+
* - A JSON string that parses to a non-object value (e.g. `'42'`, `'"text"'`,
|
|
170
|
+
* `'null'`) surfaces as `stage: 'schema-validation'` — the JSON itself was
|
|
171
|
+
* syntactically valid, so it clears the parse stage before failing the
|
|
172
|
+
* object-shape check.
|
|
173
|
+
*/
|
|
174
|
+
export function decodePushEvent(input: unknown): PushEvent {
|
|
175
|
+
let candidate: unknown = input;
|
|
176
|
+
|
|
177
|
+
if (typeof input === "string") {
|
|
178
|
+
try {
|
|
179
|
+
candidate = JSON.parse(input);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
throw new PushEventDecodeError(
|
|
182
|
+
"decodePushEvent: input string is not valid JSON",
|
|
183
|
+
{
|
|
184
|
+
issues: [
|
|
185
|
+
{
|
|
186
|
+
code: "custom",
|
|
187
|
+
message:
|
|
188
|
+
err instanceof Error ? err.message : "invalid JSON input",
|
|
189
|
+
path: [],
|
|
190
|
+
input,
|
|
191
|
+
} as PushEventDecodeIssue,
|
|
192
|
+
],
|
|
193
|
+
stage: "json-parse",
|
|
194
|
+
cause: err,
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parsed = PushEventSchema.safeParse(candidate);
|
|
201
|
+
if (!parsed.success) {
|
|
202
|
+
throw new PushEventDecodeError(
|
|
203
|
+
"decodePushEvent: payload failed PushEvent schema validation",
|
|
204
|
+
{ issues: parsed.error.issues, stage: "schema-validation" },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return parsed.data;
|
|
208
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PushTransport — outbound shipping seam for the watcher daemon.
|
|
3
|
+
*
|
|
4
|
+
* The daemon wires file-watcher events into a transport that ships each
|
|
5
|
+
* PushEvent to the cloud. Defining the interface here lets the daemon ship
|
|
6
|
+
* with a swappable boundary — tests inject a fake, a later story swaps in a
|
|
7
|
+
* concrete WebSocket/HTTP implementation, and the daemon entry point never
|
|
8
|
+
* changes.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle
|
|
11
|
+
* ─────────
|
|
12
|
+
* - `start()` is awaited BEFORE the watcher is started. It's the place
|
|
13
|
+
* to open sockets, refresh tokens, etc.
|
|
14
|
+
* - `publish(event)` is called for every coalesced PushEvent the watcher
|
|
15
|
+
* emits. Implementations decide whether to buffer, batch, or send
|
|
16
|
+
* inline; the daemon awaits the returned promise so back-pressure can
|
|
17
|
+
* be honored.
|
|
18
|
+
* - `dispose()` is awaited DURING shutdown, AFTER the watcher has been
|
|
19
|
+
* torn down. Implementations should drain in-flight publishes (with
|
|
20
|
+
* their own internal timeout) and close any sockets.
|
|
21
|
+
* - `connected` is a passive boolean used by the health endpoint. It MAY
|
|
22
|
+
* flap during reconnect attempts — that's fine; consumers treat it as
|
|
23
|
+
* advisory, not a contract.
|
|
24
|
+
*
|
|
25
|
+
* The `NoopPushTransport` shipped here is the default when no transport
|
|
26
|
+
* is wired in: it counts publishes (so unit tests can assert delivery)
|
|
27
|
+
* and logs nothing — observers should rely on the watcher's `onEvent`
|
|
28
|
+
* counter on the daemon side, not the transport's internals.
|
|
29
|
+
*
|
|
30
|
+
* Ported from indigoai-us/hq-pro PR #112 (src/sync/push-transport.ts) into
|
|
31
|
+
* @indigoai-us/hq-cloud (Path B) per project event-driven-sync-menubar US-007.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { PushEvent } from "./push-event.js";
|
|
35
|
+
|
|
36
|
+
export interface PushTransport {
|
|
37
|
+
/** Open sockets, refresh tokens. Awaited before the watcher starts. */
|
|
38
|
+
start(): Promise<void>;
|
|
39
|
+
/** Ship one coalesced PushEvent. Awaited per event for back-pressure. */
|
|
40
|
+
publish(event: PushEvent): Promise<void>;
|
|
41
|
+
/** Drain + close. Awaited during daemon shutdown after watcher.dispose. */
|
|
42
|
+
dispose(): Promise<void>;
|
|
43
|
+
/** Advisory: is the transport currently believed to be connected? */
|
|
44
|
+
readonly connected: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Default `PushTransport` used until a real implementation lands.
|
|
49
|
+
*
|
|
50
|
+
* Behavior:
|
|
51
|
+
* - `start()` flips `connected` to true.
|
|
52
|
+
* - `publish()` increments a counter (visible via `publishedCount`).
|
|
53
|
+
* - `dispose()` flips `connected` back to false.
|
|
54
|
+
*
|
|
55
|
+
* Deliberately silent: when the daemon runs with this default, the
|
|
56
|
+
* watcher's per-event log line (emitted by the daemon itself, not the
|
|
57
|
+
* transport) is the only observability. That keeps the noop from drowning
|
|
58
|
+
* the log when high-rate writes hit a dev machine.
|
|
59
|
+
*/
|
|
60
|
+
export class NoopPushTransport implements PushTransport {
|
|
61
|
+
private _connected = false;
|
|
62
|
+
private _count = 0;
|
|
63
|
+
|
|
64
|
+
get connected(): boolean {
|
|
65
|
+
return this._connected;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Test/observability hook: how many events have been published. */
|
|
69
|
+
get publishedCount(): number {
|
|
70
|
+
return this._count;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async start(): Promise<void> {
|
|
74
|
+
this._connected = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async publish(_event: PushEvent): Promise<void> {
|
|
78
|
+
this._count += 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async dispose(): Promise<void> {
|
|
82
|
+
this._connected = false;
|
|
83
|
+
}
|
|
84
|
+
}
|