@milaboratories/pl-middle-layer 1.71.15 → 1.72.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 -5
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/folders.cjs +631 -0
- package/dist/middle_layer/folders.cjs.map +1 -0
- package/dist/middle_layer/folders.d.ts +39 -0
- package/dist/middle_layer/folders.d.ts.map +1 -0
- package/dist/middle_layer/folders.js +613 -0
- package/dist/middle_layer/folders.js.map +1 -0
- package/dist/middle_layer/index.cjs +3 -0
- package/dist/middle_layer/index.d.ts +4 -3
- package/dist/middle_layer/index.js +2 -1
- package/dist/middle_layer/middle_layer.cjs +585 -370
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +215 -122
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +593 -378
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project_list.cjs +36 -19
- package/dist/middle_layer/project_list.cjs.map +1 -1
- package/dist/middle_layer/project_list.d.ts +1 -1
- package/dist/middle_layer/project_list.d.ts.map +1 -1
- package/dist/middle_layer/project_list.js +37 -21
- package/dist/middle_layer/project_list.js.map +1 -1
- package/dist/middle_layer/sharing_list.cjs +123 -93
- package/dist/middle_layer/sharing_list.cjs.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +43 -19
- package/dist/middle_layer/sharing_list.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.js +123 -93
- package/dist/middle_layer/sharing_list.js.map +1 -1
- package/dist/middle_layer/template_list.cjs +45 -20
- package/dist/middle_layer/template_list.cjs.map +1 -1
- package/dist/middle_layer/template_list.d.ts +7 -15
- package/dist/middle_layer/template_list.d.ts.map +1 -1
- package/dist/middle_layer/template_list.js +44 -21
- package/dist/middle_layer/template_list.js.map +1 -1
- package/dist/model/index.cjs +7 -5
- package/dist/model/index.d.ts +2 -2
- package/dist/model/index.js +2 -2
- package/dist/model/sharing_model.cjs +46 -20
- package/dist/model/sharing_model.cjs.map +1 -1
- package/dist/model/sharing_model.d.ts +125 -55
- package/dist/model/sharing_model.d.ts.map +1 -1
- package/dist/model/sharing_model.js +40 -16
- package/dist/model/sharing_model.js.map +1 -1
- package/dist/mutator/list.cjs +30 -0
- package/dist/mutator/list.cjs.map +1 -0
- package/dist/mutator/list.js +29 -0
- package/dist/mutator/list.js.map +1 -0
- package/dist/mutator/project.cjs +14 -1
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +15 -2
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/sharing.cjs +130 -76
- package/dist/mutator/sharing.cjs.map +1 -1
- package/dist/mutator/sharing.js +130 -76
- package/dist/mutator/sharing.js.map +1 -1
- package/dist/mutator/template.cjs +24 -17
- package/dist/mutator/template.cjs.map +1 -1
- package/dist/mutator/template.js +26 -20
- package/dist/mutator/template.js.map +1 -1
- package/package.json +18 -18
- package/src/middle_layer/folders.test.ts +1068 -0
- package/src/middle_layer/folders.ts +1059 -0
- package/src/middle_layer/folders_read.test.ts +359 -0
- package/src/middle_layer/index.ts +3 -2
- package/src/middle_layer/middle_layer.ts +813 -547
- package/src/middle_layer/project_list.test.ts +183 -0
- package/src/middle_layer/project_list.ts +58 -21
- package/src/middle_layer/sharing.test.ts +183 -0
- package/src/middle_layer/sharing_list.ts +208 -152
- package/src/middle_layer/template_list.test.ts +148 -0
- package/src/middle_layer/template_list.ts +72 -32
- package/src/middle_layer/templates.test.ts +76 -45
- package/src/model/sharing_model.test.ts +69 -2
- package/src/model/sharing_model.ts +161 -73
- package/src/mutator/list.ts +36 -0
- package/src/mutator/project-v3.test.ts +3 -1
- package/src/mutator/project.ts +13 -2
- package/src/mutator/sharing.ts +193 -120
- package/src/mutator/template.ts +37 -29
- package/src/test/with_ml.ts +73 -16
|
@@ -17,10 +17,10 @@ import {
|
|
|
17
17
|
import type { TreeAndComputableU } from "./types";
|
|
18
18
|
import { Computable } from "@milaboratories/computable";
|
|
19
19
|
import type { MiddleLayerEnvironment } from "./middle_layer";
|
|
20
|
-
import {
|
|
21
|
-
import
|
|
20
|
+
import type { ProjectTemplateV1, TemplateId } from "@milaboratories/pl-model-common";
|
|
21
|
+
import { asTemplateId } from "@milaboratories/pl-model-common";
|
|
22
|
+
import { normalizeDescription } from "@milaboratories/pl-model-middle-layer";
|
|
22
23
|
import type { TemplateExportProblem } from "../model/template_export";
|
|
23
|
-
import type { ShareId } from "../model/sharing_model";
|
|
24
24
|
import type { AppliedEntry, TemplateApplyProblem } from "../model/template_apply";
|
|
25
25
|
import type { ProjectId } from "../model/project_model";
|
|
26
26
|
|
|
@@ -30,14 +30,19 @@ export const TemplateResourceType: ResourceType = { name: "UserTemplate", versio
|
|
|
30
30
|
|
|
31
31
|
/** Mutable: the only part of a stored template a rename may touch. */
|
|
32
32
|
export const TemplateLabelKey = "TemplateLabel";
|
|
33
|
+
/** Mutable: free text about the template. Absent on a template nobody described. */
|
|
34
|
+
export const TemplateDescriptionKey = "TemplateDescription";
|
|
33
35
|
export const TemplateCreatedTimestamp = "TemplateCreated";
|
|
34
36
|
|
|
35
37
|
/**
|
|
36
38
|
* Unique template identifier in middle layer, the stringified signed resource id of the
|
|
37
39
|
* `UserTemplate`. Branded so it cannot be confused with a {@link ProjectId} — both are
|
|
38
40
|
* stringified resource ids and every template method takes one of them.
|
|
41
|
+
*
|
|
42
|
+
* Declared in the model package because the folder document places templates as well as
|
|
43
|
+
* projects, and that document is written by code that cannot see this file.
|
|
39
44
|
*/
|
|
40
|
-
export type TemplateId
|
|
45
|
+
export type { TemplateId };
|
|
41
46
|
|
|
42
47
|
/**
|
|
43
48
|
* Immutable `data` on a UserTemplate: the document plus what was true when it was taken.
|
|
@@ -67,6 +72,8 @@ export interface TemplateListEntry {
|
|
|
67
72
|
id: TemplateId;
|
|
68
73
|
/** The mutable label, the only part a rename changes. */
|
|
69
74
|
label: string;
|
|
75
|
+
/** Free text the user wrote about the template. Absent when there is none. */
|
|
76
|
+
description?: string;
|
|
70
77
|
created: Date;
|
|
71
78
|
/** Number of blocks the stored document lists — derived, not stored. */
|
|
72
79
|
blockCount: number;
|
|
@@ -79,9 +86,6 @@ export type SaveProjectAsTemplateOutcome =
|
|
|
79
86
|
| { readonly ok: true; readonly templateId: TemplateId }
|
|
80
87
|
| { readonly ok: false; readonly problems: readonly TemplateExportProblem[] };
|
|
81
88
|
|
|
82
|
-
/** What sharing a stored template yields: the share's logical id. */
|
|
83
|
-
export type ShareTemplateOutcome = { readonly shareId: ShareId };
|
|
84
|
-
|
|
85
89
|
/**
|
|
86
90
|
* What applying a stored template yields.
|
|
87
91
|
*
|
|
@@ -143,32 +147,68 @@ export async function createTemplateList(
|
|
|
143
147
|
const c = Computable.make((ctx) => {
|
|
144
148
|
const node = ctx.accessor(tree.entry()).node();
|
|
145
149
|
if (node === undefined) return undefined;
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// Templates list resource keeps templates assigned to fields. Each field name is a UUID
|
|
149
|
-
for (const field of node.listDynamicFields()) {
|
|
150
|
-
const tpl = node.traverse(field);
|
|
151
|
-
if (tpl === undefined) continue;
|
|
152
|
-
const data = tpl.getDataAsJson<StoredTemplateData>();
|
|
153
|
-
// A template whose data has not synced yet is not an entry with unknown content —
|
|
154
|
-
// it is an entry we cannot describe at all, so it stays out of the list until it has.
|
|
155
|
-
if (data === undefined) continue;
|
|
156
|
-
const label = notEmpty(tpl.getKeyValueAsJson<string>(TemplateLabelKey));
|
|
157
|
-
const created = notEmpty(tpl.getKeyValueAsJson<number>(TemplateCreatedTimestamp));
|
|
158
|
-
result.push({
|
|
159
|
-
id: resourceIdToString(tpl.id) as TemplateId,
|
|
160
|
-
label,
|
|
161
|
-
created: new Date(created),
|
|
162
|
-
blockCount: data.document.blocks.length,
|
|
163
|
-
...(data.sourceProjectLabel !== undefined
|
|
164
|
-
? { sourceProjectLabel: data.sourceProjectLabel }
|
|
165
|
-
: {}),
|
|
166
|
-
...(data.sender !== undefined ? { sender: data.sender } : {}),
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
result.sort((a, b) => b.created.valueOf() - a.created.valueOf());
|
|
170
|
-
return result;
|
|
150
|
+
return templateListEntries(node);
|
|
171
151
|
}).withStableType();
|
|
172
152
|
|
|
173
153
|
return { computable: c, tree };
|
|
174
154
|
}
|
|
155
|
+
|
|
156
|
+
/** The part of a tree node this reader needs from a single entry of the templates list. */
|
|
157
|
+
interface TemplateListEntryNode {
|
|
158
|
+
readonly id: SignedResourceId;
|
|
159
|
+
readonly resourceType: ResourceType;
|
|
160
|
+
getDataAsJson<T>(): T | undefined;
|
|
161
|
+
getKeyValueAsJson<T>(key: string): T | undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The part of a tree node this reader needs from the templates-list resource itself. */
|
|
165
|
+
interface TemplatesListNode {
|
|
166
|
+
listDynamicFields(): string[];
|
|
167
|
+
traverse(fieldName: string): TemplateListEntryNode | undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every stored template of one root, most recently created first.
|
|
172
|
+
*
|
|
173
|
+
* A function rather than a closure inside {@link createTemplateList} because the folder listing
|
|
174
|
+
* reads the same tree in its own computable: templates and projects share one folder tree, and
|
|
175
|
+
* joining it against two independently published lists would let a template show up in a folder
|
|
176
|
+
* the tree has not heard of.
|
|
177
|
+
*
|
|
178
|
+
* Skips, rather than throws on, anything that is not a fully synced template, by the same rule
|
|
179
|
+
* the project list follows: the folder listing is built on this, so one template whose data or
|
|
180
|
+
* label has not arrived must cost that entry and nothing else. The resource type is matched by
|
|
181
|
+
* name only, to exclude foreign resources without hiding a template stored under an earlier
|
|
182
|
+
* resource-type version.
|
|
183
|
+
*/
|
|
184
|
+
export function templateListEntries(node: TemplatesListNode): TemplateListEntry[] {
|
|
185
|
+
const result: TemplateListEntry[] = [];
|
|
186
|
+
|
|
187
|
+
// Templates list resource keeps templates assigned to fields. Each field name is a UUID
|
|
188
|
+
for (const field of node.listDynamicFields()) {
|
|
189
|
+
const tpl = node.traverse(field);
|
|
190
|
+
if (tpl === undefined) continue;
|
|
191
|
+
if (tpl.resourceType.name !== TemplateResourceType.name) continue;
|
|
192
|
+
|
|
193
|
+
const data = tpl.getDataAsJson<StoredTemplateData>();
|
|
194
|
+
const label = tpl.getKeyValueAsJson<string>(TemplateLabelKey);
|
|
195
|
+
const created = tpl.getKeyValueAsJson<number>(TemplateCreatedTimestamp);
|
|
196
|
+
if (data === undefined || label === undefined || created === undefined) continue;
|
|
197
|
+
|
|
198
|
+
// Written only once something is said about the template, so its absence is the ordinary case.
|
|
199
|
+
const description = normalizeDescription(tpl.getKeyValueAsJson<string>(TemplateDescriptionKey));
|
|
200
|
+
result.push({
|
|
201
|
+
id: asTemplateId(resourceIdToString(tpl.id)),
|
|
202
|
+
label,
|
|
203
|
+
...(description === undefined ? {} : { description }),
|
|
204
|
+
created: new Date(created),
|
|
205
|
+
blockCount: data.document.blocks.length,
|
|
206
|
+
...(data.sourceProjectLabel !== undefined
|
|
207
|
+
? { sourceProjectLabel: data.sourceProjectLabel }
|
|
208
|
+
: {}),
|
|
209
|
+
...(data.sender !== undefined ? { sender: data.sender } : {}),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
result.sort((a, b) => b.created.valueOf() - a.created.valueOf());
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import { resourceIdToString } from "@milaboratories/pl-client";
|
|
2
|
+
import type { SignedResourceId } from "@milaboratories/pl-client";
|
|
3
|
+
import { field, isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
|
|
5
4
|
import type {
|
|
6
5
|
BlockKindSelectorReference,
|
|
7
6
|
BlockPackLocationReference,
|
|
@@ -11,6 +10,8 @@ import type {
|
|
|
11
10
|
import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
|
|
12
11
|
import type { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
|
|
13
12
|
import type { BlockPackProvider } from "../model/template_resolve";
|
|
13
|
+
import type { ShareId } from "../model/sharing_model";
|
|
14
|
+
import { SharingStateField, hiddenField } from "../model/sharing_model";
|
|
14
15
|
import { withMl } from "../test/with_ml";
|
|
15
16
|
import { createTemplate } from "../mutator/template";
|
|
16
17
|
import type { MiddleLayer } from "./middle_layer";
|
|
@@ -18,7 +19,7 @@ import type { StoredTemplateData, TemplateId, TemplateListEntry } from "./templa
|
|
|
18
19
|
import { ensureTemplateListRid } from "./template_list";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* The stored-template entity against a live backend: rename, apply, share,
|
|
22
|
+
* The stored-template entity against a live backend: rename, apply, share, copy.
|
|
22
23
|
*
|
|
23
24
|
* Every test here stores its template directly through the mutator rather than by saving a
|
|
24
25
|
* project, so the document under test is the one the test wrote — a `file:` entry, an entry
|
|
@@ -26,6 +27,10 @@ import { ensureTemplateListRid } from "./template_list";
|
|
|
26
27
|
* produces is covered by the round trip in `drivers-ml-blocks-integration`, which has block
|
|
27
28
|
* packs on disk to build one from.
|
|
28
29
|
*
|
|
30
|
+
* The test server has one user, so every share here goes to everyone and is checked through
|
|
31
|
+
* what its donor can see: the outbox, the copies a share produces, and the donor's own
|
|
32
|
+
* SharingState. The recipient-side list leaves a user's own shares out.
|
|
33
|
+
*
|
|
29
34
|
* Needs a backend, like every `withMl` test in this package, and no gate: `PL_ADDRESS` is
|
|
30
35
|
* either configured or the client fails to connect.
|
|
31
36
|
*/
|
|
@@ -126,7 +131,7 @@ test("a project holding a block that cannot be written out produces no template"
|
|
|
126
131
|
});
|
|
127
132
|
});
|
|
128
133
|
|
|
129
|
-
test("
|
|
134
|
+
test("copying a template share lands it among the recipient's templates and builds nothing", async () => {
|
|
130
135
|
await withMl(async (ml) => {
|
|
131
136
|
const stored = await storeTemplate(ml, "A pipeline", {
|
|
132
137
|
schemaVersion: 1,
|
|
@@ -136,26 +141,58 @@ test("an accepted template share lands on the acceptor's shelf and builds nothin
|
|
|
136
141
|
|
|
137
142
|
const shared = await ml.shareTemplate(stored.id, { everyone: true, title: "A pipeline" });
|
|
138
143
|
|
|
139
|
-
const outcome = await ml.
|
|
144
|
+
const outcome = await ml.copyShare([shared.shareId]);
|
|
140
145
|
|
|
141
146
|
expect(outcome.failed).toStrictEqual([]);
|
|
142
|
-
expect(outcome.
|
|
147
|
+
expect(outcome.templates).toHaveLength(1);
|
|
143
148
|
// Nothing is built until the recipient applies it — which is what makes an all-or-nothing
|
|
144
149
|
// apply survivable for them: there is always something left to retry from.
|
|
145
|
-
expect(outcome.
|
|
150
|
+
expect(outcome.projects).toStrictEqual([]);
|
|
146
151
|
expect(await ml.projectList.awaitStableValue()).toStrictEqual([]);
|
|
147
152
|
|
|
148
153
|
const list = await awaitTemplateList(ml, (l) => l.length === 2);
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
const copied = list.find((t) => t.id === outcome.templates[0]);
|
|
155
|
+
if (copied === undefined) throw new Error("the copied template is not in the list");
|
|
156
|
+
// The original is at the top level too, and one folder never holds two things of one name.
|
|
157
|
+
expect(copied.label).toBe("A pipeline (Copy)");
|
|
158
|
+
// Who sent it, kept as the copy's provenance; the donor's own source project is not part of
|
|
159
|
+
// the payload and does not travel.
|
|
160
|
+
expect(copied.sender).toBe(ml.currentUserLogin ?? "");
|
|
161
|
+
expect(copied.sourceProjectLabel).toBeUndefined();
|
|
162
|
+
|
|
163
|
+
// A share is a shelf, not an invitation: copying takes nothing off it, so it can be copied
|
|
164
|
+
// from again, and the second copy is named beside the first.
|
|
165
|
+
const again = await ml.copyShare([shared.shareId]);
|
|
166
|
+
expect(again.failed).toStrictEqual([]);
|
|
167
|
+
expect(again.templates).toHaveLength(1);
|
|
168
|
+
const relisted = await awaitTemplateList(ml, (l) => l.length === 3);
|
|
169
|
+
expect(relisted.find((t) => t.id === again.templates[0])?.label).toBe("A pipeline (Copy 2)");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a hidden share stays listed, flagged, until it is unhidden", async () => {
|
|
174
|
+
await withMl(async (ml) => {
|
|
175
|
+
const stored = await storeTemplate(ml, "A pipeline", {
|
|
176
|
+
schemaVersion: 1,
|
|
177
|
+
document: documentOf(entry("a")),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const shared = await ml.shareTemplate(stored.id, { everyone: true, title: "A pipeline" });
|
|
181
|
+
|
|
182
|
+
await ml.hideShare(shared.shareId);
|
|
183
|
+
// Hidden, not gone: the flag the "show hidden" view reads is written on this user's own
|
|
184
|
+
// SharingState, and nothing about the envelope changed, so the donor is none the wiser.
|
|
185
|
+
expect(await hiddenFlagWritten(ml, shared.shareId)).toBe(true);
|
|
186
|
+
const outgoing = (await ml.outgoingShares.getValue()) ?? [];
|
|
187
|
+
expect(outgoing.map((s) => s.shareId)).toStrictEqual([shared.shareId]);
|
|
188
|
+
|
|
189
|
+
// Hiding is the recipient's own choice and theirs to undo.
|
|
190
|
+
await ml.unhideShare(shared.shareId);
|
|
191
|
+
expect(await hiddenFlagWritten(ml, shared.shareId)).toBe(false);
|
|
155
192
|
});
|
|
156
193
|
});
|
|
157
194
|
|
|
158
|
-
test("a
|
|
195
|
+
test("replacing a template share leaves one share, under a new id", async () => {
|
|
159
196
|
await withMl(async (ml) => {
|
|
160
197
|
const first = await storeTemplate(ml, "First", {
|
|
161
198
|
schemaVersion: 1,
|
|
@@ -168,31 +205,28 @@ test("a changed template share keeps its id, and whoever already responded is no
|
|
|
168
205
|
|
|
169
206
|
const shared = await ml.shareTemplate(first.id, { everyone: true, title: "First" });
|
|
170
207
|
|
|
171
|
-
// Someone
|
|
172
|
-
const
|
|
173
|
-
expect(
|
|
208
|
+
// Someone takes a copy, which the replace below must neither undo nor hide.
|
|
209
|
+
const copied = await ml.copyShare([shared.shareId]);
|
|
210
|
+
expect(copied.templates).toHaveLength(1);
|
|
174
211
|
|
|
175
|
-
// A stored template never changes, so an improved one is a different template
|
|
176
|
-
//
|
|
177
|
-
|
|
212
|
+
// A stored template never changes, so an improved one is a different template and a different
|
|
213
|
+
// share. Naming the old one as replaced is what keeps the two from piling up: recipients see
|
|
214
|
+
// the improved template in place of the old one rather than beside it.
|
|
215
|
+
const replacement = await ml.shareTemplate(second.id, {
|
|
216
|
+
everyone: true,
|
|
217
|
+
title: "Second",
|
|
218
|
+
replace: [shared.shareId],
|
|
219
|
+
});
|
|
220
|
+
expect(replacement.shareId).not.toBe(shared.shareId);
|
|
178
221
|
|
|
179
222
|
const outgoing = (await ml.outgoingShares.getValue()) ?? [];
|
|
180
|
-
expect(
|
|
223
|
+
expect(outgoing.map((s) => s.shareId)).toStrictEqual([replacement.shareId]);
|
|
181
224
|
expect(outgoing[0]).toMatchObject({
|
|
182
225
|
payloadKind: "template",
|
|
183
226
|
title: "Second",
|
|
184
227
|
template: { label: "Second", blockCount: 2 },
|
|
185
|
-
// A template share is granted read-only, so no recipient can ever write a reply on the
|
|
186
|
-
// envelope — a view has to say that rather than render an empty list as "nobody yet".
|
|
187
|
-
responsesAvailable: false,
|
|
188
228
|
});
|
|
189
229
|
expect(outgoing[0].projects).toStrictEqual([]);
|
|
190
|
-
|
|
191
|
-
// The decision the accept recorded is keyed on the shareId, which the change preserved, so
|
|
192
|
-
// the replaced share is not offered again. A replace that minted a new id would show up
|
|
193
|
-
// here as a fresh offer.
|
|
194
|
-
const pending = await settledPendingShareIds(ml);
|
|
195
|
-
expect(pending).not.toContain(shared.shareId);
|
|
196
230
|
});
|
|
197
231
|
});
|
|
198
232
|
|
|
@@ -220,20 +254,19 @@ type StoredTemplate = { id: TemplateId; rid: SignedResourceId };
|
|
|
220
254
|
*
|
|
221
255
|
* The middle layer has no way to store an arbitrary document — it only saves a project — so a
|
|
222
256
|
* test that needs a specific document writes it here, exactly as `saveProjectAsTemplate` and
|
|
223
|
-
*
|
|
257
|
+
* `copyShare` do.
|
|
224
258
|
*/
|
|
225
259
|
async function storeTemplate(
|
|
226
260
|
ml: MiddleLayer,
|
|
227
261
|
label: string,
|
|
228
262
|
data: StoredTemplateData,
|
|
229
263
|
): Promise<StoredTemplate> {
|
|
230
|
-
|
|
231
|
-
await ml.pl.withWriteTx("TestStoreTemplate", async (tx) => {
|
|
264
|
+
const rid = await ml.pl.withWriteTx("TestStoreTemplate", async (tx) => {
|
|
232
265
|
const listRid = await ensureTemplateListRid(tx);
|
|
233
|
-
tpl = createTemplate(tx, listRid, label, data);
|
|
266
|
+
const tpl = createTemplate(tx, listRid, { label }, data);
|
|
234
267
|
await tx.commit();
|
|
268
|
+
return await tpl.globalId;
|
|
235
269
|
});
|
|
236
|
-
const rid = await tpl!.globalId;
|
|
237
270
|
return { id: resourceIdToString(rid) as TemplateId, rid };
|
|
238
271
|
}
|
|
239
272
|
|
|
@@ -265,15 +298,13 @@ async function awaitTemplateList(
|
|
|
265
298
|
}
|
|
266
299
|
}
|
|
267
300
|
|
|
268
|
-
/**
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
await tp.setTimeout(2_000);
|
|
276
|
-
return ((await ml.pendingShares.getValue()) ?? []).map((s) => s.shareId);
|
|
301
|
+
/** Whether this user's own SharingState holds the hidden flag for a share. */
|
|
302
|
+
async function hiddenFlagWritten(ml: MiddleLayer, shareId: ShareId): Promise<boolean> {
|
|
303
|
+
return await ml.pl.withReadTx("TestReadShareHidden", async (tx) => {
|
|
304
|
+
const state = await tx.getField(field(tx.clientRoot, SharingStateField));
|
|
305
|
+
if (isNullSignedResourceId(state.value)) throw new Error("the SharingState was never created");
|
|
306
|
+
return (await tx.getFieldIfExists(field(state.value, hiddenField(shareId)))) !== undefined;
|
|
307
|
+
});
|
|
277
308
|
}
|
|
278
309
|
|
|
279
310
|
/**
|
|
@@ -5,9 +5,11 @@ import {
|
|
|
5
5
|
canImpersonate,
|
|
6
6
|
decodeEnvelopeData,
|
|
7
7
|
EnvelopeSchemaVersionCurrent,
|
|
8
|
+
envelopeFolderRoot,
|
|
8
9
|
envelopeProjectMap,
|
|
9
10
|
normalizeEnvelopeData,
|
|
10
11
|
} from "./sharing_model";
|
|
12
|
+
import type { EnvelopeFolder, EnvelopeFolderId } from "./sharing_model";
|
|
11
13
|
|
|
12
14
|
// canImpersonate is the admin gate for "open another user's root". It must be strictly
|
|
13
15
|
// stricter than canGrantToEveryone: a regular USER may share their own projects but must
|
|
@@ -32,9 +34,9 @@ test("canGrantToEveryone and canImpersonate does not include USER", () => {
|
|
|
32
34
|
// Envelope decode — the recognise-or-hide gate every reader of a share passes through.
|
|
33
35
|
//
|
|
34
36
|
// Pure by construction: the gate is a function of the blob, and the three sites that
|
|
35
|
-
// discover envelopes (the
|
|
37
|
+
// discover envelopes (the available-shares view, the live-envelope view a copy reads,
|
|
36
38
|
// and the donor's own outbox) all skip an envelope this returns `undefined` for. So an
|
|
37
|
-
// envelope that does not decode cannot be offered,
|
|
39
|
+
// envelope that does not decode cannot be offered, copied from, or listed.
|
|
38
40
|
|
|
39
41
|
/** A v1 envelope, exactly as one written before the payload discriminant existed: the
|
|
40
42
|
* project map sits at the top level and there is no `payload` field. */
|
|
@@ -102,6 +104,28 @@ test("a current envelope carrying a template decodes as one", () => {
|
|
|
102
104
|
expect(envelopeProjectMap(data!)).toStrictEqual({});
|
|
103
105
|
});
|
|
104
106
|
|
|
107
|
+
test("a project carries what it said about itself through the decode", () => {
|
|
108
|
+
const projects = {
|
|
109
|
+
"9c7e4d10-2b83-4f6a-91d5-7e0c3a8b5f42": {
|
|
110
|
+
label: "Project 1",
|
|
111
|
+
source: "42",
|
|
112
|
+
updatedAt: 1_700_000_000_000,
|
|
113
|
+
description: "Donor 14, both replicates",
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
const data = decodeEnvelopeData(
|
|
117
|
+
blob({
|
|
118
|
+
...v1Envelope,
|
|
119
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
120
|
+
projects: undefined,
|
|
121
|
+
payload: { kind: "projects", projects },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
if (data === undefined) throw new Error("a current envelope of projects must decode");
|
|
125
|
+
|
|
126
|
+
expect(envelopeProjectMap(data)).toStrictEqual(projects);
|
|
127
|
+
});
|
|
128
|
+
|
|
105
129
|
test("an envelope whose payload kind this build does not know does not decode at all", () => {
|
|
106
130
|
// The whole point of the discriminant: a share this build cannot act on is hidden rather
|
|
107
131
|
// than offered, and the decode is where that is decided — once, for every reader.
|
|
@@ -134,3 +158,46 @@ test("anything that is not an envelope object does not decode", () => {
|
|
|
134
158
|
expect(normalizeEnvelopeData("an envelope")).toBeUndefined();
|
|
135
159
|
expect(normalizeEnvelopeData(42)).toBeUndefined();
|
|
136
160
|
});
|
|
161
|
+
|
|
162
|
+
test("a folder envelope decodes, and its root is the folder with no parent", () => {
|
|
163
|
+
const folders: Record<string, EnvelopeFolder> = {
|
|
164
|
+
a: { name: "Repertoires" },
|
|
165
|
+
b: { name: "2024", parent: "a" as EnvelopeFolderId },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const decoded = decodeEnvelopeData(
|
|
169
|
+
blob({
|
|
170
|
+
...v1Envelope,
|
|
171
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
172
|
+
projects: undefined,
|
|
173
|
+
payload: {
|
|
174
|
+
kind: "folder",
|
|
175
|
+
source: "f1",
|
|
176
|
+
folders,
|
|
177
|
+
projects: {},
|
|
178
|
+
templates: [],
|
|
179
|
+
from: "someone",
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
if (decoded?.payload.kind !== "folder") throw new Error("a folder envelope must decode as one");
|
|
184
|
+
|
|
185
|
+
// The root is found on what came out of the decode, so the subtree survived it intact.
|
|
186
|
+
expect(decoded.payload.folders).toStrictEqual(folders);
|
|
187
|
+
expect(envelopeFolderRoot(decoded.payload.folders)).toBe("a");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a folder envelope with no single root describes no subtree", () => {
|
|
191
|
+
const twoRoots: Record<string, EnvelopeFolder> = {
|
|
192
|
+
a: { name: "One" },
|
|
193
|
+
b: { name: "Two" },
|
|
194
|
+
};
|
|
195
|
+
const noRoot: Record<string, EnvelopeFolder> = {
|
|
196
|
+
a: { name: "One", parent: "b" as EnvelopeFolderId },
|
|
197
|
+
b: { name: "Two", parent: "a" as EnvelopeFolderId },
|
|
198
|
+
};
|
|
199
|
+
// Rebuilding would have to guess which folder was shared; the copy refuses instead.
|
|
200
|
+
expect(envelopeFolderRoot(twoRoots)).toBeUndefined();
|
|
201
|
+
expect(envelopeFolderRoot(noRoot)).toBeUndefined();
|
|
202
|
+
expect(envelopeFolderRoot({})).toBeUndefined();
|
|
203
|
+
});
|