@indigoai-us/hq-cloud 5.25.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.
Files changed (44) hide show
  1. package/dist/bin/sync-runner.d.ts +100 -1
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +214 -16
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +372 -1
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/sync/index.d.ts +11 -0
  12. package/dist/sync/index.d.ts.map +1 -0
  13. package/dist/sync/index.js +9 -0
  14. package/dist/sync/index.js.map +1 -0
  15. package/dist/sync/push-event.d.ts +110 -0
  16. package/dist/sync/push-event.d.ts.map +1 -0
  17. package/dist/sync/push-event.js +153 -0
  18. package/dist/sync/push-event.js.map +1 -0
  19. package/dist/sync/push-event.test.d.ts +15 -0
  20. package/dist/sync/push-event.test.d.ts.map +1 -0
  21. package/dist/sync/push-event.test.js +188 -0
  22. package/dist/sync/push-event.test.js.map +1 -0
  23. package/dist/sync/push-transport.d.ts +67 -0
  24. package/dist/sync/push-transport.d.ts.map +1 -0
  25. package/dist/sync/push-transport.js +66 -0
  26. package/dist/sync/push-transport.js.map +1 -0
  27. package/dist/watcher.d.ts +160 -0
  28. package/dist/watcher.d.ts.map +1 -1
  29. package/dist/watcher.js +298 -0
  30. package/dist/watcher.js.map +1 -1
  31. package/dist/watcher.test.d.ts +2 -0
  32. package/dist/watcher.test.d.ts.map +1 -0
  33. package/dist/watcher.test.js +334 -0
  34. package/dist/watcher.test.js.map +1 -0
  35. package/package.json +3 -2
  36. package/src/bin/sync-runner.test.ts +487 -1
  37. package/src/bin/sync-runner.ts +305 -9
  38. package/src/index.ts +17 -0
  39. package/src/sync/index.ts +19 -0
  40. package/src/sync/push-event.test.ts +224 -0
  41. package/src/sync/push-event.ts +208 -0
  42. package/src/sync/push-transport.ts +84 -0
  43. package/src/watcher.test.ts +388 -0
  44. package/src/watcher.ts +386 -0
@@ -88,6 +88,12 @@ import type { ShareOptions, ShareResult } from "../cli/share.js";
88
88
  import type { ConflictStrategy } from "../cli/conflict.js";
89
89
  import type { UploadAuthor } from "../s3.js";
90
90
  import { collectAndSendTelemetry } from "../telemetry.js";
91
+ import {
92
+ TreeWatcher,
93
+ WatchPushDriver,
94
+ systemClock,
95
+ type Clock,
96
+ } from "../watcher.js";
91
97
 
92
98
  /**
93
99
  * Sync direction for a run.
@@ -442,6 +448,15 @@ interface ParsedArgs {
442
448
  watch: boolean;
443
449
  /** Auto-sync (Beta): ms between remote-pull passes. Required when watch=true. */
444
450
  pollRemoteMs?: number;
451
+ /**
452
+ * Event-driven push (Phase 1). When set (and `--watch` is on), the runner
453
+ * starts a {@link TreeWatcher} alongside the poll loop and pushes a targeted
454
+ * company/subtree within the debounce window of a local edit — instead of
455
+ * waiting up to a full `--poll-remote-ms` cycle. Gated OFF by default; the
456
+ * menubar passes it only for `@getindigo.ai` identities (see PRD decision).
457
+ * No-op without `--watch` (the one-shot path has nothing to keep alive).
458
+ */
459
+ eventPush: boolean;
445
460
  /**
446
461
  * Drop the personal target from the fanout. Combined with the
447
462
  * `HQ_SYNC_SKIP_PERSONAL` env var by `resolveSkipPersonal()` — either
@@ -460,6 +475,7 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
460
475
  let watch = false;
461
476
  let pollRemoteMs: number | undefined;
462
477
  let skipPersonal = false;
478
+ let eventPush = false;
463
479
 
464
480
  for (let i = 0; i < argv.length; i++) {
465
481
  const arg = argv[i];
@@ -519,6 +535,12 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
519
535
  // resolveSkipPersonal().
520
536
  skipPersonal = true;
521
537
  break;
538
+ case "--event-push":
539
+ // Phase 1 event-driven push enable flag. Requires --watch (validated
540
+ // below). Gated OFF by default; the menubar only passes it for
541
+ // @getindigo.ai identities for the first release.
542
+ eventPush = true;
543
+ break;
522
544
  default:
523
545
  return { error: `Unknown argument: ${arg}` };
524
546
  }
@@ -533,8 +555,21 @@ function parseArgs(argv: string[]): ParsedArgs | { error: string } {
533
555
  if (pollRemoteMs !== undefined && !watch) {
534
556
  return { error: "--poll-remote-ms requires --watch" };
535
557
  }
558
+ if (eventPush && !watch) {
559
+ return { error: "--event-push requires --watch" };
560
+ }
536
561
 
537
- return { companies, company, onConflict, hqRoot, direction, watch, pollRemoteMs, skipPersonal };
562
+ return {
563
+ companies,
564
+ company,
565
+ onConflict,
566
+ hqRoot,
567
+ direction,
568
+ watch,
569
+ pollRemoteMs,
570
+ skipPersonal,
571
+ eventPush,
572
+ };
538
573
  }
539
574
 
540
575
  // ---------------------------------------------------------------------------
@@ -1169,29 +1204,290 @@ const isDirectInvocation = (() => {
1169
1204
  * exit 0 today and so will retry — acceptable noise for the beta; deal with
1170
1205
  * it via a richer return shape if it shows up in Sentry.
1171
1206
  */
1172
- export async function runRunnerWithLoop(argv: string[]): Promise<number> {
1207
+ /**
1208
+ * Test/event-driven seam (US-001).
1209
+ *
1210
+ * `runRunnerWithLoop` performs an unbounded poll loop in production. To make
1211
+ * the loop deterministically testable (US-003 wires the event-driven watcher
1212
+ * into this same loop), the inter-pass sleep is injectable via `deps.sleep`.
1213
+ * The default uses the host timer and preserves exact production behavior.
1214
+ *
1215
+ * A test (or US-003's wiring) injects a fake sleep that resolves immediately
1216
+ * and/or coordinates with the {@link WatchPushDriver} seam in `../watcher.js`,
1217
+ * so the loop can be exercised without a real 10-minute wait.
1218
+ */
1219
+ export interface RunnerLoopDeps {
1220
+ /** Sleep `ms` between passes. Default: host setTimeout. */
1221
+ sleep?: (ms: number) => Promise<void>;
1222
+ /**
1223
+ * Run a single sync pass. Defaults to {@link runRunner}. Injected by tests
1224
+ * (and the event-push wiring) so the poll loop and the watcher-triggered
1225
+ * targeted push share one seam and one in-flight guard. The default ignores
1226
+ * `deps` and forwards just the argv to `runRunner`.
1227
+ */
1228
+ runPass?: (passArgv: string[]) => Promise<number>;
1229
+ /**
1230
+ * Clock seam for the event-push watcher's debounce window. Defaults to
1231
+ * {@link systemClock}; tests inject a `FakeClock` to advance the window
1232
+ * deterministically. Only consulted when `--event-push` is on.
1233
+ */
1234
+ clock?: Clock;
1235
+ /**
1236
+ * Factory for the file watcher used in event-push mode. Defaults to a real
1237
+ * {@link TreeWatcher} over `hqRoot`. Tests inject a stub exposing the same
1238
+ * `onChange`/`start`/`stop`/`dispose` surface so no real chokidar runs.
1239
+ */
1240
+ createWatcher?: (opts: {
1241
+ hqRoot: string;
1242
+ debounceMs: number;
1243
+ clock: Clock;
1244
+ }) => WatcherSurface;
1245
+ /**
1246
+ * Register a one-shot shutdown signal handler. Defaults to listening for
1247
+ * SIGTERM/SIGINT on `process`. Tests inject a controllable trigger to assert
1248
+ * clean teardown without sending real signals. The returned fn detaches the
1249
+ * handler (called during teardown so tests don't leak listeners).
1250
+ */
1251
+ onShutdownSignal?: (handler: () => void) => () => void;
1252
+ }
1253
+
1254
+ /**
1255
+ * The minimal watcher surface the loop drives. {@link TreeWatcher} satisfies
1256
+ * it; tests inject a lighter stub. Kept narrow so the loop never reaches past
1257
+ * the lifecycle + change-subscription contract.
1258
+ *
1259
+ * `onChange`'s listener receives an OPTIONAL changed relative path. The real
1260
+ * {@link TreeWatcher} emits a bare debounced signal (no path) — in that case
1261
+ * the loop routes the targeted push to the personal vault. A path-aware
1262
+ * watcher (or a test stub) can pass the changed `companies/<slug>/...`
1263
+ * relative path so the loop targets just that company.
1264
+ */
1265
+ export interface WatcherSurface {
1266
+ onChange(listener: (changedRelPath?: string) => void): () => void;
1267
+ start(): void;
1268
+ stop(): void;
1269
+ dispose(): void;
1270
+ }
1271
+
1272
+ /**
1273
+ * Route a changed relative path to the push target that owns it.
1274
+ *
1275
+ * - `companies/<slug>/...` → a single-company push for `<slug>` (the targeted
1276
+ * pass per PRD decision — push only the changed company, not a full
1277
+ * `--companies` fanout).
1278
+ * - anything else under hqRoot → the personal target (a `--companies` push
1279
+ * restricted to personal via the personal-vault scope; modeled here as the
1280
+ * "personal" route the caller maps to the right argv).
1281
+ *
1282
+ * Returns `null` for paths the routing cannot attribute (defensive — the
1283
+ * watcher's filter already drops excluded top-levels, so this is belt-and-
1284
+ * suspenders for synthetic events).
1285
+ */
1286
+ export function routeChangeToTarget(
1287
+ relPath: string,
1288
+ ): { kind: "company"; slug: string } | { kind: "personal" } | null {
1289
+ const norm = relPath.split(path.sep).join("/").replace(/^\.\//, "");
1290
+ if (norm === "" || norm.startsWith("..")) return null;
1291
+ const segments = norm.split("/").filter((s) => s.length > 0);
1292
+ if (segments.length === 0) return null;
1293
+ if (segments[0] === "companies") {
1294
+ // companies/<slug>/... — need at least the slug segment to target.
1295
+ if (segments.length < 2 || segments[1].length === 0) return null;
1296
+ return { kind: "company", slug: segments[1] };
1297
+ }
1298
+ return { kind: "personal" };
1299
+ }
1300
+
1301
+ /**
1302
+ * Build the argv for a targeted push pass from a routed change. The push runs
1303
+ * `--direction push` for just the routed target so a local edit propagates in
1304
+ * seconds without a full fanout. Company routes use `--company <slug>`;
1305
+ * personal routes use `--companies --direction push` (the personal-vault scope
1306
+ * is resolved inside runRunner's fanout; skipUnchanged no-ops the company
1307
+ * subtrees that didn't change). Inherits `--hq-root` / `--on-conflict` from
1308
+ * the base argv. Pure helper, exported for unit testing the routing→argv map.
1309
+ */
1310
+ export function buildTargetedPushArgv(
1311
+ route: { kind: "company"; slug: string } | { kind: "personal" },
1312
+ baseArgv: string[],
1313
+ ): string[] {
1314
+ const carried = carriedFlags(baseArgv);
1315
+ if (route.kind === "company") {
1316
+ return ["--company", route.slug, "--direction", "push", ...carried];
1317
+ }
1318
+ return ["--companies", "--direction", "push", ...carried];
1319
+ }
1320
+
1321
+ export async function runRunnerWithLoop(
1322
+ argv: string[],
1323
+ deps: RunnerLoopDeps = {},
1324
+ ): Promise<number> {
1173
1325
  if (!argv.includes("--watch")) {
1174
1326
  return runRunner(argv);
1175
1327
  }
1328
+ const sleep =
1329
+ deps.sleep ??
1330
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1331
+ const runPass = deps.runPass ?? ((passArgv: string[]) => runRunner(passArgv));
1176
1332
  const pollIdx = argv.indexOf("--poll-remote-ms");
1177
1333
  const pollMs =
1178
1334
  pollIdx >= 0 && argv[pollIdx + 1] ? Number(argv[pollIdx + 1]) : 600_000;
1335
+ const eventPush = argv.includes("--event-push");
1336
+ const hqIdx = argv.indexOf("--hq-root");
1337
+ const hqRoot =
1338
+ hqIdx >= 0 && argv[hqIdx + 1] ? argv[hqIdx + 1] : DEFAULT_HQ_ROOT;
1179
1339
 
1180
- // Strip --watch / --poll-remote-ms before delegating: the parser inside
1181
- // runRunner accepts them, but we don't want runRunner to think it's
1182
- // re-entering watch mode each iteration.
1340
+ // Strip the loop-only flags before delegating: the parser inside runRunner
1341
+ // accepts --watch/--poll-remote-ms/--event-push, but we don't want a per-
1342
+ // iteration pass to think it's re-entering watch mode.
1183
1343
  const passArgv = argv.filter((a, i) => {
1184
1344
  if (a === "--watch") return false;
1185
1345
  if (a === "--poll-remote-ms") return false;
1346
+ if (a === "--event-push") return false;
1186
1347
  if (i > 0 && argv[i - 1] === "--poll-remote-ms") return false;
1187
1348
  return true;
1188
1349
  });
1189
1350
 
1190
- while (true) {
1191
- const code = await runRunner(passArgv);
1192
- if (code !== 0) return code;
1193
- await new Promise<void>((resolve) => setTimeout(resolve, pollMs));
1351
+ // ---- shared in-flight guard ------------------------------------------
1352
+ // The poll loop AND watcher-triggered targeted pushes funnel through this
1353
+ // mutex so a watcher push never overlaps an in-flight pass (PRD AC). A
1354
+ // trigger that arrives while a pass runs is collapsed by WatchPushDriver's
1355
+ // own pending-while-pushing logic, then re-armed after the pass settles.
1356
+ let inFlight = false;
1357
+ let stopped = false;
1358
+ const runGuarded = async (
1359
+ pass: () => Promise<number>,
1360
+ ): Promise<number | "skipped"> => {
1361
+ if (inFlight) return "skipped";
1362
+ inFlight = true;
1363
+ try {
1364
+ return await pass();
1365
+ } finally {
1366
+ inFlight = false;
1367
+ }
1368
+ };
1369
+
1370
+ // ---- event-push wiring (Phase 1) -------------------------------------
1371
+ let watcher: WatcherSurface | null = null;
1372
+ let driver: WatchPushDriver | null = null;
1373
+ let detachSignal: (() => void) | null = null;
1374
+ let lastChangedRel: string | null = null;
1375
+
1376
+ if (eventPush) {
1377
+ const clock = deps.clock ?? systemClock;
1378
+ const debounceMs = 2000;
1379
+ const createWatcher =
1380
+ deps.createWatcher ??
1381
+ ((opts) =>
1382
+ new TreeWatcher({
1383
+ hqRoot: opts.hqRoot,
1384
+ debounceMs: opts.debounceMs,
1385
+ clock: opts.clock,
1386
+ personalMode: true,
1387
+ }));
1388
+ watcher = createWatcher({ hqRoot, debounceMs, clock });
1389
+
1390
+ // The driver runs the targeted push when a debounced burst settles. Its
1391
+ // concurrency guard collapses a trigger that lands mid-pass; we ALSO gate
1392
+ // through `runGuarded` so a poll pass in flight is never overlapped.
1393
+ driver = new WatchPushDriver({
1394
+ debounceMs: 0, // TreeWatcher already debounces; the driver just guards.
1395
+ clock,
1396
+ push: async () => {
1397
+ if (stopped) return;
1398
+ const rel = lastChangedRel;
1399
+ const route = rel
1400
+ ? routeChangeToTarget(rel)
1401
+ : { kind: "personal" as const };
1402
+ if (!route) return;
1403
+ const targetedArgv = buildTargetedPushArgv(route, passArgv);
1404
+ await runGuarded(() => runPass(targetedArgv));
1405
+ },
1406
+ });
1407
+
1408
+ // A debounced TreeWatcher 'changed' signal feeds the driver, which fires
1409
+ // the targeted push after its own (zero) window — i.e. immediately, but
1410
+ // still serialized behind any in-flight pass. A path-aware watcher passes
1411
+ // the changed relative path so the push targets just its owning company;
1412
+ // the bare-signal TreeWatcher leaves it null → personal-vault route.
1413
+ watcher.onChange((changedRelPath) => {
1414
+ if (stopped) return;
1415
+ lastChangedRel = changedRelPath ?? null;
1416
+ driver?.notifyChange();
1417
+ });
1418
+ watcher.start();
1419
+ }
1420
+
1421
+ // ---- clean shutdown --------------------------------------------------
1422
+ // SIGTERM (menubar stop_daemon) / SIGINT must tear down BOTH the watcher and
1423
+ // the poll loop with no leaked timers or fds. We flip `stopped`, dispose the
1424
+ // watcher + driver, and let the poll loop observe `stopped` on its next tick.
1425
+ let resolveStopped: (() => void) | null = null;
1426
+ const stoppedSignal = new Promise<void>((resolve) => {
1427
+ resolveStopped = resolve;
1428
+ });
1429
+ const shutdown = (): void => {
1430
+ if (stopped) return;
1431
+ stopped = true;
1432
+ try {
1433
+ driver?.dispose();
1434
+ } catch {
1435
+ /* ignore */
1436
+ }
1437
+ try {
1438
+ watcher?.dispose();
1439
+ } catch {
1440
+ /* ignore */
1441
+ }
1442
+ resolveStopped?.();
1443
+ };
1444
+ const onShutdownSignal =
1445
+ deps.onShutdownSignal ??
1446
+ ((handler: () => void) => {
1447
+ const wrapped = () => handler();
1448
+ process.on("SIGTERM", wrapped);
1449
+ process.on("SIGINT", wrapped);
1450
+ return () => {
1451
+ process.off("SIGTERM", wrapped);
1452
+ process.off("SIGINT", wrapped);
1453
+ };
1454
+ });
1455
+ detachSignal = onShutdownSignal(shutdown);
1456
+
1457
+ try {
1458
+ while (!stopped) {
1459
+ const result = await runGuarded(() => runPass(passArgv));
1460
+ // A poll pass that was skipped because a watcher push held the guard is
1461
+ // benign — the next iteration retries after the poll interval.
1462
+ if (typeof result === "number" && result !== 0) {
1463
+ return result;
1464
+ }
1465
+ // Sleep the poll interval, but wake early on shutdown so SIGTERM stops
1466
+ // the loop promptly instead of waiting out a 10-minute cycle.
1467
+ await Promise.race([sleep(pollMs), stoppedSignal]);
1468
+ }
1469
+ return 0;
1470
+ } finally {
1471
+ shutdown();
1472
+ detachSignal?.();
1473
+ }
1474
+ }
1475
+
1476
+ /**
1477
+ * Extract the `--hq-root` / `--on-conflict` pair-flags from a base argv so a
1478
+ * re-targeted push pass inherits the same root and conflict policy. Pure
1479
+ * helper used by the event-push targeted-push composer.
1480
+ */
1481
+ function carriedFlags(baseArgv: string[]): string[] {
1482
+ const carried: string[] = [];
1483
+ for (let i = 0; i < baseArgv.length; i++) {
1484
+ const a = baseArgv[i];
1485
+ if (a === "--hq-root" || a === "--on-conflict") {
1486
+ carried.push(a);
1487
+ if (baseArgv[i + 1] !== undefined) carried.push(baseArgv[++i]);
1488
+ }
1194
1489
  }
1490
+ return carried;
1195
1491
  }
1196
1492
 
1197
1493
  if (isDirectInvocation) {
package/src/index.ts CHANGED
@@ -281,3 +281,20 @@ export {
281
281
  _setSignalsS3Factory,
282
282
  _resetSignalsS3Factory,
283
283
  } from "./signals/internals.js";
284
+
285
+ // Event-driven sync — PushEvent wire contract + PushTransport shipping seam
286
+ // (ported from hq-pro PR #112 per project event-driven-sync-menubar US-007).
287
+ export {
288
+ CONTENT_HASH_PATTERN,
289
+ ISO8601_DATETIME_PATTERN,
290
+ PushEventSchema,
291
+ PushEventDecodeError,
292
+ encodePushEvent,
293
+ decodePushEvent,
294
+ NoopPushTransport,
295
+ } from "./sync/index.js";
296
+ export type {
297
+ PushEvent,
298
+ PushEventDecodeIssue,
299
+ PushTransport,
300
+ } from "./sync/index.js";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @indigoai-us/hq-cloud — event-driven sync (`src/sync`) barrel.
3
+ *
4
+ * Surfaces the PushEvent wire contract + the PushTransport shipping seam
5
+ * ported from hq-pro PR #112 (project event-driven-sync-menubar US-007).
6
+ */
7
+
8
+ export {
9
+ CONTENT_HASH_PATTERN,
10
+ ISO8601_DATETIME_PATTERN,
11
+ PushEventSchema,
12
+ PushEventDecodeError,
13
+ encodePushEvent,
14
+ decodePushEvent,
15
+ } from "./push-event.js";
16
+ export type { PushEvent, PushEventDecodeIssue } from "./push-event.js";
17
+
18
+ export { NoopPushTransport } from "./push-transport.js";
19
+ export type { PushTransport } from "./push-transport.js";
@@ -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
+ });