@milaboratories/pl-middle-layer 1.71.15 → 1.72.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 (85) hide show
  1. package/dist/index.cjs +10 -5
  2. package/dist/index.d.ts +5 -4
  3. package/dist/index.js +3 -2
  4. package/dist/middle_layer/build_stamp.cjs +1 -1
  5. package/dist/middle_layer/build_stamp.js +1 -1
  6. package/dist/middle_layer/folders.cjs +631 -0
  7. package/dist/middle_layer/folders.cjs.map +1 -0
  8. package/dist/middle_layer/folders.d.ts +39 -0
  9. package/dist/middle_layer/folders.d.ts.map +1 -0
  10. package/dist/middle_layer/folders.js +613 -0
  11. package/dist/middle_layer/folders.js.map +1 -0
  12. package/dist/middle_layer/index.cjs +3 -0
  13. package/dist/middle_layer/index.d.ts +4 -3
  14. package/dist/middle_layer/index.js +2 -1
  15. package/dist/middle_layer/middle_layer.cjs +585 -370
  16. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  17. package/dist/middle_layer/middle_layer.d.ts +215 -122
  18. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  19. package/dist/middle_layer/middle_layer.js +593 -378
  20. package/dist/middle_layer/middle_layer.js.map +1 -1
  21. package/dist/middle_layer/project_list.cjs +36 -19
  22. package/dist/middle_layer/project_list.cjs.map +1 -1
  23. package/dist/middle_layer/project_list.d.ts +1 -1
  24. package/dist/middle_layer/project_list.d.ts.map +1 -1
  25. package/dist/middle_layer/project_list.js +37 -21
  26. package/dist/middle_layer/project_list.js.map +1 -1
  27. package/dist/middle_layer/sharing_list.cjs +123 -93
  28. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  29. package/dist/middle_layer/sharing_list.d.ts +43 -19
  30. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  31. package/dist/middle_layer/sharing_list.js +123 -93
  32. package/dist/middle_layer/sharing_list.js.map +1 -1
  33. package/dist/middle_layer/template_list.cjs +45 -20
  34. package/dist/middle_layer/template_list.cjs.map +1 -1
  35. package/dist/middle_layer/template_list.d.ts +7 -15
  36. package/dist/middle_layer/template_list.d.ts.map +1 -1
  37. package/dist/middle_layer/template_list.js +44 -21
  38. package/dist/middle_layer/template_list.js.map +1 -1
  39. package/dist/model/index.cjs +7 -5
  40. package/dist/model/index.d.ts +2 -2
  41. package/dist/model/index.js +2 -2
  42. package/dist/model/sharing_model.cjs +46 -20
  43. package/dist/model/sharing_model.cjs.map +1 -1
  44. package/dist/model/sharing_model.d.ts +125 -55
  45. package/dist/model/sharing_model.d.ts.map +1 -1
  46. package/dist/model/sharing_model.js +40 -16
  47. package/dist/model/sharing_model.js.map +1 -1
  48. package/dist/mutator/list.cjs +30 -0
  49. package/dist/mutator/list.cjs.map +1 -0
  50. package/dist/mutator/list.js +29 -0
  51. package/dist/mutator/list.js.map +1 -0
  52. package/dist/mutator/project.cjs +14 -1
  53. package/dist/mutator/project.cjs.map +1 -1
  54. package/dist/mutator/project.d.ts.map +1 -1
  55. package/dist/mutator/project.js +15 -2
  56. package/dist/mutator/project.js.map +1 -1
  57. package/dist/mutator/sharing.cjs +130 -76
  58. package/dist/mutator/sharing.cjs.map +1 -1
  59. package/dist/mutator/sharing.js +130 -76
  60. package/dist/mutator/sharing.js.map +1 -1
  61. package/dist/mutator/template.cjs +24 -17
  62. package/dist/mutator/template.cjs.map +1 -1
  63. package/dist/mutator/template.js +26 -20
  64. package/dist/mutator/template.js.map +1 -1
  65. package/package.json +18 -18
  66. package/src/middle_layer/folders.test.ts +1068 -0
  67. package/src/middle_layer/folders.ts +1059 -0
  68. package/src/middle_layer/folders_read.test.ts +359 -0
  69. package/src/middle_layer/index.ts +3 -2
  70. package/src/middle_layer/middle_layer.ts +813 -547
  71. package/src/middle_layer/project_list.test.ts +183 -0
  72. package/src/middle_layer/project_list.ts +58 -21
  73. package/src/middle_layer/sharing.test.ts +183 -0
  74. package/src/middle_layer/sharing_list.ts +208 -152
  75. package/src/middle_layer/template_list.test.ts +148 -0
  76. package/src/middle_layer/template_list.ts +72 -32
  77. package/src/middle_layer/templates.test.ts +76 -45
  78. package/src/model/sharing_model.test.ts +69 -2
  79. package/src/model/sharing_model.ts +161 -73
  80. package/src/mutator/list.ts +36 -0
  81. package/src/mutator/project-v3.test.ts +3 -1
  82. package/src/mutator/project.ts +13 -2
  83. package/src/mutator/sharing.ts +193 -120
  84. package/src/mutator/template.ts +37 -29
  85. package/src/test/with_ml.ts +73 -16
@@ -0,0 +1,359 @@
1
+ /**
2
+ * The two decisions of the folder read and write that need no backend: what the join publishes
3
+ * for a given folder resource, and when a plan is allowed to become a write.
4
+ *
5
+ * `foldersListing` is the whole computable body and `commitFoldersMove` is the
6
+ * whole move contract, so both are fed here with plain objects instead of a synchronized tree
7
+ * and a transaction.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import type {
12
+ FolderId,
13
+ FoldersMovePlan,
14
+ FoldersMovePlanResult,
15
+ FoldersView,
16
+ } from "@milaboratories/pl-model-middle-layer";
17
+ import {
18
+ FOLDERS_SCHEMA_VERSION,
19
+ commitFoldersMove,
20
+ decodeFoldersDocument,
21
+ encodeFoldersDocument,
22
+ healFolders,
23
+ planFoldersMove,
24
+ } from "@milaboratories/pl-model-middle-layer";
25
+ import type { ProjectId, ProjectListEntry } from "../model/project_model";
26
+ import type { TemplateId, TemplateListEntry } from "./template_list";
27
+ import { FoldersDocumentField, foldersListing, foldersLocalSubtree } from "./folders";
28
+
29
+ // ── Helpers ──────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Stand-in for the folder singleton's tree node, carrying exactly what the read reads.
33
+ *
34
+ * `raw` is the stored document; `noBlob` is a document field pointing at a resource that carries
35
+ * no blob at all; `fieldPresent` alone is a document field whose value has not arrived.
36
+ */
37
+ function foldersNode(document: {
38
+ readonly raw?: string;
39
+ readonly noBlob?: boolean;
40
+ readonly fieldPresent?: boolean;
41
+ }) {
42
+ const valuePresent = document.noBlob === true || document.raw !== undefined;
43
+ const fieldPresent = document.fieldPresent ?? valuePresent;
44
+ return {
45
+ listDynamicFields: () => (fieldPresent ? [FoldersDocumentField] : []),
46
+ traverse: (step: { field: string; stableIfNotFound: true }) => {
47
+ if (step.stableIfNotFound !== true)
48
+ throw new Error("a missing document field must be a stable absence, not an unstable one");
49
+ if (step.field !== FoldersDocumentField || !valuePresent) return undefined;
50
+ return { getDataAsString: () => (document.noBlob === true ? undefined : document.raw) };
51
+ },
52
+ };
53
+ }
54
+
55
+ function entry(id: string, label: string, modified = 1): ProjectListEntry {
56
+ return {
57
+ id: id as ProjectId,
58
+ created: new Date(1),
59
+ lastModified: new Date(modified),
60
+ opened: false,
61
+ meta: { label },
62
+ };
63
+ }
64
+
65
+ const Alpha = "folder-alpha" as FolderId;
66
+ const Beta = "folder-beta" as FolderId;
67
+
68
+ function document(
69
+ folders: readonly { id: FolderId; name: string; parent?: FolderId }[],
70
+ assignments: Record<string, FolderId>,
71
+ templateAssignments: Record<string, FolderId> = {},
72
+ ): string {
73
+ return encodeFoldersDocument({
74
+ schemaVersion: FOLDERS_SCHEMA_VERSION,
75
+ folders,
76
+ assignments,
77
+ templateAssignments,
78
+ });
79
+ }
80
+
81
+ function templateEntry(id: string, label: string): TemplateListEntry {
82
+ return { id: id as TemplateId, label, created: new Date(1), blockCount: 1 };
83
+ }
84
+
85
+ /** A view over the given folders and projects, as the planner sees one. */
86
+ function viewOf(
87
+ folders: readonly { id: FolderId; name: string; parent?: FolderId }[],
88
+ projects: readonly { id: string; name: string; folder?: FolderId }[],
89
+ ): FoldersView {
90
+ const decoded = decodeFoldersDocument(
91
+ document(
92
+ folders,
93
+ Object.fromEntries(
94
+ projects.flatMap((p) => (p.folder === undefined ? [] : [[p.id, p.folder] as const])),
95
+ ),
96
+ ),
97
+ );
98
+ return healFolders(
99
+ decoded,
100
+ projects.map((p) => ({ id: p.id as ProjectId, name: p.name })),
101
+ );
102
+ }
103
+
104
+ function ready(result: ReturnType<typeof foldersListing>) {
105
+ if (result.status !== "ready") throw new Error(`expected a listing, got ${result.status}`);
106
+ return result.listing;
107
+ }
108
+
109
+ // ── The read ─────────────────────────────────────────────────────────────────
110
+
111
+ describe("foldersListing", () => {
112
+ it("publishes a flat list when the user has no folder document", () => {
113
+ const listing = ready(foldersListing(foldersNode({}), [entry("p1", "A"), entry("p2", "B")]));
114
+ expect(listing.folders).toEqual([]);
115
+ expect(listing.projects.map((p) => p.id)).toEqual(["p1", "p2"]);
116
+ expect(listing.projects.every((p) => p.folder === undefined)).toBe(true);
117
+ expect(listing.writable).toBe(true);
118
+ expect(listing.problem).toBeUndefined();
119
+ });
120
+
121
+ it("publishes nothing while a document field exists whose value has not arrived", () => {
122
+ const result = foldersListing(foldersNode({ fieldPresent: true }), [entry("p1", "A")]);
123
+ expect(result.status).toBe("not-synced");
124
+ });
125
+
126
+ it("degrades instead of stalling when the document resource carries no blob", () => {
127
+ // A node the tree holds carries whatever data it had when its frame was taken and the tree
128
+ // never fills data in afterwards, so this is a document this build cannot read — not a slow
129
+ // sync. Waiting for it would hide the whole project list behind a loading state for ever.
130
+ const listing = ready(
131
+ foldersListing(foldersNode({ noBlob: true }), [entry("p1", "A"), entry("p2", "B")]),
132
+ );
133
+ expect(listing.projects.map((p) => p.id)).toEqual(["p1", "p2"]);
134
+ expect(listing.writable).toBe(false);
135
+ expect(listing.problem?.kind).toBe("unreadable");
136
+ });
137
+
138
+ it("degrades and refuses writes for a document written by a newer build", () => {
139
+ const listing = ready(
140
+ foldersListing(
141
+ foldersNode({ raw: JSON.stringify({ schemaVersion: 99, folders: [], assignments: {} }) }),
142
+ [entry("p1", "A")],
143
+ ),
144
+ );
145
+ expect(listing.folders).toEqual([]);
146
+ expect(listing.projects.map((p) => p.id)).toEqual(["p1"]);
147
+ expect(listing.writable).toBe(false);
148
+ expect(listing.problem).toMatchObject({ kind: "newer-schema", documentSchemaVersion: 99 });
149
+ });
150
+
151
+ it("degrades for a document that is not JSON at all", () => {
152
+ const listing = ready(foldersListing(foldersNode({ raw: "{{{" }), [entry("p1", "A")]));
153
+ expect(listing.projects).toHaveLength(1);
154
+ expect(listing.writable).toBe(false);
155
+ expect(listing.problem?.kind).toBe("unreadable");
156
+ });
157
+
158
+ it("places templates in the same tree as projects, by the same path", () => {
159
+ const listing = ready(
160
+ foldersListing(
161
+ foldersNode({
162
+ raw: document(
163
+ [
164
+ { id: Alpha, name: "Samples" },
165
+ { id: Beta, name: "Runs", parent: Alpha },
166
+ ],
167
+ { p1: Alpha },
168
+ { t1: Beta },
169
+ ),
170
+ }),
171
+ [entry("p1", "Report")],
172
+ [templateEntry("t1", "Report"), templateEntry("t2", "Loose")],
173
+ ),
174
+ );
175
+
176
+ expect(listing.templates.map((t) => t.id)).toEqual(["t1", "t2"]);
177
+ expect(listing.templates.find((t) => t.id === "t1")?.path).toEqual(["Samples", "Runs"]);
178
+ expect(listing.templates.find((t) => t.id === "t1")?.ancestors).toEqual([Alpha, Beta]);
179
+ // A template the document never mentions is at the top level, not missing.
180
+ expect(listing.templates.find((t) => t.id === "t2")?.folder).toBeUndefined();
181
+ // A template and a project may carry one name in one tree; that is not a collision.
182
+ expect(listing.healed).toBe(false);
183
+ });
184
+
185
+ it("keeps every template even when the folder document cannot be read", () => {
186
+ const listing = ready(
187
+ foldersListing(foldersNode({ raw: "{{{" }), [], [templateEntry("t1", "A")]),
188
+ );
189
+ expect(listing.templates.map((t) => t.id)).toEqual(["t1"]);
190
+ expect(listing.writable).toBe(false);
191
+ });
192
+
193
+ it("gives every project its folder and the path that tells two of a name apart", () => {
194
+ const listing = ready(
195
+ foldersListing(
196
+ foldersNode({
197
+ raw: document(
198
+ [
199
+ { id: Alpha, name: "Samples" },
200
+ { id: Beta, name: "Runs", parent: Alpha },
201
+ ],
202
+ { p2: Beta },
203
+ ),
204
+ }),
205
+ [entry("p1", "Report"), entry("p2", "Report")],
206
+ ),
207
+ );
208
+ expect(listing.projects.find((p) => p.id === "p1")?.path).toEqual([]);
209
+ expect(listing.projects.find((p) => p.id === "p2")?.path).toEqual(["Samples", "Runs"]);
210
+ expect(listing.projects.find((p) => p.id === "p2")?.ancestors).toEqual([Alpha, Beta]);
211
+ });
212
+
213
+ it("keeps every project a document mentions nowhere, and every one it names wrongly", () => {
214
+ const listing = ready(
215
+ foldersListing(
216
+ foldersNode({
217
+ raw: document([{ id: Alpha, name: "Samples" }], {
218
+ p1: Beta,
219
+ gone: Alpha,
220
+ }),
221
+ }),
222
+ [entry("p1", "A"), entry("p2", "B")],
223
+ ),
224
+ );
225
+ expect(listing.projects.map((p) => p.id)).toEqual(["p1", "p2"]);
226
+ // p1 is assigned to a folder the document does not hold, so it heals to the top level.
227
+ expect(listing.projects.find((p) => p.id === "p1")?.folder).toBeUndefined();
228
+ expect(listing.healed).toBe(true);
229
+ });
230
+
231
+ it("keeps every project when the document is nothing but a cycle", () => {
232
+ const listing = ready(
233
+ foldersListing(
234
+ foldersNode({
235
+ raw: document(
236
+ [
237
+ { id: Alpha, name: "A", parent: Beta },
238
+ { id: Beta, name: "B", parent: Alpha },
239
+ ],
240
+ { p1: Alpha, p2: Beta },
241
+ ),
242
+ }),
243
+ [entry("p1", "One"), entry("p2", "Two")],
244
+ ),
245
+ );
246
+ expect(listing.projects.map((p) => p.id)).toEqual(["p1", "p2"]);
247
+ expect(listing.folders.some((f) => f.parent === undefined)).toBe(true);
248
+ expect(listing.healed).toBe(true);
249
+ });
250
+ });
251
+
252
+ // ── The move contract ────────────────────────────────────────────────────────
253
+
254
+ describe("commitFoldersMove", () => {
255
+ const view = viewOf(
256
+ [
257
+ { id: Alpha, name: "Samples" },
258
+ { id: Beta, name: "Runs" },
259
+ ],
260
+ [
261
+ { id: "p1", name: "Report" },
262
+ { id: "p2", name: "Report", folder: Alpha },
263
+ ],
264
+ );
265
+
266
+ function planMove(destination?: FolderId): FoldersMovePlanResult {
267
+ return planFoldersMove(view, [{ kind: "project", id: "p1" as ProjectId }], destination);
268
+ }
269
+
270
+ it("writes nothing and reports the fresh plan when the confirmed one disagrees", () => {
271
+ const stale: FoldersMovePlan = { destination: Alpha, entries: [] };
272
+ const edit = commitFoldersMove(view, planMove(Alpha), stale);
273
+ expect(edit.document).toBeUndefined();
274
+ expect(edit.result).toMatchObject({ ok: false, reason: "plan-changed" });
275
+ });
276
+
277
+ it("commits when the confirmed plan is the one recomputed here", () => {
278
+ const planned = planMove(Alpha);
279
+ if (!planned.ok) throw new Error("expected a plan");
280
+ const edit = commitFoldersMove(view, planned, planned.plan);
281
+ expect(edit.document).toBeDefined();
282
+ expect(edit.result).toMatchObject({ ok: true });
283
+ // The destination already holds a "Report", so the moved one is the renamed one.
284
+ expect(edit.labelRenames).toEqual([
285
+ { item: { kind: "project", id: "p1" }, name: "Report (Copy)" },
286
+ ]);
287
+ });
288
+
289
+ it("refuses a plan it cannot compute at all", () => {
290
+ const edit = commitFoldersMove(
291
+ view,
292
+ planFoldersMove(view, [{ kind: "folder", id: "nope" as FolderId }]),
293
+ undefined,
294
+ );
295
+ expect(edit.document).toBeUndefined();
296
+ expect(edit.result).toMatchObject({ ok: false, reason: "cannot-plan" });
297
+ });
298
+
299
+ it("writes nothing for a move of nothing", () => {
300
+ const planned = planFoldersMove(view, [], Alpha);
301
+ if (!planned.ok) throw new Error("expected a plan");
302
+ const edit = commitFoldersMove(view, planned, planned.plan);
303
+ expect(edit.document).toBeUndefined();
304
+ expect(edit.result).toMatchObject({ ok: true });
305
+ });
306
+ });
307
+
308
+ // ── The shape a copy carries ─────────────────────────────────────────────────
309
+
310
+ describe("foldersLocalSubtree", () => {
311
+ const Gamma = "folder-gamma" as FolderId;
312
+ const Delta = "folder-delta" as FolderId;
313
+
314
+ // Alpha ⊃ Beta ⊃ Gamma, with Delta beside Alpha.
315
+ const nested = viewOf(
316
+ [
317
+ { id: Alpha, name: "Alpha" },
318
+ { id: Beta, name: "Beta", parent: Alpha },
319
+ { id: Gamma, name: "Gamma", parent: Beta },
320
+ { id: Delta, name: "Delta" },
321
+ ],
322
+ [],
323
+ );
324
+
325
+ let next = 0;
326
+ const mint = () => `local-${next++}`;
327
+
328
+ it("carries the whole subtree and nothing beside it", () => {
329
+ next = 0;
330
+ const { inSubtree, folders } = foldersLocalSubtree(nested, Alpha, mint);
331
+ expect([...inSubtree].sort()).toEqual([Alpha, Beta, Gamma].sort());
332
+ expect(
333
+ Object.values(folders)
334
+ .map((f) => f.name)
335
+ .sort(),
336
+ ).toEqual(["Alpha", "Beta", "Gamma"]);
337
+ });
338
+
339
+ it("leaves the root's own parent behind, and keeps every other one inside", () => {
340
+ next = 0;
341
+ const { localId, folders } = foldersLocalSubtree(nested, Beta, mint);
342
+ expect(folders[localId(Beta)].parent).toBeUndefined();
343
+ expect(folders[localId(Gamma)].parent).toBe(localId(Beta));
344
+ });
345
+
346
+ it("names every folder anew, so nothing of the source's ids travels", () => {
347
+ next = 0;
348
+ const { localId } = foldersLocalSubtree(nested, Alpha, mint);
349
+ const local = [Alpha, Beta, Gamma].map(localId);
350
+ expect(new Set(local).size).toBe(3);
351
+ expect(local).not.toContain(Alpha);
352
+ });
353
+
354
+ it("refuses to name a folder outside the subtree", () => {
355
+ next = 0;
356
+ const { localId } = foldersLocalSubtree(nested, Beta, mint);
357
+ expect(() => localId(Delta)).toThrow(/not under/);
358
+ });
359
+ });
@@ -4,12 +4,13 @@ export * from "./driver_kit";
4
4
  export * from "./ops";
5
5
  export type { TreeSnapshotMiss, TreeSnapshotStat } from "./tree_snapshot_store";
6
6
  export { ProjectsField, ProjectsResourceType } from "./project_list";
7
- export type { OutgoingShare, PendingShare } from "./sharing_list";
7
+ export { FoldersField, FoldersResourceType } from "./folders";
8
+ export type { FoldersListing, FoldersProjectEntry, FoldersTemplateEntry } from "./folders";
9
+ export type { OutgoingShare, AvailableShare, EnvelopeFolderSummary } from "./sharing_list";
8
10
  export { TemplatesField } from "./template_list";
9
11
  export type {
10
12
  CreateProjectFromTemplateOutcome,
11
13
  SaveProjectAsTemplateOutcome,
12
- ShareTemplateOutcome,
13
14
  StoredTemplateData,
14
15
  TemplateId,
15
16
  TemplateListEntry,