@milaboratories/pl-middle-layer 1.71.15 → 1.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +10 -5
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/middle_layer/build_stamp.cjs +1 -1
- package/dist/middle_layer/build_stamp.js +1 -1
- package/dist/middle_layer/folders.cjs +631 -0
- package/dist/middle_layer/folders.cjs.map +1 -0
- package/dist/middle_layer/folders.d.ts +39 -0
- package/dist/middle_layer/folders.d.ts.map +1 -0
- package/dist/middle_layer/folders.js +613 -0
- package/dist/middle_layer/folders.js.map +1 -0
- package/dist/middle_layer/index.cjs +3 -0
- package/dist/middle_layer/index.d.ts +4 -3
- package/dist/middle_layer/index.js +2 -1
- package/dist/middle_layer/middle_layer.cjs +585 -370
- package/dist/middle_layer/middle_layer.cjs.map +1 -1
- package/dist/middle_layer/middle_layer.d.ts +215 -122
- package/dist/middle_layer/middle_layer.d.ts.map +1 -1
- package/dist/middle_layer/middle_layer.js +593 -378
- package/dist/middle_layer/middle_layer.js.map +1 -1
- package/dist/middle_layer/project_list.cjs +36 -19
- package/dist/middle_layer/project_list.cjs.map +1 -1
- package/dist/middle_layer/project_list.d.ts +1 -1
- package/dist/middle_layer/project_list.d.ts.map +1 -1
- package/dist/middle_layer/project_list.js +37 -21
- package/dist/middle_layer/project_list.js.map +1 -1
- package/dist/middle_layer/sharing_list.cjs +123 -93
- package/dist/middle_layer/sharing_list.cjs.map +1 -1
- package/dist/middle_layer/sharing_list.d.ts +43 -19
- package/dist/middle_layer/sharing_list.d.ts.map +1 -1
- package/dist/middle_layer/sharing_list.js +123 -93
- package/dist/middle_layer/sharing_list.js.map +1 -1
- package/dist/middle_layer/template_list.cjs +45 -20
- package/dist/middle_layer/template_list.cjs.map +1 -1
- package/dist/middle_layer/template_list.d.ts +7 -15
- package/dist/middle_layer/template_list.d.ts.map +1 -1
- package/dist/middle_layer/template_list.js +44 -21
- package/dist/middle_layer/template_list.js.map +1 -1
- package/dist/model/index.cjs +7 -5
- package/dist/model/index.d.ts +2 -2
- package/dist/model/index.js +2 -2
- package/dist/model/sharing_model.cjs +46 -20
- package/dist/model/sharing_model.cjs.map +1 -1
- package/dist/model/sharing_model.d.ts +125 -55
- package/dist/model/sharing_model.d.ts.map +1 -1
- package/dist/model/sharing_model.js +40 -16
- package/dist/model/sharing_model.js.map +1 -1
- package/dist/mutator/list.cjs +30 -0
- package/dist/mutator/list.cjs.map +1 -0
- package/dist/mutator/list.js +29 -0
- package/dist/mutator/list.js.map +1 -0
- package/dist/mutator/project.cjs +14 -1
- package/dist/mutator/project.cjs.map +1 -1
- package/dist/mutator/project.d.ts.map +1 -1
- package/dist/mutator/project.js +15 -2
- package/dist/mutator/project.js.map +1 -1
- package/dist/mutator/sharing.cjs +130 -76
- package/dist/mutator/sharing.cjs.map +1 -1
- package/dist/mutator/sharing.js +130 -76
- package/dist/mutator/sharing.js.map +1 -1
- package/dist/mutator/template.cjs +24 -17
- package/dist/mutator/template.cjs.map +1 -1
- package/dist/mutator/template.js +26 -20
- package/dist/mutator/template.js.map +1 -1
- package/package.json +18 -18
- package/src/middle_layer/folders.test.ts +1068 -0
- package/src/middle_layer/folders.ts +1059 -0
- package/src/middle_layer/folders_read.test.ts +359 -0
- package/src/middle_layer/index.ts +3 -2
- package/src/middle_layer/middle_layer.ts +813 -547
- package/src/middle_layer/project_list.test.ts +183 -0
- package/src/middle_layer/project_list.ts +58 -21
- package/src/middle_layer/sharing.test.ts +183 -0
- package/src/middle_layer/sharing_list.ts +208 -152
- package/src/middle_layer/template_list.test.ts +148 -0
- package/src/middle_layer/template_list.ts +72 -32
- package/src/middle_layer/templates.test.ts +76 -45
- package/src/model/sharing_model.test.ts +69 -2
- package/src/model/sharing_model.ts +161 -73
- package/src/mutator/list.ts +36 -0
- package/src/mutator/project-v3.test.ts +3 -1
- package/src/mutator/project.ts +13 -2
- package/src/mutator/sharing.ts +193 -120
- package/src/mutator/template.ts +37 -29
- package/src/test/with_ml.ts +73 -16
package/src/mutator/sharing.ts
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
import type { PlTransaction, ResourceRef, SignedResourceId } from "@milaboratories/pl-client";
|
|
2
|
-
import { field, isNotNullSignedResourceId
|
|
2
|
+
import { field, isNotNullSignedResourceId } from "@milaboratories/pl-client";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import type { ProjectMeta } from "@milaboratories/pl-model-middle-layer";
|
|
5
|
-
import
|
|
4
|
+
import type { FolderId, ProjectMeta } from "@milaboratories/pl-model-middle-layer";
|
|
5
|
+
import { normalizeDescription } from "@milaboratories/pl-model-middle-layer";
|
|
6
|
+
import type { ProjectId, ProjectTemplateV1, TemplateId } from "@milaboratories/pl-model-common";
|
|
6
7
|
import { ProjectMetaKey } from "../model/project_model";
|
|
7
8
|
import { duplicateProject } from "./project";
|
|
8
9
|
import type {
|
|
9
10
|
EnvelopeData,
|
|
10
|
-
|
|
11
|
+
EnvelopeFolder,
|
|
12
|
+
EnvelopeFolderId,
|
|
13
|
+
EnvelopeFolderProject,
|
|
14
|
+
EnvelopeFolderTemplate,
|
|
11
15
|
EnvelopeMode,
|
|
12
16
|
EnvelopeProject,
|
|
13
17
|
ProjectFieldUuid,
|
|
14
18
|
ShareId,
|
|
15
|
-
|
|
19
|
+
ShareHidden,
|
|
16
20
|
} from "../model/sharing_model";
|
|
17
21
|
import {
|
|
18
22
|
EnvelopeSchemaVersionCurrent,
|
|
19
23
|
SharedEnvelopeResourceType,
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
hiddenField,
|
|
25
|
+
newProjectFieldUuid,
|
|
22
26
|
newShareId,
|
|
23
27
|
} from "../model/sharing_model";
|
|
24
28
|
|
|
@@ -41,20 +45,8 @@ export function envelopeProjectFieldUuid(name: string): ProjectFieldUuid {
|
|
|
41
45
|
// Donor side
|
|
42
46
|
//
|
|
43
47
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
* re-attaches an existing snapshot (change's "keep", or an "update" whose source is gone).
|
|
47
|
-
*/
|
|
48
|
-
export type EnvelopeProjectSource =
|
|
49
|
-
| { kind: "fresh"; projectId: ProjectId; sourceRid: SignedResourceId }
|
|
50
|
-
| {
|
|
51
|
-
kind: "carry";
|
|
52
|
-
projectId: ProjectId;
|
|
53
|
-
label: string;
|
|
54
|
-
snapshotRid: SignedResourceId;
|
|
55
|
-
/** ms epoch the carried snapshot was last taken; preserved so "keep" keeps its timestamp. */
|
|
56
|
-
updatedAt: number;
|
|
57
|
-
};
|
|
48
|
+
/** One live project going into an envelope; it is snapshotted as the envelope is built. */
|
|
49
|
+
export type EnvelopeProjectSource = { projectId: ProjectId; sourceRid: SignedResourceId };
|
|
58
50
|
|
|
59
51
|
/**
|
|
60
52
|
* Builds one {@link SharedEnvelopeResourceType} on the donor side inside the given write
|
|
@@ -75,35 +67,17 @@ export async function buildShareEnvelope(
|
|
|
75
67
|
title: string;
|
|
76
68
|
/** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
|
|
77
69
|
expiresAt: number | null;
|
|
78
|
-
/** Existing shareId for a change; a fresh one is minted when omitted. */
|
|
79
|
-
shareId?: ShareId;
|
|
80
|
-
sharedAt?: number;
|
|
81
70
|
},
|
|
82
71
|
): Promise<{ envelope: ResourceRef; data: EnvelopeData }> {
|
|
83
|
-
const
|
|
84
|
-
const
|
|
72
|
+
const sharedAt = Date.now();
|
|
73
|
+
const snapshots = await snapshotEnvelopeProjects(tx, sources, sharedAt);
|
|
85
74
|
|
|
86
|
-
// Snapshot (fresh) or re-attach (carry) each project, collecting its metadata for the pack.
|
|
87
75
|
const projects: Record<ProjectFieldUuid, EnvelopeProject> = {};
|
|
88
|
-
const
|
|
89
|
-
for (const src of sources) {
|
|
90
|
-
const uuid = randomUUID() as ProjectFieldUuid;
|
|
91
|
-
if (src.kind === "fresh") {
|
|
92
|
-
const meta = await tx.getKValueJson<ProjectMeta>(src.sourceRid, ProjectMetaKey);
|
|
93
|
-
const ref = await duplicateProject(tx, src.sourceRid, { label: meta.label });
|
|
94
|
-
projects[uuid] = { label: meta.label, source: src.projectId, updatedAt: sharedAt }; // (re)snapshotted now
|
|
95
|
-
snapshots.push({ uuid, ref });
|
|
96
|
-
} else {
|
|
97
|
-
// Re-attach the prior snapshot; the new envelope references it before the old one is
|
|
98
|
-
// detached in the same tx, so it stays alive. Label and timestamp carry unchanged.
|
|
99
|
-
projects[uuid] = { label: src.label, source: src.projectId, updatedAt: src.updatedAt };
|
|
100
|
-
snapshots.push({ uuid, ref: src.snapshotRid });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
76
|
+
for (const { uuid, project } of snapshots) projects[uuid] = project;
|
|
103
77
|
|
|
104
78
|
const data: EnvelopeData = {
|
|
105
79
|
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
106
|
-
shareId,
|
|
80
|
+
shareId: newShareId(),
|
|
107
81
|
sharedAt,
|
|
108
82
|
expiresAt: params.expiresAt,
|
|
109
83
|
mode: params.mode,
|
|
@@ -112,20 +86,7 @@ export async function buildShareEnvelope(
|
|
|
112
86
|
payload: { kind: "projects", projects },
|
|
113
87
|
};
|
|
114
88
|
|
|
115
|
-
|
|
116
|
-
const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
|
|
117
|
-
|
|
118
|
-
// Attach the project snapshots as Input fields, then seal the input set one-way.
|
|
119
|
-
for (const { uuid, ref } of snapshots) {
|
|
120
|
-
tx.createField(field(envelope, envelopeProjectField(uuid)), "Input", ref);
|
|
121
|
-
}
|
|
122
|
-
tx.lockInputs(envelope);
|
|
123
|
-
|
|
124
|
-
// Attach to the outbox under {shareId} in the same transaction so the held-resource rule
|
|
125
|
-
// keeps the ephemeral envelope alive.
|
|
126
|
-
tx.createField(field(outboxRid, shareId), "Dynamic", envelope);
|
|
127
|
-
|
|
128
|
-
return { envelope, data };
|
|
89
|
+
return { envelope: sealEnvelope(tx, outboxRid, data, snapshots), data };
|
|
129
90
|
}
|
|
130
91
|
|
|
131
92
|
/**
|
|
@@ -142,21 +103,23 @@ export async function buildShareEnvelope(
|
|
|
142
103
|
export function buildTemplateShareEnvelope(
|
|
143
104
|
tx: PlTransaction,
|
|
144
105
|
outboxRid: SignedResourceId,
|
|
145
|
-
template: {
|
|
106
|
+
template: {
|
|
107
|
+
document: ProjectTemplateV1;
|
|
108
|
+
label: string;
|
|
109
|
+
description?: string;
|
|
110
|
+
source: TemplateId;
|
|
111
|
+
},
|
|
146
112
|
params: {
|
|
147
113
|
sender: string;
|
|
148
114
|
title: string;
|
|
149
115
|
/** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
|
|
150
116
|
expiresAt: number | null;
|
|
151
|
-
/** Existing shareId for a change; a fresh one is minted when omitted. */
|
|
152
|
-
shareId?: ShareId;
|
|
153
|
-
sharedAt?: number;
|
|
154
117
|
},
|
|
155
118
|
): { envelope: ResourceRef; data: EnvelopeData } {
|
|
156
119
|
const data: EnvelopeData = {
|
|
157
120
|
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
158
|
-
shareId:
|
|
159
|
-
sharedAt:
|
|
121
|
+
shareId: newShareId(),
|
|
122
|
+
sharedAt: Date.now(),
|
|
160
123
|
expiresAt: params.expiresAt,
|
|
161
124
|
mode: "read-only",
|
|
162
125
|
sender: params.sender,
|
|
@@ -164,105 +127,215 @@ export function buildTemplateShareEnvelope(
|
|
|
164
127
|
payload: {
|
|
165
128
|
kind: "template",
|
|
166
129
|
document: template.document,
|
|
130
|
+
source: template.source,
|
|
167
131
|
label: template.label,
|
|
132
|
+
...(template.description === undefined ? {} : { description: template.description }),
|
|
168
133
|
from: params.sender,
|
|
169
134
|
},
|
|
170
135
|
};
|
|
171
136
|
|
|
172
|
-
|
|
173
|
-
const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
|
|
174
|
-
|
|
175
|
-
// Attach to the outbox under {shareId} in the same transaction so the held-resource rule
|
|
176
|
-
// keeps the ephemeral envelope alive.
|
|
177
|
-
tx.createField(field(outboxRid, data.shareId), "Dynamic", envelope);
|
|
178
|
-
|
|
179
|
-
return { envelope, data };
|
|
137
|
+
return { envelope: sealEnvelope(tx, outboxRid, data, []), data };
|
|
180
138
|
}
|
|
181
139
|
|
|
182
140
|
/**
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
141
|
+
* Builds one {@link SharedEnvelopeResourceType} carrying a folder subtree: the folders, every
|
|
142
|
+
* project in them snapshotted as a `project/{uuid}` field, and every template's document inline.
|
|
143
|
+
* Attaches it under `{shareId}` on the donor's outbox. The caller issues the grant — writable,
|
|
144
|
+
* because the recipient copies the project snapshots out — and commits.
|
|
145
|
+
*
|
|
146
|
+
* Folder ids are minted here and mean nothing outside this envelope: the recipient's own document
|
|
147
|
+
* mints its own. What travels is the shape of the subtree.
|
|
148
|
+
*
|
|
149
|
+
* @returns the new envelope resource and the generated `EnvelopeData`.
|
|
186
150
|
*/
|
|
187
|
-
export function
|
|
151
|
+
export async function buildFolderShareEnvelope(
|
|
188
152
|
tx: PlTransaction,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
153
|
+
outboxRid: SignedResourceId,
|
|
154
|
+
subtree: EnvelopeFolderSubtree,
|
|
155
|
+
params: {
|
|
156
|
+
sender: string;
|
|
157
|
+
title: string;
|
|
158
|
+
/** ms epoch; sharedAt + ttl for a targeted share, null for share-with-everybody. */
|
|
159
|
+
expiresAt: number | null;
|
|
160
|
+
},
|
|
161
|
+
): Promise<{ envelope: ResourceRef; data: EnvelopeData }> {
|
|
162
|
+
const sharedAt = Date.now();
|
|
163
|
+
const snapshots = await snapshotEnvelopeProjects(tx, subtree.projects, sharedAt);
|
|
164
|
+
|
|
165
|
+
const projects: Record<ProjectFieldUuid, EnvelopeFolderProject> = {};
|
|
166
|
+
for (const { uuid, project, source } of snapshots)
|
|
167
|
+
projects[uuid] = { ...project, folder: source.folder };
|
|
168
|
+
|
|
169
|
+
const data: EnvelopeData = {
|
|
170
|
+
schemaVersion: EnvelopeSchemaVersionCurrent,
|
|
171
|
+
shareId: newShareId(),
|
|
172
|
+
sharedAt,
|
|
173
|
+
expiresAt: params.expiresAt,
|
|
174
|
+
mode: "copy",
|
|
175
|
+
sender: params.sender,
|
|
176
|
+
title: params.title,
|
|
177
|
+
payload: {
|
|
178
|
+
kind: "folder",
|
|
179
|
+
source: subtree.source,
|
|
180
|
+
folders: subtree.folders,
|
|
181
|
+
projects,
|
|
182
|
+
templates: subtree.templates,
|
|
183
|
+
from: params.sender,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return { envelope: sealEnvelope(tx, outboxRid, data, snapshots), data };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A folder subtree ready to be sealed into an envelope: the folders under their envelope-local
|
|
191
|
+
* ids, the live projects to snapshot, and the templates to carry whole. */
|
|
192
|
+
export interface EnvelopeFolderSubtree {
|
|
193
|
+
/** Donor's own id of the shared folder, carried so a later share of it finds this one. */
|
|
194
|
+
source: FolderId;
|
|
195
|
+
folders: Record<EnvelopeFolderId, EnvelopeFolder>;
|
|
196
|
+
projects: (EnvelopeProjectSource & { folder: EnvelopeFolderId })[];
|
|
197
|
+
templates: EnvelopeFolderTemplate[];
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
//
|
|
200
|
-
//
|
|
201
|
+
// Recipient side
|
|
201
202
|
//
|
|
202
203
|
|
|
203
204
|
/**
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
* share, not on the envelope instance.
|
|
205
|
+
* Puts a share out of this recipient's sight, as a dynamic field on their own SharingState.
|
|
206
|
+
* Keyed on the shareId, so a share that replaces this one — a new shareId — is shown again.
|
|
207
207
|
*/
|
|
208
|
-
export function
|
|
208
|
+
export function writeShareHidden(
|
|
209
|
+
tx: PlTransaction,
|
|
210
|
+
stateRid: SignedResourceId,
|
|
211
|
+
shareId: ShareId,
|
|
212
|
+
timestamp: number,
|
|
213
|
+
): void {
|
|
214
|
+
const value = tx.createJsonValue({ hidden: true, timestamp } satisfies ShareHidden);
|
|
215
|
+
tx.createField(field(stateRid, hiddenField(shareId)), "Dynamic", value);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Brings a hidden share back into this recipient's list. */
|
|
219
|
+
export function clearShareHidden(
|
|
209
220
|
tx: PlTransaction,
|
|
210
221
|
stateRid: SignedResourceId,
|
|
211
222
|
shareId: ShareId,
|
|
212
|
-
decision: SharingDecision,
|
|
213
223
|
): void {
|
|
214
|
-
|
|
215
|
-
tx.createField(field(stateRid, decisionField(shareId)), "Dynamic", value);
|
|
224
|
+
tx.removeField(field(stateRid, hiddenField(shareId)));
|
|
216
225
|
}
|
|
217
226
|
|
|
218
227
|
/**
|
|
219
|
-
* Copies every project snapshot inside an envelope into the
|
|
220
|
-
* cross-color attach the backend permits.
|
|
221
|
-
*
|
|
222
|
-
*
|
|
228
|
+
* Copies every project snapshot inside an envelope into the recipient's own project list — a
|
|
229
|
+
* cross-color attach the backend permits. The source is resolved against the envelope tree, not
|
|
230
|
+
* the recipient's own list.
|
|
231
|
+
*
|
|
232
|
+
* `label` names each copy from its snapshot's label. The caller picks the names against the
|
|
233
|
+
* destination the copies land in, because that is where they have to be free; left out, each copy
|
|
234
|
+
* keeps its snapshot's label.
|
|
223
235
|
*
|
|
224
|
-
*
|
|
236
|
+
* Each result carries the envelope field uuid it was copied from, because that uuid is what the
|
|
237
|
+
* payload names a project by — a folder share reads the folder of each copy off it. Pairing by
|
|
238
|
+
* position would happen to work today and quietly stop working the moment the order does.
|
|
239
|
+
*
|
|
240
|
+
* @returns one entry per copied project: the envelope uuid it came from and its new id.
|
|
225
241
|
*/
|
|
226
242
|
export async function copyEnvelopeProjectsIntoList(
|
|
227
243
|
tx: PlTransaction,
|
|
228
244
|
envelopeRid: SignedResourceId,
|
|
229
245
|
projectListRid: SignedResourceId,
|
|
230
|
-
|
|
231
|
-
): Promise<SignedResourceId[]> {
|
|
232
|
-
//
|
|
233
|
-
const projectListData = await tx.getResourceData(projectListRid, true);
|
|
234
|
-
const existingRids = projectListData.fields.map((f) => f.value).filter(isNotNullSignedResourceId);
|
|
235
|
-
const existingLabels = (
|
|
236
|
-
await Promise.all(existingRids.map((rid) => tx.getKValueJson<ProjectMeta>(rid, ProjectMetaKey)))
|
|
237
|
-
).map((m) => m.label);
|
|
238
|
-
|
|
239
|
-
// Enumerate the envelope's project/{uuid} input field values (signed envelope-colored ids).
|
|
246
|
+
label?: (sourceLabel: string) => string,
|
|
247
|
+
): Promise<{ uuid: ProjectFieldUuid; rid: SignedResourceId }[]> {
|
|
248
|
+
// Enumerate the envelope's project/{uuid} input fields (signed envelope-colored ids).
|
|
240
249
|
const envelopeData = await tx.getResourceData(envelopeRid, true);
|
|
241
|
-
const
|
|
250
|
+
const sources = envelopeData.fields
|
|
242
251
|
.filter((f) => isEnvelopeProjectField(f.name))
|
|
243
|
-
.
|
|
244
|
-
|
|
252
|
+
.flatMap((f) =>
|
|
253
|
+
isNotNullSignedResourceId(f.value)
|
|
254
|
+
? [{ uuid: envelopeProjectFieldUuid(f.name), sourceRid: f.value }]
|
|
255
|
+
: [],
|
|
256
|
+
);
|
|
245
257
|
|
|
246
|
-
const created: SignedResourceId[] = [];
|
|
247
|
-
for (const sourceRid of
|
|
258
|
+
const created: { uuid: ProjectFieldUuid; rid: SignedResourceId }[] = [];
|
|
259
|
+
for (const { uuid, sourceRid } of sources) {
|
|
248
260
|
const sourceMeta = await tx.getKValueJson<ProjectMeta>(sourceRid, ProjectMetaKey);
|
|
249
|
-
const newLabel =
|
|
250
|
-
existingLabels.push(newLabel);
|
|
261
|
+
const newLabel = label === undefined ? sourceMeta.label : label(sourceMeta.label);
|
|
251
262
|
|
|
252
|
-
// Cross-color attach: a new UserProject in the
|
|
263
|
+
// Cross-color attach: a new UserProject in the recipient's color whose fields point at
|
|
253
264
|
// envelope-colored resources. Fails with PermissionDenied: color mismatch on a backend
|
|
254
265
|
// that lacks crossTreeRefs:v1.
|
|
255
|
-
const newPrj = await duplicateProject(tx, sourceRid, { label: newLabel });
|
|
266
|
+
const newPrj = await duplicateProject(tx, sourceRid, { ...sourceMeta, label: newLabel });
|
|
256
267
|
tx.createField(field(projectListRid, randomUUID()), "Dynamic", newPrj);
|
|
257
268
|
|
|
258
|
-
|
|
259
|
-
created.push(signedRid);
|
|
269
|
+
created.push({ uuid, rid: await newPrj.globalId });
|
|
260
270
|
}
|
|
261
271
|
|
|
262
272
|
return created;
|
|
263
273
|
}
|
|
264
274
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
275
|
+
//
|
|
276
|
+
// Internals
|
|
277
|
+
//
|
|
278
|
+
|
|
279
|
+
/** One source project snapshotted for an envelope: the field uuid it rides under, the snapshot
|
|
280
|
+
* itself, and what the payload says about it. */
|
|
281
|
+
type EnvelopeSnapshot<Source extends EnvelopeProjectSource> = {
|
|
282
|
+
uuid: ProjectFieldUuid;
|
|
283
|
+
ref: ResourceRef;
|
|
284
|
+
source: Source;
|
|
285
|
+
project: EnvelopeProject;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Snapshots each source project by reference, in the given order, under a freshly minted field
|
|
290
|
+
* uuid. The payload entry carries the project's label and description as they were at `sharedAt`,
|
|
291
|
+
* so a share list renders without traversing into the snapshot.
|
|
292
|
+
*/
|
|
293
|
+
async function snapshotEnvelopeProjects<Source extends EnvelopeProjectSource>(
|
|
294
|
+
tx: PlTransaction,
|
|
295
|
+
sources: readonly Source[],
|
|
296
|
+
sharedAt: number,
|
|
297
|
+
): Promise<EnvelopeSnapshot<Source>[]> {
|
|
298
|
+
const snapshots: EnvelopeSnapshot<Source>[] = [];
|
|
299
|
+
for (const source of sources) {
|
|
300
|
+
const meta = await tx.getKValueJson<ProjectMeta>(source.sourceRid, ProjectMetaKey);
|
|
301
|
+
const ref = await duplicateProject(tx, source.sourceRid, meta);
|
|
302
|
+
const description = normalizeDescription(meta.description);
|
|
303
|
+
snapshots.push({
|
|
304
|
+
uuid: newProjectFieldUuid(),
|
|
305
|
+
ref,
|
|
306
|
+
source,
|
|
307
|
+
project: {
|
|
308
|
+
label: meta.label,
|
|
309
|
+
source: source.projectId,
|
|
310
|
+
updatedAt: sharedAt,
|
|
311
|
+
...(description === undefined ? {} : { description }),
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return snapshots;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Creates the envelope with its immutable `data`, attaches each snapshot as a `project/{uuid}`
|
|
320
|
+
* input field, and hangs the envelope on the donor's outbox under its shareId.
|
|
321
|
+
*
|
|
322
|
+
* A payload that carries project snapshots has its input set sealed one-way, even when the
|
|
323
|
+
* subtree it describes holds none, because such an envelope is granted writable. A template
|
|
324
|
+
* payload has no input set to seal. The outbox attach happens in the same transaction, so the
|
|
325
|
+
* held-resource rule keeps the ephemeral envelope alive.
|
|
326
|
+
*/
|
|
327
|
+
function sealEnvelope(
|
|
328
|
+
tx: PlTransaction,
|
|
329
|
+
outboxRid: SignedResourceId,
|
|
330
|
+
data: EnvelopeData,
|
|
331
|
+
snapshots: readonly { uuid: ProjectFieldUuid; ref: ResourceRef }[],
|
|
332
|
+
): ResourceRef {
|
|
333
|
+
const envelope = tx.createEphemeral(SharedEnvelopeResourceType, JSON.stringify(data));
|
|
334
|
+
|
|
335
|
+
for (const { uuid, ref } of snapshots)
|
|
336
|
+
tx.createField(field(envelope, envelopeProjectField(uuid)), "Input", ref);
|
|
337
|
+
if (data.payload.kind !== "template") tx.lockInputs(envelope);
|
|
338
|
+
|
|
339
|
+
tx.createField(field(outboxRid, data.shareId), "Dynamic", envelope);
|
|
340
|
+
return envelope;
|
|
268
341
|
}
|
package/src/mutator/template.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
AnyResourceRef,
|
|
3
|
+
PlTransaction,
|
|
4
|
+
ResourceRef,
|
|
5
|
+
SignedResourceId,
|
|
6
|
+
} from "@milaboratories/pl-client";
|
|
7
|
+
import { field } from "@milaboratories/pl-client";
|
|
8
|
+
import { normalizeDescription } from "@milaboratories/pl-model-middle-layer";
|
|
3
9
|
import { randomUUID } from "node:crypto";
|
|
4
10
|
import type { StoredTemplateData, TemplateId } from "../middle_layer/template_list";
|
|
5
11
|
import {
|
|
6
12
|
TemplateCreatedTimestamp,
|
|
13
|
+
TemplateDescriptionKey,
|
|
7
14
|
TemplateLabelKey,
|
|
8
15
|
TemplateResourceType,
|
|
9
16
|
} from "../middle_layer/template_list";
|
|
17
|
+
import { listedById, notListedError } from "./list";
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* Creates one `UserTemplate` inside the given write transaction and attaches it to the
|
|
@@ -15,21 +23,26 @@ import {
|
|
|
15
23
|
* Create and attach are the same transaction on purpose: an ephemeral resource nothing
|
|
16
24
|
* holds is collectable, so the list field is what keeps the template alive.
|
|
17
25
|
*
|
|
18
|
-
* The document rides in the immutable `data` blob, set once here and never altered
|
|
19
|
-
* the
|
|
26
|
+
* The document rides in the immutable `data` blob, set once here and never altered. The label,
|
|
27
|
+
* the creation timestamp and the description live in KV beside it; the label and the description
|
|
28
|
+
* may be written again later, the timestamp never is. A blank description stores nothing, the
|
|
29
|
+
* same as {@link setTemplateDescription} does.
|
|
20
30
|
*
|
|
21
31
|
* @returns the new template resource; the caller reads its `globalId` after the commit.
|
|
22
32
|
*/
|
|
23
33
|
export function createTemplate(
|
|
24
34
|
tx: PlTransaction,
|
|
25
35
|
listRid: SignedResourceId,
|
|
26
|
-
label: string,
|
|
36
|
+
meta: { readonly label: string; readonly description?: string },
|
|
27
37
|
data: StoredTemplateData,
|
|
28
38
|
): ResourceRef {
|
|
29
39
|
const tpl = tx.createEphemeral(TemplateResourceType, JSON.stringify(data));
|
|
30
40
|
tx.lock(tpl);
|
|
31
|
-
tx.setKValue(tpl, TemplateLabelKey, JSON.stringify(label));
|
|
41
|
+
tx.setKValue(tpl, TemplateLabelKey, JSON.stringify(meta.label));
|
|
32
42
|
tx.setKValue(tpl, TemplateCreatedTimestamp, String(Date.now()));
|
|
43
|
+
const description = normalizeDescription(meta.description);
|
|
44
|
+
if (description !== undefined)
|
|
45
|
+
tx.setKValue(tpl, TemplateDescriptionKey, JSON.stringify(description));
|
|
33
46
|
tx.createField(field(listRid, randomUUID()), "Dynamic", tpl);
|
|
34
47
|
return tpl;
|
|
35
48
|
}
|
|
@@ -40,36 +53,31 @@ export function renameTemplate(tx: PlTransaction, rid: SignedResourceId, label:
|
|
|
40
53
|
tx.setKValue(rid, TemplateLabelKey, JSON.stringify(label));
|
|
41
54
|
}
|
|
42
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Sets what a stored template says about itself. Blank removes the entry rather than storing an
|
|
58
|
+
* empty one, so a template that was never described and one whose description was cleared are
|
|
59
|
+
* stored the same way.
|
|
60
|
+
*/
|
|
61
|
+
export function setTemplateDescription(
|
|
62
|
+
tx: PlTransaction,
|
|
63
|
+
rid: AnyResourceRef,
|
|
64
|
+
description: string,
|
|
65
|
+
): void {
|
|
66
|
+
const wanted = normalizeDescription(description);
|
|
67
|
+
if (wanted === undefined) tx.deleteKValue(rid, TemplateDescriptionKey);
|
|
68
|
+
else tx.setKValue(rid, TemplateDescriptionKey, JSON.stringify(wanted));
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
/**
|
|
44
72
|
* Detaches a template from the templates list, which is what destroys it — the list field is
|
|
45
73
|
* the only thing holding the ephemeral resource.
|
|
46
|
-
*
|
|
47
|
-
* The field name is a uuid unrelated to the template id, so the field carrying the template
|
|
48
|
-
* is found by value, the same way a project is removed from the project list.
|
|
49
74
|
*/
|
|
50
75
|
export async function deleteTemplate(
|
|
51
76
|
tx: PlTransaction,
|
|
52
77
|
listRid: SignedResourceId,
|
|
53
78
|
id: TemplateId,
|
|
54
79
|
): Promise<void> {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
tx.removeField(field(listRid, fieldName));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
// Internals
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
async function findTemplateField(
|
|
65
|
-
tx: PlTransaction,
|
|
66
|
-
listRid: SignedResourceId,
|
|
67
|
-
id: TemplateId,
|
|
68
|
-
): Promise<string | undefined> {
|
|
69
|
-
const data = await tx.getResourceData(listRid, true);
|
|
70
|
-
for (const f of data.fields) {
|
|
71
|
-
if (isNullSignedResourceId(f.value)) continue;
|
|
72
|
-
if (resourceIdToString(f.value) === (id as string)) return f.name;
|
|
73
|
-
}
|
|
74
|
-
return undefined;
|
|
80
|
+
const entry = (await listedById(tx, listRid)).get(id);
|
|
81
|
+
if (entry === undefined) throw notListedError("Template", id);
|
|
82
|
+
tx.removeField(field(listRid, entry.fieldName));
|
|
75
83
|
}
|
package/src/test/with_ml.ts
CHANGED
|
@@ -2,6 +2,7 @@ import path from "path";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import type { PlClient } from "@milaboratories/pl-client";
|
|
4
4
|
import { TestHelpers } from "@milaboratories/pl-client";
|
|
5
|
+
import { afterAll } from "vitest";
|
|
5
6
|
import { MiddleLayer } from "../middle_layer/middle_layer";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -14,25 +15,81 @@ import { MiddleLayer } from "../middle_layer/middle_layer";
|
|
|
14
15
|
export async function withMl(
|
|
15
16
|
cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
|
|
16
17
|
): Promise<void> {
|
|
17
|
-
const workFolder = path.resolve(`work/${randomUUID()}`);
|
|
18
|
-
|
|
19
18
|
await TestHelpers.withTempRoot(async (pl: PlClient) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
19
|
+
await runMl(pl, cb);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A {@link withMl} for one test file whose temporary roots all outlive their tests and are deleted
|
|
25
|
+
* together once the file is done. Call it at the top of the file; each test still gets a root of
|
|
26
|
+
* its own.
|
|
27
|
+
*
|
|
28
|
+
* Deleting a root deletes every project in it, and the backend then cleans up each of their
|
|
29
|
+
* templates one transaction at a time. That cleanup conflicts with any project being created
|
|
30
|
+
* meanwhile, anywhere, so a file that deleted its roots test by test kept failing its own next
|
|
31
|
+
* test's writes. Deferred, the cleanup happens once, after the file's last test.
|
|
32
|
+
*/
|
|
33
|
+
export function withMlKeepingRoots(): (
|
|
34
|
+
cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
|
|
35
|
+
) => Promise<void> {
|
|
36
|
+
const roots: string[] = [];
|
|
37
|
+
|
|
38
|
+
afterAll(async () => {
|
|
39
|
+
const owner = await TestHelpers.getTestClient();
|
|
32
40
|
try {
|
|
33
|
-
|
|
41
|
+
for (const root of roots) await owner.deleteAlternativeRoot(root);
|
|
34
42
|
} finally {
|
|
35
|
-
await
|
|
43
|
+
await owner.close();
|
|
36
44
|
}
|
|
37
45
|
});
|
|
46
|
+
|
|
47
|
+
return async (cb) => {
|
|
48
|
+
const root = `test_${Date.now()}_${randomUUID()}`;
|
|
49
|
+
roots.push(root);
|
|
50
|
+
await runMl(await TestHelpers.getTestClient(root), cb);
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A live {@link MiddleLayer} over the test user's own root, on a client of its own, closed again
|
|
56
|
+
* when the body returns — what a test restarting the middle layer opens once per run.
|
|
57
|
+
*
|
|
58
|
+
* The user's own root rather than a temporary one: a client asking for a temporary root by name
|
|
59
|
+
* gets a fresh, empty one every time, so a second run would start from nothing. What a test
|
|
60
|
+
* leaves in this root outlives it, so the test cleans up after itself. The work folder is fresh on
|
|
61
|
+
* every call, so nothing local carries over from one run to the next.
|
|
62
|
+
*/
|
|
63
|
+
export async function withMlOnUserRoot(
|
|
64
|
+
cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
await runMl(await TestHelpers.getTestClient(), cb);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
//
|
|
70
|
+
// Internals
|
|
71
|
+
//
|
|
72
|
+
|
|
73
|
+
async function runMl(
|
|
74
|
+
pl: PlClient,
|
|
75
|
+
cb: (ml: MiddleLayer, workFolder: string) => Promise<void>,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const workFolder = path.resolve(`work/${randomUUID()}`);
|
|
78
|
+
const ml = await MiddleLayer.init(pl, workFolder, {
|
|
79
|
+
defaultTreeOptions: { pollingInterval: 250, stopPollingDelay: 500 },
|
|
80
|
+
devBlockUpdateRecheckInterval: 300,
|
|
81
|
+
localSecret: MiddleLayer.generateLocalSecret(),
|
|
82
|
+
localProjections: [],
|
|
83
|
+
openFileDialogCallback: () => {
|
|
84
|
+
throw new Error("Not implemented.");
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
ml.addRuntimeCapability("requiresUIAPIVersion", 1);
|
|
88
|
+
ml.addRuntimeCapability("requiresUIAPIVersion", 2);
|
|
89
|
+
ml.addRuntimeCapability("requiresUIAPIVersion", 3);
|
|
90
|
+
try {
|
|
91
|
+
await cb(ml, workFolder);
|
|
92
|
+
} finally {
|
|
93
|
+
await ml.close();
|
|
94
|
+
}
|
|
38
95
|
}
|