@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,1059 @@
1
+ import type {
2
+ Filter,
3
+ PlClient,
4
+ PlTransaction,
5
+ ResourceType,
6
+ SignedResourceId,
7
+ } from "@milaboratories/pl-client";
8
+ import {
9
+ field,
10
+ isNullSignedResourceId,
11
+ resourceIdToString,
12
+ resourceTypesEqual,
13
+ treeFilter,
14
+ } from "@milaboratories/pl-client";
15
+ import type { PruningFunction } from "@milaboratories/pl-tree";
16
+ import { SynchronizedTreeState } from "@milaboratories/pl-tree";
17
+ import type { WatchableValue } from "@milaboratories/computable";
18
+ import { Computable } from "@milaboratories/computable";
19
+ import type {
20
+ FoldersDecoded,
21
+ FoldersDocument,
22
+ FoldersDocumentProblem,
23
+ FoldersEdit,
24
+ FolderId,
25
+ FolderView,
26
+ FoldersItem,
27
+ FoldersLabelRename,
28
+ FoldersLeafItem,
29
+ FoldersMoveOutcome,
30
+ FoldersMovePlan,
31
+ FoldersMovePlanResult,
32
+ FoldersPlacement,
33
+ FoldersProjectInput,
34
+ FoldersRemoval,
35
+ FoldersRemovalOutcome,
36
+ FoldersRemovalPlanResult,
37
+ FoldersTemplateInput,
38
+ FoldersView,
39
+ FoldersViolation,
40
+ ProjectMeta,
41
+ } from "@milaboratories/pl-model-middle-layer";
42
+ import { asProjectId, asTemplateId } from "@milaboratories/pl-model-common";
43
+ import {
44
+ asFolderId,
45
+ commitFoldersMove,
46
+ commitFoldersRemoval,
47
+ decodeStoredFoldersDocument,
48
+ emptyFoldersDocument,
49
+ formatFoldersViolation,
50
+ healFolders,
51
+ planFoldersRemoval,
52
+ planFoldersMove,
53
+ foldersDocumentFromView,
54
+ foldersNameBlank,
55
+ foldersNameTaken,
56
+ foldersSiblingNames,
57
+ foldersSubtree,
58
+ foldersUniqueName,
59
+ normalizeDescription,
60
+ validateFoldersDocument,
61
+ } from "@milaboratories/pl-model-middle-layer";
62
+ import { randomUUID } from "node:crypto";
63
+ import type { ProjectId, ProjectListEntry } from "../model/project_model";
64
+ import { ProjectLastModifiedTimestamp, ProjectMetaKey } from "../model/project_model";
65
+ import type { MiddleLayerEnvironment } from "./middle_layer";
66
+ import type { TreeAndComputableU } from "./types";
67
+ import { projectListEntries } from "./project_list";
68
+ import type { TemplateId, TemplateListEntry } from "./template_list";
69
+ import { TemplateLabelKey, templateListEntries } from "./template_list";
70
+ import { renameTemplate } from "../mutator/template";
71
+ import type { ListedKind } from "../mutator/list";
72
+ import { listedById, notListedError } from "../mutator/list";
73
+
74
+ /** Field on the user's client root holding the folder singleton. */
75
+ export const FoldersField = "folders";
76
+ export const FoldersResourceType: ResourceType = { name: "Folders", version: "1" };
77
+
78
+ /**
79
+ * The one field of the singleton. It holds the whole tree as a single immutable JSON value
80
+ * resource; a write mints a new value and re-points this field at it, the way a block's stored
81
+ * state is rewritten.
82
+ */
83
+ export const FoldersDocumentField = "document";
84
+
85
+ /** A project list entry with its place in the folder tree resolved. */
86
+ export type FoldersProjectEntry = ProjectListEntry & FoldersPlacement;
87
+
88
+ /** A template list entry with its place in the folder tree resolved. */
89
+ export type FoldersTemplateEntry = TemplateListEntry & FoldersPlacement;
90
+
91
+ /**
92
+ * The single value the folder feature puts across the API boundary: the tree and the project list
93
+ * already joined.
94
+ *
95
+ * Two independently refreshed values would let a project show up in a folder the tree has not
96
+ * heard of, with nobody owning the reconciliation, so the join happens here and the desktop never
97
+ * sees the halves.
98
+ */
99
+ export interface FoldersListing {
100
+ readonly folders: readonly FolderView[];
101
+ /** Every project, most recently modified first. Nothing here is ever dropped. */
102
+ readonly projects: readonly FoldersProjectEntry[];
103
+ /** Every stored template, most recently created first. Nothing here is ever dropped. */
104
+ readonly templates: readonly FoldersTemplateEntry[];
105
+ /** False when every folder write is refused, because the document is not ours to rewrite. */
106
+ readonly writable: boolean;
107
+ /** Present when the stored document could not be used as it stands. */
108
+ readonly problem?: FoldersDocumentProblem;
109
+ /** True when what the listing shows differs from what is stored, because the read healed it. */
110
+ readonly healed: boolean;
111
+ }
112
+
113
+ /** Every singleton a folder operation touches. */
114
+ export interface FoldersRids {
115
+ readonly folders: SignedResourceId;
116
+ readonly projects: SignedResourceId;
117
+ readonly templates: SignedResourceId;
118
+ }
119
+
120
+ /**
121
+ * Resolves the folder singleton on the transaction's client root, lazily creating (and locking)
122
+ * an empty one when {@link FoldersField} is not yet populated. Same shape as the
123
+ * project-list and template-list singletons beside it.
124
+ */
125
+ export async function ensureFoldersRid(tx: PlTransaction): Promise<SignedResourceId> {
126
+ const f = field(tx.clientRoot, FoldersField);
127
+ tx.createField(f, "Dynamic");
128
+ const fData = await tx.getField(f);
129
+ if (isNullSignedResourceId(fData.value)) {
130
+ const ref = tx.createEphemeral(FoldersResourceType);
131
+ tx.lock(ref);
132
+ tx.setField(f, ref);
133
+ return await ref.globalId;
134
+ }
135
+ return fData.value;
136
+ }
137
+
138
+ export const FoldersTreePruningFunction: PruningFunction = (resource) => {
139
+ if (!resourceTypesEqual(resource.type, FoldersResourceType)) return [];
140
+ return resource.fields;
141
+ };
142
+
143
+ export const foldersFieldFilter: Filter = treeFilter.resourceTypeEq(FoldersResourceType.name);
144
+
145
+ /**
146
+ * The folder tree plus the computable that joins it with the project list.
147
+ *
148
+ * Both halves are read from one computable context, so the listing is always one consistent
149
+ * snapshot. Nothing is published while either half is still syncing: a project would otherwise
150
+ * render at the top level for a poll or two and then jump into its folder.
151
+ */
152
+ export async function createFolderList(
153
+ pl: PlClient,
154
+ rid: SignedResourceId,
155
+ projectsTree: SynchronizedTreeState,
156
+ templatesTree: SynchronizedTreeState,
157
+ openedProjects: WatchableValue<ProjectId[]>,
158
+ env: MiddleLayerEnvironment,
159
+ ): Promise<TreeAndComputableU<FoldersListing>> {
160
+ const tree = await SynchronizedTreeState.init(
161
+ pl,
162
+ rid,
163
+ {
164
+ ...env.ops.defaultTreeOptions,
165
+ pruning: FoldersTreePruningFunction,
166
+ fieldFilter: foldersFieldFilter,
167
+ },
168
+ env.logger,
169
+ );
170
+
171
+ const c = Computable.make((ctx) => {
172
+ const projectsNode = ctx.accessor(projectsTree.entry()).node();
173
+ const templatesNode = ctx.accessor(templatesTree.entry()).node();
174
+ const foldersNode = ctx.accessor(tree.entry()).node();
175
+ if (projectsNode === undefined || templatesNode === undefined || foldersNode === undefined) {
176
+ ctx.markUnstable("folders_not_synced");
177
+ return undefined;
178
+ }
179
+
180
+ const entries = projectListEntries(projectsNode, openedProjects.getValue(ctx));
181
+ const templates = templateListEntries(templatesNode);
182
+ const result = foldersListing(foldersNode, entries, templates);
183
+ if (result.status === "not-synced") {
184
+ ctx.markUnstable(result.reason);
185
+ return undefined;
186
+ }
187
+ return result.listing;
188
+ }).withStableType();
189
+
190
+ return { computable: c, tree };
191
+ }
192
+
193
+ /** The part of a tree node the folder reader needs from the folder singleton. */
194
+ export interface FoldersNode {
195
+ listDynamicFields(): string[];
196
+ traverse(step: {
197
+ field: string;
198
+ stableIfNotFound: true;
199
+ }): { getDataAsString(): string | undefined } | undefined;
200
+ }
201
+
202
+ /** Either a listing to publish, or the reason there is nothing worth publishing yet. */
203
+ export type FoldersListingResult =
204
+ | { readonly status: "not-synced"; readonly reason: string }
205
+ | { readonly status: "ready"; readonly listing: FoldersListing };
206
+
207
+ /**
208
+ * Joins the folder singleton with an already-read project list.
209
+ *
210
+ * Split out of the computable so that the join, the first-paint rule and the degrade can be
211
+ * exercised without a backend — the same reason {@link projectListEntries} is a function rather
212
+ * than a closure. The interface it takes is the exact slice of the tree accessor it touches, so
213
+ * the real accessor satisfies it structurally.
214
+ *
215
+ * The listing is built by mapping over the **project list**, never over the folder view, so no
216
+ * folder input can drop a project. Do not refactor that into a map over the view.
217
+ */
218
+ export function foldersListing(
219
+ foldersNode: FoldersNode,
220
+ entries: readonly ProjectListEntry[],
221
+ templateEntries: readonly TemplateListEntry[] = [],
222
+ ): FoldersListingResult {
223
+ // The field is absent on every account that has never made a folder, and that absence is
224
+ // final until a write creates it.
225
+ const documentNode = foldersNode.traverse({
226
+ field: FoldersDocumentField,
227
+ stableIfNotFound: true as const,
228
+ });
229
+
230
+ // A document field that exists but whose value has not arrived is not "no folders" — it is a
231
+ // tree we cannot describe yet, and publishing a flat list here is exactly the first-paint
232
+ // flicker this join exists to avoid.
233
+ if (documentNode === undefined && foldersNode.listDynamicFields().includes(FoldersDocumentField))
234
+ return { status: "not-synced", reason: "folders_document_not_synced" };
235
+
236
+ // A node that is in the tree carries whatever data it had when the frame was taken; the tree
237
+ // never fills data in later. So a document resource present with no blob is not a slow sync —
238
+ // it is a document this build cannot read, and the read degrades to the flat list with writes
239
+ // refused rather than waiting for something that will not arrive.
240
+ const decoded = decodeStoredFoldersDocument(
241
+ documentNode === undefined
242
+ ? { present: false }
243
+ : { present: true, raw: documentNode.getDataAsString() },
244
+ );
245
+
246
+ const view = healFolders(
247
+ decoded,
248
+ entries.map((entry) => ({ id: entry.id, name: entry.meta.label })),
249
+ templateEntries.map((entry) => ({ id: entry.id, name: entry.label })),
250
+ );
251
+
252
+ return {
253
+ status: "ready",
254
+ listing: {
255
+ folders: view.folders,
256
+ projects: withPlacement(entries, view.projects),
257
+ templates: withPlacement(templateEntries, view.templates),
258
+ writable: view.writable,
259
+ ...(view.problem === undefined ? {} : { problem: view.problem }),
260
+ healed: view.healed,
261
+ },
262
+ };
263
+ }
264
+
265
+ /**
266
+ * The one way the folder document is written by a folder operation.
267
+ *
268
+ * The whole body runs inside a single write transaction: the document and the project list are
269
+ * re-read there, the edit is computed from what that read found, and only then is a new value
270
+ * resource minted and the field re-pointed. The backend validates read sets, so a racing commit
271
+ * aborts this one and the client replays the body against fresh state — which is only true
272
+ * because the reading happens *inside* the body. Nothing may be carried across attempts, and
273
+ * anything with an identity of its own (a folder id) is minted by the caller beforehand, so a
274
+ * retry cannot hand back an id that was never stored.
275
+ *
276
+ * The advisory lock serialises writers inside one process. It is not a substitute for the
277
+ * backend's conflict detection — it just removes the cheap case, one window racing itself.
278
+ */
279
+ export async function withFolders<T>(
280
+ pl: PlClient,
281
+ name: string,
282
+ rids: FoldersRids,
283
+ body: (view: FoldersView) => FoldersEdit<T>,
284
+ ): Promise<T> {
285
+ return await pl.withWriteTx(
286
+ name,
287
+ async (tx) => {
288
+ const read = await readFolders(tx, rids);
289
+ if (!read.decoded.writable) throw new Error(refusalMessage(read.decoded.problem));
290
+
291
+ const edit = body(read.view);
292
+ if (edit.document === undefined) return edit.result;
293
+
294
+ rejectNewViolations(read, edit.document, edit.labelRenames ?? [], [
295
+ ...(edit.deletedProjects ?? []),
296
+ ...(edit.deletedTemplates ?? []),
297
+ ]);
298
+ writeFoldersDocument(tx, rids, read, edit.document);
299
+
300
+ // A project's label is written straight into its metadata rather than through the project
301
+ // mutator, which would migrate projects the user only asked to move. Everything else the
302
+ // metadata carries — the description — is preserved by writing the value read here rather
303
+ // than a fresh one. It is safe to do so because that value was read in this same
304
+ // transaction: a writable transaction's point reads are conflict-tracked, so a metadata
305
+ // write committing in parallel aborts one of the two and the loser is replayed against the
306
+ // winner's state. A template's label is a value of its own and is simply replaced.
307
+ const timestamp = JSON.stringify(Date.now());
308
+ for (const rename of edit.labelRenames ?? []) {
309
+ if (rename.item.kind === "template") {
310
+ const rid = read.templateRids.get(rename.item.id);
311
+ if (rid === undefined) throw notListedError("Template", rename.item.id);
312
+ renameTemplate(tx, rid, rename.name);
313
+ continue;
314
+ }
315
+ const rid = read.projectRids.get(rename.item.id);
316
+ const meta = read.projectMetas.get(rename.item.id);
317
+ if (rid === undefined || meta === undefined)
318
+ throw notListedError("Project", rename.item.id);
319
+ tx.setKValue(rid, ProjectMetaKey, JSON.stringify({ ...meta, label: rename.name }));
320
+ tx.setKValue(rid, ProjectLastModifiedTimestamp, timestamp);
321
+ }
322
+
323
+ // Removing the field from the list is what deletes a project or a template, exactly as
324
+ // MiddleLayer.deleteProject and deleteTemplate do it; here it rides the same transaction as
325
+ // the document rewrite, so a folder never disappears while what it held survives, or the
326
+ // reverse.
327
+ await removeListedFields(tx, rids.projects, edit.deletedProjects ?? [], "Project");
328
+ await removeListedFields(tx, rids.templates, edit.deletedTemplates ?? [], "Template");
329
+
330
+ await tx.commit();
331
+ return edit.result;
332
+ },
333
+ { lockId: foldersLockId(rids.folders) },
334
+ );
335
+ }
336
+
337
+ /**
338
+ * The same read the write path performs, in a read transaction — what a preview is computed from,
339
+ * so that a preview and the write that follows it never disagree about how the tree was read.
340
+ */
341
+ export async function readFoldersView(
342
+ pl: PlClient,
343
+ name: string,
344
+ rids: FoldersRids,
345
+ ): Promise<FoldersView> {
346
+ return await pl.withReadTx(name, async (tx) => {
347
+ return (await readFolders(tx, rids)).view;
348
+ });
349
+ }
350
+
351
+ /**
352
+ * The folder tree inside a transaction that is not a folder operation of its own: one that
353
+ * creates, renames or copies a project or a template and has to name it or place it.
354
+ *
355
+ * Opening it reads the tree once, and every call answers from that read. A placement updates
356
+ * the tree in memory as well as in the transaction, so a later call on the same handle sees the
357
+ * folders and assignments it wrote. An item created in the transaction after the handle was
358
+ * opened is placed by id only: the handle never learned its name.
359
+ *
360
+ * Unlike {@link withFolders} this takes no advisory lock, because it does not own the
361
+ * transaction. The backend's read-set validation still applies: a folder write committing in
362
+ * parallel aborts one of the two and the loser is replayed.
363
+ *
364
+ * Every placement is best-effort towards a document that is not ours to rewrite — one written by
365
+ * a newer build, or one nothing here can parse: it writes nothing and never throws, and the item
366
+ * stays at the top level, exactly where it landed before folders existed. An item that could not
367
+ * be placed is still an item, and failing its creation over the placement would be the worse
368
+ * trade. A destination that no longer exists is different: it is a folder the caller named and
369
+ * the user chose, so the placement throws and the creation fails with it.
370
+ */
371
+ export interface FoldersTx {
372
+ /** The tree as this transaction sees it, placements made through this handle included. */
373
+ readonly view: FoldersView;
374
+ /** Resource id of every project the list holds. */
375
+ readonly projectRids: ReadonlyMap<ProjectId, SignedResourceId>;
376
+ /** Resource id of every template the list holds under a label. */
377
+ readonly templateRids: ReadonlyMap<TemplateId, SignedResourceId>;
378
+ /**
379
+ * Names already taken in a folder, or at the top level, for choosing one that is free before
380
+ * anything is created.
381
+ *
382
+ * Empty when the folder document is not ours to read: nothing is then known about who sits
383
+ * where, and inventing collisions out of that would rename copies for no reason.
384
+ */
385
+ namesTakenIn(folder: FolderId | undefined): string[];
386
+ /** Names already taken where `project` sits — where something made from it lands. Empty for
387
+ * the same reason {@link namesTakenIn} can be. */
388
+ namesTakenBeside(project: ProjectId): string[];
389
+ /** The folder holding a project, or undefined at the top level. */
390
+ folderOf(project: ProjectId): FolderId | undefined;
391
+ /**
392
+ * Refuses a name for a project or template rename when one of its siblings already carries it.
393
+ *
394
+ * Renaming a project or a template is not a folder operation and does not go through
395
+ * {@link withFolders} — it writes the item's own metadata. But it shares the namespace the
396
+ * folder rule governs, so without this check a user could rename two things sitting in the
397
+ * same folder to the same name, and the uniqueness the rest of the feature relies on would be
398
+ * false from the day it shipped.
399
+ *
400
+ * The name is checked against the state the rename actually commits against, since the read
401
+ * happened in the rename's own transaction. Four things it deliberately does not do:
402
+ *
403
+ * - It never refuses a blank name: projects and templates may carry an empty label.
404
+ * - It never refuses a rename to what the item is already called, compared without regard to
405
+ * case. An account predating folders may already hold two projects with the same name in
406
+ * one place, because nothing enforced uniqueness before; such a pair must stay renameable,
407
+ * and neither of them is rewritten by anything here.
408
+ * - It stays silent when the item is not in the tree — a project whose metadata could not be
409
+ * read — since there is then no current name to compare against and refusing would block a
410
+ * rename on a transient read.
411
+ * - It stays silent when the folder document is not ours to read. Such a document reads as
412
+ * "no folders", which would put every project at the top level and turn this per-parent
413
+ * check into a global one, refusing renames that are perfectly legal in the tree that is
414
+ * actually stored. Folder *writes* are refused in that state; a rename is not a folder write
415
+ * and must keep working.
416
+ */
417
+ assertNameFree(item: FoldersLeafItem, name: string): void;
418
+ /** Puts items into a folder. Items destined for the top level need no assignment at all, so
419
+ * nothing is written for them. */
420
+ place(items: readonly FoldersLeafItem[], folder: FolderId | undefined): void;
421
+ /**
422
+ * Recreates a folder subtree under `destination`, or at the top level, and puts each item in
423
+ * its own folder inside it.
424
+ *
425
+ * What travels is the shape of a subtree, not its identity: the incoming folder keys are the
426
+ * caller's and mean nothing here, so a fresh id is minted for each. Only the root is renamed to
427
+ * be free among the destination's siblings — the folders inside it are only ever compared with
428
+ * each other, and they came in already distinct.
429
+ *
430
+ * @returns `unwritable` when the document is not ours to rewrite and nothing was grafted.
431
+ */
432
+ graft(subtree: FoldersGraft, destination: FolderId | undefined): "grafted" | "unwritable";
433
+ }
434
+
435
+ /** A subtree to graft: folders under keys local to the caller, and items naming the folder
436
+ * holding them. */
437
+ export interface FoldersGraft {
438
+ /** Key of the folder that becomes the grafted root; the one folder with no parent. */
439
+ readonly root: string;
440
+ readonly folders: Readonly<
441
+ Record<
442
+ string,
443
+ { readonly name: string; readonly parent?: string; readonly description?: string }
444
+ >
445
+ >;
446
+ readonly items: readonly (FoldersLeafItem & { readonly folder: string })[];
447
+ }
448
+
449
+ /** Opens the folder tree inside the caller's transaction; see {@link FoldersTx}. */
450
+ export async function openFoldersTx(tx: PlTransaction, rids: FoldersRids): Promise<FoldersTx> {
451
+ const read = await readFolders(tx, rids);
452
+ const projects = read.view.projects.map(({ id, name }) => ({ id, name }));
453
+ const templates = read.view.templates.map(({ id, name }) => ({ id, name }));
454
+
455
+ let view = read.view;
456
+ let document = foldersDocumentFromView(read.view);
457
+ let documentFieldExists = read.documentFieldExists;
458
+
459
+ const write = (next: FoldersDocument): void => {
460
+ writeFoldersDocument(tx, rids, { ...read, documentFieldExists }, next);
461
+ documentFieldExists = true;
462
+ document = next;
463
+ view = healFolders({ document: next, writable: true }, projects, templates);
464
+ };
465
+
466
+ const assertFolderExists = (folder: FolderId): void => {
467
+ if (!view.folders.some((candidate) => candidate.id === folder))
468
+ throw new Error(`Folder ${folder} does not exist.`);
469
+ };
470
+
471
+ const namesTakenIn = (folder: FolderId | undefined): string[] =>
472
+ read.decoded.writable ? foldersSiblingNames(view, folder) : [];
473
+
474
+ const folderOf = (project: ProjectId): FolderId | undefined =>
475
+ view.projects.find((candidate) => candidate.id === project)?.folder;
476
+
477
+ return {
478
+ get view() {
479
+ return view;
480
+ },
481
+ projectRids: read.projectRids,
482
+ templateRids: read.templateRids,
483
+ namesTakenIn,
484
+ namesTakenBeside: (project) => namesTakenIn(folderOf(project)),
485
+ folderOf,
486
+
487
+ assertNameFree(item, name) {
488
+ if (foldersNameBlank(name) || !read.decoded.writable) return;
489
+
490
+ const placed =
491
+ item.kind === "project"
492
+ ? view.projects.find((candidate) => candidate.id === item.id)
493
+ : view.templates.find((candidate) => candidate.id === item.id);
494
+ if (placed === undefined) return;
495
+
496
+ // Nothing is in collision with itself, so a rename that only changes the case of its own
497
+ // name goes through even where a pre-existing duplicate sits beside it.
498
+ if (foldersNameTaken(name, [placed.name])) return;
499
+
500
+ const siblings = foldersSiblingNames(view, placed.folder, [item.id]);
501
+ if (foldersNameTaken(name, siblings)) throw new Error(`"${name}" is already used here.`);
502
+ },
503
+
504
+ place(items, folder) {
505
+ if (folder === undefined || items.length === 0 || !read.decoded.writable) return;
506
+ assertFolderExists(folder);
507
+
508
+ const assignments = { ...document.assignments };
509
+ const templateAssignments = { ...document.templateAssignments };
510
+ for (const item of items) {
511
+ if (item.kind === "project") assignments[item.id] = folder;
512
+ else templateAssignments[item.id] = folder;
513
+ }
514
+ write({ ...document, assignments, templateAssignments });
515
+ },
516
+
517
+ graft(subtree, destination) {
518
+ if (!read.decoded.writable) return "unwritable";
519
+ if (destination !== undefined) assertFolderExists(destination);
520
+
521
+ const root: FoldersGraft["folders"][string] | undefined = subtree.folders[subtree.root];
522
+ if (root === undefined) throw new Error(`The subtree holds no folder ${subtree.root}.`);
523
+ const rootName = foldersUniqueName(root.name, foldersSiblingNames(view, destination));
524
+
525
+ const incoming = Object.keys(subtree.folders);
526
+ const minted = new Map<string, FolderId>();
527
+ for (const key of incoming) minted.set(key, asFolderId(randomUUID()));
528
+
529
+ const folders = [...document.folders];
530
+ for (const key of incoming) {
531
+ const incomingFolder = subtree.folders[key];
532
+ const parent =
533
+ key === subtree.root
534
+ ? destination
535
+ : mustGet(minted, incomingFolder.parent ?? subtree.root);
536
+ const description = normalizeDescription(incomingFolder.description);
537
+ folders.push({
538
+ id: mustGet(minted, key),
539
+ name: key === subtree.root ? rootName : incomingFolder.name,
540
+ ...(parent === undefined ? {} : { parent }),
541
+ ...(description === undefined ? {} : { description }),
542
+ });
543
+ }
544
+
545
+ const assignments = { ...document.assignments };
546
+ const templateAssignments = { ...document.templateAssignments };
547
+ for (const item of subtree.items) {
548
+ const folder = minted.get(item.folder);
549
+ if (folder === undefined) continue; // an item pointing at a folder that never arrived
550
+ if (item.kind === "project") assignments[item.id] = folder;
551
+ else templateAssignments[item.id] = folder;
552
+ }
553
+
554
+ write({ ...document, folders, assignments, templateAssignments });
555
+ return "grafted";
556
+ },
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Creates a folder and returns its id.
562
+ *
563
+ * The name is typed by a human, so a name already used in the destination is rejected rather than
564
+ * quietly suffixed — a text field that disagrees with what was typed is worse than one that says
565
+ * no.
566
+ */
567
+ export async function createFolder(
568
+ pl: PlClient,
569
+ rids: FoldersRids,
570
+ name: string,
571
+ parent?: FolderId,
572
+ ): Promise<FolderId> {
573
+ if (foldersNameBlank(name)) throw new Error("A folder name cannot be empty.");
574
+ const wanted = name.trim();
575
+
576
+ // Minted here, outside the transaction: an attempt that conflicts persists nothing, so an id
577
+ // minted inside one could be handed back to the caller after never having been stored.
578
+ const id = asFolderId(randomUUID());
579
+
580
+ await withFolders(pl, "MLCreateFolder", rids, (view) => {
581
+ if (parent !== undefined && !view.folders.some((folder) => folder.id === parent))
582
+ throw new Error(`Folder ${parent} does not exist.`);
583
+ if (foldersNameTaken(wanted, foldersSiblingNames(view, parent)))
584
+ throw new Error(`"${wanted}" is already used here.`);
585
+
586
+ const base = foldersDocumentFromView(view);
587
+ return {
588
+ result: undefined,
589
+ document: {
590
+ ...base,
591
+ folders: [
592
+ ...base.folders,
593
+ parent === undefined ? { id, name: wanted } : { id, name: wanted, parent },
594
+ ],
595
+ },
596
+ };
597
+ });
598
+
599
+ return id;
600
+ }
601
+
602
+ /** Renames a folder. A human typed this name too, so a collision is rejected, not suffixed. */
603
+ export async function renameFolder(
604
+ pl: PlClient,
605
+ rids: FoldersRids,
606
+ folder: FolderId,
607
+ name: string,
608
+ ): Promise<void> {
609
+ if (foldersNameBlank(name)) throw new Error("A folder name cannot be empty.");
610
+ const wanted = name.trim();
611
+
612
+ await withFolders(pl, "MLRenameFolder", rids, (view) => {
613
+ const target = view.folders.find((candidate) => candidate.id === folder);
614
+ if (target === undefined) throw new Error(`Folder ${folder} does not exist.`);
615
+
616
+ const siblings = foldersSiblingNames(view, target.parent, [folder]);
617
+ if (foldersNameTaken(wanted, siblings)) throw new Error(`"${wanted}" is already used here.`);
618
+
619
+ const base = foldersDocumentFromView(view);
620
+ return {
621
+ result: undefined,
622
+ document: {
623
+ ...base,
624
+ folders: base.folders.map((candidate) =>
625
+ candidate.id === folder ? { ...candidate, name: wanted } : candidate,
626
+ ),
627
+ },
628
+ };
629
+ });
630
+ }
631
+
632
+ /**
633
+ * Sets what a folder says about itself. Blank clears it.
634
+ *
635
+ * Unlike a name, a description is in no namespace: nothing is checked against the siblings, and
636
+ * two folders may say the same thing about themselves.
637
+ */
638
+ export async function setFolderDescription(
639
+ pl: PlClient,
640
+ rids: FoldersRids,
641
+ folder: FolderId,
642
+ description: string,
643
+ ): Promise<void> {
644
+ const wanted = normalizeDescription(description);
645
+
646
+ await withFolders(pl, "MLSetFolderDescription", rids, (view) => {
647
+ const target = view.folders.find((candidate) => candidate.id === folder);
648
+ if (target === undefined) throw new Error(`Folder ${folder} does not exist.`);
649
+
650
+ const base = foldersDocumentFromView(view);
651
+ return {
652
+ result: undefined,
653
+ document: {
654
+ ...base,
655
+ folders: base.folders.map((candidate) => {
656
+ if (candidate.id !== folder) return candidate;
657
+ const { description: _dropped, ...rest } = candidate;
658
+ return wanted === undefined ? rest : { ...rest, description: wanted };
659
+ }),
660
+ },
661
+ };
662
+ });
663
+ }
664
+
665
+ /**
666
+ * The shape of a folder subtree under ids local to the caller: a fresh id per folder, the root's
667
+ * own parent left behind so the subtree stands alone, and a lookup from a real folder id to its
668
+ * local one.
669
+ *
670
+ * Both ways of copying a folder need this. A share carries the shape into another user's tree and
671
+ * a duplicate rebuilds it beside the source, and neither may carry the folder ids themselves,
672
+ * because the document that receives the subtree mints its own.
673
+ */
674
+ export function foldersLocalSubtree<Id extends string>(
675
+ view: FoldersView,
676
+ root: FolderId,
677
+ mint: () => Id,
678
+ ): {
679
+ readonly inSubtree: ReadonlySet<FolderId>;
680
+ readonly localId: (id: FolderId) => Id;
681
+ readonly folders: Record<Id, { name: string; parent?: Id; description?: string }>;
682
+ } {
683
+ const inSubtree = new Set(foldersSubtree(view, root));
684
+
685
+ const localIds = new Map<FolderId, Id>();
686
+ for (const id of inSubtree) localIds.set(id, mint());
687
+ const localId = (id: FolderId): Id => {
688
+ const local = localIds.get(id);
689
+ if (local === undefined) throw new Error(`Folder ${id} is not under ${root}.`);
690
+ return local;
691
+ };
692
+
693
+ const folders = {} as Record<Id, { name: string; parent?: Id; description?: string }>;
694
+ for (const candidate of view.folders) {
695
+ if (!inSubtree.has(candidate.id)) continue;
696
+ // The copied folder is the subtree's root: its own parent stays behind. Every other folder of
697
+ // the subtree has its parent in the subtree too.
698
+ const parent = candidate.id === root ? undefined : localId(candidate.parent ?? root);
699
+ folders[localId(candidate.id)] = {
700
+ name: candidate.name,
701
+ ...(parent === undefined ? {} : { parent }),
702
+ ...(candidate.description === undefined ? {} : { description: candidate.description }),
703
+ };
704
+ }
705
+
706
+ return { inSubtree, localId, folders };
707
+ }
708
+
709
+ /**
710
+ * The plan a move would produce, computed from a fresh read — what the confirmation dialog shows.
711
+ * The same planner runs again inside the write, over the same shape of read.
712
+ */
713
+ export async function previewFoldersMove(
714
+ pl: PlClient,
715
+ rids: FoldersRids,
716
+ items: readonly FoldersItem[],
717
+ destination?: FolderId,
718
+ ): Promise<FoldersMovePlanResult> {
719
+ const view = await readFoldersView(pl, "MLPreviewFoldersMove", rids);
720
+ return planFoldersMove(view, items, destination);
721
+ }
722
+
723
+ /**
724
+ * Moves any mix of folders, projects and templates into one destination, committing only when
725
+ * the plan recomputed inside the transaction is identical to `confirmedPlan`.
726
+ */
727
+ export async function moveFolderItems(
728
+ pl: PlClient,
729
+ rids: FoldersRids,
730
+ items: readonly FoldersItem[],
731
+ destination: FolderId | undefined,
732
+ confirmedPlan: FoldersMovePlan,
733
+ ): Promise<FoldersMoveOutcome> {
734
+ return await withFolders(pl, "MLMoveFolderItems", rids, (view) =>
735
+ commitFoldersMove(view, planFoldersMove(view, items, destination), confirmedPlan),
736
+ );
737
+ }
738
+
739
+ /** What deleting a folder would destroy: the subtree of folders, and every project and template
740
+ * in it. */
741
+ export async function previewFolderDeletion(
742
+ pl: PlClient,
743
+ rids: FoldersRids,
744
+ folder: FolderId,
745
+ ): Promise<FoldersRemovalPlanResult> {
746
+ const view = await readFoldersView(pl, "MLPreviewFolderDeletion", rids);
747
+ return planFoldersRemoval(view, folder);
748
+ }
749
+
750
+ /**
751
+ * Deletes a folder, every folder inside it, and every project and template held anywhere in that
752
+ * subtree.
753
+ *
754
+ * Deletion cannot be undone, so `confirmedRemoval` is required: the removal is recomputed here and
755
+ * the write commits only if it names exactly the same things the caller confirmed. A project
756
+ * dropped into the folder after the dialog opened therefore aborts the deletion rather than being
757
+ * destroyed unseen.
758
+ */
759
+ export async function deleteFolder(
760
+ pl: PlClient,
761
+ rids: FoldersRids,
762
+ folder: FolderId,
763
+ confirmedRemoval?: FoldersRemoval,
764
+ ): Promise<FoldersRemovalOutcome> {
765
+ return await withFolders(pl, "MLDeleteFolder", rids, (view) =>
766
+ commitFoldersRemoval(view, planFoldersRemoval(view, folder), confirmedRemoval),
767
+ );
768
+ }
769
+
770
+ /**
771
+ * Replaces a folder document this build cannot read with an empty one: no folders and no
772
+ * assignments, so every project and template shows at the top level. Nothing but the document is
773
+ * touched, and what the unreadable tree held is lost for good.
774
+ *
775
+ * It is refused while the document reads fine, so a working tree can never be wiped through it.
776
+ * "Reads fine" is decided by the same decode the listing uses, in the transaction that writes.
777
+ */
778
+ export async function resetFolders(pl: PlClient, rids: FoldersRids): Promise<void> {
779
+ await pl.withWriteTx(
780
+ "MLResetFolders",
781
+ async (tx) => {
782
+ const stored = await readStoredFolders(tx, rids);
783
+ if (stored.decoded.writable)
784
+ throw new Error(
785
+ "Folders can only be reset when they cannot be read, and these can be read. Nothing was changed.",
786
+ );
787
+
788
+ writeFoldersDocument(
789
+ tx,
790
+ rids,
791
+ { carriedAssignments: {}, documentFieldExists: stored.documentFieldExists },
792
+ emptyFoldersDocument(),
793
+ );
794
+ await tx.commit();
795
+ },
796
+ { lockId: foldersLockId(rids.folders) },
797
+ );
798
+ }
799
+
800
+ //
801
+ // Internals
802
+ //
803
+
804
+ /** A key looked up in a map that was built from those very keys; absent means the code above
805
+ * changed and the two no longer agree. */
806
+ function mustGet<K, V>(map: Map<K, V>, key: K): V {
807
+ const value = map.get(key);
808
+ if (value === undefined) throw new Error(`No entry for ${String(key)}.`);
809
+ return value;
810
+ }
811
+
812
+ /** Each entry of a list with its place in the tree, looked up by id; an entry the tree does not
813
+ * place is at the top level. */
814
+ function withPlacement<E extends { readonly id: string }>(
815
+ entries: readonly E[],
816
+ placed: readonly ({ readonly id: string } & FoldersPlacement)[],
817
+ ): (E & FoldersPlacement)[] {
818
+ const byId = new Map(placed.map((item) => [item.id, item]));
819
+ return entries.map((entry) => {
820
+ const placement = byId.get(entry.id);
821
+ return {
822
+ ...entry,
823
+ ...(placement?.folder === undefined ? {} : { folder: placement.folder }),
824
+ ancestors: placement?.ancestors ?? [],
825
+ path: placement?.path ?? [],
826
+ };
827
+ });
828
+ }
829
+
830
+ interface FoldersRead {
831
+ readonly view: FoldersView;
832
+ readonly decoded: FoldersDecoded;
833
+ readonly projectRids: ReadonlyMap<ProjectId, SignedResourceId>;
834
+ readonly projectMetas: ReadonlyMap<ProjectId, ProjectMeta>;
835
+ readonly templateRids: ReadonlyMap<TemplateId, SignedResourceId>;
836
+ readonly documentFieldExists: boolean;
837
+ /**
838
+ * Assignments of projects that are in the list but whose metadata could not be read. They are
839
+ * missing from the view, so a document rebuilt from the view would drop them — and a project
840
+ * whose folder is forgotten because its metadata was briefly unreadable is a loss, not healing.
841
+ */
842
+ readonly carriedAssignments: Readonly<Record<string, FolderId>>;
843
+ }
844
+
845
+ /**
846
+ * The folder document and both item lists, read inside a transaction.
847
+ *
848
+ * An absent document field is a user with no folders, and is writable. A field that points at
849
+ * something carrying no readable blob is a document that exists and cannot be read here, and must
850
+ * refuse writes, or the first folder edit replaces that tree with an empty one.
851
+ *
852
+ * A list entry counts as a project when it carries project metadata, and as a template when it
853
+ * carries a template label. Nothing else is checked. The listing readers are stricter:
854
+ * {@link projectListEntries} also requires the project resource type by name and both the created
855
+ * and the last-modified timestamps, and {@link templateListEntries} also requires the template
856
+ * resource type by name, the data blob and the created timestamp. So every entry the listing shows
857
+ * is kept here, and so is an entry the listing skips because it has only partly synced or is a
858
+ * foreign resource carrying one of those keys: it still holds its name and its folder assignment,
859
+ * which is the safe side for a write. Telling a foreign resource apart by its type instead would
860
+ * read the state of every resource in the list, and a read of a project's state conflicts with
861
+ * every write that project's own session makes, so a folder edit would keep losing to any project
862
+ * that is open.
863
+ */
864
+ async function readFolders(tx: PlTransaction, rids: FoldersRids): Promise<FoldersRead> {
865
+ const { decoded, documentFieldExists } = await readStoredFolders(tx, rids);
866
+
867
+ const [listedProjects, listedTemplates] = await Promise.all([
868
+ listedById(tx, rids.projects),
869
+ listedById(tx, rids.templates),
870
+ ]);
871
+ const [metas, labels] = await Promise.all([
872
+ Promise.all(
873
+ [...listedProjects.values()].map(({ rid }) =>
874
+ tx.getKValueJsonIfExists<ProjectMeta>(rid, ProjectMetaKey),
875
+ ),
876
+ ),
877
+ Promise.all(
878
+ [...listedTemplates.values()].map(({ rid }) =>
879
+ tx.getKValueJsonIfExists<string>(rid, TemplateLabelKey),
880
+ ),
881
+ ),
882
+ ]);
883
+
884
+ const projectRids = new Map<ProjectId, SignedResourceId>();
885
+ const projectMetas = new Map<ProjectId, ProjectMeta>();
886
+ const projects: FoldersProjectInput[] = [];
887
+ const carriedAssignments: Record<string, FolderId> = {};
888
+ [...listedProjects.values()].forEach(({ rid }, index) => {
889
+ const id = asProjectId(resourceIdToString(rid));
890
+ projectRids.set(id, rid);
891
+ const meta = metas[index];
892
+ if (meta === undefined) {
893
+ const folder = decoded.document.assignments[id];
894
+ if (folder !== undefined) carriedAssignments[id] = folder;
895
+ return;
896
+ }
897
+ projectMetas.set(id, meta);
898
+ projects.push({ id, name: meta.label });
899
+ });
900
+
901
+ // Labels as well as ids: a template is named in the same namespace as folders and projects.
902
+ const templateRids = new Map<TemplateId, SignedResourceId>();
903
+ const templates: FoldersTemplateInput[] = [];
904
+ [...listedTemplates.values()].forEach(({ rid }, index) => {
905
+ const label = labels[index];
906
+ if (label === undefined) return;
907
+ const id = asTemplateId(resourceIdToString(rid));
908
+ templateRids.set(id, rid);
909
+ templates.push({ id, name: label });
910
+ });
911
+
912
+ return {
913
+ view: healFolders(decoded, projects, templates),
914
+ decoded,
915
+ projectRids,
916
+ projectMetas,
917
+ templateRids,
918
+ documentFieldExists,
919
+ carriedAssignments,
920
+ };
921
+ }
922
+
923
+ /** The folder document alone, decoded, and whether its field exists at all. */
924
+ async function readStoredFolders(
925
+ tx: PlTransaction,
926
+ rids: FoldersRids,
927
+ ): Promise<Pick<FoldersRead, "decoded" | "documentFieldExists">> {
928
+ const documentField = await tx.getFieldIfExists(field(rids.folders, FoldersDocumentField));
929
+ const decoded = decodeStoredFoldersDocument(
930
+ documentField === undefined || isNullSignedResourceId(documentField.value)
931
+ ? { present: false }
932
+ : { present: true, raw: await readDocumentText(tx, documentField.value) },
933
+ );
934
+ return { decoded, documentFieldExists: documentField !== undefined };
935
+ }
936
+
937
+ /** The stored JSON of a document value, or undefined when the value carries no blob. */
938
+ async function readDocumentText(
939
+ tx: PlTransaction,
940
+ rid: SignedResourceId,
941
+ ): Promise<string | undefined> {
942
+ const data = await tx.getResourceData(rid, false);
943
+ return data.data === undefined ? undefined : Buffer.from(data.data).toString("utf-8");
944
+ }
945
+
946
+ /**
947
+ * Persists a folder document: the assignments the read had to carry are added back, a new value
948
+ * resource is minted, and the one document field is created or re-pointed at it.
949
+ */
950
+ function writeFoldersDocument(
951
+ tx: PlTransaction,
952
+ rids: FoldersRids,
953
+ read: Pick<FoldersRead, "carriedAssignments" | "documentFieldExists">,
954
+ document: FoldersDocument,
955
+ ): void {
956
+ const ref = tx.createJsonValue(withCarriedAssignments(document, read.carriedAssignments));
957
+ const documentField = field(rids.folders, FoldersDocumentField);
958
+ if (read.documentFieldExists) tx.setField(documentField, ref);
959
+ else tx.createField(documentField, "Dynamic", ref);
960
+ }
961
+
962
+ /**
963
+ * A document written by a newer build, or one nothing here can parse, reads as no folders so that
964
+ * the project list still renders — and every write is refused, because degrading the read while
965
+ * still writing would silently replace that tree with this build's truncated view of it.
966
+ */
967
+ function refusalMessage(problem: FoldersDocumentProblem): string {
968
+ return (
969
+ `Folders cannot be changed: ${problem.message}. ` +
970
+ `Update the application to a version that understands this folder document.`
971
+ );
972
+ }
973
+
974
+ function withCarriedAssignments(
975
+ document: FoldersDocument,
976
+ carried: Readonly<Record<string, FolderId>>,
977
+ ): FoldersDocument {
978
+ const entries = Object.entries(carried).filter(([, folder]) =>
979
+ document.folders.some((candidate) => candidate.id === folder),
980
+ );
981
+ if (entries.length === 0) return document;
982
+ return { ...document, assignments: { ...document.assignments, ...Object.fromEntries(entries) } };
983
+ }
984
+
985
+ /**
986
+ * Refuses a write that would put a *new* rule violation into the document.
987
+ *
988
+ * Only new ones: accounts predating folders may already hold two projects with the same name in
989
+ * one place, because nothing enforced uniqueness before, and a write that merely carries such a
990
+ * collision along must not be blocked by it. What the folder machinery must never do is create
991
+ * one.
992
+ *
993
+ * What the write deletes is left out of the document it leaves behind. A deleted project or
994
+ * template has no folder any more, and counted anyway it would stand at the top level under its
995
+ * old name — where it clashes with whatever is really there, and refuses the deletion.
996
+ */
997
+ function rejectNewViolations(
998
+ read: FoldersRead,
999
+ document: FoldersDocument,
1000
+ renames: readonly FoldersLabelRename[],
1001
+ deleted: readonly string[],
1002
+ ): void {
1003
+ const renamed = new Map<string, string>(renames.map((rename) => [rename.item.id, rename.name]));
1004
+ const gone = new Set<string>(deleted);
1005
+ const templates = read.view.templates.map(({ id, name }) => ({ id, name }));
1006
+ const before = validateFoldersDocument(
1007
+ foldersDocumentFromView(read.view),
1008
+ [...read.projectMetas].map(([id, meta]) => ({ id, name: meta.label })),
1009
+ templates,
1010
+ );
1011
+ const after = validateFoldersDocument(
1012
+ document,
1013
+ [...read.projectMetas]
1014
+ .filter(([id]) => !gone.has(id))
1015
+ .map(([id, meta]) => ({ id, name: renamed.get(id) ?? meta.label })),
1016
+ templates
1017
+ .filter(({ id }) => !gone.has(id))
1018
+ .map(({ id, name }) => ({ id, name: renamed.get(id) ?? name })),
1019
+ );
1020
+
1021
+ const known = new Set(before.map(violationKey));
1022
+ const introduced = after.filter((violation) => !known.has(violationKey(violation)));
1023
+ if (introduced.length === 0) return;
1024
+
1025
+ throw new Error(`Folder change refused: ${introduced.map(formatFoldersViolation).join("; ")}.`);
1026
+ }
1027
+
1028
+ /**
1029
+ * Deletes the named items from one of the root's lists. Every id must be there: an id the list
1030
+ * does not hold means the write is deleting something other than what was planned, and the
1031
+ * transaction is abandoned rather than allowed to destroy a guess.
1032
+ */
1033
+ async function removeListedFields(
1034
+ tx: PlTransaction,
1035
+ listRid: SignedResourceId,
1036
+ ids: readonly string[],
1037
+ kind: ListedKind,
1038
+ ): Promise<void> {
1039
+ if (ids.length === 0) return;
1040
+
1041
+ const listed = await listedById(tx, listRid);
1042
+ for (const id of new Set(ids)) {
1043
+ const entry = listed.get(id);
1044
+ if (entry === undefined) throw notListedError(kind, id);
1045
+ tx.removeField(field(listRid, entry.fieldName));
1046
+ }
1047
+ }
1048
+
1049
+ function violationKey(violation: FoldersViolation): string {
1050
+ return JSON.stringify(violation);
1051
+ }
1052
+
1053
+ /**
1054
+ * Scoped to the singleton rather than to the process, so that two middle layers over two users'
1055
+ * roots — an impersonating admin session and the admin's own — do not queue behind each other.
1056
+ */
1057
+ function foldersLockId(foldersRid: SignedResourceId): string {
1058
+ return `folders:${foldersRid}`;
1059
+ }