@milaboratories/pl-middle-layer 1.68.3 → 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.
- package/dist/index.cjs +10 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -2
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/index.cjs +2 -0
- package/dist/middle_layer/index.d.ts +2 -1
- package/dist/middle_layer/index.js +2 -1
- package/dist/middle_layer/middle_layer.cjs +333 -26
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +125 -7
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +335 -28
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project_list.d.ts +1 -1
- package/dist/middle_layer/sharing_list.cjs +13 -5
- package/dist/middle_layer/sharing_list.cjs.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +16 -2
- package/dist/middle_layer/sharing_list.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.js +14 -6
- package/dist/middle_layer/sharing_list.js.map +1 -1
- package/dist/middle_layer/template_list.cjs +72 -0
- package/dist/middle_layer/template_list.cjs.map +1 -0
- package/dist/middle_layer/template_list.d.ts +77 -0
- package/dist/middle_layer/template_list.d.ts.map +1 -0
- package/dist/middle_layer/template_list.js +64 -0
- package/dist/middle_layer/template_list.js.map +1 -0
- package/dist/model/index.cjs +8 -0
- package/dist/model/index.d.ts +5 -2
- package/dist/model/index.js +4 -2
- package/dist/model/sharing_model.cjs +59 -2
- package/dist/model/sharing_model.cjs.map +1 -1
- package/dist/model/sharing_model.d.ts +70 -7
- package/dist/model/sharing_model.d.ts.map +1 -1
- package/dist/model/sharing_model.js +57 -3
- package/dist/model/sharing_model.js.map +1 -1
- package/dist/model/template_serializer.d.ts +36 -0
- package/dist/model/template_serializer.d.ts.map +1 -1
- package/dist/model/template_share.cjs +42 -0
- package/dist/model/template_share.cjs.map +1 -0
- package/dist/model/template_share.d.ts +27 -0
- package/dist/model/template_share.d.ts.map +1 -0
- package/dist/model/template_share.js +42 -0
- package/dist/model/template_share.js.map +1 -0
- package/dist/mutator/project.cjs +2 -2
- package/dist/mutator/project.js +2 -2
- package/dist/mutator/sharing.cjs +40 -2
- package/dist/mutator/sharing.cjs.map +1 -1
- package/dist/mutator/sharing.js +40 -3
- package/dist/mutator/sharing.js.map +1 -1
- package/dist/mutator/template.cjs +54 -0
- package/dist/mutator/template.cjs.map +1 -0
- package/dist/mutator/template.js +52 -0
- package/dist/mutator/template.js.map +1 -0
- package/package.json +8 -8
- package/src/middle_layer/index.ts +9 -0
- package/src/middle_layer/middle_layer.ts +449 -36
- package/src/middle_layer/sharing_list.ts +37 -7
- package/src/middle_layer/template_list.ts +177 -0
- package/src/middle_layer/templates.test.ts +301 -0
- package/src/model/index.ts +14 -0
- package/src/model/sharing_model.test.ts +115 -1
- package/src/model/sharing_model.ts +134 -9
- package/src/model/template_share.test.ts +78 -0
- package/src/model/template_share.ts +52 -0
- package/src/mutator/sharing.ts +55 -3
- package/src/mutator/template.ts +75 -0
- 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
|
|
455
|
+
const preparation = await this.prepareTemplateEntries(document, provider, {
|
|
431
456
|
allowUnstable: options.allowUnstable ?? false,
|
|
432
457
|
});
|
|
433
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
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<{
|
|
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([
|
|
1059
|
-
|
|
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
|
+
}
|