@indigoai-us/hq-cloud 6.14.49 → 6.15.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 (126) hide show
  1. package/.github/workflows/unreleased-commits-nag.yml +256 -0
  2. package/dist/bin/sync-runner-watch-loop.d.ts +12 -0
  3. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  4. package/dist/bin/sync-runner-watch-loop.js +153 -25
  5. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  6. package/dist/bin/sync-runner-watch-routes.d.ts +17 -0
  7. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  8. package/dist/bin/sync-runner-watch-routes.js +11 -0
  9. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  10. package/dist/bin/sync-runner-watch-routes.test.d.ts +2 -0
  11. package/dist/bin/sync-runner-watch-routes.test.d.ts.map +1 -0
  12. package/dist/bin/sync-runner-watch-routes.test.js +51 -0
  13. package/dist/bin/sync-runner-watch-routes.test.js.map +1 -0
  14. package/dist/bin/sync-runner.d.ts +11 -12
  15. package/dist/bin/sync-runner.d.ts.map +1 -1
  16. package/dist/bin/sync-runner.js.map +1 -1
  17. package/dist/bin/sync-runner.test.js +173 -52
  18. package/dist/bin/sync-runner.test.js.map +1 -1
  19. package/dist/cli/reindex-knowledge.d.ts +7 -0
  20. package/dist/cli/reindex-knowledge.d.ts.map +1 -0
  21. package/dist/cli/reindex-knowledge.js +389 -0
  22. package/dist/cli/reindex-knowledge.js.map +1 -0
  23. package/dist/cli/reindex-knowledge.test.d.ts +12 -0
  24. package/dist/cli/reindex-knowledge.test.d.ts.map +1 -0
  25. package/dist/cli/reindex-knowledge.test.js +260 -0
  26. package/dist/cli/reindex-knowledge.test.js.map +1 -0
  27. package/dist/cli/reindex.d.ts.map +1 -1
  28. package/dist/cli/reindex.js +8 -0
  29. package/dist/cli/reindex.js.map +1 -1
  30. package/dist/cli/rescue-classify-ordering.test.js +8 -2
  31. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  32. package/dist/cli/rescue-core.d.ts +85 -0
  33. package/dist/cli/rescue-core.d.ts.map +1 -1
  34. package/dist/cli/rescue-core.js +390 -1
  35. package/dist/cli/rescue-core.js.map +1 -1
  36. package/dist/cli/rescue-settings-reconcile.test.d.ts +2 -0
  37. package/dist/cli/rescue-settings-reconcile.test.d.ts.map +1 -0
  38. package/dist/cli/rescue-settings-reconcile.test.js +523 -0
  39. package/dist/cli/rescue-settings-reconcile.test.js.map +1 -0
  40. package/dist/index.d.ts +1 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +1 -1
  43. package/dist/index.js.map +1 -1
  44. package/dist/journal.d.ts.map +1 -1
  45. package/dist/journal.js +81 -51
  46. package/dist/journal.js.map +1 -1
  47. package/dist/journal.test.js +11 -19
  48. package/dist/journal.test.js.map +1 -1
  49. package/dist/sync/event-sync.d.ts +8 -25
  50. package/dist/sync/event-sync.d.ts.map +1 -1
  51. package/dist/sync/event-sync.js +23 -48
  52. package/dist/sync/event-sync.js.map +1 -1
  53. package/dist/sync/event-sync.test.js +6 -32
  54. package/dist/sync/event-sync.test.js.map +1 -1
  55. package/dist/sync/feature-flags.d.ts +20 -117
  56. package/dist/sync/feature-flags.d.ts.map +1 -1
  57. package/dist/sync/feature-flags.js +24 -147
  58. package/dist/sync/feature-flags.js.map +1 -1
  59. package/dist/sync/feature-flags.test.js +15 -61
  60. package/dist/sync/feature-flags.test.js.map +1 -1
  61. package/dist/sync/index.d.ts +1 -1
  62. package/dist/sync/index.d.ts.map +1 -1
  63. package/dist/sync/index.js +1 -1
  64. package/dist/sync/index.js.map +1 -1
  65. package/dist/sync/lease-client.d.ts +67 -0
  66. package/dist/sync/lease-client.d.ts.map +1 -0
  67. package/dist/sync/lease-client.js +158 -0
  68. package/dist/sync/lease-client.js.map +1 -0
  69. package/dist/sync/lease-client.test.d.ts +2 -0
  70. package/dist/sync/lease-client.test.d.ts.map +1 -0
  71. package/dist/sync/lease-client.test.js +119 -0
  72. package/dist/sync/lease-client.test.js.map +1 -0
  73. package/dist/sync/push-receiver.d.ts +8 -19
  74. package/dist/sync/push-receiver.d.ts.map +1 -1
  75. package/dist/sync/push-receiver.js +6 -50
  76. package/dist/sync/push-receiver.js.map +1 -1
  77. package/dist/sync/push-receiver.test.js +6 -57
  78. package/dist/sync/push-receiver.test.js.map +1 -1
  79. package/dist/sync/realtime-rollout.d.ts +85 -0
  80. package/dist/sync/realtime-rollout.d.ts.map +1 -0
  81. package/dist/sync/realtime-rollout.js +206 -0
  82. package/dist/sync/realtime-rollout.js.map +1 -0
  83. package/dist/sync/realtime-rollout.test.d.ts +2 -0
  84. package/dist/sync/realtime-rollout.test.d.ts.map +1 -0
  85. package/dist/sync/realtime-rollout.test.js +71 -0
  86. package/dist/sync/realtime-rollout.test.js.map +1 -0
  87. package/dist/sync/state-store.d.ts +40 -0
  88. package/dist/sync/state-store.d.ts.map +1 -0
  89. package/dist/sync/state-store.js +680 -0
  90. package/dist/sync/state-store.js.map +1 -0
  91. package/dist/sync/state-store.test.d.ts +2 -0
  92. package/dist/sync/state-store.test.d.ts.map +1 -0
  93. package/dist/sync/state-store.test.js +159 -0
  94. package/dist/sync/state-store.test.js.map +1 -0
  95. package/dist/types.d.ts +12 -0
  96. package/dist/types.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/bin/sync-runner-watch-loop.ts +176 -32
  99. package/src/bin/sync-runner-watch-routes.test.ts +71 -0
  100. package/src/bin/sync-runner-watch-routes.ts +27 -0
  101. package/src/bin/sync-runner.test.ts +188 -53
  102. package/src/bin/sync-runner.ts +11 -12
  103. package/src/cli/reindex-knowledge.test.ts +307 -0
  104. package/src/cli/reindex-knowledge.ts +450 -0
  105. package/src/cli/reindex.ts +9 -0
  106. package/src/cli/rescue-classify-ordering.test.ts +9 -3
  107. package/src/cli/rescue-core.ts +459 -1
  108. package/src/cli/rescue-settings-reconcile.test.ts +637 -0
  109. package/src/index.ts +2 -4
  110. package/src/journal.test.ts +19 -21
  111. package/src/journal.ts +87 -54
  112. package/src/sync/event-sync.test.ts +9 -44
  113. package/src/sync/event-sync.ts +26 -52
  114. package/src/sync/feature-flags.test.ts +22 -72
  115. package/src/sync/feature-flags.ts +32 -199
  116. package/src/sync/index.ts +2 -4
  117. package/src/sync/lease-client.test.ts +128 -0
  118. package/src/sync/lease-client.ts +207 -0
  119. package/src/sync/push-receiver.test.ts +6 -58
  120. package/src/sync/push-receiver.ts +9 -74
  121. package/src/sync/realtime-rollout.test.ts +86 -0
  122. package/src/sync/realtime-rollout.ts +262 -0
  123. package/src/sync/state-store.test.ts +194 -0
  124. package/src/sync/state-store.ts +727 -0
  125. package/src/types.ts +14 -0
  126. package/test/e2e/sync/transient-company-leg.test.ts +8 -4
@@ -0,0 +1,262 @@
1
+ import type { AuthTokenSource, FetchLike } from "./push-transport.js";
2
+
3
+ export const REALTIME_SYNC_CONTRACT_VERSION = 2 as const;
4
+ export const MAX_REALTIME_ROLLOUT_CACHE_MS = 30_000;
5
+ export const DEFAULT_REALTIME_AUTHORIZATION_TIMEOUT_MS = 15_000;
6
+
7
+ export interface RealtimeCapabilityVector {
8
+ hqCloud: string;
9
+ hqDesktop: string | "none";
10
+ protocol: 2;
11
+ journal: number;
12
+ durableApply: number;
13
+ }
14
+
15
+ export type RealtimeInventoryState =
16
+ | { state: "legacy"; v1Allowed: true }
17
+ | { state: "qualifying" | "frozen" | "seeding"; retryAfterMs: number }
18
+ | { state: "bootstrap-required"; snapshotDescriptorId: string }
19
+ | { state: "v2-ready"; cursor: string }
20
+ | { state: "paused"; reasonCode: string }
21
+ | { state: "reset-required"; resetId: string; snapshotDescriptorId: string }
22
+ | { state: "revoked"; localMaterialPolicy: string }
23
+ | { state: "restoring"; retryAfterMs: number }
24
+ | { state: "blocked"; reasonCode: string };
25
+
26
+ export type RealtimeInventoryGrant = RealtimeInventoryState & {
27
+ scopeUid: string;
28
+ scopeHandle: string;
29
+ scopeRevision: string;
30
+ };
31
+
32
+ export interface RealtimeInventory {
33
+ scopes: readonly RealtimeInventoryGrant[];
34
+ }
35
+
36
+ export interface RealtimeInventoryRequest {
37
+ /** A changed token must never reuse a positive inventory result. */
38
+ bearer: string;
39
+ deviceId: string;
40
+ capabilityVector: RealtimeCapabilityVector;
41
+ /** Identifies the active client configuration/capability selection. */
42
+ configurationFingerprint: string;
43
+ /** Identifies the scopes this guarded pass is about to touch. */
44
+ scopeFingerprint: string;
45
+ }
46
+
47
+ export interface RealtimeRolloutClientOptions {
48
+ apiUrl: string;
49
+ fetchImpl?: FetchLike;
50
+ now?: () => number;
51
+ maxCacheAgeMs?: number;
52
+ authorizationTimeoutMs?: number;
53
+ }
54
+
55
+ interface CachedInventory {
56
+ expiresAt: number;
57
+ value: RealtimeInventory;
58
+ }
59
+
60
+ function isRecord(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null && !Array.isArray(value);
62
+ }
63
+
64
+ function nonEmpty(value: unknown, field: string): string {
65
+ if (typeof value !== "string" || value.trim() === "") {
66
+ throw new Error(`realtime inventory: ${field} must be a non-empty string`);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ function exactKeys(value: Record<string, unknown>, allowed: readonly string[], label: string): void {
72
+ if (Object.keys(value).some((key) => !allowed.includes(key))) {
73
+ throw new Error(`realtime inventory: ${label} contains an unknown field`);
74
+ }
75
+ }
76
+
77
+ function parseState(value: unknown): RealtimeInventoryState {
78
+ if (!isRecord(value) || typeof value.state !== "string") {
79
+ throw new Error("realtime inventory: scope state is invalid");
80
+ }
81
+ switch (value.state) {
82
+ case "legacy":
83
+ exactKeys(value, ["state", "v1Allowed"], "legacy state");
84
+ if (value.v1Allowed !== true) throw new Error("realtime inventory: legacy must explicitly allow v1");
85
+ return { state: "legacy", v1Allowed: true };
86
+ case "qualifying":
87
+ case "frozen":
88
+ case "seeding":
89
+ case "restoring":
90
+ exactKeys(value, ["state", "retryAfterMs"], `${value.state} state`);
91
+ if (
92
+ typeof value.retryAfterMs !== "number" ||
93
+ !Number.isSafeInteger(value.retryAfterMs) ||
94
+ value.retryAfterMs < 0
95
+ ) {
96
+ throw new Error(`realtime inventory: ${value.state} retryAfterMs is invalid`);
97
+ }
98
+ return { state: value.state, retryAfterMs: value.retryAfterMs };
99
+ case "bootstrap-required":
100
+ exactKeys(value, ["state", "snapshotDescriptorId"], "bootstrap-required state");
101
+ return { state: value.state, snapshotDescriptorId: nonEmpty(value.snapshotDescriptorId, "snapshotDescriptorId") };
102
+ case "v2-ready":
103
+ exactKeys(value, ["state", "cursor"], "v2-ready state");
104
+ return { state: value.state, cursor: nonEmpty(value.cursor, "cursor") };
105
+ case "paused":
106
+ case "blocked":
107
+ exactKeys(value, ["state", "reasonCode"], `${value.state} state`);
108
+ return { state: value.state, reasonCode: nonEmpty(value.reasonCode, "reasonCode") };
109
+ case "reset-required":
110
+ exactKeys(value, ["state", "resetId", "snapshotDescriptorId"], "reset-required state");
111
+ return {
112
+ state: value.state,
113
+ resetId: nonEmpty(value.resetId, "resetId"),
114
+ snapshotDescriptorId: nonEmpty(value.snapshotDescriptorId, "snapshotDescriptorId"),
115
+ };
116
+ case "revoked":
117
+ exactKeys(value, ["state", "localMaterialPolicy"], "revoked state");
118
+ return { state: value.state, localMaterialPolicy: nonEmpty(value.localMaterialPolicy, "localMaterialPolicy") };
119
+ default:
120
+ throw new Error(`realtime inventory: unsupported scope state ${value.state}`);
121
+ }
122
+ }
123
+
124
+ export function parseRealtimeInventory(value: unknown): RealtimeInventory {
125
+ if (!isRecord(value)) throw new Error("realtime inventory: response is not an object");
126
+ exactKeys(value, ["scopes"], "response");
127
+ if (!Array.isArray(value.scopes)) throw new Error("realtime inventory: scopes is not an array");
128
+ const scopeHandles = new Set<string>();
129
+ const scopes = value.scopes.map((entry): RealtimeInventoryGrant => {
130
+ if (!isRecord(entry)) throw new Error("realtime inventory: scope grant is not an object");
131
+ const { scopeUid, scopeHandle, scopeRevision, ...state } = entry;
132
+ const parsedState = parseState(state);
133
+ const grant = {
134
+ scopeUid: nonEmpty(scopeUid, "scopeUid"),
135
+ scopeHandle: nonEmpty(scopeHandle, "scopeHandle"),
136
+ scopeRevision: nonEmpty(scopeRevision, "scopeRevision"),
137
+ ...parsedState,
138
+ } as RealtimeInventoryGrant;
139
+ if (scopeHandles.has(grant.scopeHandle)) {
140
+ throw new Error("realtime inventory: duplicate scopeHandle");
141
+ }
142
+ scopeHandles.add(grant.scopeHandle);
143
+ return grant;
144
+ });
145
+ return { scopes };
146
+ }
147
+
148
+ function inventoryCacheKey(request: RealtimeInventoryRequest): string {
149
+ const capability = request.capabilityVector;
150
+ return JSON.stringify([
151
+ request.bearer,
152
+ request.deviceId,
153
+ request.configurationFingerprint,
154
+ request.scopeFingerprint,
155
+ capability.hqCloud,
156
+ capability.hqDesktop,
157
+ capability.protocol,
158
+ capability.journal,
159
+ capability.durableApply,
160
+ ]);
161
+ }
162
+
163
+ function cacheAgeForInventory(inventory: RealtimeInventory, maximum: number): number {
164
+ return inventory.scopes.reduce((age, grant) => {
165
+ if (
166
+ grant.state === "qualifying" ||
167
+ grant.state === "frozen" ||
168
+ grant.state === "seeding" ||
169
+ grant.state === "restoring"
170
+ ) return Math.min(age, grant.retryAfterMs);
171
+ if (grant.state === "legacy" || grant.state === "v2-ready") return age;
172
+ return 0;
173
+ }, maximum);
174
+ }
175
+
176
+ /**
177
+ * Strict client for the authenticated inventory endpoint. The cache is keyed
178
+ * by bearer, device, capability/configuration, and the scope selection. A
179
+ * positive inventory record is only an optimization: callers must still mint
180
+ * a new lease before I/O, where the server strongly rechecks all authority.
181
+ */
182
+ export class RealtimeRolloutClient {
183
+ private readonly apiUrl: string;
184
+ private readonly fetchImpl: FetchLike;
185
+ private readonly now: () => number;
186
+ private readonly maxCacheAgeMs: number;
187
+ private readonly authorizationTimeoutMs: number;
188
+ private readonly cache = new Map<string, CachedInventory>();
189
+
190
+ constructor(options: RealtimeRolloutClientOptions) {
191
+ if (!options.apiUrl.trim()) throw new Error("realtime inventory: apiUrl is required");
192
+ this.apiUrl = options.apiUrl.replace(/\/+$/, "");
193
+ this.fetchImpl = options.fetchImpl ?? ((input, init) => (globalThis.fetch as unknown as FetchLike)(input, init));
194
+ this.now = options.now ?? Date.now;
195
+ this.maxCacheAgeMs = options.maxCacheAgeMs ?? MAX_REALTIME_ROLLOUT_CACHE_MS;
196
+ if (!Number.isSafeInteger(this.maxCacheAgeMs) || this.maxCacheAgeMs < 0 || this.maxCacheAgeMs > MAX_REALTIME_ROLLOUT_CACHE_MS) {
197
+ throw new Error(`realtime inventory: maxCacheAgeMs must be within 0-${MAX_REALTIME_ROLLOUT_CACHE_MS}`);
198
+ }
199
+ this.authorizationTimeoutMs = options.authorizationTimeoutMs ?? DEFAULT_REALTIME_AUTHORIZATION_TIMEOUT_MS;
200
+ if (!Number.isSafeInteger(this.authorizationTimeoutMs) || this.authorizationTimeoutMs <= 0) {
201
+ throw new Error("realtime inventory: authorizationTimeoutMs must be a positive integer");
202
+ }
203
+ }
204
+
205
+ invalidate(): void {
206
+ this.cache.clear();
207
+ }
208
+
209
+ async inventory(request: RealtimeInventoryRequest): Promise<RealtimeInventory> {
210
+ const now = this.now();
211
+ for (const [key, entry] of this.cache) {
212
+ if (entry.expiresAt <= now) this.cache.delete(key);
213
+ }
214
+ const cacheKey = inventoryCacheKey(request);
215
+ const cached = this.cache.get(cacheKey);
216
+ if (cached && now < cached.expiresAt) return cached.value;
217
+
218
+ const controller = new AbortController();
219
+ const timeout = setTimeout(() => controller.abort(), this.authorizationTimeoutMs);
220
+ let response: Awaited<ReturnType<FetchLike>>;
221
+ try {
222
+ response = await this.fetchImpl(`${this.apiUrl}/v2/sync/scopes`, {
223
+ method: "GET",
224
+ headers: {
225
+ Authorization: `Bearer ${request.bearer}`,
226
+ Accept: "application/json",
227
+ "X-HQ-Sync-Contract": String(REALTIME_SYNC_CONTRACT_VERSION),
228
+ "X-HQ-Device-ID": request.deviceId,
229
+ "X-HQ-Cloud-Version": request.capabilityVector.hqCloud,
230
+ "X-HQ-Desktop-Version": request.capabilityVector.hqDesktop,
231
+ "X-HQ-Sync-Journal": String(request.capabilityVector.journal),
232
+ "X-HQ-Sync-Durable-Apply": String(request.capabilityVector.durableApply),
233
+ },
234
+ signal: controller.signal,
235
+ });
236
+ } catch (error) {
237
+ throw new Error(`realtime inventory: GET /v2/sync/scopes failed: ${error instanceof Error ? error.message : String(error)}`);
238
+ } finally {
239
+ clearTimeout(timeout);
240
+ }
241
+ const text = await response.text().catch((error: unknown) => {
242
+ throw new Error(`realtime inventory: unable to read response: ${error instanceof Error ? error.message : String(error)}`);
243
+ });
244
+ if (!response.ok) throw new Error(`realtime inventory: GET /v2/sync/scopes failed (${response.status}): ${text.slice(0, 300)}`);
245
+ let parsed: unknown;
246
+ try {
247
+ parsed = JSON.parse(text);
248
+ } catch (error) {
249
+ throw new Error(`realtime inventory: response is not JSON: ${error instanceof Error ? error.message : String(error)}`);
250
+ }
251
+ const inventory = parseRealtimeInventory(parsed);
252
+ const cacheAgeMs = cacheAgeForInventory(inventory, this.maxCacheAgeMs);
253
+ if (cacheAgeMs > 0) this.cache.set(cacheKey, { value: inventory, expiresAt: this.now() + cacheAgeMs });
254
+ return inventory;
255
+ }
256
+ }
257
+
258
+ export async function resolveRealtimeBearer(source: AuthTokenSource): Promise<string> {
259
+ const bearer = typeof source === "function" ? await source() : source;
260
+ if (typeof bearer !== "string" || bearer.trim() === "") throw new Error("realtime authorization: access token is empty");
261
+ return bearer;
262
+ }
@@ -0,0 +1,194 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import { StateStore, StateStoreCorruptionError } from "./state-store.js";
6
+
7
+ interface TestState {
8
+ values: string[];
9
+ }
10
+
11
+ const RECORD = 7;
12
+ const roots: string[] = [];
13
+
14
+ afterEach(() => {
15
+ for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
16
+ });
17
+
18
+ function openStore(root = makeRoot(), threshold = 256): StateStore<TestState> {
19
+ return StateStore.open<TestState>({
20
+ rootDir: root,
21
+ scopeId: "prs_test_scope",
22
+ initialState: { values: [] },
23
+ maxRecordsBeforeCompaction: threshold,
24
+ reduce: (state, record) => {
25
+ if (record.type !== RECORD || typeof record.payload !== "string") {
26
+ throw new Error("test reducer received an invalid record");
27
+ }
28
+ return { values: [...state.values, record.payload] };
29
+ },
30
+ });
31
+ }
32
+
33
+ function makeRoot(): string {
34
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-store-test-"));
35
+ roots.push(root);
36
+ return root;
37
+ }
38
+
39
+ describe("StateStore WAL v3", () => {
40
+ it("writes protocol byte headers and appends frames without rewriting the snapshot hot path", () => {
41
+ const root = makeRoot();
42
+ const store = openStore(root);
43
+ const snapshot = path.join(store.stateDir, "snapshot-1.bin");
44
+ const wal = path.join(store.stateDir, "wal-1.bin");
45
+ const before = fs.statSync(snapshot);
46
+ const beforeWalSize = fs.statSync(wal).size;
47
+
48
+ store.append(RECORD, "first", 3);
49
+
50
+ const after = fs.statSync(snapshot);
51
+ const walBytes = fs.readFileSync(wal);
52
+ expect(after.ino).toBe(before.ino);
53
+ expect(fs.statSync(wal).size).toBeGreaterThan(beforeWalSize);
54
+ expect(walBytes.subarray(0, 8).toString("ascii")).toBe("HQWAL3\0\0");
55
+ expect(walBytes.readUInt32BE(80)).toBe(0x48515733);
56
+ expect(walBytes.readBigUInt64BE(88)).toBe(1n);
57
+ expect(walBytes.readUInt16BE(96)).toBe(RECORD);
58
+ expect(walBytes.readUInt16BE(98)).toBe(3);
59
+ });
60
+
61
+ it("truncates every incomplete tail at the last good frame boundary", () => {
62
+ const root = makeRoot();
63
+ const first = openStore(root);
64
+ first.append(RECORD, "durable");
65
+ const wal = path.join(first.stateDir, "wal-1.bin");
66
+ const validLength = fs.statSync(wal).size;
67
+ fs.appendFileSync(wal, Buffer.from([0x48, 0x51, 0x57]));
68
+
69
+ const recovered = openStore(root);
70
+
71
+ expect(recovered.getState()).toEqual({ values: ["durable"] });
72
+ expect(fs.statSync(wal).size).toBe(validLength);
73
+ });
74
+
75
+ it("never skips a complete frame with a bad checksum", () => {
76
+ const root = makeRoot();
77
+ const store = openStore(root);
78
+ store.append(RECORD, "bad");
79
+ const wal = path.join(store.stateDir, "wal-1.bin");
80
+ const bytes = fs.readFileSync(wal);
81
+ bytes[bytes.length - 1] ^= 0xff;
82
+ fs.writeFileSync(wal, bytes);
83
+
84
+ expect(() => openStore(root)).toThrow(StateStoreCorruptionError);
85
+ });
86
+
87
+ it("falls back to the retained last-good generation after a later complete frame corrupts", () => {
88
+ const root = makeRoot();
89
+ const store = openStore(root);
90
+ store.append(RECORD, "last-good");
91
+ store.compact();
92
+ store.append(RECORD, "corrupt-later");
93
+ const wal = path.join(store.stateDir, "wal-2.bin");
94
+ const bytes = fs.readFileSync(wal);
95
+ bytes[bytes.length - 1] ^= 0xff;
96
+ fs.writeFileSync(wal, bytes);
97
+
98
+ const recovered = openStore(root);
99
+
100
+ expect(recovered.getGeneration()).toBe(1);
101
+ expect(recovered.getState()).toEqual({ values: ["last-good"] });
102
+ });
103
+
104
+ it("allocates above a rejected generation when compaction resumes from last-good", () => {
105
+ const root = makeRoot();
106
+ const store = openStore(root);
107
+ store.append(RECORD, "last-good");
108
+ store.compact();
109
+ store.append(RECORD, "corrupt-later");
110
+ const wal = path.join(store.stateDir, "wal-2.bin");
111
+ const bytes = fs.readFileSync(wal);
112
+ bytes[bytes.length - 1] ^= 0xff;
113
+ fs.writeFileSync(wal, bytes);
114
+
115
+ const recovered = openStore(root);
116
+ recovered.compact();
117
+
118
+ expect(recovered.getGeneration()).toBe(3);
119
+ expect(openStore(root).getState()).toEqual({ values: ["last-good"] });
120
+ });
121
+
122
+ it("rejects a corrupted final frame length instead of truncating it as torn", () => {
123
+ const root = makeRoot();
124
+ const store = openStore(root);
125
+ store.append(RECORD, "durable");
126
+ const wal = path.join(store.stateDir, "wal-1.bin");
127
+ const bytes = fs.readFileSync(wal);
128
+ bytes.writeUInt32BE(bytes.readUInt32BE(84) + 1, 84);
129
+ fs.writeFileSync(wal, bytes);
130
+
131
+ expect(() => openStore(root)).toThrow(StateStoreCorruptionError);
132
+ expect(fs.statSync(wal).size).toBe(bytes.length);
133
+ });
134
+
135
+ it("chooses the highest fully valid generation even when an interrupted rename left a stale pointer", () => {
136
+ const root = makeRoot();
137
+ const store = openStore(root);
138
+ store.append(RECORD, "one");
139
+ store.compact();
140
+ fs.writeFileSync(path.join(store.stateDir, "current"), "1\n");
141
+
142
+ const recovered = openStore(root);
143
+
144
+ expect(recovered.getGeneration()).toBe(2);
145
+ expect(recovered.getState()).toEqual({ values: ["one"] });
146
+ });
147
+
148
+ it("compacts bounded WAL generations and preserves record sequence", () => {
149
+ const root = makeRoot();
150
+ const store = openStore(root, 2);
151
+ store.append(RECORD, "one");
152
+ store.append(RECORD, "two");
153
+ expect(store.getGeneration()).toBe(2);
154
+ store.append(RECORD, "three");
155
+
156
+ const recovered = openStore(root, 2);
157
+
158
+ expect(recovered.getGeneration()).toBe(2);
159
+ expect(recovered.getLastRecordSequence()).toBe(3);
160
+ expect(recovered.getState()).toEqual({ values: ["one", "two", "three"] });
161
+ });
162
+
163
+ it("serializes stale handles so records keep contiguous sequence numbers", () => {
164
+ const root = makeRoot();
165
+ const first = openStore(root);
166
+ const second = openStore(root);
167
+
168
+ first.append(RECORD, "one");
169
+ second.append(RECORD, "two");
170
+
171
+ expect(openStore(root).getState()).toEqual({ values: ["one", "two"] });
172
+ });
173
+
174
+ it("does not durably append a record rejected by the reducer", () => {
175
+ const root = makeRoot();
176
+ const store = openStore(root);
177
+
178
+ expect(() => store.append(99, "unsupported")).toThrow(/invalid record/);
179
+ expect(openStore(root).getState()).toEqual({ values: [] });
180
+ });
181
+
182
+ it("surfaces incompatible reducer records instead of falling back to stale state", () => {
183
+ const root = makeRoot();
184
+ const writer = openStore(root);
185
+ writer.append(RECORD, "newer-record");
186
+
187
+ expect(() => StateStore.open<TestState>({
188
+ rootDir: root,
189
+ scopeId: "prs_test_scope",
190
+ initialState: { values: [] },
191
+ reduce: () => { throw new Error("unsupported record type"); },
192
+ })).toThrow(/reducer rejected generation/);
193
+ });
194
+ });