@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.
- 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 +636 -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 +617 -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 +17 -17
- package/src/middle_layer/folders.test.ts +1076 -0
- package/src/middle_layer/folders.ts +1069 -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
|
@@ -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
|
-
|
|
23
|
-
|
|
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; //
|
|
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,
|
|
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
|
|
41
|
-
*
|
|
42
|
-
* snapshot was
|
|
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
|
|
45
|
-
template
|
|
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
|
-
/**
|
|
67
|
-
|
|
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;
|
|
72
|
-
/** What the
|
|
73
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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}
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
130
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
232
|
-
* {@link SharedEnvelopeResourceType}
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
267
|
-
* second discovery path. The envelope's signed `rid` (from the tree node) is what the write
|
|
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
|
-
*
|
|
271
|
-
*
|
|
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
|
|
295
|
-
*
|
|
296
|
-
*
|
|
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
|
|
300
|
-
*
|
|
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
|
|
257
|
+
export function createAvailableSharesComputable(
|
|
303
258
|
sharedTree: SynchronizedTreeState,
|
|
304
259
|
sharingStateTree: SynchronizedTreeState,
|
|
305
260
|
currentUserLogin: string | null,
|
|
306
|
-
): Computable<
|
|
261
|
+
): Computable<AvailableShare[] | undefined> {
|
|
307
262
|
return Computable.make((ctx) => {
|
|
308
|
-
|
|
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
|
|
266
|
+
const hidden = new Set<ShareId>();
|
|
313
267
|
if (stateNode !== undefined)
|
|
314
268
|
for (const f of stateNode.listDynamicFields())
|
|
315
|
-
if (f.
|
|
316
|
-
handled.add(asShareId(f.slice(DecisionFieldPrefix.length)));
|
|
269
|
+
if (isHiddenField(f)) hidden.add(hiddenFieldShareId(f));
|
|
317
270
|
|
|
318
|
-
const result:
|
|
319
|
-
for (const
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
});
|