@milaboratories/pl-middle-layer 1.71.16 → 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.
Files changed (85) hide show
  1. package/dist/index.cjs +10 -5
  2. package/dist/index.d.ts +5 -4
  3. package/dist/index.js +3 -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/folders.cjs +631 -0
  7. package/dist/middle_layer/folders.cjs.map +1 -0
  8. package/dist/middle_layer/folders.d.ts +39 -0
  9. package/dist/middle_layer/folders.d.ts.map +1 -0
  10. package/dist/middle_layer/folders.js +613 -0
  11. package/dist/middle_layer/folders.js.map +1 -0
  12. package/dist/middle_layer/index.cjs +3 -0
  13. package/dist/middle_layer/index.d.ts +4 -3
  14. package/dist/middle_layer/index.js +2 -1
  15. package/dist/middle_layer/middle_layer.cjs +585 -370
  16. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  17. package/dist/middle_layer/middle_layer.d.ts +215 -122
  18. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  19. package/dist/middle_layer/middle_layer.js +593 -378
  20. package/dist/middle_layer/middle_layer.js.map +1 -1
  21. package/dist/middle_layer/project_list.cjs +36 -19
  22. package/dist/middle_layer/project_list.cjs.map +1 -1
  23. package/dist/middle_layer/project_list.d.ts +1 -1
  24. package/dist/middle_layer/project_list.d.ts.map +1 -1
  25. package/dist/middle_layer/project_list.js +37 -21
  26. package/dist/middle_layer/project_list.js.map +1 -1
  27. package/dist/middle_layer/sharing_list.cjs +123 -93
  28. package/dist/middle_layer/sharing_list.cjs.map +1 -1
  29. package/dist/middle_layer/sharing_list.d.ts +43 -19
  30. package/dist/middle_layer/sharing_list.d.ts.map +1 -1
  31. package/dist/middle_layer/sharing_list.js +123 -93
  32. package/dist/middle_layer/sharing_list.js.map +1 -1
  33. package/dist/middle_layer/template_list.cjs +45 -20
  34. package/dist/middle_layer/template_list.cjs.map +1 -1
  35. package/dist/middle_layer/template_list.d.ts +7 -15
  36. package/dist/middle_layer/template_list.d.ts.map +1 -1
  37. package/dist/middle_layer/template_list.js +44 -21
  38. package/dist/middle_layer/template_list.js.map +1 -1
  39. package/dist/model/index.cjs +7 -5
  40. package/dist/model/index.d.ts +2 -2
  41. package/dist/model/index.js +2 -2
  42. package/dist/model/sharing_model.cjs +46 -20
  43. package/dist/model/sharing_model.cjs.map +1 -1
  44. package/dist/model/sharing_model.d.ts +125 -55
  45. package/dist/model/sharing_model.d.ts.map +1 -1
  46. package/dist/model/sharing_model.js +40 -16
  47. package/dist/model/sharing_model.js.map +1 -1
  48. package/dist/mutator/list.cjs +30 -0
  49. package/dist/mutator/list.cjs.map +1 -0
  50. package/dist/mutator/list.js +29 -0
  51. package/dist/mutator/list.js.map +1 -0
  52. package/dist/mutator/project.cjs +14 -1
  53. package/dist/mutator/project.cjs.map +1 -1
  54. package/dist/mutator/project.d.ts.map +1 -1
  55. package/dist/mutator/project.js +15 -2
  56. package/dist/mutator/project.js.map +1 -1
  57. package/dist/mutator/sharing.cjs +130 -76
  58. package/dist/mutator/sharing.cjs.map +1 -1
  59. package/dist/mutator/sharing.js +130 -76
  60. package/dist/mutator/sharing.js.map +1 -1
  61. package/dist/mutator/template.cjs +24 -17
  62. package/dist/mutator/template.cjs.map +1 -1
  63. package/dist/mutator/template.js +26 -20
  64. package/dist/mutator/template.js.map +1 -1
  65. package/package.json +19 -19
  66. package/src/middle_layer/folders.test.ts +1068 -0
  67. package/src/middle_layer/folders.ts +1059 -0
  68. package/src/middle_layer/folders_read.test.ts +359 -0
  69. package/src/middle_layer/index.ts +3 -2
  70. package/src/middle_layer/middle_layer.ts +813 -547
  71. package/src/middle_layer/project_list.test.ts +183 -0
  72. package/src/middle_layer/project_list.ts +58 -21
  73. package/src/middle_layer/sharing.test.ts +183 -0
  74. package/src/middle_layer/sharing_list.ts +208 -152
  75. package/src/middle_layer/template_list.test.ts +148 -0
  76. package/src/middle_layer/template_list.ts +72 -32
  77. package/src/middle_layer/templates.test.ts +76 -45
  78. package/src/model/sharing_model.test.ts +69 -2
  79. package/src/model/sharing_model.ts +161 -73
  80. package/src/mutator/list.ts +36 -0
  81. package/src/mutator/project-v3.test.ts +3 -1
  82. package/src/mutator/project.ts +13 -2
  83. package/src/mutator/sharing.ts +193 -120
  84. package/src/mutator/template.ts +37 -29
  85. 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 { notEmpty } from "@milaboratories/ts-helpers";
21
- import type { Branded, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
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 = Branded<string, "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
- const result: TemplateListEntry[] = [];
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 * as tp from "node:timers/promises";
3
- import type { ResourceRef, SignedResourceId } from "@milaboratories/pl-client";
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, accept.
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("an accepted template share lands on the acceptor's shelf and builds nothing", async () => {
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.acceptShare([shared.shareId]);
144
+ const outcome = await ml.copyShare([shared.shareId]);
140
145
 
141
146
  expect(outcome.failed).toStrictEqual([]);
142
- expect(outcome.acceptedTemplates).toHaveLength(1);
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.accepted).toStrictEqual([]);
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 accepted = list.find((t) => t.id === outcome.acceptedTemplates[0])!;
150
- expect(accepted.label).toBe("A pipeline");
151
- // Who sent it, kept as the accepted template's provenance; the donor's own source project
152
- // is not part of the payload and does not travel.
153
- expect(accepted.sender).toBe(ml.currentUserLogin ?? "");
154
- expect(accepted.sourceProjectLabel).toBeUndefined();
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 changed template share keeps its id, and whoever already responded is not re-prompted", async () => {
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 responds to the share, which is what the replace below must not undo.
172
- const accept = await ml.acceptShare([shared.shareId]);
173
- expect(accept.acceptedTemplates).toHaveLength(1);
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 — the
176
- // replace names it rather than re-reading the one the share started from.
177
- await ml.changeShare(shared.shareId, { templateId: second.id, title: "Second" });
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((outgoing ?? []).map((s) => s.shareId)).toStrictEqual([shared.shareId]);
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
- * the accept path do.
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
- let tpl: ResourceRef;
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
- * The shareIds currently offered to this user, read after discovery has had a poll to run.
270
- *
271
- * Discovery of a just-granted envelope is a poll behind, so reading the view once would say
272
- * "not offered" about a share that simply had not been seen yet.
273
- */
274
- async function settledPendingShareIds(ml: MiddleLayer): Promise<string[]> {
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 pending view, the live-envelope view the accept flow reads,
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, accepted, or listed.
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
+ });