@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.
- 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 +18 -18
- 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,1068 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import * as tp from "node:timers/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { SignedResourceId } from "@milaboratories/pl-client";
|
|
5
|
+
import { field, isNullSignedResourceId } from "@milaboratories/pl-client";
|
|
6
|
+
import type { FolderId, FoldersMovePlan } from "@milaboratories/pl-model-middle-layer";
|
|
7
|
+
import { withMlKeepingRoots, withMlOnUserRoot } from "../test/with_ml";
|
|
8
|
+
import type { ProjectId } from "../model/project_model";
|
|
9
|
+
import type { MiddleLayer } from "./middle_layer";
|
|
10
|
+
import type { FoldersListing } from "./folders";
|
|
11
|
+
import { FoldersDocumentField, FoldersField, ensureFoldersRid } from "./folders";
|
|
12
|
+
import type { TemplateId } from "./template_list";
|
|
13
|
+
|
|
14
|
+
// The file's roots outlive their tests and are deleted together after the last one, so the
|
|
15
|
+
// backend's cleanup of their projects never runs under this file's own writes.
|
|
16
|
+
const withMl = withMlKeepingRoots();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The folder feature end to end against a live backend: the singleton slot, the joined read, the
|
|
20
|
+
* one write helper, and the move contract.
|
|
21
|
+
*
|
|
22
|
+
* Needs a backend, like every `withMl` test in this package, and no gate: `PL_ADDRESS` is either
|
|
23
|
+
* configured or the client fails to connect.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
test("a user with no folders sees every project at the top level", async () => {
|
|
27
|
+
await withMl(async (ml) => {
|
|
28
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
29
|
+
|
|
30
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 1);
|
|
31
|
+
expect(listing.folders).toStrictEqual([]);
|
|
32
|
+
expect(listing.writable).toBe(true);
|
|
33
|
+
expect(listing.healed).toBe(false);
|
|
34
|
+
expect(listing.problem).toBeUndefined();
|
|
35
|
+
expect(listing.projects[0].id).toBe(project);
|
|
36
|
+
expect(listing.projects[0].folder).toBeUndefined();
|
|
37
|
+
expect(listing.projects[0].path).toStrictEqual([]);
|
|
38
|
+
|
|
39
|
+
// Reading writes nothing. The singleton is made by init, but nothing has put a document in
|
|
40
|
+
// it, so an account that never touches folders never grows one.
|
|
41
|
+
expect(await singletonExists(ml)).toBe(true);
|
|
42
|
+
expect(await documentFieldExists(ml)).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("folders nest to any depth", async () => {
|
|
47
|
+
await withMl(async (ml) => {
|
|
48
|
+
const one = await ml.createFolder("One");
|
|
49
|
+
const two = await ml.createFolder("Two", one);
|
|
50
|
+
const three = await ml.createFolder("Three", two);
|
|
51
|
+
const four = await ml.createFolder("Four", three);
|
|
52
|
+
|
|
53
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 4);
|
|
54
|
+
expect(depthOf(listing, one)).toBe(1);
|
|
55
|
+
expect(depthOf(listing, two)).toBe(2);
|
|
56
|
+
expect(depthOf(listing, three)).toBe(3);
|
|
57
|
+
expect(depthOf(listing, four)).toBe(4);
|
|
58
|
+
expect(listing.folders.find((f) => f.id === four)?.path).toStrictEqual([
|
|
59
|
+
"One",
|
|
60
|
+
"Two",
|
|
61
|
+
"Three",
|
|
62
|
+
"Four",
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a typed name is stored trimmed, and a blank folder name is refused", async () => {
|
|
68
|
+
await withMl(async (ml) => {
|
|
69
|
+
const folder = await ml.createFolder(" Samples ");
|
|
70
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
71
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
72
|
+
await ml.setProjectMeta(project, { label: " Beta " });
|
|
73
|
+
|
|
74
|
+
const listing = await untilListing(ml, (l) => labelOf(l, project) === "Beta");
|
|
75
|
+
expect(listing.folders.find((f) => f.id === folder)?.name).toBe("Samples");
|
|
76
|
+
|
|
77
|
+
await expect(ml.createFolder(" ")).rejects.toThrow(/cannot be empty/);
|
|
78
|
+
await expect(ml.renameFolder(folder, " ")).rejects.toThrow(/cannot be empty/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a name a human typed is rejected when it is taken, whatever its case", async () => {
|
|
83
|
+
await withMl(async (ml) => {
|
|
84
|
+
await ml.createFolder("Samples");
|
|
85
|
+
await expect(ml.createFolder("samples")).rejects.toThrow(/already used/);
|
|
86
|
+
|
|
87
|
+
// A project shares the namespace with folders inside the same parent.
|
|
88
|
+
await ml.createProject({ label: "Alpha" });
|
|
89
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
90
|
+
await expect(ml.createFolder("alpha")).rejects.toThrow(/already used/);
|
|
91
|
+
|
|
92
|
+
const other = await ml.createFolder("Other");
|
|
93
|
+
await expect(ml.renameFolder(other, "SAMPLES")).rejects.toThrow(/already used/);
|
|
94
|
+
|
|
95
|
+
// Renaming a folder to what it is already called is not a collision with itself.
|
|
96
|
+
await ml.renameFolder(other, "Other");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("moving a project into a folder gives it the folder's path and keeps it in the list", async () => {
|
|
101
|
+
await withMl(async (ml) => {
|
|
102
|
+
const folder = await ml.createFolder("Samples");
|
|
103
|
+
const nested = await ml.createFolder("2024", folder);
|
|
104
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
105
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
106
|
+
|
|
107
|
+
const planned = await ml.previewFoldersMove([item(project)], nested);
|
|
108
|
+
expect(planned.ok).toBe(true);
|
|
109
|
+
if (!planned.ok) return;
|
|
110
|
+
expect(planned.plan.entries).toHaveLength(1);
|
|
111
|
+
expect(planned.plan.entries[0].name).toBe("Alpha");
|
|
112
|
+
expect(planned.plan.entries[0].renamed).toBe(false);
|
|
113
|
+
|
|
114
|
+
const outcome = await ml.moveFolderItems([item(project)], nested, planned.plan);
|
|
115
|
+
expect(outcome.ok).toBe(true);
|
|
116
|
+
|
|
117
|
+
const listing = await untilListing(ml, (l) => l.projects[0]?.folder !== undefined);
|
|
118
|
+
expect(listing.projects[0].folder).toBe(nested);
|
|
119
|
+
expect(listing.projects[0].ancestors).toStrictEqual([folder, nested]);
|
|
120
|
+
expect(listing.projects[0].path).toStrictEqual(["Samples", "2024"]);
|
|
121
|
+
expect((await ml.projectList.awaitStableValue()).map((e) => e.id)).toStrictEqual([project]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a move into a taken name renames the moved project, and the rename is persisted", async () => {
|
|
126
|
+
await withMl(async (ml) => {
|
|
127
|
+
const folder = await ml.createFolder("Samples");
|
|
128
|
+
const resident = await ml.createProject({ label: "Alpha" });
|
|
129
|
+
const incoming = await ml.createProject({ label: "Alpha" });
|
|
130
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
131
|
+
|
|
132
|
+
await move(ml, resident, folder);
|
|
133
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === folder));
|
|
134
|
+
|
|
135
|
+
const planned = await ml.previewFoldersMove([item(incoming)], folder);
|
|
136
|
+
expect(planned.ok).toBe(true);
|
|
137
|
+
if (!planned.ok) return;
|
|
138
|
+
expect(planned.plan.entries[0].name).toBe("Alpha (Copy)");
|
|
139
|
+
expect(planned.plan.entries[0].renamed).toBe(true);
|
|
140
|
+
|
|
141
|
+
expect((await ml.moveFolderItems([item(incoming)], folder, planned.plan)).ok).toBe(true);
|
|
142
|
+
|
|
143
|
+
const listing = await untilListing(
|
|
144
|
+
ml,
|
|
145
|
+
(l) => l.projects.filter((p) => p.folder === folder).length === 2,
|
|
146
|
+
);
|
|
147
|
+
expect(labelOf(listing, incoming)).toBe("Alpha (Copy)");
|
|
148
|
+
expect(labelOf(listing, resident)).toBe("Alpha");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a plan confirmed against state that has since changed is refused and writes nothing", async () => {
|
|
153
|
+
await withMl(async (ml) => {
|
|
154
|
+
const folder = await ml.createFolder("Samples");
|
|
155
|
+
const first = await ml.createProject({ label: "Alpha" });
|
|
156
|
+
const second = await ml.createProject({ label: "Alpha" });
|
|
157
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
158
|
+
|
|
159
|
+
// Confirmed while the folder is still empty, so it promises no rename.
|
|
160
|
+
const confirmed = await ml.previewFoldersMove([item(first)], folder);
|
|
161
|
+
expect(confirmed.ok).toBe(true);
|
|
162
|
+
if (!confirmed.ok) return;
|
|
163
|
+
expect(confirmed.plan.entries[0].renamed).toBe(false);
|
|
164
|
+
|
|
165
|
+
// Somebody else takes the name in the meantime.
|
|
166
|
+
await move(ml, second, folder);
|
|
167
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === folder));
|
|
168
|
+
|
|
169
|
+
const refused = await ml.moveFolderItems([item(first)], folder, confirmed.plan);
|
|
170
|
+
expect(refused.ok).toBe(false);
|
|
171
|
+
if (refused.ok) return;
|
|
172
|
+
expect(refused.reason).toBe("plan-changed");
|
|
173
|
+
if (refused.reason !== "plan-changed") return;
|
|
174
|
+
expect(refused.plan.entries[0].name).toBe("Alpha (Copy)");
|
|
175
|
+
|
|
176
|
+
// Nothing was written: the project is still where it was, under the name it had.
|
|
177
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 2);
|
|
178
|
+
expect(listing.projects.find((p) => p.id === first)?.folder).toBeUndefined();
|
|
179
|
+
expect(labelOf(listing, first)).toBe("Alpha");
|
|
180
|
+
|
|
181
|
+
// Re-confirming the fresh plan commits it.
|
|
182
|
+
expect((await ml.moveFolderItems([item(first)], folder, refused.plan)).ok).toBe(true);
|
|
183
|
+
const after = await untilListing(
|
|
184
|
+
ml,
|
|
185
|
+
(l) => l.projects.filter((p) => p.folder === folder).length === 2,
|
|
186
|
+
);
|
|
187
|
+
expect(labelOf(after, first)).toBe("Alpha (Copy)");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("deleting a folder destroys its whole subtree, projects included", async () => {
|
|
192
|
+
await withMl(async (ml) => {
|
|
193
|
+
const outer = await ml.createFolder("Outer");
|
|
194
|
+
const inner = await ml.createFolder("Inner", outer);
|
|
195
|
+
const deep = await ml.createFolder("Deep", inner);
|
|
196
|
+
const doomed = await ml.createProject({ label: "Alpha" });
|
|
197
|
+
const spared = await ml.createProject({ label: "Beta" });
|
|
198
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
199
|
+
await move(ml, doomed, deep);
|
|
200
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === deep));
|
|
201
|
+
|
|
202
|
+
const planned = await ml.previewFolderDeletion(inner);
|
|
203
|
+
expect(planned.ok).toBe(true);
|
|
204
|
+
if (!planned.ok) return;
|
|
205
|
+
expect([...planned.removal.folders].sort()).toStrictEqual([inner, deep].sort());
|
|
206
|
+
expect(planned.removal.projects).toStrictEqual([doomed]);
|
|
207
|
+
|
|
208
|
+
expect((await ml.deleteFolder(inner, planned.removal)).ok).toBe(true);
|
|
209
|
+
|
|
210
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 1);
|
|
211
|
+
expect(listing.folders.map((f) => f.id)).toStrictEqual([outer]);
|
|
212
|
+
// The project inside is gone from the project list itself, not merely from the tree.
|
|
213
|
+
expect(listing.projects.map((p) => p.id)).toStrictEqual([spared]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("deleting a folder whose project shares a name with one at the top level", async () => {
|
|
218
|
+
await withMl(async (ml) => {
|
|
219
|
+
const folder = await ml.createFolder("Holder");
|
|
220
|
+
const inside = await ml.createProject({ label: "Alpaca" });
|
|
221
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
222
|
+
await move(ml, inside, folder);
|
|
223
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === folder));
|
|
224
|
+
const outside = await ml.createProject({ label: "Alpaca" });
|
|
225
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
226
|
+
|
|
227
|
+
const planned = await ml.previewFolderDeletion(folder);
|
|
228
|
+
expect(planned.ok).toBe(true);
|
|
229
|
+
if (!planned.ok) return;
|
|
230
|
+
|
|
231
|
+
// The deleted project leaves no folder behind to stand in, so it must not be counted at the
|
|
232
|
+
// top level beside the "Alpaca" that is really there.
|
|
233
|
+
expect((await ml.deleteFolder(folder, planned.removal)).ok).toBe(true);
|
|
234
|
+
|
|
235
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 0);
|
|
236
|
+
expect(listing.projects.map((p) => p.id)).toStrictEqual([outside]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a deletion is refused when the subtree changed after the dialog read it", async () => {
|
|
241
|
+
await withMl(async (ml) => {
|
|
242
|
+
const folder = await ml.createFolder("Outer");
|
|
243
|
+
const first = await ml.createProject({ label: "Alpha" });
|
|
244
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
245
|
+
await move(ml, first, folder);
|
|
246
|
+
await untilListing(ml, (l) => l.projects[0]?.folder === folder);
|
|
247
|
+
|
|
248
|
+
const planned = await ml.previewFolderDeletion(folder);
|
|
249
|
+
expect(planned.ok).toBe(true);
|
|
250
|
+
if (!planned.ok) return;
|
|
251
|
+
|
|
252
|
+
// A second project lands in the folder between the preview and the write.
|
|
253
|
+
const latecomer = await ml.createProject({ label: "Beta" });
|
|
254
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
255
|
+
await move(ml, latecomer, folder);
|
|
256
|
+
await untilListing(ml, (l) => l.projects.filter((p) => p.folder === folder).length === 2);
|
|
257
|
+
|
|
258
|
+
const outcome = await ml.deleteFolder(folder, planned.removal);
|
|
259
|
+
expect(outcome.ok).toBe(false);
|
|
260
|
+
if (outcome.ok) return;
|
|
261
|
+
if (outcome.reason === "cannot-plan") throw new Error("expected a fresh removal");
|
|
262
|
+
expect(outcome.reason).toBe("plan-changed");
|
|
263
|
+
expect(outcome.removal.projects).toContain(latecomer);
|
|
264
|
+
|
|
265
|
+
// Nothing was written: both projects and the folder are still there.
|
|
266
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 2);
|
|
267
|
+
expect(listing.folders.map((f) => f.id)).toStrictEqual([folder]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a stored tree that cannot be honoured is healed on read, not hidden", async () => {
|
|
272
|
+
await withMl(async (ml) => {
|
|
273
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
274
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
275
|
+
|
|
276
|
+
await writeRawDocument(ml, {
|
|
277
|
+
schemaVersion: 1,
|
|
278
|
+
folders: [
|
|
279
|
+
{ id: "orphan", name: "Orphan", parent: "gone" },
|
|
280
|
+
{ id: "child", name: "Child", parent: "orphan" },
|
|
281
|
+
],
|
|
282
|
+
assignments: { [project]: "child", ghost: "orphan" },
|
|
283
|
+
templateAssignments: {},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 2);
|
|
287
|
+
expect(listing.healed).toBe(true);
|
|
288
|
+
expect(listing.writable).toBe(true);
|
|
289
|
+
expect(depthOf(listing, "orphan" as FolderId)).toBe(1);
|
|
290
|
+
// The lifted folder keeps its own subtree beneath it.
|
|
291
|
+
expect(depthOf(listing, "child" as FolderId)).toBe(2);
|
|
292
|
+
expect(listing.projects[0].id).toBe(project);
|
|
293
|
+
expect(listing.projects[0].path).toStrictEqual(["Orphan", "Child"]);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("a document written by a newer build reads as no folders and refuses every write", async () => {
|
|
298
|
+
await withMl(async (ml) => {
|
|
299
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
300
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
301
|
+
|
|
302
|
+
await writeRawDocument(ml, { schemaVersion: 99, folders: [], assignments: {} });
|
|
303
|
+
|
|
304
|
+
const listing = await untilListing(ml, (l) => !l.writable);
|
|
305
|
+
expect(listing.folders).toStrictEqual([]);
|
|
306
|
+
// The project list still renders — the read degrades, it does not fail.
|
|
307
|
+
expect(listing.projects.map((p) => p.id)).toStrictEqual([project]);
|
|
308
|
+
expect(listing.problem?.kind).toBe("newer-schema");
|
|
309
|
+
|
|
310
|
+
await expect(ml.createFolder("Samples")).rejects.toThrow(/Update the application/);
|
|
311
|
+
await expect(ml.deleteFolder("whatever" as FolderId)).rejects.toThrow(/Update the application/);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("a document stored in a shape this build cannot read is never written over", async () => {
|
|
316
|
+
await withMl(async (ml) => {
|
|
317
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
318
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
319
|
+
|
|
320
|
+
// What a build that kept the tree in some other resource shape would leave behind: the field
|
|
321
|
+
// exists and points at something, and there is no JSON blob on it. Absent and unreadable must
|
|
322
|
+
// not be the same thing here — an absent document is a user with no folders and is writable,
|
|
323
|
+
// and treating this as that would replace whatever is stored with an empty tree.
|
|
324
|
+
const foreign = await writeDataLessDocumentResource(ml);
|
|
325
|
+
|
|
326
|
+
await expect(ml.createFolder("Samples")).rejects.toThrow(/Update the application/);
|
|
327
|
+
await expect(ml.deleteFolder("whatever" as FolderId)).rejects.toThrow(/Update the application/);
|
|
328
|
+
await expect(ml.moveFolderItems([item(project)], undefined, { entries: [] })).rejects.toThrow(
|
|
329
|
+
/Update the application/,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Nothing was written: the field still points at exactly what was there before.
|
|
333
|
+
expect(await documentValueRid(ml)).toStrictEqual(foreign);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("a document written by a newer build can be reset to no folders", async () => {
|
|
338
|
+
await withMl(async (ml) => {
|
|
339
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
340
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
341
|
+
|
|
342
|
+
await writeRawDocument(ml, {
|
|
343
|
+
schemaVersion: 99,
|
|
344
|
+
folders: [{ id: "newer", name: "Newer" }],
|
|
345
|
+
assignments: { [project]: "newer" },
|
|
346
|
+
});
|
|
347
|
+
await untilListing(ml, (l) => l.problem?.kind === "newer-schema");
|
|
348
|
+
|
|
349
|
+
await ml.resetFolders();
|
|
350
|
+
|
|
351
|
+
const listing = await untilListing(ml, (l) => l.writable);
|
|
352
|
+
expect(listing.problem).toBeUndefined();
|
|
353
|
+
expect(listing.folders).toStrictEqual([]);
|
|
354
|
+
expect(listing.projects.map((p) => p.id)).toStrictEqual([project]);
|
|
355
|
+
expect(listing.projects[0].folder).toBeUndefined();
|
|
356
|
+
|
|
357
|
+
const reset = await documentValueRid(ml);
|
|
358
|
+
if (reset === undefined) throw new Error("the reset left no document behind");
|
|
359
|
+
expect(await rawDocumentAt(ml, reset)).toStrictEqual({
|
|
360
|
+
schemaVersion: 1,
|
|
361
|
+
folders: [],
|
|
362
|
+
assignments: {},
|
|
363
|
+
templateAssignments: {},
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// The tree is ours again, so folder writes go through.
|
|
367
|
+
await ml.createFolder("Samples");
|
|
368
|
+
await untilListing(ml, (l) => l.folders.length === 1);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("a document stored in a shape this build cannot read can be reset to no folders", async () => {
|
|
373
|
+
await withMl(async (ml) => {
|
|
374
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
375
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
376
|
+
|
|
377
|
+
const foreign = await writeDataLessDocumentResource(ml);
|
|
378
|
+
await untilListing(ml, (l) => l.problem?.kind === "unreadable");
|
|
379
|
+
|
|
380
|
+
await ml.resetFolders();
|
|
381
|
+
|
|
382
|
+
const listing = await untilListing(ml, (l) => l.writable);
|
|
383
|
+
expect(listing.problem).toBeUndefined();
|
|
384
|
+
expect(listing.folders).toStrictEqual([]);
|
|
385
|
+
expect(listing.projects.map((p) => p.id)).toStrictEqual([project]);
|
|
386
|
+
expect(await documentValueRid(ml)).not.toStrictEqual(foreign);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("a document that reads fine is never reset", async () => {
|
|
391
|
+
await withMl(async (ml) => {
|
|
392
|
+
const folder = await ml.createFolder("Samples");
|
|
393
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
394
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
395
|
+
await move(ml, project, folder);
|
|
396
|
+
await untilListing(ml, (l) => l.projects[0]?.folder === folder);
|
|
397
|
+
const before = await documentValueRid(ml);
|
|
398
|
+
|
|
399
|
+
await expect(ml.resetFolders()).rejects.toThrow(/can only be reset when they cannot be read/);
|
|
400
|
+
|
|
401
|
+
expect(await documentValueRid(ml)).toStrictEqual(before);
|
|
402
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 1);
|
|
403
|
+
expect(listing.projects[0].folder).toBe(folder);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("a user with no folder document is never reset", async () => {
|
|
408
|
+
await withMl(async (ml) => {
|
|
409
|
+
await expect(ml.resetFolders()).rejects.toThrow(/can only be reset when they cannot be read/);
|
|
410
|
+
expect(await documentFieldExists(ml)).toBe(false);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("a move of nothing writes nothing", async () => {
|
|
415
|
+
await withMl(async (ml) => {
|
|
416
|
+
await ml.createFolder("Samples");
|
|
417
|
+
const before = await documentValueRid(ml);
|
|
418
|
+
|
|
419
|
+
const outcome = await ml.moveFolderItems([], undefined, { entries: [] });
|
|
420
|
+
|
|
421
|
+
expect(outcome.ok).toBe(true);
|
|
422
|
+
// No value was minted and the field was not re-pointed, so an empty selection cannot conflict
|
|
423
|
+
// with whatever else is editing the tree.
|
|
424
|
+
expect(await documentValueRid(ml)).toStrictEqual(before);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("every write mints a new value resource and re-points the one document field", async () => {
|
|
429
|
+
await withMl(async (ml) => {
|
|
430
|
+
await ml.createFolder("First");
|
|
431
|
+
const firstValue = await documentValueRid(ml);
|
|
432
|
+
await ml.createFolder("Second");
|
|
433
|
+
const secondValue = await documentValueRid(ml);
|
|
434
|
+
|
|
435
|
+
expect(firstValue).toBeDefined();
|
|
436
|
+
expect(secondValue).not.toBe(firstValue);
|
|
437
|
+
|
|
438
|
+
// Nothing was mutated: the value the field used to point at still holds the old document.
|
|
439
|
+
if (firstValue === undefined) throw new Error("the first write stored no value");
|
|
440
|
+
const superseded = await documentAt(ml, firstValue);
|
|
441
|
+
expect(superseded?.folders.map((f) => f.name)).toStrictEqual(["First"]);
|
|
442
|
+
|
|
443
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 2);
|
|
444
|
+
expect(listing.folders.map((f) => f.name).sort()).toStrictEqual(["First", "Second"]);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a move preview names exactly the items the applied move renames", async () => {
|
|
449
|
+
await withMl(async (ml) => {
|
|
450
|
+
const folder = await ml.createFolder("Samples");
|
|
451
|
+
const resident = await ml.createProject({ label: "Alpha" });
|
|
452
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
453
|
+
await move(ml, resident, folder);
|
|
454
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === folder));
|
|
455
|
+
|
|
456
|
+
// Two of the three collide with the resident — one exactly, one only case-insensitively.
|
|
457
|
+
const colliding = await ml.createProject({ label: "Alpha" });
|
|
458
|
+
const distinct = await ml.createProject({ label: "Beta" });
|
|
459
|
+
const alsoColliding = await ml.createProject({ label: "alpha" });
|
|
460
|
+
await untilListing(ml, (l) => l.projects.length === 4);
|
|
461
|
+
|
|
462
|
+
const items = [item(colliding), item(distinct), item(alsoColliding)];
|
|
463
|
+
const planned = await ml.previewFoldersMove(items, folder);
|
|
464
|
+
expect(planned.ok).toBe(true);
|
|
465
|
+
if (!planned.ok) return;
|
|
466
|
+
|
|
467
|
+
const promised = new Map(planned.plan.entries.map((e) => [e.item.id as ProjectId, e]));
|
|
468
|
+
expect([...promised.keys()].sort()).toStrictEqual([colliding, distinct, alsoColliding].sort());
|
|
469
|
+
expect(
|
|
470
|
+
[...promised.values()]
|
|
471
|
+
.filter((e) => e.renamed)
|
|
472
|
+
.map((e) => e.item.id)
|
|
473
|
+
.sort(),
|
|
474
|
+
).toStrictEqual([colliding, alsoColliding].sort());
|
|
475
|
+
|
|
476
|
+
expect((await ml.moveFolderItems(items, folder, planned.plan)).ok).toBe(true);
|
|
477
|
+
const listing = await untilListing(
|
|
478
|
+
ml,
|
|
479
|
+
(l) => l.projects.filter((p) => p.folder === folder).length === 4,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
// Every item carries exactly the name the preview promised it, renamed or not, and the
|
|
483
|
+
// resident the plan never mentioned is untouched.
|
|
484
|
+
for (const [id, entry] of promised) expect(labelOf(listing, id)).toBe(entry.name);
|
|
485
|
+
expect(labelOf(listing, resident)).toBe("Alpha");
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("a move that would nest a folder inside itself is refused by the write path", async () => {
|
|
490
|
+
await withMl(async (ml) => {
|
|
491
|
+
const outer = await ml.createFolder("Outer");
|
|
492
|
+
const inner = await ml.createFolder("Inner", outer);
|
|
493
|
+
await untilListing(ml, (l) => l.folders.length === 2);
|
|
494
|
+
|
|
495
|
+
const planned = await ml.previewFoldersMove([folderItem(outer)], inner);
|
|
496
|
+
expect(planned.ok).toBe(false);
|
|
497
|
+
if (planned.ok) return;
|
|
498
|
+
expect(planned.issues.map((i) => i.kind)).toContain("into-own-descendant");
|
|
499
|
+
|
|
500
|
+
// The write refuses it on its own account, handed a plan the planner would never produce.
|
|
501
|
+
const refused = await ml.moveFolderItems([folderItem(outer)], inner, {
|
|
502
|
+
destination: inner,
|
|
503
|
+
entries: [{ item: folderItem(outer), currentName: "Outer", name: "Outer", renamed: false }],
|
|
504
|
+
});
|
|
505
|
+
expect(refused.ok).toBe(false);
|
|
506
|
+
if (refused.ok) return;
|
|
507
|
+
expect(refused.reason).toBe("cannot-plan");
|
|
508
|
+
if (refused.reason !== "cannot-plan") return;
|
|
509
|
+
expect(refused.issues.map((i) => i.kind)).toContain("into-own-descendant");
|
|
510
|
+
|
|
511
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 2);
|
|
512
|
+
expect(listing.folders.find((f) => f.id === outer)?.parent).toBeUndefined();
|
|
513
|
+
expect(listing.folders.find((f) => f.id === inner)?.parent).toBe(outer);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test("a subtree moves under another folder whole, however deep it then reaches", async () => {
|
|
518
|
+
await withMl(async (ml) => {
|
|
519
|
+
const one = await ml.createFolder("One");
|
|
520
|
+
const two = await ml.createFolder("Two", one);
|
|
521
|
+
const three = await ml.createFolder("Three", two);
|
|
522
|
+
const elsewhere = await ml.createFolder("Elsewhere");
|
|
523
|
+
await untilListing(ml, (l) => l.folders.length === 4);
|
|
524
|
+
|
|
525
|
+
const planned = await ml.previewFoldersMove([folderItem(one)], elsewhere);
|
|
526
|
+
expect(planned.ok).toBe(true);
|
|
527
|
+
if (!planned.ok) return;
|
|
528
|
+
expect((await ml.moveFolderItems([folderItem(one)], elsewhere, planned.plan)).ok).toBe(true);
|
|
529
|
+
|
|
530
|
+
const listing = await untilListing(ml, (l) => depthOf(l, one) === 2);
|
|
531
|
+
expect(listing.folders.find((f) => f.id === one)?.parent).toBe(elsewhere);
|
|
532
|
+
expect(depthOf(listing, three)).toBe(4);
|
|
533
|
+
expect(listing.folders.find((f) => f.id === three)?.path).toStrictEqual([
|
|
534
|
+
"Elsewhere",
|
|
535
|
+
"One",
|
|
536
|
+
"Two",
|
|
537
|
+
"Three",
|
|
538
|
+
]);
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("a project deleted behind the tree's back does not break the read or the next write", async () => {
|
|
543
|
+
await withMl(async (ml) => {
|
|
544
|
+
const folder = await ml.createFolder("Samples");
|
|
545
|
+
const kept = await ml.createProject({ label: "Kept" });
|
|
546
|
+
const doomed = await ml.createProject({ label: "Doomed" });
|
|
547
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
548
|
+
await move(ml, kept, folder);
|
|
549
|
+
await move(ml, doomed, folder);
|
|
550
|
+
await untilListing(ml, (l) => l.projects.filter((p) => p.folder === folder).length === 2);
|
|
551
|
+
|
|
552
|
+
// Deletion goes nowhere near the folder document, which is left naming a project that is gone.
|
|
553
|
+
await ml.deleteProject(doomed);
|
|
554
|
+
|
|
555
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 1);
|
|
556
|
+
expect(listing.projects[0].id).toBe(kept);
|
|
557
|
+
expect(listing.projects[0].folder).toBe(folder);
|
|
558
|
+
expect(listing.folders.map((f) => f.id)).toStrictEqual([folder]);
|
|
559
|
+
expect(listing.writable).toBe(true);
|
|
560
|
+
expect(listing.problem).toBeUndefined();
|
|
561
|
+
|
|
562
|
+
const second = await ml.createFolder("Second");
|
|
563
|
+
const after = await untilListing(ml, (l) => l.folders.length === 2);
|
|
564
|
+
expect(after.folders.map((f) => f.id).sort()).toStrictEqual([folder, second].sort());
|
|
565
|
+
expect(after.projects.map((p) => p.id)).toStrictEqual([kept]);
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("the tree survives a restart of the middle layer", async () => {
|
|
570
|
+
// Names of their own: the user's root keeps whatever other tests and earlier runs left there.
|
|
571
|
+
const name = `Samples ${randomUUID()}`;
|
|
572
|
+
let folder: FolderId | undefined = undefined;
|
|
573
|
+
let project: ProjectId | undefined = undefined;
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
await withMlOnUserRoot(async (ml) => {
|
|
577
|
+
const created = await ml.createFolder(name);
|
|
578
|
+
folder = created;
|
|
579
|
+
const prj = await ml.createProject({ label: `Alpha ${randomUUID()}` });
|
|
580
|
+
project = prj;
|
|
581
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.id === prj));
|
|
582
|
+
await move(ml, prj, created);
|
|
583
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.id === prj && p.folder === created));
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
if (folder === undefined || project === undefined)
|
|
587
|
+
throw new Error("setup did not produce a folder and a project");
|
|
588
|
+
const storedFolder: FolderId = folder;
|
|
589
|
+
const storedProject: ProjectId = project;
|
|
590
|
+
|
|
591
|
+
// A second middle layer over a work folder of its own: nothing local carries over, so what
|
|
592
|
+
// comes back can only have come from the backend.
|
|
593
|
+
await withMlOnUserRoot(async (ml) => {
|
|
594
|
+
const listing = await untilListing(ml, (l) =>
|
|
595
|
+
l.projects.some((p) => p.id === storedProject && p.folder === storedFolder),
|
|
596
|
+
);
|
|
597
|
+
expect(listing.folders.find((f) => f.id === storedFolder)?.name).toBe(name);
|
|
598
|
+
expect(listing.projects.find((p) => p.id === storedProject)?.path).toStrictEqual([name]);
|
|
599
|
+
expect(listing.writable).toBe(true);
|
|
600
|
+
|
|
601
|
+
// And it is still the same document being written to, not a fresh one.
|
|
602
|
+
const nested = await ml.createFolder("2024", storedFolder);
|
|
603
|
+
const after = await untilListing(ml, (l) => l.folders.some((f) => f.id === nested));
|
|
604
|
+
expect(after.folders.find((f) => f.id === nested)?.parent).toBe(storedFolder);
|
|
605
|
+
});
|
|
606
|
+
} finally {
|
|
607
|
+
// Deleting the folder takes the project and the nested folder with it.
|
|
608
|
+
await withMlOnUserRoot(async (ml) => {
|
|
609
|
+
if (folder !== undefined) {
|
|
610
|
+
const planned = await ml.previewFolderDeletion(folder);
|
|
611
|
+
if (planned.ok) await ml.deleteFolder(folder, planned.removal);
|
|
612
|
+
}
|
|
613
|
+
if (project !== undefined) await ml.deleteProject(project).catch(() => undefined);
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("renaming a project onto a sibling's name is refused, in whatever case", async () => {
|
|
619
|
+
await withMl(async (ml) => {
|
|
620
|
+
const folder = await ml.createFolder("Samples");
|
|
621
|
+
const resident = await ml.createProject({ label: "Alpha" });
|
|
622
|
+
const other = await ml.createProject({ label: "Beta" });
|
|
623
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
624
|
+
await move(ml, resident, folder);
|
|
625
|
+
await move(ml, other, folder);
|
|
626
|
+
await untilListing(ml, (l) => l.projects.filter((p) => p.folder === folder).length === 2);
|
|
627
|
+
|
|
628
|
+
await expect(ml.setProjectMeta(other, { label: "Alpha" })).rejects.toThrow(/already used/);
|
|
629
|
+
await expect(ml.setProjectMeta(other, { label: "ALPHA" })).rejects.toThrow(/already used/);
|
|
630
|
+
// Folders and projects share one namespace inside a parent.
|
|
631
|
+
await ml.createFolder("Archive", folder);
|
|
632
|
+
await expect(ml.setProjectMeta(other, { label: "archive" })).rejects.toThrow(/already used/);
|
|
633
|
+
|
|
634
|
+
// Nothing was written by any of the refusals.
|
|
635
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 2);
|
|
636
|
+
expect(labelOf(listing, other)).toBe("Beta");
|
|
637
|
+
expect(labelOf(listing, resident)).toBe("Alpha");
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("the same project name in two different folders is allowed", async () => {
|
|
642
|
+
await withMl(async (ml) => {
|
|
643
|
+
const here = await ml.createFolder("Here");
|
|
644
|
+
const there = await ml.createFolder("There");
|
|
645
|
+
const first = await ml.createProject({ label: "Alpha" });
|
|
646
|
+
const second = await ml.createProject({ label: "Beta" });
|
|
647
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
648
|
+
await move(ml, first, here);
|
|
649
|
+
await move(ml, second, there);
|
|
650
|
+
await untilListing(ml, (l) => l.projects.every((p) => p.folder !== undefined));
|
|
651
|
+
|
|
652
|
+
// Per parent, not globally — this is the whole point of the scope.
|
|
653
|
+
await ml.setProjectMeta(second, { label: "Alpha" });
|
|
654
|
+
const listing = await untilListing(ml, (l) => labelOf(l, second) === "Alpha");
|
|
655
|
+
expect(labelOf(listing, first)).toBe("Alpha");
|
|
656
|
+
expect(listing.projects.find((p) => p.id === first)?.folder).toBe(here);
|
|
657
|
+
expect(listing.projects.find((p) => p.id === second)?.folder).toBe(there);
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("a project is never in collision with itself", async () => {
|
|
662
|
+
await withMl(async (ml) => {
|
|
663
|
+
const project = await ml.createProject({ label: "Alpha" });
|
|
664
|
+
await untilListing(ml, (l) => l.projects.length === 1);
|
|
665
|
+
|
|
666
|
+
await ml.setProjectMeta(project, { label: "Alpha" });
|
|
667
|
+
await ml.setProjectMeta(project, { label: "ALPHA" });
|
|
668
|
+
|
|
669
|
+
const listing = await untilListing(ml, (l) => labelOf(l, project) === "ALPHA");
|
|
670
|
+
expect(listing.projects).toHaveLength(1);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("a collision an account already holds is tolerated, not rewritten and not a dead end", async () => {
|
|
675
|
+
await withMl(async (ml) => {
|
|
676
|
+
const folder = await ml.createFolder("Samples");
|
|
677
|
+
const first = await ml.createProject({ label: "Alpha" });
|
|
678
|
+
const second = await ml.createProject({ label: "Alpha" });
|
|
679
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
680
|
+
|
|
681
|
+
// Straight into the slot: no path through the folder machinery would ever produce this, which
|
|
682
|
+
// is exactly what an account predating the feature may nonetheless be holding.
|
|
683
|
+
await writeRawDocument(ml, {
|
|
684
|
+
schemaVersion: 1,
|
|
685
|
+
folders: [{ id: folder, name: "Samples" }],
|
|
686
|
+
assignments: { [first]: folder, [second]: folder },
|
|
687
|
+
templateAssignments: {},
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const colliding = await untilListing(
|
|
691
|
+
ml,
|
|
692
|
+
(l) => l.projects.filter((p) => p.folder === folder).length === 2,
|
|
693
|
+
);
|
|
694
|
+
expect(labelOf(colliding, first)).toBe("Alpha");
|
|
695
|
+
expect(labelOf(colliding, second)).toBe("Alpha");
|
|
696
|
+
|
|
697
|
+
// Neither is stuck: a free name is still accepted, and nothing rewrote the pair on its own.
|
|
698
|
+
await ml.setProjectMeta(second, { label: "Beta" });
|
|
699
|
+
const listing = await untilListing(ml, (l) => labelOf(l, second) === "Beta");
|
|
700
|
+
expect(labelOf(listing, first)).toBe("Alpha");
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("a folder document this build cannot read does not block a project rename", async () => {
|
|
705
|
+
await withMl(async (ml) => {
|
|
706
|
+
const first = await ml.createProject({ label: "Alpha" });
|
|
707
|
+
const second = await ml.createProject({ label: "Beta" });
|
|
708
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
709
|
+
|
|
710
|
+
await writeRawDocument(ml, { schemaVersion: 99, folders: [], assignments: {} });
|
|
711
|
+
await untilListing(ml, (l) => !l.writable);
|
|
712
|
+
|
|
713
|
+
// The tree is unreadable, so where these two sit is unknown — refusing on the flat reading
|
|
714
|
+
// would turn a per-parent rule into a global one and block a rename that may be legal.
|
|
715
|
+
await ml.setProjectMeta(second, { label: "Alpha" });
|
|
716
|
+
const listing = await untilListing(ml, (l) => labelOf(l, second) === "Alpha");
|
|
717
|
+
expect(labelOf(listing, first)).toBe("Alpha");
|
|
718
|
+
expect(listing.writable).toBe(false);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("a project that is open when a move renames it keeps the name the move gave it", async () => {
|
|
723
|
+
await withMl(async (ml) => {
|
|
724
|
+
const folder = await ml.createFolder("Samples");
|
|
725
|
+
const resident = await ml.createProject({ label: "Alpha" });
|
|
726
|
+
const incoming = await ml.createProject({ label: "Alpha" });
|
|
727
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
728
|
+
await move(ml, resident, folder);
|
|
729
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === folder));
|
|
730
|
+
|
|
731
|
+
// Open before the move and left open across it: the state the rename is supposed to be lost
|
|
732
|
+
// in — a project whose maintenance loop is running while something else writes its label.
|
|
733
|
+
await ml.openProject(incoming);
|
|
734
|
+
try {
|
|
735
|
+
const planned = await ml.previewFoldersMove([item(incoming)], folder);
|
|
736
|
+
expect(planned.ok).toBe(true);
|
|
737
|
+
if (!planned.ok) return;
|
|
738
|
+
expect(planned.plan.entries[0].name).toBe("Alpha (Copy)");
|
|
739
|
+
|
|
740
|
+
expect((await ml.moveFolderItems([item(incoming)], folder, planned.plan)).ok).toBe(true);
|
|
741
|
+
await untilListing(ml, (l) => labelOf(l, incoming) === "Alpha (Copy)");
|
|
742
|
+
|
|
743
|
+
// Several passes of that loop. Each one loads a mutator and calls save(); a mutator holding
|
|
744
|
+
// a pre-move label would put it back here.
|
|
745
|
+
await tp.setTimeout(3 * ProjectRefreshPass);
|
|
746
|
+
expect(labelOf(await ml.folders.awaitStableValue(), incoming)).toBe("Alpha (Copy)");
|
|
747
|
+
} finally {
|
|
748
|
+
await ml.closeProject(incoming);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Re-opening applies migrations and then a deliberate no-op mutation — the heaviest save the
|
|
752
|
+
// project path performs without anyone having asked for one.
|
|
753
|
+
await ml.openProject(incoming);
|
|
754
|
+
try {
|
|
755
|
+
await tp.setTimeout(2 * ProjectRefreshPass);
|
|
756
|
+
const listing = await ml.folders.awaitStableValue();
|
|
757
|
+
expect(labelOf(listing, incoming)).toBe("Alpha (Copy)");
|
|
758
|
+
expect(listing.projects.find((p) => p.id === incoming)?.folder).toBe(folder);
|
|
759
|
+
expect(labelOf(listing, resident)).toBe("Alpha");
|
|
760
|
+
} finally {
|
|
761
|
+
await ml.closeProject(incoming);
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
test("a rename racing a move loses neither: whoever commits second sees the other", async () => {
|
|
767
|
+
await withMl(async (ml) => {
|
|
768
|
+
const folder = await ml.createFolder("Samples");
|
|
769
|
+
const resident = await ml.createProject({ label: "Alpha" });
|
|
770
|
+
const incoming = await ml.createProject({ label: "Alpha" });
|
|
771
|
+
await untilListing(ml, (l) => l.projects.length === 2);
|
|
772
|
+
await move(ml, resident, folder);
|
|
773
|
+
await untilListing(ml, (l) => l.projects.some((p) => p.folder === folder));
|
|
774
|
+
|
|
775
|
+
const planned = await ml.previewFoldersMove([item(incoming)], folder);
|
|
776
|
+
expect(planned.ok).toBe(true);
|
|
777
|
+
if (!planned.ok) return;
|
|
778
|
+
expect(planned.plan.entries[0].name).toBe("Alpha (Copy)");
|
|
779
|
+
|
|
780
|
+
// Both writers read and write the same metadata key inside their own write transaction, so
|
|
781
|
+
// one of them is aborted and replayed. Which one is not ours to choose; what must hold is
|
|
782
|
+
// that the loser re-reads instead of putting its stale idea of the label back.
|
|
783
|
+
const [moved, renamed] = await Promise.allSettled([
|
|
784
|
+
ml.moveFolderItems([item(incoming)], folder, planned.plan),
|
|
785
|
+
ml.setProjectMeta(incoming, { label: "Gamma" }),
|
|
786
|
+
]);
|
|
787
|
+
|
|
788
|
+
const listing = await untilListing(ml, (l) => labelOf(l, incoming) !== "Alpha");
|
|
789
|
+
const label = labelOf(listing, incoming);
|
|
790
|
+
|
|
791
|
+
// "Alpha" is the one answer nobody decided on: it is the name the move took away and the name
|
|
792
|
+
// the rename replaced. Seeing it means a writer wrote back what it read before the other ran.
|
|
793
|
+
expect(label).not.toBe("Alpha");
|
|
794
|
+
expect(["Alpha (Copy)", "Gamma"]).toContain(label);
|
|
795
|
+
expect(labelOf(listing, resident)).toBe("Alpha");
|
|
796
|
+
|
|
797
|
+
// A move that reported success placed the project; one refused as `plan-changed` wrote
|
|
798
|
+
// nothing at all, and the rename is then the only thing that happened.
|
|
799
|
+
if (moved.status === "fulfilled" && moved.value.ok)
|
|
800
|
+
expect(listing.projects.find((p) => p.id === incoming)?.folder).toBe(folder);
|
|
801
|
+
if (renamed.status === "fulfilled" && label === "Gamma")
|
|
802
|
+
expect(listing.projects).toHaveLength(2);
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("a whitespace-only description clears what a folder said about itself", async () => {
|
|
807
|
+
await withMl(async (ml) => {
|
|
808
|
+
const folder = await ml.createFolder("Samples");
|
|
809
|
+
await ml.setFolderDescription(folder, " Plates ");
|
|
810
|
+
const described = await untilListing(ml, (l) => l.folders[0]?.description !== undefined);
|
|
811
|
+
expect(described.folders[0].description).toBe("Plates");
|
|
812
|
+
|
|
813
|
+
await ml.setFolderDescription(folder, " ");
|
|
814
|
+
const cleared = await untilListing(ml, (l) => l.folders[0]?.description === undefined);
|
|
815
|
+
expect(cleared.folders[0]).not.toHaveProperty("description");
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("a project created into a folder lands there, and into a deleted one at the top level", async () => {
|
|
820
|
+
await withMl(async (ml) => {
|
|
821
|
+
const folder = await ml.createFolder("Samples");
|
|
822
|
+
const doomed = await ml.createFolder("Doomed");
|
|
823
|
+
const inside = await ml.createProject({ label: "Alpha" }, folder);
|
|
824
|
+
|
|
825
|
+
const planned = await ml.previewFolderDeletion(doomed);
|
|
826
|
+
if (!planned.ok) throw new Error("expected a removal");
|
|
827
|
+
expect((await ml.deleteFolder(doomed, planned.removal)).ok).toBe(true);
|
|
828
|
+
const stray = await ml.createProject({ label: "Beta" }, doomed);
|
|
829
|
+
|
|
830
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 2);
|
|
831
|
+
expect(listing.projects.find((p) => p.id === inside)?.folder).toBe(folder);
|
|
832
|
+
expect(listing.projects.find((p) => p.id === stray)?.folder).toBeUndefined();
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("a duplicated project lands beside its source, under a name free there", async () => {
|
|
837
|
+
await withMl(async (ml) => {
|
|
838
|
+
const folder = await ml.createFolder("Samples");
|
|
839
|
+
const source = await ml.createProject({ label: "Alpha" }, folder);
|
|
840
|
+
|
|
841
|
+
const copy = await ml.duplicateProject(source);
|
|
842
|
+
|
|
843
|
+
const listing = await untilListing(ml, (l) => l.projects.length === 2);
|
|
844
|
+
expect(listing.projects.find((p) => p.id === copy)?.folder).toBe(folder);
|
|
845
|
+
expect(labelOf(listing, copy)).toBe("Alpha (Copy)");
|
|
846
|
+
expect(labelOf(listing, source)).toBe("Alpha");
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
test("a template saved from a project lands beside it", async () => {
|
|
851
|
+
await withMl(async (ml) => {
|
|
852
|
+
const folder = await ml.createFolder("Samples");
|
|
853
|
+
const project = await ml.createProject({ label: "Alpha" }, folder);
|
|
854
|
+
|
|
855
|
+
const saved = await ml.saveProjectAsTemplate(project);
|
|
856
|
+
if (!saved.ok) throw new Error("the project could not be saved as a template");
|
|
857
|
+
|
|
858
|
+
const listing = await untilListing(ml, (l) => l.templates.length === 1);
|
|
859
|
+
expect(listing.templates[0].id).toBe(saved.templateId);
|
|
860
|
+
expect(listing.templates[0].folder).toBe(folder);
|
|
861
|
+
// The project beside it already answers to its own label.
|
|
862
|
+
expect(listing.templates[0].label).toBe("Alpha (Copy)");
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("a template saved from a project carries the description it is given, and only that", async () => {
|
|
867
|
+
await withMl(async (ml) => {
|
|
868
|
+
const project = await ml.createProject({ label: "Alpha", description: "First pass" });
|
|
869
|
+
|
|
870
|
+
const described = await ml.saveProjectAsTemplate(project, "Described", " Reusable ");
|
|
871
|
+
const blank = await ml.saveProjectAsTemplate(project, "Blank", " ");
|
|
872
|
+
const undescribed = await ml.saveProjectAsTemplate(project, "Undescribed");
|
|
873
|
+
if (!described.ok || !blank.ok || !undescribed.ok)
|
|
874
|
+
throw new Error("the project could not be saved as a template");
|
|
875
|
+
|
|
876
|
+
const listing = await untilListing(ml, (l) => l.templates.length === 3);
|
|
877
|
+
const descriptionOf = (id: TemplateId) =>
|
|
878
|
+
listing.templates.find((t) => t.id === id)?.description;
|
|
879
|
+
expect(descriptionOf(described.templateId)).toBe("Reusable");
|
|
880
|
+
expect(descriptionOf(blank.templateId)).toBeUndefined();
|
|
881
|
+
// The project's own description is not taken over when none is given.
|
|
882
|
+
expect(descriptionOf(undescribed.templateId)).toBeUndefined();
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test("duplicating a folder rebuilds its subtree beside it, descriptions and all", async () => {
|
|
887
|
+
await withMl(async (ml) => {
|
|
888
|
+
const outer = await ml.createFolder("Outer");
|
|
889
|
+
const source = await ml.createFolder("Samples", outer);
|
|
890
|
+
const inner = await ml.createFolder("Runs", source);
|
|
891
|
+
await ml.setFolderDescription(inner, "Weekly runs");
|
|
892
|
+
const project = await ml.createProject({ label: "Alpha", description: "First pass" }, inner);
|
|
893
|
+
const saved = await ml.saveProjectAsTemplate(project);
|
|
894
|
+
if (!saved.ok) throw new Error("the project could not be saved as a template");
|
|
895
|
+
await ml.setTemplateDescription(saved.templateId, "Reusable");
|
|
896
|
+
await untilListing(ml, (l) => l.templates.some((t) => t.description === "Reusable"));
|
|
897
|
+
|
|
898
|
+
await ml.duplicateFolder(source);
|
|
899
|
+
|
|
900
|
+
const listing = await untilListing(ml, (l) => l.folders.length === 5);
|
|
901
|
+
const copy = listing.folders.find((f) => f.parent === outer && f.id !== source);
|
|
902
|
+
if (copy === undefined) throw new Error("no copy beside the source");
|
|
903
|
+
expect(copy.name).toBe("Samples (Copy)");
|
|
904
|
+
|
|
905
|
+
const copiedInner = listing.folders.find((f) => f.parent === copy.id);
|
|
906
|
+
if (copiedInner === undefined) throw new Error("the copy holds no folder");
|
|
907
|
+
expect(copiedInner.name).toBe("Runs");
|
|
908
|
+
expect(copiedInner.description).toBe("Weekly runs");
|
|
909
|
+
|
|
910
|
+
const copiedProject = listing.projects.find((p) => p.id !== project);
|
|
911
|
+
expect(copiedProject?.folder).toBe(copiedInner.id);
|
|
912
|
+
expect(copiedProject?.meta).toStrictEqual({ label: "Alpha", description: "First pass" });
|
|
913
|
+
|
|
914
|
+
const copiedTemplate = listing.templates.find((t) => t.id !== saved.templateId);
|
|
915
|
+
expect(copiedTemplate?.folder).toBe(copiedInner.id);
|
|
916
|
+
expect(copiedTemplate?.label).toBe("Alpha (Copy)");
|
|
917
|
+
expect(copiedTemplate?.description).toBe("Reusable");
|
|
918
|
+
});
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
test("what a deleted folder held is gone for good, not only from the tree", async () => {
|
|
922
|
+
await withMl(async (ml) => {
|
|
923
|
+
const folder = await ml.createFolder("Samples");
|
|
924
|
+
const project = await ml.createProject({ label: "Alpha" }, folder);
|
|
925
|
+
const saved = await ml.saveProjectAsTemplate(project);
|
|
926
|
+
if (!saved.ok) throw new Error("the project could not be saved as a template");
|
|
927
|
+
const template: TemplateId = saved.templateId;
|
|
928
|
+
|
|
929
|
+
const planned = await ml.previewFolderDeletion(folder);
|
|
930
|
+
if (!planned.ok) throw new Error("expected a removal");
|
|
931
|
+
expect((await ml.deleteFolder(folder, planned.removal)).ok).toBe(true);
|
|
932
|
+
|
|
933
|
+
// Both ids were resolved moments ago; neither may still resolve to what the folder held.
|
|
934
|
+
await expect(ml.setProjectMeta(project, { label: "Beta" })).rejects.toThrow(/not found/);
|
|
935
|
+
await expect(ml.renameTemplate(template, "Gamma")).rejects.toThrow(/not found/);
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
//
|
|
940
|
+
// Internals
|
|
941
|
+
//
|
|
942
|
+
|
|
943
|
+
/** One pass of an open project's maintenance loop, at the middle layer's default interval. */
|
|
944
|
+
const ProjectRefreshPass = 2000;
|
|
945
|
+
|
|
946
|
+
function item(project: ProjectId) {
|
|
947
|
+
return { kind: "project", id: project } as const;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function folderItem(folder: FolderId) {
|
|
951
|
+
return { kind: "folder", id: folder } as const;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/** 1 for a top-level folder. */
|
|
955
|
+
function depthOf(listing: FoldersListing, folder: FolderId): number | undefined {
|
|
956
|
+
const found = listing.folders.find((f) => f.id === folder);
|
|
957
|
+
return found === undefined ? undefined : found.ancestors.length + 1;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function labelOf(listing: FoldersListing, project: ProjectId): string | undefined {
|
|
961
|
+
return listing.projects.find((p) => p.id === project)?.meta.label;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Moves one project with its own fresh plan, for the steps a test only needs as setup. */
|
|
965
|
+
async function move(ml: MiddleLayer, project: ProjectId, destination: FolderId) {
|
|
966
|
+
const planned = await ml.previewFoldersMove([item(project)], destination);
|
|
967
|
+
if (!planned.ok) throw new Error(`cannot plan move: ${JSON.stringify(planned.issues)}`);
|
|
968
|
+
const plan: FoldersMovePlan = planned.plan;
|
|
969
|
+
const outcome = await ml.moveFolderItems([item(project)], destination, plan);
|
|
970
|
+
if (!outcome.ok) throw new Error(`move refused: ${outcome.reason}`);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* The listing is fed by two polled trees, and a write made outside the middle layer's own helpers
|
|
975
|
+
* has nothing to refresh them with, so a test waits for the state it expects rather than reading
|
|
976
|
+
* once.
|
|
977
|
+
*/
|
|
978
|
+
async function untilListing(
|
|
979
|
+
ml: MiddleLayer,
|
|
980
|
+
predicate: (listing: FoldersListing) => boolean,
|
|
981
|
+
): Promise<FoldersListing> {
|
|
982
|
+
let last: FoldersListing | undefined = undefined;
|
|
983
|
+
for (let attempt = 0; attempt < 60; attempt++) {
|
|
984
|
+
last = await ml.folders.awaitStableValue();
|
|
985
|
+
if (predicate(last)) return last;
|
|
986
|
+
await tp.setTimeout(250);
|
|
987
|
+
}
|
|
988
|
+
throw new Error(`folder listing never matched; last value: ${JSON.stringify(last)}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** Writes a document straight into the slot, to stage what only a foreign writer could store. */
|
|
992
|
+
async function writeRawDocument(ml: MiddleLayer, document: unknown): Promise<void> {
|
|
993
|
+
await ml.pl.withWriteTx("TestWriteFoldersDocument", async (tx) => {
|
|
994
|
+
const rid = await ensureFoldersRid(tx);
|
|
995
|
+
const f = field(rid, FoldersDocumentField);
|
|
996
|
+
const ref = tx.createJsonValue(document);
|
|
997
|
+
if (await tx.fieldExists(f)) tx.setField(f, ref);
|
|
998
|
+
else tx.createField(f, "Dynamic", ref);
|
|
999
|
+
await tx.commit();
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Points the document field at a resource that carries no blob at all, which is what a build
|
|
1005
|
+
* storing the tree in some other shape would leave for this one to find. Returns its id.
|
|
1006
|
+
*/
|
|
1007
|
+
async function writeDataLessDocumentResource(ml: MiddleLayer): Promise<SignedResourceId> {
|
|
1008
|
+
return await ml.pl.withWriteTx("TestWriteForeignFoldersDocument", async (tx) => {
|
|
1009
|
+
const rid = await ensureFoldersRid(tx);
|
|
1010
|
+
const f = field(rid, FoldersDocumentField);
|
|
1011
|
+
const ref = tx.createEphemeral({ name: "FoldersSomethingElse", version: "1" });
|
|
1012
|
+
tx.lock(ref);
|
|
1013
|
+
if (await tx.fieldExists(f)) tx.setField(f, ref);
|
|
1014
|
+
else tx.createField(f, "Dynamic", ref);
|
|
1015
|
+
await tx.commit();
|
|
1016
|
+
return await ref.globalId;
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/** Resource the single document field currently points at. */
|
|
1021
|
+
async function documentValueRid(ml: MiddleLayer): Promise<SignedResourceId | undefined> {
|
|
1022
|
+
return await ml.pl.withReadTx("TestReadFoldersField", async (tx) => {
|
|
1023
|
+
const singleton = await tx.getField(field(tx.clientRoot, FoldersField));
|
|
1024
|
+
if (isNullSignedResourceId(singleton.value)) return undefined;
|
|
1025
|
+
const fData = await tx.getFieldIfExists(field(singleton.value, FoldersDocumentField));
|
|
1026
|
+
if (fData === undefined || isNullSignedResourceId(fData.value)) return undefined;
|
|
1027
|
+
return fData.value;
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Document held by one particular value resource, whichever one the field points at now. */
|
|
1032
|
+
async function documentAt(
|
|
1033
|
+
ml: MiddleLayer,
|
|
1034
|
+
rid: SignedResourceId,
|
|
1035
|
+
): Promise<{ folders: { name: string }[] } | undefined> {
|
|
1036
|
+
return await ml.pl.withReadTx("TestReadFoldersValue", async (tx) => {
|
|
1037
|
+
const data = await tx.getResourceData(rid, false);
|
|
1038
|
+
if (data.data === undefined) return undefined;
|
|
1039
|
+
return JSON.parse(Buffer.from(data.data).toString("utf-8")) as { folders: { name: string }[] };
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/** The parsed JSON held by one particular value resource. */
|
|
1044
|
+
async function rawDocumentAt(ml: MiddleLayer, rid: SignedResourceId): Promise<unknown> {
|
|
1045
|
+
return await ml.pl.withReadTx("TestReadRawFoldersValue", async (tx) => {
|
|
1046
|
+
const data = await tx.getResourceData(rid, false);
|
|
1047
|
+
if (data.data === undefined) return undefined;
|
|
1048
|
+
return JSON.parse(Buffer.from(data.data).toString("utf-8")) as unknown;
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Whether the folder singleton itself has been created on the client root. */
|
|
1053
|
+
async function singletonExists(ml: MiddleLayer): Promise<boolean> {
|
|
1054
|
+
return await ml.pl.withReadTx("TestFoldersSingletonExists", async (tx) => {
|
|
1055
|
+
const fData = await tx.getFieldIfExists(field(tx.clientRoot, FoldersField));
|
|
1056
|
+
return fData !== undefined && !isNullSignedResourceId(fData.value);
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Whether the singleton's one document field has been created at all. */
|
|
1061
|
+
async function documentFieldExists(ml: MiddleLayer): Promise<boolean> {
|
|
1062
|
+
return await ml.pl.withReadTx("TestFoldersDocumentExists", async (tx) => {
|
|
1063
|
+
const singleton = await tx.getField(field(tx.clientRoot, FoldersField));
|
|
1064
|
+
if (isNullSignedResourceId(singleton.value)) return false;
|
|
1065
|
+
const fData = await tx.getFieldIfExists(field(singleton.value, FoldersDocumentField));
|
|
1066
|
+
return fData !== undefined;
|
|
1067
|
+
});
|
|
1068
|
+
}
|