@milaboratories/pl-middle-layer 1.71.16 → 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.
- package/dist/index.cjs +10 -5
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/folders.cjs +631 -0
- package/dist/middle_layer/folders.cjs.map +1 -0
- package/dist/middle_layer/folders.d.ts +39 -0
- package/dist/middle_layer/folders.d.ts.map +1 -0
- package/dist/middle_layer/folders.js +613 -0
- package/dist/middle_layer/folders.js.map +1 -0
- package/dist/middle_layer/index.cjs +3 -0
- package/dist/middle_layer/index.d.ts +4 -3
- package/dist/middle_layer/index.js +2 -1
- package/dist/middle_layer/middle_layer.cjs +585 -370
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +215 -122
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +593 -378
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project_list.cjs +36 -19
- package/dist/middle_layer/project_list.cjs.map +1 -1
- package/dist/middle_layer/project_list.d.ts +1 -1
- package/dist/middle_layer/project_list.d.ts.map +1 -1
- package/dist/middle_layer/project_list.js +37 -21
- package/dist/middle_layer/project_list.js.map +1 -1
- package/dist/middle_layer/sharing_list.cjs +123 -93
- package/dist/middle_layer/sharing_list.cjs.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +43 -19
- package/dist/middle_layer/sharing_list.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.js +123 -93
- package/dist/middle_layer/sharing_list.js.map +1 -1
- package/dist/middle_layer/template_list.cjs +45 -20
- package/dist/middle_layer/template_list.cjs.map +1 -1
- package/dist/middle_layer/template_list.d.ts +7 -15
- package/dist/middle_layer/template_list.d.ts.map +1 -1
- package/dist/middle_layer/template_list.js +44 -21
- package/dist/middle_layer/template_list.js.map +1 -1
- package/dist/model/index.cjs +7 -5
- package/dist/model/index.d.ts +2 -2
- package/dist/model/index.js +2 -2
- package/dist/model/sharing_model.cjs +46 -20
- package/dist/model/sharing_model.cjs.map +1 -1
- package/dist/model/sharing_model.d.ts +125 -55
- package/dist/model/sharing_model.d.ts.map +1 -1
- package/dist/model/sharing_model.js +40 -16
- package/dist/model/sharing_model.js.map +1 -1
- package/dist/mutator/list.cjs +30 -0
- package/dist/mutator/list.cjs.map +1 -0
- package/dist/mutator/list.js +29 -0
- package/dist/mutator/list.js.map +1 -0
- package/dist/mutator/project.cjs +14 -1
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +15 -2
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/sharing.cjs +130 -76
- package/dist/mutator/sharing.cjs.map +1 -1
- package/dist/mutator/sharing.js +130 -76
- package/dist/mutator/sharing.js.map +1 -1
- package/dist/mutator/template.cjs +24 -17
- package/dist/mutator/template.cjs.map +1 -1
- package/dist/mutator/template.js +26 -20
- package/dist/mutator/template.js.map +1 -1
- package/package.json +19 -19
- package/src/middle_layer/folders.test.ts +1068 -0
- package/src/middle_layer/folders.ts +1059 -0
- package/src/middle_layer/folders_read.test.ts +359 -0
- package/src/middle_layer/index.ts +3 -2
- package/src/middle_layer/middle_layer.ts +813 -547
- package/src/middle_layer/project_list.test.ts +183 -0
- package/src/middle_layer/project_list.ts +58 -21
- package/src/middle_layer/sharing.test.ts +183 -0
- package/src/middle_layer/sharing_list.ts +208 -152
- package/src/middle_layer/template_list.test.ts +148 -0
- package/src/middle_layer/template_list.ts +72 -32
- package/src/middle_layer/templates.test.ts +76 -45
- package/src/model/sharing_model.test.ts +69 -2
- package/src/model/sharing_model.ts +161 -73
- package/src/mutator/list.ts +36 -0
- package/src/mutator/project-v3.test.ts +3 -1
- package/src/mutator/project.ts +13 -2
- package/src/mutator/sharing.ts +193 -120
- package/src/mutator/template.ts +37 -29
- package/src/test/with_ml.ts +73 -16
|
@@ -0,0 +1,613 @@
|
|
|
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) => read.decoded.writable ? foldersSiblingNames(view, folder) : [];
|
|
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) => namesTakenIn(folderOf(project)),
|
|
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]))) throw new Error(`"${name}" is already used here.`);
|
|
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));
|
|
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 already used in the destination is rejected rather than
|
|
261
|
+
* quietly suffixed — a text field that disagrees with what was typed is worse than one that says
|
|
262
|
+
* no.
|
|
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))) throw new Error(`"${wanted}" is already used here.`);
|
|
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]))) throw new Error(`"${wanted}" is already used here.`);
|
|
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
|
+
/** A key looked up in a map that was built from those very keys; absent means the code above
|
|
422
|
+
* changed and the two no longer agree. */
|
|
423
|
+
function mustGet(map, key) {
|
|
424
|
+
const value = map.get(key);
|
|
425
|
+
if (value === void 0) throw new Error(`No entry for ${String(key)}.`);
|
|
426
|
+
return value;
|
|
427
|
+
}
|
|
428
|
+
/** Each entry of a list with its place in the tree, looked up by id; an entry the tree does not
|
|
429
|
+
* place is at the top level. */
|
|
430
|
+
function withPlacement(entries, placed) {
|
|
431
|
+
const byId = new Map(placed.map((item) => [item.id, item]));
|
|
432
|
+
return entries.map((entry) => {
|
|
433
|
+
const placement = byId.get(entry.id);
|
|
434
|
+
return {
|
|
435
|
+
...entry,
|
|
436
|
+
...placement?.folder === void 0 ? {} : { folder: placement.folder },
|
|
437
|
+
ancestors: placement?.ancestors ?? [],
|
|
438
|
+
path: placement?.path ?? []
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* The folder document and both item lists, read inside a transaction.
|
|
444
|
+
*
|
|
445
|
+
* An absent document field is a user with no folders, and is writable. A field that points at
|
|
446
|
+
* something carrying no readable blob is a document that exists and cannot be read here, and must
|
|
447
|
+
* refuse writes, or the first folder edit replaces that tree with an empty one.
|
|
448
|
+
*
|
|
449
|
+
* A list entry counts as a project when it carries project metadata, and as a template when it
|
|
450
|
+
* carries a template label. Nothing else is checked. The listing readers are stricter:
|
|
451
|
+
* {@link projectListEntries} also requires the project resource type by name and both the created
|
|
452
|
+
* and the last-modified timestamps, and {@link templateListEntries} also requires the template
|
|
453
|
+
* resource type by name, the data blob and the created timestamp. So every entry the listing shows
|
|
454
|
+
* is kept here, and so is an entry the listing skips because it has only partly synced or is a
|
|
455
|
+
* foreign resource carrying one of those keys: it still holds its name and its folder assignment,
|
|
456
|
+
* which is the safe side for a write. Telling a foreign resource apart by its type instead would
|
|
457
|
+
* read the state of every resource in the list, and a read of a project's state conflicts with
|
|
458
|
+
* every write that project's own session makes, so a folder edit would keep losing to any project
|
|
459
|
+
* that is open.
|
|
460
|
+
*/
|
|
461
|
+
async function readFolders(tx, rids) {
|
|
462
|
+
const { decoded, documentFieldExists } = await readStoredFolders(tx, rids);
|
|
463
|
+
const [listedProjects, listedTemplates] = await Promise.all([listedById(tx, rids.projects), listedById(tx, rids.templates)]);
|
|
464
|
+
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)))]);
|
|
465
|
+
const projectRids = /* @__PURE__ */ new Map();
|
|
466
|
+
const projectMetas = /* @__PURE__ */ new Map();
|
|
467
|
+
const projects = [];
|
|
468
|
+
const carriedAssignments = {};
|
|
469
|
+
[...listedProjects.values()].forEach(({ rid }, index) => {
|
|
470
|
+
const id = asProjectId(resourceIdToString(rid));
|
|
471
|
+
projectRids.set(id, rid);
|
|
472
|
+
const meta = metas[index];
|
|
473
|
+
if (meta === void 0) {
|
|
474
|
+
const folder = decoded.document.assignments[id];
|
|
475
|
+
if (folder !== void 0) carriedAssignments[id] = folder;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
projectMetas.set(id, meta);
|
|
479
|
+
projects.push({
|
|
480
|
+
id,
|
|
481
|
+
name: meta.label
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
const templateRids = /* @__PURE__ */ new Map();
|
|
485
|
+
const templates = [];
|
|
486
|
+
[...listedTemplates.values()].forEach(({ rid }, index) => {
|
|
487
|
+
const label = labels[index];
|
|
488
|
+
if (label === void 0) return;
|
|
489
|
+
const id = asTemplateId(resourceIdToString(rid));
|
|
490
|
+
templateRids.set(id, rid);
|
|
491
|
+
templates.push({
|
|
492
|
+
id,
|
|
493
|
+
name: label
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
return {
|
|
497
|
+
view: healFolders(decoded, projects, templates),
|
|
498
|
+
decoded,
|
|
499
|
+
projectRids,
|
|
500
|
+
projectMetas,
|
|
501
|
+
templateRids,
|
|
502
|
+
documentFieldExists,
|
|
503
|
+
carriedAssignments
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
/** The folder document alone, decoded, and whether its field exists at all. */
|
|
507
|
+
async function readStoredFolders(tx, rids) {
|
|
508
|
+
const documentField = await tx.getFieldIfExists(field(rids.folders, FoldersDocumentField));
|
|
509
|
+
return {
|
|
510
|
+
decoded: decodeStoredFoldersDocument(documentField === void 0 || isNullSignedResourceId(documentField.value) ? { present: false } : {
|
|
511
|
+
present: true,
|
|
512
|
+
raw: await readDocumentText(tx, documentField.value)
|
|
513
|
+
}),
|
|
514
|
+
documentFieldExists: documentField !== void 0
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** The stored JSON of a document value, or undefined when the value carries no blob. */
|
|
518
|
+
async function readDocumentText(tx, rid) {
|
|
519
|
+
const data = await tx.getResourceData(rid, false);
|
|
520
|
+
return data.data === void 0 ? void 0 : Buffer.from(data.data).toString("utf-8");
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Persists a folder document: the assignments the read had to carry are added back, a new value
|
|
524
|
+
* resource is minted, and the one document field is created or re-pointed at it.
|
|
525
|
+
*/
|
|
526
|
+
function writeFoldersDocument(tx, rids, read, document) {
|
|
527
|
+
const ref = tx.createJsonValue(withCarriedAssignments(document, read.carriedAssignments));
|
|
528
|
+
const documentField = field(rids.folders, FoldersDocumentField);
|
|
529
|
+
if (read.documentFieldExists) tx.setField(documentField, ref);
|
|
530
|
+
else tx.createField(documentField, "Dynamic", ref);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A document written by a newer build, or one nothing here can parse, reads as no folders so that
|
|
534
|
+
* the project list still renders — and every write is refused, because degrading the read while
|
|
535
|
+
* still writing would silently replace that tree with this build's truncated view of it.
|
|
536
|
+
*/
|
|
537
|
+
function refusalMessage(problem) {
|
|
538
|
+
return `Folders cannot be changed: ${problem.message}. Update the application to a version that understands this folder document.`;
|
|
539
|
+
}
|
|
540
|
+
function withCarriedAssignments(document, carried) {
|
|
541
|
+
const entries = Object.entries(carried).filter(([, folder]) => document.folders.some((candidate) => candidate.id === folder));
|
|
542
|
+
if (entries.length === 0) return document;
|
|
543
|
+
return {
|
|
544
|
+
...document,
|
|
545
|
+
assignments: {
|
|
546
|
+
...document.assignments,
|
|
547
|
+
...Object.fromEntries(entries)
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Refuses a write that would put a *new* rule violation into the document.
|
|
553
|
+
*
|
|
554
|
+
* Only new ones: accounts predating folders may already hold two projects with the same name in
|
|
555
|
+
* one place, because nothing enforced uniqueness before, and a write that merely carries such a
|
|
556
|
+
* collision along must not be blocked by it. What the folder machinery must never do is create
|
|
557
|
+
* one.
|
|
558
|
+
*
|
|
559
|
+
* What the write deletes is left out of the document it leaves behind. A deleted project or
|
|
560
|
+
* template has no folder any more, and counted anyway it would stand at the top level under its
|
|
561
|
+
* old name — where it clashes with whatever is really there, and refuses the deletion.
|
|
562
|
+
*/
|
|
563
|
+
function rejectNewViolations(read, document, renames, deleted) {
|
|
564
|
+
const renamed = new Map(renames.map((rename) => [rename.item.id, rename.name]));
|
|
565
|
+
const gone = new Set(deleted);
|
|
566
|
+
const templates = read.view.templates.map(({ id, name }) => ({
|
|
567
|
+
id,
|
|
568
|
+
name
|
|
569
|
+
}));
|
|
570
|
+
const before = validateFoldersDocument(foldersDocumentFromView(read.view), [...read.projectMetas].map(([id, meta]) => ({
|
|
571
|
+
id,
|
|
572
|
+
name: meta.label
|
|
573
|
+
})), templates);
|
|
574
|
+
const after = validateFoldersDocument(document, [...read.projectMetas].filter(([id]) => !gone.has(id)).map(([id, meta]) => ({
|
|
575
|
+
id,
|
|
576
|
+
name: renamed.get(id) ?? meta.label
|
|
577
|
+
})), templates.filter(({ id }) => !gone.has(id)).map(({ id, name }) => ({
|
|
578
|
+
id,
|
|
579
|
+
name: renamed.get(id) ?? name
|
|
580
|
+
})));
|
|
581
|
+
const known = new Set(before.map(violationKey));
|
|
582
|
+
const introduced = after.filter((violation) => !known.has(violationKey(violation)));
|
|
583
|
+
if (introduced.length === 0) return;
|
|
584
|
+
throw new Error(`Folder change refused: ${introduced.map(formatFoldersViolation).join("; ")}.`);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Deletes the named items from one of the root's lists. Every id must be there: an id the list
|
|
588
|
+
* does not hold means the write is deleting something other than what was planned, and the
|
|
589
|
+
* transaction is abandoned rather than allowed to destroy a guess.
|
|
590
|
+
*/
|
|
591
|
+
async function removeListedFields(tx, listRid, ids, kind) {
|
|
592
|
+
if (ids.length === 0) return;
|
|
593
|
+
const listed = await listedById(tx, listRid);
|
|
594
|
+
for (const id of new Set(ids)) {
|
|
595
|
+
const entry = listed.get(id);
|
|
596
|
+
if (entry === void 0) throw notListedError(kind, id);
|
|
597
|
+
tx.removeField(field(listRid, entry.fieldName));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function violationKey(violation) {
|
|
601
|
+
return JSON.stringify(violation);
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Scoped to the singleton rather than to the process, so that two middle layers over two users'
|
|
605
|
+
* roots — an impersonating admin session and the admin's own — do not queue behind each other.
|
|
606
|
+
*/
|
|
607
|
+
function foldersLockId(foldersRid) {
|
|
608
|
+
return `folders:${foldersRid}`;
|
|
609
|
+
}
|
|
610
|
+
//#endregion
|
|
611
|
+
export { FoldersDocumentField, FoldersField, FoldersResourceType, FoldersTreePruningFunction, createFolder, createFolderList, deleteFolder, foldersFieldFilter, foldersListing, foldersLocalSubtree, moveFolderItems, openFoldersTx, previewFolderDeletion, previewFoldersMove, readFoldersView, renameFolder, resetFolders, setFolderDescription, withFolders };
|
|
612
|
+
|
|
613
|
+
//# sourceMappingURL=folders.js.map
|