@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
@@ -15,11 +15,14 @@ import type {
15
15
  EnvelopeAcceptance,
16
16
  EnvelopeData,
17
17
  EnvelopeMode,
18
+ EnvelopePayloadKind,
18
19
  ShareId,
19
20
  } from "../model/sharing_model";
20
21
  import {
21
22
  AcceptanceFieldPrefix,
22
23
  asShareId,
24
+ envelopeProjectMap,
25
+ normalizeEnvelopeData,
23
26
  SharedEnvelopeResourceType,
24
27
  SharingOutboxResourceType,
25
28
  SharingStateResourceType,
@@ -32,19 +35,28 @@ export interface OutgoingShare {
32
35
  expiresAt?: number; // EnvelopeData.expiresAt; null maps to undefined = never expires
33
36
  mode: EnvelopeMode;
34
37
  title: string; // display name shown to recipients; defaults to the first project's name
38
+ /** What the share carries — a pack of projects, or one template document. */
39
+ payloadKind: EnvelopePayloadKind;
35
40
  /** One entry per project in the pack, so the change UI can offer a per-project decision.
36
41
  * `projectId` is the donor's source project id; `updatedAt` is when this project's
37
- * snapshot was last (re)taken. */
42
+ * snapshot was last (re)taken. Empty for a template share. */
38
43
  projects: { projectId: ProjectId; label: string; updatedAt: number }[];
44
+ /** The shared template, for a template share; absent for a project share. */
45
+ template?: { label: string; blockCount: number };
39
46
  /** Full recipient logins, from `ListGrants` on the envelope; `["*"]` for everyone-shares. */
40
47
  recipients: string[];
48
+ /** Whether {@link responses} can ever be populated for this share. A template share is granted
49
+ * read-only, so no recipient can record a reply on the envelope and the donor never learns who
50
+ * accepted — a view must say so rather than render an empty response list as "nobody yet". */
51
+ responsesAvailable: boolean;
41
52
  /** Per recipient who has responded: their decision and when, from acceptance/{login}. */
42
53
  responses: Record<string, { action: "accepted" | "rejected"; timestamp: number }>;
43
54
  }
44
55
 
45
- /** Per-project view for the donor's change UI, from {@link EnvelopeData.projects}. */
56
+ /** Per-project view for the donor's change UI, from the envelope's `projects` payload; empty
57
+ * for a payload that carries no project. */
46
58
  function envelopeProjects(data: EnvelopeData): OutgoingShare["projects"] {
47
- return Object.values(data.projects).map((p) => ({
59
+ return Object.values(envelopeProjectMap(data)).map((p) => ({
48
60
  projectId: p.source,
49
61
  label: p.label,
50
62
  updatedAt: p.updatedAt,
@@ -57,6 +69,9 @@ export interface PendingShare {
57
69
  sender: string; // EnvelopeData.sender, display only
58
70
  title: string; // display name shown to recipients; defaults to the first project's name
59
71
  mode: EnvelopeMode; // v1 renders only "copy" entries
72
+ /** What the offer carries, so the recipient is told what they are being offered — projects to
73
+ * copy, or a template for their own shelf. */
74
+ payloadKind: EnvelopePayloadKind;
60
75
  grantedAt: number;
61
76
  }
62
77
 
@@ -108,8 +123,8 @@ export function createOutgoingSharesComputable(
108
123
  if (envelope === undefined) continue;
109
124
  if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
110
125
 
111
- const data = envelope.getDataAsJson<EnvelopeData>();
112
- if (data === undefined) continue;
126
+ const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
127
+ if (data === undefined) continue; // unknown version or payload kind — not ours to show
113
128
 
114
129
  const responses: OutgoingShare["responses"] = {};
115
130
  for (const f of envelope.listDynamicFields()) {
@@ -127,7 +142,17 @@ export function createOutgoingSharesComputable(
127
142
  ...(data.expiresAt !== null ? { expiresAt: data.expiresAt } : {}),
128
143
  mode: data.mode,
129
144
  title: data.title,
145
+ payloadKind: data.payload.kind,
130
146
  projects: envelopeProjects(data),
147
+ ...(data.payload.kind === "template"
148
+ ? {
149
+ template: {
150
+ label: data.payload.label,
151
+ blockCount: data.payload.document.blocks.length,
152
+ },
153
+ }
154
+ : {}),
155
+ responsesAvailable: data.payload.kind !== "template",
131
156
  responses,
132
157
  envelopeRid: envelope.id,
133
158
  });
@@ -255,7 +280,9 @@ export function createLiveEnvelopesComputable(
255
280
  for (const envelope of roots) {
256
281
  if (envelope === undefined) continue;
257
282
  if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
258
- const data = envelope.getDataAsJson<EnvelopeData>();
283
+ const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
284
+ // Same recognise-or-hide rule as the pending view, and for the same envelope: hiding an
285
+ // offer while accept could still resolve it would only move the problem.
259
286
  if (data === undefined) continue;
260
287
  result.push({ rid: envelope.id, data });
261
288
  }
@@ -292,7 +319,9 @@ export function createPendingSharesComputable(
292
319
  for (const envelope of roots) {
293
320
  if (envelope === undefined) continue;
294
321
  if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
295
- const data = envelope.getDataAsJson<EnvelopeData>();
322
+ const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
323
+ // An envelope whose schemaVersion or payload kind this build does not know is hidden, not
324
+ // offered: there is nothing useful to do with a share we cannot read.
296
325
  if (data === undefined) continue;
297
326
  if (handled.has(data.shareId)) continue; // already accepted or rejected
298
327
  if (currentUserLogin !== null && data.sender === currentUserLogin) continue; // own share
@@ -303,6 +332,7 @@ export function createPendingSharesComputable(
303
332
  sender: data.sender,
304
333
  title: data.title,
305
334
  mode: data.mode,
335
+ payloadKind: data.payload.kind,
306
336
  grantedAt: data.sharedAt,
307
337
  });
308
338
  }
@@ -0,0 +1,177 @@
1
+ import type { PruningFunction } from "@milaboratories/pl-tree";
2
+ import { SynchronizedTreeState } from "@milaboratories/pl-tree";
3
+ import type {
4
+ Filter,
5
+ PlClient,
6
+ PlTransaction,
7
+ ResourceType,
8
+ SignedResourceId,
9
+ } from "@milaboratories/pl-client";
10
+ import {
11
+ field,
12
+ isNullSignedResourceId,
13
+ resourceIdToString,
14
+ resourceTypesEqual,
15
+ treeFilter,
16
+ } from "@milaboratories/pl-client";
17
+ import type { TreeAndComputableU } from "./types";
18
+ import { Computable } from "@milaboratories/computable";
19
+ import type { MiddleLayerEnvironment } from "./middle_layer";
20
+ import { notEmpty } from "@milaboratories/ts-helpers";
21
+ import type { Branded, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
22
+ import type { TemplateExportProblem } from "../model/template_export";
23
+ import type { TemplateShareProblem } from "../model/template_share";
24
+ import type { ShareId } from "../model/sharing_model";
25
+ import type { AppliedEntry, TemplateApplyProblem } from "../model/template_apply";
26
+ import type { ProjectId } from "../model/project_model";
27
+
28
+ export const TemplatesField = "templates";
29
+ export const TemplatesResourceType: ResourceType = { name: "Templates", version: "1" };
30
+ export const TemplateResourceType: ResourceType = { name: "UserTemplate", version: "1" };
31
+
32
+ /** Mutable: the only part of a stored template a rename may touch. */
33
+ export const TemplateLabelKey = "TemplateLabel";
34
+ export const TemplateCreatedTimestamp = "TemplateCreated";
35
+
36
+ /**
37
+ * Unique template identifier in middle layer, the stringified signed resource id of the
38
+ * `UserTemplate`. Branded so it cannot be confused with a {@link ProjectId} — both are
39
+ * stringified resource ids and every template method takes one of them.
40
+ */
41
+ export type TemplateId = Branded<string, "TemplateId">;
42
+
43
+ /**
44
+ * Immutable `data` on a UserTemplate: the document plus what was true when it was taken.
45
+ *
46
+ * The document lives here and never in KV: KV is listed and fully re-read on every poll,
47
+ * while resource data syncs incrementally, and a template document is a multi-kilobyte
48
+ * value that never changes.
49
+ */
50
+ export interface StoredTemplateData {
51
+ schemaVersion: 1;
52
+ document: ProjectTemplateV1;
53
+ /** Provenance, display only; absent for a template that arrived as a share. */
54
+ sourceProjectLabel?: string;
55
+ /** Login of the sender, when it arrived as a share. */
56
+ sender?: string;
57
+ }
58
+
59
+ /** Decodes the immutable `data` blob of a `UserTemplate` read through a transaction. The
60
+ * single raw-decode site; the tree side reads the same JSON with `getDataAsJson`. */
61
+ export function decodeStoredTemplateData(data: Uint8Array): StoredTemplateData {
62
+ return JSON.parse(Buffer.from(data).toString("utf-8")) as StoredTemplateData;
63
+ }
64
+
65
+ /** One template as the template list surfaces it. */
66
+ export interface TemplateListEntry {
67
+ /** Unique template identifier in middle layer. Use to operate with the given template. */
68
+ id: TemplateId;
69
+ /** The mutable label, the only part a rename changes. */
70
+ label: string;
71
+ created: Date;
72
+ /** Number of blocks the stored document lists — derived, not stored. */
73
+ blockCount: number;
74
+ sourceProjectLabel?: string;
75
+ sender?: string;
76
+ }
77
+
78
+ /** What saving a project as a template yields: the stored template, or every block in the way. */
79
+ export type SaveProjectAsTemplateOutcome =
80
+ | { readonly ok: true; readonly templateId: TemplateId }
81
+ | { readonly ok: false; readonly problems: readonly TemplateExportProblem[] };
82
+
83
+ /** What sharing a stored template yields: the share's logical id, or every entry in the way. */
84
+ export type ShareTemplateOutcome =
85
+ | { readonly ok: true; readonly shareId: ShareId }
86
+ | { readonly ok: false; readonly problems: readonly TemplateShareProblem[] };
87
+
88
+ /**
89
+ * What applying a stored template yields.
90
+ *
91
+ * `ok: false` carries no project id because no project was created: nothing is written
92
+ * until every entry has an installable block.
93
+ */
94
+ export type CreateProjectFromTemplateOutcome =
95
+ | {
96
+ readonly ok: true;
97
+ readonly projectId: ProjectId;
98
+ readonly added: readonly AppliedEntry[];
99
+ }
100
+ | { readonly ok: false; readonly problems: readonly TemplateApplyProblem[] };
101
+
102
+ /**
103
+ * Resolves the templates-list resource on the transaction's client root, lazily creating (and
104
+ * locking) an empty one when the {@link TemplatesField} is not yet populated. Returns its signed
105
+ * id. Used when writing into a root that may have no templates list yet, e.g. a template landing
106
+ * in a recipient's root.
107
+ */
108
+ export async function ensureTemplateListRid(tx: PlTransaction): Promise<SignedResourceId> {
109
+ const templatesField = field(tx.clientRoot, TemplatesField);
110
+ tx.createField(templatesField, "Dynamic");
111
+ const fData = await tx.getField(templatesField);
112
+ if (isNullSignedResourceId(fData.value)) {
113
+ const ref = tx.createEphemeral(TemplatesResourceType);
114
+ tx.lock(ref);
115
+ tx.setField(templatesField, ref);
116
+ return await ref.globalId;
117
+ }
118
+ return fData.value;
119
+ }
120
+
121
+ export const TemplatesListTreePruningFunction: PruningFunction = (resource) => {
122
+ if (!resourceTypesEqual(resource.type, TemplatesResourceType)) return [];
123
+ return resource.fields;
124
+ };
125
+
126
+ export const templatesListFieldFilter: Filter = treeFilter.resourceTypeEq(
127
+ TemplatesResourceType.name,
128
+ );
129
+
130
+ export async function createTemplateList(
131
+ pl: PlClient,
132
+ rid: SignedResourceId,
133
+ env: MiddleLayerEnvironment,
134
+ ): Promise<TreeAndComputableU<TemplateListEntry[]>> {
135
+ const tree = await SynchronizedTreeState.init(
136
+ pl,
137
+ rid,
138
+ {
139
+ ...env.ops.defaultTreeOptions,
140
+ pruning: TemplatesListTreePruningFunction,
141
+ fieldFilter: templatesListFieldFilter,
142
+ },
143
+ env.logger,
144
+ );
145
+
146
+ const c = Computable.make((ctx) => {
147
+ const node = ctx.accessor(tree.entry()).node();
148
+ if (node === undefined) return undefined;
149
+ const result: TemplateListEntry[] = [];
150
+
151
+ // Templates list resource keeps templates assigned to fields. Each field name is a UUID
152
+ for (const field of node.listDynamicFields()) {
153
+ const tpl = node.traverse(field);
154
+ if (tpl === undefined) continue;
155
+ const data = tpl.getDataAsJson<StoredTemplateData>();
156
+ // A template whose data has not synced yet is not an entry with unknown content —
157
+ // it is an entry we cannot describe at all, so it stays out of the list until it has.
158
+ if (data === undefined) continue;
159
+ const label = notEmpty(tpl.getKeyValueAsJson<string>(TemplateLabelKey));
160
+ const created = notEmpty(tpl.getKeyValueAsJson<number>(TemplateCreatedTimestamp));
161
+ result.push({
162
+ id: resourceIdToString(tpl.id) as TemplateId,
163
+ label,
164
+ created: new Date(created),
165
+ blockCount: data.document.blocks.length,
166
+ ...(data.sourceProjectLabel !== undefined
167
+ ? { sourceProjectLabel: data.sourceProjectLabel }
168
+ : {}),
169
+ ...(data.sender !== undefined ? { sender: data.sender } : {}),
170
+ });
171
+ }
172
+ result.sort((a, b) => b.created.valueOf() - a.created.valueOf());
173
+ return result;
174
+ }).withStableType();
175
+
176
+ return { computable: c, tree };
177
+ }
@@ -0,0 +1,301 @@
1
+ import { expect, test } from "vitest";
2
+ import * as tp from "node:timers/promises";
3
+ import type { ResourceRef, SignedResourceId } from "@milaboratories/pl-client";
4
+ import { resourceIdToString } from "@milaboratories/pl-client";
5
+ import type {
6
+ BlockKindSelectorReference,
7
+ BlockPackLocationReference,
8
+ ProjectTemplateV1,
9
+ ProjectTemplateV1Entry,
10
+ } from "@milaboratories/pl-model-common";
11
+ import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
12
+ import type { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
13
+ import type { BlockPackProvider } from "../model/template_resolve";
14
+ import { withMl } from "../test/with_ml";
15
+ import { createTemplate } from "../mutator/template";
16
+ import type { MiddleLayer } from "./middle_layer";
17
+ import type { StoredTemplateData, TemplateId, TemplateListEntry } from "./template_list";
18
+ import { ensureTemplateListRid } from "./template_list";
19
+
20
+ /**
21
+ * The stored-template entity against a live backend: rename, apply, share, accept.
22
+ *
23
+ * Every test here stores its template directly through the mutator rather than by saving a
24
+ * project, so the document under test is the one the test wrote — a `file:` entry, an entry
25
+ * nothing can resolve — none of which a real project would produce. What a real project
26
+ * produces is covered by the round trip in `drivers-ml-blocks-integration`, which has block
27
+ * packs on disk to build one from.
28
+ *
29
+ * Needs a backend, like every `withMl` test in this package, and no gate: `PL_ADDRESS` is
30
+ * either configured or the client fails to connect.
31
+ */
32
+
33
+ const KIND = "@platforma-open/milaboratories.demo.kind@^1.0.0" as BlockKindSelectorReference;
34
+
35
+ /** A block installed from a folder on the author's own machine. */
36
+ const LOCAL_FOLDER = "file:///Users/dev/blocks/demo/block" as BlockPackLocationReference;
37
+
38
+ /** A legacy registry block, which predates kinds — so it cannot be written to a template. */
39
+ const KindlessBlock: BlockPackSpec = {
40
+ type: "from-registry-v1",
41
+ registryUrl: "https://block.registry.platforma.bio/releases",
42
+ id: { organization: "milaboratory", name: "enter-numbers", version: "1.1.1" },
43
+ };
44
+
45
+ test("a rename changes the label and leaves the stored document byte-identical", async () => {
46
+ await withMl(async (ml) => {
47
+ const document = documentOf(entry("a"), entry("b"));
48
+ const stored = await storeTemplate(ml, "First name", {
49
+ schemaVersion: 1,
50
+ document,
51
+ sourceProjectLabel: "Source project",
52
+ });
53
+ const dataBefore = await rawTemplateData(ml, stored.rid);
54
+
55
+ await ml.renameTemplate(stored.id, "Second name");
56
+
57
+ // The label is the only mutable part: the document rides in the immutable `data` blob,
58
+ // which has no setter — improving a template means saving a new one.
59
+ expect(await rawTemplateData(ml, stored.rid)).toStrictEqual(dataBefore);
60
+ expect((await ml.getTemplateData(stored.id)).document).toStrictEqual(document);
61
+
62
+ const list = await awaitTemplateList(ml, (l) => l.some((t) => t.label === "Second name"));
63
+ expect(list).toHaveLength(1);
64
+ expect(list[0]).toMatchObject({
65
+ id: stored.id,
66
+ label: "Second name",
67
+ blockCount: 2,
68
+ sourceProjectLabel: "Source project",
69
+ });
70
+ });
71
+ });
72
+
73
+ test("an entry nothing can resolve creates no project, and every entry is reported", async () => {
74
+ await withMl(async (ml) => {
75
+ const stored = await storeTemplate(ml, "Nothing implements these", {
76
+ schemaVersion: 1,
77
+ document: documentOf(entry("a"), entry("b")),
78
+ });
79
+
80
+ const outcome = await ml.createProjectFromTemplate(
81
+ stored.id,
82
+ "From a template",
83
+ resolvesNothing(),
84
+ );
85
+
86
+ if (outcome.ok) throw new Error("a template nothing can resolve must not apply");
87
+ // Resolution runs before the project exists, which is what makes this a statement about
88
+ // the template rather than about a half-built project.
89
+ expect(outcome.problems.map((p) => p.entryId)).toStrictEqual(["a", "b"]);
90
+ expect(await ml.projectList.awaitStableValue()).toStrictEqual([]);
91
+ });
92
+ });
93
+
94
+ test("a template holding a block from a folder on this machine is refused rather than shared", async () => {
95
+ await withMl(async (ml) => {
96
+ const stored = await storeTemplate(ml, "Built here", {
97
+ schemaVersion: 1,
98
+ document: documentOf(entry("a"), entry("b", LOCAL_FOLDER)),
99
+ });
100
+
101
+ // Asked of a template that is merely being displayed, so the refusal can be stated on the
102
+ // template itself instead of only once the user has tried to send it.
103
+ expect((await ml.checkTemplateShareable(stored.id)).map((p) => p.entryId)).toStrictEqual(["b"]);
104
+
105
+ const outcome = await ml.shareTemplate(stored.id, {
106
+ recipients: ["colleague"],
107
+ title: "Built here",
108
+ });
109
+
110
+ if (outcome.ok) throw new Error("a template with a file: entry must not be shareable");
111
+ expect(outcome.problems.map((p) => p.entryId)).toStrictEqual(["b"]);
112
+ // Refused before anything was written: no envelope, so nothing to revoke.
113
+ expect((await ml.outgoingShares.getValue()) ?? []).toStrictEqual([]);
114
+ });
115
+ });
116
+
117
+ test("a project holding a block that cannot be written out produces no template", async () => {
118
+ await withMl(async (ml) => {
119
+ const projectId = await ml.createProject({ label: "Two legacy blocks" });
120
+ await ml.openProject(projectId);
121
+ const project = ml.getOpenedProject(projectId);
122
+
123
+ const first = await project.addBlock("Block 1", KindlessBlock);
124
+ const second = await project.addBlock("Block 2", KindlessBlock);
125
+
126
+ const outcome = await ml.saveProjectAsTemplate(projectId);
127
+
128
+ if (outcome.ok) throw new Error("a project with an unexportable block must store no template");
129
+ // Every offending block at once, not the first one: fixing an unexportable project takes
130
+ // one pass, not one pass per block.
131
+ expect(outcome.problems.map((p) => p.blockId).sort()).toStrictEqual([first, second].sort());
132
+ expect(await ml.templateList.awaitStableValue()).toStrictEqual([]);
133
+ });
134
+ });
135
+
136
+ test("an accepted template share lands on the acceptor's shelf and builds nothing", async () => {
137
+ await withMl(async (ml) => {
138
+ const stored = await storeTemplate(ml, "A pipeline", {
139
+ schemaVersion: 1,
140
+ document: documentOf(entry("a")),
141
+ sourceProjectLabel: "Source project",
142
+ });
143
+
144
+ const shared = await ml.shareTemplate(stored.id, { everyone: true, title: "A pipeline" });
145
+ if (!shared.ok) throw new Error(`share refused: ${JSON.stringify(shared.problems)}`);
146
+
147
+ const outcome = await ml.acceptShare([shared.shareId]);
148
+
149
+ expect(outcome.failed).toStrictEqual([]);
150
+ expect(outcome.acceptedTemplates).toHaveLength(1);
151
+ // Nothing is built until the recipient applies it — which is what makes an all-or-nothing
152
+ // apply survivable for them: there is always something left to retry from.
153
+ expect(outcome.accepted).toStrictEqual([]);
154
+ expect(await ml.projectList.awaitStableValue()).toStrictEqual([]);
155
+
156
+ const list = await awaitTemplateList(ml, (l) => l.length === 2);
157
+ const accepted = list.find((t) => t.id === outcome.acceptedTemplates[0])!;
158
+ expect(accepted.label).toBe("A pipeline");
159
+ // Who sent it, kept as the accepted template's provenance; the donor's own source project
160
+ // is not part of the payload and does not travel.
161
+ expect(accepted.sender).toBe(ml.currentUserLogin ?? "");
162
+ expect(accepted.sourceProjectLabel).toBeUndefined();
163
+ });
164
+ });
165
+
166
+ test("a changed template share keeps its id, and whoever already responded is not re-prompted", async () => {
167
+ await withMl(async (ml) => {
168
+ const first = await storeTemplate(ml, "First", {
169
+ schemaVersion: 1,
170
+ document: documentOf(entry("a")),
171
+ });
172
+ const second = await storeTemplate(ml, "Second", {
173
+ schemaVersion: 1,
174
+ document: documentOf(entry("a"), entry("b")),
175
+ });
176
+
177
+ const shared = await ml.shareTemplate(first.id, { everyone: true, title: "First" });
178
+ if (!shared.ok) throw new Error(`share refused: ${JSON.stringify(shared.problems)}`);
179
+
180
+ // Someone responds to the share, which is what the replace below must not undo.
181
+ const accept = await ml.acceptShare([shared.shareId]);
182
+ expect(accept.acceptedTemplates).toHaveLength(1);
183
+
184
+ // A stored template never changes, so an improved one is a different template — the
185
+ // replace names it rather than re-reading the one the share started from.
186
+ await ml.changeShare(shared.shareId, { templateId: second.id, title: "Second" });
187
+
188
+ const outgoing = (await ml.outgoingShares.getValue()) ?? [];
189
+ expect(outgoing.map((s) => s.shareId)).toStrictEqual([shared.shareId]);
190
+ expect(outgoing[0]).toMatchObject({
191
+ payloadKind: "template",
192
+ title: "Second",
193
+ template: { label: "Second", blockCount: 2 },
194
+ // A template share is granted read-only, so no recipient can ever write a reply on the
195
+ // envelope — a view has to say that rather than render an empty list as "nobody yet".
196
+ responsesAvailable: false,
197
+ });
198
+ expect(outgoing[0].projects).toStrictEqual([]);
199
+
200
+ // The decision the accept recorded is keyed on the shareId, which the change preserved, so
201
+ // the replaced share is not offered again. A replace that minted a new id would show up
202
+ // here as a fresh offer.
203
+ const pending = await settledPendingShareIds(ml);
204
+ expect(pending).not.toContain(shared.shareId);
205
+ });
206
+ });
207
+
208
+ //
209
+ // Internals
210
+ //
211
+
212
+ const entry = (id: string, location?: BlockPackLocationReference): ProjectTemplateV1Entry => ({
213
+ id,
214
+ kind: KIND,
215
+ params: {},
216
+ ...(location !== undefined ? { location } : {}),
217
+ });
218
+
219
+ const documentOf = (...blocks: ProjectTemplateV1Entry[]): ProjectTemplateV1 => ({
220
+ schema: PROJECT_TEMPLATE_SCHEMA_V1,
221
+ blocks,
222
+ });
223
+
224
+ /** A stored template, plus the resource id needed to read its raw `data` blob back. */
225
+ type StoredTemplate = { id: TemplateId; rid: SignedResourceId };
226
+
227
+ /**
228
+ * Stores one template through the mutator, on the same templates list the middle layer reads.
229
+ *
230
+ * The middle layer has no way to store an arbitrary document — it only saves a project — so a
231
+ * test that needs a specific document writes it here, exactly as `saveProjectAsTemplate` and
232
+ * the accept path do.
233
+ */
234
+ async function storeTemplate(
235
+ ml: MiddleLayer,
236
+ label: string,
237
+ data: StoredTemplateData,
238
+ ): Promise<StoredTemplate> {
239
+ let tpl: ResourceRef;
240
+ await ml.pl.withWriteTx("TestStoreTemplate", async (tx) => {
241
+ const listRid = await ensureTemplateListRid(tx);
242
+ tpl = createTemplate(tx, listRid, label, data);
243
+ await tx.commit();
244
+ });
245
+ const rid = await tpl!.globalId;
246
+ return { id: resourceIdToString(rid) as TemplateId, rid };
247
+ }
248
+
249
+ /** The template's immutable `data` blob, as bytes — the form a rename must not touch. */
250
+ async function rawTemplateData(ml: MiddleLayer, rid: SignedResourceId): Promise<Buffer> {
251
+ return await ml.pl.withReadTx("TestReadTemplateData", async (tx) => {
252
+ const rd = await tx.getResourceData(rid, false);
253
+ if (rd.data === undefined) throw new Error("template carries no document");
254
+ return Buffer.from(rd.data);
255
+ });
256
+ }
257
+
258
+ /**
259
+ * The template list once it satisfies `predicate`.
260
+ *
261
+ * A template written by the mutator lands in the list through the tree's own poll, with no
262
+ * refresh to await, so a test that stored one waits for it rather than reading once.
263
+ */
264
+ async function awaitTemplateList(
265
+ ml: MiddleLayer,
266
+ predicate: (list: TemplateListEntry[]) => boolean,
267
+ timeoutMs = 15_000,
268
+ ): Promise<TemplateListEntry[]> {
269
+ const abortSignal = AbortSignal.timeout(timeoutMs);
270
+ while (true) {
271
+ const list = await ml.templateList.getValue();
272
+ if (list !== undefined && predicate(list)) return list;
273
+ await ml.templateList.awaitChange(abortSignal);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * The shareIds currently offered to this user, read after discovery has had a poll to run.
279
+ *
280
+ * Discovery of a just-granted envelope is a poll behind, so reading the view once would say
281
+ * "not offered" about a share that simply had not been seen yet.
282
+ */
283
+ async function settledPendingShareIds(ml: MiddleLayer): Promise<string[]> {
284
+ await tp.setTimeout(2_000);
285
+ return ((await ml.pendingShares.getValue()) ?? []).map((s) => s.shareId);
286
+ }
287
+
288
+ /**
289
+ * A provider that finds nothing, for a document whose entries resolve through their kind.
290
+ *
291
+ * `no-implementation` rather than a thrown error: an entry whose kind exists but which
292
+ * nothing implements is the reachable case, and it is a problem about that entry rather
293
+ * than a failure of the apply.
294
+ */
295
+ function resolvesNothing(): BlockPackProvider {
296
+ return {
297
+ byKind: () => Promise.resolve({ ok: false, reason: "no-implementation" }),
298
+ byExactVersion: () => Promise.resolve({ ok: false, reason: "no-such-block-version" }),
299
+ byLocation: () => Promise.resolve({ ok: false, reason: "not-found" }),
300
+ };
301
+ }
@@ -35,3 +35,17 @@ export {
35
35
  type TemplateApplyProblem,
36
36
  type TemplateApplyReport,
37
37
  } from "./template_apply";
38
+
39
+ // The template export path. A caller renders a stored template back to a file, and reports
40
+ // every block that stood in the way of storing one, so the outcome type and the stringifier
41
+ // are as public as the `MiddleLayer.saveProjectAsTemplate` that produces them.
42
+ export {
43
+ stringifyProjectTemplateV1,
44
+ locationOf,
45
+ type ProjectTemplateExportOutcome,
46
+ } from "./template_serializer";
47
+ export type { TemplateExportProblem } from "./template_export";
48
+
49
+ // The template share path. Whether a template may be shared at all is a question a UI asks about
50
+ // a template it is merely displaying, so the check and its problem type are public.
51
+ export { unshareableTemplateEntries, type TemplateShareProblem } from "./template_share";