@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.
- 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 +10 -10
- 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
|
@@ -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
|
|
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
|
|
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<
|
|
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<
|
|
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<
|
|
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
|
+
}
|
package/src/model/index.ts
CHANGED
|
@@ -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";
|