@milaboratories/pl-middle-layer 1.68.2 → 1.69.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 (68) hide show
  1. package/dist/index.cjs +10 -0
  2. package/dist/index.d.ts +6 -2
  3. package/dist/index.js +5 -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/index.cjs +2 -0
  7. package/dist/middle_layer/index.d.ts +2 -1
  8. package/dist/middle_layer/index.js +2 -1
  9. package/dist/middle_layer/middle_layer.cjs +333 -26
  10. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  11. package/dist/middle_layer/middle_layer.d.ts +125 -7
  12. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  13. package/dist/middle_layer/middle_layer.js +335 -28
  14. package/dist/middle_layer/middle_layer.js.map +1 -1
  15. package/dist/middle_layer/project_list.d.ts +1 -1
  16. package/dist/middle_layer/sharing_list.cjs +13 -5
  17. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  18. package/dist/middle_layer/sharing_list.d.ts +16 -2
  19. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  20. package/dist/middle_layer/sharing_list.js +14 -6
  21. package/dist/middle_layer/sharing_list.js.map +1 -1
  22. package/dist/middle_layer/template_list.cjs +72 -0
  23. package/dist/middle_layer/template_list.cjs.map +1 -0
  24. package/dist/middle_layer/template_list.d.ts +77 -0
  25. package/dist/middle_layer/template_list.d.ts.map +1 -0
  26. package/dist/middle_layer/template_list.js +64 -0
  27. package/dist/middle_layer/template_list.js.map +1 -0
  28. package/dist/model/index.cjs +8 -0
  29. package/dist/model/index.d.ts +5 -2
  30. package/dist/model/index.js +4 -2
  31. package/dist/model/sharing_model.cjs +59 -2
  32. package/dist/model/sharing_model.cjs.map +1 -1
  33. package/dist/model/sharing_model.d.ts +70 -7
  34. package/dist/model/sharing_model.d.ts.map +1 -1
  35. package/dist/model/sharing_model.js +57 -3
  36. package/dist/model/sharing_model.js.map +1 -1
  37. package/dist/model/template_serializer.d.ts +36 -0
  38. package/dist/model/template_serializer.d.ts.map +1 -1
  39. package/dist/model/template_share.cjs +42 -0
  40. package/dist/model/template_share.cjs.map +1 -0
  41. package/dist/model/template_share.d.ts +27 -0
  42. package/dist/model/template_share.d.ts.map +1 -0
  43. package/dist/model/template_share.js +42 -0
  44. package/dist/model/template_share.js.map +1 -0
  45. package/dist/mutator/project.cjs +2 -2
  46. package/dist/mutator/project.js +2 -2
  47. package/dist/mutator/sharing.cjs +40 -2
  48. package/dist/mutator/sharing.cjs.map +1 -1
  49. package/dist/mutator/sharing.js +40 -3
  50. package/dist/mutator/sharing.js.map +1 -1
  51. package/dist/mutator/template.cjs +54 -0
  52. package/dist/mutator/template.cjs.map +1 -0
  53. package/dist/mutator/template.js +52 -0
  54. package/dist/mutator/template.js.map +1 -0
  55. package/package.json +10 -10
  56. package/src/middle_layer/index.ts +9 -0
  57. package/src/middle_layer/middle_layer.ts +449 -36
  58. package/src/middle_layer/sharing_list.ts +37 -7
  59. package/src/middle_layer/template_list.ts +177 -0
  60. package/src/middle_layer/templates.test.ts +301 -0
  61. package/src/model/index.ts +14 -0
  62. package/src/model/sharing_model.test.ts +115 -1
  63. package/src/model/sharing_model.ts +134 -9
  64. package/src/model/template_share.test.ts +78 -0
  65. package/src/model/template_share.ts +52 -0
  66. package/src/mutator/sharing.ts +55 -3
  67. package/src/mutator/template.ts +75 -0
  68. package/src/test/with_ml.ts +38 -0
@@ -20,6 +20,22 @@ import {
20
20
  ProjectsField,
21
21
  ProjectsResourceType,
22
22
  } from "./project_list";
23
+ import type {
24
+ CreateProjectFromTemplateOutcome,
25
+ SaveProjectAsTemplateOutcome,
26
+ ShareTemplateOutcome,
27
+ StoredTemplateData,
28
+ TemplateId,
29
+ TemplateListEntry,
30
+ } from "./template_list";
31
+ import {
32
+ createTemplateList,
33
+ decodeStoredTemplateData,
34
+ TemplateLabelKey,
35
+ TemplatesField,
36
+ TemplatesResourceType,
37
+ } from "./template_list";
38
+ import { createTemplate, deleteTemplate, renameTemplate } from "../mutator/template";
23
39
  import {
24
40
  createProject,
25
41
  duplicateProject,
@@ -31,7 +47,7 @@ import type { ProjectTemplateV1 } from "@milaboratories/pl-model-common";
31
47
  import { extractConfig, ensureError } from "@platforma-sdk/model";
32
48
  import type { TemplateApplyProblem, TemplateApplyReport } from "../model/template_apply";
33
49
  import { TemplateEntryRejected, kindMismatch } from "../model/template_apply";
34
- import type { BlockPackProvider } from "../model/template_resolve";
50
+ import type { BlockPackProvider, TemplateResolveOutcome } from "../model/template_resolve";
35
51
  import { resolveTemplateEntries } from "../model/template_resolve";
36
52
  import type { PreparedTemplateEntry } from "../mutator/template_construct";
37
53
  import { applyTemplateEntries } from "../mutator/template_construct";
@@ -45,6 +61,7 @@ import {
45
61
  canGrantToEveryone,
46
62
  canImpersonate,
47
63
  decodeEnvelopeData,
64
+ envelopeProjectMap,
48
65
  isAcceptanceField,
49
66
  SharingOutboxField,
50
67
  SharingOutboxResourceType,
@@ -56,9 +73,13 @@ import {
56
73
  type ProjectFieldUuid,
57
74
  type ShareId,
58
75
  type ShareProjectsOptions,
76
+ type ShareTemplateOptions,
59
77
  } from "../model/sharing_model";
78
+ import type { TemplateShareProblem } from "../model/template_share";
79
+ import { unshareableTemplateEntries } from "../model/template_share";
60
80
  import {
61
81
  buildShareEnvelope,
82
+ buildTemplateShareEnvelope,
62
83
  copyEnvelopeProjectsIntoList,
63
84
  envelopeProjectFieldUuid,
64
85
  isEnvelopeProjectField,
@@ -161,16 +182,20 @@ export class MiddleLayer {
161
182
  public readonly driverKit: DriverKit,
162
183
  public readonly signer: Signer,
163
184
  private readonly projectListResourceId: SignedResourceId,
185
+ private readonly templateListResourceId: SignedResourceId,
164
186
  private readonly sharingOutboxResourceId: SignedResourceId,
165
187
  private readonly sharingStateResourceId: SignedResourceId,
166
188
  private readonly openedProjectsList: WatchableValue<ProjectId[]>,
167
189
  private readonly projectListTree: SynchronizedTreeState,
190
+ private readonly templateListTree: SynchronizedTreeState,
168
191
  private readonly sharingOutboxTree: SynchronizedTreeState,
169
192
  private readonly sharingStateTree: SynchronizedTreeState,
170
193
  private readonly pendingSharesTree: SynchronizedTreeState,
171
194
  public readonly blockRegistryProvider: V2RegistryProvider,
172
195
  /** Contains a reactive list of projects along with their meta information. */
173
196
  public readonly projectList: ComputableStableDefined<ProjectListEntry[]>,
197
+ /** Contains a reactive list of stored templates along with their labels and provenance. */
198
+ public readonly templateList: ComputableStableDefined<TemplateListEntry[]>,
174
199
  /** Reactive view of the donor's outbox — the shares this user has created.
175
200
  * v1: API only, no UI. */
176
201
  public outgoingShares: Computable<OutgoingShare[] | undefined>,
@@ -427,15 +452,37 @@ export class MiddleLayer {
427
452
  provider: BlockPackProvider,
428
453
  options: { allowUnstable?: boolean; author?: AuthorMarker } = {},
429
454
  ): Promise<TemplateApplyReport> {
430
- const resolution = await resolveTemplateEntries(document, provider, {
455
+ const preparation = await this.prepareTemplateEntries(document, provider, {
431
456
  allowUnstable: options.allowUnstable ?? false,
432
457
  });
433
- if (resolution.problems.length > 0) return { added: [], problems: resolution.problems };
458
+ if (preparation.problems.length > 0) return { added: [], problems: preparation.problems };
459
+ return await this.applyPreparedEntries(id, document, preparation.prepared, options.author);
460
+ }
461
+
462
+ /**
463
+ * Stages 1 and 2 of an apply — resolve every entry to a block pack, then prepare every
464
+ * block — for a document that may not have a project yet.
465
+ *
466
+ * Neither stage creates anything, which is what lets a caller run them before it decides to
467
+ * create a project at all: {@link createProjectFromTemplate} does exactly that, so an
468
+ * unapplicable template leaves no empty project behind.
469
+ */
470
+ private async prepareTemplateEntries(
471
+ document: ProjectTemplateV1,
472
+ provider: BlockPackProvider,
473
+ options: { allowUnstable: boolean },
474
+ ): Promise<{
475
+ prepared: Map<string, PreparedTemplateEntry>;
476
+ problems: TemplateApplyProblem[];
477
+ }> {
478
+ const prepared = new Map<string, PreparedTemplateEntry>();
479
+
480
+ const resolution = await resolveTemplateEntries(document, provider, options);
481
+ if (resolution.problems.length > 0) return { prepared, problems: [...resolution.problems] };
434
482
 
435
483
  // One map, not one per field: resolution reports by entry id, so everything this loop
436
484
  // needs about an entry is looked up the same way.
437
485
  const byEntryId = new Map(document.blocks.map((entry) => [entry.id, entry]));
438
- const prepared = new Map<string, PreparedTemplateEntry>();
439
486
  const problems: TemplateApplyProblem[] = [];
440
487
 
441
488
  for (const entry of resolution.resolved) {
@@ -492,8 +539,21 @@ export class MiddleLayer {
492
539
  }
493
540
  }
494
541
 
495
- if (problems.length > 0) return { added: [], problems };
542
+ return { prepared, problems };
543
+ }
496
544
 
545
+ /**
546
+ * Stage 3 of an apply — create the blocks in one transaction, all or nothing.
547
+ *
548
+ * An entry it cannot create throws, the transaction is never committed, and the project keeps
549
+ * none of the blocks the apply had placed.
550
+ */
551
+ private async applyPreparedEntries(
552
+ id: ProjectId,
553
+ document: ProjectTemplateV1,
554
+ prepared: Map<string, PreparedTemplateEntry>,
555
+ author?: AuthorMarker,
556
+ ): Promise<TemplateApplyReport> {
497
557
  const rid = await this.resolveProjectId(id);
498
558
  let added: AppliedEntry[] = [];
499
559
  try {
@@ -501,7 +561,7 @@ export class MiddleLayer {
501
561
  this.env.projectHelper,
502
562
  this.pl,
503
563
  rid,
504
- options.author,
564
+ author,
505
565
  (mut) => {
506
566
  added = applyTemplateEntries({
507
567
  document,
@@ -526,6 +586,159 @@ export class MiddleLayer {
526
586
  return { added, problems: [] };
527
587
  }
528
588
 
589
+ //
590
+ // Template List Manipulation
591
+ //
592
+
593
+ private readonly templateIdCache = new LRUCache<TemplateId, SignedResourceId>({ max: 1024 });
594
+
595
+ /**
596
+ * Saves a project as a template: a snapshot of its blocks and their params, no data.
597
+ *
598
+ * The document the export produced is what gets stored; the YAML it also rendered is a
599
+ * file format, and a stored template is rendered to it only on download.
600
+ *
601
+ * A block that cannot be expressed as a template entry stores nothing at all, and every
602
+ * such block is reported — fixing an unexportable project takes one pass, not one per block.
603
+ *
604
+ * @param projectId project to snapshot
605
+ * @param label label for the template; defaults to the project's own label
606
+ */
607
+ public async saveProjectAsTemplate(
608
+ projectId: ProjectId,
609
+ label?: string,
610
+ ): Promise<SaveProjectAsTemplateOutcome> {
611
+ const outcome = await this.exportProjectAsTemplate(projectId);
612
+ if (!outcome.ok) return { ok: false, problems: outcome.problems };
613
+
614
+ const rid = await this.resolveProjectId(projectId);
615
+ let tpl: ResourceRef;
616
+ await this.pl.withWriteTx("MLSaveProjectAsTemplate", async (tx) => {
617
+ const meta = await tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey);
618
+ tpl = createTemplate(tx, this.templateListResourceId, label ?? meta.label, {
619
+ schemaVersion: 1,
620
+ document: outcome.document,
621
+ sourceProjectLabel: meta.label,
622
+ });
623
+ await tx.commit();
624
+ });
625
+ await this.templateListTree.refreshState();
626
+
627
+ const signedRid = await tpl!.globalId;
628
+ const templateId = resourceIdToString(signedRid) as TemplateId;
629
+ this.templateIdCache.set(templateId, signedRid);
630
+ return { ok: true, templateId };
631
+ }
632
+
633
+ /** Changes a template's label. The stored document is immutable and stays untouched —
634
+ * improving a template means saving a new one. */
635
+ public async renameTemplate(id: TemplateId, label: string): Promise<void> {
636
+ const rid = await this.resolveTemplateId(id);
637
+ await this.pl.withWriteTx("MLRenameTemplate", async (tx) => {
638
+ renameTemplate(tx, rid, label);
639
+ await tx.commit();
640
+ });
641
+ await this.templateListTree.refreshState();
642
+ }
643
+
644
+ /** Permanently deletes a template from the template list. */
645
+ public async deleteTemplate(id: TemplateId): Promise<void> {
646
+ await this.pl.withWriteTx("MLRemoveTemplate", async (tx) => {
647
+ await deleteTemplate(tx, this.templateListResourceId, id);
648
+ await tx.commit();
649
+ });
650
+ this.templateIdCache.delete(id);
651
+ await this.templateListTree.refreshState();
652
+ }
653
+
654
+ /** Reads a stored template: its document plus what was true when it was taken. */
655
+ public async getTemplateData(id: TemplateId): Promise<StoredTemplateData> {
656
+ const rid = await this.resolveTemplateId(id);
657
+ return await this.pl.withReadTx("MLGetTemplate", async (tx) => {
658
+ const rd = await tx.getResourceData(rid, false);
659
+ if (rd.data === undefined) throw new Error(`Template ${id} carries no document.`);
660
+ return decodeStoredTemplateData(rd.data);
661
+ });
662
+ }
663
+
664
+ /**
665
+ * Where each entry of a stored template would get its block from, and which entries have
666
+ * nowhere to get one — resolution creates nothing, so this is the preview a UI shows before
667
+ * offering Apply. {@link createProjectFromTemplate} runs the same stage itself.
668
+ */
669
+ public async resolveTemplate(
670
+ id: TemplateId,
671
+ provider: BlockPackProvider,
672
+ options: { allowUnstable?: boolean } = {},
673
+ ): Promise<TemplateResolveOutcome> {
674
+ const stored = await this.getTemplateData(id);
675
+ return await resolveTemplateEntries(stored.document, provider, {
676
+ allowUnstable: options.allowUnstable ?? false,
677
+ });
678
+ }
679
+
680
+ /**
681
+ * Creates one project holding every block the stored template lists, in the template's order.
682
+ *
683
+ * Resolution and preparation run before the project exists, so a template with an entry
684
+ * nothing can supply a block for leaves no empty project in the list. The one write that
685
+ * follows is all or nothing, and an entry it rejects takes the project with it.
686
+ *
687
+ * @param id template to apply
688
+ * @param label label for the new project
689
+ * @param provider where each entry's block comes from
690
+ * @param options `allowUnstable` widens resolution to pre-release implementations
691
+ */
692
+ public async createProjectFromTemplate(
693
+ id: TemplateId,
694
+ label: string,
695
+ provider: BlockPackProvider,
696
+ options: { allowUnstable?: boolean; author?: AuthorMarker } = {},
697
+ ): Promise<CreateProjectFromTemplateOutcome> {
698
+ const stored = await this.getTemplateData(id);
699
+
700
+ const preparation = await this.prepareTemplateEntries(stored.document, provider, {
701
+ allowUnstable: options.allowUnstable ?? false,
702
+ });
703
+ if (preparation.problems.length > 0) return { ok: false, problems: preparation.problems };
704
+
705
+ const projectId = await this.createProject({ label });
706
+ const report = await this.applyPreparedEntries(
707
+ projectId,
708
+ stored.document,
709
+ preparation.prepared,
710
+ options.author,
711
+ );
712
+ if (report.problems.length > 0) {
713
+ // The apply is one transaction, so the project holds none of the blocks: it is the empty
714
+ // project this call created moments ago and nothing else, and leaving it in the list would
715
+ // show the user a project they never asked for.
716
+ await this.deleteProject(projectId);
717
+ return { ok: false, problems: report.problems };
718
+ }
719
+ return { ok: true, projectId, added: report.added };
720
+ }
721
+
722
+ /** Resolves a TemplateId to a signed SignedResourceId.
723
+ * Uses LRU cache with TX-scan fallback. */
724
+ private async resolveTemplateId(templateId: TemplateId): Promise<SignedResourceId> {
725
+ const cached = this.templateIdCache.get(templateId);
726
+ if (cached !== undefined) return cached;
727
+
728
+ // Cache miss — scan template list fields to find the matching resource
729
+ const rid = await this.pl.withReadTx("ResolveTemplateId", async (tx) => {
730
+ const data = await tx.getResourceData(this.templateListResourceId, true);
731
+ for (const f of data.fields) {
732
+ if (isNullSignedResourceId(f.value)) continue;
733
+ if (resourceIdToString(f.value) === (templateId as string)) return f.value;
734
+ }
735
+ throw new Error(`Template ${templateId} not found in template list.`);
736
+ });
737
+
738
+ this.templateIdCache.set(templateId, rid);
739
+ return rid;
740
+ }
741
+
529
742
  /** Permanently deletes project from the project list, this will result in
530
743
  * destruction of all attached objects, like files, analysis results etc. */
531
744
  public async deleteProject(id: ProjectId): Promise<void> {
@@ -748,23 +961,116 @@ export class MiddleLayer {
748
961
  expiresAt,
749
962
  });
750
963
 
751
- // Grant in the same transaction (writable: the cross-color accept rule demands a writable
752
- // grant on the envelope). Atomic with the create.
753
- const envelopeGid = await envelope.globalId;
754
- if (everyone) {
755
- // One everyone-grant: empty/ignored target, ANY_AUTHORISED. The backend rewrites
756
- // the target to the everyone-user; gated by role + permission ceiling.
757
- tx.grantAccess(envelopeGid, "", { writable: true }, GrantType.ANY_AUTHORISED);
758
- } else {
759
- for (const recipient of options.recipients) {
760
- tx.grantAccess(envelopeGid, recipient, { writable: true });
761
- }
762
- }
964
+ // Grant in the same transaction, atomic with the create.
965
+ await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, {
966
+ writable: true,
967
+ });
968
+
969
+ await tx.commit();
970
+ });
971
+
972
+ await this.sharingOutboxTree.refreshState();
973
+ }
974
+
975
+ /**
976
+ * Grants one freshly built envelope inside the transaction that created it: a single make-public
977
+ * grant for an everyone-share (empty/ignored target, ANY_AUTHORISED — the backend rewrites the
978
+ * target to the everyone-user, gated by role + permission ceiling), or one grant per named
979
+ * recipient.
980
+ *
981
+ * `writable` is not a preference. A project pack needs a writable grant because accepting copies
982
+ * the snapshots out of the envelope, and the cross-color attach rule permits that only to a
983
+ * writable grant holder. A template share copies nothing — the document sits in the envelope's
984
+ * own immutable data — so it is granted read-only, and must be: a writable everyone-grant would
985
+ * hand every user on the server write access to the envelope.
986
+ */
987
+ private async grantShareEnvelope(
988
+ tx: PlTransaction,
989
+ envelope: ResourceRef,
990
+ everyone: boolean,
991
+ recipients: string[],
992
+ permissions: { writable: boolean },
993
+ ): Promise<void> {
994
+ const gid = await envelope.globalId;
995
+ if (everyone) tx.grantAccess(gid, "", permissions, GrantType.ANY_AUTHORISED);
996
+ else for (const r of recipients) tx.grantAccess(gid, r, permissions);
997
+ }
998
+
999
+ /**
1000
+ * Shares one stored template. The envelope carries the document itself, so there is no project
1001
+ * snapshot and no resource for the recipient to copy out — which is why the grant is read-only.
1002
+ * The cost of that is the donor's receipt: nobody can write an acceptance onto a read-only
1003
+ * envelope, so a template share never reports who accepted it.
1004
+ *
1005
+ * A template holding a block installed from a folder on this machine is refused rather than sent,
1006
+ * with every offending entry named. {@link checkTemplateShareable} answers the same question
1007
+ * without attempting the share, so a UI can state it on the template itself.
1008
+ *
1009
+ * @param id template to share
1010
+ * @param options recipients XOR everyone, plus the title recipients see
1011
+ */
1012
+ public async shareTemplate(
1013
+ id: TemplateId,
1014
+ options: ShareTemplateOptions,
1015
+ ): Promise<ShareTemplateOutcome> {
1016
+ const loaded = await this.loadShareableTemplate(id);
1017
+ if (!loaded.ok) return { ok: false, problems: loaded.problems };
763
1018
 
1019
+ const everyone = "everyone" in options;
1020
+ const sender = this.currentUserLogin ?? "";
1021
+ // Targeted share: sharedAt + ttl. Share-with-everybody: never expires (null).
1022
+ const expiresAt = everyone ? null : Date.now() + this.env.ops.envelopeTtlMs;
1023
+
1024
+ let shareId: ShareId | undefined;
1025
+ await this.pl.withWriteTx("MLShareTemplate", async (tx) => {
1026
+ const { envelope, data } = buildTemplateShareEnvelope(
1027
+ tx,
1028
+ this.sharingOutboxResourceId,
1029
+ loaded.template,
1030
+ { sender, title: options.title, expiresAt },
1031
+ );
1032
+ shareId = data.shareId;
1033
+ await this.grantShareEnvelope(tx, envelope, everyone, everyone ? [] : options.recipients, {
1034
+ writable: false,
1035
+ });
764
1036
  await tx.commit();
765
1037
  });
766
1038
 
767
1039
  await this.sharingOutboxTree.refreshState();
1040
+ return { ok: true, shareId: shareId! };
1041
+ }
1042
+
1043
+ /**
1044
+ * Every entry of a stored template that stands in the way of sharing it, empty for a template
1045
+ * that can be shared. Reads the template and nothing else, so a UI can state the refusal on the
1046
+ * template itself instead of only when the user tries to share it.
1047
+ */
1048
+ public async checkTemplateShareable(id: TemplateId): Promise<readonly TemplateShareProblem[]> {
1049
+ const stored = await this.getTemplateData(id);
1050
+ return unshareableTemplateEntries(stored.document);
1051
+ }
1052
+
1053
+ /** The document and the label of a template that may be shared, or every entry that stops it.
1054
+ * The label is what the recipient's own list will show, so it travels with the document. */
1055
+ private async loadShareableTemplate(
1056
+ id: TemplateId,
1057
+ ): Promise<
1058
+ | { ok: true; template: { document: ProjectTemplateV1; label: string } }
1059
+ | { ok: false; problems: readonly TemplateShareProblem[] }
1060
+ > {
1061
+ const rid = await this.resolveTemplateId(id);
1062
+ const template = await this.pl.withReadTx("MLReadTemplateForShare", async (tx) => {
1063
+ const rd = await tx.getResourceData(rid, false);
1064
+ if (rd.data === undefined) throw new Error(`Template ${id} carries no document.`);
1065
+ return {
1066
+ document: decodeStoredTemplateData(rd.data).document,
1067
+ label: await tx.getKValueJson<string>(rid, TemplateLabelKey),
1068
+ };
1069
+ });
1070
+
1071
+ const problems = unshareableTemplateEntries(template.document);
1072
+ if (problems.length > 0) return { ok: false, problems };
1073
+ return { ok: true, template };
768
1074
  }
769
1075
 
770
1076
  /**
@@ -782,6 +1088,12 @@ export class MiddleLayer {
782
1088
  * existing snapshot (and its timestamp), `remove` drops the project from the pack. A project not
783
1089
  * in the map defaults to `keep`. Omit the whole map for the legacy auto behavior (live sources
784
1090
  * updated, gone ones kept) — the everyone-refresh path relies on that.
1091
+ *
1092
+ * `opts.templateId` is required for, and only used by, a share that carries a template: a stored
1093
+ * template is immutable, so an improved one is a different template and the share cannot re-read
1094
+ * the one it started from — the caller names the new target. Every other option means the same
1095
+ * thing for both kinds of share. Sharing the named template must be permitted (see
1096
+ * {@link checkTemplateShareable}) or this throws.
785
1097
  */
786
1098
  public async changeShare(
787
1099
  shareId: ShareId,
@@ -790,8 +1102,18 @@ export class MiddleLayer {
790
1102
  everyone?: boolean;
791
1103
  title?: string;
792
1104
  projectActions?: Record<ProjectId, ProjectChangeAction>;
1105
+ templateId?: TemplateId;
793
1106
  } = {},
794
1107
  ): Promise<void> {
1108
+ // Read outside the write tx: it is two round-trips of its own, and the refusal it can produce
1109
+ // must be raised before anything is torn down.
1110
+ const target =
1111
+ opts.templateId === undefined ? undefined : await this.loadShareableTemplate(opts.templateId);
1112
+ if (target !== undefined && !target.ok)
1113
+ throw new Error(
1114
+ `changeShare: template ${opts.templateId} cannot be shared: ${describeShareProblems(target.problems)}`,
1115
+ );
1116
+
795
1117
  await this.pl.withWriteTx("MLChangeShare", async (tx) => {
796
1118
  const old = await this.resolveOutboxEnvelope(tx, shareId);
797
1119
  if (old === undefined)
@@ -801,6 +1123,38 @@ export class MiddleLayer {
801
1123
  const grants = await tx.listGrants(old.rid);
802
1124
  // A targeted share may be upgraded to everyone; an everyone-share can't be narrowed back.
803
1125
  const everyone = grants.some((g) => isEveryoneUserLogin(g.user)) || opts.everyone === true;
1126
+ const priorRecipients = grants
1127
+ .filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self)
1128
+ .map((g) => g.user);
1129
+
1130
+ if (old.data.payload.kind === "template") {
1131
+ if (target === undefined)
1132
+ throw new Error(
1133
+ `changeShare: share ${shareId} carries a template, so it needs an explicit target ` +
1134
+ "template — a stored template never changes, so an improved one is a different template.",
1135
+ );
1136
+ const recipients = everyone ? [] : (opts.recipients ?? priorRecipients);
1137
+
1138
+ // Same shareId, same outbox field name — detach the old field before rebuilding, or they collide.
1139
+ tx.removeField(field(this.sharingOutboxResourceId, old.fieldName));
1140
+ const { envelope } = buildTemplateShareEnvelope(
1141
+ tx,
1142
+ this.sharingOutboxResourceId,
1143
+ target.template,
1144
+ {
1145
+ sender: self,
1146
+ title: opts.title === undefined ? old.data.title : opts.title.trim(),
1147
+ expiresAt: everyone ? null : Date.now() + this.env.ops.envelopeTtlMs,
1148
+ shareId, // SAME shareId — the essence of change
1149
+ },
1150
+ );
1151
+
1152
+ // Nothing to transfer: a read-only grant cannot write an acceptance, so a template share
1153
+ // never accumulated one.
1154
+ await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: false });
1155
+ await tx.commit();
1156
+ return;
1157
+ }
804
1158
 
805
1159
  // Read the old envelope's project snapshots (uuid -> rid) and accept/reject records.
806
1160
  const oldRd = await tx.getResourceData(old.rid, true);
@@ -822,9 +1176,6 @@ export class MiddleLayer {
822
1176
  const decidedLogins = acceptances.map((a) => a.login);
823
1177
 
824
1178
  // Everyone-shares ignore recipients; targeted shares keep decided users plus the edited set.
825
- const priorRecipients = grants
826
- .filter((g) => !isEveryoneUserLogin(g.user) && g.user !== self)
827
- .map((g) => g.user);
828
1179
  const recipients = everyone
829
1180
  ? []
830
1181
  : Array.from(new Set([...(opts.recipients ?? priorRecipients), ...decidedLogins]));
@@ -841,8 +1192,9 @@ export class MiddleLayer {
841
1192
  // projectActions map, fall back to the legacy auto behavior: update a live source, keep a gone one.
842
1193
  const actions = opts.projectActions;
843
1194
  const sources: EnvelopeProjectSource[] = [];
844
- for (const uuid of Object.keys(old.data.projects) as ProjectFieldUuid[]) {
845
- const { label, source, updatedAt } = old.data.projects[uuid];
1195
+ const oldProjects = envelopeProjectMap(old.data);
1196
+ for (const uuid of Object.keys(oldProjects) as ProjectFieldUuid[]) {
1197
+ const { label, source, updatedAt } = oldProjects[uuid];
846
1198
  const liveRid = liveProjects.get(source);
847
1199
 
848
1200
  const action = actions
@@ -884,9 +1236,7 @@ export class MiddleLayer {
884
1236
  writeEnvelopeAcceptance(tx, envelope, login, acc.action, acc.timestamp);
885
1237
  }
886
1238
 
887
- const gid = await envelope.globalId;
888
- if (everyone) tx.grantAccess(gid, "", { writable: true }, GrantType.ANY_AUTHORISED);
889
- else for (const r of recipients) tx.grantAccess(gid, r, { writable: true });
1239
+ await this.grantShareEnvelope(tx, envelope, everyone, recipients, { writable: true });
890
1240
 
891
1241
  await tx.commit();
892
1242
  });
@@ -919,7 +1269,8 @@ export class MiddleLayer {
919
1269
  const rd = await tx.getResourceData(f.value, false);
920
1270
  if (rd.data === undefined) continue;
921
1271
  const data = decodeEnvelopeData(rd.data);
922
- if (Object.values(data.projects).some((p) => wanted.has(p.source)))
1272
+ if (data === undefined) continue;
1273
+ if (Object.values(envelopeProjectMap(data)).some((p) => wanted.has(p.source)))
923
1274
  out.push({ fieldName: f.name, rid: f.value, shareId: data.shareId });
924
1275
  }
925
1276
  return out;
@@ -971,6 +1322,7 @@ export class MiddleLayer {
971
1322
  const rd = await tx.getResourceData(f.value, false);
972
1323
  if (rd.data === undefined) continue;
973
1324
  const data = decodeEnvelopeData(rd.data);
1325
+ if (data === undefined) continue;
974
1326
  if (data.shareId === shareId) return { fieldName: f.name, rid: f.value, data };
975
1327
  }
976
1328
  return undefined;
@@ -996,22 +1348,31 @@ export class MiddleLayer {
996
1348
  }
997
1349
 
998
1350
  /**
999
- * Accepts one or more pending shares: duplicates each share's projects into this user's
1000
- * project list, records the decision per share, and (read-write share) writes the donor-visible
1001
- * acceptance onto the envelope. Per-share failures (e.g. an expiry race) are collected, not
1002
- * short-circuited the rest still get accepted. Accept-all = pass every current pending shareId.
1351
+ * Accepts one or more pending shares. What accepting does depends on what the share carries: a
1352
+ * pack of projects is duplicated into this user's project list, while a template is added to this
1353
+ * user's own template list and builds nothing the recipient decides later whether to apply it.
1354
+ * Either way the decision is recorded per share, and a read-write share also gets the
1355
+ * donor-visible acceptance written onto its envelope. Per-share failures (e.g. an expiry race)
1356
+ * are collected, not short-circuited — the rest still get accepted. Accept-all = pass every
1357
+ * current pending shareId.
1003
1358
  *
1004
1359
  * `rename` resolves label collisions (same callback contract as {@link duplicateProject}), but
1005
- * the source lives in the envelope tree, so accept calls the low-level mutator directly.
1360
+ * the source lives in the envelope tree, so accept calls the low-level mutator directly. It does
1361
+ * not apply to a template share, whose label is not required to be unique.
1006
1362
  */
1007
1363
  public async acceptShare(
1008
1364
  shareIds: ShareId[],
1009
1365
  rename?: (previousLabel: string, existingLabels: string[]) => string,
1010
- ): Promise<{ accepted: ProjectId[]; failed: { shareId: ShareId; error: string }[] }> {
1366
+ ): Promise<{
1367
+ accepted: ProjectId[];
1368
+ acceptedTemplates: TemplateId[];
1369
+ failed: { shareId: ShareId; error: string }[];
1370
+ }> {
1011
1371
  const live = await this.resolveLiveEnvelopes();
1012
1372
  const login = this.currentUserLogin;
1013
1373
 
1014
1374
  const accepted: ProjectId[] = [];
1375
+ const acceptedTemplates: TemplateId[] = [];
1015
1376
  const failed: { shareId: ShareId; error: string }[] = [];
1016
1377
 
1017
1378
  for (const shareId of shareIds) {
@@ -1022,6 +1383,36 @@ export class MiddleLayer {
1022
1383
  }
1023
1384
  try {
1024
1385
  const now = Date.now();
1386
+ const payload = envelope.data.payload;
1387
+
1388
+ if (payload.kind === "template") {
1389
+ const rid = await this.pl.withWriteTx("MLAcceptTemplateShare", async (tx) => {
1390
+ // The template lands on this user's own shelf, keeping who sent it as its provenance.
1391
+ const tpl = createTemplate(tx, this.templateListResourceId, payload.label, {
1392
+ schemaVersion: 1,
1393
+ document: payload.document,
1394
+ sender: payload.from,
1395
+ });
1396
+
1397
+ writeSharingDecision(tx, this.sharingStateResourceId, shareId, {
1398
+ decision: "accepted",
1399
+ timestamp: now,
1400
+ envelopeSharedAt: envelope.data.sharedAt,
1401
+ acceptedProjects: [], // a template share creates no project
1402
+ });
1403
+
1404
+ // No acceptance/{login} on the envelope: the grant is read-only, so the write would be
1405
+ // refused by the backend, and the donor deliberately gave up that receipt.
1406
+ await tx.commit();
1407
+ return await tpl.globalId;
1408
+ });
1409
+
1410
+ const templateId = resourceIdToString(rid) as TemplateId;
1411
+ this.templateIdCache.set(templateId, rid);
1412
+ acceptedTemplates.push(templateId);
1413
+ continue;
1414
+ }
1415
+
1025
1416
  const createdRids = await this.pl.withWriteTx("MLAcceptShare", async (tx) => {
1026
1417
  const created = await copyEnvelopeProjectsIntoList(
1027
1418
  tx,
@@ -1055,8 +1446,12 @@ export class MiddleLayer {
1055
1446
  }
1056
1447
  }
1057
1448
 
1058
- await Promise.all([this.projectListTree.refreshState(), this.sharingStateTree.refreshState()]);
1059
- return { accepted, failed };
1449
+ await Promise.all([
1450
+ this.projectListTree.refreshState(),
1451
+ this.templateListTree.refreshState(),
1452
+ this.sharingStateTree.refreshState(),
1453
+ ]);
1454
+ return { accepted, acceptedTemplates, failed };
1060
1455
  }
1061
1456
 
1062
1457
  /** Records rejection of a pending share; it never surfaces again. */
@@ -1114,6 +1509,7 @@ export class MiddleLayer {
1114
1509
  const rd = await tx.getResourceData(f.value, false);
1115
1510
  if (rd.data === undefined) continue;
1116
1511
  const envData = decodeEnvelopeData(rd.data);
1512
+ if (envData === undefined) continue;
1117
1513
  if (envData.expiresAt === null) continue; // never expires
1118
1514
  if (envData.expiresAt <= now) toDelete.push({ fieldName: f.name });
1119
1515
  }
@@ -1229,6 +1625,7 @@ export class MiddleLayer {
1229
1625
  // this.env.quickJs;
1230
1626
  await Promise.all([
1231
1627
  this.projectListTree.terminate(),
1628
+ this.templateListTree.terminate(),
1232
1629
  this.sharingOutboxTree.terminate(),
1233
1630
  this.sharingStateTree.terminate(),
1234
1631
  this.pendingSharesTree.terminate(),
@@ -1276,7 +1673,7 @@ export class MiddleLayer {
1276
1673
  )
1277
1674
  ops.defaultTreeOptions.traversalMode = getDebugFlags().treeTraversalMode;
1278
1675
 
1279
- const { projects, sharingOutbox, sharingState } = await pl.withWriteTx(
1676
+ const { projects, templates, sharingOutbox, sharingState } = await pl.withWriteTx(
1280
1677
  "MLInitialization",
1281
1678
  async (tx) => {
1282
1679
  // Lazily create each clientRoot-attached singleton resource. Returns the existing
@@ -1298,6 +1695,7 @@ export class MiddleLayer {
1298
1695
  };
1299
1696
 
1300
1697
  const projectsR = await lazyInit(ProjectsField, ProjectsResourceType);
1698
+ const templatesR = await lazyInit(TemplatesField, TemplatesResourceType);
1301
1699
  const outboxR = await lazyInit(SharingOutboxField, SharingOutboxResourceType);
1302
1700
  const stateR = await lazyInit(SharingStateField, SharingStateResourceType);
1303
1701
 
@@ -1305,6 +1703,7 @@ export class MiddleLayer {
1305
1703
 
1306
1704
  return {
1307
1705
  projects: projectsR.existing ?? (await projectsR.ref!.globalId),
1706
+ templates: templatesR.existing ?? (await templatesR.ref!.globalId),
1308
1707
  sharingState: stateR.existing ?? (await stateR.ref!.globalId),
1309
1708
  sharingOutbox: outboxR.existing ?? (await outboxR.ref!.globalId),
1310
1709
  };
@@ -1389,6 +1788,7 @@ export class MiddleLayer {
1389
1788
 
1390
1789
  const openedProjects = new WatchableValue<ProjectId[]>([]);
1391
1790
  const projectListTC = await createProjectList(pl, projects, openedProjects, env);
1791
+ const templateListTC = await createTemplateList(pl, templates, env);
1392
1792
 
1393
1793
  // Project sharing trees and reactive views.
1394
1794
  const outgoingTC = await createOutgoingShares(pl, sharingOutbox, env);
@@ -1406,18 +1806,31 @@ export class MiddleLayer {
1406
1806
  driverKit,
1407
1807
  driverKit.signer,
1408
1808
  projects,
1809
+ templates,
1409
1810
  sharingOutbox,
1410
1811
  sharingState,
1411
1812
  openedProjects,
1412
1813
  projectListTC.tree,
1814
+ templateListTC.tree,
1413
1815
  outgoingTC.tree,
1414
1816
  sharingStateTree,
1415
1817
  pendingSharesTree,
1416
1818
  v2RegistryProvider,
1417
1819
  projectListTC.computable,
1820
+ templateListTC.computable,
1418
1821
  outgoingTC.computable,
1419
1822
  pendingShares,
1420
1823
  liveEnvelopes,
1421
1824
  );
1422
1825
  }
1423
1826
  }
1827
+
1828
+ //
1829
+ // Internals
1830
+ //
1831
+
1832
+ /** Refusal reasons as one line, each naming the entry it belongs to, so a throw that escapes to a
1833
+ * log still says which block stands in the way. */
1834
+ function describeShareProblems(problems: readonly TemplateShareProblem[]): string {
1835
+ return problems.map((p) => `${p.entryId}: ${p.error}`).join("; ");
1836
+ }