@milaboratories/pl-middle-layer 1.67.6 → 1.68.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 (40) hide show
  1. package/dist/index.d.ts +3 -2
  2. package/dist/middle_layer/build_stamp.cjs +34 -0
  3. package/dist/middle_layer/build_stamp.cjs.map +1 -0
  4. package/dist/middle_layer/build_stamp.js +34 -0
  5. package/dist/middle_layer/build_stamp.js.map +1 -0
  6. package/dist/middle_layer/index.d.ts +3 -2
  7. package/dist/middle_layer/middle_layer.cjs +48 -0
  8. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  9. package/dist/middle_layer/middle_layer.d.ts +19 -0
  10. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  11. package/dist/middle_layer/middle_layer.js +48 -0
  12. package/dist/middle_layer/middle_layer.js.map +1 -1
  13. package/dist/middle_layer/ops.cjs +8 -2
  14. package/dist/middle_layer/ops.cjs.map +1 -1
  15. package/dist/middle_layer/ops.d.ts +35 -2
  16. package/dist/middle_layer/ops.d.ts.map +1 -1
  17. package/dist/middle_layer/ops.js +8 -2
  18. package/dist/middle_layer/ops.js.map +1 -1
  19. package/dist/middle_layer/project.cjs +163 -8
  20. package/dist/middle_layer/project.cjs.map +1 -1
  21. package/dist/middle_layer/project.d.ts +51 -1
  22. package/dist/middle_layer/project.d.ts.map +1 -1
  23. package/dist/middle_layer/project.js +165 -11
  24. package/dist/middle_layer/project.js.map +1 -1
  25. package/dist/middle_layer/tree_snapshot_store.cjs +283 -0
  26. package/dist/middle_layer/tree_snapshot_store.cjs.map +1 -0
  27. package/dist/middle_layer/tree_snapshot_store.d.ts +143 -0
  28. package/dist/middle_layer/tree_snapshot_store.d.ts.map +1 -0
  29. package/dist/middle_layer/tree_snapshot_store.js +280 -0
  30. package/dist/middle_layer/tree_snapshot_store.js.map +1 -0
  31. package/package.json +10 -10
  32. package/src/middle_layer/build_stamp.ts +36 -0
  33. package/src/middle_layer/index.ts +1 -0
  34. package/src/middle_layer/middle_layer.ts +77 -0
  35. package/src/middle_layer/ops.ts +46 -1
  36. package/src/middle_layer/project.ts +234 -14
  37. package/src/middle_layer/project_failsafe.test.ts +47 -0
  38. package/src/middle_layer/tree_snapshot_scenarios.test.ts +337 -0
  39. package/src/middle_layer/tree_snapshot_store.test.ts +331 -0
  40. package/src/middle_layer/tree_snapshot_store.ts +419 -0
@@ -11,7 +11,10 @@ import {
11
11
  ensureSignedResourceIdNotNull,
12
12
  field,
13
13
  isNotFoundError,
14
+ isPermissionDenied,
14
15
  isTimeoutOrCancelError,
16
+ isUnauthenticated,
17
+ parseSignedResourceId,
15
18
  Pl,
16
19
  resourceIdToString,
17
20
  ResourceTypeName,
@@ -25,7 +28,12 @@ import type { BlockPackSpecAny } from "../model";
25
28
  import { randomUUID } from "node:crypto";
26
29
  import { withProject, withProjectAuthored } from "../mutator/project";
27
30
  import type { ExtendedResourceData, PruningFunction } from "@milaboratories/pl-tree";
28
- import { SynchronizedTreeState, treeDumpStats } from "@milaboratories/pl-tree";
31
+ import {
32
+ SynchronizedTreeState,
33
+ treeDumpStats,
34
+ TreeStateUpdateError,
35
+ } from "@milaboratories/pl-tree";
36
+ import type { TreeSnapshotStore } from "./tree_snapshot_store";
29
37
  import { setTimeout } from "node:timers/promises";
30
38
  import { frontendData } from "./frontend_path";
31
39
  import type { NavigationState } from "@milaboratories/pl-model-common";
@@ -102,6 +110,21 @@ export class Project {
102
110
 
103
111
  private readonly abortController = new AbortController();
104
112
 
113
+ /** Tree change generation as of the snapshot currently on disk, or -1 when this session has
114
+ * not written one. Compared against the tree's current generation to skip writing a mirror
115
+ * that has not moved, which is what makes a project left open and idle go quiet. */
116
+ private snapshotGeneration: number;
117
+
118
+ /** When a snapshot was last attempted, for the periodic write's wall-clock gate.
119
+ *
120
+ * Zero, not the construction time, so the first write lands on the first maintenance pass
121
+ * after the tree has settled rather than a full interval later. Sessions shorter than one
122
+ * interval are the common case for a desktop app that is quit with a project still open, and
123
+ * seeding this to now would leave every one of them with nothing on disk. Set on every
124
+ * attempt, successful or not, so a persistently failing write retries at the interval rather
125
+ * than on every pass of the loop. */
126
+ private lastSnapshotAt = 0;
127
+
105
128
  private get destroyed() {
106
129
  return this.abortController.signal.aborted;
107
130
  }
@@ -111,7 +134,11 @@ export class Project {
111
134
  public readonly id: ProjectId /* Project ID, exposed to outer consumers, who work with ML */,
112
135
  readonly rid: SignedResourceId /* Contains signature, not exposed outside middle layer. */,
113
136
  private readonly projectTree: SynchronizedTreeState,
137
+ /** Whether this tree was seeded from a snapshot. When it was, the file on disk already
138
+ * holds generation 0, so an idle warm reopen writes nothing at all. */
139
+ restoredFromSnapshot: boolean = false,
114
140
  ) {
141
+ this.snapshotGeneration = restoredFromSnapshot ? 0 : -1;
115
142
  this.overview = projectOverview(
116
143
  projectTree.entry(),
117
144
  this.navigationStates,
@@ -129,6 +156,116 @@ export class Project {
129
156
  return "project:" + this.id.toString();
130
157
  }
131
158
 
159
+ /**
160
+ * Periodic snapshot write, carried on the maintenance loop rather than a timer of its own.
161
+ *
162
+ * Gated on the tree having changed since the last snapshot, so a project left open and idle
163
+ * writes once and then goes quiet, and on wall clock, so a project changing continuously
164
+ * writes at most once per interval.
165
+ */
166
+ private async maybeWriteSnapshot(): Promise<void> {
167
+ const store = this.env.treeSnapshots;
168
+ if (store === undefined) return;
169
+
170
+ const generation = this.projectTree.changeGeneration;
171
+ if (generation === this.snapshotGeneration) return;
172
+ if (Date.now() - this.lastSnapshotAt < this.env.ops.treeSnapshotOps.writeInterval) return;
173
+
174
+ await this.writeSnapshot(store, generation);
175
+ }
176
+
177
+ /**
178
+ * Starts the close-boundary snapshot and returns without waiting for the write.
179
+ *
180
+ * On top of the periodic write, since closing is a natural point to persist. Change-gated but
181
+ * not interval-gated: rewriting a mirror that has not moved is pure waste, but a mirror that
182
+ * has moved is worth keeping however recently the last write happened.
183
+ *
184
+ * The **capture is synchronous and happens here**, before the caller destroys the tree,
185
+ * because destroying it invalidates it and a later capture would be refused. Only the encode
186
+ * and the write are deferred: they are up to ten megabytes of work, and project switching
187
+ * should not wait for them. Deferring is safe only because a capture is a copy rather than a
188
+ * view of the tree.
189
+ *
190
+ * The returned promise never rejects. The caller is expected to keep it so it can be drained
191
+ * at shutdown, not to await it here.
192
+ */
193
+ public snapshotOnClose(): Promise<void> {
194
+ const store = this.env.treeSnapshots;
195
+ if (store === undefined) return Promise.resolve();
196
+
197
+ const generation = this.projectTree.changeGeneration;
198
+ if (generation === this.snapshotGeneration) return Promise.resolve();
199
+
200
+ let snapshot;
201
+ try {
202
+ snapshot = this.projectTree.capture(parseSignedResourceId(this.rid).signature);
203
+ } catch (e: unknown) {
204
+ this.env.logger.warn(
205
+ new Error(`failed to capture tree snapshot for project ${this.id} on close`, { cause: e }),
206
+ );
207
+ return Promise.resolve();
208
+ }
209
+
210
+ this.lastSnapshotAt = Date.now();
211
+
212
+ // Queued behind any in-flight periodic write rather than racing it. Both would land
213
+ // atomically, but the loser would be a wasted encode of the same mirror.
214
+ const previous = this.snapshotInFlight ?? Promise.resolve();
215
+ const write = previous.then(async () => {
216
+ if (this.snapshotGeneration >= generation) return; // the in-flight write covered it
217
+ if (await store.write(this.rid, snapshot)) this.snapshotGeneration = generation;
218
+ });
219
+
220
+ this.snapshotInFlight = write.finally(() => {
221
+ this.snapshotInFlight = undefined;
222
+ });
223
+ return this.snapshotInFlight;
224
+ }
225
+
226
+ /** In-flight snapshot write, if any. Both triggers can fire close together (the close write
227
+ * lands while the loop is mid-write), and encoding ten megabytes twice for the same mirror
228
+ * is worth avoiding. */
229
+ private snapshotInFlight: Promise<void> | undefined;
230
+
231
+ /** Serializes writes, and skips one that the in-flight write has already made redundant. */
232
+ private async writeSnapshot(store: TreeSnapshotStore, generation: number): Promise<void> {
233
+ // A loop, not a single check: with three or more callers, re-checking only once would let
234
+ // a waiter install its own promise over another's and clear the field while that write is
235
+ // still running. Two callers is the most that can happen today, so this is a guard against
236
+ // the next caller rather than a live fix.
237
+ while (this.snapshotInFlight !== undefined) {
238
+ await this.snapshotInFlight;
239
+ if (this.snapshotGeneration >= generation) return;
240
+ }
241
+
242
+ this.snapshotInFlight = this.captureAndWrite(store, generation).finally(() => {
243
+ this.snapshotInFlight = undefined;
244
+ });
245
+ await this.snapshotInFlight;
246
+ }
247
+
248
+ /** Captures and writes, never throwing: a snapshot is an optimisation and must not fail
249
+ * whatever triggered it. */
250
+ private async captureAndWrite(store: TreeSnapshotStore, generation: number): Promise<void> {
251
+ // Recorded before the attempt and regardless of its outcome, so a failing disk is retried
252
+ // once per interval instead of on every pass of the maintenance loop.
253
+ this.lastSnapshotAt = Date.now();
254
+ try {
255
+ // The root's signature is the session witness a later open compares against.
256
+ const snapshot = this.projectTree.capture(parseSignedResourceId(this.rid).signature);
257
+
258
+ // Only a real write advances the change gate. Marking the generation persisted after a
259
+ // failed write would tell both triggers the tree is already on disk, so one transient
260
+ // I/O error would cost the rest of the session, close write included.
261
+ if (await store.write(this.rid, snapshot)) this.snapshotGeneration = generation;
262
+ } catch (e: unknown) {
263
+ this.env.logger.warn(
264
+ new Error(`failed to capture tree snapshot for project ${this.id}`, { cause: e }),
265
+ );
266
+ }
267
+ }
268
+
132
269
  private async refreshLoop(): Promise<void> {
133
270
  let retryState: InfiniteRetryState | undefined;
134
271
  while (!this.destroyed) {
@@ -147,6 +284,8 @@ export class Project {
147
284
  signal: this.abortController.signal,
148
285
  });
149
286
 
287
+ await this.maybeWriteSnapshot();
288
+
150
289
  // Block computables housekeeping
151
290
  const overviewLight = await this.overviewLight.getValue();
152
291
  const existingBlocks = new Set(overviewLight.listOfBlocks);
@@ -727,18 +866,8 @@ export class Project {
727
866
  // Doing a no-op mutation to apply all migration and schema fixes
728
867
  await withProject(env.projectHelper, env.pl, rid, (_) => {}, { name: "init" });
729
868
 
730
- // Loading project tree
731
- const projectTree = await SynchronizedTreeState.init(
732
- env.pl,
733
- rid,
734
- {
735
- ...env.ops.defaultTreeOptions,
736
- pruning: projectTreePruning(env.logger),
737
- fieldFilter: projectTreeFieldFilter(),
738
- traverseStopRules: projectTreeTraverseStopRules(),
739
- },
740
- env.logger,
741
- );
869
+ // Loading project tree, warm from a persisted mirror when one is usable
870
+ const { tree: projectTree, restored } = await loadProjectTree(env, rid);
742
871
 
743
872
  if (env.ops.debugOps.dumpInitialTreeState) {
744
873
  const state = projectTree.dumpState();
@@ -748,8 +877,99 @@ export class Project {
748
877
  await fs.writeFile(`${resourceIdToString(rid)}.stats.json`, stringifyForDump(stats));
749
878
  }
750
879
 
751
- return new Project(env, id, rid, projectTree);
880
+ return new Project(env, id, rid, projectTree, restored);
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Opens the project tree, seeded from a persisted mirror when there is a usable one.
886
+ *
887
+ * Carries the fail-safe: if the restored tree fails its first refresh on authentication,
888
+ * permission or an inconsistency, the snapshot is deleted and the open is retried cold. Once,
889
+ * and only for that first refresh, so a genuinely dead session still surfaces as itself rather
890
+ * than being masked as a slow open.
891
+ *
892
+ * The fail-safe is what bounds every case the cache key does not cover: a rotated master
893
+ * secret, a revoked grant, a snapshot valid in itself but no longer matching what the backend
894
+ * will serve. Without it, an explicit-root tree propagates the refresh failure rather than
895
+ * healing, so the project would fail to open on every attempt until someone deleted the cache
896
+ * directory by hand.
897
+ */
898
+ async function loadProjectTree(
899
+ env: MiddleLayerEnvironment,
900
+ rid: SignedResourceId,
901
+ ): Promise<{ tree: SynchronizedTreeState; restored: boolean }> {
902
+ const treeOps = {
903
+ ...env.ops.defaultTreeOptions,
904
+ pruning: projectTreePruning(env.logger),
905
+ fieldFilter: projectTreeFieldFilter(),
906
+ traverseStopRules: projectTreeTraverseStopRules(),
907
+ };
908
+ const cold = async () => ({
909
+ tree: await SynchronizedTreeState.init(env.pl, rid, treeOps, env.logger),
910
+ restored: false,
911
+ });
912
+
913
+ const store = env.treeSnapshots;
914
+ if (store === undefined) return await cold();
915
+
916
+ const snapshot = await store.read(rid);
917
+ if (!snapshot.ok) {
918
+ env.logger.info(`project tree opening cold, snapshot miss: ${snapshot.miss}`);
919
+ return await cold();
920
+ }
921
+
922
+ try {
923
+ const tree = await SynchronizedTreeState.init(
924
+ env.pl,
925
+ rid,
926
+ { ...treeOps, restoreFrom: snapshot.tree },
927
+ env.logger,
928
+ );
929
+
930
+ // Read from the tree rather than assumed: a snapshot can be handed over and still be
931
+ // refused, in which case this open was cold and the file on disk does not describe the
932
+ // tree we now hold.
933
+ const restored = tree.wasRestoredFromSnapshot;
934
+ if (restored) store.noteRestored();
935
+ else env.logger.info("project tree opening cold: the snapshot was not applied");
936
+
937
+ return { tree, restored };
938
+ } catch (e: unknown) {
939
+ // Retry cold on ANY failure of the warm open, not only on the classified ones. A cold open
940
+ // is exactly what this code did before snapshots existed, so the retry cannot regress
941
+ // anything, whereas rethrowing here leaves a project that fails to open on every attempt
942
+ // until someone deletes the cache directory by hand: the snapshot stays on disk and the
943
+ // next open restores it and fails the same way. That is the outcome this fail-safe exists
944
+ // to prevent, and the error classes that can reach here are not a closed set.
945
+ env.logger.warn(
946
+ new Error("restored project tree failed its first refresh, opening cold", { cause: e }),
947
+ );
948
+
949
+ // Deleting is reserved for failures that implicate the snapshot itself. Anything else (a
950
+ // timeout, a dropped connection) says nothing about the file, and throwing it away would
951
+ // destroy a mirror that is still good, along with the evidence a later signature refresh
952
+ // would repair.
953
+ if (isSnapshotFailsafeError(e)) await store.discard(rid);
954
+
955
+ return await cold();
956
+ }
957
+ }
958
+
959
+ /** The failures that implicate the snapshot rather than the link or the session: a rotated
960
+ * master secret, a revoked grant, or state the tree cannot reconcile. Only these delete the
961
+ * file; every other failure still falls back to a cold open, it just keeps the file.
962
+ *
963
+ * The cause chain is walked because a wrapper anywhere between the tree update and here would
964
+ * otherwise silently disarm the inconsistency arm. `isUnauthenticated` and `isPermissionDenied`
965
+ * do their own one-level unwrapping. */
966
+ export function isSnapshotFailsafeError(e: unknown): boolean {
967
+ if (isUnauthenticated(e) || isPermissionDenied(e)) return true;
968
+ for (let cause: unknown = e, depth = 0; cause !== undefined && depth < 8; depth++) {
969
+ if (cause instanceof TreeStateUpdateError) return true;
970
+ cause = (cause as { cause?: unknown } | null)?.cause;
752
971
  }
972
+ return false;
753
973
  }
754
974
 
755
975
  export function projectTreePruning(logger: MiLogger): PruningFunction {
@@ -0,0 +1,47 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { TreeStateUpdateError } from "@milaboratories/pl-tree";
3
+ import { PermissionDeniedError, UnauthenticatedError } from "@milaboratories/pl-client";
4
+ import { isSnapshotFailsafeError } from "./project";
5
+
6
+ /**
7
+ * Which first-refresh failures implicate the snapshot itself, and so delete it.
8
+ *
9
+ * Every failure retries cold regardless, so a mistake here cannot leave a project unopenable.
10
+ * What it can do is either destroy a good mirror over a transient link problem, or keep a bad
11
+ * one and pay a wasted warm attempt on every open.
12
+ */
13
+ describe("the fail-safe classification", () => {
14
+ test("authentication and permission failures implicate the snapshot", () => {
15
+ expect(isSnapshotFailsafeError(new UnauthenticatedError("token expired"))).toBe(true);
16
+ expect(isSnapshotFailsafeError(new PermissionDeniedError("grant revoked"))).toBe(true);
17
+ });
18
+
19
+ test("a tree inconsistency implicates the snapshot", () => {
20
+ expect(isSnapshotFailsafeError(new TreeStateUpdateError("orphan resource"))).toBe(true);
21
+ });
22
+
23
+ test("a wrapped tree inconsistency still implicates it", () => {
24
+ // The cause chain is walked precisely so that a wrapper introduced anywhere between the
25
+ // tree update and the caller cannot silently disarm this arm of the fail-safe.
26
+ const wrapped = new Error("refresh failed", {
27
+ cause: new Error("while loading", { cause: new TreeStateUpdateError("orphan resource") }),
28
+ });
29
+ expect(isSnapshotFailsafeError(wrapped)).toBe(true);
30
+ });
31
+
32
+ test("a link failure does not, so the mirror is kept", () => {
33
+ expect(isSnapshotFailsafeError(new Error("socket hang up"))).toBe(false);
34
+ expect(isSnapshotFailsafeError(new Error("deadline exceeded"))).toBe(false);
35
+ });
36
+
37
+ test("nothing exotic throws", () => {
38
+ // A cause chain that loops must not hang the classifier.
39
+ const looped: { cause?: unknown } = {};
40
+ looped.cause = looped;
41
+
42
+ expect(isSnapshotFailsafeError(looped)).toBe(false);
43
+ expect(isSnapshotFailsafeError(undefined)).toBe(false);
44
+ expect(isSnapshotFailsafeError(null)).toBe(false);
45
+ expect(isSnapshotFailsafeError("a string")).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,337 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { TestHelpers } from "@milaboratories/pl-client";
3
+ import { randomUUID } from "node:crypto";
4
+ import fsp from "node:fs/promises";
5
+ import path from "node:path";
6
+ import * as tp from "node:timers/promises";
7
+ import { MiddleLayer } from "./middle_layer";
8
+ import type { ProjectId } from "../model/project_model";
9
+ import type { TreeSnapshotOps } from "./ops";
10
+
11
+ /**
12
+ * The acceptance scenarios that need a live backend. Each one runs several middle layers in
13
+ * turn against one backend root and one work folder, which is what makes a reopen a reopen:
14
+ * the projects are the same projects and the snapshot directory is the same directory.
15
+ *
16
+ * `MiddleLayer.close()` closes the client it was given, so every middle layer here gets its
17
+ * own client. They share a session, because the test client reuses one cached token, and a
18
+ * shared session is exactly what a warm reopen needs.
19
+ */
20
+
21
+ const WORK_ROOT = path.resolve(import.meta.dirname, "..", "..", "work");
22
+
23
+ /** Short intervals so the periodic write is observable inside a test rather than in five
24
+ * minutes. Everything else is left at its default. */
25
+ function fastSnapshots(overrides: Partial<TreeSnapshotOps> = {}): TreeSnapshotOps {
26
+ return {
27
+ enabled: true,
28
+ writeInterval: 250,
29
+ maxSizeBytes: 256 * 1024 * 1024,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ type Scenario = {
35
+ /** Opens another middle layer, on its own client, over the same root and work folder. */
36
+ open: (treeSnapshotOps?: TreeSnapshotOps) => Promise<MiddleLayer>;
37
+ /** Closes one, so it is not closed twice during cleanup. */
38
+ close: (ml: MiddleLayer) => Promise<void>;
39
+ /** Creates a project, opens it, and lets its tree settle so there is a mirror worth writing.
40
+ * Tracked for cleanup. */
41
+ project: (ml: MiddleLayer, label: string) => Promise<ProjectId>;
42
+ /** The shared snapshot directory. */
43
+ snapshotDir: string;
44
+ };
45
+
46
+ /**
47
+ * Each middle layer gets its own client, because `MiddleLayer.close()` closes the client it
48
+ * was given, and a reopen has to survive that.
49
+ *
50
+ * The clients use the caller's own root rather than a temporary one: `PlClient.init` with an
51
+ * `alternativeRoot` name always creates a fresh ephemeral root and overwrites the field, so a
52
+ * second client asking for the same name gets an empty project list, which is precisely the
53
+ * state a reopen must not start from. The projects created here are deleted afterwards.
54
+ */
55
+ async function withScenario(body: (scenario: Scenario) => Promise<void>): Promise<void> {
56
+ const workFolder = path.resolve(WORK_ROOT, randomUUID());
57
+ const live = new Set<MiddleLayer>();
58
+ const projects = new Set<ProjectId>();
59
+
60
+ const openMl = async (treeSnapshotOps: TreeSnapshotOps) => {
61
+ const client = await TestHelpers.getTestClient();
62
+ const ml = await MiddleLayer.init(client, workFolder, {
63
+ defaultTreeOptions: { pollingInterval: 250, stopPollingDelay: 500 },
64
+ devBlockUpdateRecheckInterval: 300,
65
+ projectRefreshInterval: 250,
66
+ localSecret: MiddleLayer.generateLocalSecret(),
67
+ localProjections: [],
68
+ openFileDialogCallback: () => {
69
+ throw new Error("Not implemented.");
70
+ },
71
+ treeSnapshotOps,
72
+ });
73
+ live.add(ml);
74
+ return ml;
75
+ };
76
+
77
+ const scenario: Scenario = {
78
+ snapshotDir: path.join(workFolder, "treeSnapshots"),
79
+ open: async (treeSnapshotOps = fastSnapshots()) => await openMl(treeSnapshotOps),
80
+ close: async (ml: MiddleLayer) => {
81
+ live.delete(ml);
82
+ await ml.close();
83
+ },
84
+ project: async (ml: MiddleLayer, label: string) => {
85
+ const id = await ml.createProject({ label: `${label} ${randomUUID()}` });
86
+ projects.add(id);
87
+ await ml.openProject(id);
88
+ // Reading the overview forces the tree to load and the computables to resolve.
89
+ await ml.getOpenedProject(id).overview.awaitStableValue();
90
+ return id;
91
+ },
92
+ };
93
+
94
+ try {
95
+ await body(scenario);
96
+ } finally {
97
+ for (const ml of live) await ml.close().catch(() => {});
98
+
99
+ // The root outlives the test, so the projects have to be cleaned up explicitly.
100
+ if (projects.size > 0) {
101
+ const cleanup = await openMl({ ...fastSnapshots(), enabled: false });
102
+ try {
103
+ for (const id of projects) await cleanup.deleteProject(id).catch(() => {});
104
+ } finally {
105
+ await cleanup.close().catch(() => {});
106
+ }
107
+ }
108
+ await fsp.rm(workFolder, { recursive: true, force: true });
109
+ }
110
+ }
111
+
112
+ async function snapshotFiles(dir: string): Promise<string[]> {
113
+ try {
114
+ return (await fsp.readdir(dir)).sort();
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ describe("reopening a project", () => {
121
+ test("the close write makes the next open warm", async () => {
122
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
123
+ const first = await open();
124
+ const id = await project(first, "warm reopen");
125
+ await first.closeProject(id);
126
+
127
+ // The close write is started, not awaited, so that closing a project does not sit behind
128
+ // an encode. Shutdown drains it, which is what makes it observable here.
129
+ await close(first);
130
+
131
+ // One file for the one project, written at the close boundary.
132
+ expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
133
+ expect(first.treeSnapshotStats?.writes).toBeGreaterThanOrEqual(1);
134
+
135
+ const second = await open();
136
+ await second.openProject(id);
137
+ // The claim: the reopen read the snapshot and restored from it.
138
+ expect(second.treeSnapshotStats?.hits).toBe(1);
139
+ expect(second.treeSnapshotStats?.misses.absent).toBe(0);
140
+ // Read is not enough: this is the tree actually accepting the mirror.
141
+ expect(second.treeSnapshotStats?.restores).toBe(1);
142
+
143
+ // And the project is genuinely usable, not merely restored.
144
+ const overview = await second.getOpenedProject(id).overview.awaitStableValue();
145
+ expect(overview.meta.label).toContain("warm reopen");
146
+ await close(second);
147
+ });
148
+ });
149
+
150
+ test("closing a project does not wait for its snapshot to be written", async () => {
151
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
152
+ const ml = await open();
153
+ const id = await project(ml, "unblocked close");
154
+
155
+ await ml.closeProject(id);
156
+ // The capture happened synchronously inside closeProject, but the encode and write are
157
+ // deferred, so the file is normally not there yet. Asserted as "not blocked on it"
158
+ // rather than "definitely absent": a tiny mirror can beat us to the assertion, and the
159
+ // point is that close does not await, not that the write is slow.
160
+ const writesRightAfterClose = ml.treeSnapshotStats!.writes;
161
+
162
+ await close(ml); // drains
163
+ expect(ml.treeSnapshotStats!.writes).toBeGreaterThanOrEqual(writesRightAfterClose);
164
+ expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
165
+ });
166
+ });
167
+
168
+ test("project switching: both returns hit", async () => {
169
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
170
+ const first = await open();
171
+ const a = await project(first, "A");
172
+ const b = await project(first, "B");
173
+ await first.closeProject(a);
174
+ await first.closeProject(b);
175
+ await close(first); // drains both deferred close writes
176
+ expect(await snapshotFiles(snapshotDir)).toHaveLength(2);
177
+
178
+ const second = await open();
179
+ await second.openProject(a);
180
+ await second.openProject(b);
181
+ expect(second.treeSnapshotStats?.hits).toBe(2);
182
+ expect(second.treeSnapshotStats?.restores).toBe(2);
183
+ await close(second);
184
+ });
185
+ });
186
+
187
+ test("a killed process is covered by the periodic write", async () => {
188
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
189
+ const first = await open(fastSnapshots({ writeInterval: 250 }));
190
+ const id = await project(first, "killed");
191
+
192
+ // Never closed, standing in for a reboot, a lost connection or a kill. The periodic
193
+ // write on the maintenance loop is the only thing that can have saved this.
194
+ //
195
+ // What this does NOT reproduce is the relaunch: both middle layers here share a session,
196
+ // because the test client reuses one cached token. In production the equivalent is the
197
+ // desktop app reconnecting with the JWT it persisted, which keeps the session and so the
198
+ // signatures; a change that made relaunch re-login instead would break the warm reopen
199
+ // and no assertion here would notice.
200
+ await tp.setTimeout(1500);
201
+ expect(first.treeSnapshotStats?.writes).toBeGreaterThanOrEqual(1);
202
+ expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
203
+
204
+ // Closing the middle layer without closing the project: close() must not snapshot, so
205
+ // whatever is on disk came from the periodic write.
206
+ const writesBefore = first.treeSnapshotStats!.writes;
207
+ await close(first);
208
+ expect(first.treeSnapshotStats?.writes).toBe(writesBefore);
209
+
210
+ const second = await open();
211
+ await second.openProject(id);
212
+ expect(second.treeSnapshotStats?.hits).toBe(1);
213
+ expect(second.treeSnapshotStats?.restores).toBe(1);
214
+ await close(second);
215
+ });
216
+ });
217
+ });
218
+
219
+ describe("write cadence", () => {
220
+ test("open and idle writes once, then goes quiet", async () => {
221
+ await withScenario(async ({ open, close, project }) => {
222
+ const ml = await open(fastSnapshots({ writeInterval: 250 }));
223
+ await project(ml, "idle");
224
+
225
+ // Several intervals of nothing happening.
226
+ await tp.setTimeout(2000);
227
+
228
+ // Exactly one, not "at most one": a cold open loads a tree, so the change gate is open
229
+ // and the first maintenance pass writes. Asserting <= 1 would pass with zero writes and
230
+ // prove nothing about the periodic trigger existing at all.
231
+ expect(ml.treeSnapshotStats?.writes).toBe(1);
232
+
233
+ // And then quiet, because the gate closes on a mirror that has not moved.
234
+ await tp.setTimeout(1500);
235
+ expect(ml.treeSnapshotStats?.writes).toBe(1);
236
+ await close(ml);
237
+ });
238
+ });
239
+
240
+ test("a project that keeps changing writes at most once per interval", async () => {
241
+ await withScenario(async ({ open, close, project }) => {
242
+ const ml = await open(fastSnapshots({ writeInterval: 1000 }));
243
+ const id = await project(ml, "changing");
244
+
245
+ // Keep the tree moving for roughly three intervals.
246
+ const until = Date.now() + 3000;
247
+ let n = 0;
248
+ while (Date.now() < until) {
249
+ await ml.setProjectMeta(id, { label: `changing ${n++}` });
250
+ await tp.setTimeout(150);
251
+ }
252
+
253
+ // Bounded by wall clock, not by how often the tree changed.
254
+ expect(ml.treeSnapshotStats!.writes).toBeLessThanOrEqual(4);
255
+ expect(n).toBeGreaterThan(4);
256
+ await close(ml);
257
+ });
258
+ });
259
+ });
260
+
261
+ describe("when the snapshot cannot be used", () => {
262
+ test("a rotated signature is a miss, the file is kept, and the project still opens", async () => {
263
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
264
+ const first = await open();
265
+ const id = await project(first, "rotated");
266
+ await first.closeProject(id);
267
+ await close(first);
268
+
269
+ // Rewrite the witness in the header to stand in for a session that has ended. Its
270
+ // offset is fixed: magic (4) + schema (2) + flags (2), then a u16 length and the bytes.
271
+ const [name] = await snapshotFiles(snapshotDir);
272
+ const file = path.join(snapshotDir, name);
273
+ const bytes = await fsp.readFile(file);
274
+ const witnessLength = bytes.readUInt16LE(8);
275
+ expect(witnessLength).toBeGreaterThan(0);
276
+ bytes[10] = bytes[10] ^ 0xff;
277
+ await fsp.writeFile(file, bytes);
278
+
279
+ const second = await open();
280
+ await second.openProject(id);
281
+ expect(second.treeSnapshotStats?.hits).toBe(0);
282
+ expect(second.treeSnapshotStats?.restores).toBe(0);
283
+ expect(second.treeSnapshotStats?.misses["session-rotated"]).toBe(1);
284
+
285
+ // Kept: the bodies are still good, only the signatures died.
286
+ expect(await snapshotFiles(snapshotDir)).toHaveLength(1);
287
+
288
+ const overview = await second.getOpenedProject(id).overview.awaitStableValue();
289
+ expect(overview.meta.label).toContain("rotated");
290
+ await close(second);
291
+ });
292
+ });
293
+
294
+ test("a truncated snapshot opens cold without raising", async () => {
295
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
296
+ const first = await open();
297
+ const id = await project(first, "poisoned");
298
+ await first.closeProject(id);
299
+ await close(first);
300
+
301
+ const [name] = await snapshotFiles(snapshotDir);
302
+ const file = path.join(snapshotDir, name);
303
+ const bytes = await fsp.readFile(file);
304
+ await fsp.writeFile(file, bytes.subarray(0, Math.floor(bytes.length / 2)));
305
+
306
+ const second = await open();
307
+ await second.openProject(id);
308
+ expect(second.treeSnapshotStats?.hits).toBe(0);
309
+ expect(second.treeSnapshotStats?.restores).toBe(0);
310
+
311
+ const overview = await second.getOpenedProject(id).overview.awaitStableValue();
312
+ expect(overview.meta.label).toContain("poisoned");
313
+ await close(second);
314
+ });
315
+ });
316
+ });
317
+
318
+ describe("the kill switch", () => {
319
+ test("nothing is read or written when snapshots are off", async () => {
320
+ await withScenario(async ({ open, close, project, snapshotDir }) => {
321
+ const first = await open(fastSnapshots({ enabled: false }));
322
+ const id = await project(first, "disabled");
323
+ await tp.setTimeout(1000);
324
+ await first.closeProject(id);
325
+
326
+ expect(first.treeSnapshotStats).toBeUndefined();
327
+ expect(await snapshotFiles(snapshotDir)).toStrictEqual([]);
328
+ await close(first);
329
+
330
+ // And a project created while off still opens once it is back on.
331
+ const second = await open();
332
+ await second.openProject(id);
333
+ expect(second.treeSnapshotStats?.misses.absent).toBe(1);
334
+ await close(second);
335
+ });
336
+ });
337
+ });