@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.
Files changed (85) hide show
  1. package/dist/index.cjs +10 -5
  2. package/dist/index.d.ts +5 -4
  3. package/dist/index.js +3 -2
  4. package/dist/middle_layer/build_stamp.cjs +1 -1
  5. package/dist/middle_layer/build_stamp.js +1 -1
  6. package/dist/middle_layer/folders.cjs +631 -0
  7. package/dist/middle_layer/folders.cjs.map +1 -0
  8. package/dist/middle_layer/folders.d.ts +39 -0
  9. package/dist/middle_layer/folders.d.ts.map +1 -0
  10. package/dist/middle_layer/folders.js +613 -0
  11. package/dist/middle_layer/folders.js.map +1 -0
  12. package/dist/middle_layer/index.cjs +3 -0
  13. package/dist/middle_layer/index.d.ts +4 -3
  14. package/dist/middle_layer/index.js +2 -1
  15. package/dist/middle_layer/middle_layer.cjs +585 -370
  16. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  17. package/dist/middle_layer/middle_layer.d.ts +215 -122
  18. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  19. package/dist/middle_layer/middle_layer.js +593 -378
  20. package/dist/middle_layer/middle_layer.js.map +1 -1
  21. package/dist/middle_layer/project_list.cjs +36 -19
  22. package/dist/middle_layer/project_list.cjs.map +1 -1
  23. package/dist/middle_layer/project_list.d.ts +1 -1
  24. package/dist/middle_layer/project_list.d.ts.map +1 -1
  25. package/dist/middle_layer/project_list.js +37 -21
  26. package/dist/middle_layer/project_list.js.map +1 -1
  27. package/dist/middle_layer/sharing_list.cjs +123 -93
  28. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  29. package/dist/middle_layer/sharing_list.d.ts +43 -19
  30. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  31. package/dist/middle_layer/sharing_list.js +123 -93
  32. package/dist/middle_layer/sharing_list.js.map +1 -1
  33. package/dist/middle_layer/template_list.cjs +45 -20
  34. package/dist/middle_layer/template_list.cjs.map +1 -1
  35. package/dist/middle_layer/template_list.d.ts +7 -15
  36. package/dist/middle_layer/template_list.d.ts.map +1 -1
  37. package/dist/middle_layer/template_list.js +44 -21
  38. package/dist/middle_layer/template_list.js.map +1 -1
  39. package/dist/model/index.cjs +7 -5
  40. package/dist/model/index.d.ts +2 -2
  41. package/dist/model/index.js +2 -2
  42. package/dist/model/sharing_model.cjs +46 -20
  43. package/dist/model/sharing_model.cjs.map +1 -1
  44. package/dist/model/sharing_model.d.ts +125 -55
  45. package/dist/model/sharing_model.d.ts.map +1 -1
  46. package/dist/model/sharing_model.js +40 -16
  47. package/dist/model/sharing_model.js.map +1 -1
  48. package/dist/mutator/list.cjs +30 -0
  49. package/dist/mutator/list.cjs.map +1 -0
  50. package/dist/mutator/list.js +29 -0
  51. package/dist/mutator/list.js.map +1 -0
  52. package/dist/mutator/project.cjs +14 -1
  53. package/dist/mutator/project.cjs.map +1 -1
  54. package/dist/mutator/project.d.ts.map +1 -1
  55. package/dist/mutator/project.js +15 -2
  56. package/dist/mutator/project.js.map +1 -1
  57. package/dist/mutator/sharing.cjs +130 -76
  58. package/dist/mutator/sharing.cjs.map +1 -1
  59. package/dist/mutator/sharing.js +130 -76
  60. package/dist/mutator/sharing.js.map +1 -1
  61. package/dist/mutator/template.cjs +24 -17
  62. package/dist/mutator/template.cjs.map +1 -1
  63. package/dist/mutator/template.js +26 -20
  64. package/dist/mutator/template.js.map +1 -1
  65. package/package.json +19 -19
  66. package/src/middle_layer/folders.test.ts +1068 -0
  67. package/src/middle_layer/folders.ts +1059 -0
  68. package/src/middle_layer/folders_read.test.ts +359 -0
  69. package/src/middle_layer/index.ts +3 -2
  70. package/src/middle_layer/middle_layer.ts +813 -547
  71. package/src/middle_layer/project_list.test.ts +183 -0
  72. package/src/middle_layer/project_list.ts +58 -21
  73. package/src/middle_layer/sharing.test.ts +183 -0
  74. package/src/middle_layer/sharing_list.ts +208 -152
  75. package/src/middle_layer/template_list.test.ts +148 -0
  76. package/src/middle_layer/template_list.ts +72 -32
  77. package/src/middle_layer/templates.test.ts +76 -45
  78. package/src/model/sharing_model.test.ts +69 -2
  79. package/src/model/sharing_model.ts +161 -73
  80. package/src/mutator/list.ts +36 -0
  81. package/src/mutator/project-v3.test.ts +3 -1
  82. package/src/mutator/project.ts +13 -2
  83. package/src/mutator/sharing.ts +193 -120
  84. package/src/mutator/template.ts +37 -29
  85. package/src/test/with_ml.ts +73 -16
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The project-list reader against a hand-built projects resource: what it keeps, what it skips
3
+ * and in which order. No backend — `projectListEntries` is the whole computable body, fed here
4
+ * with a stand-in node instead of a synchronized tree.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import type { ResourceType } from "@milaboratories/pl-client";
9
+ import { asSignedResourceId } from "@milaboratories/pl-client";
10
+ import type { ProjectMeta } from "@milaboratories/pl-model-middle-layer";
11
+ import {
12
+ ProjectCreatedTimestamp,
13
+ ProjectLastModifiedTimestamp,
14
+ ProjectMetaKey,
15
+ ProjectResourceType,
16
+ } from "../model/project_model";
17
+ import type { ProjectId } from "../model/project_model";
18
+ import { projectListEntries } from "./project_list";
19
+
20
+ // ── Helpers ──────────────────────────────────────────────────────────────────
21
+
22
+ /** One field of the projects resource: a node, or a field that resolves to nothing. */
23
+ interface Entry {
24
+ readonly field: string;
25
+ readonly id?: number;
26
+ readonly type?: ResourceType;
27
+ readonly kv?: Record<string, unknown>;
28
+ /** A field whose value has not resolved yet — `traverse` returns undefined. */
29
+ readonly unresolved?: boolean;
30
+ }
31
+
32
+ /** A project field with the metadata a synced project carries. */
33
+ function project(field: string, id: number, label: string, created: number, modified: number) {
34
+ return {
35
+ field,
36
+ id,
37
+ type: ProjectResourceType,
38
+ kv: {
39
+ [ProjectMetaKey]: { label } satisfies ProjectMeta,
40
+ [ProjectCreatedTimestamp]: created,
41
+ [ProjectLastModifiedTimestamp]: modified,
42
+ },
43
+ } satisfies Entry;
44
+ }
45
+
46
+ /** Stand-in for the projects-list tree node, carrying exactly what the reader reads. */
47
+ function projectsNode(...entries: Entry[]) {
48
+ return {
49
+ listDynamicFields: () => entries.map((e) => e.field),
50
+ traverse: (fieldName: string) => {
51
+ const entry = entries.find((e) => e.field === fieldName);
52
+ if (entry === undefined || entry.unresolved) return undefined;
53
+ return {
54
+ id: asSignedResourceId(`0x${(entry.id ?? 0).toString(16)}|ab`),
55
+ resourceType: entry.type ?? ProjectResourceType,
56
+ getKeyValueAsJson: <T>(key: string) => (entry.kv ?? {})[key] as T | undefined,
57
+ };
58
+ },
59
+ };
60
+ }
61
+
62
+ const NoneOpened: ProjectId[] = [];
63
+
64
+ // ── Tests ────────────────────────────────────────────────────────────────────
65
+
66
+ describe("tolerance", () => {
67
+ it("keeps every synced project", () => {
68
+ const entries = projectListEntries(
69
+ projectsNode(project("uuid-a", 1, "A", 100, 100), project("uuid-b", 2, "B", 200, 200)),
70
+ NoneOpened,
71
+ );
72
+
73
+ expect(entries.map((e) => e.meta.label)).toStrictEqual(["B", "A"]);
74
+ expect(entries[1].created).toStrictEqual(new Date(100));
75
+ expect(entries[1].lastModified).toStrictEqual(new Date(100));
76
+ });
77
+
78
+ it("skips a sibling that is not a project, and keeps the projects around it", () => {
79
+ const entries = projectListEntries(
80
+ projectsNode(
81
+ project("uuid-a", 1, "A", 100, 100),
82
+ { field: "folders", id: 9, type: { name: "Folders", version: "1" } },
83
+ project("uuid-b", 2, "B", 200, 200),
84
+ ),
85
+ NoneOpened,
86
+ );
87
+
88
+ expect(entries.map((e) => e.meta.label)).toStrictEqual(["B", "A"]);
89
+ });
90
+
91
+ it("skips a project whose metadata has not synced yet", () => {
92
+ const halfSynced = [
93
+ { name: "no key at all", kv: {} },
94
+ {
95
+ name: "meta missing",
96
+ kv: { [ProjectCreatedTimestamp]: 1, [ProjectLastModifiedTimestamp]: 1 },
97
+ },
98
+ {
99
+ name: "created missing",
100
+ kv: { [ProjectMetaKey]: { label: "x" }, [ProjectLastModifiedTimestamp]: 1 },
101
+ },
102
+ {
103
+ name: "lastModified missing",
104
+ kv: { [ProjectMetaKey]: { label: "x" }, [ProjectCreatedTimestamp]: 1 },
105
+ },
106
+ ];
107
+
108
+ for (const { name, kv } of halfSynced) {
109
+ const entries = projectListEntries(
110
+ projectsNode(
111
+ { field: "uuid-half", id: 9, type: ProjectResourceType, kv },
112
+ project("uuid-a", 1, "A", 100, 100),
113
+ ),
114
+ NoneOpened,
115
+ );
116
+ expect(
117
+ entries.map((e) => e.meta.label),
118
+ name,
119
+ ).toStrictEqual(["A"]);
120
+ }
121
+ });
122
+
123
+ it("skips a field that resolves to nothing", () => {
124
+ const entries = projectListEntries(
125
+ projectsNode({ field: "uuid-pending", unresolved: true }, project("uuid-a", 1, "A", 1, 1)),
126
+ NoneOpened,
127
+ );
128
+
129
+ expect(entries.map((e) => e.meta.label)).toStrictEqual(["A"]);
130
+ });
131
+
132
+ it("returns an empty list rather than throwing when nothing on the resource is a project", () => {
133
+ const entries = projectListEntries(
134
+ projectsNode({ field: "folders", id: 9, type: { name: "Folders", version: "1" } }),
135
+ NoneOpened,
136
+ );
137
+
138
+ expect(entries).toStrictEqual([]);
139
+ });
140
+ });
141
+
142
+ describe("ordering", () => {
143
+ it("sorts most recently modified first, regardless of field order", () => {
144
+ const entries = projectListEntries(
145
+ projectsNode(
146
+ project("uuid-mid", 1, "mid", 0, 200),
147
+ project("uuid-newest", 2, "newest", 0, 300),
148
+ project("uuid-oldest", 3, "oldest", 0, 100),
149
+ ),
150
+ NoneOpened,
151
+ );
152
+
153
+ expect(entries.map((e) => e.meta.label)).toStrictEqual(["newest", "mid", "oldest"]);
154
+ });
155
+
156
+ it("orders on last-modified and not on creation", () => {
157
+ const entries = projectListEntries(
158
+ projectsNode(
159
+ project("uuid-a", 1, "created first, touched last", 100, 900),
160
+ project("uuid-b", 2, "created last, touched first", 800, 850),
161
+ ),
162
+ NoneOpened,
163
+ );
164
+
165
+ expect(entries.map((e) => e.meta.label)).toStrictEqual([
166
+ "created first, touched last",
167
+ "created last, touched first",
168
+ ]);
169
+ });
170
+ });
171
+
172
+ describe("opened flag", () => {
173
+ it("marks exactly the projects the caller lists as opened", () => {
174
+ const node = projectsNode(project("uuid-a", 1, "A", 1, 1), project("uuid-b", 2, "B", 2, 2));
175
+ const opened = projectListEntries(node, NoneOpened)
176
+ .filter((e) => e.meta.label === "A")
177
+ .map((e) => e.id);
178
+
179
+ const entries = projectListEntries(node, opened);
180
+
181
+ expect(entries.filter((e) => e.opened).map((e) => e.meta.label)).toStrictEqual(["A"]);
182
+ });
183
+ });
@@ -22,10 +22,11 @@ import {
22
22
  ProjectCreatedTimestamp,
23
23
  ProjectLastModifiedTimestamp,
24
24
  ProjectMetaKey,
25
+ ProjectResourceType,
25
26
  } from "../model/project_model";
26
27
  import type { MiddleLayerEnvironment } from "./middle_layer";
27
- import { notEmpty } from "@milaboratories/ts-helpers";
28
28
  import type { ProjectMeta } from "@milaboratories/pl-model-middle-layer";
29
+ import { asProjectId } from "@milaboratories/pl-model-common";
29
30
 
30
31
  export const ProjectsField = "projects";
31
32
  export const ProjectsResourceType: ResourceType = { name: "Projects", version: "1" };
@@ -77,27 +78,63 @@ export async function createProjectList(
77
78
  const node = ctx.accessor(tree.entry()).node();
78
79
  const oProjects = openedProjects.getValue(ctx);
79
80
  if (node === undefined) return undefined;
80
- const result: ProjectListEntry[] = [];
81
-
82
- // Projects list resource keeps projects assigned to fields. Each field name is project's UUID
83
- for (const field of node.listDynamicFields()) {
84
- const prj = node.traverse(field);
85
- if (prj === undefined) continue;
86
- const meta = notEmpty(prj.getKeyValueAsJson<ProjectMeta>(ProjectMetaKey));
87
- const created = notEmpty(prj.getKeyValueAsJson<number>(ProjectCreatedTimestamp));
88
- const lastModified = notEmpty(prj.getKeyValueAsJson<number>(ProjectLastModifiedTimestamp));
89
- const projectId = resourceIdToString(prj.id) as ProjectId;
90
- result.push({
91
- id: projectId,
92
- created: new Date(created),
93
- lastModified: new Date(lastModified),
94
- opened: oProjects.indexOf(projectId) >= 0,
95
- meta,
96
- });
97
- }
98
- result.sort((p) => -p.lastModified.valueOf());
99
- return result;
81
+ return projectListEntries(node, oProjects);
100
82
  }).withStableType();
101
83
 
102
84
  return { computable: c, tree };
103
85
  }
86
+
87
+ /** The part of a tree node this reader needs from a single entry of the projects list. */
88
+ interface ProjectListEntryNode {
89
+ readonly id: SignedResourceId;
90
+ readonly resourceType: ResourceType;
91
+ getKeyValueAsJson<T>(key: string): T | undefined;
92
+ }
93
+
94
+ /** The part of a tree node this reader needs from the projects-list resource itself. */
95
+ interface ProjectsListNode {
96
+ listDynamicFields(): string[];
97
+ traverse(fieldName: string): ProjectListEntryNode | undefined;
98
+ }
99
+
100
+ /**
101
+ * Builds the project list out of the projects-list resource, most recently modified first.
102
+ *
103
+ * Skips, rather than throws on, anything that is not a fully synced project. The projects
104
+ * resource carries the user's entire project list, so one unexpected sibling field — or one
105
+ * project whose metadata has not arrived yet — must cost that entry and nothing else. Both the
106
+ * traversal and the metadata reads are watched, so an entry appears as soon as it syncs.
107
+ *
108
+ * The resource type is matched by name only: the point is to exclude foreign resources, not to
109
+ * hide a project stored under an earlier resource-type version.
110
+ */
111
+ export function projectListEntries(
112
+ node: ProjectsListNode,
113
+ openedProjects: readonly ProjectId[],
114
+ ): ProjectListEntry[] {
115
+ const result: ProjectListEntry[] = [];
116
+
117
+ // Projects list resource keeps projects assigned to fields. Each field name is project's UUID
118
+ for (const field of node.listDynamicFields()) {
119
+ const prj = node.traverse(field);
120
+ if (prj === undefined) continue;
121
+ if (prj.resourceType.name !== ProjectResourceType.name) continue;
122
+
123
+ const meta = prj.getKeyValueAsJson<ProjectMeta>(ProjectMetaKey);
124
+ const created = prj.getKeyValueAsJson<number>(ProjectCreatedTimestamp);
125
+ const lastModified = prj.getKeyValueAsJson<number>(ProjectLastModifiedTimestamp);
126
+ if (meta === undefined || created === undefined || lastModified === undefined) continue;
127
+
128
+ const projectId = asProjectId(resourceIdToString(prj.id));
129
+ result.push({
130
+ id: projectId,
131
+ created: new Date(created),
132
+ lastModified: new Date(lastModified),
133
+ opened: openedProjects.indexOf(projectId) >= 0,
134
+ meta,
135
+ });
136
+ }
137
+
138
+ result.sort((a, b) => b.lastModified.valueOf() - a.lastModified.valueOf());
139
+ return result;
140
+ }
@@ -0,0 +1,183 @@
1
+ import { expect, test } from "vitest";
2
+ import * as tp from "node:timers/promises";
3
+ import { resourceIdToString } from "@milaboratories/pl-client";
4
+ import type { FolderId, FoldersItem } from "@milaboratories/pl-model-middle-layer";
5
+ import type { TemplateId } from "@milaboratories/pl-model-common";
6
+ import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
7
+ import { withMlKeepingRoots } from "../test/with_ml";
8
+ import { createTemplate } from "../mutator/template";
9
+ import type { FoldersListing } from "./folders";
10
+ import type { MiddleLayer } from "./middle_layer";
11
+ import { ensureTemplateListRid } from "./template_list";
12
+
13
+ // The file's roots outlive their tests and are deleted together after the last one, so the
14
+ // backend's cleanup of their projects never runs under this file's own writes.
15
+ const withMl = withMlKeepingRoots();
16
+
17
+ /**
18
+ * Sharing folders and projects against a live backend: what a copy out of a share builds, and
19
+ * where it puts it.
20
+ *
21
+ * The test server has one user, so every share here goes to everyone and is copied by its own
22
+ * donor, which is enough to exercise the whole copy: the envelope is discovered and copied from
23
+ * exactly as a recipient's would be.
24
+ *
25
+ * Needs a backend, like every `withMl` test in this package, and no gate: `PL_ADDRESS` is either
26
+ * configured or the client fails to connect.
27
+ */
28
+
29
+ test("a shared folder is rebuilt whole inside the destination, its root named to fit there", async () => {
30
+ await withMl(async (ml) => {
31
+ const source = await ml.createFolder("Repertoires");
32
+ await ml.setFolderDescription(source, "Every donor, both replicates");
33
+ const nested = await ml.createFolder("2024", source);
34
+ const alpha = await ml.createProject({ label: "Alpha", description: "Donor 14" }, source);
35
+ await ml.createProject({ label: "Beta" }, nested);
36
+ const pipeline = await storeTemplate(ml, "Pipeline", "Clonotyping, then clustering");
37
+ await move(ml, { kind: "template", id: pipeline }, nested);
38
+
39
+ // The destination already holds a folder of the shared root's name.
40
+ const inbox = await ml.createFolder("Inbox");
41
+ await ml.createFolder("Repertoires", inbox);
42
+
43
+ const shared = await ml.shareFolder(source, { everyone: true, title: "Repertoires" });
44
+ const outcome = await ml.copyShare([shared.shareId], inbox);
45
+
46
+ expect(outcome.failed).toStrictEqual([]);
47
+ expect(outcome.projects).toHaveLength(2);
48
+ expect(outcome.templates).toHaveLength(1);
49
+
50
+ const listing = await untilListing(
51
+ ml,
52
+ (l) => l.projects.length === 4 && l.templates.length === 2 && l.folders.length === 6,
53
+ );
54
+
55
+ // Only the root lands beside what the destination already holds, so only the root is renamed.
56
+ const root = listing.folders.find((f) => f.parent === inbox && f.name !== "Repertoires");
57
+ if (root === undefined) throw new Error("the shared folder was not rebuilt in the destination");
58
+ expect(root.name).toBe("Repertoires (Copy)");
59
+ expect(root.description).toBe("Every donor, both replicates");
60
+
61
+ const inside = listing.folders.filter((f) => f.ancestors.includes(root.id));
62
+ expect(inside.map((f) => f.name)).toStrictEqual(["2024"]);
63
+ const rebuilt = inside[0];
64
+ expect(rebuilt.parent).toBe(root.id);
65
+
66
+ // Every copy lands in the rebuilt counterpart of the folder it was shared from.
67
+ const copies = listing.projects.filter((p) => outcome.projects.includes(p.id));
68
+ const alphaCopy = copies.find((p) => p.meta.label === "Alpha");
69
+ expect(alphaCopy?.folder).toBe(root.id);
70
+ expect(alphaCopy?.meta.description).toBe("Donor 14");
71
+ expect(copies.find((p) => p.meta.label === "Beta")?.folder).toBe(rebuilt.id);
72
+
73
+ const pipelineCopy = listing.templates.find((t) => t.id === outcome.templates[0]);
74
+ expect(pipelineCopy).toMatchObject({
75
+ label: "Pipeline",
76
+ description: "Clonotyping, then clustering",
77
+ folder: rebuilt.id,
78
+ });
79
+
80
+ // The shared subtree itself is left where it was.
81
+ expect(listing.projects.find((p) => p.id === alpha)?.folder).toBe(source);
82
+ expect(listing.templates.find((t) => t.id === pipeline)?.folder).toBe(nested);
83
+ });
84
+ });
85
+
86
+ test("a shared project copied into a folder lands there, named to fit beside what it holds", async () => {
87
+ await withMl(async (ml) => {
88
+ const source = await ml.createProject({ label: "Alpha" });
89
+ const inbox = await ml.createFolder("Inbox");
90
+ await ml.createProject({ label: "Alpha" }, inbox);
91
+
92
+ const shared = await ml.shareProjects([source], {
93
+ everyone: true,
94
+ title: "Alpha",
95
+ mode: "copy",
96
+ });
97
+ const outcome = await ml.copyShare([shared.shareId], inbox);
98
+
99
+ expect(outcome.failed).toStrictEqual([]);
100
+ expect(outcome.projects).toHaveLength(1);
101
+
102
+ const listing = await untilListing(ml, (l) => l.projects.length === 3);
103
+ const copy = listing.projects.find((p) => p.id === outcome.projects[0]);
104
+ expect(copy?.folder).toBe(inbox);
105
+ expect(copy?.meta.label).toBe("Alpha (Copy)");
106
+ expect(listing.projects.find((p) => p.id === source)?.folder).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ test("replacing a project share leaves one share, under a new id", async () => {
111
+ await withMl(async (ml) => {
112
+ const project = await ml.createProject({ label: "Alpha" });
113
+
114
+ const first = await ml.shareProjects([project], {
115
+ everyone: true,
116
+ title: "Alpha",
117
+ mode: "copy",
118
+ });
119
+ const replacement = await ml.shareProjects([project], {
120
+ everyone: true,
121
+ title: "Alpha, again",
122
+ mode: "copy",
123
+ replace: [first.shareId],
124
+ });
125
+ expect(replacement.shareId).not.toBe(first.shareId);
126
+
127
+ // The replaced share is deleted in the transaction that creates its replacement, so the
128
+ // outbox never holds both.
129
+ const outgoing = (await ml.outgoingShares.getValue()) ?? [];
130
+ expect(outgoing.map((s) => s.shareId)).toStrictEqual([replacement.shareId]);
131
+ expect(outgoing[0]).toMatchObject({ payloadKind: "projects", title: "Alpha, again" });
132
+ expect(outgoing[0].projects.map((p) => p.projectId)).toStrictEqual([project]);
133
+ });
134
+ });
135
+
136
+ //
137
+ // Internals
138
+ //
139
+
140
+ /** Stores an empty template at the top level and returns its id. */
141
+ async function storeTemplate(
142
+ ml: MiddleLayer,
143
+ label: string,
144
+ description: string,
145
+ ): Promise<TemplateId> {
146
+ const rid = await ml.pl.withWriteTx("TestStoreTemplate", async (tx) => {
147
+ const listRid = await ensureTemplateListRid(tx);
148
+ const tpl = createTemplate(
149
+ tx,
150
+ listRid,
151
+ { label, description },
152
+ { schemaVersion: 1, document: { schema: PROJECT_TEMPLATE_SCHEMA_V1, blocks: [] } },
153
+ );
154
+ await tx.commit();
155
+ return await tpl.globalId;
156
+ });
157
+ return resourceIdToString(rid) as TemplateId;
158
+ }
159
+
160
+ /** Moves one item with its own fresh plan, for the steps a test only needs as setup. */
161
+ async function move(ml: MiddleLayer, item: FoldersItem, destination: FolderId): Promise<void> {
162
+ const planned = await ml.previewFoldersMove([item], destination);
163
+ if (!planned.ok) throw new Error(`cannot plan move: ${JSON.stringify(planned.issues)}`);
164
+ const outcome = await ml.moveFolderItems([item], destination, planned.plan);
165
+ if (!outcome.ok) throw new Error(`move refused: ${outcome.reason}`);
166
+ }
167
+
168
+ /**
169
+ * The listing is fed by polled trees, so a test waits for the state it expects rather than
170
+ * reading once.
171
+ */
172
+ async function untilListing(
173
+ ml: MiddleLayer,
174
+ predicate: (listing: FoldersListing) => boolean,
175
+ ): Promise<FoldersListing> {
176
+ let last: FoldersListing | undefined = undefined;
177
+ for (let attempt = 0; attempt < 60; attempt++) {
178
+ last = await ml.folders.awaitStableValue();
179
+ if (predicate(last)) return last;
180
+ await tp.setTimeout(250);
181
+ }
182
+ throw new Error(`folder listing never matched; last value: ${JSON.stringify(last)}`);
183
+ }