@milaboratories/pl-middle-layer 1.71.16 → 1.73.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 +636 -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 +617 -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 +17 -17
  66. package/src/middle_layer/folders.test.ts +1076 -0
  67. package/src/middle_layer/folders.ts +1069 -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,617 @@
1
+ import { ProjectLastModifiedTimestamp, ProjectMetaKey } from "../model/project_model.js";
2
+ import { projectListEntries } from "./project_list.js";
3
+ import { TemplateLabelKey, templateListEntries } from "./template_list.js";
4
+ import { listedById, notListedError } from "../mutator/list.js";
5
+ import { renameTemplate } from "../mutator/template.js";
6
+ import { asFolderId, commitFoldersMove, commitFoldersRemoval, decodeStoredFoldersDocument, emptyFoldersDocument, foldersDocumentFromView, foldersNameBlank, foldersNameTaken, foldersSiblingNames, foldersSubtree, foldersUniqueName, formatFoldersViolation, healFolders, normalizeDescription, planFoldersMove, planFoldersRemoval, validateFoldersDocument } from "@milaboratories/pl-model-middle-layer";
7
+ import { asProjectId, asTemplateId } from "@milaboratories/pl-model-common";
8
+ import { field, isNullSignedResourceId, resourceIdToString, resourceTypesEqual, treeFilter } from "@milaboratories/pl-client";
9
+ import { SynchronizedTreeState } from "@milaboratories/pl-tree";
10
+ import { Computable } from "@milaboratories/computable";
11
+ import { randomUUID } from "node:crypto";
12
+ //#region src/middle_layer/folders.ts
13
+ /** Field on the user's client root holding the folder singleton. */
14
+ const FoldersField = "folders";
15
+ const FoldersResourceType = {
16
+ name: "Folders",
17
+ version: "1"
18
+ };
19
+ /**
20
+ * The one field of the singleton. It holds the whole tree as a single immutable JSON value
21
+ * resource; a write mints a new value and re-points this field at it, the way a block's stored
22
+ * state is rewritten.
23
+ */
24
+ const FoldersDocumentField = "document";
25
+ const FoldersTreePruningFunction = (resource) => {
26
+ if (!resourceTypesEqual(resource.type, FoldersResourceType)) return [];
27
+ return resource.fields;
28
+ };
29
+ const foldersFieldFilter = treeFilter.resourceTypeEq(FoldersResourceType.name);
30
+ /**
31
+ * The folder tree plus the computable that joins it with the project list.
32
+ *
33
+ * Both halves are read from one computable context, so the listing is always one consistent
34
+ * snapshot. Nothing is published while either half is still syncing: a project would otherwise
35
+ * render at the top level for a poll or two and then jump into its folder.
36
+ */
37
+ async function createFolderList(pl, rid, projectsTree, templatesTree, openedProjects, env) {
38
+ const tree = await SynchronizedTreeState.init(pl, rid, {
39
+ ...env.ops.defaultTreeOptions,
40
+ pruning: FoldersTreePruningFunction,
41
+ fieldFilter: foldersFieldFilter
42
+ }, env.logger);
43
+ return {
44
+ computable: Computable.make((ctx) => {
45
+ const projectsNode = ctx.accessor(projectsTree.entry()).node();
46
+ const templatesNode = ctx.accessor(templatesTree.entry()).node();
47
+ const foldersNode = ctx.accessor(tree.entry()).node();
48
+ if (projectsNode === void 0 || templatesNode === void 0 || foldersNode === void 0) {
49
+ ctx.markUnstable("folders_not_synced");
50
+ return;
51
+ }
52
+ const result = foldersListing(foldersNode, projectListEntries(projectsNode, openedProjects.getValue(ctx)), templateListEntries(templatesNode));
53
+ if (result.status === "not-synced") {
54
+ ctx.markUnstable(result.reason);
55
+ return;
56
+ }
57
+ return result.listing;
58
+ }).withStableType(),
59
+ tree
60
+ };
61
+ }
62
+ /**
63
+ * Joins the folder singleton with an already-read project list.
64
+ *
65
+ * Split out of the computable so that the join, the first-paint rule and the degrade can be
66
+ * exercised without a backend — the same reason {@link projectListEntries} is a function rather
67
+ * than a closure. The interface it takes is the exact slice of the tree accessor it touches, so
68
+ * the real accessor satisfies it structurally.
69
+ *
70
+ * The listing is built by mapping over the **project list**, never over the folder view, so no
71
+ * folder input can drop a project. Do not refactor that into a map over the view.
72
+ */
73
+ function foldersListing(foldersNode, entries, templateEntries = []) {
74
+ const documentNode = foldersNode.traverse({
75
+ field: FoldersDocumentField,
76
+ stableIfNotFound: true
77
+ });
78
+ if (documentNode === void 0 && foldersNode.listDynamicFields().includes("document")) return {
79
+ status: "not-synced",
80
+ reason: "folders_document_not_synced"
81
+ };
82
+ const view = healFolders(decodeStoredFoldersDocument(documentNode === void 0 ? { present: false } : {
83
+ present: true,
84
+ raw: documentNode.getDataAsString()
85
+ }), entries.map((entry) => ({
86
+ id: entry.id,
87
+ name: entry.meta.label
88
+ })), templateEntries.map((entry) => ({
89
+ id: entry.id,
90
+ name: entry.label
91
+ })));
92
+ return {
93
+ status: "ready",
94
+ listing: {
95
+ folders: view.folders,
96
+ projects: withPlacement(entries, view.projects),
97
+ templates: withPlacement(templateEntries, view.templates),
98
+ writable: view.writable,
99
+ ...view.problem === void 0 ? {} : { problem: view.problem },
100
+ healed: view.healed
101
+ }
102
+ };
103
+ }
104
+ /**
105
+ * The one way the folder document is written by a folder operation.
106
+ *
107
+ * The whole body runs inside a single write transaction: the document and the project list are
108
+ * re-read there, the edit is computed from what that read found, and only then is a new value
109
+ * resource minted and the field re-pointed. The backend validates read sets, so a racing commit
110
+ * aborts this one and the client replays the body against fresh state — which is only true
111
+ * because the reading happens *inside* the body. Nothing may be carried across attempts, and
112
+ * anything with an identity of its own (a folder id) is minted by the caller beforehand, so a
113
+ * retry cannot hand back an id that was never stored.
114
+ *
115
+ * The advisory lock serialises writers inside one process. It is not a substitute for the
116
+ * backend's conflict detection — it just removes the cheap case, one window racing itself.
117
+ */
118
+ async function withFolders(pl, name, rids, body) {
119
+ return await pl.withWriteTx(name, async (tx) => {
120
+ const read = await readFolders(tx, rids);
121
+ if (!read.decoded.writable) throw new Error(refusalMessage(read.decoded.problem));
122
+ const edit = body(read.view);
123
+ if (edit.document === void 0) return edit.result;
124
+ rejectNewViolations(read, edit.document, edit.labelRenames ?? [], [...edit.deletedProjects ?? [], ...edit.deletedTemplates ?? []]);
125
+ writeFoldersDocument(tx, rids, read, edit.document);
126
+ const timestamp = JSON.stringify(Date.now());
127
+ for (const rename of edit.labelRenames ?? []) {
128
+ if (rename.item.kind === "template") {
129
+ const rid = read.templateRids.get(rename.item.id);
130
+ if (rid === void 0) throw notListedError("Template", rename.item.id);
131
+ renameTemplate(tx, rid, rename.name);
132
+ continue;
133
+ }
134
+ const rid = read.projectRids.get(rename.item.id);
135
+ const meta = read.projectMetas.get(rename.item.id);
136
+ if (rid === void 0 || meta === void 0) throw notListedError("Project", rename.item.id);
137
+ tx.setKValue(rid, ProjectMetaKey, JSON.stringify({
138
+ ...meta,
139
+ label: rename.name
140
+ }));
141
+ tx.setKValue(rid, ProjectLastModifiedTimestamp, timestamp);
142
+ }
143
+ await removeListedFields(tx, rids.projects, edit.deletedProjects ?? [], "Project");
144
+ await removeListedFields(tx, rids.templates, edit.deletedTemplates ?? [], "Template");
145
+ await tx.commit();
146
+ return edit.result;
147
+ }, { lockId: foldersLockId(rids.folders) });
148
+ }
149
+ /**
150
+ * The same read the write path performs, in a read transaction — what a preview is computed from,
151
+ * so that a preview and the write that follows it never disagree about how the tree was read.
152
+ */
153
+ async function readFoldersView(pl, name, rids) {
154
+ return await pl.withReadTx(name, async (tx) => {
155
+ return (await readFolders(tx, rids)).view;
156
+ });
157
+ }
158
+ /** Opens the folder tree inside the caller's transaction; see {@link FoldersTx}. */
159
+ async function openFoldersTx(tx, rids) {
160
+ const read = await readFolders(tx, rids);
161
+ const projects = read.view.projects.map(({ id, name }) => ({
162
+ id,
163
+ name
164
+ }));
165
+ const templates = read.view.templates.map(({ id, name }) => ({
166
+ id,
167
+ name
168
+ }));
169
+ let view = read.view;
170
+ let document = foldersDocumentFromView(read.view);
171
+ let documentFieldExists = read.documentFieldExists;
172
+ const write = (next) => {
173
+ writeFoldersDocument(tx, rids, {
174
+ ...read,
175
+ documentFieldExists
176
+ }, next);
177
+ documentFieldExists = true;
178
+ document = next;
179
+ view = healFolders({
180
+ document: next,
181
+ writable: true
182
+ }, projects, templates);
183
+ };
184
+ const assertFolderExists = (folder) => {
185
+ if (!view.folders.some((candidate) => candidate.id === folder)) throw new Error(`Folder ${folder} does not exist.`);
186
+ };
187
+ const namesTakenIn = (folder, kind) => read.decoded.writable ? foldersSiblingNames(view, folder, [], { kind }) : [];
188
+ const folderOf = (project) => view.projects.find((candidate) => candidate.id === project)?.folder;
189
+ return {
190
+ get view() {
191
+ return view;
192
+ },
193
+ projectRids: read.projectRids,
194
+ templateRids: read.templateRids,
195
+ namesTakenIn,
196
+ namesTakenBeside: (project, kind) => namesTakenIn(folderOf(project), kind),
197
+ folderOf,
198
+ assertNameFree(item, name) {
199
+ if (foldersNameBlank(name) || !read.decoded.writable) return;
200
+ const placed = item.kind === "project" ? view.projects.find((candidate) => candidate.id === item.id) : view.templates.find((candidate) => candidate.id === item.id);
201
+ if (placed === void 0) return;
202
+ if (foldersNameTaken(name, [placed.name])) return;
203
+ if (foldersNameTaken(name, foldersSiblingNames(view, placed.folder, [item.id], { kind: item.kind }))) throw new Error(nameTakenMessage(item.kind, name));
204
+ },
205
+ place(items, folder) {
206
+ if (folder === void 0 || items.length === 0 || !read.decoded.writable) return;
207
+ assertFolderExists(folder);
208
+ const assignments = { ...document.assignments };
209
+ const templateAssignments = { ...document.templateAssignments };
210
+ for (const item of items) if (item.kind === "project") assignments[item.id] = folder;
211
+ else templateAssignments[item.id] = folder;
212
+ write({
213
+ ...document,
214
+ assignments,
215
+ templateAssignments
216
+ });
217
+ },
218
+ graft(subtree, destination) {
219
+ if (!read.decoded.writable) return "unwritable";
220
+ if (destination !== void 0) assertFolderExists(destination);
221
+ const root = subtree.folders[subtree.root];
222
+ if (root === void 0) throw new Error(`The subtree holds no folder ${subtree.root}.`);
223
+ const rootName = foldersUniqueName(root.name, foldersSiblingNames(view, destination, [], { kind: "folder" }));
224
+ const incoming = Object.keys(subtree.folders);
225
+ const minted = /* @__PURE__ */ new Map();
226
+ for (const key of incoming) minted.set(key, asFolderId(randomUUID()));
227
+ const folders = [...document.folders];
228
+ for (const key of incoming) {
229
+ const incomingFolder = subtree.folders[key];
230
+ const parent = key === subtree.root ? destination : mustGet(minted, incomingFolder.parent ?? subtree.root);
231
+ const description = normalizeDescription(incomingFolder.description);
232
+ folders.push({
233
+ id: mustGet(minted, key),
234
+ name: key === subtree.root ? rootName : incomingFolder.name,
235
+ ...parent === void 0 ? {} : { parent },
236
+ ...description === void 0 ? {} : { description }
237
+ });
238
+ }
239
+ const assignments = { ...document.assignments };
240
+ const templateAssignments = { ...document.templateAssignments };
241
+ for (const item of subtree.items) {
242
+ const folder = minted.get(item.folder);
243
+ if (folder === void 0) continue;
244
+ if (item.kind === "project") assignments[item.id] = folder;
245
+ else templateAssignments[item.id] = folder;
246
+ }
247
+ write({
248
+ ...document,
249
+ folders,
250
+ assignments,
251
+ templateAssignments
252
+ });
253
+ return "grafted";
254
+ }
255
+ };
256
+ }
257
+ /**
258
+ * Creates a folder and returns its id.
259
+ *
260
+ * The name is typed by a human, so a name another folder in the destination already carries is
261
+ * rejected rather than quietly suffixed — a text field that disagrees with what was typed is worse
262
+ * than one that says no. A project or a template of that name beside it is no obstacle.
263
+ */
264
+ async function createFolder(pl, rids, name, parent) {
265
+ if (foldersNameBlank(name)) throw new Error("A folder name cannot be empty.");
266
+ const wanted = name.trim();
267
+ const id = asFolderId(randomUUID());
268
+ await withFolders(pl, "MLCreateFolder", rids, (view) => {
269
+ if (parent !== void 0 && !view.folders.some((folder) => folder.id === parent)) throw new Error(`Folder ${parent} does not exist.`);
270
+ if (foldersNameTaken(wanted, foldersSiblingNames(view, parent, [], { kind: "folder" }))) throw new Error(nameTakenMessage("folder", wanted));
271
+ const base = foldersDocumentFromView(view);
272
+ return {
273
+ result: void 0,
274
+ document: {
275
+ ...base,
276
+ folders: [...base.folders, parent === void 0 ? {
277
+ id,
278
+ name: wanted
279
+ } : {
280
+ id,
281
+ name: wanted,
282
+ parent
283
+ }]
284
+ }
285
+ };
286
+ });
287
+ return id;
288
+ }
289
+ /** Renames a folder. A human typed this name too, so a collision is rejected, not suffixed. */
290
+ async function renameFolder(pl, rids, folder, name) {
291
+ if (foldersNameBlank(name)) throw new Error("A folder name cannot be empty.");
292
+ const wanted = name.trim();
293
+ await withFolders(pl, "MLRenameFolder", rids, (view) => {
294
+ const target = view.folders.find((candidate) => candidate.id === folder);
295
+ if (target === void 0) throw new Error(`Folder ${folder} does not exist.`);
296
+ if (foldersNameTaken(wanted, foldersSiblingNames(view, target.parent, [folder], { kind: "folder" }))) throw new Error(nameTakenMessage("folder", wanted));
297
+ const base = foldersDocumentFromView(view);
298
+ return {
299
+ result: void 0,
300
+ document: {
301
+ ...base,
302
+ folders: base.folders.map((candidate) => candidate.id === folder ? {
303
+ ...candidate,
304
+ name: wanted
305
+ } : candidate)
306
+ }
307
+ };
308
+ });
309
+ }
310
+ /**
311
+ * Sets what a folder says about itself. Blank clears it.
312
+ *
313
+ * Unlike a name, a description is in no namespace: nothing is checked against the siblings, and
314
+ * two folders may say the same thing about themselves.
315
+ */
316
+ async function setFolderDescription(pl, rids, folder, description) {
317
+ const wanted = normalizeDescription(description);
318
+ await withFolders(pl, "MLSetFolderDescription", rids, (view) => {
319
+ if (view.folders.find((candidate) => candidate.id === folder) === void 0) throw new Error(`Folder ${folder} does not exist.`);
320
+ const base = foldersDocumentFromView(view);
321
+ return {
322
+ result: void 0,
323
+ document: {
324
+ ...base,
325
+ folders: base.folders.map((candidate) => {
326
+ if (candidate.id !== folder) return candidate;
327
+ const { description: _dropped, ...rest } = candidate;
328
+ return wanted === void 0 ? rest : {
329
+ ...rest,
330
+ description: wanted
331
+ };
332
+ })
333
+ }
334
+ };
335
+ });
336
+ }
337
+ /**
338
+ * The shape of a folder subtree under ids local to the caller: a fresh id per folder, the root's
339
+ * own parent left behind so the subtree stands alone, and a lookup from a real folder id to its
340
+ * local one.
341
+ *
342
+ * Both ways of copying a folder need this. A share carries the shape into another user's tree and
343
+ * a duplicate rebuilds it beside the source, and neither may carry the folder ids themselves,
344
+ * because the document that receives the subtree mints its own.
345
+ */
346
+ function foldersLocalSubtree(view, root, mint) {
347
+ const inSubtree = new Set(foldersSubtree(view, root));
348
+ const localIds = /* @__PURE__ */ new Map();
349
+ for (const id of inSubtree) localIds.set(id, mint());
350
+ const localId = (id) => {
351
+ const local = localIds.get(id);
352
+ if (local === void 0) throw new Error(`Folder ${id} is not under ${root}.`);
353
+ return local;
354
+ };
355
+ const folders = {};
356
+ for (const candidate of view.folders) {
357
+ if (!inSubtree.has(candidate.id)) continue;
358
+ const parent = candidate.id === root ? void 0 : localId(candidate.parent ?? root);
359
+ folders[localId(candidate.id)] = {
360
+ name: candidate.name,
361
+ ...parent === void 0 ? {} : { parent },
362
+ ...candidate.description === void 0 ? {} : { description: candidate.description }
363
+ };
364
+ }
365
+ return {
366
+ inSubtree,
367
+ localId,
368
+ folders
369
+ };
370
+ }
371
+ /**
372
+ * The plan a move would produce, computed from a fresh read — what the confirmation dialog shows.
373
+ * The same planner runs again inside the write, over the same shape of read.
374
+ */
375
+ async function previewFoldersMove(pl, rids, items, destination) {
376
+ return planFoldersMove(await readFoldersView(pl, "MLPreviewFoldersMove", rids), items, destination);
377
+ }
378
+ /**
379
+ * Moves any mix of folders, projects and templates into one destination, committing only when
380
+ * the plan recomputed inside the transaction is identical to `confirmedPlan`.
381
+ */
382
+ async function moveFolderItems(pl, rids, items, destination, confirmedPlan) {
383
+ return await withFolders(pl, "MLMoveFolderItems", rids, (view) => commitFoldersMove(view, planFoldersMove(view, items, destination), confirmedPlan));
384
+ }
385
+ /** What deleting a folder would destroy: the subtree of folders, and every project and template
386
+ * in it. */
387
+ async function previewFolderDeletion(pl, rids, folder) {
388
+ return planFoldersRemoval(await readFoldersView(pl, "MLPreviewFolderDeletion", rids), folder);
389
+ }
390
+ /**
391
+ * Deletes a folder, every folder inside it, and every project and template held anywhere in that
392
+ * subtree.
393
+ *
394
+ * Deletion cannot be undone, so `confirmedRemoval` is required: the removal is recomputed here and
395
+ * the write commits only if it names exactly the same things the caller confirmed. A project
396
+ * dropped into the folder after the dialog opened therefore aborts the deletion rather than being
397
+ * destroyed unseen.
398
+ */
399
+ async function deleteFolder(pl, rids, folder, confirmedRemoval) {
400
+ return await withFolders(pl, "MLDeleteFolder", rids, (view) => commitFoldersRemoval(view, planFoldersRemoval(view, folder), confirmedRemoval));
401
+ }
402
+ /**
403
+ * Replaces a folder document this build cannot read with an empty one: no folders and no
404
+ * assignments, so every project and template shows at the top level. Nothing but the document is
405
+ * touched, and what the unreadable tree held is lost for good.
406
+ *
407
+ * It is refused while the document reads fine, so a working tree can never be wiped through it.
408
+ * "Reads fine" is decided by the same decode the listing uses, in the transaction that writes.
409
+ */
410
+ async function resetFolders(pl, rids) {
411
+ await pl.withWriteTx("MLResetFolders", async (tx) => {
412
+ const stored = await readStoredFolders(tx, rids);
413
+ if (stored.decoded.writable) throw new Error("Folders can only be reset when they cannot be read, and these can be read. Nothing was changed.");
414
+ writeFoldersDocument(tx, rids, {
415
+ carriedAssignments: {},
416
+ documentFieldExists: stored.documentFieldExists
417
+ }, emptyFoldersDocument());
418
+ await tx.commit();
419
+ }, { lockId: foldersLockId(rids.folders) });
420
+ }
421
+ /** Why a typed name is refused: another item of the same kind beside it already carries it. */
422
+ function nameTakenMessage(kind, name) {
423
+ return `A ${kind} named "${name}" is already here.`;
424
+ }
425
+ /** A key looked up in a map that was built from those very keys; absent means the code above
426
+ * changed and the two no longer agree. */
427
+ function mustGet(map, key) {
428
+ const value = map.get(key);
429
+ if (value === void 0) throw new Error(`No entry for ${String(key)}.`);
430
+ return value;
431
+ }
432
+ /** Each entry of a list with its place in the tree, looked up by id; an entry the tree does not
433
+ * place is at the top level. */
434
+ function withPlacement(entries, placed) {
435
+ const byId = new Map(placed.map((item) => [item.id, item]));
436
+ return entries.map((entry) => {
437
+ const placement = byId.get(entry.id);
438
+ return {
439
+ ...entry,
440
+ ...placement?.folder === void 0 ? {} : { folder: placement.folder },
441
+ ancestors: placement?.ancestors ?? [],
442
+ path: placement?.path ?? []
443
+ };
444
+ });
445
+ }
446
+ /**
447
+ * The folder document and both item lists, read inside a transaction.
448
+ *
449
+ * An absent document field is a user with no folders, and is writable. A field that points at
450
+ * something carrying no readable blob is a document that exists and cannot be read here, and must
451
+ * refuse writes, or the first folder edit replaces that tree with an empty one.
452
+ *
453
+ * A list entry counts as a project when it carries project metadata, and as a template when it
454
+ * carries a template label. Nothing else is checked. The listing readers are stricter:
455
+ * {@link projectListEntries} also requires the project resource type by name and both the created
456
+ * and the last-modified timestamps, and {@link templateListEntries} also requires the template
457
+ * resource type by name, the data blob and the created timestamp. So every entry the listing shows
458
+ * is kept here, and so is an entry the listing skips because it has only partly synced or is a
459
+ * foreign resource carrying one of those keys: it still holds its name and its folder assignment,
460
+ * which is the safe side for a write. Telling a foreign resource apart by its type instead would
461
+ * read the state of every resource in the list, and a read of a project's state conflicts with
462
+ * every write that project's own session makes, so a folder edit would keep losing to any project
463
+ * that is open.
464
+ */
465
+ async function readFolders(tx, rids) {
466
+ const { decoded, documentFieldExists } = await readStoredFolders(tx, rids);
467
+ const [listedProjects, listedTemplates] = await Promise.all([listedById(tx, rids.projects), listedById(tx, rids.templates)]);
468
+ const [metas, labels] = await Promise.all([Promise.all([...listedProjects.values()].map(({ rid }) => tx.getKValueJsonIfExists(rid, ProjectMetaKey))), Promise.all([...listedTemplates.values()].map(({ rid }) => tx.getKValueJsonIfExists(rid, TemplateLabelKey)))]);
469
+ const projectRids = /* @__PURE__ */ new Map();
470
+ const projectMetas = /* @__PURE__ */ new Map();
471
+ const projects = [];
472
+ const carriedAssignments = {};
473
+ [...listedProjects.values()].forEach(({ rid }, index) => {
474
+ const id = asProjectId(resourceIdToString(rid));
475
+ projectRids.set(id, rid);
476
+ const meta = metas[index];
477
+ if (meta === void 0) {
478
+ const folder = decoded.document.assignments[id];
479
+ if (folder !== void 0) carriedAssignments[id] = folder;
480
+ return;
481
+ }
482
+ projectMetas.set(id, meta);
483
+ projects.push({
484
+ id,
485
+ name: meta.label
486
+ });
487
+ });
488
+ const templateRids = /* @__PURE__ */ new Map();
489
+ const templates = [];
490
+ [...listedTemplates.values()].forEach(({ rid }, index) => {
491
+ const label = labels[index];
492
+ if (label === void 0) return;
493
+ const id = asTemplateId(resourceIdToString(rid));
494
+ templateRids.set(id, rid);
495
+ templates.push({
496
+ id,
497
+ name: label
498
+ });
499
+ });
500
+ return {
501
+ view: healFolders(decoded, projects, templates),
502
+ decoded,
503
+ projectRids,
504
+ projectMetas,
505
+ templateRids,
506
+ documentFieldExists,
507
+ carriedAssignments
508
+ };
509
+ }
510
+ /** The folder document alone, decoded, and whether its field exists at all. */
511
+ async function readStoredFolders(tx, rids) {
512
+ const documentField = await tx.getFieldIfExists(field(rids.folders, FoldersDocumentField));
513
+ return {
514
+ decoded: decodeStoredFoldersDocument(documentField === void 0 || isNullSignedResourceId(documentField.value) ? { present: false } : {
515
+ present: true,
516
+ raw: await readDocumentText(tx, documentField.value)
517
+ }),
518
+ documentFieldExists: documentField !== void 0
519
+ };
520
+ }
521
+ /** The stored JSON of a document value, or undefined when the value carries no blob. */
522
+ async function readDocumentText(tx, rid) {
523
+ const data = await tx.getResourceData(rid, false);
524
+ return data.data === void 0 ? void 0 : Buffer.from(data.data).toString("utf-8");
525
+ }
526
+ /**
527
+ * Persists a folder document: the assignments the read had to carry are added back, a new value
528
+ * resource is minted, and the one document field is created or re-pointed at it.
529
+ */
530
+ function writeFoldersDocument(tx, rids, read, document) {
531
+ const ref = tx.createJsonValue(withCarriedAssignments(document, read.carriedAssignments));
532
+ const documentField = field(rids.folders, FoldersDocumentField);
533
+ if (read.documentFieldExists) tx.setField(documentField, ref);
534
+ else tx.createField(documentField, "Dynamic", ref);
535
+ }
536
+ /**
537
+ * A document written by a newer build, or one nothing here can parse, reads as no folders so that
538
+ * the project list still renders — and every write is refused, because degrading the read while
539
+ * still writing would silently replace that tree with this build's truncated view of it.
540
+ */
541
+ function refusalMessage(problem) {
542
+ return `Folders cannot be changed: ${problem.message}. Update the application to a version that understands this folder document.`;
543
+ }
544
+ function withCarriedAssignments(document, carried) {
545
+ const entries = Object.entries(carried).filter(([, folder]) => document.folders.some((candidate) => candidate.id === folder));
546
+ if (entries.length === 0) return document;
547
+ return {
548
+ ...document,
549
+ assignments: {
550
+ ...document.assignments,
551
+ ...Object.fromEntries(entries)
552
+ }
553
+ };
554
+ }
555
+ /**
556
+ * Refuses a write that would put a *new* rule violation into the document.
557
+ *
558
+ * Only new ones: accounts predating folders may already hold two projects with the same name in
559
+ * one place, because nothing enforced uniqueness before, and a write that merely carries such a
560
+ * collision along must not be blocked by it. What the folder machinery must never do is create
561
+ * one.
562
+ *
563
+ * What the write deletes is left out of the document it leaves behind. A deleted project or
564
+ * template has no folder any more, and counted anyway it would stand at the top level under its
565
+ * old name — where it clashes with whatever is really there, and refuses the deletion.
566
+ */
567
+ function rejectNewViolations(read, document, renames, deleted) {
568
+ const renamed = new Map(renames.map((rename) => [rename.item.id, rename.name]));
569
+ const gone = new Set(deleted);
570
+ const templates = read.view.templates.map(({ id, name }) => ({
571
+ id,
572
+ name
573
+ }));
574
+ const before = validateFoldersDocument(foldersDocumentFromView(read.view), [...read.projectMetas].map(([id, meta]) => ({
575
+ id,
576
+ name: meta.label
577
+ })), templates);
578
+ const after = validateFoldersDocument(document, [...read.projectMetas].filter(([id]) => !gone.has(id)).map(([id, meta]) => ({
579
+ id,
580
+ name: renamed.get(id) ?? meta.label
581
+ })), templates.filter(({ id }) => !gone.has(id)).map(({ id, name }) => ({
582
+ id,
583
+ name: renamed.get(id) ?? name
584
+ })));
585
+ const known = new Set(before.map(violationKey));
586
+ const introduced = after.filter((violation) => !known.has(violationKey(violation)));
587
+ if (introduced.length === 0) return;
588
+ throw new Error(`Folder change refused: ${introduced.map(formatFoldersViolation).join("; ")}.`);
589
+ }
590
+ /**
591
+ * Deletes the named items from one of the root's lists. Every id must be there: an id the list
592
+ * does not hold means the write is deleting something other than what was planned, and the
593
+ * transaction is abandoned rather than allowed to destroy a guess.
594
+ */
595
+ async function removeListedFields(tx, listRid, ids, kind) {
596
+ if (ids.length === 0) return;
597
+ const listed = await listedById(tx, listRid);
598
+ for (const id of new Set(ids)) {
599
+ const entry = listed.get(id);
600
+ if (entry === void 0) throw notListedError(kind, id);
601
+ tx.removeField(field(listRid, entry.fieldName));
602
+ }
603
+ }
604
+ function violationKey(violation) {
605
+ return JSON.stringify(violation);
606
+ }
607
+ /**
608
+ * Scoped to the singleton rather than to the process, so that two middle layers over two users'
609
+ * roots — an impersonating admin session and the admin's own — do not queue behind each other.
610
+ */
611
+ function foldersLockId(foldersRid) {
612
+ return `folders:${foldersRid}`;
613
+ }
614
+ //#endregion
615
+ export { FoldersDocumentField, FoldersField, FoldersResourceType, FoldersTreePruningFunction, createFolder, createFolderList, deleteFolder, foldersFieldFilter, foldersListing, foldersLocalSubtree, moveFolderItems, nameTakenMessage, openFoldersTx, previewFolderDeletion, previewFoldersMove, readFoldersView, renameFolder, resetFolders, setFolderDescription, withFolders };
616
+
617
+ //# sourceMappingURL=folders.js.map