@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
@@ -6,10 +6,8 @@ import type {
6
6
  Role,
7
7
  } from "@milaboratories/pl-client";
8
8
  import {
9
- isEveryoneUserLogin,
10
9
  field,
11
10
  GrantType,
12
- isNotNullSignedResourceId,
13
11
  isNullSignedResourceId,
14
12
  resourceIdToString,
15
13
  } from "@milaboratories/pl-client";
@@ -20,10 +18,37 @@ import {
20
18
  ProjectsField,
21
19
  ProjectsResourceType,
22
20
  } from "./project_list";
21
+ import type { FoldersListing, FoldersRids } from "./folders";
22
+ import {
23
+ createFolder,
24
+ createFolderList,
25
+ deleteFolder,
26
+ moveFolderItems,
27
+ nameTakenMessage,
28
+ openFoldersTx,
29
+ previewFolderDeletion,
30
+ previewFoldersMove,
31
+ foldersLocalSubtree,
32
+ FoldersField,
33
+ FoldersResourceType,
34
+ renameFolder,
35
+ resetFolders,
36
+ setFolderDescription,
37
+ } from "./folders";
38
+ import type {
39
+ FolderId,
40
+ FoldersItem,
41
+ FoldersLeafItem,
42
+ FoldersMoveOutcome,
43
+ FoldersMovePlan,
44
+ FoldersMovePlanResult,
45
+ FoldersRemoval,
46
+ FoldersRemovalOutcome,
47
+ FoldersRemovalPlanResult,
48
+ } from "@milaboratories/pl-model-middle-layer";
23
49
  import type {
24
50
  CreateProjectFromTemplateOutcome,
25
51
  SaveProjectAsTemplateOutcome,
26
- ShareTemplateOutcome,
27
52
  StoredTemplateData,
28
53
  TemplateId,
29
54
  TemplateListEntry,
@@ -31,11 +56,18 @@ import type {
31
56
  import {
32
57
  createTemplateList,
33
58
  decodeStoredTemplateData,
59
+ TemplateDescriptionKey,
34
60
  TemplateLabelKey,
35
61
  TemplatesField,
36
62
  TemplatesResourceType,
37
63
  } from "./template_list";
38
- import { createTemplate, deleteTemplate, renameTemplate } from "../mutator/template";
64
+ import {
65
+ createTemplate,
66
+ deleteTemplate,
67
+ renameTemplate,
68
+ setTemplateDescription,
69
+ } from "../mutator/template";
70
+ import { listedById, notListedError } from "../mutator/list";
39
71
  import {
40
72
  createProject,
41
73
  duplicateProject,
@@ -44,6 +76,7 @@ import {
44
76
  } from "../mutator/project";
45
77
  import type { ProjectTemplateExportOutcome } from "../model/template_serializer";
46
78
  import type { ProjectTemplateV1 } from "@milaboratories/pl-model-common";
79
+ import { asProjectId, asTemplateId } from "@milaboratories/pl-model-common";
47
80
  import { extractConfig, ensureError } from "@platforma-sdk/model";
48
81
  import type { TemplateApplyProblem, TemplateApplyReport } from "../model/template_apply";
49
82
  import { TemplateEntryRejected, kindMismatch } from "../model/template_apply";
@@ -57,46 +90,44 @@ import { ProjectMetaKey } from "../model/project_model";
57
90
  import type { ProjectId } from "../model/project_model";
58
91
  import type { SynchronizedTreeState } from "@milaboratories/pl-tree";
59
92
  import {
60
- acceptanceFieldLogin,
61
93
  canGrantToEveryone,
62
94
  canImpersonate,
63
95
  decodeEnvelopeData,
64
- envelopeProjectMap,
65
- isAcceptanceField,
66
96
  SharingOutboxField,
67
97
  SharingOutboxResourceType,
68
98
  SharingStateField,
69
99
  SharingStateResourceType,
70
- type EnvelopeAcceptance,
100
+ envelopeFolderRoot,
101
+ newEnvelopeFolderId,
71
102
  type EnvelopeData,
72
- type ProjectChangeAction,
73
- type ProjectFieldUuid,
103
+ type ShareFolderOptions,
74
104
  type ShareId,
105
+ type ShareOptions,
106
+ type ShareOutcome,
75
107
  type ShareProjectsOptions,
76
108
  type ShareTemplateOptions,
77
109
  } from "../model/sharing_model";
78
110
  import {
111
+ buildFolderShareEnvelope,
79
112
  buildShareEnvelope,
80
113
  buildTemplateShareEnvelope,
81
114
  copyEnvelopeProjectsIntoList,
82
- envelopeProjectFieldUuid,
83
- isEnvelopeProjectField,
84
- resourceIdsToStrings,
85
- writeEnvelopeAcceptance,
86
- writeSharingDecision,
115
+ writeShareHidden,
116
+ clearShareHidden,
117
+ type EnvelopeFolderSubtree,
87
118
  type EnvelopeProjectSource,
88
119
  } from "../mutator/sharing";
89
- import type { LiveEnvelope, OutgoingShare, PendingShare } from "./sharing_list";
120
+ import type { LiveEnvelope, OutgoingShare, AvailableShare } from "./sharing_list";
90
121
  import {
91
122
  createLiveEnvelopesComputable,
92
123
  createOutgoingShares,
93
- createPendingSharesComputable,
94
- createPendingSharesTree,
124
+ createAvailableSharesComputable,
125
+ createAvailableSharesTree,
95
126
  createSharingStateTree,
96
127
  } from "./sharing_list";
97
128
  import { BlockPackPreparer } from "../mutator/block-pack/block_pack";
98
129
  import type { MiLogger, Signer } from "@milaboratories/ts-helpers";
99
- import { BlockEventDispatcher, cachedDeserialize } from "@milaboratories/ts-helpers";
130
+ import { BlockEventDispatcher } from "@milaboratories/ts-helpers";
100
131
  import { HmacSha256Signer } from "@milaboratories/ts-helpers";
101
132
  import type { Computable, ComputableStableDefined } from "@milaboratories/computable";
102
133
  import { WatchableValue } from "@milaboratories/computable";
@@ -110,6 +141,12 @@ import type {
110
141
  ProjectMeta,
111
142
  BlockPlatform,
112
143
  } from "@milaboratories/pl-model-middle-layer";
144
+ import {
145
+ foldersNameTaken,
146
+ foldersUniqueName,
147
+ inheritedFolder,
148
+ normalizeDescription,
149
+ } from "@milaboratories/pl-model-middle-layer";
113
150
  import type { AppliedEntry } from "../model/template_apply";
114
151
  import { BlockUpdateWatcher } from "../block_registry/watcher";
115
152
  import type { QuickJSWASMModule } from "quickjs-emscripten";
@@ -183,25 +220,29 @@ export class MiddleLayer {
183
220
  private readonly templateListResourceId: SignedResourceId,
184
221
  private readonly sharingOutboxResourceId: SignedResourceId,
185
222
  private readonly sharingStateResourceId: SignedResourceId,
223
+ private readonly foldersResourceId: SignedResourceId,
186
224
  private readonly openedProjectsList: WatchableValue<ProjectId[]>,
187
225
  private readonly projectListTree: SynchronizedTreeState,
188
226
  private readonly templateListTree: SynchronizedTreeState,
227
+ private readonly foldersTree: SynchronizedTreeState,
189
228
  private readonly sharingOutboxTree: SynchronizedTreeState,
190
229
  private readonly sharingStateTree: SynchronizedTreeState,
191
- private readonly pendingSharesTree: SynchronizedTreeState,
230
+ private readonly availableSharesTree: SynchronizedTreeState,
192
231
  public readonly blockRegistryProvider: V2RegistryProvider,
193
232
  /** Contains a reactive list of projects along with their meta information. */
194
233
  public readonly projectList: ComputableStableDefined<ProjectListEntry[]>,
195
234
  /** Contains a reactive list of stored templates along with their labels and provenance. */
196
235
  public readonly templateList: ComputableStableDefined<TemplateListEntry[]>,
197
- /** Reactive view of the donor's outbox — the shares this user has created.
198
- * v1: API only, no UI. */
236
+ /** The folder tree and the project list, already joined. The desktop reads this and never
237
+ * the two halves, so it cannot render a list and a tree that are one refresh apart. */
238
+ public readonly folders: ComputableStableDefined<FoldersListing>,
239
+ /** Reactive view of the donor's outbox — the shares this user has created. */
199
240
  public outgoingShares: Computable<OutgoingShare[] | undefined>,
200
- /** Envelopes granted to this user, not yet accepted or rejected. Fed by the
241
+ /** Shares granted to this user, hidden ones included and flagged as such. Fed by the
201
242
  * shared-resource discovery tree. */
202
- public pendingShares: Computable<PendingShare[] | undefined>,
203
- /** Internal: the acceptor's currently-live envelopes, read from the same shared-resource
204
- * discovery tree as {@link pendingShares}. The single source the accept/reject flow resolves
243
+ public availableShares: Computable<AvailableShare[] | undefined>,
244
+ /** Internal: the recipient's currently-live envelopes, read from the same shared-resource
245
+ * discovery tree as {@link availableShares}. The single source {@link copyShare} resolves
205
246
  * live envelopes from — no second discovery path. */
206
247
  private readonly liveEnvelopes: Computable<LiveEnvelope[] | undefined>,
207
248
  ) {
@@ -238,7 +279,7 @@ export class MiddleLayer {
238
279
  /**
239
280
  * Whether the connected backend supports project sharing. Synthetic — computed
240
281
  * in the middle layer from the backend capabilities the share flow needs (the
241
- * cross-color field-reference relaxation the accept flow rests on). It can absorb
282
+ * cross-color field-reference relaxation a copy out of a share rests on). It can absorb
242
283
  * additional required capabilities later without a UI change.
243
284
  */
244
285
  public get sharingSupported(): boolean {
@@ -323,12 +364,9 @@ export class MiddleLayer {
323
364
 
324
365
  // Cache miss — scan project list fields to find the matching resource
325
366
  const rid = await this.pl.withReadTx("ResolveProjectId", async (tx) => {
326
- const data = await tx.getResourceData(this.projectListResourceId, true);
327
- for (const f of data.fields) {
328
- if (isNullSignedResourceId(f.value)) continue;
329
- if (resourceIdToString(f.value) === (projectId as string)) return f.value;
330
- }
331
- throw new Error(`Project ${projectId} not found in project list.`);
367
+ const entry = (await listedById(tx, this.projectListResourceId)).get(projectId);
368
+ if (entry === undefined) throw notListedError("Project", projectId);
369
+ return entry.rid;
332
370
  });
333
371
 
334
372
  this.projectIdCache.set(projectId, rid);
@@ -339,40 +377,291 @@ export class MiddleLayer {
339
377
  // Project List Manipulation
340
378
  //
341
379
 
342
- /** Creates a project with initial state and adds it to project list. */
343
- public async createProject(meta: ProjectMeta): Promise<ProjectId> {
344
- let prj: ResourceRef;
345
- await this.pl.withWriteTx("MLCreateProject", async (tx) => {
346
- prj = await createProject(tx, meta);
380
+ /**
381
+ * Creates a project with initial state and adds it to project list.
382
+ *
383
+ * `folder` is where it lands, placed in the transaction that creates it so the project never
384
+ * shows up at the top level first. Left out, or naming a folder that is gone, it lands at the
385
+ * top level.
386
+ */
387
+ public async createProject(meta: ProjectMeta, folder?: FolderId): Promise<ProjectId> {
388
+ const signedRid = await this.pl.withWriteTx("MLCreateProject", async (tx) => {
389
+ const tree = folder === undefined ? undefined : await openFoldersTx(tx, this.foldersRids);
390
+ const prj = await createProject(tx, meta);
347
391
  tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", prj);
392
+ const rid = await prj.globalId;
393
+ // A folder deleted while the project was being made costs the project its placement, not
394
+ // its existence.
395
+ if (tree !== undefined && tree.view.folders.some((candidate) => candidate.id === folder))
396
+ tree.place([{ kind: "project", id: asProjectId(resourceIdToString(rid)) }], folder);
348
397
  await tx.commit();
398
+ return rid;
349
399
  });
350
- await this.projectListTree.refreshState();
400
+ await Promise.all([
401
+ this.projectListTree.refreshState(),
402
+ ...(folder === undefined ? [] : [this.foldersTree.refreshState()]),
403
+ ]);
351
404
 
352
- const signedRid = await prj!.globalId;
353
- const projectId = resourceIdToString(signedRid) as ProjectId;
405
+ const projectId = asProjectId(resourceIdToString(signedRid));
354
406
  this.projectIdCache.set(projectId, signedRid);
355
407
  return projectId;
356
408
  }
357
409
 
358
- /** Updates project metadata */
410
+ /**
411
+ * Updates the project metadata fields the caller names, leaving the others as they are.
412
+ *
413
+ * A patch rather than a replacement because the label and the description are edited from two
414
+ * different places: a rename that carried a stale description alongside the new name would
415
+ * undo a description edit that happened in between, and the reverse.
416
+ *
417
+ * The label is stored trimmed, and the folder rule governs it, so a label another project
418
+ * inside the same folder already carries is refused rather than written — a folder or a
419
+ * template of that name beside it is no obstacle. A human typed it, and a name that silently becomes a different name is worse than one that is
420
+ * turned down. The check and the write share one transaction, so two renames racing for the
421
+ * same name cannot both win. Duplicates an account already holds are tolerated and never
422
+ * rewritten; see the name check of {@link openFoldersTx}.
423
+ */
359
424
  public async setProjectMeta(
360
425
  id: ProjectId,
361
- meta: ProjectMeta,
426
+ meta: Partial<ProjectMeta>,
362
427
  author?: AuthorMarker,
363
428
  ): Promise<void> {
364
429
  const rid = await this.resolveProjectId(id);
365
- await withProjectAuthored(
366
- this.env.projectHelper,
430
+ const label = meta.label?.trim();
431
+ const patch = label === undefined ? meta : { ...meta, label };
432
+ await this.pl.withWriteTx("ProjectAction: setProjectMeta", async (tx) => {
433
+ if (label !== undefined)
434
+ (await openFoldersTx(tx, this.foldersRids)).assertNameFree({ kind: "project", id }, label);
435
+ await withProjectAuthored(this.env.projectHelper, tx, rid, author, (prj) => {
436
+ prj.updateMeta(patch);
437
+ });
438
+ await tx.commit();
439
+ });
440
+ await this.projectListTree.refreshState();
441
+ }
442
+
443
+ //
444
+ // Folders
445
+ //
446
+
447
+ private get foldersRids(): FoldersRids {
448
+ return {
449
+ folders: this.foldersResourceId,
450
+ projects: this.projectListResourceId,
451
+ templates: this.templateListResourceId,
452
+ };
453
+ }
454
+
455
+ /** Creates a folder inside `parent`, or at the top level, and returns its id. A name already
456
+ * used there is rejected. */
457
+ public async createFolder(name: string, parent?: FolderId): Promise<FolderId> {
458
+ const id = await createFolder(this.pl, this.foldersRids, name, parent);
459
+ await this.foldersTree.refreshState();
460
+ return id;
461
+ }
462
+
463
+ /** Renames a folder. A name another folder beside it already carries is rejected. */
464
+ public async renameFolder(folder: FolderId, name: string): Promise<void> {
465
+ await renameFolder(this.pl, this.foldersRids, folder, name);
466
+ await this.foldersTree.refreshState();
467
+ }
468
+
469
+ /** Sets what a folder says about itself; blank clears it. Descriptions are in no namespace, so
470
+ * nothing is refused here. */
471
+ public async setFolderDescription(folder: FolderId, description: string): Promise<void> {
472
+ await setFolderDescription(this.pl, this.foldersRids, folder, description);
473
+ await this.foldersTree.refreshState();
474
+ }
475
+
476
+ /** The plan a move would produce — every item and the name it ends up with. Shown for
477
+ * confirmation, then handed back to {@link moveFolderItems} unchanged. */
478
+ public async previewFoldersMove(
479
+ items: readonly FoldersItem[],
480
+ destination?: FolderId,
481
+ ): Promise<FoldersMovePlanResult> {
482
+ return await previewFoldersMove(this.pl, this.foldersRids, items, destination);
483
+ }
484
+
485
+ /**
486
+ * Moves folders, projects and templates into one destination.
487
+ *
488
+ * The plan is recomputed inside the write transaction and the move commits only when it is
489
+ * identical to `confirmedPlan`; otherwise nothing is written and the fresh plan comes back to
490
+ * be confirmed again.
491
+ */
492
+ public async moveFolderItems(
493
+ items: readonly FoldersItem[],
494
+ destination: FolderId | undefined,
495
+ confirmedPlan: FoldersMovePlan,
496
+ ): Promise<FoldersMoveOutcome> {
497
+ const outcome = await moveFolderItems(
367
498
  this.pl,
368
- rid,
369
- author,
370
- (prj) => {
371
- prj.setMeta(meta);
372
- },
373
- { name: "setProjectMeta" },
499
+ this.foldersRids,
500
+ items,
501
+ destination,
502
+ confirmedPlan,
374
503
  );
375
- await this.projectListTree.refreshState();
504
+ if (outcome.ok)
505
+ await Promise.all([
506
+ this.foldersTree.refreshState(),
507
+ this.projectListTree.refreshState(),
508
+ this.templateListTree.refreshState(),
509
+ ]);
510
+ return outcome;
511
+ }
512
+
513
+ /** What deleting a folder would destroy: the subtree of folders, and everything in it. */
514
+ public async previewFolderDeletion(folder: FolderId): Promise<FoldersRemovalPlanResult> {
515
+ return await previewFolderDeletion(this.pl, this.foldersRids, folder);
516
+ }
517
+
518
+ /** Deletes a folder, every folder inside it, and every project and template held anywhere in
519
+ * that subtree.
520
+ * Refused as `needs-confirmation` until the removal it returns is passed back as
521
+ * `confirmedRemoval`, and as `plan-changed` if the subtree has changed since. */
522
+ public async deleteFolder(
523
+ folder: FolderId,
524
+ confirmedRemoval?: FoldersRemoval,
525
+ ): Promise<FoldersRemovalOutcome> {
526
+ const outcome = await deleteFolder(this.pl, this.foldersRids, folder, confirmedRemoval);
527
+ if (outcome.ok) {
528
+ // What the folder held is gone from the lists, so an id cached for it would resolve to a
529
+ // resource nothing holds any more.
530
+ for (const id of outcome.removal.projects) this.projectIdCache.delete(id);
531
+ for (const id of outcome.removal.templates) this.templateIdCache.delete(id);
532
+ await Promise.all([
533
+ this.foldersTree.refreshState(),
534
+ this.projectListTree.refreshState(),
535
+ this.templateListTree.refreshState(),
536
+ ]);
537
+ }
538
+ return outcome;
539
+ }
540
+
541
+ /**
542
+ * Replaces a folder document this build cannot read — one written by a newer version, or one
543
+ * nothing here can parse — with an empty one. Every folder is gone afterwards; every project and
544
+ * template stays and shows at the top level. Refused while the document can be read.
545
+ */
546
+ public async resetFolders(): Promise<void> {
547
+ await resetFolders(this.pl, this.foldersRids);
548
+ await this.foldersTree.refreshState();
549
+ }
550
+
551
+ /**
552
+ * Duplicates a folder and everything under it: the folders inside, a duplicate of every project
553
+ * in them, and a copy of every template.
554
+ *
555
+ * The copy lands beside the source, so only its root needs a name of its own — `X (Copy)`.
556
+ * Nothing inside is renamed: names are compared among siblings of one kind, and a copied folder's children
557
+ * are only ever compared with each other, where they came in distinct already.
558
+ *
559
+ * The subtree is read first and rebuilt in one write transaction, so the folders and everything
560
+ * they hold appear together or not at all.
561
+ */
562
+ public async duplicateFolder(folder: FolderId): Promise<void> {
563
+ const subtree = await this.loadFolderSubtree(folder, () => randomUUID());
564
+
565
+ const created = await this.pl.withWriteTx("MLDuplicateFolder", async (tx) => {
566
+ const tree = await openFoldersTx(tx, this.foldersRids);
567
+ const items: (FoldersLeafItem & { folder: string })[] = [];
568
+ const projects: SignedResourceId[] = [];
569
+ const templates: { id: TemplateId; rid: SignedResourceId }[] = [];
570
+
571
+ for (const project of subtree.projects) {
572
+ // The whole metadata carries over, label included: the duplicate lands in a folder of its
573
+ // own that holds nothing else, so there is nothing there for its name to collide with.
574
+ const meta = await tx.getKValueJson<ProjectMeta>(project.rid, ProjectMetaKey);
575
+ const copy = await duplicateProject(tx, project.rid, meta, this.env.projectHelper);
576
+ tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", copy);
577
+ const rid = await copy.globalId;
578
+ projects.push(rid);
579
+ items.push({
580
+ kind: "project",
581
+ id: asProjectId(resourceIdToString(rid)),
582
+ folder: project.folder,
583
+ });
584
+ }
585
+
586
+ for (const template of subtree.templates) {
587
+ // A stored template is immutable, so its copy is the same blob under a new resource —
588
+ // provenance and all.
589
+ const copy = createTemplate(
590
+ tx,
591
+ this.templateListResourceId,
592
+ { label: template.label, description: template.description },
593
+ template.data,
594
+ );
595
+ const rid = await copy.globalId;
596
+ const id = asTemplateId(resourceIdToString(rid));
597
+ templates.push({ id, rid });
598
+ items.push({ kind: "template", id, folder: template.folder });
599
+ }
600
+
601
+ tree.graft({ root: subtree.root, folders: subtree.folders, items }, subtree.parent);
602
+
603
+ await tx.commit();
604
+ return { projects, templates };
605
+ });
606
+
607
+ for (const rid of created.projects)
608
+ this.projectIdCache.set(asProjectId(resourceIdToString(rid)), rid);
609
+ for (const { id, rid } of created.templates) this.templateIdCache.set(id, rid);
610
+
611
+ await Promise.all([
612
+ this.foldersTree.refreshState(),
613
+ this.projectListTree.refreshState(),
614
+ this.templateListTree.refreshState(),
615
+ ]);
616
+ }
617
+
618
+ /**
619
+ * A folder subtree as a copy needs it: the folders under ids local to this read, the key the
620
+ * subtree's root goes by, the resource of every project in them, every template read whole, and
621
+ * the folder the source sits in — which is where a duplicate lands.
622
+ *
623
+ * Read in a transaction of its own, before the write that copies it: every template is a read,
624
+ * and none of it has to be atomic with the copying, because a project or a template that
625
+ * disappears meanwhile fails that write on its own.
626
+ */
627
+ private async loadFolderSubtree<Id extends string>(
628
+ folder: FolderId,
629
+ mint: () => Id,
630
+ ): Promise<FolderSubtree<Id>> {
631
+ return await this.pl.withReadTx("MLReadFolderSubtree", async (tx) => {
632
+ const tree = await openFoldersTx(tx, this.foldersRids);
633
+ const source = tree.view.folders.find((candidate) => candidate.id === folder);
634
+ if (source === undefined) throw new Error(`Folder ${folder} does not exist.`);
635
+
636
+ const { inSubtree, localId, folders } = foldersLocalSubtree(tree.view, folder, mint);
637
+
638
+ const projects: FolderSubtree<Id>["projects"] = [];
639
+ for (const project of tree.view.projects) {
640
+ if (project.folder === undefined || !inSubtree.has(project.folder)) continue;
641
+ const rid = tree.projectRids.get(project.id);
642
+ if (rid === undefined) throw notListedError("Project", project.id);
643
+ projects.push({ projectId: project.id, rid, folder: localId(project.folder) });
644
+ }
645
+
646
+ const templates: Promise<FolderSubtree<Id>["templates"][number]>[] = [];
647
+ for (const template of tree.view.templates) {
648
+ if (template.folder === undefined || !inSubtree.has(template.folder)) continue;
649
+ const rid = tree.templateRids.get(template.id);
650
+ if (rid === undefined) throw notListedError("Template", template.id);
651
+ const local = localId(template.folder);
652
+ templates.push(
653
+ readStoredTemplate(tx, template.id, rid).then((stored) => ({ ...stored, folder: local })),
654
+ );
655
+ }
656
+
657
+ return {
658
+ ...(source.parent === undefined ? {} : { parent: source.parent }),
659
+ root: localId(folder),
660
+ folders,
661
+ projects,
662
+ templates: await Promise.all(templates),
663
+ };
664
+ });
376
665
  }
377
666
 
378
667
  /**
@@ -599,41 +888,85 @@ export class MiddleLayer {
599
888
  * A block that cannot be expressed as a template entry stores nothing at all, and every
600
889
  * such block is reported — fixing an unexportable project takes one pass, not one per block.
601
890
  *
891
+ * The template lands beside its project, and is named by the rule templates there are named
892
+ * by: a label the caller chose is stored trimmed and refused if another template there already
893
+ * carries it, and without one the project's own label is taken — suffixed only when a template
894
+ * there already answers to it. The project is not a template, so its own name is no obstacle.
895
+ *
896
+ * The template's description is the one the caller gives, stored trimmed; blank means none.
897
+ * Without one the template is stored undescribed, whatever the project's own description says.
898
+ *
602
899
  * @param projectId project to snapshot
603
- * @param label label for the template; defaults to the project's own label
900
+ * @param label label for the template; defaults to the project's own label, made free
901
+ * @param description what the template is for; blank or absent stores none
604
902
  */
605
903
  public async saveProjectAsTemplate(
606
904
  projectId: ProjectId,
607
905
  label?: string,
906
+ description?: string,
608
907
  ): Promise<SaveProjectAsTemplateOutcome> {
609
908
  const outcome = await this.exportProjectAsTemplate(projectId);
610
909
  if (!outcome.ok) return { ok: false, problems: outcome.problems };
611
910
 
612
911
  const rid = await this.resolveProjectId(projectId);
613
- let tpl: ResourceRef;
614
- await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
912
+ const wanted = label?.trim();
913
+ const signedRid = await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
615
914
  const meta = await tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey);
616
- tpl = createTemplate(tx, this.templateListResourceId, label ?? meta.label, {
617
- schemaVersion: 1,
618
- document: outcome.document,
619
- sourceProjectLabel: meta.label,
620
- });
915
+ const tree = await openFoldersTx(tx, this.foldersRids);
916
+ const taken = tree.namesTakenBeside(projectId, "template");
917
+ if (wanted !== undefined && foldersNameTaken(wanted, taken))
918
+ throw new Error(nameTakenMessage("template", wanted));
919
+ const name = wanted ?? foldersUniqueName(meta.label, taken);
920
+
921
+ const tpl = createTemplate(
922
+ tx,
923
+ this.templateListResourceId,
924
+ { label: name, description },
925
+ { schemaVersion: 1, document: outcome.document, sourceProjectLabel: meta.label },
926
+ );
927
+
928
+ // A template taken from a project belongs beside that project, and the placement rides the
929
+ // same transaction so the two can never disagree about where it is. Its name was chosen
930
+ // free there, so nothing can send it anywhere else.
931
+ const created = await tpl.globalId;
932
+ tree.place(
933
+ [{ kind: "template", id: asTemplateId(resourceIdToString(created)) }],
934
+ tree.folderOf(projectId),
935
+ );
621
936
  await tx.commit();
937
+ return created;
622
938
  });
623
- await this.templateListTree.refreshState();
939
+ await Promise.all([this.templateListTree.refreshState(), this.foldersTree.refreshState()]);
624
940
 
625
- const signedRid = await tpl!.globalId;
626
- const templateId = resourceIdToString(signedRid) as TemplateId;
941
+ const templateId = asTemplateId(resourceIdToString(signedRid));
627
942
  this.templateIdCache.set(templateId, signedRid);
628
943
  return { ok: true, templateId };
629
944
  }
630
945
 
631
- /** Changes a template's label. The stored document is immutable and stays untouched —
632
- * improving a template means saving a new one. */
946
+ /**
947
+ * Changes a template's label. The stored document is immutable and stays untouched —
948
+ * improving a template means saving a new one.
949
+ *
950
+ * The label is stored trimmed. One another template beside it already carries is refused, in
951
+ * the transaction that writes it; a folder or a project of that name beside it is no obstacle.
952
+ */
633
953
  public async renameTemplate(id: TemplateId, label: string): Promise<void> {
634
954
  const rid = await this.resolveTemplateId(id);
955
+ const wanted = label.trim();
635
956
  await this.pl.withWriteTx("MLRenameTemplate", async (tx) => {
636
- renameTemplate(tx, rid, label);
957
+ (await openFoldersTx(tx, this.foldersRids)).assertNameFree({ kind: "template", id }, wanted);
958
+ renameTemplate(tx, rid, wanted);
959
+ await tx.commit();
960
+ });
961
+ await this.templateListTree.refreshState();
962
+ }
963
+
964
+ /** Sets what a stored template says about itself; blank clears it. The stored document is not
965
+ * touched, so it stays byte-identical. */
966
+ public async setTemplateDescription(id: TemplateId, description: string): Promise<void> {
967
+ const rid = await this.resolveTemplateId(id);
968
+ await this.pl.withWriteTx("MLSetTemplateDescription", async (tx) => {
969
+ setTemplateDescription(tx, rid, description);
637
970
  await tx.commit();
638
971
  });
639
972
  await this.templateListTree.refreshState();
@@ -685,13 +1018,19 @@ export class MiddleLayer {
685
1018
  * @param id template to apply
686
1019
  * @param label label for the new project
687
1020
  * @param provider where each entry's block comes from
688
- * @param options `allowUnstable` widens resolution to pre-release implementations
1021
+ * @param options `allowUnstable` widens resolution to pre-release implementations; `folder` is
1022
+ * where the new project lands
689
1023
  */
690
1024
  public async createProjectFromTemplate(
691
1025
  id: TemplateId,
692
1026
  label: string,
693
1027
  provider: BlockPackProvider,
694
- options: { allowUnstable?: boolean; author?: AuthorMarker } = {},
1028
+ options: {
1029
+ allowUnstable?: boolean;
1030
+ author?: AuthorMarker;
1031
+ /** Where the project lands; the top level when left out. */
1032
+ folder?: FolderId;
1033
+ } = {},
695
1034
  ): Promise<CreateProjectFromTemplateOutcome> {
696
1035
  const stored = await this.getTemplateData(id);
697
1036
 
@@ -700,7 +1039,7 @@ export class MiddleLayer {
700
1039
  });
701
1040
  if (preparation.problems.length > 0) return { ok: false, problems: preparation.problems };
702
1041
 
703
- const projectId = await this.createProject({ label });
1042
+ const projectId = await this.createProject({ label }, options.folder);
704
1043
  const report = await this.applyPreparedEntries(
705
1044
  projectId,
706
1045
  stored.document,
@@ -725,12 +1064,9 @@ export class MiddleLayer {
725
1064
 
726
1065
  // Cache miss — scan template list fields to find the matching resource
727
1066
  const rid = await this.pl.withReadTx("ResolveTemplateId", async (tx) => {
728
- const data = await tx.getResourceData(this.templateListResourceId, true);
729
- for (const f of data.fields) {
730
- if (isNullSignedResourceId(f.value)) continue;
731
- if (resourceIdToString(f.value) === (templateId as string)) return f.value;
732
- }
733
- throw new Error(`Template ${templateId} not found in template list.`);
1067
+ const entry = (await listedById(tx, this.templateListResourceId)).get(templateId);
1068
+ if (entry === undefined) throw notListedError("Template", templateId);
1069
+ return entry.rid;
734
1070
  });
735
1071
 
736
1072
  this.templateIdCache.set(templateId, rid);
@@ -741,17 +1077,9 @@ export class MiddleLayer {
741
1077
  * destruction of all attached objects, like files, analysis results etc. */
742
1078
  public async deleteProject(id: ProjectId): Promise<void> {
743
1079
  await this.pl.withWriteTx("MLRemoveProject", async (tx) => {
744
- const data = await tx.getResourceData(this.projectListResourceId, true);
745
- let fieldName: string | undefined;
746
- for (const f of data.fields) {
747
- if (isNullSignedResourceId(f.value)) continue;
748
- if (resourceIdToString(f.value) === (id as string)) {
749
- fieldName = f.name;
750
- break;
751
- }
752
- }
753
- if (fieldName === undefined) throw new Error(`Project ${id} not found in project list.`);
754
- tx.removeField(field(this.projectListResourceId, fieldName));
1080
+ const entry = (await listedById(tx, this.projectListResourceId)).get(id);
1081
+ if (entry === undefined) throw notListedError("Project", id);
1082
+ tx.removeField(field(this.projectListResourceId, entry.fieldName));
755
1083
  await tx.commit();
756
1084
  });
757
1085
  this.projectIdCache.delete(id);
@@ -759,11 +1087,18 @@ export class MiddleLayer {
759
1087
  }
760
1088
 
761
1089
  /**
762
- * Duplicates an existing project and adds the copy to this user's project list.
1090
+ * Duplicates an existing project and adds the copy to this user's project list, beside the
1091
+ * project it was copied from.
1092
+ *
1093
+ * Without `rename` the copy is named by the rule everything beside the source is named by: the
1094
+ * source's own label, suffixed to be free there — `X (Copy)`. The name is chosen inside the
1095
+ * transaction that creates the copy, against the tree that transaction reads.
763
1096
  *
764
1097
  * @param srcProjectId - project id of the project to duplicate
765
1098
  * @param rename - optional function that receives the source label and all existing
766
- * project labels (read within the same transaction), and returns the label for the copy
1099
+ * project labels (read within the same transaction), and returns the label for the copy.
1100
+ * A label chosen this way is the caller's and is never suffixed: when it is already taken
1101
+ * beside the source, the copy lands at the top level instead.
767
1102
  */
768
1103
  public async duplicateProject(
769
1104
  srcProjectId: ProjectId,
@@ -772,42 +1107,51 @@ export class MiddleLayer {
772
1107
  const sourceRid = await this.resolveProjectId(srcProjectId);
773
1108
 
774
1109
  const newPrj: ResourceRef = await this.pl.withWriteTx("MLDuplicateProject", async (tx) => {
775
- // Read source project meta
776
1110
  const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
777
-
778
- // Read all existing project labels from the project list (parallel reads)
779
- const projectListData = await tx.getResourceData(this.projectListResourceId, true);
780
- const projectRids = projectListData.fields
781
- .map((f) => f.value)
782
- .filter(isNotNullSignedResourceId);
783
- const existingLabels = (
784
- await Promise.all(
785
- projectRids.map((rid) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)),
786
- )
787
- ).map((m) => m.label);
788
-
789
- // Compute new label
790
- const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
791
-
792
- // Create the duplicate
1111
+ const tree = await openFoldersTx(tx, this.foldersRids);
1112
+
1113
+ // The source's own label is taken beside it even when the tree cannot say what else is.
1114
+ const label =
1115
+ rename === undefined
1116
+ ? foldersUniqueName(sourceMeta.label, [
1117
+ sourceMeta.label,
1118
+ ...tree.namesTakenBeside(srcProjectId, "project"),
1119
+ ])
1120
+ : rename(sourceMeta.label, await existingProjectLabels(tx, this.projectListResourceId));
1121
+
1122
+ // The whole source metadata carries over, so a copy keeps what the original said about
1123
+ // itself; only the label is the copy's own.
793
1124
  const newPrj = await duplicateProject(
794
1125
  tx,
795
1126
  sourceRid,
796
- { label: newLabel },
1127
+ { ...sourceMeta, label },
797
1128
  this.env.projectHelper,
798
1129
  );
799
1130
 
800
1131
  // Attach to project list with a random UUID field name
801
1132
  tx.createField(field(this.projectListResourceId, randomUUID()), "Dynamic", newPrj);
1133
+
1134
+ // A copy belongs beside the project it was copied from, placed in the same transaction so
1135
+ // it is never shown at the top level first.
1136
+ const created: FoldersLeafItem = {
1137
+ kind: "project",
1138
+ id: asProjectId(resourceIdToString(await newPrj.globalId)),
1139
+ };
1140
+ tree.place(
1141
+ [created],
1142
+ rename === undefined
1143
+ ? tree.folderOf(srcProjectId)
1144
+ : inheritedFolder(tree.view, srcProjectId, created, label),
1145
+ );
802
1146
  await tx.commit();
803
1147
 
804
1148
  return newPrj;
805
1149
  });
806
1150
 
807
- await this.projectListTree.refreshState();
1151
+ await Promise.all([this.projectListTree.refreshState(), this.foldersTree.refreshState()]);
808
1152
 
809
1153
  const signedRid = await newPrj.globalId;
810
- const newProjectId = resourceIdToString(signedRid) as ProjectId;
1154
+ const newProjectId = asProjectId(resourceIdToString(signedRid));
811
1155
  this.projectIdCache.set(newProjectId, signedRid);
812
1156
  return newProjectId;
813
1157
  }
@@ -816,7 +1160,7 @@ export class MiddleLayer {
816
1160
  * Duplicates a project into another user's root, minted in the TARGET user's color so the target
817
1161
  * owns it. Sibling of {@link duplicateProject}, but writes into a different root. The source
818
1162
  * project (on the current client root) is referenced cross-color for its block data, kept alive
819
- * by refcounting, exactly like accepting a shared project. Works both ways: pull (while
1163
+ * by refcounting, exactly like a project copied out of a share. Works both ways: pull (while
820
1164
  * impersonating a user, copy their project to yourself) and push (from your own root, copy a
821
1165
  * project to a user). Admin cross-root op; requires the crossTreeRefs:v1 backend capability.
822
1166
  */
@@ -839,21 +1183,15 @@ export class MiddleLayer {
839
1183
 
840
1184
  // Source label + the target's existing labels, for collision-aware renaming.
841
1185
  const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
842
- const targetListData = await tx.getResourceData(targetProjectListRid, true);
843
- const existingLabels = (
844
- await Promise.all(
845
- targetListData.fields
846
- .map((f) => f.value)
847
- .filter(isNotNullSignedResourceId)
848
- .map((rid) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)),
849
- )
850
- ).map((m) => m.label);
1186
+ const existingLabels = await existingProjectLabels(tx, targetProjectListRid);
851
1187
  const newLabel = rename ? rename(sourceMeta.label, existingLabels) : sourceMeta.label;
852
1188
 
1189
+ // The whole source metadata carries over, so a copy keeps what the original said about
1190
+ // itself; only the label is the copy's own.
853
1191
  const newPrj = await duplicateProject(
854
1192
  tx,
855
1193
  sourceRid,
856
- { label: newLabel },
1194
+ { ...sourceMeta, label: newLabel },
857
1195
  this.env.projectHelper,
858
1196
  );
859
1197
  tx.createField(field(targetProjectListRid, randomUUID()), "Dynamic", newPrj);
@@ -866,139 +1204,37 @@ export class MiddleLayer {
866
1204
  //
867
1205
 
868
1206
  /**
869
- * Shares the given projects (Copy & Share). Snapshots the projects, creates one envelope, and
870
- * grants it — all in one atomic write transaction, so a failed grant rolls the whole thing back
871
- * and the outbox is left as it was.
872
- *
873
- * Two variants (see {@link ShareProjectsOptions}):
874
- * - `{ recipients }` — one writable grant per named recipient; the envelope expires after the
875
- * default TTL (`sharedAt + envelopeTtlMs`).
876
- * - `{ everyone: true }` — one make-public grant (backend rewrites the target to the
877
- * everyone-user); the envelope's `expiresAt` is `null`, so it never expires.
1207
+ * Shares the given projects (Copy & Share): snapshots them into one envelope, in the transaction
1208
+ * {@link shareEnvelope} describes.
878
1209
  *
879
1210
  * v1 always passes `mode: "copy"`.
880
1211
  */
881
1212
  public async shareProjects(
882
1213
  projectIds: ProjectId[],
883
1214
  options: ShareProjectsOptions,
884
- ): Promise<void> {
1215
+ ): Promise<ShareOutcome> {
885
1216
  if (projectIds.length === 0) throw new Error("shareProjects: no projects given");
886
1217
 
887
- // Everyone + replace: refresh the existing everyone-share of this project under its stable
888
- // shareId (so recipients who already decided aren't re-prompted), if one exists. Found
889
- // automatically by project overlap; falls through to a fresh share when none exists.
890
- if ("everyone" in options && options.replace) {
891
- const priorEveryone = (await this.findSupersedableEnvelopes(projectIds)).find(
892
- (p) => p.everyone,
893
- );
894
- if (priorEveryone !== undefined) {
895
- await this.changeShare(priorEveryone.shareId, { title: options.title });
896
- return;
897
- }
898
- }
899
-
900
- await this.createNewShare(projectIds, options);
901
- }
902
-
903
- /**
904
- * Mints a fresh share: snapshots the projects into one new envelope (a fresh shareId),
905
- * supersedes prior shares of the same project, and grants it — all in one atomic write
906
- * transaction, so a failed grant rolls the whole thing back and the outbox is left as it was.
907
- * The everyone-refresh path is the {@link changeShare} branch of {@link shareProjects}; this is
908
- * the mint-a-new-envelope branch.
909
- */
910
- private async createNewShare(
911
- projectIds: ProjectId[],
912
- options: ShareProjectsOptions,
913
- ): Promise<void> {
914
- const everyone = "everyone" in options;
915
1218
  const sources: EnvelopeProjectSource[] = await Promise.all(
916
1219
  projectIds.map(
917
1220
  async (id): Promise<EnvelopeProjectSource> => ({
918
- kind: "fresh",
919
1221
  projectId: id,
920
1222
  sourceRid: await this.resolveProjectId(id),
921
1223
  }),
922
1224
  ),
923
1225
  );
924
- const sender = this.currentUserLogin ?? "";
925
- // Targeted share: sharedAt + ttl. Share-with-everybody: never expires (null).
926
- const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
927
-
928
- // Supersede prior shares of the same project(s) so they never pile up. Resolved before
929
- // the write tx (ListGrants is a separate RPC). Everyone-share supersedes a prior
930
- // everyone-share of the same project; a targeted share pulls each named recipient out of
931
- // any prior share of that project, deleting that share if it ends up with no recipients.
932
- const priors = await this.findSupersedableEnvelopes(projectIds);
933
-
934
- await this.pl.withWriteTx("MLShareProjects", async (tx) => {
935
- if (everyone) {
936
- for (const prior of priors) {
937
- if (prior.everyone) tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
938
- }
939
- } else {
940
- const newRecipients = new Set(options.recipients);
941
- for (const prior of priors) {
942
- if (prior.everyone) continue; // a single user can't be pulled from an everyone-grant
943
- const toRemove = prior.recipients.filter((u) => newRecipients.has(u));
944
- if (toRemove.length === 0) continue;
945
- const remaining = prior.recipients.filter((u) => !newRecipients.has(u));
946
- if (remaining.length === 0) {
947
- // Nobody left on the old share — drop the whole envelope.
948
- tx.removeField(field(this.sharingOutboxResourceId, prior.fieldName));
949
- } else {
950
- for (const u of toRemove) tx.revokeAccess(prior.rid, u);
951
- }
952
- }
953
- }
954
1226
 
955
- const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
1227
+ return await this.shareEnvelope("MLShareProjects", options, { writable: true }, (tx, meta) =>
1228
+ buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
956
1229
  mode: options.mode,
957
- sender,
958
- title: options.title,
959
- expiresAt,
960
- });
961
-
962
- // Grant in the same transaction, atomic with the create.
963
- await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, {
964
- writable: true,
965
- });
966
-
967
- await tx.commit();
968
- });
969
-
970
- await this.sharingOutboxTree.refreshState();
971
- }
972
-
973
- /**
974
- * Grants one freshly built envelope inside the transaction that created it: a single make-public
975
- * grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
976
- * target to the everyone-user, gated by role + permission ceiling), or one grant per named
977
- * recipient.
978
- *
979
- * `writable` is not a preference. A project pack needs a writable grant because accepting copies
980
- * the snapshots out of the envelope, and the cross-color attach rule permits that only to a
981
- * writable grant holder. A template share copies nothing — the document sits in the envelope's
982
- * own immutable data — so it is granted read-only, and must be: a writable everyone-grant would
983
- * hand every user on the server write access to the envelope.
984
- */
985
- private async grantShareEnvelope(
986
- tx: PlTransaction,
987
- envelope: ResourceRef,
988
- everyone: boolean,
989
- recipients: string[],
990
- permissions: { writable: boolean },
991
- ): Promise<void> {
992
- const gid = await envelope.globalId;
993
- if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
994
- else for (const r of recipients) tx.grantAccess(gid, r, permissions);
1230
+ ...meta,
1231
+ }),
1232
+ );
995
1233
  }
996
1234
 
997
1235
  /**
998
1236
  * Shares one stored template. The envelope carries the document itself, so there is no project
999
1237
  * snapshot and no resource for the recipient to copy out — which is why the grant is read-only.
1000
- * The cost of that is the donor's receipt: nobody can write an acceptance onto a read-only
1001
- * envelope, so a template share never reports who accepted it.
1002
1238
  *
1003
1239
  * Nothing about the document is checked: a stored template is shareable by virtue of existing.
1004
1240
  * An entry the recipient cannot resolve — a block installed from a folder on the sender's
@@ -1008,262 +1244,153 @@ export class MiddleLayer {
1008
1244
  * @param id template to share
1009
1245
  * @param options recipients XOR everyone, plus the title recipients see
1010
1246
  */
1011
- public async shareTemplate(
1012
- id: TemplateId,
1013
- options: ShareTemplateOptions,
1014
- ): Promise<ShareTemplateOutcome> {
1015
- const template = await this.loadTemplateForShare(id);
1016
-
1017
- const everyone = "everyone" in options;
1018
- const sender = this.currentUserLogin ?? "";
1019
- // Targeted share: sharedAt + ttl. Share-with-everybody: never expires (null).
1020
- const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
1247
+ public async shareTemplate(id: TemplateId, options: ShareTemplateOptions): Promise<ShareOutcome> {
1248
+ const rid = await this.resolveTemplateId(id);
1249
+ const stored = await this.pl.withReadTx("MLReadStoredTemplate", (tx) =>
1250
+ readStoredTemplate(tx, id, rid),
1251
+ );
1021
1252
 
1022
- let shareId: ShareId | undefined;
1023
- await this.pl.withWriteTx("MLShareTemplate", async (tx) => {
1024
- const { envelope, data } = buildTemplateShareEnvelope(
1253
+ return await this.shareEnvelope("MLShareTemplate", options, { writable: false }, (tx, meta) =>
1254
+ buildTemplateShareEnvelope(
1025
1255
  tx,
1026
1256
  this.sharingOutboxResourceId,
1027
- template,
1028
- { sender, title: options.title, expiresAt },
1029
- );
1030
- shareId = data.shareId;
1031
- await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, {
1032
- writable: false,
1033
- });
1034
- await tx.commit();
1035
- });
1036
-
1037
- await this.sharingOutboxTree.refreshState();
1038
- return { shareId: shareId! };
1039
- }
1040
-
1041
- /** The document and the label of a template about to be shared. The label is what the
1042
- * recipient's own list will show, so it travels with the document. */
1043
- private async loadTemplateForShare(
1044
- id: TemplateId,
1045
- ): Promise<{ document: ProjectTemplateV1; label: string }> {
1046
- const rid = await this.resolveTemplateId(id);
1047
- return await this.pl.withReadTx("MLReadTemplateForShare", async (tx) => {
1048
- const rd = await tx.getResourceData(rid, false);
1049
- if (rd.data === undefined) throw new Error(`Template ${id} carries no document.`);
1050
- return {
1051
- document: decodeStoredTemplateData(rd.data).document,
1052
- label: await tx.getKValueJson<string>(rid, TemplateLabelKey),
1053
- };
1054
- });
1257
+ {
1258
+ document: stored.data.document,
1259
+ label: stored.label,
1260
+ ...(stored.description === undefined ? {} : { description: stored.description }),
1261
+ source: id,
1262
+ },
1263
+ meta,
1264
+ ),
1265
+ );
1055
1266
  }
1056
1267
 
1057
1268
  /**
1058
- * Changes a share in place (same {@link ShareId}), in one write transaction: re-snapshots live
1059
- * source projects and carries deleted ones' snapshots forward; applies edited recipients/title;
1060
- * transfers already-decided recipients' accept/reject records (they keep their copy and aren't
1061
- * re-prompted); re-grants; drops the old envelope.
1269
+ * Shares one folder and everything under it: the subtree's folders, every project in them
1270
+ * snapshotted, and every template carried whole.
1062
1271
  *
1063
- * `opts.recipients` is the full targeted set (decided users are always kept). `opts.title`
1064
- * replaces the title — omit keeps the current one. `opts.everyone` upgrades targeted ->
1065
- * everyone; the reverse is impossible and ignored.
1272
+ * The grant is writable, because a folder holding projects is copied out of the envelope the
1273
+ * way a project pack is. An everyone-share of a folder therefore hands every user on the server
1274
+ * write access to the envelope — the same trade a project share already makes.
1066
1275
  *
1067
- * `opts.projectActions` is a per-source-project decision, keyed by projectId: `update`
1068
- * re-snapshots the live source (falls back to carry if the source is gone), `keep` carries the
1069
- * existing snapshot (and its timestamp), `remove` drops the project from the pack. A project not
1070
- * in the map defaults to `keep`. Omit the whole map for the legacy auto behavior (live sources
1071
- * updated, gone ones kept) — the everyone-refresh path relies on that.
1276
+ * Folder ids do not travel. What the recipient gets is the shape of the subtree, rebuilt under
1277
+ * a folder of their own choosing with ids their own document mints.
1072
1278
  *
1073
- * `opts.templateId` is required for, and only used by, a share that carries a template: a stored
1074
- * template is immutable, so an improved one is a different template and the share cannot re-read
1075
- * the one it started from — the caller names the new target. Every other option means the same
1076
- * thing for both kinds of share.
1279
+ * @param folder folder to share; everything beneath it goes with it
1280
+ * @param options recipients XOR everyone, plus the title recipients see
1077
1281
  */
1078
- public async changeShare(
1079
- shareId: ShareId,
1080
- opts: {
1081
- recipients?: string[];
1082
- everyone?: boolean;
1083
- title?: string;
1084
- projectActions?: Record<ProjectId, ProjectChangeAction>;
1085
- templateId?: TemplateId;
1086
- } = {},
1087
- ): Promise<void> {
1088
- // Read outside the write tx: it is two round-trips of its own.
1089
- const target =
1090
- opts.templateId === undefined ? undefined : await this.loadTemplateForShare(opts.templateId);
1091
-
1092
- await this.pl.withWriteTx("MLChangeShare", async (tx) => {
1093
- const old = await this.resolveOutboxEnvelope(tx, shareId);
1094
- if (old === undefined)
1095
- throw new Error(`changeShare: no live share with id ${shareId} in the outbox.`);
1096
-
1097
- const self = this.currentUserLogin ?? "";
1098
- const grants = await tx.listGrants(old.rid);
1099
- // A targeted share may be upgraded to everyone; an everyone-share can't be narrowed back.
1100
- const everyone = grants.some((g) => isEveryoneUserLogin(g.user)) || opts.everyone === true;
1101
- const priorRecipients = grants
1102
- .filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self)
1103
- .map((g) => g.user);
1104
-
1105
- if (old.data.payload.kind === "template") {
1106
- if (target === undefined)
1107
- throw new Error(
1108
- `changeShare: share ${shareId} carries a template, so it needs an explicit target ` +
1109
- "template — a stored template never changes, so an improved one is a different template.",
1110
- );
1111
- const recipients = everyone ? [] : (opts.recipients ?? priorRecipients);
1112
-
1113
- // Same shareId, same outbox field name — detach the old field before rebuilding, or they collide.
1114
- tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
1115
- const { envelope } = buildTemplateShareEnvelope(tx, this.sharingOutboxResourceId, target, {
1116
- sender: self,
1117
- title: opts.title === undefined ? old.data.title : opts.title.trim(),
1118
- expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs,
1119
- shareId, // SAME shareId — the essence of change
1120
- });
1121
-
1122
- // Nothing to transfer: a read-only grant cannot write an acceptance, so a template share
1123
- // never accumulated one.
1124
- await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: false });
1125
- await tx.commit();
1126
- return;
1127
- }
1128
-
1129
- // Read the old envelope's project snapshots (uuid -> rid) and accept/reject records.
1130
- const oldRd = await tx.getResourceData(old.rid, true);
1131
- const snapshotByUuid = new Map<string, SignedResourceId>();
1132
- const acceptances: { login: string; acc: EnvelopeAcceptance }[] = [];
1133
- for (const f of oldRd.fields) {
1134
- if (isNullSignedResourceId(f.value)) continue;
1135
- if (isEnvelopeProjectField(f.name)) {
1136
- snapshotByUuid.set(envelopeProjectFieldUuid(f.name), f.value);
1137
- } else if (isAcceptanceField(f.name)) {
1138
- const raw = (await tx.getResourceData(f.value, false)).data;
1139
- if (raw === undefined) continue;
1140
- acceptances.push({
1141
- login: acceptanceFieldLogin(f.name),
1142
- acc: cachedDeserialize(raw) as EnvelopeAcceptance,
1143
- });
1144
- }
1145
- }
1146
- const decidedLogins = acceptances.map((a) => a.login);
1147
-
1148
- // Everyone-shares ignore recipients; targeted shares keep decided users plus the edited set.
1149
- const recipients = everyone
1150
- ? []
1151
- : Array.from(new Set([...(opts.recipients ?? priorRecipients), ...decidedLogins]));
1152
-
1153
- // Live source projects by persistable id — these get a fresh snapshot.
1154
- const liveProjects = new Map<string, SignedResourceId>();
1155
- const projList = await tx.getResourceData(this.projectListResourceId, true);
1156
- for (const f of projList.fields) {
1157
- if (isNullSignedResourceId(f.value)) continue;
1158
- liveProjects.set(resourceIdToString(f.value), f.value);
1159
- }
1160
-
1161
- // Per project (keyed by field uuid), apply the caller's decision (default `keep`); with no
1162
- // projectActions map, fall back to the legacy auto behavior: update a live source, keep a gone one.
1163
- const actions = opts.projectActions;
1164
- const sources: EnvelopeProjectSource[] = [];
1165
- const oldProjects = envelopeProjectMap(old.data);
1166
- for (const uuid of Object.keys(oldProjects) as ProjectFieldUuid[]) {
1167
- const { label, source, updatedAt } = oldProjects[uuid];
1168
- const liveRid = liveProjects.get(source);
1169
-
1170
- const action = actions
1171
- ? (actions[source] ?? "keep")
1172
- : liveRid !== undefined
1173
- ? "update"
1174
- : "keep";
1175
- if (action === "remove") continue;
1176
-
1177
- if (action === "update" && liveRid !== undefined) {
1178
- sources.push({ kind: "fresh", projectId: source, sourceRid: liveRid });
1179
- } else {
1180
- // keep, or an "update" whose source vanished before commit (deleted meanwhile, e.g. from
1181
- // another client): carry the prior snapshot. Liveness is read inside this write tx — race-safe.
1182
- const snapshotRid = snapshotByUuid.get(uuid);
1183
- if (snapshotRid !== undefined)
1184
- sources.push({ kind: "carry", projectId: source, label, snapshotRid, updatedAt });
1185
- }
1186
- }
1187
-
1188
- // Omit (undefined) keeps the current title; a provided value replaces it.
1189
- const title = opts.title === undefined ? old.data.title : opts.title.trim();
1190
- const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
1191
-
1192
- // Same shareId, same outbox field name — detach the old field before rebuilding, or they collide.
1193
- tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
1194
-
1195
- const { envelope } = await buildShareEnvelope(tx, this.sharingOutboxResourceId, sources, {
1196
- mode: old.data.mode,
1197
- sender: self,
1198
- title,
1199
- expiresAt,
1200
- shareId, // SAME shareId — the essence of change
1201
- });
1282
+ public async shareFolder(folder: FolderId, options: ShareFolderOptions): Promise<ShareOutcome> {
1283
+ const loaded = await this.loadFolderSubtree(folder, newEnvelopeFolderId);
1284
+ const subtree: EnvelopeFolderSubtree = {
1285
+ source: folder,
1286
+ folders: loaded.folders,
1287
+ projects: loaded.projects.map((project) => ({
1288
+ projectId: project.projectId,
1289
+ sourceRid: project.rid,
1290
+ folder: project.folder,
1291
+ })),
1292
+ templates: loaded.templates.map((template) => ({
1293
+ document: template.data.document,
1294
+ label: template.label,
1295
+ ...(template.description === undefined ? {} : { description: template.description }),
1296
+ folder: template.folder,
1297
+ })),
1298
+ };
1202
1299
 
1203
- // Transfer the decided users' records onto the new envelope (donor-written copies).
1204
- for (const { login, acc } of acceptances) {
1205
- if (!everyone && !recipients.includes(login)) continue;
1206
- writeEnvelopeAcceptance(tx, envelope, login, acc.action, acc.timestamp);
1207
- }
1300
+ return await this.shareEnvelope("MLShareFolder", options, { writable: true }, (tx, meta) =>
1301
+ buildFolderShareEnvelope(tx, this.sharingOutboxResourceId, subtree, meta),
1302
+ );
1303
+ }
1208
1304
 
1209
- await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: true });
1305
+ /**
1306
+ * The one transaction every share is made in: the shares named by `options.replace` are
1307
+ * dropped, the envelope `build` makes is created, and it is granted — all at once, so a failed
1308
+ * grant rolls the whole thing back and the outbox is left as it was.
1309
+ *
1310
+ * A share with named recipients grants each of them and expires after the default TTL
1311
+ * (`sharedAt + envelopeTtlMs`). A share with everyone is one make-public grant, and its
1312
+ * `expiresAt` is `null`, so it never expires.
1313
+ */
1314
+ private async shareEnvelope(
1315
+ txName: string,
1316
+ options: ShareOptions,
1317
+ permissions: { writable: boolean },
1318
+ build: (
1319
+ tx: PlTransaction,
1320
+ meta: { sender: string; title: string; expiresAt: number | null },
1321
+ ) =>
1322
+ | { envelope: ResourceRef; data: EnvelopeData }
1323
+ | Promise<{ envelope: ResourceRef; data: EnvelopeData }>,
1324
+ ): Promise<ShareOutcome> {
1325
+ const everyone = "everyone" in options;
1326
+ const meta = {
1327
+ sender: this.currentUserLogin ?? "",
1328
+ title: options.title,
1329
+ expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs,
1330
+ };
1210
1331
 
1332
+ const outcome = await this.pl.withWriteTx(txName, async (tx) => {
1333
+ await this.dropShares(tx, options.replace);
1334
+ const { envelope, data } = await build(tx, meta);
1335
+ await this.grantShareEnvelope(
1336
+ tx,
1337
+ envelope,
1338
+ everyone,
1339
+ everyone ? [] : options.recipients,
1340
+ permissions,
1341
+ );
1211
1342
  await tx.commit();
1343
+ return { shareId: data.shareId };
1212
1344
  });
1213
1345
 
1214
1346
  await this.sharingOutboxTree.refreshState();
1347
+ return outcome;
1215
1348
  }
1216
1349
 
1217
1350
  /**
1218
- * Finds the donor's own outgoing envelopes built from any of the given source projects —
1219
- * the supersede candidates for a fresh share of the same project(s). Reads each envelope's
1220
- * recipient set via `ListGrants` so the caller can pull individual recipients or detect an
1221
- * everyone-share.
1351
+ * Detaches the named shares from the donor's outbox inside the caller's transaction, so a
1352
+ * replacement and the shares it supersedes land together or not at all.
1353
+ *
1354
+ * A share that no longer resolves is skipped rather than reported: the caller names shares the
1355
+ * author saw a moment ago, and one revoked meanwhile is already in the wanted state.
1222
1356
  */
1223
- private async findSupersedableEnvelopes(projectIds: ProjectId[]): Promise<
1224
- {
1225
- fieldName: string;
1226
- rid: SignedResourceId;
1227
- shareId: ShareId;
1228
- everyone: boolean;
1229
- recipients: string[];
1230
- }[]
1231
- > {
1232
- const wanted = new Set(projectIds);
1233
-
1234
- const matched = await this.pl.withReadTx("MLFindSupersede", async (tx) => {
1235
- const outbox = await tx.getResourceData(this.sharingOutboxResourceId, true);
1236
- const out: { fieldName: string; rid: SignedResourceId; shareId: ShareId }[] = [];
1237
- for (const f of outbox.fields) {
1238
- if (isNullSignedResourceId(f.value)) continue;
1239
- const rd = await tx.getResourceData(f.value, false);
1240
- if (rd.data === undefined) continue;
1241
- const data = decodeEnvelopeData(rd.data);
1242
- if (data === undefined) continue;
1243
- if (Object.values(envelopeProjectMap(data)).some((p) => wanted.has(p.source)))
1244
- out.push({ fieldName: f.name, rid: f.value, shareId: data.shareId });
1245
- }
1246
- return out;
1247
- });
1357
+ private async dropShares(tx: PlTransaction, shareIds: ShareId[] | undefined): Promise<void> {
1358
+ for (const shareId of shareIds ?? []) {
1359
+ const target = await this.resolveOutboxEnvelope(tx, shareId);
1360
+ if (target === undefined) continue;
1361
+ tx.removeField(field(this.sharingOutboxResourceId, target.fieldName));
1362
+ }
1363
+ }
1248
1364
 
1249
- return await Promise.all(
1250
- matched.map(async ({ fieldName, rid, shareId }) => {
1251
- const grants = await this.pl.userResources.listGrants(rid);
1252
- return {
1253
- fieldName,
1254
- rid,
1255
- shareId,
1256
- everyone: grants.some((g) => isEveryoneUserLogin(g.user)),
1257
- recipients: grants.filter((g) => !isEveryoneUserLogin(g.user)).map((g) => g.user),
1258
- };
1259
- }),
1260
- );
1365
+ /**
1366
+ * Grants one freshly built envelope inside the transaction that created it: a single make-public
1367
+ * grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
1368
+ * target to the everyone-user, gated by role + permission ceiling), or one grant per named
1369
+ * recipient.
1370
+ *
1371
+ * `writable` is not a preference. A project pack needs a writable grant because a copy out of
1372
+ * it takes the snapshots out of the envelope, and the cross-color attach rule permits that only
1373
+ * to a writable grant holder. A template share copies nothing — the document sits in the
1374
+ * envelope's own immutable data — so it is granted read-only, and must be: a writable
1375
+ * everyone-grant would hand every user on the server write access to the envelope.
1376
+ */
1377
+ private async grantShareEnvelope(
1378
+ tx: PlTransaction,
1379
+ envelope: ResourceRef,
1380
+ everyone: boolean,
1381
+ recipients: string[],
1382
+ permissions: { writable: boolean },
1383
+ ): Promise<void> {
1384
+ const gid = await envelope.globalId;
1385
+ if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
1386
+ else for (const r of recipients) tx.grantAccess(gid, r, permissions);
1261
1387
  }
1262
1388
 
1263
1389
  /**
1264
1390
  * Revokes and deletes an outgoing share for all recipients: detaches and deletes the envelope, and
1265
- * its grants are revoked along with it. Already-accepted copies are unaffected (ref-counting keeps
1266
- * the adopted resources alive). Idempotent — revoking a share that is already gone is a no-op.
1391
+ * its grants are revoked along with it. Copies recipients already took out of it are unaffected
1392
+ * (ref-counting keeps the resources they point at alive). Idempotent — revoking a share that is
1393
+ * already gone is a no-op.
1267
1394
  */
1268
1395
  public async revokeShare(shareId: ShareId): Promise<void> {
1269
1396
  await this.pl.withWriteTx("MLRevokeShare", async (tx) => {
@@ -1303,13 +1430,13 @@ export class MiddleLayer {
1303
1430
  * the envelope's logical `shareId`.
1304
1431
  *
1305
1432
  * Reads the {@link liveEnvelopes} Computable — the same shared-resource discovery tree that
1306
- * feeds {@link pendingShares}. This is the single discovery mechanism: there is no separate
1307
- * `ListUserResources` re-stream on every accept/reject. `refreshState()` is awaited first so a
1433
+ * feeds {@link availableShares}. This is the single discovery mechanism: there is no separate
1434
+ * `ListUserResources` re-stream on every copy. `refreshState()` is awaited first so a
1308
1435
  * just-granted envelope is observed (the tree's discovery poll may otherwise lag a freshly
1309
1436
  * landed grant). The tree is gRPC-only, so this is empty on a REST-connected client.
1310
1437
  */
1311
1438
  private async resolveLiveEnvelopes(): Promise<Map<ShareId, LiveEnvelope>> {
1312
- await this.pendingSharesTree.refreshState();
1439
+ await this.availableSharesTree.refreshState();
1313
1440
  const live = (await this.liveEnvelopes.getValue()) ?? [];
1314
1441
  // Dedup by logical shareId (last writer wins — at most one live envelope per shareId).
1315
1442
  const map = new Map<ShareId, LiveEnvelope>();
@@ -1318,31 +1445,32 @@ export class MiddleLayer {
1318
1445
  }
1319
1446
 
1320
1447
  /**
1321
- * Accepts one or more pending shares. What accepting does depends on what the share carries: a
1322
- * pack of projects is duplicated into this user's project list, while a template is added to this
1323
- * user's own template list and builds nothing — the recipient decides later whether to apply it.
1324
- * Either way the decision is recorded per share, and a read-write share also gets the
1325
- * donor-visible acceptance written onto its envelope. Per-share failures (e.g. an expiry race)
1326
- * are collected, not short-circuited — the rest still get accepted. Accept-all = pass every
1327
- * current pending shareId.
1448
+ * Copies what one or more shares carry into this user's own tree, optionally into a folder.
1328
1449
  *
1329
- * `rename` resolves label collisions (same callback contract as {@link duplicateProject}), but
1330
- * the source lives in the envelope tree, so accept calls the low-level mutator directly. It does
1331
- * not apply to a template share, whose label is not required to be unique.
1450
+ * A share is a shelf, not an invitation: copying takes nothing off it and records no decision,
1451
+ * so the same share can be copied from again, by this user or anyone else it was granted to.
1452
+ * What a copy produces depends on the payload — a pack of projects lands in the project list,
1453
+ * a template among their templates, building nothing until the recipient applies it, and a
1454
+ * folder is rebuilt whole with everything it held.
1455
+ *
1456
+ * Names are chosen against the destination folder, since that is where the uniqueness rule
1457
+ * applies, and a project and a template follow the same rule. A destination deleted meanwhile
1458
+ * fails the copy rather than spilling it at the top level.
1459
+ * Per-share failures (a revoked envelope, say) are collected rather than short-circuited, so
1460
+ * one dead share does not cost the others.
1332
1461
  */
1333
- public async acceptShare(
1462
+ public async copyShare(
1334
1463
  shareIds: ShareId[],
1335
- rename?: (previousLabel: string, existingLabels: string[]) => string,
1464
+ destination?: FolderId,
1336
1465
  ): Promise<{
1337
- accepted: ProjectId[];
1338
- acceptedTemplates: TemplateId[];
1466
+ projects: ProjectId[];
1467
+ templates: TemplateId[];
1339
1468
  failed: { shareId: ShareId; error: string }[];
1340
1469
  }> {
1341
1470
  const live = await this.resolveLiveEnvelopes();
1342
- const login = this.currentUserLogin;
1343
1471
 
1344
- const accepted: ProjectId[] = [];
1345
- const acceptedTemplates: TemplateId[] = [];
1472
+ const projects: ProjectId[] = [];
1473
+ const templates: TemplateId[] = [];
1346
1474
  const failed: { shareId: ShareId; error: string }[] = [];
1347
1475
 
1348
1476
  for (const shareId of shareIds) {
@@ -1352,64 +1480,130 @@ export class MiddleLayer {
1352
1480
  continue;
1353
1481
  }
1354
1482
  try {
1355
- const now = Date.now();
1356
1483
  const payload = envelope.data.payload;
1357
1484
 
1358
1485
  if (payload.kind === "template") {
1359
- const rid = await this.pl.withWriteTx("MLAcceptTemplateShare", async (tx) => {
1360
- // The template lands on this user's own shelf, keeping who sent it as its provenance.
1361
- const tpl = createTemplate(tx, this.templateListResourceId, payload.label, {
1362
- schemaVersion: 1,
1363
- document: payload.document,
1364
- sender: payload.from,
1365
- });
1366
-
1367
- writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
1368
- decision: "accepted",
1369
- timestamp: now,
1370
- envelopeSharedAt: envelope.data.sharedAt,
1371
- acceptedProjects: [], // a template share creates no project
1372
- });
1373
-
1374
- // No acceptance/{login} on the envelope: the grant is read-only, so the write would be
1375
- // refused by the backend, and the donor deliberately gave up that receipt.
1486
+ const rid = await this.pl.withWriteTx("MLCopyTemplateShare", async (tx) => {
1487
+ const tree = await openFoldersTx(tx, this.foldersRids);
1488
+ // The template lands in their templates, keeping who sent it as its provenance, under
1489
+ // a name that is free where it lands — the same rule a copied project follows. What
1490
+ // the donor said about the template is part of the template, not of the share.
1491
+ const tpl = createTemplate(
1492
+ tx,
1493
+ this.templateListResourceId,
1494
+ {
1495
+ label: foldersUniqueName(payload.label, tree.namesTakenIn(destination, "template")),
1496
+ description: payload.description,
1497
+ },
1498
+ { schemaVersion: 1, document: payload.document, sender: payload.from },
1499
+ );
1500
+
1501
+ const created = await tpl.globalId;
1502
+ tree.place(
1503
+ [{ kind: "template", id: asTemplateId(resourceIdToString(created)) }],
1504
+ destination,
1505
+ );
1506
+
1376
1507
  await tx.commit();
1377
- return await tpl.globalId;
1508
+ return created;
1378
1509
  });
1379
1510
 
1380
- const templateId = resourceIdToString(rid) as TemplateId;
1511
+ const templateId = asTemplateId(resourceIdToString(rid));
1381
1512
  this.templateIdCache.set(templateId, rid);
1382
- acceptedTemplates.push(templateId);
1513
+ templates.push(templateId);
1514
+ continue;
1515
+ }
1516
+
1517
+ if (payload.kind === "folder") {
1518
+ const root = envelopeFolderRoot(payload.folders);
1519
+ if (root === undefined)
1520
+ throw new Error("This share does not describe one folder, so nothing can be rebuilt.");
1521
+
1522
+ const copied = await this.pl.withWriteTx("MLCopyFolderShare", async (tx) => {
1523
+ const tree = await openFoldersTx(tx, this.foldersRids);
1524
+ // The subtree is rebuilt whole, so names are only ever compared inside it — except
1525
+ // the root, which lands beside whatever the destination already holds.
1526
+ const created = await copyEnvelopeProjectsIntoList(
1527
+ tx,
1528
+ envelope.rid,
1529
+ this.projectListResourceId,
1530
+ );
1531
+
1532
+ const createdTemplates: TemplateId[] = [];
1533
+ const items: (FoldersLeafItem & { folder: string })[] = [];
1534
+ for (const { uuid, rid } of created) {
1535
+ const inFolder = payload.projects[uuid];
1536
+ items.push({
1537
+ kind: "project",
1538
+ id: asProjectId(resourceIdToString(rid)),
1539
+ // A project whose payload entry is missing still exists; it lands at the root.
1540
+ folder: inFolder?.folder ?? root,
1541
+ });
1542
+ }
1543
+
1544
+ for (const carried of payload.templates) {
1545
+ // The template lands in their templates, keeping who sent it as its provenance.
1546
+ const tpl = createTemplate(
1547
+ tx,
1548
+ this.templateListResourceId,
1549
+ { label: carried.label, description: carried.description },
1550
+ { schemaVersion: 1, document: carried.document, sender: payload.from },
1551
+ );
1552
+ const id = asTemplateId(resourceIdToString(await tpl.globalId));
1553
+ createdTemplates.push(id);
1554
+ items.push({ kind: "template", id, folder: carried.folder });
1555
+ }
1556
+
1557
+ // Folders this build cannot rewrite leave the subtree unbuilt, and the copies land at
1558
+ // the top level. That is deliberate: folders are an arrangement, not the content, and
1559
+ // a copy the user asked for is not held back for their sake.
1560
+ tree.graft({ root, folders: payload.folders, items }, destination);
1561
+
1562
+ await tx.commit();
1563
+ return { projects: created, templates: createdTemplates };
1564
+ });
1565
+
1566
+ for (const { rid } of copied.projects) {
1567
+ const projectId = asProjectId(resourceIdToString(rid));
1568
+ this.projectIdCache.set(projectId, rid);
1569
+ projects.push(projectId);
1570
+ }
1571
+ templates.push(...copied.templates);
1383
1572
  continue;
1384
1573
  }
1385
1574
 
1386
- const createdRids = await this.pl.withWriteTx("MLAcceptShare", async (tx) => {
1575
+ const createdRids = await this.pl.withWriteTx("MLCopyShare", async (tx) => {
1576
+ const tree = await openFoldersTx(tx, this.foldersRids);
1577
+ // Scoped to where the copies are going, because that is the only place their names have
1578
+ // to be free. `taken` grows as the pack is copied, so two projects of one name inside a
1579
+ // single share do not land on top of each other either.
1580
+ const taken = [...tree.namesTakenIn(destination, "project")];
1387
1581
  const created = await copyEnvelopeProjectsIntoList(
1388
1582
  tx,
1389
1583
  envelope.rid,
1390
1584
  this.projectListResourceId,
1391
- rename,
1585
+ (sourceLabel) => {
1586
+ const name = foldersUniqueName(sourceLabel, taken);
1587
+ taken.push(name);
1588
+ return name;
1589
+ },
1392
1590
  );
1393
1591
 
1394
- // Record the decision on the acceptor's own SharingState, keyed on shareId.
1395
- writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
1396
- decision: "accepted",
1397
- timestamp: now,
1398
- envelopeSharedAt: envelope.data.sharedAt,
1399
- acceptedProjects: resourceIdsToStrings(created),
1400
- });
1401
-
1402
- // Read-write share: write the donor-visible acceptance onto the envelope.
1403
- if (login !== null && envelope.data.mode !== "read-only")
1404
- writeEnvelopeAcceptance(tx, envelope.rid, login, "accepted", now);
1592
+ tree.place(
1593
+ created.map(({ rid }) => ({
1594
+ kind: "project" as const,
1595
+ id: asProjectId(resourceIdToString(rid)),
1596
+ })),
1597
+ destination,
1598
+ );
1405
1599
 
1406
1600
  await tx.commit();
1407
1601
  return created;
1408
1602
  });
1409
- for (const rid of createdRids) {
1410
- const projectId = resourceIdToString(rid) as ProjectId;
1603
+ for (const { rid } of createdRids) {
1604
+ const projectId = asProjectId(resourceIdToString(rid));
1411
1605
  this.projectIdCache.set(projectId, rid);
1412
- accepted.push(projectId);
1606
+ projects.push(projectId);
1413
1607
  }
1414
1608
  } catch (e) {
1415
1609
  failed.push({ shareId, error: e instanceof Error ? e.message : String(e) });
@@ -1419,30 +1613,30 @@ export class MiddleLayer {
1419
1613
  await Promise.all([
1420
1614
  this.projectListTree.refreshState(),
1421
1615
  this.templateListTree.refreshState(),
1422
- this.sharingStateTree.refreshState(),
1616
+ this.foldersTree.refreshState(),
1423
1617
  ]);
1424
- return { accepted, acceptedTemplates, failed };
1618
+ return { projects, templates, failed };
1425
1619
  }
1426
1620
 
1427
- /** Records rejection of a pending share; it never surfaces again. */
1428
- public async rejectShare(shareId: ShareId): Promise<void> {
1429
- const live = await this.resolveLiveEnvelopes();
1430
- const envelope = live.get(shareId);
1431
- const login = this.currentUserLogin;
1621
+ /**
1622
+ * Puts a share out of this user's sight. Private to them and reversible with
1623
+ * {@link unhideShare}: nothing is deleted, the donor is not told, and what was already copied
1624
+ * out of it is unaffected.
1625
+ */
1626
+ public async hideShare(shareId: ShareId): Promise<void> {
1432
1627
  const now = Date.now();
1628
+ await this.pl.withWriteTx("MLHideShare", async (tx) => {
1629
+ writeShareHidden(tx, this.sharingStateResourceId, shareId, now);
1630
+ await tx.commit();
1631
+ });
1433
1632
 
1434
- await this.pl.withWriteTx("MLRejectShare", async (tx) => {
1435
- writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
1436
- decision: "rejected",
1437
- timestamp: now,
1438
- envelopeSharedAt: envelope?.data.sharedAt ?? now,
1439
- acceptedProjects: [],
1440
- });
1441
-
1442
- // Read-write share: write the donor-visible rejection onto the envelope (if still live).
1443
- if (envelope !== undefined && login !== null && envelope.data.mode !== "read-only")
1444
- writeEnvelopeAcceptance(tx, envelope.rid, login, "rejected", now);
1633
+ await this.sharingStateTree.refreshState();
1634
+ }
1445
1635
 
1636
+ /** Brings a hidden share back into this user's list. */
1637
+ public async unhideShare(shareId: ShareId): Promise<void> {
1638
+ await this.pl.withWriteTx("MLUnhideShare", async (tx) => {
1639
+ clearShareHidden(tx, this.sharingStateResourceId, shareId);
1446
1640
  await tx.commit();
1447
1641
  });
1448
1642
 
@@ -1596,9 +1790,10 @@ export class MiddleLayer {
1596
1790
  await Promise.all([
1597
1791
  this.projectListTree.terminate(),
1598
1792
  this.templateListTree.terminate(),
1793
+ this.foldersTree.terminate(),
1599
1794
  this.sharingOutboxTree.terminate(),
1600
1795
  this.sharingStateTree.terminate(),
1601
- this.pendingSharesTree.terminate(),
1796
+ this.availableSharesTree.terminate(),
1602
1797
  ]);
1603
1798
  await this.drainSnapshotWrites(SNAPSHOT_DRAIN_TIMEOUT_MS);
1604
1799
  await this.env.dispose();
@@ -1643,15 +1838,17 @@ export class MiddleLayer {
1643
1838
  )
1644
1839
  ops.defaultTreeOptions.traversalMode = getDebugFlags().treeTraversalMode;
1645
1840
 
1646
- const { projects, templates, sharingOutbox, sharingState } = await pl.withWriteTx(
1841
+ const { projects, templates, sharingOutbox, sharingState, folders } = await pl.withWriteTx(
1647
1842
  "MLInitialization",
1648
1843
  async (tx) => {
1649
1844
  // Lazily create each clientRoot-attached singleton resource. Returns the existing
1650
1845
  // resource id if the field is already populated, otherwise creates + locks + sets it.
1846
+ // A created resource's id is known only once the transaction commits.
1847
+ type Singleton = { existing: SignedResourceId } | { ref: ResourceRef };
1651
1848
  const lazyInit = async (
1652
1849
  fieldName: string,
1653
1850
  type: { name: string; version: string },
1654
- ): Promise<{ ref?: ResourceRef; existing?: SignedResourceId }> => {
1851
+ ): Promise<Singleton> => {
1655
1852
  const f = field(tx.clientRoot, fieldName);
1656
1853
  tx.createField(f, "Dynamic");
1657
1854
  const fData = await tx.getField(f);
@@ -1668,14 +1865,20 @@ export class MiddleLayer {
1668
1865
  const templatesR = await lazyInit(TemplatesField, TemplatesResourceType);
1669
1866
  const outboxR = await lazyInit(SharingOutboxField, SharingOutboxResourceType);
1670
1867
  const stateR = await lazyInit(SharingStateField, SharingStateResourceType);
1868
+ // The folder tree gets its own root-attached singleton rather than a field on the
1869
+ // projects resource: an extra field there is walked by the released project-list reader
1870
+ // and dereferenced as a project, which takes the whole list down, not just the folders.
1871
+ const foldersR = await lazyInit(FoldersField, FoldersResourceType);
1671
1872
 
1672
1873
  await tx.commit();
1673
1874
 
1875
+ const idOf = async (r: Singleton) => ("existing" in r ? r.existing : await r.ref.globalId);
1674
1876
  return {
1675
- projects: projectsR.existing ?? (await projectsR.ref!.globalId),
1676
- templates: templatesR.existing ?? (await templatesR.ref!.globalId),
1677
- sharingState: stateR.existing ?? (await stateR.ref!.globalId),
1678
- sharingOutbox: outboxR.existing ?? (await outboxR.ref!.globalId),
1877
+ projects: await idOf(projectsR),
1878
+ templates: await idOf(templatesR),
1879
+ sharingState: await idOf(stateR),
1880
+ sharingOutbox: await idOf(outboxR),
1881
+ folders: await idOf(foldersR),
1679
1882
  };
1680
1883
  },
1681
1884
  );
@@ -1759,17 +1962,25 @@ export class MiddleLayer {
1759
1962
  const openedProjects = new WatchableValue<ProjectId[]>([]);
1760
1963
  const projectListTC = await createProjectList(pl, projects, openedProjects, env);
1761
1964
  const templateListTC = await createTemplateList(pl, templates, env);
1965
+ const foldersTC = await createFolderList(
1966
+ pl,
1967
+ folders,
1968
+ projectListTC.tree,
1969
+ templateListTC.tree,
1970
+ openedProjects,
1971
+ env,
1972
+ );
1762
1973
 
1763
1974
  // Project sharing trees and reactive views.
1764
1975
  const outgoingTC = await createOutgoingShares(pl, sharingOutbox, env);
1765
1976
  const sharingStateTree = await createSharingStateTree(pl, sharingState, env);
1766
- const pendingSharesTree = await createPendingSharesTree(pl, env);
1767
- const pendingShares = createPendingSharesComputable(
1768
- pendingSharesTree,
1977
+ const availableSharesTree = await createAvailableSharesTree(pl, env);
1978
+ const availableShares = createAvailableSharesComputable(
1979
+ availableSharesTree,
1769
1980
  sharingStateTree,
1770
1981
  pl.userResources.authUser,
1771
1982
  );
1772
- const liveEnvelopes = createLiveEnvelopesComputable(pendingSharesTree);
1983
+ const liveEnvelopes = createLiveEnvelopesComputable(availableSharesTree);
1773
1984
 
1774
1985
  return new MiddleLayer(
1775
1986
  env,
@@ -1779,17 +1990,20 @@ export class MiddleLayer {
1779
1990
  templates,
1780
1991
  sharingOutbox,
1781
1992
  sharingState,
1993
+ folders,
1782
1994
  openedProjects,
1783
1995
  projectListTC.tree,
1784
1996
  templateListTC.tree,
1997
+ foldersTC.tree,
1785
1998
  outgoingTC.tree,
1786
1999
  sharingStateTree,
1787
- pendingSharesTree,
2000
+ availableSharesTree,
1788
2001
  v2RegistryProvider,
1789
2002
  projectListTC.computable,
1790
2003
  templateListTC.computable,
2004
+ foldersTC.computable,
1791
2005
  outgoingTC.computable,
1792
- pendingShares,
2006
+ availableShares,
1793
2007
  liveEnvelopes,
1794
2008
  );
1795
2009
  }
@@ -1798,3 +2012,55 @@ export class MiddleLayer {
1798
2012
  //
1799
2013
  // Internals
1800
2014
  //
2015
+
2016
+ /** A folder subtree read for copying; see {@link MiddleLayer.loadFolderSubtree}. */
2017
+ interface FolderSubtree<Id extends string> {
2018
+ /** Folder holding the subtree's root; absent when the root is at the top level. */
2019
+ readonly parent?: FolderId;
2020
+ /** Local key of the subtree's root. */
2021
+ readonly root: Id;
2022
+ readonly folders: Record<Id, { name: string; parent?: Id; description?: string }>;
2023
+ readonly projects: { projectId: ProjectId; rid: SignedResourceId; folder: Id }[];
2024
+ readonly templates: (StoredTemplate & { folder: Id })[];
2025
+ }
2026
+
2027
+ /** Everything a stored template is: its immutable blob, plus the label and the description the
2028
+ * list shows, both of which live beside the blob rather than in it. */
2029
+ interface StoredTemplate {
2030
+ readonly data: StoredTemplateData;
2031
+ readonly label: string;
2032
+ /** Absent when nobody described the template. */
2033
+ readonly description?: string;
2034
+ }
2035
+
2036
+ /** Reads one stored template within the caller's transaction. A share carries its document on;
2037
+ * a duplicate carries the blob whole, so the copy says of itself what the original did. */
2038
+ async function readStoredTemplate(
2039
+ tx: PlTransaction,
2040
+ id: TemplateId,
2041
+ rid: SignedResourceId,
2042
+ ): Promise<StoredTemplate> {
2043
+ const rd = await tx.getResourceData(rid, false);
2044
+ if (rd.data === undefined) throw new Error(`Template ${id} carries no document.`);
2045
+ const [label, description] = await Promise.all([
2046
+ tx.getKValueJson<string>(rid, TemplateLabelKey),
2047
+ tx.getKValueJsonIfExists<string>(rid, TemplateDescriptionKey).then(normalizeDescription),
2048
+ ]);
2049
+ return {
2050
+ data: decodeStoredTemplateData(rd.data),
2051
+ label,
2052
+ ...(description === undefined ? {} : { description }),
2053
+ };
2054
+ }
2055
+
2056
+ /** The label of every project in a project list, read within the caller's transaction. */
2057
+ async function existingProjectLabels(
2058
+ tx: PlTransaction,
2059
+ listRid: SignedResourceId,
2060
+ ): Promise<string[]> {
2061
+ const listed = await listedById(tx, listRid);
2062
+ const metas = await Promise.all(
2063
+ [...listed.values()].map(({ rid }) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)),
2064
+ );
2065
+ return metas.map((meta) => meta.label);
2066
+ }