@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,10 +1,16 @@
1
1
  import type { ResourceType, Role } from "@milaboratories/pl-client";
2
2
  import { Role as RoleEnum } from "@milaboratories/pl-client";
3
- import type { Branded, ProjectId, ProjectTemplateV1 } from "@milaboratories/pl-model-common";
3
+ import type {
4
+ Branded,
5
+ ProjectId,
6
+ ProjectTemplateV1,
7
+ TemplateId,
8
+ } from "@milaboratories/pl-model-common";
9
+ import type { FolderId } from "@milaboratories/pl-model-middle-layer";
4
10
  import { randomUUID } from "node:crypto";
5
11
 
6
12
  /**
7
- * Logical identity of a share, stable across replaces. A donor-generated UUID string,
13
+ * Identity of one share. A donor-generated UUID string,
8
14
  * branded so it cannot be silently confused with a project id, a login, or a raw field
9
15
  * name. Minted once with {@link newShareId}; every other site receives it (from decoded
10
16
  * {@link EnvelopeData} or by parsing a `decision/{shareId}` field name) and threads it
@@ -32,7 +38,7 @@ export function asShareId(id: string): ShareId {
32
38
 
33
39
  /** Field on the donor's clientRoot holding the {@link SharingOutboxResourceType} resource. */
34
40
  export const SharingOutboxField = "sharingOutbox";
35
- /** Field on the acceptor's clientRoot holding the {@link SharingStateResourceType} resource. */
41
+ /** Field on the recipient's clientRoot holding the {@link SharingStateResourceType} resource. */
36
42
  export const SharingStateField = "sharingState";
37
43
 
38
44
  export const SharingOutboxResourceType: ResourceType = { name: "SharingOutbox", version: "1" };
@@ -41,14 +47,15 @@ export const SharingStateResourceType: ResourceType = { name: "SharingState", ve
41
47
 
42
48
  export type EnvelopeMode = "copy" | "read-only" | "collaboration";
43
49
 
44
- /** Per-project decision on change, matching the UI labels: re-snapshot the live source ("update"),
45
- * carry the existing snapshot ("keep"), or drop the project from the pack ("remove"). */
46
- export type ProjectChangeAction = "keep" | "update" | "remove";
47
-
48
50
  /** Key of the per-project envelope maps: a uuid minted per snapshot to name the `project/{uuid}`
49
51
  * field. Distinct from {@link ProjectId} — re-snapshotting one source yields a new uuid each time. */
50
52
  export type ProjectFieldUuid = Branded<string, "ProjectFieldUuid">;
51
53
 
54
+ /** Mints a fresh {@link ProjectFieldUuid} for one snapshot. */
55
+ export function newProjectFieldUuid(): ProjectFieldUuid {
56
+ return randomUUID() as ProjectFieldUuid;
57
+ }
58
+
52
59
  /**
53
60
  * Whether a role may make a resource public (grant to everyone): true for controller,
54
61
  * admin; false for workflow and unspecified. The middle layer carries no policy
@@ -87,9 +94,50 @@ export function canImpersonate(role: Role | null): boolean {
87
94
  /** One project's snapshot inside an envelope, keyed by {@link ProjectFieldUuid} in a
88
95
  * `projects` {@link EnvelopePayload}. */
89
96
  export interface EnvelopeProject {
90
- label: string; // carried so the pending-share UI renders without traversing into the project
91
- source: ProjectId; // donor's source projectId; supersedes a prior share and matches the snapshot to its live source on change
97
+ label: string; // carried so the share lists render without traversing into the project
98
+ source: ProjectId; // donor's source projectId; what a prior share of the same project is matched on
92
99
  updatedAt: number; // ms epoch of the last (re)snapshot
100
+ /** What the project said about itself when it was snapshotted, carried for the same reason as
101
+ * `label`. Absent when it had none, and on envelopes written before it was carried. */
102
+ description?: string;
103
+ }
104
+
105
+ /**
106
+ * Identifier of a folder inside one envelope.
107
+ *
108
+ * Local to the envelope, because the recipient's own folder document mints its own ids and the
109
+ * donor's mean nothing there. What travels is the shape of the subtree, not its identity.
110
+ */
111
+ export type EnvelopeFolderId = Branded<string, "EnvelopeFolderId">;
112
+
113
+ /** Mints a fresh {@link EnvelopeFolderId} for one folder of the envelope being built. */
114
+ export function newEnvelopeFolderId(): EnvelopeFolderId {
115
+ return randomUUID() as EnvelopeFolderId;
116
+ }
117
+
118
+ /** One folder of a shared subtree. */
119
+ export interface EnvelopeFolder {
120
+ name: string;
121
+ /** Absent for the subtree's root — the folder that was shared. */
122
+ parent?: EnvelopeFolderId;
123
+ /** What the donor wrote about the folder. Absent when there is none. */
124
+ description?: string;
125
+ }
126
+
127
+ /** A project of a shared subtree: an {@link EnvelopeProject} placed in the subtree. */
128
+ export interface EnvelopeFolderProject extends EnvelopeProject {
129
+ folder: EnvelopeFolderId;
130
+ }
131
+
132
+ /** A template of a shared subtree. The document rides here whole, exactly as a `template`
133
+ * payload carries it — a template is never snapshotted. */
134
+ export interface EnvelopeFolderTemplate {
135
+ document: ProjectTemplateV1;
136
+ /** Label to give the template among the recipient's templates. */
137
+ label: string;
138
+ /** What the template says about itself. Absent when the donor described it with nothing. */
139
+ description?: string;
140
+ folder: EnvelopeFolderId;
93
141
  }
94
142
 
95
143
  /**
@@ -105,9 +153,31 @@ export type EnvelopePayload =
105
153
  | {
106
154
  kind: "template";
107
155
  document: ProjectTemplateV1;
108
- /** Label to give the template on the recipient's own shelf. */
156
+ /** Donor's own id of the shared template; what a prior share of the same template is
157
+ * matched on. It names nothing in the recipient's tree, and envelopes written before it
158
+ * existed carry none — those match no later share of anything. */
159
+ source?: TemplateId;
160
+ /** Label to give the template among the recipient's templates. */
109
161
  label: string;
110
- /** Donor login, kept on the accepted template as its provenance. */
162
+ /** What the template says about itself, carried to the recipient's copy. Absent when the
163
+ * donor described it with nothing. */
164
+ description?: string;
165
+ /** Donor login, kept on each copy of the template as its provenance. */
166
+ from: string;
167
+ }
168
+ | {
169
+ kind: "folder";
170
+ /** Donor's own id of the shared folder; what a prior share of the same folder is matched
171
+ * on. It names nothing in the recipient's tree. */
172
+ source: FolderId;
173
+ /** The shared subtree. Exactly one folder has no parent, and that one is its root. */
174
+ folders: Record<EnvelopeFolderId, EnvelopeFolder>;
175
+ /** Project snapshots, each tagged with the folder of the subtree holding it. Their
176
+ * `project/{uuid}` fields are the same ones a `projects` payload describes. */
177
+ projects: Record<ProjectFieldUuid, EnvelopeFolderProject>;
178
+ /** Templates of the subtree, documents and all. Nothing of a template is snapshotted. */
179
+ templates: EnvelopeFolderTemplate[];
180
+ /** Donor login, kept on each copied template as its provenance. */
111
181
  from: string;
112
182
  };
113
183
 
@@ -130,10 +200,10 @@ export const EnvelopeSchemaVersionCurrent = 2 satisfies EnvelopeSchemaVersion;
130
200
  */
131
201
  export interface EnvelopeData {
132
202
  schemaVersion: typeof EnvelopeSchemaVersionCurrent;
133
- shareId: ShareId; // donor-generated UUID; logical share identity, stable across changes
134
- sharedAt: number; // ms epoch; this instance's creation time — distinguishes instances of one shareId
203
+ shareId: ShareId; // donor-generated UUID; identity of this share alone
204
+ sharedAt: number; // ms epoch; when the share was created
135
205
  expiresAt: number | null; // ms epoch; sharedAt + ttl (default 14 days) for a targeted share; null for share-with-everybody (never expires)
136
- mode: EnvelopeMode; // what the acceptor's app should do with the contents
206
+ mode: EnvelopeMode; // what the recipient's app should do with the contents
137
207
  sender: string; // donor login (informational; backend granted_by is authoritative)
138
208
  title: string; // display name shown to recipients; defaults to the first project's name
139
209
  payload: EnvelopePayload; // what the share carries
@@ -145,31 +215,44 @@ export function envelopeProjectMap(data: EnvelopeData): Record<ProjectFieldUuid,
145
215
  return data.payload.kind === "projects" ? data.payload.projects : {};
146
216
  }
147
217
 
148
- /** Dynamic field on SharingState, one per handled share, keyed by shareId. */
149
- export const decisionField = (shareId: ShareId) => `decision/${shareId}`;
150
-
151
- export interface SharingDecision {
152
- decision: "accepted" | "rejected";
153
- timestamp: number; // ms epoch — when the acceptor acted
154
- envelopeSharedAt: number; // the acted-on envelope instance's sharedAt — pins which instance was handled (paired with the shareId key; the resource id is never stored)
155
- acceptedProjects: string[]; // ids of the projects created in the acceptor's list ([] for a rejected share, and for a template share, which creates none)
218
+ /**
219
+ * The shared folder itself: the one folder of the subtree that has no parent.
220
+ *
221
+ * Derived rather than stored, so it cannot disagree with the folders beside it. `undefined` for
222
+ * a subtree with no root or more than one, which is an envelope nothing can be reconstructed
223
+ * from — the copy reports it rather than guessing which folder was meant.
224
+ */
225
+ export function envelopeFolderRoot(
226
+ folders: Record<EnvelopeFolderId, EnvelopeFolder>,
227
+ ): EnvelopeFolderId | undefined {
228
+ const roots = (Object.keys(folders) as EnvelopeFolderId[]).filter(
229
+ (id) => folders[id].parent === undefined,
230
+ );
231
+ return roots.length === 1 ? roots[0] : undefined;
156
232
  }
157
233
 
158
- /** Dynamic field on SharedEnvelope, one per recipient who accepted or rejected, keyed
159
- * by recipient login. Written by the acceptor in read-write shares only (Copy & Share,
160
- * Live collaboration) — the acceptor's writable envelope grant is what permits the
161
- * write; read-only shares omit it. The donor reads these from its own outbox to see
162
- * who responded and when. Informational, not authoritative (a writable grant holder
163
- * could write under another login — same trust assumption as the sender field).
164
- * Copied forward when a share is changed. */
165
- export const AcceptanceFieldPrefix = "acceptance/";
166
- export const acceptanceField = (login: string) => `${AcceptanceFieldPrefix}${login}`;
167
- export const isAcceptanceField = (name: string) => name.startsWith(AcceptanceFieldPrefix);
168
- export const acceptanceFieldLogin = (name: string) => name.slice(AcceptanceFieldPrefix.length);
169
-
170
- export interface EnvelopeAcceptance {
171
- action: "accepted" | "rejected";
172
- timestamp: number; // ms since epoch
234
+ /**
235
+ * Dynamic field on SharingState, one per share this user has hidden, keyed by shareId.
236
+ *
237
+ * Hiding is private to the recipient and reversible: the field is written to put a share out of
238
+ * sight and removed to bring it back. It says nothing to the donor and nothing about whether
239
+ * anything was ever copied out of the share — a share can be copied from any number of times,
240
+ * before or after being hidden.
241
+ *
242
+ * The field name keeps its original `decision/` prefix. Records written before hiding replaced
243
+ * accept/reject carry a different value under the same key, and every reader treats the presence
244
+ * of the field as the whole answer: someone who accepted or rejected a share back then does not
245
+ * want to see it, which is exactly what hidden means.
246
+ */
247
+ export const HiddenFieldPrefix = "decision/";
248
+ export const hiddenField = (shareId: ShareId) => `${HiddenFieldPrefix}${shareId}`;
249
+ export const isHiddenField = (name: string) => name.startsWith(HiddenFieldPrefix);
250
+ export const hiddenFieldShareId = (name: string): ShareId =>
251
+ asShareId(name.slice(HiddenFieldPrefix.length));
252
+
253
+ export interface ShareHidden {
254
+ hidden: true;
255
+ timestamp: number; // ms epoch — when the recipient hid it
173
256
  }
174
257
 
175
258
  /**
@@ -218,48 +301,52 @@ export function normalizeEnvelopeData(raw: unknown): EnvelopeData | undefined {
218
301
  }
219
302
 
220
303
  /**
221
- * Options for {@link MiddleLayer.shareProjects}.
304
+ * Who a share is granted to: named recipients XOR everyone — two clean variants, not one struct
305
+ * with mutually exclusive optional fields.
222
306
  *
223
- * Recipients XOR everyone — two clean variants, not one struct with mutually exclusive
224
- * optional fields. The everyone variant issues a single make-public grant (the envelope's
225
- * `expiresAt` is set to `null`, so it never expires); the recipients variant grants each
226
- * named recipient and the envelope expires after the default TTL.
307
+ * The everyone variant issues a single make-public grant, and the envelope's `expiresAt` is `null`,
308
+ * so it never expires. The recipients variant grants each named login, and the envelope expires
309
+ * after the default TTL.
227
310
  */
228
- export type ShareProjectsOptions =
229
- | {
230
- recipients: string[]; // recipient logins
231
- title: string; // display name shown to recipients; defaults to the first project's name
232
- mode: EnvelopeMode; // v1 UI always sends "copy"
233
- }
234
- | {
235
- everyone: true; // share with all users on the server
236
- /**
237
- * When true and an everyone-share of the same project already exists, refresh it under its
238
- * stable shareId (recipients who already accepted or rejected are not re-prompted) instead of
239
- * minting a new share. No-op when no prior everyone-share of the project exists. Callers that
240
- * don't care pass `false`.
241
- */
242
- replace: boolean;
243
- title: string;
244
- mode: EnvelopeMode;
245
- };
311
+ export type ShareAudience =
312
+ | { recipients: string[] } // recipient logins
313
+ | { everyone: true }; // every user on the server
246
314
 
247
315
  /**
248
- * Options for {@link MiddleLayer.shareTemplate}.
316
+ * Options every share takes: the audience, the title recipients see it under, and the prior
317
+ * shares it replaces.
318
+ */
319
+ export type ShareOptions = ShareAudience & {
320
+ title: string;
321
+ replace?: ShareReplaceOption;
322
+ };
323
+
324
+ /**
325
+ * Prior shares the new one supersedes: each is deleted in the same transaction that creates the
326
+ * replacement, so the outbox never holds both.
327
+ *
328
+ * The set is the caller's, never inferred here. The author is shown the shares that will go and
329
+ * agrees to that list, so what was shown has to be what is deleted — an overlap rule computed on
330
+ * this side would diverge from it. Ids that no longer resolve are skipped: a share revoked between
331
+ * the dialog opening and the confirm is nothing to undo.
249
332
  *
250
- * Recipients XOR everyone, exactly as {@link ShareProjectsOptions}, minus the mode: a template
251
- * share is always granted read-only, because the recipient copies no resource out of the
252
- * envelope — the document is in the envelope's own data.
333
+ * The replacement is a new {@link ShareId}: a recipient who had hidden the old share sees the new
334
+ * one, and copies already taken from the old share are untouched.
253
335
  */
254
- export type ShareTemplateOptions =
255
- | {
256
- recipients: string[]; // recipient logins
257
- title: string; // display name shown to recipients; defaults to the template's label
258
- }
259
- | {
260
- everyone: true; // share with all users on the server
261
- title: string;
262
- };
336
+ export type ShareReplaceOption = ShareId[];
337
+
338
+ /** Options for {@link MiddleLayer.shareProjects}: the common ones, plus what the recipient's app
339
+ * does with the projects. */
340
+ export type ShareProjectsOptions = ShareOptions & { mode: EnvelopeMode };
341
+
342
+ /** Options for {@link MiddleLayer.shareTemplate}. */
343
+ export type ShareTemplateOptions = ShareOptions;
344
+
345
+ /** Options for {@link MiddleLayer.shareFolder}. */
346
+ export type ShareFolderOptions = ShareOptions;
347
+
348
+ /** What creating a share hands back: the id of the share just created. */
349
+ export type ShareOutcome = { readonly shareId: ShareId };
263
350
 
264
351
  //
265
352
  // Internals
@@ -271,6 +358,7 @@ export type ShareTemplateOptions =
271
358
  const KnownPayloadKinds: Record<EnvelopePayloadKind, true> = {
272
359
  projects: true,
273
360
  template: true,
361
+ folder: true,
274
362
  };
275
363
 
276
364
  /** Every schema version {@link normalizeEnvelopeData} accepts. Keyed by
@@ -0,0 +1,36 @@
1
+ import type { PlTransaction, SignedResourceId } from "@milaboratories/pl-client";
2
+ import { isNullSignedResourceId, resourceIdToString } from "@milaboratories/pl-client";
3
+
4
+ /** One entry of a list resource: the field holding it and the resource that field points at. */
5
+ export interface ListedEntry {
6
+ readonly fieldName: string;
7
+ readonly rid: SignedResourceId;
8
+ }
9
+
10
+ /** The kinds of list a root keeps, as a not-found message names them. */
11
+ export type ListedKind = "Project" | "Template";
12
+
13
+ /**
14
+ * Every entry of a list resource — the project list or the template list — keyed by the string
15
+ * form of the resource id it points at, which is what a project or template id is.
16
+ *
17
+ * A list field's name is a uuid unrelated to the id of what it holds, so an entry is only ever
18
+ * found by value. Fields that point at nothing yet are left out.
19
+ */
20
+ export async function listedById(
21
+ tx: PlTransaction,
22
+ listRid: SignedResourceId,
23
+ ): Promise<Map<string, ListedEntry>> {
24
+ const data = await tx.getResourceData(listRid, true);
25
+ const entries = new Map<string, ListedEntry>();
26
+ for (const f of data.fields) {
27
+ if (isNullSignedResourceId(f.value)) continue;
28
+ entries.set(resourceIdToString(f.value), { fieldName: f.name, rid: f.value });
29
+ }
30
+ return entries;
31
+ }
32
+
33
+ /** What is thrown for an id the list does not hold, worded the same for every list. */
34
+ export function notListedError(kind: ListedKind, id: string): Error {
35
+ return new Error(`${kind} ${id} not found in the ${kind.toLowerCase()} list.`);
36
+ }
@@ -23,6 +23,8 @@ const BPSpecSumV3: BlockPackSpec = {
23
23
  folder: path.resolve(__dirname, "../../../../../etc/blocks/sum-numbers/block"),
24
24
  };
25
25
 
26
+ // Waits for two blocks to compute on the backend, so it runs as long as the backend is busy with
27
+ // whatever else the suite is running; it gets twice the suite's default.
26
28
  test("v3 blocks: basic test with unified state", async () => {
27
29
  const quickJs = await getQuickJS();
28
30
 
@@ -154,7 +156,7 @@ test("v3 blocks: basic test with unified state", async () => {
154
156
  expect(sum).toBe(21);
155
157
  });
156
158
  });
157
- });
159
+ }, 160_000);
158
160
 
159
161
  test("v3 blocks: prerunArgs skip test", async () => {
160
162
  const quickJs = await getQuickJS();
@@ -63,7 +63,7 @@ import type {
63
63
  BlockSettings,
64
64
  ProjectMeta,
65
65
  } from "@milaboratories/pl-model-middle-layer";
66
- import { InitialBlockSettings } from "@milaboratories/pl-model-middle-layer";
66
+ import { InitialBlockSettings, normalizeProjectMeta } from "@milaboratories/pl-model-middle-layer";
67
67
  import Denque from "denque";
68
68
  import { exportContext, getPreparedExportTemplateEnvelope } from "./context_export";
69
69
  import { loadTemplate } from "./template/template_loading";
@@ -1796,11 +1796,22 @@ export class ProjectMutator {
1796
1796
 
1797
1797
  /** Updates project metadata */
1798
1798
  public setMeta(meta: ProjectMeta): void {
1799
- this.meta = meta;
1799
+ this.meta = normalizeProjectMeta(meta);
1800
1800
  this.metaChanged = true;
1801
1801
  this.updateLastModified();
1802
1802
  }
1803
1803
 
1804
+ /**
1805
+ * Updates the metadata fields the caller names, leaving the rest as they are.
1806
+ *
1807
+ * The merge happens against the metadata this mutator loaded inside the very transaction it
1808
+ * writes in, so a rename and a description edit racing each other cannot make one of them
1809
+ * revert the other's field.
1810
+ */
1811
+ public updateMeta(patch: Partial<ProjectMeta>): void {
1812
+ this.setMeta({ ...this.meta, ...patch });
1813
+ }
1814
+
1804
1815
  //
1805
1816
  // Maintenance
1806
1817
  //