@milaboratories/pl-middle-layer 1.71.16 → 1.73.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 +636 -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 +617 -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 +17 -17
  66. package/src/middle_layer/folders.test.ts +1076 -0
  67. package/src/middle_layer/folders.ts +1069 -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
@@ -1,26 +1,26 @@
1
- import type { PruningFunction } from "@milaboratories/pl-tree";
1
+ import type { PlTreeNodeAccessor, PruningFunction } from "@milaboratories/pl-tree";
2
2
  import { SynchronizedTreeState } from "@milaboratories/pl-tree";
3
3
  import type { Filter, PlClient, SignedResourceId } from "@milaboratories/pl-client";
4
4
  import {
5
5
  isEveryoneUserLogin,
6
- resourceIdToString,
7
6
  resourceType,
8
7
  resourceTypesEqual,
9
8
  treeFilter,
10
9
  } from "@milaboratories/pl-client";
11
10
  import { Computable } from "@milaboratories/computable";
12
11
  import type { MiddleLayerEnvironment } from "./middle_layer";
13
- import type { ProjectId } from "@milaboratories/pl-model-common";
12
+ import type { ProjectId, TemplateId } from "@milaboratories/pl-model-common";
13
+ import type { FolderId } from "@milaboratories/pl-model-middle-layer";
14
14
  import type {
15
- EnvelopeAcceptance,
16
15
  EnvelopeData,
17
16
  EnvelopeMode,
18
17
  EnvelopePayloadKind,
19
18
  ShareId,
20
19
  } from "../model/sharing_model";
21
20
  import {
22
- AcceptanceFieldPrefix,
23
- asShareId,
21
+ hiddenFieldShareId,
22
+ isHiddenField,
23
+ envelopeFolderRoot,
24
24
  envelopeProjectMap,
25
25
  normalizeEnvelopeData,
26
26
  SharedEnvelopeResourceType,
@@ -28,84 +28,78 @@ import {
28
28
  SharingStateResourceType,
29
29
  } from "../model/sharing_model";
30
30
 
31
+ /** What a folder share carries, as both share lists show it. */
32
+ export interface EnvelopeFolderSummary {
33
+ label: string;
34
+ /** Donor's own id of the shared folder. */
35
+ source: FolderId;
36
+ folderCount: number;
37
+ projectCount: number;
38
+ templateCount: number;
39
+ }
40
+
31
41
  /** Donor-facing view of one outgoing share. */
32
42
  export interface OutgoingShare {
33
- shareId: ShareId; // stable logical identity of the share, preserved across changes
43
+ shareId: ShareId; // identity of the share; a share that replaces it gets its own
34
44
  sharedAt: number; // this instance's creation time (ms epoch)
35
45
  expiresAt?: number; // EnvelopeData.expiresAt; null maps to undefined = never expires
36
46
  mode: EnvelopeMode;
37
47
  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. */
48
+ /** What the share carries — a pack of projects, one template document, or a folder subtree. */
39
49
  payloadKind: EnvelopePayloadKind;
40
- /** One entry per project in the pack, so the change UI can offer a per-project decision.
41
- * `projectId` is the donor's source project id; `updatedAt` is when this project's
42
- * snapshot was last (re)taken. Empty for a template share. */
50
+ /** One entry per project in the pack. `projectId` is the donor's source project id, which is
51
+ * what the share dialog matches a project's prior shares on; `updatedAt` is when this
52
+ * project's snapshot was taken. Empty for any share that is not a pack of projects. */
43
53
  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 };
54
+ /** The shared template, for a template share; absent otherwise. `source` is the donor's own
55
+ * template id, which is what the share dialog matches a template's prior shares on; a share
56
+ * made before it was carried has none and matches nothing. */
57
+ template?: { label: string; blockCount: number; source?: TemplateId };
58
+ /** The shared folder, for a folder share; absent otherwise. `label` is the root folder's own
59
+ * name, `source` the donor's own id for it — what a prior share of the same folder is matched
60
+ * on — and the counts are of the whole subtree, the root folder itself excluded. */
61
+ folder?: EnvelopeFolderSummary;
62
+ /** What the shared thing says about itself: a template's, a folder's, or the one project's of
63
+ * a single-project share. Absent for a pack of several, which has no one thing to describe. */
64
+ description?: string;
46
65
  /** Full recipient logins, from `ListGrants` on the envelope; `["*"]` for everyone-shares. */
47
66
  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;
52
- /** Per recipient who has responded: their decision and when, from acceptance/{login}. */
53
- responses: Record<string, { action: "accepted" | "rejected"; timestamp: number }>;
54
- }
55
-
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. */
58
- function envelopeProjects(data: EnvelopeData): OutgoingShare["projects"] {
59
- return Object.values(envelopeProjectMap(data)).map((p) => ({
60
- projectId: p.source,
61
- label: p.label,
62
- updatedAt: p.updatedAt,
63
- }));
64
67
  }
65
68
 
66
- /** Acceptor-facing view of one pending share. */
67
- export interface PendingShare {
69
+ /**
70
+ * Recipient-facing view of one share that is open to this user.
71
+ *
72
+ * A share is a shelf, not an invitation: it stays listed for as long as the envelope lives, and
73
+ * the recipient may copy from it any number of times. {@link hidden} is that recipient's own
74
+ * private choice to stop seeing it, and it can be undone.
75
+ */
76
+ export interface AvailableShare {
68
77
  shareId: ShareId;
69
78
  sender: string; // EnvelopeData.sender, display only
70
79
  title: string; // display name shown to recipients; defaults to the first project's name
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. */
80
+ mode: EnvelopeMode;
81
+ /** What the share carries — projects to copy, a template to add to the recipient's templates,
82
+ * or a folder subtree to rebuild in the recipient's tree. */
74
83
  payloadKind: EnvelopePayloadKind;
75
84
  grantedAt: number;
85
+ /** What the shared thing says about itself. See {@link OutgoingShare.description}. */
86
+ description?: string;
87
+ /** The shared folder, for a folder share; absent otherwise. See {@link OutgoingShare.folder}. */
88
+ folder?: EnvelopeFolderSummary;
89
+ /** Whether this recipient has put it out of sight. Hidden shares are listed, not dropped: the
90
+ * view that shows them is what "show hidden" turns on. */
91
+ hidden: boolean;
76
92
  }
77
93
 
78
- const SharingOutboxPruningFunction: PruningFunction = (resource) => {
79
- if (
80
- !resourceTypesEqual(resource.type, SharingOutboxResourceType) &&
81
- !resourceTypesEqual(resource.type, SharedEnvelopeResourceType)
82
- )
83
- return [];
84
- return resource.fields;
85
- };
86
-
87
- // Server-side traversal scope (modern resourceTree path). Pruning is client-side ONLY and does
88
- // NOT stop the backend walk — without a fieldFilter the backend descends through the envelope's
89
- // project/{uuid} snapshots into the whole project graph (StreamManager etc.), whose field-driven
90
- // finality predicate then throws on the pruned-to-[] fields. Following fields only FROM the outbox
91
- // and the envelope stops the walk at the project snapshots (UserProject), which we never traverse.
92
- const SharingOutboxFieldFilter: Filter = treeFilter.or(
93
- treeFilter.resourceTypeEq(SharingOutboxResourceType.name),
94
- treeFilter.resourceTypeEq(SharedEnvelopeResourceType.name),
95
- );
96
-
97
- /** Intermediate of an outgoing share before its full recipient list is fetched: everything the
98
- * tree carries synchronously, plus the envelope's signed id for the async `ListGrants` enrich. */
99
- type OutgoingShareDraft = Omit<OutgoingShare, "recipients"> & { envelopeRid: SignedResourceId };
94
+ /** A live envelope discovered in the recipient's shared-resource tree: its signed resource id and
95
+ * decoded {@link EnvelopeData}. A copy out of a share finds it by `data.shareId`. */
96
+ export type LiveEnvelope = { rid: SignedResourceId; data: EnvelopeData };
100
97
 
101
98
  /**
102
99
  * Reactive view of the donor's own outbox. Reads each live envelope's immutable
103
- * {@link EnvelopeData} plus the per-recipient `acceptance/{login}` records from the tree, then
104
- * enriches each share's full recipient list via `ListGrants` on the envelope (`["*"]` for an
105
- * everyone-share). `ListGrants` is gated backend-side to the envelope owner (the donor), which is
106
- * exactly who reads this view.
107
- *
108
- * API-level only in M1 (no UI).
100
+ * {@link EnvelopeData} from the tree, then enriches each share's full recipient list via
101
+ * `ListGrants` on the envelope (`["*"]` for an everyone-share). `ListGrants` is gated backend-side
102
+ * to the envelope owner (the donor), which is exactly who reads this view.
109
103
  */
110
104
  export function createOutgoingSharesComputable(
111
105
  pl: PlClient,
@@ -126,16 +120,8 @@ export function createOutgoingSharesComputable(
126
120
  const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
127
121
  if (data === undefined) continue; // unknown version or payload kind — not ours to show
128
122
 
129
- const responses: OutgoingShare["responses"] = {};
130
- for (const f of envelope.listDynamicFields()) {
131
- if (!f.startsWith(AcceptanceFieldPrefix)) continue;
132
- const login = f.slice(AcceptanceFieldPrefix.length);
133
- const acc = envelope.traverse(f);
134
- const accData = acc?.getDataAsJson<EnvelopeAcceptance>();
135
- if (accData === undefined) continue;
136
- responses[login] = { action: accData.action, timestamp: accData.timestamp };
137
- }
138
-
123
+ const folder = envelopeFolderSummary(data);
124
+ const description = envelopeDescription(data);
139
125
  drafts.push({
140
126
  shareId: data.shareId,
141
127
  sharedAt: data.sharedAt,
@@ -149,11 +135,12 @@ export function createOutgoingSharesComputable(
149
135
  template: {
150
136
  label: data.payload.label,
151
137
  blockCount: data.payload.document.blocks.length,
138
+ ...(data.payload.source === undefined ? {} : { source: data.payload.source }),
152
139
  },
153
140
  }
154
141
  : {}),
155
- responsesAvailable: data.payload.kind !== "template",
156
- responses,
142
+ ...(folder === undefined ? {} : { folder }),
143
+ ...(description === undefined ? {} : { description }),
157
144
  envelopeRid: envelope.id,
158
145
  });
159
146
  }
@@ -215,26 +202,12 @@ export async function createOutgoingShares(
215
202
  return { computable: createOutgoingSharesComputable(pl, tree), tree };
216
203
  }
217
204
 
218
- const DecisionFieldPrefix = "decision/";
219
-
220
- const SharedEnvelopePruningFunction: PruningFunction = (resource) => {
221
- if (!resourceTypesEqual(resource.type, SharedEnvelopeResourceType)) return [];
222
- return resource.fields;
223
- };
224
-
225
- // Discovery only needs each envelope's immutable EnvelopeData (basic resource data); it must NOT
226
- // descend into the project snapshots. Following fields only FROM the envelope stops the walk at
227
- // the UserProject snapshots (their fields are never followed).
228
- const PendingSharesFieldFilter: Filter = treeFilter.resourceTypeEq(SharedEnvelopeResourceType.name);
229
-
230
205
  /**
231
- * Creates the acceptor's shared-resource discovery tree (a `{kind:'shared'}` seed over
232
- * {@link SharedEnvelopeResourceType}) plus the {@link PendingShare} computable over it.
233
- *
234
- * An envelope surfaces only when its shareId has NO decision/{shareId} on SharingState —
235
- * the dedup is applied by the caller, which holds the SharingState tree.
206
+ * Creates the recipient's shared-resource discovery tree: a `{kind:'shared'}` seed over
207
+ * {@link SharedEnvelopeResourceType}. {@link createAvailableSharesComputable} and
208
+ * {@link createLiveEnvelopesComputable} both read it.
236
209
  */
237
- export async function createPendingSharesTree(
210
+ export async function createAvailableSharesTree(
238
211
  pl: PlClient,
239
212
  env: MiddleLayerEnvironment,
240
213
  ): Promise<SynchronizedTreeState> {
@@ -250,83 +223,58 @@ export async function createPendingSharesTree(
250
223
  {
251
224
  ...env.ops.defaultTreeOptions,
252
225
  pruning: SharedEnvelopePruningFunction,
253
- fieldFilter: PendingSharesFieldFilter,
226
+ fieldFilter: AvailableSharesFieldFilter,
254
227
  },
255
228
  env.logger,
256
229
  );
257
230
  }
258
231
 
259
- /** A live envelope discovered in the acceptor's shared-resource tree: its signed resource id and
260
- * decoded {@link EnvelopeData}. The accept/reject flow keys these by `data.shareId`. */
261
- export type LiveEnvelope = { rid: SignedResourceId; data: EnvelopeData };
262
-
263
232
  /**
264
- * Builds a Computable yielding the acceptor's currently-live envelopes, read from the
233
+ * Builds a Computable yielding the recipient's currently-live envelopes, read from the
265
234
  * shared-resource discovery tree the ML already maintains — the single discovery mechanism.
266
- * Accept/reject `.getValue()` this instead of re-streaming `ListUserResources`, so there is no
267
- * second discovery path. The envelope's signed `rid` (from the tree node) is what the write tx
268
- * needs; `copyEnvelopeProjectsIntoList` reads the project snapshots itself inside the tx.
235
+ * A copy out of a share `.getValue()`s this instead of re-streaming `ListUserResources`, so there
236
+ * is no second discovery path. The envelope's signed `rid` (from the tree node) is what the write
237
+ * tx needs; `copyEnvelopeProjectsIntoList` reads the project snapshots itself inside the tx.
269
238
  *
270
- * Returns `undefined` while the tree is still empty/unresolved (mirrors the other tree-backed
271
- * computables here). Yields a flat list; the caller dedups by `shareId` — at most one live
272
- * envelope per shareId is expected (the donor keeps one), and a replace tears the old one down.
239
+ * Yields a flat list; the caller dedups by `shareId` — at most one live envelope per shareId is
240
+ * expected (the donor keeps one), and a replace tears the old one down.
273
241
  */
274
242
  export function createLiveEnvelopesComputable(
275
243
  sharedTree: SynchronizedTreeState,
276
244
  ): Computable<LiveEnvelope[] | undefined> {
277
- return Computable.make((ctx) => {
278
- const roots = ctx.accessor(sharedTree.rootsEntry()).nodes();
279
- const result: LiveEnvelope[] = [];
280
- for (const envelope of roots) {
281
- if (envelope === undefined) continue;
282
- if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
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.
286
- if (data === undefined) continue;
287
- result.push({ rid: envelope.id, data });
288
- }
289
- return result;
290
- });
245
+ return Computable.make((ctx) => decodedEnvelopes(ctx.accessor(sharedTree.rootsEntry()).nodes()));
291
246
  }
292
247
 
293
248
  /**
294
- * Builds the {@link PendingShare} computable over the shared-resource discovery tree, filtered
295
- * against the set of already-handled shareIds (those with a decision/{shareId} in the acceptor's
296
- * SharingState). Both trees feed one Computable so it recomputes when either changes.
249
+ * Builds the {@link AvailableShare} computable over the shared-resource discovery tree, marking
250
+ * each entry with whether this user has hidden it (a hiddenField in their own SharingState).
251
+ * Both trees feed one Computable so it recomputes when either changes.
297
252
  *
298
253
  * `currentUserLogin` (when known) suppresses the user's own shares: a share-with-everybody grants
299
- * the everyone-user, so the donor discovers their own envelope as a pending share. There is no
300
- * scenario where a user accepts their own share, so they are dropped from the pending view.
254
+ * the everyone-user, so the donor discovers their own envelope here. A donor already owns what
255
+ * they shared, so their own envelopes are dropped from this view.
301
256
  */
302
- export function createPendingSharesComputable(
257
+ export function createAvailableSharesComputable(
303
258
  sharedTree: SynchronizedTreeState,
304
259
  sharingStateTree: SynchronizedTreeState,
305
260
  currentUserLogin: string | null,
306
- ): Computable<PendingShare[] | undefined> {
261
+ ): Computable<AvailableShare[] | undefined> {
307
262
  return Computable.make((ctx) => {
308
- const roots = ctx.accessor(sharedTree.rootsEntry()).nodes();
309
-
310
- // Set of shareIds already handled (accepted or rejected) — dedup discovery on the logical share.
263
+ // Shares this user has put out of sight, keyed on the share. A share replaced by a newer one
264
+ // is a new share, so its replacement arrives unhidden.
311
265
  const stateNode = ctx.accessor(sharingStateTree.entry()).node();
312
- const handled = new Set<ShareId>();
266
+ const hidden = new Set<ShareId>();
313
267
  if (stateNode !== undefined)
314
268
  for (const f of stateNode.listDynamicFields())
315
- if (f.startsWith(DecisionFieldPrefix))
316
- handled.add(asShareId(f.slice(DecisionFieldPrefix.length)));
269
+ if (isHiddenField(f)) hidden.add(hiddenFieldShareId(f));
317
270
 
318
- const result: PendingShare[] = [];
319
- for (const envelope of roots) {
320
- if (envelope === undefined) continue;
321
- if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
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.
325
- if (data === undefined) continue;
326
- if (handled.has(data.shareId)) continue; // already accepted or rejected
271
+ const result: AvailableShare[] = [];
272
+ for (const { data } of decodedEnvelopes(ctx.accessor(sharedTree.rootsEntry()).nodes())) {
327
273
  if (currentUserLogin !== null && data.sender === currentUserLogin) continue; // own share
328
274
  if (data.expiresAt !== null && data.expiresAt <= Date.now()) continue;
329
275
 
276
+ const folder = envelopeFolderSummary(data);
277
+ const description = envelopeDescription(data);
330
278
  result.push({
331
279
  shareId: data.shareId,
332
280
  sender: data.sender,
@@ -334,6 +282,9 @@ export function createPendingSharesComputable(
334
282
  mode: data.mode,
335
283
  payloadKind: data.payload.kind,
336
284
  grantedAt: data.sharedAt,
285
+ ...(description === undefined ? {} : { description }),
286
+ ...(folder === undefined ? {} : { folder }),
287
+ hidden: hidden.has(data.shareId),
337
288
  });
338
289
  }
339
290
  result.sort((a, b) => b.grantedAt - a.grantedAt);
@@ -341,15 +292,7 @@ export function createPendingSharesComputable(
341
292
  });
342
293
  }
343
294
 
344
- const SharingStatePruningFunction: PruningFunction = (resource) => {
345
- if (!resourceTypesEqual(resource.type, SharingStateResourceType)) return [];
346
- return resource.fields;
347
- };
348
-
349
- // decision/{shareId} values are leaf JSON resources; follow fields only from SharingState.
350
- const SharingStateFieldFilter: Filter = treeFilter.resourceTypeEq(SharingStateResourceType.name);
351
-
352
- /** Creates the acceptor's SharingState synchronized tree (single explicit root). */
295
+ /** Creates the recipient's SharingState synchronized tree (single explicit root). */
353
296
  export async function createSharingStateTree(
354
297
  pl: PlClient,
355
298
  stateRid: SignedResourceId,
@@ -367,7 +310,120 @@ export async function createSharingStateTree(
367
310
  );
368
311
  }
369
312
 
370
- /** Maps an envelope resource id (signed) to its string form, for failure reporting. */
371
- export function envelopeIdString(rid: SignedResourceId): string {
372
- return resourceIdToString(rid);
313
+ //
314
+ // Internals
315
+ //
316
+
317
+ const SharingOutboxPruningFunction: PruningFunction = (resource) => {
318
+ if (
319
+ !resourceTypesEqual(resource.type, SharingOutboxResourceType) &&
320
+ !resourceTypesEqual(resource.type, SharedEnvelopeResourceType)
321
+ )
322
+ return [];
323
+ return resource.fields;
324
+ };
325
+
326
+ // Server-side traversal scope (modern resourceTree path). Pruning is client-side ONLY and does
327
+ // NOT stop the backend walk — without a fieldFilter the backend descends through the envelope's
328
+ // project/{uuid} snapshots into the whole project graph (StreamManager etc.), whose field-driven
329
+ // finality predicate then throws on the pruned-to-[] fields. Following fields only FROM the outbox
330
+ // and the envelope stops the walk at the project snapshots (UserProject), which we never traverse.
331
+ const SharingOutboxFieldFilter: Filter = treeFilter.or(
332
+ treeFilter.resourceTypeEq(SharingOutboxResourceType.name),
333
+ treeFilter.resourceTypeEq(SharedEnvelopeResourceType.name),
334
+ );
335
+
336
+ const SharedEnvelopePruningFunction: PruningFunction = (resource) => {
337
+ if (!resourceTypesEqual(resource.type, SharedEnvelopeResourceType)) return [];
338
+ return resource.fields;
339
+ };
340
+
341
+ // Discovery only needs each envelope's immutable EnvelopeData (basic resource data); it must NOT
342
+ // descend into the project snapshots. Following fields only FROM the envelope stops the walk at
343
+ // the UserProject snapshots (their fields are never followed).
344
+ const AvailableSharesFieldFilter: Filter = treeFilter.resourceTypeEq(
345
+ SharedEnvelopeResourceType.name,
346
+ );
347
+
348
+ const SharingStatePruningFunction: PruningFunction = (resource) => {
349
+ if (!resourceTypesEqual(resource.type, SharingStateResourceType)) return [];
350
+ return resource.fields;
351
+ };
352
+
353
+ // decision/{shareId} values are leaf JSON resources; follow fields only from SharingState.
354
+ const SharingStateFieldFilter: Filter = treeFilter.resourceTypeEq(SharingStateResourceType.name);
355
+
356
+ /** Intermediate of an outgoing share before its full recipient list is fetched: everything the
357
+ * tree carries synchronously, plus the envelope's signed id for the async `ListGrants` enrich. */
358
+ type OutgoingShareDraft = Omit<OutgoingShare, "recipients"> & { envelopeRid: SignedResourceId };
359
+
360
+ /**
361
+ * Every envelope among the discovery tree's roots that this build can read, decoded. A root that
362
+ * is not a {@link SharedEnvelopeResourceType}, or whose schemaVersion or payload kind this build
363
+ * does not know, is left out rather than offered: there is nothing useful to do with a share that
364
+ * cannot be read.
365
+ */
366
+ function decodedEnvelopes(roots: readonly (PlTreeNodeAccessor | undefined)[]): LiveEnvelope[] {
367
+ const result: LiveEnvelope[] = [];
368
+ for (const envelope of roots) {
369
+ if (envelope === undefined) continue;
370
+ if (!resourceTypesEqual(envelope.resourceType, SharedEnvelopeResourceType)) continue;
371
+ const data = normalizeEnvelopeData(envelope.getDataAsJson<unknown>());
372
+ if (data === undefined) continue;
373
+ result.push({ rid: envelope.id, data });
374
+ }
375
+ return result;
376
+ }
377
+
378
+ /** Per-project view for the donor, from the envelope's `projects` payload; empty for a payload
379
+ * that carries no project. */
380
+ function envelopeProjects(data: EnvelopeData): OutgoingShare["projects"] {
381
+ return Object.values(envelopeProjectMap(data)).map((p) => ({
382
+ projectId: p.source,
383
+ label: p.label,
384
+ updatedAt: p.updatedAt,
385
+ }));
386
+ }
387
+
388
+ /**
389
+ * What a folder share is, in the words both lists show it in: the shared folder's name and how
390
+ * much is inside. `undefined` for any other payload.
391
+ *
392
+ * The root folder is excluded from `folderCount` — it is the thing being described, not something
393
+ * inside it. A folder envelope with no single root is one nothing can be rebuilt from, and it is
394
+ * described as nothing rather than guessed at.
395
+ */
396
+ function envelopeFolderSummary(data: EnvelopeData): EnvelopeFolderSummary | undefined {
397
+ if (data.payload.kind !== "folder") return undefined;
398
+ const { folders, projects, templates } = data.payload;
399
+ const root = envelopeFolderRoot(folders);
400
+ if (root === undefined) return undefined;
401
+ return {
402
+ label: folders[root].name,
403
+ source: data.payload.source,
404
+ folderCount: Object.keys(folders).length - 1,
405
+ projectCount: Object.keys(projects).length,
406
+ templateCount: templates.length,
407
+ };
408
+ }
409
+
410
+ /**
411
+ * What the shared thing says about itself: the template's description, the root folder's, or the
412
+ * one project's of a single-project share. `undefined` when it has none, for a pack of several
413
+ * projects, which describes no single thing, and for a folder envelope with no single root.
414
+ */
415
+ function envelopeDescription(data: EnvelopeData): string | undefined {
416
+ const payload = data.payload;
417
+ switch (payload.kind) {
418
+ case "template":
419
+ return payload.description;
420
+ case "folder": {
421
+ const root = envelopeFolderRoot(payload.folders);
422
+ return root === undefined ? undefined : payload.folders[root].description;
423
+ }
424
+ case "projects": {
425
+ const projects = Object.values(payload.projects);
426
+ return projects.length === 1 ? projects[0]?.description : undefined;
427
+ }
428
+ }
373
429
  }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The template-list reader against a hand-built templates resource: what it keeps, what it skips
3
+ * and in which order. No backend — `templateListEntries` is the whole computable body, fed here
4
+ * with a stand-in node instead of a synchronized tree.
5
+ */
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import type { ResourceType } from "@milaboratories/pl-client";
9
+ import { asSignedResourceId } from "@milaboratories/pl-client";
10
+ import type { BlockKindSelectorReference } from "@milaboratories/pl-model-common";
11
+ import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
12
+ import type { StoredTemplateData } from "./template_list";
13
+ import {
14
+ TemplateCreatedTimestamp,
15
+ TemplateDescriptionKey,
16
+ TemplateLabelKey,
17
+ TemplateResourceType,
18
+ templateListEntries,
19
+ } from "./template_list";
20
+
21
+ // ── Helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ /** One field of the templates resource: a node, or a field that resolves to nothing. */
24
+ interface Entry {
25
+ readonly field: string;
26
+ readonly id?: number;
27
+ readonly type?: ResourceType;
28
+ readonly data?: StoredTemplateData;
29
+ readonly kv?: Record<string, unknown>;
30
+ /** A field whose value has not resolved yet — `traverse` returns undefined. */
31
+ readonly unresolved?: boolean;
32
+ }
33
+
34
+ /** A stored document listing the given number of blocks; only its length is read. */
35
+ function stored(blocks: number): StoredTemplateData {
36
+ return {
37
+ schemaVersion: 1,
38
+ document: {
39
+ schema: PROJECT_TEMPLATE_SCHEMA_V1,
40
+ blocks: Array.from({ length: blocks }, (_, i) => ({
41
+ id: `block-${i}`,
42
+ kind: KIND,
43
+ params: {},
44
+ })),
45
+ },
46
+ };
47
+ }
48
+
49
+ const KIND = "@platforma-open/milaboratories.demo.kind@^1.0.0" as BlockKindSelectorReference;
50
+
51
+ /** A template field with the data and metadata a synced template carries. */
52
+ function template(field: string, id: number, label: string, created: number) {
53
+ return {
54
+ field,
55
+ id,
56
+ type: TemplateResourceType,
57
+ data: stored(2),
58
+ kv: { [TemplateLabelKey]: label, [TemplateCreatedTimestamp]: created },
59
+ } satisfies Entry;
60
+ }
61
+
62
+ /** Stand-in for the templates-list tree node, carrying exactly what the reader reads. */
63
+ function templatesNode(...entries: Entry[]) {
64
+ return {
65
+ listDynamicFields: () => entries.map((e) => e.field),
66
+ traverse: (fieldName: string) => {
67
+ const entry = entries.find((e) => e.field === fieldName);
68
+ if (entry === undefined || entry.unresolved) return undefined;
69
+ return {
70
+ id: asSignedResourceId(`0x${(entry.id ?? 0).toString(16)}|ab`),
71
+ resourceType: entry.type ?? TemplateResourceType,
72
+ getDataAsJson: <T>() => entry.data as T | undefined,
73
+ getKeyValueAsJson: <T>(key: string) => (entry.kv ?? {})[key] as T | undefined,
74
+ };
75
+ },
76
+ };
77
+ }
78
+
79
+ // ── Tests ────────────────────────────────────────────────────────────────────
80
+
81
+ describe("tolerance", () => {
82
+ it("keeps every synced template", () => {
83
+ const entries = templateListEntries(
84
+ templatesNode(template("uuid-a", 1, "A", 100), template("uuid-b", 2, "B", 200)),
85
+ );
86
+
87
+ expect(entries.map((e) => e.label)).toStrictEqual(["B", "A"]);
88
+ expect(entries[1].created).toStrictEqual(new Date(100));
89
+ expect(entries[1].blockCount).toBe(2);
90
+ });
91
+
92
+ it("skips a sibling that is not a template, and keeps the templates around it", () => {
93
+ const entries = templateListEntries(
94
+ templatesNode(
95
+ template("uuid-a", 1, "A", 100),
96
+ { field: "stray", id: 9, type: { name: "Folders", version: "1" }, data: stored(1) },
97
+ template("uuid-b", 2, "B", 200),
98
+ ),
99
+ );
100
+
101
+ expect(entries.map((e) => e.label)).toStrictEqual(["B", "A"]);
102
+ });
103
+
104
+ it("skips a template whose data or metadata has not synced yet", () => {
105
+ const halfSynced: { name: string; data?: StoredTemplateData; kv: Record<string, unknown> }[] = [
106
+ { name: "data missing", kv: { [TemplateLabelKey]: "x", [TemplateCreatedTimestamp]: 1 } },
107
+ { name: "label missing", data: stored(1), kv: { [TemplateCreatedTimestamp]: 1 } },
108
+ { name: "created missing", data: stored(1), kv: { [TemplateLabelKey]: "x" } },
109
+ ];
110
+
111
+ for (const { name, data, kv } of halfSynced) {
112
+ const entries = templateListEntries(
113
+ templatesNode(
114
+ { field: "uuid-half", id: 9, type: TemplateResourceType, data, kv },
115
+ template("uuid-a", 1, "A", 100),
116
+ ),
117
+ );
118
+ expect(
119
+ entries.map((e) => e.label),
120
+ name,
121
+ ).toStrictEqual(["A"]);
122
+ }
123
+ });
124
+
125
+ it("skips a field that resolves to nothing", () => {
126
+ const entries = templateListEntries(
127
+ templatesNode({ field: "uuid-pending", unresolved: true }, template("uuid-a", 1, "A", 1)),
128
+ );
129
+
130
+ expect(entries.map((e) => e.label)).toStrictEqual(["A"]);
131
+ });
132
+ });
133
+
134
+ describe("description", () => {
135
+ it("carries a description, and reads a blank one as none", () => {
136
+ const described = template("uuid-a", 1, "A", 100);
137
+ const blank = template("uuid-b", 2, "B", 200);
138
+ const entries = templateListEntries(
139
+ templatesNode(
140
+ { ...described, kv: { ...described.kv, [TemplateDescriptionKey]: " Plates " } },
141
+ { ...blank, kv: { ...blank.kv, [TemplateDescriptionKey]: " " } },
142
+ ),
143
+ );
144
+
145
+ expect(entries.find((e) => e.label === "A")?.description).toBe("Plates");
146
+ expect(entries.find((e) => e.label === "B")).not.toHaveProperty("description");
147
+ });
148
+ });