@indigoai-us/hq-cloud 5.25.0 → 5.27.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 (87) hide show
  1. package/.github/workflows/ci.yml +34 -0
  2. package/dist/bin/sync-runner.d.ts +138 -1
  3. package/dist/bin/sync-runner.d.ts.map +1 -1
  4. package/dist/bin/sync-runner.js +288 -16
  5. package/dist/bin/sync-runner.js.map +1 -1
  6. package/dist/bin/sync-runner.test.js +372 -1
  7. package/dist/bin/sync-runner.test.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +6 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/sync/feature-flags.d.ts +136 -0
  13. package/dist/sync/feature-flags.d.ts.map +1 -0
  14. package/dist/sync/feature-flags.js +160 -0
  15. package/dist/sync/feature-flags.js.map +1 -0
  16. package/dist/sync/feature-flags.test.d.ts +24 -0
  17. package/dist/sync/feature-flags.test.d.ts.map +1 -0
  18. package/dist/sync/feature-flags.test.js +330 -0
  19. package/dist/sync/feature-flags.test.js.map +1 -0
  20. package/dist/sync/index.d.ts +19 -0
  21. package/dist/sync/index.d.ts.map +1 -0
  22. package/dist/sync/index.js +13 -0
  23. package/dist/sync/index.js.map +1 -0
  24. package/dist/sync/logger.d.ts +61 -0
  25. package/dist/sync/logger.d.ts.map +1 -0
  26. package/dist/sync/logger.js +51 -0
  27. package/dist/sync/logger.js.map +1 -0
  28. package/dist/sync/logger.test.d.ts +19 -0
  29. package/dist/sync/logger.test.d.ts.map +1 -0
  30. package/dist/sync/logger.test.js +199 -0
  31. package/dist/sync/logger.test.js.map +1 -0
  32. package/dist/sync/metrics.d.ts +89 -0
  33. package/dist/sync/metrics.d.ts.map +1 -0
  34. package/dist/sync/metrics.js +105 -0
  35. package/dist/sync/metrics.js.map +1 -0
  36. package/dist/sync/metrics.test.d.ts +19 -0
  37. package/dist/sync/metrics.test.d.ts.map +1 -0
  38. package/dist/sync/metrics.test.js +280 -0
  39. package/dist/sync/metrics.test.js.map +1 -0
  40. package/dist/sync/push-event.d.ts +110 -0
  41. package/dist/sync/push-event.d.ts.map +1 -0
  42. package/dist/sync/push-event.js +153 -0
  43. package/dist/sync/push-event.js.map +1 -0
  44. package/dist/sync/push-event.test.d.ts +15 -0
  45. package/dist/sync/push-event.test.d.ts.map +1 -0
  46. package/dist/sync/push-event.test.js +188 -0
  47. package/dist/sync/push-event.test.js.map +1 -0
  48. package/dist/sync/push-receiver.d.ts +442 -0
  49. package/dist/sync/push-receiver.d.ts.map +1 -0
  50. package/dist/sync/push-receiver.js +782 -0
  51. package/dist/sync/push-receiver.js.map +1 -0
  52. package/dist/sync/push-receiver.test.d.ts +25 -0
  53. package/dist/sync/push-receiver.test.d.ts.map +1 -0
  54. package/dist/sync/push-receiver.test.js +477 -0
  55. package/dist/sync/push-receiver.test.js.map +1 -0
  56. package/dist/sync/push-transport.d.ts +150 -0
  57. package/dist/sync/push-transport.d.ts.map +1 -0
  58. package/dist/sync/push-transport.js +150 -0
  59. package/dist/sync/push-transport.js.map +1 -0
  60. package/dist/watcher.d.ts +271 -0
  61. package/dist/watcher.d.ts.map +1 -1
  62. package/dist/watcher.js +480 -3
  63. package/dist/watcher.js.map +1 -1
  64. package/dist/watcher.test.d.ts +2 -0
  65. package/dist/watcher.test.d.ts.map +1 -0
  66. package/dist/watcher.test.js +334 -0
  67. package/dist/watcher.test.js.map +1 -0
  68. package/package.json +10 -5
  69. package/src/bin/sync-runner.test.ts +487 -1
  70. package/src/bin/sync-runner.ts +406 -9
  71. package/src/index.ts +38 -0
  72. package/src/sync/feature-flags.test.ts +392 -0
  73. package/src/sync/feature-flags.ts +229 -0
  74. package/src/sync/index.ts +74 -0
  75. package/src/sync/logger.test.ts +241 -0
  76. package/src/sync/logger.ts +79 -0
  77. package/src/sync/metrics.test.ts +380 -0
  78. package/src/sync/metrics.ts +158 -0
  79. package/src/sync/push-event.test.ts +224 -0
  80. package/src/sync/push-event.ts +208 -0
  81. package/src/sync/push-receiver.test.ts +545 -0
  82. package/src/sync/push-receiver.ts +1077 -0
  83. package/src/sync/push-transport.ts +231 -0
  84. package/src/watcher.test.ts +388 -0
  85. package/src/watcher.ts +672 -4
  86. package/test/e2e/sync/cross-tenant-isolation.test.ts +502 -0
  87. package/test/e2e/watcher-real-chokidar.test.ts +105 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Sync pipeline metrics — CloudWatch custom metrics for the event-driven push
3
+ * receive loop (project event-driven-sync-menubar US-011).
4
+ *
5
+ * Publishes one `hq-cloud.sync.p95_latency_seconds` datum per successfully
6
+ * processed push event to the `HQPro/Sync` namespace, with a `TenantId`
7
+ * dimension so the dashboard widget can be filtered per tenant. CloudWatch
8
+ * aggregates p50/p95/p99 across the time window via the dashboard's
9
+ * `Statistics` setting; the receive-loop side just emits one raw value per
10
+ * event.
11
+ *
12
+ * Best-effort emission
13
+ * ────────────────────
14
+ * - module-level singleton CloudWatchClient with a `_setSyncCloudWatchClient`
15
+ * test seam
16
+ * - `publishSyncLatencyMetric` catches all errors and NEVER throws — a
17
+ * CloudWatch outage MUST NOT crash the sync receive loop. The cadence
18
+ * safety net still picks up missed work and metric blanks are recoverable;
19
+ * a crashed loop is not.
20
+ * - explicit `Unit` + `Timestamp` + `Dimensions` per datum
21
+ *
22
+ * Why latency (and not also "events received" / "events failed")?
23
+ * ───────────────────────────────────────────────────────────────
24
+ * US-011 AC#1 calls for the p95 latency metric specifically. The receive-loop's
25
+ * existing `processedCount` / log lines cover the count/failure dimensions for
26
+ * now; widening to additional metric names happens in a follow-up if the
27
+ * operator dashboard grows.
28
+ *
29
+ * Adapted from indigoai-us/hq-pro PR #112 (src/sync/metrics.ts) into
30
+ * @indigoai-us/hq-cloud (Path B).
31
+ */
32
+
33
+ import {
34
+ CloudWatchClient,
35
+ PutMetricDataCommand,
36
+ type MetricDatum,
37
+ } from "@aws-sdk/client-cloudwatch";
38
+ import type { Logger } from "pino";
39
+
40
+ // ── Constants ──────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * CloudWatch metric namespace for the sync pipeline. Matches the server-side
44
+ * namespace (hq-pro PR #112) so the dashboard + alarm cover the client path.
45
+ */
46
+ export const SYNC_METRIC_NAMESPACE = "HQPro/Sync";
47
+
48
+ /**
49
+ * Metric name for per-event sync latency in seconds. The dashboard widget
50
+ * applies `Statistics: ["p95"]` to aggregate across the time window — the
51
+ * receive loop just emits one raw `Seconds` value per processed event.
52
+ *
53
+ * Name chosen to match the PRD's alarm threshold (`p95 > 10s`).
54
+ */
55
+ export const SYNC_LATENCY_METRIC_NAME = "hq-cloud.sync.p95_latency_seconds";
56
+
57
+ // ── Types ──────────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * One latency observation. `latencySeconds` is the wall-clock duration from
61
+ * save-on-A to visible-on-B (or, on the receive loop, the `syncFn(ctx)`
62
+ * duration); we ONLY publish on the success path so failed syncs don't skew
63
+ * p95 toward infinity.
64
+ *
65
+ * `relativePath` and `sequenceNumber` are NOT used as CloudWatch dimensions
66
+ * (cardinality explosion) — they're captured here for the optional debug log
67
+ * emitted on failure so operators can correlate back to the 3-log chain when
68
+ * investigating a spike.
69
+ */
70
+ export interface SyncLatencyMetric {
71
+ tenantId: string;
72
+ relativePath: string;
73
+ sequenceNumber: number;
74
+ latencySeconds: number;
75
+ timestamp: Date;
76
+ }
77
+
78
+ // ── Client ─────────────────────────────────────────────────────────────────
79
+
80
+ let _cwClient: CloudWatchClient | undefined;
81
+
82
+ function getCloudWatchClient(): CloudWatchClient {
83
+ if (!_cwClient) {
84
+ _cwClient = new CloudWatchClient({});
85
+ }
86
+ return _cwClient;
87
+ }
88
+
89
+ /**
90
+ * Replace the CloudWatch client (for testing).
91
+ * @internal
92
+ */
93
+ export function _setSyncCloudWatchClient(client: CloudWatchClient): void {
94
+ _cwClient = client;
95
+ }
96
+
97
+ // ── Publish ────────────────────────────────────────────────────────────────
98
+
99
+ export interface PublishSyncLatencyMetricOptions {
100
+ /** Override the CloudWatch client (tests). Defaults to the module singleton. */
101
+ client?: CloudWatchClient;
102
+ /** Optional pino logger for emission failures. */
103
+ logger?: Logger;
104
+ }
105
+
106
+ /**
107
+ * Publish a single latency datum to CloudWatch.
108
+ *
109
+ * Best-effort: any error from the SDK is CAUGHT and logged. A CloudWatch outage
110
+ * MUST NOT crash the sync receive loop — the cadence safety net still picks up
111
+ * missed work, and metric blanks are recoverable; a crashed loop is not.
112
+ *
113
+ * Dimension: `TenantId` only. The `relativePath`/`sequenceNumber` fields on the
114
+ * input are intentionally NOT promoted to dimensions (cardinality), but they
115
+ * ride along on the failure log so an operator can correlate a missed datum to
116
+ * its 3-log chain entry.
117
+ */
118
+ export async function publishSyncLatencyMetric(
119
+ metric: SyncLatencyMetric,
120
+ opts: PublishSyncLatencyMetricOptions = {},
121
+ ): Promise<void> {
122
+ const datum: MetricDatum = {
123
+ MetricName: SYNC_LATENCY_METRIC_NAME,
124
+ Value: metric.latencySeconds,
125
+ Unit: "Seconds",
126
+ Timestamp: metric.timestamp,
127
+ Dimensions: [{ Name: "TenantId", Value: metric.tenantId }],
128
+ };
129
+
130
+ try {
131
+ const client = opts.client ?? getCloudWatchClient();
132
+ await client.send(
133
+ new PutMetricDataCommand({
134
+ Namespace: SYNC_METRIC_NAMESPACE,
135
+ MetricData: [datum],
136
+ }),
137
+ );
138
+ } catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err);
140
+ if (opts.logger) {
141
+ opts.logger.warn(
142
+ {
143
+ event: "sync.metric.publish_failed",
144
+ tenantId: metric.tenantId,
145
+ relativePath: metric.relativePath,
146
+ sequenceNumber: metric.sequenceNumber,
147
+ err: { message },
148
+ },
149
+ "failed to publish sync latency metric to CloudWatch",
150
+ );
151
+ } else {
152
+ console.error(
153
+ "Failed to publish sync latency metric to CloudWatch:",
154
+ message,
155
+ );
156
+ }
157
+ }
158
+ }
@@ -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
+ }