@milaboratories/pl-middle-layer 1.71.16 → 1.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/index.cjs +10 -5
  2. package/dist/index.d.ts +5 -4
  3. package/dist/index.js +3 -2
  4. package/dist/middle_layer/build_stamp.cjs +1 -1
  5. package/dist/middle_layer/build_stamp.js +1 -1
  6. package/dist/middle_layer/folders.cjs +636 -0
  7. package/dist/middle_layer/folders.cjs.map +1 -0
  8. package/dist/middle_layer/folders.d.ts +39 -0
  9. package/dist/middle_layer/folders.d.ts.map +1 -0
  10. package/dist/middle_layer/folders.js +617 -0
  11. package/dist/middle_layer/folders.js.map +1 -0
  12. package/dist/middle_layer/index.cjs +3 -0
  13. package/dist/middle_layer/index.d.ts +4 -3
  14. package/dist/middle_layer/index.js +2 -1
  15. package/dist/middle_layer/middle_layer.cjs +585 -370
  16. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  17. package/dist/middle_layer/middle_layer.d.ts +215 -122
  18. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  19. package/dist/middle_layer/middle_layer.js +593 -378
  20. package/dist/middle_layer/middle_layer.js.map +1 -1
  21. package/dist/middle_layer/project_list.cjs +36 -19
  22. package/dist/middle_layer/project_list.cjs.map +1 -1
  23. package/dist/middle_layer/project_list.d.ts +1 -1
  24. package/dist/middle_layer/project_list.d.ts.map +1 -1
  25. package/dist/middle_layer/project_list.js +37 -21
  26. package/dist/middle_layer/project_list.js.map +1 -1
  27. package/dist/middle_layer/sharing_list.cjs +123 -93
  28. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  29. package/dist/middle_layer/sharing_list.d.ts +43 -19
  30. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  31. package/dist/middle_layer/sharing_list.js +123 -93
  32. package/dist/middle_layer/sharing_list.js.map +1 -1
  33. package/dist/middle_layer/template_list.cjs +45 -20
  34. package/dist/middle_layer/template_list.cjs.map +1 -1
  35. package/dist/middle_layer/template_list.d.ts +7 -15
  36. package/dist/middle_layer/template_list.d.ts.map +1 -1
  37. package/dist/middle_layer/template_list.js +44 -21
  38. package/dist/middle_layer/template_list.js.map +1 -1
  39. package/dist/model/index.cjs +7 -5
  40. package/dist/model/index.d.ts +2 -2
  41. package/dist/model/index.js +2 -2
  42. package/dist/model/sharing_model.cjs +46 -20
  43. package/dist/model/sharing_model.cjs.map +1 -1
  44. package/dist/model/sharing_model.d.ts +125 -55
  45. package/dist/model/sharing_model.d.ts.map +1 -1
  46. package/dist/model/sharing_model.js +40 -16
  47. package/dist/model/sharing_model.js.map +1 -1
  48. package/dist/mutator/list.cjs +30 -0
  49. package/dist/mutator/list.cjs.map +1 -0
  50. package/dist/mutator/list.js +29 -0
  51. package/dist/mutator/list.js.map +1 -0
  52. package/dist/mutator/project.cjs +14 -1
  53. package/dist/mutator/project.cjs.map +1 -1
  54. package/dist/mutator/project.d.ts.map +1 -1
  55. package/dist/mutator/project.js +15 -2
  56. package/dist/mutator/project.js.map +1 -1
  57. package/dist/mutator/sharing.cjs +130 -76
  58. package/dist/mutator/sharing.cjs.map +1 -1
  59. package/dist/mutator/sharing.js +130 -76
  60. package/dist/mutator/sharing.js.map +1 -1
  61. package/dist/mutator/template.cjs +24 -17
  62. package/dist/mutator/template.cjs.map +1 -1
  63. package/dist/mutator/template.js +26 -20
  64. package/dist/mutator/template.js.map +1 -1
  65. package/package.json +17 -17
  66. package/src/middle_layer/folders.test.ts +1076 -0
  67. package/src/middle_layer/folders.ts +1069 -0
  68. package/src/middle_layer/folders_read.test.ts +359 -0
  69. package/src/middle_layer/index.ts +3 -2
  70. package/src/middle_layer/middle_layer.ts +813 -547
  71. package/src/middle_layer/project_list.test.ts +183 -0
  72. package/src/middle_layer/project_list.ts +58 -21
  73. package/src/middle_layer/sharing.test.ts +183 -0
  74. package/src/middle_layer/sharing_list.ts +208 -152
  75. package/src/middle_layer/template_list.test.ts +148 -0
  76. package/src/middle_layer/template_list.ts +72 -32
  77. package/src/middle_layer/templates.test.ts +76 -45
  78. package/src/model/sharing_model.test.ts +69 -2
  79. package/src/model/sharing_model.ts +161 -73
  80. package/src/mutator/list.ts +36 -0
  81. package/src/mutator/project-v3.test.ts +3 -1
  82. package/src/mutator/project.ts +13 -2
  83. package/src/mutator/sharing.ts +193 -120
  84. package/src/mutator/template.ts +37 -29
  85. package/src/test/with_ml.ts +73 -16
@@ -1,24 +1,28 @@
1
1
  import type { PlTransaction, ResourceRef, SignedResourceId } from "@milaboratories/pl-client";
2
- import { field, isNotNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
2
+ import { field, isNotNullSignedResourceId } from "@milaboratories/pl-client";
3
3
  import { randomUUID } from "node:crypto";
4
- import type { ProjectMeta } from "@milaboratories/pl-model-middle-layer";
5
- import type { ProjectId, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
4
+ import type { FolderId, ProjectMeta } from "@milaboratories/pl-model-middle-layer";
5
+ import { normalizeDescription } from "@milaboratories/pl-model-middle-layer";
6
+ import type { ProjectId, ProjectTemplateV1, TemplateId } from "@milaboratories/pl-model-common";
6
7
  import { ProjectMetaKey } from "../model/project_model";
7
8
  import { duplicateProject } from "./project";
8
9
  import type {
9
10
  EnvelopeData,
10
- EnvelopeAcceptance,
11
+ EnvelopeFolder,
12
+ EnvelopeFolderId,
13
+ EnvelopeFolderProject,
14
+ EnvelopeFolderTemplate,
11
15
  EnvelopeMode,
12
16
  EnvelopeProject,
13
17
  ProjectFieldUuid,
14
18
  ShareId,
15
- SharingDecision,
19
+ ShareHidden,
16
20
  } from "../model/sharing_model";
17
21
  import {
18
22
  EnvelopeSchemaVersionCurrent,
19
23
  SharedEnvelopeResourceType,
20
- acceptanceField,
21
- decisionField,
24
+ hiddenField,
25
+ newProjectFieldUuid,
22
26
  newShareId,
23
27
  } from "../model/sharing_model";
24
28
 
@@ -41,20 +45,8 @@ export function envelopeProjectFieldUuid(name: string): ProjectFieldUuid {
41
45
  // Donor side
42
46
  //
43
47
 
44
- /**
45
- * One project going into an envelope: `fresh` snapshots a live source (normal path); `carry`
46
- * re-attaches an existing snapshot (change's "keep", or an "update" whose source is gone).
47
- */
48
- export type EnvelopeProjectSource =
49
- | { kind: "fresh"; projectId: ProjectId; sourceRid: SignedResourceId }
50
- | {
51
- kind: "carry";
52
- projectId: ProjectId;
53
- label: string;
54
- snapshotRid: SignedResourceId;
55
- /** ms epoch the carried snapshot was last taken; preserved so "keep" keeps its timestamp. */
56
- updatedAt: number;
57
- };
48
+ /** One live project going into an envelope; it is snapshotted as the envelope is built. */
49
+ export type EnvelopeProjectSource = { projectId: ProjectId; sourceRid: SignedResourceId };
58
50
 
59
51
  /**
60
52
  * Builds one {@link SharedEnvelopeResourceType} on the donor side inside the given write
@@ -75,35 +67,17 @@ export async function buildShareEnvelope(
75
67
  title: string;
76
68
  /** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
77
69
  expiresAt: number | null;
78
- /** Existing shareId for a change; a fresh one is minted when omitted. */
79
- shareId?: ShareId;
80
- sharedAt?: number;
81
70
  },
82
71
  ): Promise<{ envelope: ResourceRef; data: EnvelopeData }> {
83
- const shareId = params.shareId ?? newShareId();
84
- const sharedAt = params.sharedAt ?? Date.now();
72
+ const sharedAt = Date.now();
73
+ const snapshots = await snapshotEnvelopeProjects(tx, sources, sharedAt);
85
74
 
86
- // Snapshot (fresh) or re-attach (carry) each project, collecting its metadata for the pack.
87
75
  const projects: Record<ProjectFieldUuid, EnvelopeProject> = {};
88
- const snapshots: { uuid: ProjectFieldUuid; ref: ResourceRef | SignedResourceId }[] = [];
89
- for (const src of sources) {
90
- const uuid = randomUUID() as ProjectFieldUuid;
91
- if (src.kind === "fresh") {
92
- const meta = await tx.getKValueJson<ProjectMeta>(src.sourceRid, ProjectMetaKey);
93
- const ref = await duplicateProject(tx, src.sourceRid, { label: meta.label });
94
- projects[uuid] = { label: meta.label, source: src.projectId, updatedAt: sharedAt }; // (re)snapshotted now
95
- snapshots.push({ uuid, ref });
96
- } else {
97
- // Re-attach the prior snapshot; the new envelope references it before the old one is
98
- // detached in the same tx, so it stays alive. Label and timestamp carry unchanged.
99
- projects[uuid] = { label: src.label, source: src.projectId, updatedAt: src.updatedAt };
100
- snapshots.push({ uuid, ref: src.snapshotRid });
101
- }
102
- }
76
+ for (const { uuid, project } of snapshots) projects[uuid] = project;
103
77
 
104
78
  const data: EnvelopeData = {
105
79
  schemaVersion: EnvelopeSchemaVersionCurrent,
106
- shareId,
80
+ shareId: newShareId(),
107
81
  sharedAt,
108
82
  expiresAt: params.expiresAt,
109
83
  mode: params.mode,
@@ -112,20 +86,7 @@ export async function buildShareEnvelope(
112
86
  payload: { kind: "projects", projects },
113
87
  };
114
88
 
115
- // Immutable data set once at creation, never altered.
116
- const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
117
-
118
- // Attach the project snapshots as Input fields, then seal the input set one-way.
119
- for (const { uuid, ref } of snapshots) {
120
- tx.createField(field(envelope, envelopeProjectField(uuid)), "Input", ref);
121
- }
122
- tx.lockInputs(envelope);
123
-
124
- // Attach to the outbox under {shareId} in the same transaction so the held-resource rule
125
- // keeps the ephemeral envelope alive.
126
- tx.createField(field(outboxRid, shareId), "Dynamic", envelope);
127
-
128
- return { envelope, data };
89
+ return { envelope: sealEnvelope(tx, outboxRid, data, snapshots), data };
129
90
  }
130
91
 
131
92
  /**
@@ -142,21 +103,23 @@ export async function buildShareEnvelope(
142
103
  export function buildTemplateShareEnvelope(
143
104
  tx: PlTransaction,
144
105
  outboxRid: SignedResourceId,
145
- template: { document: ProjectTemplateV1; label: string },
106
+ template: {
107
+ document: ProjectTemplateV1;
108
+ label: string;
109
+ description?: string;
110
+ source: TemplateId;
111
+ },
146
112
  params: {
147
113
  sender: string;
148
114
  title: string;
149
115
  /** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
150
116
  expiresAt: number | null;
151
- /** Existing shareId for a change; a fresh one is minted when omitted. */
152
- shareId?: ShareId;
153
- sharedAt?: number;
154
117
  },
155
118
  ): { envelope: ResourceRef; data: EnvelopeData } {
156
119
  const data: EnvelopeData = {
157
120
  schemaVersion: EnvelopeSchemaVersionCurrent,
158
- shareId: params.shareId ?? newShareId(),
159
- sharedAt: params.sharedAt ?? Date.now(),
121
+ shareId: newShareId(),
122
+ sharedAt: Date.now(),
160
123
  expiresAt: params.expiresAt,
161
124
  mode: "read-only",
162
125
  sender: params.sender,
@@ -164,105 +127,215 @@ export function buildTemplateShareEnvelope(
164
127
  payload: {
165
128
  kind: "template",
166
129
  document: template.document,
130
+ source: template.source,
167
131
  label: template.label,
132
+ ...(template.description === undefined ? {} : { description: template.description }),
168
133
  from: params.sender,
169
134
  },
170
135
  };
171
136
 
172
- // Immutable data set once at creation, never altered.
173
- const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
174
-
175
- // Attach to the outbox under {shareId} in the same transaction so the held-resource rule
176
- // keeps the ephemeral envelope alive.
177
- tx.createField(field(outboxRid, data.shareId), "Dynamic", envelope);
178
-
179
- return { envelope, data };
137
+ return { envelope: sealEnvelope(tx, outboxRid, data, []), data };
180
138
  }
181
139
 
182
140
  /**
183
- * Records a response onto the envelope as a dynamic `acceptance/{login}` field: the acceptor
184
- * writing their own decision (their writable grant permits it), or the donor transferring an
185
- * existing record onto a changed envelope. Accepts the envelope by ref or id.
141
+ * Builds one {@link SharedEnvelopeResourceType} carrying a folder subtree: the folders, every
142
+ * project in them snapshotted as a `project/{uuid}` field, and every template's document inline.
143
+ * Attaches it under `{shareId}` on the donor's outbox. The caller issues the grant — writable,
144
+ * because the recipient copies the project snapshots out — and commits.
145
+ *
146
+ * Folder ids are minted here and mean nothing outside this envelope: the recipient's own document
147
+ * mints its own. What travels is the shape of the subtree.
148
+ *
149
+ * @returns the new envelope resource and the generated `EnvelopeData`.
186
150
  */
187
- export function writeEnvelopeAcceptance(
151
+ export async function buildFolderShareEnvelope(
188
152
  tx: PlTransaction,
189
- envelopeRid: ResourceRef | SignedResourceId,
190
- login: string,
191
- action: EnvelopeAcceptance["action"],
192
- timestamp: number,
193
- ): void {
194
- const acceptance: EnvelopeAcceptance = { action, timestamp };
195
- const value = tx.createJsonValue(acceptance);
196
- tx.createField(field(envelopeRid, acceptanceField(login)), "Dynamic", value);
153
+ outboxRid: SignedResourceId,
154
+ subtree: EnvelopeFolderSubtree,
155
+ params: {
156
+ sender: string;
157
+ title: string;
158
+ /** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
159
+ expiresAt: number | null;
160
+ },
161
+ ): Promise<{ envelope: ResourceRef; data: EnvelopeData }> {
162
+ const sharedAt = Date.now();
163
+ const snapshots = await snapshotEnvelopeProjects(tx, subtree.projects, sharedAt);
164
+
165
+ const projects: Record<ProjectFieldUuid, EnvelopeFolderProject> = {};
166
+ for (const { uuid, project, source } of snapshots)
167
+ projects[uuid] = { ...project, folder: source.folder };
168
+
169
+ const data: EnvelopeData = {
170
+ schemaVersion: EnvelopeSchemaVersionCurrent,
171
+ shareId: newShareId(),
172
+ sharedAt,
173
+ expiresAt: params.expiresAt,
174
+ mode: "copy",
175
+ sender: params.sender,
176
+ title: params.title,
177
+ payload: {
178
+ kind: "folder",
179
+ source: subtree.source,
180
+ folders: subtree.folders,
181
+ projects,
182
+ templates: subtree.templates,
183
+ from: params.sender,
184
+ },
185
+ };
186
+
187
+ return { envelope: sealEnvelope(tx, outboxRid, data, snapshots), data };
188
+ }
189
+
190
+ /** A folder subtree ready to be sealed into an envelope: the folders under their envelope-local
191
+ * ids, the live projects to snapshot, and the templates to carry whole. */
192
+ export interface EnvelopeFolderSubtree {
193
+ /** Donor's own id of the shared folder, carried so a later share of it finds this one. */
194
+ source: FolderId;
195
+ folders: Record<EnvelopeFolderId, EnvelopeFolder>;
196
+ projects: (EnvelopeProjectSource & { folder: EnvelopeFolderId })[];
197
+ templates: EnvelopeFolderTemplate[];
197
198
  }
198
199
 
199
200
  //
200
- // Acceptor side
201
+ // Recipient side
201
202
  //
202
203
 
203
204
  /**
204
- * Records the acceptor's decision for a handled share on the acceptor's SharingState as a
205
- * dynamic `decision/{shareId}` field. Keyed on the logical shareId so discovery dedups on the
206
- * share, not on the envelope instance.
205
+ * Puts a share out of this recipient's sight, as a dynamic field on their own SharingState.
206
+ * Keyed on the shareId, so a share that replaces this one — a new shareId — is shown again.
207
207
  */
208
- export function writeSharingDecision(
208
+ export function writeShareHidden(
209
+ tx: PlTransaction,
210
+ stateRid: SignedResourceId,
211
+ shareId: ShareId,
212
+ timestamp: number,
213
+ ): void {
214
+ const value = tx.createJsonValue({ hidden: true, timestamp } satisfies ShareHidden);
215
+ tx.createField(field(stateRid, hiddenField(shareId)), "Dynamic", value);
216
+ }
217
+
218
+ /** Brings a hidden share back into this recipient's list. */
219
+ export function clearShareHidden(
209
220
  tx: PlTransaction,
210
221
  stateRid: SignedResourceId,
211
222
  shareId: ShareId,
212
- decision: SharingDecision,
213
223
  ): void {
214
- const value = tx.createJsonValue(decision);
215
- tx.createField(field(stateRid, decisionField(shareId)), "Dynamic", value);
224
+ tx.removeField(field(stateRid, hiddenField(shareId)));
216
225
  }
217
226
 
218
227
  /**
219
- * Copies every project snapshot inside an envelope into the acceptor's own project list — a
220
- * cross-color attach the backend permits. Mirrors {@link duplicateProject}'s `rename` contract,
221
- * but resolves the source against the envelope tree (not the acceptor's own list), so the
222
- * wrapper cannot be reused.
228
+ * Copies every project snapshot inside an envelope into the recipient's own project list — a
229
+ * cross-color attach the backend permits. The source is resolved against the envelope tree, not
230
+ * the recipient's own list.
231
+ *
232
+ * `label` names each copy from its snapshot's label. The caller picks the names against the
233
+ * destination the copies land in, because that is where they have to be free; left out, each copy
234
+ * keeps its snapshot's label.
223
235
  *
224
- * @returns ids of the projects created in the acceptor's list.
236
+ * Each result carries the envelope field uuid it was copied from, because that uuid is what the
237
+ * payload names a project by — a folder share reads the folder of each copy off it. Pairing by
238
+ * position would happen to work today and quietly stop working the moment the order does.
239
+ *
240
+ * @returns one entry per copied project: the envelope uuid it came from and its new id.
225
241
  */
226
242
  export async function copyEnvelopeProjectsIntoList(
227
243
  tx: PlTransaction,
228
244
  envelopeRid: SignedResourceId,
229
245
  projectListRid: SignedResourceId,
230
- rename?: (previousLabel: string, existingLabels: string[]) => string,
231
- ): Promise<SignedResourceId[]> {
232
- // Read the acceptor's existing project labels once (own color, no relaxation).
233
- const projectListData = await tx.getResourceData(projectListRid, true);
234
- const existingRids = projectListData.fields.map((f) => f.value).filter(isNotNullSignedResourceId);
235
- const existingLabels = (
236
- await Promise.all(existingRids.map((rid) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)))
237
- ).map((m) => m.label);
238
-
239
- // Enumerate the envelope's project/{uuid} input field values (signed envelope-colored ids).
246
+ label?: (sourceLabel: string) => string,
247
+ ): Promise<{ uuid: ProjectFieldUuid; rid: SignedResourceId }[]> {
248
+ // Enumerate the envelope's project/{uuid} input fields (signed envelope-colored ids).
240
249
  const envelopeData = await tx.getResourceData(envelopeRid, true);
241
- const sourceRids = envelopeData.fields
250
+ const sources = envelopeData.fields
242
251
  .filter((f) => isEnvelopeProjectField(f.name))
243
- .map((f) => f.value)
244
- .filter(isNotNullSignedResourceId);
252
+ .flatMap((f) =>
253
+ isNotNullSignedResourceId(f.value)
254
+ ? [{ uuid: envelopeProjectFieldUuid(f.name), sourceRid: f.value }]
255
+ : [],
256
+ );
245
257
 
246
- const created: SignedResourceId[] = [];
247
- for (const sourceRid of sourceRids) {
258
+ const created: { uuid: ProjectFieldUuid; rid: SignedResourceId }[] = [];
259
+ for (const { uuid, sourceRid } of sources) {
248
260
  const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
249
- const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
250
- existingLabels.push(newLabel);
261
+ const newLabel = label === undefined ? sourceMeta.label : label(sourceMeta.label);
251
262
 
252
- // Cross-color attach: a new UserProject in the acceptor's color whose fields point at
263
+ // Cross-color attach: a new UserProject in the recipient's color whose fields point at
253
264
  // envelope-colored resources. Fails with PermissionDenied: color mismatch on a backend
254
265
  // that lacks crossTreeRefs:v1.
255
- const newPrj = await duplicateProject(tx, sourceRid, { label: newLabel });
266
+ const newPrj = await duplicateProject(tx, sourceRid, { ...sourceMeta, label: newLabel });
256
267
  tx.createField(field(projectListRid, randomUUID()), "Dynamic", newPrj);
257
268
 
258
- const signedRid = await newPrj.globalId;
259
- created.push(signedRid);
269
+ created.push({ uuid, rid: await newPrj.globalId });
260
270
  }
261
271
 
262
272
  return created;
263
273
  }
264
274
 
265
- /** String-form ids of the projects created by {@link copyEnvelopeProjectsIntoList}. */
266
- export function resourceIdsToStrings(ids: SignedResourceId[]): string[] {
267
- return ids.map((id) => resourceIdToString(id));
275
+ //
276
+ // Internals
277
+ //
278
+
279
+ /** One source project snapshotted for an envelope: the field uuid it rides under, the snapshot
280
+ * itself, and what the payload says about it. */
281
+ type EnvelopeSnapshot<Source extends EnvelopeProjectSource> = {
282
+ uuid: ProjectFieldUuid;
283
+ ref: ResourceRef;
284
+ source: Source;
285
+ project: EnvelopeProject;
286
+ };
287
+
288
+ /**
289
+ * Snapshots each source project by reference, in the given order, under a freshly minted field
290
+ * uuid. The payload entry carries the project's label and description as they were at `sharedAt`,
291
+ * so a share list renders without traversing into the snapshot.
292
+ */
293
+ async function snapshotEnvelopeProjects<Source extends EnvelopeProjectSource>(
294
+ tx: PlTransaction,
295
+ sources: readonly Source[],
296
+ sharedAt: number,
297
+ ): Promise<EnvelopeSnapshot<Source>[]> {
298
+ const snapshots: EnvelopeSnapshot<Source>[] = [];
299
+ for (const source of sources) {
300
+ const meta = await tx.getKValueJson<ProjectMeta>(source.sourceRid, ProjectMetaKey);
301
+ const ref = await duplicateProject(tx, source.sourceRid, meta);
302
+ const description = normalizeDescription(meta.description);
303
+ snapshots.push({
304
+ uuid: newProjectFieldUuid(),
305
+ ref,
306
+ source,
307
+ project: {
308
+ label: meta.label,
309
+ source: source.projectId,
310
+ updatedAt: sharedAt,
311
+ ...(description === undefined ? {} : { description }),
312
+ },
313
+ });
314
+ }
315
+ return snapshots;
316
+ }
317
+
318
+ /**
319
+ * Creates the envelope with its immutable `data`, attaches each snapshot as a `project/{uuid}`
320
+ * input field, and hangs the envelope on the donor's outbox under its shareId.
321
+ *
322
+ * A payload that carries project snapshots has its input set sealed one-way, even when the
323
+ * subtree it describes holds none, because such an envelope is granted writable. A template
324
+ * payload has no input set to seal. The outbox attach happens in the same transaction, so the
325
+ * held-resource rule keeps the ephemeral envelope alive.
326
+ */
327
+ function sealEnvelope(
328
+ tx: PlTransaction,
329
+ outboxRid: SignedResourceId,
330
+ data: EnvelopeData,
331
+ snapshots: readonly { uuid: ProjectFieldUuid; ref: ResourceRef }[],
332
+ ): ResourceRef {
333
+ const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
334
+
335
+ for (const { uuid, ref } of snapshots)
336
+ tx.createField(field(envelope, envelopeProjectField(uuid)), "Input", ref);
337
+ if (data.payload.kind !== "template") tx.lockInputs(envelope);
338
+
339
+ tx.createField(field(outboxRid, data.shareId), "Dynamic", envelope);
340
+ return envelope;
268
341
  }
@@ -1,12 +1,20 @@
1
- import type { PlTransaction, ResourceRef, SignedResourceId } from "@milaboratories/pl-client";
2
- import { field, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
1
+ import type {
2
+ AnyResourceRef,
3
+ PlTransaction,
4
+ ResourceRef,
5
+ SignedResourceId,
6
+ } from "@milaboratories/pl-client";
7
+ import { field } from "@milaboratories/pl-client";
8
+ import { normalizeDescription } from "@milaboratories/pl-model-middle-layer";
3
9
  import { randomUUID } from "node:crypto";
4
10
  import type { StoredTemplateData, TemplateId } from "../middle_layer/template_list";
5
11
  import {
6
12
  TemplateCreatedTimestamp,
13
+ TemplateDescriptionKey,
7
14
  TemplateLabelKey,
8
15
  TemplateResourceType,
9
16
  } from "../middle_layer/template_list";
17
+ import { listedById, notListedError } from "./list";
10
18
 
11
19
  /**
12
20
  * Creates one `UserTemplate` inside the given write transaction and attaches it to the
@@ -15,21 +23,26 @@ import {
15
23
  * Create and attach are the same transaction on purpose: an ephemeral resource nothing
16
24
  * holds is collectable, so the list field is what keeps the template alive.
17
25
  *
18
- * The document rides in the immutable `data` blob, set once here and never altered; only
19
- * the label and the creation timestamp go to KV, and only the label is ever written again.
26
+ * The document rides in the immutable `data` blob, set once here and never altered. The label,
27
+ * the creation timestamp and the description live in KV beside it; the label and the description
28
+ * may be written again later, the timestamp never is. A blank description stores nothing, the
29
+ * same as {@link setTemplateDescription} does.
20
30
  *
21
31
  * @returns the new template resource; the caller reads its `globalId` after the commit.
22
32
  */
23
33
  export function createTemplate(
24
34
  tx: PlTransaction,
25
35
  listRid: SignedResourceId,
26
- label: string,
36
+ meta: { readonly label: string; readonly description?: string },
27
37
  data: StoredTemplateData,
28
38
  ): ResourceRef {
29
39
  const tpl = tx.createEphemeral(TemplateResourceType, JSON.stringify(data));
30
40
  tx.lock(tpl);
31
- tx.setKValue(tpl, TemplateLabelKey, JSON.stringify(label));
41
+ tx.setKValue(tpl, TemplateLabelKey, JSON.stringify(meta.label));
32
42
  tx.setKValue(tpl, TemplateCreatedTimestamp, String(Date.now()));
43
+ const description = normalizeDescription(meta.description);
44
+ if (description !== undefined)
45
+ tx.setKValue(tpl, TemplateDescriptionKey, JSON.stringify(description));
33
46
  tx.createField(field(listRid, randomUUID()), "Dynamic", tpl);
34
47
  return tpl;
35
48
  }
@@ -40,36 +53,31 @@ export function renameTemplate(tx: PlTransaction, rid: SignedResourceId, label:
40
53
  tx.setKValue(rid, TemplateLabelKey, JSON.stringify(label));
41
54
  }
42
55
 
56
+ /**
57
+ * Sets what a stored template says about itself. Blank removes the entry rather than storing an
58
+ * empty one, so a template that was never described and one whose description was cleared are
59
+ * stored the same way.
60
+ */
61
+ export function setTemplateDescription(
62
+ tx: PlTransaction,
63
+ rid: AnyResourceRef,
64
+ description: string,
65
+ ): void {
66
+ const wanted = normalizeDescription(description);
67
+ if (wanted === undefined) tx.deleteKValue(rid, TemplateDescriptionKey);
68
+ else tx.setKValue(rid, TemplateDescriptionKey, JSON.stringify(wanted));
69
+ }
70
+
43
71
  /**
44
72
  * Detaches a template from the templates list, which is what destroys it — the list field is
45
73
  * the only thing holding the ephemeral resource.
46
- *
47
- * The field name is a uuid unrelated to the template id, so the field carrying the template
48
- * is found by value, the same way a project is removed from the project list.
49
74
  */
50
75
  export async function deleteTemplate(
51
76
  tx: PlTransaction,
52
77
  listRid: SignedResourceId,
53
78
  id: TemplateId,
54
79
  ): Promise<void> {
55
- const fieldName = await findTemplateField(tx, listRid, id);
56
- if (fieldName === undefined) throw new Error(`Template ${id} not found in template list.`);
57
- tx.removeField(field(listRid, fieldName));
58
- }
59
-
60
- //
61
- // Internals
62
- //
63
-
64
- async function findTemplateField(
65
- tx: PlTransaction,
66
- listRid: SignedResourceId,
67
- id: TemplateId,
68
- ): Promise<string | undefined> {
69
- const data = await tx.getResourceData(listRid, true);
70
- for (const f of data.fields) {
71
- if (isNullSignedResourceId(f.value)) continue;
72
- if (resourceIdToString(f.value) === (id as string)) return f.name;
73
- }
74
- return undefined;
80
+ const entry = (await listedById(tx, listRid)).get(id);
81
+ if (entry === undefined) throw notListedError("Template", id);
82
+ tx.removeField(field(listRid, entry.fieldName));
75
83
  }
@@ -2,6 +2,7 @@ import path from "path";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import type { PlClient } from "@milaboratories/pl-client";
4
4
  import { TestHelpers } from "@milaboratories/pl-client";
5
+ import { afterAll } from "vitest";
5
6
  import { MiddleLayer } from "../middle_layer/middle_layer";
6
7
 
7
8
  /**
@@ -14,25 +15,81 @@ import { MiddleLayer } from "../middle_layer/middle_layer";
14
15
  export async function withMl(
15
16
  cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
16
17
  ): Promise<void> {
17
- const workFolder = path.resolve(`work/${randomUUID()}`);
18
-
19
18
  await TestHelpers.withTempRoot(async (pl: PlClient) => {
20
- const ml = await MiddleLayer.init(pl, workFolder, {
21
- defaultTreeOptions: { pollingInterval: 250, stopPollingDelay: 500 },
22
- devBlockUpdateRecheckInterval: 300,
23
- localSecret: MiddleLayer.generateLocalSecret(),
24
- localProjections: [],
25
- openFileDialogCallback: () => {
26
- throw new Error("Not implemented.");
27
- },
28
- });
29
- ml.addRuntimeCapability("requiresUIAPIVersion", 1);
30
- ml.addRuntimeCapability("requiresUIAPIVersion", 2);
31
- ml.addRuntimeCapability("requiresUIAPIVersion", 3);
19
+ await runMl(pl, cb);
20
+ });
21
+ }
22
+
23
+ /**
24
+ * A {@link withMl} for one test file whose temporary roots all outlive their tests and are deleted
25
+ * together once the file is done. Call it at the top of the file; each test still gets a root of
26
+ * its own.
27
+ *
28
+ * Deleting a root deletes every project in it, and the backend then cleans up each of their
29
+ * templates one transaction at a time. That cleanup conflicts with any project being created
30
+ * meanwhile, anywhere, so a file that deleted its roots test by test kept failing its own next
31
+ * test's writes. Deferred, the cleanup happens once, after the file's last test.
32
+ */
33
+ export function withMlKeepingRoots(): (
34
+ cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
35
+ ) => Promise<void> {
36
+ const roots: string[] = [];
37
+
38
+ afterAll(async () => {
39
+ const owner = await TestHelpers.getTestClient();
32
40
  try {
33
- await cb(ml, workFolder);
41
+ for (const root of roots) await owner.deleteAlternativeRoot(root);
34
42
  } finally {
35
- await ml.close();
43
+ await owner.close();
36
44
  }
37
45
  });
46
+
47
+ return async (cb) => {
48
+ const root = `test_${Date.now()}_${randomUUID()}`;
49
+ roots.push(root);
50
+ await runMl(await TestHelpers.getTestClient(root), cb);
51
+ };
52
+ }
53
+
54
+ /**
55
+ * A live {@link MiddleLayer} over the test user's own root, on a client of its own, closed again
56
+ * when the body returns — what a test restarting the middle layer opens once per run.
57
+ *
58
+ * The user's own root rather than a temporary one: a client asking for a temporary root by name
59
+ * gets a fresh, empty one every time, so a second run would start from nothing. What a test
60
+ * leaves in this root outlives it, so the test cleans up after itself. The work folder is fresh on
61
+ * every call, so nothing local carries over from one run to the next.
62
+ */
63
+ export async function withMlOnUserRoot(
64
+ cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
65
+ ): Promise<void> {
66
+ await runMl(await TestHelpers.getTestClient(), cb);
67
+ }
68
+
69
+ //
70
+ // Internals
71
+ //
72
+
73
+ async function runMl(
74
+ pl: PlClient,
75
+ cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
76
+ ): Promise<void> {
77
+ const workFolder = path.resolve(`work/${randomUUID()}`);
78
+ const ml = await MiddleLayer.init(pl, workFolder, {
79
+ defaultTreeOptions: { pollingInterval: 250, stopPollingDelay: 500 },
80
+ devBlockUpdateRecheckInterval: 300,
81
+ localSecret: MiddleLayer.generateLocalSecret(),
82
+ localProjections: [],
83
+ openFileDialogCallback: () => {
84
+ throw new Error("Not implemented.");
85
+ },
86
+ });
87
+ ml.addRuntimeCapability("requiresUIAPIVersion", 1);
88
+ ml.addRuntimeCapability("requiresUIAPIVersion", 2);
89
+ ml.addRuntimeCapability("requiresUIAPIVersion", 3);
90
+ try {
91
+ await cb(ml, workFolder);
92
+ } finally {
93
+ await ml.close();
94
+ }
38
95
  }