@milaboratories/pl-middle-layer 1.71.15 → 1.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/index.cjs +10 -5
  2. package/dist/index.d.ts +5 -4
  3. package/dist/index.js +3 -2
  4. package/dist/middle_layer/build_stamp.cjs +1 -1
  5. package/dist/middle_layer/build_stamp.js +1 -1
  6. package/dist/middle_layer/folders.cjs +631 -0
  7. package/dist/middle_layer/folders.cjs.map +1 -0
  8. package/dist/middle_layer/folders.d.ts +39 -0
  9. package/dist/middle_layer/folders.d.ts.map +1 -0
  10. package/dist/middle_layer/folders.js +613 -0
  11. package/dist/middle_layer/folders.js.map +1 -0
  12. package/dist/middle_layer/index.cjs +3 -0
  13. package/dist/middle_layer/index.d.ts +4 -3
  14. package/dist/middle_layer/index.js +2 -1
  15. package/dist/middle_layer/middle_layer.cjs +585 -370
  16. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  17. package/dist/middle_layer/middle_layer.d.ts +215 -122
  18. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  19. package/dist/middle_layer/middle_layer.js +593 -378
  20. package/dist/middle_layer/middle_layer.js.map +1 -1
  21. package/dist/middle_layer/project_list.cjs +36 -19
  22. package/dist/middle_layer/project_list.cjs.map +1 -1
  23. package/dist/middle_layer/project_list.d.ts +1 -1
  24. package/dist/middle_layer/project_list.d.ts.map +1 -1
  25. package/dist/middle_layer/project_list.js +37 -21
  26. package/dist/middle_layer/project_list.js.map +1 -1
  27. package/dist/middle_layer/sharing_list.cjs +123 -93
  28. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  29. package/dist/middle_layer/sharing_list.d.ts +43 -19
  30. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  31. package/dist/middle_layer/sharing_list.js +123 -93
  32. package/dist/middle_layer/sharing_list.js.map +1 -1
  33. package/dist/middle_layer/template_list.cjs +45 -20
  34. package/dist/middle_layer/template_list.cjs.map +1 -1
  35. package/dist/middle_layer/template_list.d.ts +7 -15
  36. package/dist/middle_layer/template_list.d.ts.map +1 -1
  37. package/dist/middle_layer/template_list.js +44 -21
  38. package/dist/middle_layer/template_list.js.map +1 -1
  39. package/dist/model/index.cjs +7 -5
  40. package/dist/model/index.d.ts +2 -2
  41. package/dist/model/index.js +2 -2
  42. package/dist/model/sharing_model.cjs +46 -20
  43. package/dist/model/sharing_model.cjs.map +1 -1
  44. package/dist/model/sharing_model.d.ts +125 -55
  45. package/dist/model/sharing_model.d.ts.map +1 -1
  46. package/dist/model/sharing_model.js +40 -16
  47. package/dist/model/sharing_model.js.map +1 -1
  48. package/dist/mutator/list.cjs +30 -0
  49. package/dist/mutator/list.cjs.map +1 -0
  50. package/dist/mutator/list.js +29 -0
  51. package/dist/mutator/list.js.map +1 -0
  52. package/dist/mutator/project.cjs +14 -1
  53. package/dist/mutator/project.cjs.map +1 -1
  54. package/dist/mutator/project.d.ts.map +1 -1
  55. package/dist/mutator/project.js +15 -2
  56. package/dist/mutator/project.js.map +1 -1
  57. package/dist/mutator/sharing.cjs +130 -76
  58. package/dist/mutator/sharing.cjs.map +1 -1
  59. package/dist/mutator/sharing.js +130 -76
  60. package/dist/mutator/sharing.js.map +1 -1
  61. package/dist/mutator/template.cjs +24 -17
  62. package/dist/mutator/template.cjs.map +1 -1
  63. package/dist/mutator/template.js +26 -20
  64. package/dist/mutator/template.js.map +1 -1
  65. package/package.json +18 -18
  66. package/src/middle_layer/folders.test.ts +1068 -0
  67. package/src/middle_layer/folders.ts +1059 -0
  68. package/src/middle_layer/folders_read.test.ts +359 -0
  69. package/src/middle_layer/index.ts +3 -2
  70. package/src/middle_layer/middle_layer.ts +813 -547
  71. package/src/middle_layer/project_list.test.ts +183 -0
  72. package/src/middle_layer/project_list.ts +58 -21
  73. package/src/middle_layer/sharing.test.ts +183 -0
  74. package/src/middle_layer/sharing_list.ts +208 -152
  75. package/src/middle_layer/template_list.test.ts +148 -0
  76. package/src/middle_layer/template_list.ts +72 -32
  77. package/src/middle_layer/templates.test.ts +76 -45
  78. package/src/model/sharing_model.test.ts +69 -2
  79. package/src/model/sharing_model.ts +161 -73
  80. package/src/mutator/list.ts +36 -0
  81. package/src/mutator/project-v3.test.ts +3 -1
  82. package/src/mutator/project.ts +13 -2
  83. package/src/mutator/sharing.ts +193 -120
  84. package/src/mutator/template.ts +37 -29
  85. package/src/test/with_ml.ts +73 -16
@@ -2,9 +2,11 @@ import { V2RegistryProvider } from "../block_registry/registry-v2-provider.js";
2
2
  import "../block_registry/index.js";
3
3
  import { ProjectMetaKey } from "../model/project_model.js";
4
4
  import { ProjectsField, ProjectsResourceType, createProjectList, ensureProjectListRid } from "./project_list.js";
5
- import { TemplateLabelKey, TemplatesField, TemplatesResourceType, createTemplateList, decodeStoredTemplateData } from "./template_list.js";
6
- import { createTemplate, deleteTemplate, renameTemplate } from "../mutator/template.js";
7
- import { SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, acceptanceFieldLogin, canGrantToEveryone, canImpersonate, decodeEnvelopeData, envelopeProjectMap, isAcceptanceField } from "../model/sharing_model.js";
5
+ import { TemplateDescriptionKey, TemplateLabelKey, TemplatesField, TemplatesResourceType, createTemplateList, decodeStoredTemplateData } from "./template_list.js";
6
+ import { listedById, notListedError } from "../mutator/list.js";
7
+ import { createTemplate, deleteTemplate, renameTemplate, setTemplateDescription } from "../mutator/template.js";
8
+ import { FoldersField, FoldersResourceType, createFolder, createFolderList, deleteFolder, foldersLocalSubtree, moveFolderItems, openFoldersTx, previewFolderDeletion, previewFoldersMove, renameFolder, resetFolders, setFolderDescription } from "./folders.js";
9
+ import { SharingOutboxField, SharingOutboxResourceType, SharingStateField, SharingStateResourceType, canGrantToEveryone, canImpersonate, decodeEnvelopeData, envelopeFolderRoot, newEnvelopeFolderId } from "../model/sharing_model.js";
8
10
  import { resolveTemplateEntries } from "../model/template_resolve.js";
9
11
  import { TemplateEntryRejected, kindMismatch } from "../model/template_apply.js";
10
12
  import { BlockPackPreparer } from "../mutator/block-pack/block_pack.js";
@@ -13,8 +15,8 @@ import { createProject, duplicateProject, withProject, withProjectAuthored } fro
13
15
  import { applyTemplateEntries } from "../mutator/template_construct.js";
14
16
  import { cacheBlockPackTemplate } from "../mutator/template/template_cache.js";
15
17
  import { Project, throwIfMissingServerCapabilities } from "./project.js";
16
- import { buildShareEnvelope, buildTemplateShareEnvelope, copyEnvelopeProjectsIntoList, envelopeProjectFieldUuid, isEnvelopeProjectField, resourceIdsToStrings, writeEnvelopeAcceptance, writeSharingDecision } from "../mutator/sharing.js";
17
- import { createLiveEnvelopesComputable, createOutgoingShares, createPendingSharesComputable, createPendingSharesTree, createSharingStateTree } from "./sharing_list.js";
18
+ import { buildFolderShareEnvelope, buildShareEnvelope, buildTemplateShareEnvelope, clearShareHidden, copyEnvelopeProjectsIntoList, writeShareHidden } from "../mutator/sharing.js";
19
+ import { createAvailableSharesComputable, createAvailableSharesTree, createLiveEnvelopesComputable, createOutgoingShares, createSharingStateTree } from "./sharing_list.js";
18
20
  import { DefaultMiddleLayerOpsPaths, DefaultMiddleLayerOpsSettings } from "./ops.js";
19
21
  import { BlockUpdateWatcher } from "../block_registry/watcher.js";
20
22
  import { initDriverKit } from "./driver_kit.js";
@@ -22,10 +24,11 @@ import { createModelServiceRegistry } from "../service_factories.js";
22
24
  import { ProjectHelper } from "../model/project_helper.js";
23
25
  import { TreeSnapshotStore } from "./tree_snapshot_store.js";
24
26
  import { RuntimeCapabilities, ensureError, extractConfig } from "@platforma-sdk/model";
27
+ import { foldersNameTaken, foldersUniqueName, inheritedFolder, normalizeDescription } from "@milaboratories/pl-model-middle-layer";
25
28
  import { RetryAgent } from "undici";
26
- import { BlockEventDispatcher, HmacSha256Signer, cachedDeserialize } from "@milaboratories/ts-helpers";
27
- import { REQUIRES_PFRAMES_VERSION, registerServiceCapabilities } from "@milaboratories/pl-model-common";
28
- import { GrantType, field, isEveryoneUserLogin, isNotNullSignedResourceId, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
29
+ import { BlockEventDispatcher, HmacSha256Signer } from "@milaboratories/ts-helpers";
30
+ import { REQUIRES_PFRAMES_VERSION, asProjectId, asTemplateId, registerServiceCapabilities } from "@milaboratories/pl-model-common";
31
+ import { GrantType, field, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
29
32
  import { LRUCache } from "lru-cache";
30
33
  import { WatchableValue } from "@milaboratories/computable";
31
34
  import { randomUUID } from "node:crypto";
@@ -55,20 +58,23 @@ var MiddleLayer = class MiddleLayer {
55
58
  templateListResourceId;
56
59
  sharingOutboxResourceId;
57
60
  sharingStateResourceId;
61
+ foldersResourceId;
58
62
  openedProjectsList;
59
63
  projectListTree;
60
64
  templateListTree;
65
+ foldersTree;
61
66
  sharingOutboxTree;
62
67
  sharingStateTree;
63
- pendingSharesTree;
68
+ availableSharesTree;
64
69
  blockRegistryProvider;
65
70
  projectList;
66
71
  templateList;
72
+ folders;
67
73
  outgoingShares;
68
- pendingShares;
74
+ availableShares;
69
75
  liveEnvelopes;
70
76
  pl;
71
- constructor(env, driverKit, signer, projectListResourceId, templateListResourceId, sharingOutboxResourceId, sharingStateResourceId, openedProjectsList, projectListTree, templateListTree, sharingOutboxTree, sharingStateTree, pendingSharesTree, blockRegistryProvider, projectList, templateList, outgoingShares, pendingShares, liveEnvelopes) {
77
+ constructor(env, driverKit, signer, projectListResourceId, templateListResourceId, sharingOutboxResourceId, sharingStateResourceId, foldersResourceId, openedProjectsList, projectListTree, templateListTree, foldersTree, sharingOutboxTree, sharingStateTree, availableSharesTree, blockRegistryProvider, projectList, templateList, folders, outgoingShares, availableShares, liveEnvelopes) {
72
78
  this.env = env;
73
79
  this.driverKit = driverKit;
74
80
  this.signer = signer;
@@ -76,17 +82,20 @@ var MiddleLayer = class MiddleLayer {
76
82
  this.templateListResourceId = templateListResourceId;
77
83
  this.sharingOutboxResourceId = sharingOutboxResourceId;
78
84
  this.sharingStateResourceId = sharingStateResourceId;
85
+ this.foldersResourceId = foldersResourceId;
79
86
  this.openedProjectsList = openedProjectsList;
80
87
  this.projectListTree = projectListTree;
81
88
  this.templateListTree = templateListTree;
89
+ this.foldersTree = foldersTree;
82
90
  this.sharingOutboxTree = sharingOutboxTree;
83
91
  this.sharingStateTree = sharingStateTree;
84
- this.pendingSharesTree = pendingSharesTree;
92
+ this.availableSharesTree = availableSharesTree;
85
93
  this.blockRegistryProvider = blockRegistryProvider;
86
94
  this.projectList = projectList;
87
95
  this.templateList = templateList;
96
+ this.folders = folders;
88
97
  this.outgoingShares = outgoingShares;
89
- this.pendingShares = pendingShares;
98
+ this.availableShares = availableShares;
90
99
  this.liveEnvelopes = liveEnvelopes;
91
100
  this.pl = this.env.pl;
92
101
  this.startEnvelopeCleanup();
@@ -117,7 +126,7 @@ var MiddleLayer = class MiddleLayer {
117
126
  /**
118
127
  * Whether the connected backend supports project sharing. Synthetic — computed
119
128
  * in the middle layer from the backend capabilities the share flow needs (the
120
- * cross-color field-reference relaxation the accept flow rests on). It can absorb
129
+ * cross-color field-reference relaxation a copy out of a share rests on). It can absorb
121
130
  * additional required capabilities later without a UI change.
122
131
  */
123
132
  get sharingSupported() {
@@ -176,38 +185,258 @@ var MiddleLayer = class MiddleLayer {
176
185
  const cached = this.projectIdCache.get(projectId);
177
186
  if (cached !== void 0) return cached;
178
187
  const rid = await this.pl.withReadTx("ResolveProjectId", async (tx) => {
179
- const data = await tx.getResourceData(this.projectListResourceId, true);
180
- for (const f of data.fields) {
181
- if (isNullSignedResourceId(f.value)) continue;
182
- if (resourceIdToString(f.value) === projectId) return f.value;
183
- }
184
- throw new Error(`Project ${projectId} not found in project list.`);
188
+ const entry = (await listedById(tx, this.projectListResourceId)).get(projectId);
189
+ if (entry === void 0) throw notListedError("Project", projectId);
190
+ return entry.rid;
185
191
  });
186
192
  this.projectIdCache.set(projectId, rid);
187
193
  return rid;
188
194
  }
189
- /** Creates a project with initial state and adds it to project list. */
190
- async createProject(meta) {
191
- let prj;
192
- await this.pl.withWriteTx("MLCreateProject", async (tx) => {
193
- prj = await createProject(tx, meta);
195
+ /**
196
+ * Creates a project with initial state and adds it to project list.
197
+ *
198
+ * `folder` is where it lands, placed in the transaction that creates it so the project never
199
+ * shows up at the top level first. Left out, or naming a folder that is gone, it lands at the
200
+ * top level.
201
+ */
202
+ async createProject(meta, folder) {
203
+ const signedRid = await this.pl.withWriteTx("MLCreateProject", async (tx) => {
204
+ const tree = folder === void 0 ? void 0 : await openFoldersTx(tx, this.foldersRids);
205
+ const prj = await createProject(tx, meta);
194
206
  tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", prj);
207
+ const rid = await prj.globalId;
208
+ if (tree !== void 0 && tree.view.folders.some((candidate) => candidate.id === folder)) tree.place([{
209
+ kind: "project",
210
+ id: asProjectId(resourceIdToString(rid))
211
+ }], folder);
195
212
  await tx.commit();
213
+ return rid;
196
214
  });
197
- await this.projectListTree.refreshState();
198
- const signedRid = await prj.globalId;
199
- const projectId = resourceIdToString(signedRid);
215
+ await Promise.all([this.projectListTree.refreshState(), ...folder === void 0 ? [] : [this.foldersTree.refreshState()]]);
216
+ const projectId = asProjectId(resourceIdToString(signedRid));
200
217
  this.projectIdCache.set(projectId, signedRid);
201
218
  return projectId;
202
219
  }
203
- /** Updates project metadata */
220
+ /**
221
+ * Updates the project metadata fields the caller names, leaving the others as they are.
222
+ *
223
+ * A patch rather than a replacement because the label and the description are edited from two
224
+ * different places: a rename that carried a stale description alongside the new name would
225
+ * undo a description edit that happened in between, and the reverse.
226
+ *
227
+ * The label is stored trimmed, and it goes into the namespace folders share, so a label
228
+ * already carried by something else inside the same folder is refused rather than written — a
229
+ * human typed it, and a name that silently becomes a different name is worse than one that is
230
+ * turned down. The check and the write share one transaction, so two renames racing for the
231
+ * same name cannot both win. Duplicates an account already holds are tolerated and never
232
+ * rewritten; see the name check of {@link openFoldersTx}.
233
+ */
204
234
  async setProjectMeta(id, meta, author) {
205
235
  const rid = await this.resolveProjectId(id);
206
- await withProjectAuthored(this.env.projectHelper, this.pl, rid, author, (prj) => {
207
- prj.setMeta(meta);
208
- }, { name: "setProjectMeta" });
236
+ const label = meta.label?.trim();
237
+ const patch = label === void 0 ? meta : {
238
+ ...meta,
239
+ label
240
+ };
241
+ await this.pl.withWriteTx("ProjectAction: setProjectMeta", async (tx) => {
242
+ if (label !== void 0) (await openFoldersTx(tx, this.foldersRids)).assertNameFree({
243
+ kind: "project",
244
+ id
245
+ }, label);
246
+ await withProjectAuthored(this.env.projectHelper, tx, rid, author, (prj) => {
247
+ prj.updateMeta(patch);
248
+ });
249
+ await tx.commit();
250
+ });
209
251
  await this.projectListTree.refreshState();
210
252
  }
253
+ get foldersRids() {
254
+ return {
255
+ folders: this.foldersResourceId,
256
+ projects: this.projectListResourceId,
257
+ templates: this.templateListResourceId
258
+ };
259
+ }
260
+ /** Creates a folder inside `parent`, or at the top level, and returns its id. A name already
261
+ * used there is rejected. */
262
+ async createFolder(name, parent) {
263
+ const id = await createFolder(this.pl, this.foldersRids, name, parent);
264
+ await this.foldersTree.refreshState();
265
+ return id;
266
+ }
267
+ /** Renames a folder. A name already used beside it is rejected. */
268
+ async renameFolder(folder, name) {
269
+ await renameFolder(this.pl, this.foldersRids, folder, name);
270
+ await this.foldersTree.refreshState();
271
+ }
272
+ /** Sets what a folder says about itself; blank clears it. Descriptions are in no namespace, so
273
+ * nothing is refused here. */
274
+ async setFolderDescription(folder, description) {
275
+ await setFolderDescription(this.pl, this.foldersRids, folder, description);
276
+ await this.foldersTree.refreshState();
277
+ }
278
+ /** The plan a move would produce — every item and the name it ends up with. Shown for
279
+ * confirmation, then handed back to {@link moveFolderItems} unchanged. */
280
+ async previewFoldersMove(items, destination) {
281
+ return await previewFoldersMove(this.pl, this.foldersRids, items, destination);
282
+ }
283
+ /**
284
+ * Moves folders, projects and templates into one destination.
285
+ *
286
+ * The plan is recomputed inside the write transaction and the move commits only when it is
287
+ * identical to `confirmedPlan`; otherwise nothing is written and the fresh plan comes back to
288
+ * be confirmed again.
289
+ */
290
+ async moveFolderItems(items, destination, confirmedPlan) {
291
+ const outcome = await moveFolderItems(this.pl, this.foldersRids, items, destination, confirmedPlan);
292
+ if (outcome.ok) await Promise.all([
293
+ this.foldersTree.refreshState(),
294
+ this.projectListTree.refreshState(),
295
+ this.templateListTree.refreshState()
296
+ ]);
297
+ return outcome;
298
+ }
299
+ /** What deleting a folder would destroy: the subtree of folders, and everything in it. */
300
+ async previewFolderDeletion(folder) {
301
+ return await previewFolderDeletion(this.pl, this.foldersRids, folder);
302
+ }
303
+ /** Deletes a folder, every folder inside it, and every project and template held anywhere in
304
+ * that subtree.
305
+ * Refused as `needs-confirmation` until the removal it returns is passed back as
306
+ * `confirmedRemoval`, and as `plan-changed` if the subtree has changed since. */
307
+ async deleteFolder(folder, confirmedRemoval) {
308
+ const outcome = await deleteFolder(this.pl, this.foldersRids, folder, confirmedRemoval);
309
+ if (outcome.ok) {
310
+ for (const id of outcome.removal.projects) this.projectIdCache.delete(id);
311
+ for (const id of outcome.removal.templates) this.templateIdCache.delete(id);
312
+ await Promise.all([
313
+ this.foldersTree.refreshState(),
314
+ this.projectListTree.refreshState(),
315
+ this.templateListTree.refreshState()
316
+ ]);
317
+ }
318
+ return outcome;
319
+ }
320
+ /**
321
+ * Replaces a folder document this build cannot read — one written by a newer version, or one
322
+ * nothing here can parse — with an empty one. Every folder is gone afterwards; every project and
323
+ * template stays and shows at the top level. Refused while the document can be read.
324
+ */
325
+ async resetFolders() {
326
+ await resetFolders(this.pl, this.foldersRids);
327
+ await this.foldersTree.refreshState();
328
+ }
329
+ /**
330
+ * Duplicates a folder and everything under it: the folders inside, a duplicate of every project
331
+ * in them, and a copy of every template.
332
+ *
333
+ * The copy lands beside the source, so only its root needs a name of its own — `X (Copy)`.
334
+ * Nothing inside is renamed: names are compared among siblings, and a copied folder's children
335
+ * are only ever compared with each other, where they came in distinct already.
336
+ *
337
+ * The subtree is read first and rebuilt in one write transaction, so the folders and everything
338
+ * they hold appear together or not at all.
339
+ */
340
+ async duplicateFolder(folder) {
341
+ const subtree = await this.loadFolderSubtree(folder, () => randomUUID());
342
+ const created = await this.pl.withWriteTx("MLDuplicateFolder", async (tx) => {
343
+ const tree = await openFoldersTx(tx, this.foldersRids);
344
+ const items = [];
345
+ const projects = [];
346
+ const templates = [];
347
+ for (const project of subtree.projects) {
348
+ const meta = await tx.getKValueJson(project.rid, ProjectMetaKey);
349
+ const copy = await duplicateProject(tx, project.rid, meta, this.env.projectHelper);
350
+ tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", copy);
351
+ const rid = await copy.globalId;
352
+ projects.push(rid);
353
+ items.push({
354
+ kind: "project",
355
+ id: asProjectId(resourceIdToString(rid)),
356
+ folder: project.folder
357
+ });
358
+ }
359
+ for (const template of subtree.templates) {
360
+ const rid = await createTemplate(tx, this.templateListResourceId, {
361
+ label: template.label,
362
+ description: template.description
363
+ }, template.data).globalId;
364
+ const id = asTemplateId(resourceIdToString(rid));
365
+ templates.push({
366
+ id,
367
+ rid
368
+ });
369
+ items.push({
370
+ kind: "template",
371
+ id,
372
+ folder: template.folder
373
+ });
374
+ }
375
+ tree.graft({
376
+ root: subtree.root,
377
+ folders: subtree.folders,
378
+ items
379
+ }, subtree.parent);
380
+ await tx.commit();
381
+ return {
382
+ projects,
383
+ templates
384
+ };
385
+ });
386
+ for (const rid of created.projects) this.projectIdCache.set(asProjectId(resourceIdToString(rid)), rid);
387
+ for (const { id, rid } of created.templates) this.templateIdCache.set(id, rid);
388
+ await Promise.all([
389
+ this.foldersTree.refreshState(),
390
+ this.projectListTree.refreshState(),
391
+ this.templateListTree.refreshState()
392
+ ]);
393
+ }
394
+ /**
395
+ * A folder subtree as a copy needs it: the folders under ids local to this read, the key the
396
+ * subtree's root goes by, the resource of every project in them, every template read whole, and
397
+ * the folder the source sits in — which is where a duplicate lands.
398
+ *
399
+ * Read in a transaction of its own, before the write that copies it: every template is a read,
400
+ * and none of it has to be atomic with the copying, because a project or a template that
401
+ * disappears meanwhile fails that write on its own.
402
+ */
403
+ async loadFolderSubtree(folder, mint) {
404
+ return await this.pl.withReadTx("MLReadFolderSubtree", async (tx) => {
405
+ const tree = await openFoldersTx(tx, this.foldersRids);
406
+ const source = tree.view.folders.find((candidate) => candidate.id === folder);
407
+ if (source === void 0) throw new Error(`Folder ${folder} does not exist.`);
408
+ const { inSubtree, localId, folders } = foldersLocalSubtree(tree.view, folder, mint);
409
+ const projects = [];
410
+ for (const project of tree.view.projects) {
411
+ if (project.folder === void 0 || !inSubtree.has(project.folder)) continue;
412
+ const rid = tree.projectRids.get(project.id);
413
+ if (rid === void 0) throw notListedError("Project", project.id);
414
+ projects.push({
415
+ projectId: project.id,
416
+ rid,
417
+ folder: localId(project.folder)
418
+ });
419
+ }
420
+ const templates = [];
421
+ for (const template of tree.view.templates) {
422
+ if (template.folder === void 0 || !inSubtree.has(template.folder)) continue;
423
+ const rid = tree.templateRids.get(template.id);
424
+ if (rid === void 0) throw notListedError("Template", template.id);
425
+ const local = localId(template.folder);
426
+ templates.push(readStoredTemplate(tx, template.id, rid).then((stored) => ({
427
+ ...stored,
428
+ folder: local
429
+ })));
430
+ }
431
+ return {
432
+ ...source.parent === void 0 ? {} : { parent: source.parent },
433
+ root: localId(folder),
434
+ folders,
435
+ projects,
436
+ templates: await Promise.all(templates)
437
+ };
438
+ });
439
+ }
211
440
  /**
212
441
  * Renders a project as a `template-v1` YAML document, or reports every reason it
213
442
  * cannot be — the backing call for an "Export Project as Template…" command.
@@ -380,41 +609,81 @@ var MiddleLayer = class MiddleLayer {
380
609
  * A block that cannot be expressed as a template entry stores nothing at all, and every
381
610
  * such block is reported — fixing an unexportable project takes one pass, not one per block.
382
611
  *
612
+ * The template lands beside its project, and is named by the rule everything there is named
613
+ * by: a label the caller chose is stored trimmed and refused if it is already used there, and
614
+ * without one the project's own label is taken — suffixed, since the project itself already
615
+ * answers to it.
616
+ *
617
+ * The template's description is the one the caller gives, stored trimmed; blank means none.
618
+ * Without one the template is stored undescribed, whatever the project's own description says.
619
+ *
383
620
  * @param projectId project to snapshot
384
- * @param label label for the template; defaults to the project's own label
621
+ * @param label label for the template; defaults to the project's own label, made free
622
+ * @param description what the template is for; blank or absent stores none
385
623
  */
386
- async saveProjectAsTemplate(projectId, label) {
624
+ async saveProjectAsTemplate(projectId, label, description) {
387
625
  const outcome = await this.exportProjectAsTemplate(projectId);
388
626
  if (!outcome.ok) return {
389
627
  ok: false,
390
628
  problems: outcome.problems
391
629
  };
392
630
  const rid = await this.resolveProjectId(projectId);
393
- let tpl;
394
- await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
631
+ const wanted = label?.trim();
632
+ const signedRid = await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
395
633
  const meta = await tx.getKValueJson(rid, ProjectMetaKey);
396
- tpl = createTemplate(tx, this.templateListResourceId, label ?? meta.label, {
634
+ const tree = await openFoldersTx(tx, this.foldersRids);
635
+ const taken = tree.namesTakenBeside(projectId);
636
+ if (wanted !== void 0 && foldersNameTaken(wanted, taken)) throw new Error(`"${wanted}" is already used here.`);
637
+ const name = wanted ?? foldersUniqueName(meta.label, [meta.label, ...taken]);
638
+ const created = await createTemplate(tx, this.templateListResourceId, {
639
+ label: name,
640
+ description
641
+ }, {
397
642
  schemaVersion: 1,
398
643
  document: outcome.document,
399
644
  sourceProjectLabel: meta.label
400
- });
645
+ }).globalId;
646
+ tree.place([{
647
+ kind: "template",
648
+ id: asTemplateId(resourceIdToString(created))
649
+ }], tree.folderOf(projectId));
401
650
  await tx.commit();
651
+ return created;
402
652
  });
403
- await this.templateListTree.refreshState();
404
- const signedRid = await tpl.globalId;
405
- const templateId = resourceIdToString(signedRid);
653
+ await Promise.all([this.templateListTree.refreshState(), this.foldersTree.refreshState()]);
654
+ const templateId = asTemplateId(resourceIdToString(signedRid));
406
655
  this.templateIdCache.set(templateId, signedRid);
407
656
  return {
408
657
  ok: true,
409
658
  templateId
410
659
  };
411
660
  }
412
- /** Changes a template's label. The stored document is immutable and stays untouched —
413
- * improving a template means saving a new one. */
661
+ /**
662
+ * Changes a template's label. The stored document is immutable and stays untouched —
663
+ * improving a template means saving a new one.
664
+ *
665
+ * The label is stored trimmed. It is in the namespace folders, projects and templates share,
666
+ * so one already used beside the template is refused, in the transaction that writes it.
667
+ */
414
668
  async renameTemplate(id, label) {
415
669
  const rid = await this.resolveTemplateId(id);
670
+ const wanted = label.trim();
416
671
  await this.pl.withWriteTx("MLRenameTemplate", async (tx) => {
417
- renameTemplate(tx, rid, label);
672
+ (await openFoldersTx(tx, this.foldersRids)).assertNameFree({
673
+ kind: "template",
674
+ id
675
+ }, wanted);
676
+ renameTemplate(tx, rid, wanted);
677
+ await tx.commit();
678
+ });
679
+ await this.templateListTree.refreshState();
680
+ }
681
+ /** Sets what a stored template says about itself; blank clears it. The stored document is not
682
+ * touched, so it stays byte-identical. */
683
+ async setTemplateDescription(id, description) {
684
+ const rid = await this.resolveTemplateId(id);
685
+ await this.pl.withWriteTx("MLSetTemplateDescription", async (tx) => {
686
+ setTemplateDescription(tx, rid, description);
418
687
  await tx.commit();
419
688
  });
420
689
  await this.templateListTree.refreshState();
@@ -455,7 +724,8 @@ var MiddleLayer = class MiddleLayer {
455
724
  * @param id template to apply
456
725
  * @param label label for the new project
457
726
  * @param provider where each entry's block comes from
458
- * @param options `allowUnstable` widens resolution to pre-release implementations
727
+ * @param options `allowUnstable` widens resolution to pre-release implementations; `folder` is
728
+ * where the new project lands
459
729
  */
460
730
  async createProjectFromTemplate(id, label, provider, options = {}) {
461
731
  const stored = await this.getTemplateData(id);
@@ -464,7 +734,7 @@ var MiddleLayer = class MiddleLayer {
464
734
  ok: false,
465
735
  problems: preparation.problems
466
736
  };
467
- const projectId = await this.createProject({ label });
737
+ const projectId = await this.createProject({ label }, options.folder);
468
738
  const report = await this.applyPreparedEntries(projectId, stored.document, preparation.prepared, options.author);
469
739
  if (report.problems.length > 0) {
470
740
  await this.deleteProject(projectId);
@@ -485,12 +755,9 @@ var MiddleLayer = class MiddleLayer {
485
755
  const cached = this.templateIdCache.get(templateId);
486
756
  if (cached !== void 0) return cached;
487
757
  const rid = await this.pl.withReadTx("ResolveTemplateId", async (tx) => {
488
- const data = await tx.getResourceData(this.templateListResourceId, true);
489
- for (const f of data.fields) {
490
- if (isNullSignedResourceId(f.value)) continue;
491
- if (resourceIdToString(f.value) === templateId) return f.value;
492
- }
493
- throw new Error(`Template ${templateId} not found in template list.`);
758
+ const entry = (await listedById(tx, this.templateListResourceId)).get(templateId);
759
+ if (entry === void 0) throw notListedError("Template", templateId);
760
+ return entry.rid;
494
761
  });
495
762
  this.templateIdCache.set(templateId, rid);
496
763
  return rid;
@@ -499,43 +766,50 @@ var MiddleLayer = class MiddleLayer {
499
766
  * destruction of all attached objects, like files, analysis results etc. */
500
767
  async deleteProject(id) {
501
768
  await this.pl.withWriteTx("MLRemoveProject", async (tx) => {
502
- const data = await tx.getResourceData(this.projectListResourceId, true);
503
- let fieldName;
504
- for (const f of data.fields) {
505
- if (isNullSignedResourceId(f.value)) continue;
506
- if (resourceIdToString(f.value) === id) {
507
- fieldName = f.name;
508
- break;
509
- }
510
- }
511
- if (fieldName === void 0) throw new Error(`Project ${id} not found in project list.`);
512
- tx.removeField(field(this.projectListResourceId, fieldName));
769
+ const entry = (await listedById(tx, this.projectListResourceId)).get(id);
770
+ if (entry === void 0) throw notListedError("Project", id);
771
+ tx.removeField(field(this.projectListResourceId, entry.fieldName));
513
772
  await tx.commit();
514
773
  });
515
774
  this.projectIdCache.delete(id);
516
775
  await this.projectListTree.refreshState();
517
776
  }
518
777
  /**
519
- * Duplicates an existing project and adds the copy to this user's project list.
778
+ * Duplicates an existing project and adds the copy to this user's project list, beside the
779
+ * project it was copied from.
780
+ *
781
+ * Without `rename` the copy is named by the rule everything beside the source is named by: the
782
+ * source's own label, suffixed to be free there — `X (Copy)`. The name is chosen inside the
783
+ * transaction that creates the copy, against the tree that transaction reads.
520
784
  *
521
785
  * @param srcProjectId - project id of the project to duplicate
522
786
  * @param rename - optional function that receives the source label and all existing
523
- * project labels (read within the same transaction), and returns the label for the copy
787
+ * project labels (read within the same transaction), and returns the label for the copy.
788
+ * A label chosen this way is the caller's and is never suffixed: when it is already taken
789
+ * beside the source, the copy lands at the top level instead.
524
790
  */
525
791
  async duplicateProject(srcProjectId, rename) {
526
792
  const sourceRid = await this.resolveProjectId(srcProjectId);
527
793
  const newPrj = await this.pl.withWriteTx("MLDuplicateProject", async (tx) => {
528
794
  const sourceMeta = await tx.getKValueJson(sourceRid, ProjectMetaKey);
529
- const projectRids = (await tx.getResourceData(this.projectListResourceId, true)).fields.map((f) => f.value).filter(isNotNullSignedResourceId);
530
- const existingLabels = (await Promise.all(projectRids.map((rid) => tx.getKValueJson(rid, ProjectMetaKey)))).map((m) => m.label);
531
- const newPrj = await duplicateProject(tx, sourceRid, { label: rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label }, this.env.projectHelper);
795
+ const tree = await openFoldersTx(tx, this.foldersRids);
796
+ const label = rename === void 0 ? foldersUniqueName(sourceMeta.label, [sourceMeta.label, ...tree.namesTakenBeside(srcProjectId)]) : rename(sourceMeta.label, await existingProjectLabels(tx, this.projectListResourceId));
797
+ const newPrj = await duplicateProject(tx, sourceRid, {
798
+ ...sourceMeta,
799
+ label
800
+ }, this.env.projectHelper);
532
801
  tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", newPrj);
802
+ const created = {
803
+ kind: "project",
804
+ id: asProjectId(resourceIdToString(await newPrj.globalId))
805
+ };
806
+ tree.place([created], rename === void 0 ? tree.folderOf(srcProjectId) : inheritedFolder(tree.view, srcProjectId, created, label));
533
807
  await tx.commit();
534
808
  return newPrj;
535
809
  });
536
- await this.projectListTree.refreshState();
810
+ await Promise.all([this.projectListTree.refreshState(), this.foldersTree.refreshState()]);
537
811
  const signedRid = await newPrj.globalId;
538
- const newProjectId = resourceIdToString(signedRid);
812
+ const newProjectId = asProjectId(resourceIdToString(signedRid));
539
813
  this.projectIdCache.set(newProjectId, signedRid);
540
814
  return newProjectId;
541
815
  }
@@ -543,7 +817,7 @@ var MiddleLayer = class MiddleLayer {
543
817
  * Duplicates a project into another user's root, minted in the TARGET user's color so the target
544
818
  * owns it. Sibling of {@link duplicateProject}, but writes into a different root. The source
545
819
  * project (on the current client root) is referenced cross-color for its block data, kept alive
546
- * by refcounting, exactly like accepting a shared project. Works both ways: pull (while
820
+ * by refcounting, exactly like a project copied out of a share. Works both ways: pull (while
547
821
  * impersonating a user, copy their project to yourself) and push (from your own root, copy a
548
822
  * project to a user). Admin cross-root op; requires the crossTreeRefs:v1 backend capability.
549
823
  */
@@ -557,100 +831,36 @@ var MiddleLayer = class MiddleLayer {
557
831
  await this.pl.withWriteTxOnRoot(targetRoot, "MLDuplicateProjectToUser", async (tx) => {
558
832
  const targetProjectListRid = await ensureProjectListRid(tx);
559
833
  const sourceMeta = await tx.getKValueJson(sourceRid, ProjectMetaKey);
560
- const targetListData = await tx.getResourceData(targetProjectListRid, true);
561
- const existingLabels = (await Promise.all(targetListData.fields.map((f) => f.value).filter(isNotNullSignedResourceId).map((rid) => tx.getKValueJson(rid, ProjectMetaKey)))).map((m) => m.label);
562
- const newPrj = await duplicateProject(tx, sourceRid, { label: rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label }, this.env.projectHelper);
834
+ const existingLabels = await existingProjectLabels(tx, targetProjectListRid);
835
+ const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
836
+ const newPrj = await duplicateProject(tx, sourceRid, {
837
+ ...sourceMeta,
838
+ label: newLabel
839
+ }, this.env.projectHelper);
563
840
  tx.createField(field(targetProjectListRid, randomUUID()), "Dynamic", newPrj);
564
841
  await tx.commit();
565
842
  });
566
843
  }
567
844
  /**
568
- * Shares the given projects (Copy & Share). Snapshots the projects, creates one envelope, and
569
- * grants it — all in one atomic write transaction, so a failed grant rolls the whole thing back
570
- * and the outbox is left as it was.
571
- *
572
- * Two variants (see {@link ShareProjectsOptions}):
573
- * - `{ recipients }` — one writable grant per named recipient; the envelope expires after the
574
- * default TTL (`sharedAt + envelopeTtlMs`).
575
- * - `{ everyone: true }` — one make-public grant (backend rewrites the target to the
576
- * everyone-user); the envelope's `expiresAt` is `null`, so it never expires.
845
+ * Shares the given projects (Copy & Share): snapshots them into one envelope, in the transaction
846
+ * {@link shareEnvelope} describes.
577
847
  *
578
848
  * v1 always passes `mode: "copy"`.
579
849
  */
580
850
  async shareProjects(projectIds, options) {
581
851
  if (projectIds.length === 0) throw new Error("shareProjects: no projects given");
582
- if ("everyone" in options && options.replace) {
583
- const priorEveryone = (await this.findSupersedableEnvelopes(projectIds)).find((p) => p.everyone);
584
- if (priorEveryone !== void 0) {
585
- await this.changeShare(priorEveryone.shareId, { title: options.title });
586
- return;
587
- }
588
- }
589
- await this.createNewShare(projectIds, options);
590
- }
591
- /**
592
- * Mints a fresh share: snapshots the projects into one new envelope (a fresh shareId),
593
- * supersedes prior shares of the same project, and grants it — all in one atomic write
594
- * transaction, so a failed grant rolls the whole thing back and the outbox is left as it was.
595
- * The everyone-refresh path is the {@link changeShare} branch of {@link shareProjects}; this is
596
- * the mint-a-new-envelope branch.
597
- */
598
- async createNewShare(projectIds, options) {
599
- const everyone = "everyone" in options;
600
852
  const sources = await Promise.all(projectIds.map(async (id) => ({
601
- kind: "fresh",
602
853
  projectId: id,
603
854
  sourceRid: await this.resolveProjectId(id)
604
855
  })));
605
- const sender = this.currentUserLogin ?? "";
606
- const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
607
- const priors = await this.findSupersedableEnvelopes(projectIds);
608
- await this.pl.withWriteTx("MLShareProjects", async (tx) => {
609
- if (everyone) {
610
- for (const prior of priors) if (prior.everyone) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
611
- } else {
612
- const newRecipients = new Set(options.recipients);
613
- for (const prior of priors) {
614
- if (prior.everyone) continue;
615
- const toRemove = prior.recipients.filter((u) => newRecipients.has(u));
616
- if (toRemove.length === 0) continue;
617
- if (prior.recipients.filter((u) => !newRecipients.has(u)).length === 0) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
618
- else for (const u of toRemove) tx.revokeAccess(prior.rid, u);
619
- }
620
- }
621
- const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
622
- mode: options.mode,
623
- sender,
624
- title: options.title,
625
- expiresAt
626
- });
627
- await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, { writable: true });
628
- await tx.commit();
629
- });
630
- await this.sharingOutboxTree.refreshState();
631
- }
632
- /**
633
- * Grants one freshly built envelope inside the transaction that created it: a single make-public
634
- * grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
635
- * target to the everyone-user, gated by role + permission ceiling), or one grant per named
636
- * recipient.
637
- *
638
- * `writable` is not a preference. A project pack needs a writable grant because accepting copies
639
- * the snapshots out of the envelope, and the cross-color attach rule permits that only to a
640
- * writable grant holder. A template share copies nothing — the document sits in the envelope's
641
- * own immutable data — so it is granted read-only, and must be: a writable everyone-grant would
642
- * hand every user on the server write access to the envelope.
643
- */
644
- async grantShareEnvelope(tx, envelope, everyone, recipients, permissions) {
645
- const gid = await envelope.globalId;
646
- if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
647
- else for (const r of recipients) tx.grantAccess(gid, r, permissions);
856
+ return await this.shareEnvelope("MLShareProjects", options, { writable: true }, (tx, meta) => buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
857
+ mode: options.mode,
858
+ ...meta
859
+ }));
648
860
  }
649
861
  /**
650
862
  * Shares one stored template. The envelope carries the document itself, so there is no project
651
863
  * snapshot and no resource for the recipient to copy out — which is why the grant is read-only.
652
- * The cost of that is the donor's receipt: nobody can write an acceptance onto a read-only
653
- * envelope, so a template share never reports who accepted it.
654
864
  *
655
865
  * Nothing about the document is checked: a stored template is shareable by virtue of existing.
656
866
  * An entry the recipient cannot resolve — a block installed from a folder on the sender's
@@ -661,187 +871,110 @@ var MiddleLayer = class MiddleLayer {
661
871
  * @param options recipients XOR everyone, plus the title recipients see
662
872
  */
663
873
  async shareTemplate(id, options) {
664
- const template = await this.loadTemplateForShare(id);
665
- const everyone = "everyone" in options;
666
- const sender = this.currentUserLogin ?? "";
667
- const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
668
- let shareId;
669
- await this.pl.withWriteTx("MLShareTemplate", async (tx) => {
670
- const { envelope, data } = buildTemplateShareEnvelope(tx, this.sharingOutboxResourceId, template, {
671
- sender,
672
- title: options.title,
673
- expiresAt
674
- });
675
- shareId = data.shareId;
676
- await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, { writable: false });
677
- await tx.commit();
678
- });
679
- await this.sharingOutboxTree.refreshState();
680
- return { shareId };
681
- }
682
- /** The document and the label of a template about to be shared. The label is what the
683
- * recipient's own list will show, so it travels with the document. */
684
- async loadTemplateForShare(id) {
685
874
  const rid = await this.resolveTemplateId(id);
686
- return await this.pl.withReadTx("MLReadTemplateForShare", async (tx) => {
687
- const rd = await tx.getResourceData(rid, false);
688
- if (rd.data === void 0) throw new Error(`Template ${id} carries no document.`);
689
- return {
690
- document: decodeStoredTemplateData(rd.data).document,
691
- label: await tx.getKValueJson(rid, TemplateLabelKey)
692
- };
693
- });
875
+ const stored = await this.pl.withReadTx("MLReadStoredTemplate", (tx) => readStoredTemplate(tx, id, rid));
876
+ return await this.shareEnvelope("MLShareTemplate", options, { writable: false }, (tx, meta) => buildTemplateShareEnvelope(tx, this.sharingOutboxResourceId, {
877
+ document: stored.data.document,
878
+ label: stored.label,
879
+ ...stored.description === void 0 ? {} : { description: stored.description },
880
+ source: id
881
+ }, meta));
694
882
  }
695
883
  /**
696
- * Changes a share in place (same {@link ShareId}), in one write transaction: re-snapshots live
697
- * source projects and carries deleted ones' snapshots forward; applies edited recipients/title;
698
- * transfers already-decided recipients' accept/reject records (they keep their copy and aren't
699
- * re-prompted); re-grants; drops the old envelope.
884
+ * Shares one folder and everything under it: the subtree's folders, every project in them
885
+ * snapshotted, and every template carried whole.
700
886
  *
701
- * `opts.recipients` is the full targeted set (decided users are always kept). `opts.title`
702
- * replaces the title — omit keeps the current one. `opts.everyone` upgrades targeted ->
703
- * everyone; the reverse is impossible and ignored.
887
+ * The grant is writable, because a folder holding projects is copied out of the envelope the
888
+ * way a project pack is. An everyone-share of a folder therefore hands every user on the server
889
+ * write access to the envelope — the same trade a project share already makes.
704
890
  *
705
- * `opts.projectActions` is a per-source-project decision, keyed by projectId: `update`
706
- * re-snapshots the live source (falls back to carry if the source is gone), `keep` carries the
707
- * existing snapshot (and its timestamp), `remove` drops the project from the pack. A project not
708
- * in the map defaults to `keep`. Omit the whole map for the legacy auto behavior (live sources
709
- * updated, gone ones kept) — the everyone-refresh path relies on that.
891
+ * Folder ids do not travel. What the recipient gets is the shape of the subtree, rebuilt under
892
+ * a folder of their own choosing with ids their own document mints.
710
893
  *
711
- * `opts.templateId` is required for, and only used by, a share that carries a template: a stored
712
- * template is immutable, so an improved one is a different template and the share cannot re-read
713
- * the one it started from — the caller names the new target. Every other option means the same
714
- * thing for both kinds of share.
894
+ * @param folder folder to share; everything beneath it goes with it
895
+ * @param options recipients XOR everyone, plus the title recipients see
715
896
  */
716
- async changeShare(shareId, opts = {}) {
717
- const target = opts.templateId === void 0 ? void 0 : await this.loadTemplateForShare(opts.templateId);
718
- await this.pl.withWriteTx("MLChangeShare", async (tx) => {
719
- const old = await this.resolveOutboxEnvelope(tx, shareId);
720
- if (old === void 0) throw new Error(`changeShare: no live share with id ${shareId} in the outbox.`);
721
- const self = this.currentUserLogin ?? "";
722
- const grants = await tx.listGrants(old.rid);
723
- const everyone = grants.some((g) => isEveryoneUserLogin(g.user)) || opts.everyone === true;
724
- const priorRecipients = grants.filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self).map((g) => g.user);
725
- if (old.data.payload.kind === "template") {
726
- if (target === void 0) throw new Error(`changeShare: share ${shareId} carries a template, so it needs an explicit target template — a stored template never changes, so an improved one is a different template.`);
727
- const recipients = everyone ? [] : opts.recipients ?? priorRecipients;
728
- tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
729
- const { envelope } = buildTemplateShareEnvelope(tx, this.sharingOutboxResourceId, target, {
730
- sender: self,
731
- title: opts.title === void 0 ? old.data.title : opts.title.trim(),
732
- expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs,
733
- shareId
734
- });
735
- await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: false });
736
- await tx.commit();
737
- return;
738
- }
739
- const oldRd = await tx.getResourceData(old.rid, true);
740
- const snapshotByUuid = /* @__PURE__ */ new Map();
741
- const acceptances = [];
742
- for (const f of oldRd.fields) {
743
- if (isNullSignedResourceId(f.value)) continue;
744
- if (isEnvelopeProjectField(f.name)) snapshotByUuid.set(envelopeProjectFieldUuid(f.name), f.value);
745
- else if (isAcceptanceField(f.name)) {
746
- const raw = (await tx.getResourceData(f.value, false)).data;
747
- if (raw === void 0) continue;
748
- acceptances.push({
749
- login: acceptanceFieldLogin(f.name),
750
- acc: cachedDeserialize(raw)
751
- });
752
- }
753
- }
754
- const decidedLogins = acceptances.map((a) => a.login);
755
- const recipients = everyone ? [] : Array.from(/* @__PURE__ */ new Set([...opts.recipients ?? priorRecipients, ...decidedLogins]));
756
- const liveProjects = /* @__PURE__ */ new Map();
757
- const projList = await tx.getResourceData(this.projectListResourceId, true);
758
- for (const f of projList.fields) {
759
- if (isNullSignedResourceId(f.value)) continue;
760
- liveProjects.set(resourceIdToString(f.value), f.value);
761
- }
762
- const actions = opts.projectActions;
763
- const sources = [];
764
- const oldProjects = envelopeProjectMap(old.data);
765
- for (const uuid of Object.keys(oldProjects)) {
766
- const { label, source, updatedAt } = oldProjects[uuid];
767
- const liveRid = liveProjects.get(source);
768
- const action = actions ? actions[source] ?? "keep" : liveRid !== void 0 ? "update" : "keep";
769
- if (action === "remove") continue;
770
- if (action === "update" && liveRid !== void 0) sources.push({
771
- kind: "fresh",
772
- projectId: source,
773
- sourceRid: liveRid
774
- });
775
- else {
776
- const snapshotRid = snapshotByUuid.get(uuid);
777
- if (snapshotRid !== void 0) sources.push({
778
- kind: "carry",
779
- projectId: source,
780
- label,
781
- snapshotRid,
782
- updatedAt
783
- });
784
- }
785
- }
786
- const title = opts.title === void 0 ? old.data.title : opts.title.trim();
787
- const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
788
- tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
789
- const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
790
- mode: old.data.mode,
791
- sender: self,
792
- title,
793
- expiresAt,
794
- shareId
795
- });
796
- for (const { login, acc } of acceptances) {
797
- if (!everyone && !recipients.includes(login)) continue;
798
- writeEnvelopeAcceptance(tx, envelope, login, acc.action, acc.timestamp);
799
- }
800
- await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: true });
897
+ async shareFolder(folder, options) {
898
+ const loaded = await this.loadFolderSubtree(folder, newEnvelopeFolderId);
899
+ const subtree = {
900
+ source: folder,
901
+ folders: loaded.folders,
902
+ projects: loaded.projects.map((project) => ({
903
+ projectId: project.projectId,
904
+ sourceRid: project.rid,
905
+ folder: project.folder
906
+ })),
907
+ templates: loaded.templates.map((template) => ({
908
+ document: template.data.document,
909
+ label: template.label,
910
+ ...template.description === void 0 ? {} : { description: template.description },
911
+ folder: template.folder
912
+ }))
913
+ };
914
+ return await this.shareEnvelope("MLShareFolder", options, { writable: true }, (tx, meta) => buildFolderShareEnvelope(tx, this.sharingOutboxResourceId, subtree, meta));
915
+ }
916
+ /**
917
+ * The one transaction every share is made in: the shares named by `options.replace` are
918
+ * dropped, the envelope `build` makes is created, and it is granted — all at once, so a failed
919
+ * grant rolls the whole thing back and the outbox is left as it was.
920
+ *
921
+ * A share with named recipients grants each of them and expires after the default TTL
922
+ * (`sharedAt + envelopeTtlMs`). A share with everyone is one make-public grant, and its
923
+ * `expiresAt` is `null`, so it never expires.
924
+ */
925
+ async shareEnvelope(txName, options, permissions, build) {
926
+ const everyone = "everyone" in options;
927
+ const meta = {
928
+ sender: this.currentUserLogin ?? "",
929
+ title: options.title,
930
+ expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs
931
+ };
932
+ const outcome = await this.pl.withWriteTx(txName, async (tx) => {
933
+ await this.dropShares(tx, options.replace);
934
+ const { envelope, data } = await build(tx, meta);
935
+ await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, permissions);
801
936
  await tx.commit();
937
+ return { shareId: data.shareId };
802
938
  });
803
939
  await this.sharingOutboxTree.refreshState();
940
+ return outcome;
804
941
  }
805
942
  /**
806
- * Finds the donor's own outgoing envelopes built from any of the given source projects —
807
- * the supersede candidates for a fresh share of the same project(s). Reads each envelope's
808
- * recipient set via `ListGrants` so the caller can pull individual recipients or detect an
809
- * everyone-share.
943
+ * Detaches the named shares from the donor's outbox inside the caller's transaction, so a
944
+ * replacement and the shares it supersedes land together or not at all.
945
+ *
946
+ * A share that no longer resolves is skipped rather than reported: the caller names shares the
947
+ * author saw a moment ago, and one revoked meanwhile is already in the wanted state.
810
948
  */
811
- async findSupersedableEnvelopes(projectIds) {
812
- const wanted = new Set(projectIds);
813
- const matched = await this.pl.withReadTx("MLFindSupersede", async (tx) => {
814
- const outbox = await tx.getResourceData(this.sharingOutboxResourceId, true);
815
- const out = [];
816
- for (const f of outbox.fields) {
817
- if (isNullSignedResourceId(f.value)) continue;
818
- const rd = await tx.getResourceData(f.value, false);
819
- if (rd.data === void 0) continue;
820
- const data = decodeEnvelopeData(rd.data);
821
- if (data === void 0) continue;
822
- if (Object.values(envelopeProjectMap(data)).some((p) => wanted.has(p.source))) out.push({
823
- fieldName: f.name,
824
- rid: f.value,
825
- shareId: data.shareId
826
- });
827
- }
828
- return out;
829
- });
830
- return await Promise.all(matched.map(async ({ fieldName, rid, shareId }) => {
831
- const grants = await this.pl.userResources.listGrants(rid);
832
- return {
833
- fieldName,
834
- rid,
835
- shareId,
836
- everyone: grants.some((g) => isEveryoneUserLogin(g.user)),
837
- recipients: grants.filter((g) => !isEveryoneUserLogin(g.user)).map((g) => g.user)
838
- };
839
- }));
949
+ async dropShares(tx, shareIds) {
950
+ for (const shareId of shareIds ?? []) {
951
+ const target = await this.resolveOutboxEnvelope(tx, shareId);
952
+ if (target === void 0) continue;
953
+ tx.removeField(field(this.sharingOutboxResourceId, target.fieldName));
954
+ }
955
+ }
956
+ /**
957
+ * Grants one freshly built envelope inside the transaction that created it: a single make-public
958
+ * grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
959
+ * target to the everyone-user, gated by role + permission ceiling), or one grant per named
960
+ * recipient.
961
+ *
962
+ * `writable` is not a preference. A project pack needs a writable grant because a copy out of
963
+ * it takes the snapshots out of the envelope, and the cross-color attach rule permits that only
964
+ * to a writable grant holder. A template share copies nothing — the document sits in the
965
+ * envelope's own immutable data — so it is granted read-only, and must be: a writable
966
+ * everyone-grant would hand every user on the server write access to the envelope.
967
+ */
968
+ async grantShareEnvelope(tx, envelope, everyone, recipients, permissions) {
969
+ const gid = await envelope.globalId;
970
+ if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
971
+ else for (const r of recipients) tx.grantAccess(gid, r, permissions);
840
972
  }
841
973
  /**
842
974
  * Revokes and deletes an outgoing share for all recipients: detaches and deletes the envelope, and
843
- * its grants are revoked along with it. Already-accepted copies are unaffected (ref-counting keeps
844
- * the adopted resources alive). Idempotent — revoking a share that is already gone is a no-op.
975
+ * its grants are revoked along with it. Copies recipients already took out of it are unaffected
976
+ * (ref-counting keeps the resources they point at alive). Idempotent — revoking a share that is
977
+ * already gone is a no-op.
845
978
  */
846
979
  async revokeShare(shareId) {
847
980
  await this.pl.withWriteTx("MLRevokeShare", async (tx) => {
@@ -878,36 +1011,37 @@ var MiddleLayer = class MiddleLayer {
878
1011
  * the envelope's logical `shareId`.
879
1012
  *
880
1013
  * Reads the {@link liveEnvelopes} Computable — the same shared-resource discovery tree that
881
- * feeds {@link pendingShares}. This is the single discovery mechanism: there is no separate
882
- * `ListUserResources` re-stream on every accept/reject. `refreshState()` is awaited first so a
1014
+ * feeds {@link availableShares}. This is the single discovery mechanism: there is no separate
1015
+ * `ListUserResources` re-stream on every copy. `refreshState()` is awaited first so a
883
1016
  * just-granted envelope is observed (the tree's discovery poll may otherwise lag a freshly
884
1017
  * landed grant). The tree is gRPC-only, so this is empty on a REST-connected client.
885
1018
  */
886
1019
  async resolveLiveEnvelopes() {
887
- await this.pendingSharesTree.refreshState();
1020
+ await this.availableSharesTree.refreshState();
888
1021
  const live = await this.liveEnvelopes.getValue() ?? [];
889
1022
  const map = /* @__PURE__ */ new Map();
890
1023
  for (const e of live) map.set(e.data.shareId, e);
891
1024
  return map;
892
1025
  }
893
1026
  /**
894
- * Accepts one or more pending shares. What accepting does depends on what the share carries: a
895
- * pack of projects is duplicated into this user's project list, while a template is added to this
896
- * user's own template list and builds nothing — the recipient decides later whether to apply it.
897
- * Either way the decision is recorded per share, and a read-write share also gets the
898
- * donor-visible acceptance written onto its envelope. Per-share failures (e.g. an expiry race)
899
- * are collected, not short-circuited — the rest still get accepted. Accept-all = pass every
900
- * current pending shareId.
1027
+ * Copies what one or more shares carry into this user's own tree, optionally into a folder.
901
1028
  *
902
- * `rename` resolves label collisions (same callback contract as {@link duplicateProject}), but
903
- * the source lives in the envelope tree, so accept calls the low-level mutator directly. It does
904
- * not apply to a template share, whose label is not required to be unique.
1029
+ * A share is a shelf, not an invitation: copying takes nothing off it and records no decision,
1030
+ * so the same share can be copied from again, by this user or anyone else it was granted to.
1031
+ * What a copy produces depends on the payload — a pack of projects lands in the project list,
1032
+ * a template among their templates, building nothing until the recipient applies it, and a
1033
+ * folder is rebuilt whole with everything it held.
1034
+ *
1035
+ * Names are chosen against the destination folder, since that is where the uniqueness rule
1036
+ * applies, and a project and a template follow the same rule. A destination deleted meanwhile
1037
+ * fails the copy rather than spilling it at the top level.
1038
+ * Per-share failures (a revoked envelope, say) are collected rather than short-circuited, so
1039
+ * one dead share does not cost the others.
905
1040
  */
906
- async acceptShare(shareIds, rename) {
1041
+ async copyShare(shareIds, destination) {
907
1042
  const live = await this.resolveLiveEnvelopes();
908
- const login = this.currentUserLogin;
909
- const accepted = [];
910
- const acceptedTemplates = [];
1043
+ const projects = [];
1044
+ const templates = [];
911
1045
  const failed = [];
912
1046
  for (const shareId of shareIds) {
913
1047
  const envelope = live.get(shareId);
@@ -919,45 +1053,100 @@ var MiddleLayer = class MiddleLayer {
919
1053
  continue;
920
1054
  }
921
1055
  try {
922
- const now = Date.now();
923
1056
  const payload = envelope.data.payload;
924
1057
  if (payload.kind === "template") {
925
- const rid = await this.pl.withWriteTx("MLAcceptTemplateShare", async (tx) => {
926
- const tpl = createTemplate(tx, this.templateListResourceId, payload.label, {
1058
+ const rid = await this.pl.withWriteTx("MLCopyTemplateShare", async (tx) => {
1059
+ const tree = await openFoldersTx(tx, this.foldersRids);
1060
+ const created = await createTemplate(tx, this.templateListResourceId, {
1061
+ label: foldersUniqueName(payload.label, tree.namesTakenIn(destination)),
1062
+ description: payload.description
1063
+ }, {
927
1064
  schemaVersion: 1,
928
1065
  document: payload.document,
929
1066
  sender: payload.from
930
- });
931
- writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
932
- decision: "accepted",
933
- timestamp: now,
934
- envelopeSharedAt: envelope.data.sharedAt,
935
- acceptedProjects: []
936
- });
1067
+ }).globalId;
1068
+ tree.place([{
1069
+ kind: "template",
1070
+ id: asTemplateId(resourceIdToString(created))
1071
+ }], destination);
937
1072
  await tx.commit();
938
- return await tpl.globalId;
1073
+ return created;
939
1074
  });
940
- const templateId = resourceIdToString(rid);
1075
+ const templateId = asTemplateId(resourceIdToString(rid));
941
1076
  this.templateIdCache.set(templateId, rid);
942
- acceptedTemplates.push(templateId);
1077
+ templates.push(templateId);
943
1078
  continue;
944
1079
  }
945
- const createdRids = await this.pl.withWriteTx("MLAcceptShare", async (tx) => {
946
- const created = await copyEnvelopeProjectsIntoList(tx, envelope.rid, this.projectListResourceId, rename);
947
- writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
948
- decision: "accepted",
949
- timestamp: now,
950
- envelopeSharedAt: envelope.data.sharedAt,
951
- acceptedProjects: resourceIdsToStrings(created)
1080
+ if (payload.kind === "folder") {
1081
+ const root = envelopeFolderRoot(payload.folders);
1082
+ if (root === void 0) throw new Error("This share does not describe one folder, so nothing can be rebuilt.");
1083
+ const copied = await this.pl.withWriteTx("MLCopyFolderShare", async (tx) => {
1084
+ const tree = await openFoldersTx(tx, this.foldersRids);
1085
+ const created = await copyEnvelopeProjectsIntoList(tx, envelope.rid, this.projectListResourceId);
1086
+ const createdTemplates = [];
1087
+ const items = [];
1088
+ for (const { uuid, rid } of created) {
1089
+ const inFolder = payload.projects[uuid];
1090
+ items.push({
1091
+ kind: "project",
1092
+ id: asProjectId(resourceIdToString(rid)),
1093
+ folder: inFolder?.folder ?? root
1094
+ });
1095
+ }
1096
+ for (const carried of payload.templates) {
1097
+ const id = asTemplateId(resourceIdToString(await createTemplate(tx, this.templateListResourceId, {
1098
+ label: carried.label,
1099
+ description: carried.description
1100
+ }, {
1101
+ schemaVersion: 1,
1102
+ document: carried.document,
1103
+ sender: payload.from
1104
+ }).globalId));
1105
+ createdTemplates.push(id);
1106
+ items.push({
1107
+ kind: "template",
1108
+ id,
1109
+ folder: carried.folder
1110
+ });
1111
+ }
1112
+ tree.graft({
1113
+ root,
1114
+ folders: payload.folders,
1115
+ items
1116
+ }, destination);
1117
+ await tx.commit();
1118
+ return {
1119
+ projects: created,
1120
+ templates: createdTemplates
1121
+ };
952
1122
  });
953
- if (login !== null && envelope.data.mode !== "read-only") writeEnvelopeAcceptance(tx, envelope.rid, login, "accepted", now);
1123
+ for (const { rid } of copied.projects) {
1124
+ const projectId = asProjectId(resourceIdToString(rid));
1125
+ this.projectIdCache.set(projectId, rid);
1126
+ projects.push(projectId);
1127
+ }
1128
+ templates.push(...copied.templates);
1129
+ continue;
1130
+ }
1131
+ const createdRids = await this.pl.withWriteTx("MLCopyShare", async (tx) => {
1132
+ const tree = await openFoldersTx(tx, this.foldersRids);
1133
+ const taken = [...tree.namesTakenIn(destination)];
1134
+ const created = await copyEnvelopeProjectsIntoList(tx, envelope.rid, this.projectListResourceId, (sourceLabel) => {
1135
+ const name = foldersUniqueName(sourceLabel, taken);
1136
+ taken.push(name);
1137
+ return name;
1138
+ });
1139
+ tree.place(created.map(({ rid }) => ({
1140
+ kind: "project",
1141
+ id: asProjectId(resourceIdToString(rid))
1142
+ })), destination);
954
1143
  await tx.commit();
955
1144
  return created;
956
1145
  });
957
- for (const rid of createdRids) {
958
- const projectId = resourceIdToString(rid);
1146
+ for (const { rid } of createdRids) {
1147
+ const projectId = asProjectId(resourceIdToString(rid));
959
1148
  this.projectIdCache.set(projectId, rid);
960
- accepted.push(projectId);
1149
+ projects.push(projectId);
961
1150
  }
962
1151
  } catch (e) {
963
1152
  failed.push({
@@ -969,27 +1158,31 @@ var MiddleLayer = class MiddleLayer {
969
1158
  await Promise.all([
970
1159
  this.projectListTree.refreshState(),
971
1160
  this.templateListTree.refreshState(),
972
- this.sharingStateTree.refreshState()
1161
+ this.foldersTree.refreshState()
973
1162
  ]);
974
1163
  return {
975
- accepted,
976
- acceptedTemplates,
1164
+ projects,
1165
+ templates,
977
1166
  failed
978
1167
  };
979
1168
  }
980
- /** Records rejection of a pending share; it never surfaces again. */
981
- async rejectShare(shareId) {
982
- const envelope = (await this.resolveLiveEnvelopes()).get(shareId);
983
- const login = this.currentUserLogin;
1169
+ /**
1170
+ * Puts a share out of this user's sight. Private to them and reversible with
1171
+ * {@link unhideShare}: nothing is deleted, the donor is not told, and what was already copied
1172
+ * out of it is unaffected.
1173
+ */
1174
+ async hideShare(shareId) {
984
1175
  const now = Date.now();
985
- await this.pl.withWriteTx("MLRejectShare", async (tx) => {
986
- writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
987
- decision: "rejected",
988
- timestamp: now,
989
- envelopeSharedAt: envelope?.data.sharedAt ?? now,
990
- acceptedProjects: []
991
- });
992
- if (envelope !== void 0 && login !== null && envelope.data.mode !== "read-only") writeEnvelopeAcceptance(tx, envelope.rid, login, "rejected", now);
1176
+ await this.pl.withWriteTx("MLHideShare", async (tx) => {
1177
+ writeShareHidden(tx, this.sharingStateResourceId, shareId, now);
1178
+ await tx.commit();
1179
+ });
1180
+ await this.sharingStateTree.refreshState();
1181
+ }
1182
+ /** Brings a hidden share back into this user's list. */
1183
+ async unhideShare(shareId) {
1184
+ await this.pl.withWriteTx("MLUnhideShare", async (tx) => {
1185
+ clearShareHidden(tx, this.sharingStateResourceId, shareId);
993
1186
  await tx.commit();
994
1187
  });
995
1188
  await this.sharingStateTree.refreshState();
@@ -1103,9 +1296,10 @@ var MiddleLayer = class MiddleLayer {
1103
1296
  await Promise.all([
1104
1297
  this.projectListTree.terminate(),
1105
1298
  this.templateListTree.terminate(),
1299
+ this.foldersTree.terminate(),
1106
1300
  this.sharingOutboxTree.terminate(),
1107
1301
  this.sharingStateTree.terminate(),
1108
- this.pendingSharesTree.terminate()
1302
+ this.availableSharesTree.terminate()
1109
1303
  ]);
1110
1304
  await this.drainSnapshotWrites(SNAPSHOT_DRAIN_TIMEOUT_MS);
1111
1305
  await this.env.dispose();
@@ -1134,7 +1328,7 @@ var MiddleLayer = class MiddleLayer {
1134
1328
  ops.defaultTreeOptions.logStat = getDebugFlags().logTreeStats;
1135
1329
  ops.debugOps.dumpInitialTreeState = getDebugFlags().dumpInitialTreeState;
1136
1330
  if (ops.defaultTreeOptions.traversalMode === void 0 && getDebugFlags().treeTraversalMode !== void 0) ops.defaultTreeOptions.traversalMode = getDebugFlags().treeTraversalMode;
1137
- const { projects, templates, sharingOutbox, sharingState } = await pl.withWriteTx("MLInitialization", async (tx) => {
1331
+ const { projects, templates, sharingOutbox, sharingState, folders } = await pl.withWriteTx("MLInitialization", async (tx) => {
1138
1332
  const lazyInit = async (fieldName, type) => {
1139
1333
  const f = field(tx.clientRoot, fieldName);
1140
1334
  tx.createField(f, "Dynamic");
@@ -1151,12 +1345,15 @@ var MiddleLayer = class MiddleLayer {
1151
1345
  const templatesR = await lazyInit(TemplatesField, TemplatesResourceType);
1152
1346
  const outboxR = await lazyInit(SharingOutboxField, SharingOutboxResourceType);
1153
1347
  const stateR = await lazyInit(SharingStateField, SharingStateResourceType);
1348
+ const foldersR = await lazyInit(FoldersField, FoldersResourceType);
1154
1349
  await tx.commit();
1350
+ const idOf = async (r) => "existing" in r ? r.existing : await r.ref.globalId;
1155
1351
  return {
1156
- projects: projectsR.existing ?? await projectsR.ref.globalId,
1157
- templates: templatesR.existing ?? await templatesR.ref.globalId,
1158
- sharingState: stateR.existing ?? await stateR.ref.globalId,
1159
- sharingOutbox: outboxR.existing ?? await outboxR.ref.globalId
1352
+ projects: await idOf(projectsR),
1353
+ templates: await idOf(templatesR),
1354
+ sharingState: await idOf(stateR),
1355
+ sharingOutbox: await idOf(outboxR),
1356
+ folders: await idOf(foldersR)
1160
1357
  };
1161
1358
  });
1162
1359
  const logger = ops.logger;
@@ -1210,14 +1407,32 @@ var MiddleLayer = class MiddleLayer {
1210
1407
  const openedProjects = new WatchableValue([]);
1211
1408
  const projectListTC = await createProjectList(pl, projects, openedProjects, env);
1212
1409
  const templateListTC = await createTemplateList(pl, templates, env);
1410
+ const foldersTC = await createFolderList(pl, folders, projectListTC.tree, templateListTC.tree, openedProjects, env);
1213
1411
  const outgoingTC = await createOutgoingShares(pl, sharingOutbox, env);
1214
1412
  const sharingStateTree = await createSharingStateTree(pl, sharingState, env);
1215
- const pendingSharesTree = await createPendingSharesTree(pl, env);
1216
- const pendingShares = createPendingSharesComputable(pendingSharesTree, sharingStateTree, pl.userResources.authUser);
1217
- const liveEnvelopes = createLiveEnvelopesComputable(pendingSharesTree);
1218
- return new MiddleLayer(env, driverKit, driverKit.signer, projects, templates, sharingOutbox, sharingState, openedProjects, projectListTC.tree, templateListTC.tree, outgoingTC.tree, sharingStateTree, pendingSharesTree, v2RegistryProvider, projectListTC.computable, templateListTC.computable, outgoingTC.computable, pendingShares, liveEnvelopes);
1413
+ const availableSharesTree = await createAvailableSharesTree(pl, env);
1414
+ const availableShares = createAvailableSharesComputable(availableSharesTree, sharingStateTree, pl.userResources.authUser);
1415
+ const liveEnvelopes = createLiveEnvelopesComputable(availableSharesTree);
1416
+ return new MiddleLayer(env, driverKit, driverKit.signer, projects, templates, sharingOutbox, sharingState, folders, openedProjects, projectListTC.tree, templateListTC.tree, foldersTC.tree, outgoingTC.tree, sharingStateTree, availableSharesTree, v2RegistryProvider, projectListTC.computable, templateListTC.computable, foldersTC.computable, outgoingTC.computable, availableShares, liveEnvelopes);
1219
1417
  }
1220
1418
  };
1419
+ /** Reads one stored template within the caller's transaction. A share carries its document on;
1420
+ * a duplicate carries the blob whole, so the copy says of itself what the original did. */
1421
+ async function readStoredTemplate(tx, id, rid) {
1422
+ const rd = await tx.getResourceData(rid, false);
1423
+ if (rd.data === void 0) throw new Error(`Template ${id} carries no document.`);
1424
+ const [label, description] = await Promise.all([tx.getKValueJson(rid, TemplateLabelKey), tx.getKValueJsonIfExists(rid, TemplateDescriptionKey).then(normalizeDescription)]);
1425
+ return {
1426
+ data: decodeStoredTemplateData(rd.data),
1427
+ label,
1428
+ ...description === void 0 ? {} : { description }
1429
+ };
1430
+ }
1431
+ /** The label of every project in a project list, read within the caller's transaction. */
1432
+ async function existingProjectLabels(tx, listRid) {
1433
+ const listed = await listedById(tx, listRid);
1434
+ return (await Promise.all([...listed.values()].map(({ rid }) => tx.getKValueJson(rid, ProjectMetaKey)))).map((meta) => meta.label);
1435
+ }
1221
1436
  //#endregion
1222
1437
  export { MiddleLayer };
1223
1438