@milaboratories/pl-middle-layer 1.73.10 → 1.74.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milaboratories/pl-middle-layer",
3
- "version": "1.73.10",
3
+ "version": "1.74.1",
4
4
  "description": "Pl Middle Layer",
5
5
  "keywords": [],
6
6
  "license": "UNLICENSED",
@@ -30,25 +30,25 @@
30
30
  "utility-types": "^3.11.0",
31
31
  "yaml": "^2.8.0",
32
32
  "zod": "~3.25.76",
33
- "@milaboratories/columns-collection-driver": "0.2.8",
34
- "@milaboratories/pf-driver": "1.9.7",
35
- "@milaboratories/helpers": "1.14.5",
36
- "@milaboratories/pf-spec-driver": "1.5.7",
37
- "@milaboratories/pl-crash-recorder": "0.3.4",
33
+ "@milaboratories/pf-driver": "1.9.8",
34
+ "@milaboratories/columns-collection-driver": "0.2.9",
38
35
  "@milaboratories/computable": "2.9.8",
39
- "@milaboratories/pl-client": "3.18.1",
40
- "@milaboratories/pl-drivers": "1.16.32",
41
- "@milaboratories/pl-deployments": "3.0.20",
42
- "@milaboratories/pl-model-backend": "1.4.35",
43
- "@milaboratories/pl-errors": "1.4.50",
36
+ "@milaboratories/pl-crash-recorder": "0.3.5",
37
+ "@milaboratories/helpers": "1.14.5",
38
+ "@milaboratories/pl-deployments": "3.0.21",
39
+ "@milaboratories/pl-client": "3.18.2",
40
+ "@milaboratories/pf-spec-driver": "1.5.8",
41
+ "@milaboratories/pl-drivers": "1.16.33",
42
+ "@milaboratories/pl-errors": "1.4.51",
43
+ "@milaboratories/pl-model-backend": "1.4.36",
44
44
  "@milaboratories/pl-http": "1.2.4",
45
- "@milaboratories/pl-model-common": "1.52.0",
46
- "@milaboratories/pl-model-middle-layer": "1.34.1",
47
- "@milaboratories/pl-tree": "1.16.2",
48
- "@milaboratories/ts-helpers": "1.8.6",
45
+ "@milaboratories/pl-model-common": "1.53.0",
46
+ "@milaboratories/pl-model-middle-layer": "1.34.2",
47
+ "@milaboratories/pl-tree": "1.16.3",
49
48
  "@milaboratories/resolve-helper": "1.1.3",
50
- "@platforma-sdk/block-tools": "2.16.11",
51
- "@platforma-sdk/model": "1.84.10",
49
+ "@milaboratories/ts-helpers": "1.8.6",
50
+ "@platforma-sdk/block-tools": "2.16.13",
51
+ "@platforma-sdk/model": "1.84.17",
52
52
  "@platforma-sdk/workflow-tengo": "6.15.0"
53
53
  },
54
54
  "devDependencies": {
@@ -57,9 +57,9 @@
57
57
  "@vitest/coverage-istanbul": "^4.1.3",
58
58
  "typescript": "7.0.2",
59
59
  "vitest": "^4.1.3",
60
+ "@milaboratories/build-configs": "2.0.2",
60
61
  "@milaboratories/ts-builder": "1.7.2",
61
- "@milaboratories/ts-configs": "1.4.0",
62
- "@milaboratories/build-configs": "2.0.2"
62
+ "@milaboratories/ts-configs": "1.4.0"
63
63
  },
64
64
  "engines": {
65
65
  "node": ">=22.19.0"
@@ -76,7 +76,7 @@ import {
76
76
  } from "../mutator/project";
77
77
  import type { ProjectTemplateExportOutcome } from "../model/template_serializer";
78
78
  import type { ProjectTemplateV1 } from "@milaboratories/pl-model-common";
79
- import { asProjectId, asTemplateId } from "@milaboratories/pl-model-common";
79
+ import { asProjectId, asTemplateId, withTemplateMeta } from "@milaboratories/pl-model-common";
80
80
  import { extractConfig, ensureError } from "@platforma-sdk/model";
81
81
  import type { TemplateApplyProblem, TemplateApplyReport } from "../model/template_apply";
82
82
  import { TemplateEntryRejected, kindMismatch } from "../model/template_apply";
@@ -943,6 +943,68 @@ export class MiddleLayer {
943
943
  return { ok: true, templateId };
944
944
  }
945
945
 
946
+ /**
947
+ * Stores a template brought in from outside — a template file the user picked — so it can be
948
+ * seen, applied and shared like one saved from a project.
949
+ *
950
+ * The document's description becomes the template's own, which stays editable; the stored
951
+ * document keeps only the blocks, without the label and description it was written with, so
952
+ * there is one name and one description to edit and nothing to drift.
953
+ *
954
+ * The template lands in `folder`, or at the top level, under `label` made free among the
955
+ * templates already there. A folder deleted meanwhile costs the template its placement, not its
956
+ * existence.
957
+ *
958
+ * @param document the parsed template to store
959
+ * @param options.label the name to store it under — usually the document's own label, which
960
+ * the caller weighs against whatever else it knows, such as the file's name; suffixed when a
961
+ * template there carries it
962
+ * @param options.folder where it lands; the top level when absent
963
+ */
964
+ public async importTemplate(
965
+ document: ProjectTemplateV1,
966
+ options: { label: string; folder?: FolderId },
967
+ ): Promise<TemplateId> {
968
+ const { folder } = options;
969
+ const signedRid = await this.pl.withWriteTx("MLImportTemplate", async (tx) => {
970
+ const tree = await openFoldersTx(tx, this.foldersRids);
971
+ // The name is made free where the template actually lands: the top level when the folder
972
+ // asked for is gone.
973
+ const destination =
974
+ folder !== undefined && tree.view.folders.some((candidate) => candidate.id === folder)
975
+ ? folder
976
+ : undefined;
977
+ const name = foldersUniqueName(
978
+ options.label.trim(),
979
+ tree.namesTakenIn(destination, "template"),
980
+ );
981
+
982
+ const tpl = createTemplate(
983
+ tx,
984
+ this.templateListResourceId,
985
+ { label: name, description: document.description },
986
+ { schemaVersion: 1, document: withTemplateMeta(document, {}) },
987
+ );
988
+
989
+ const created = await tpl.globalId;
990
+ if (destination !== undefined)
991
+ tree.place(
992
+ [{ kind: "template", id: asTemplateId(resourceIdToString(created)) }],
993
+ destination,
994
+ );
995
+ await tx.commit();
996
+ return created;
997
+ });
998
+ await Promise.all([
999
+ this.templateListTree.refreshState(),
1000
+ ...(folder === undefined ? [] : [this.foldersTree.refreshState()]),
1001
+ ]);
1002
+
1003
+ const templateId = asTemplateId(resourceIdToString(signedRid));
1004
+ this.templateIdCache.set(templateId, signedRid);
1005
+ return templateId;
1006
+ }
1007
+
946
1008
  /**
947
1009
  * Changes a template's label. The stored document is immutable and stays untouched —
948
1010
  * improving a template means saving a new one.
@@ -7,7 +7,7 @@ import type {
7
7
  ProjectTemplateV1,
8
8
  ProjectTemplateV1Entry,
9
9
  } from "@milaboratories/pl-model-common";
10
- import { PROJECT_TEMPLATE_SCHEMA_V1 } from "@milaboratories/pl-model-common";
10
+ import { PROJECT_TEMPLATE_SCHEMA_V1, withTemplateMeta } from "@milaboratories/pl-model-common";
11
11
  import type { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
12
12
  import type { BlockPackProvider } from "../model/template_resolve";
13
13
  import type { ShareId } from "../model/sharing_model";
@@ -75,6 +75,50 @@ test("a rename changes the label and leaves the stored document byte-identical",
75
75
  });
76
76
  });
77
77
 
78
+ test("an imported template takes the file's description as its own, under a free name", async () => {
79
+ await withMl(async (ml) => {
80
+ const document = withTemplateMeta(documentOf(entry("a")), {
81
+ label: "Written in the file",
82
+ description: "What it builds",
83
+ });
84
+
85
+ const first = await ml.importTemplate(document, { label: " Imported " });
86
+ const second = await ml.importTemplate(document, { label: "Imported" });
87
+
88
+ // The description moves to the template's own, editable one, and the label is the one asked
89
+ // for: the stored document keeps only the blocks, so there is one name and one description,
90
+ // and nothing for an edit to leave behind.
91
+ expect((await ml.getTemplateData(first)).document).toStrictEqual(documentOf(entry("a")));
92
+
93
+ const list = await awaitTemplateList(ml, (l) => l.length === 2);
94
+ expect(list.find((t) => t.id === first)).toMatchObject({
95
+ label: "Imported",
96
+ description: "What it builds",
97
+ blockCount: 1,
98
+ });
99
+ expect(list.find((t) => t.id === second)?.label).not.toBe("Imported");
100
+ });
101
+ });
102
+
103
+ test("an import into a folder deleted meanwhile lands at the top level, under a name free there", async () => {
104
+ await withMl(async (ml) => {
105
+ const document = documentOf(entry("a"));
106
+ const topLevel = await ml.importTemplate(document, { label: "Imported" });
107
+
108
+ const folder = await ml.createFolder("Gone");
109
+ const planned = await ml.previewFolderDeletion(folder);
110
+ expect(planned.ok).toBe(true);
111
+ if (!planned.ok) return;
112
+ expect((await ml.deleteFolder(folder, planned.removal)).ok).toBe(true);
113
+
114
+ const orphan = await ml.importTemplate(document, { label: "Imported", folder });
115
+
116
+ const list = await awaitTemplateList(ml, (l) => l.length === 2);
117
+ expect(list.find((t) => t.id === topLevel)?.label).toBe("Imported");
118
+ expect(list.find((t) => t.id === orphan)?.label).not.toBe("Imported");
119
+ });
120
+ });
121
+
78
122
  test("an entry nothing can resolve creates no project, and every entry is reported", async () => {
79
123
  await withMl(async (ml) => {
80
124
  const stored = await storeTemplate(ml, "Nothing implements these", {
@@ -252,9 +296,9 @@ type StoredTemplate = { id: TemplateId; rid: SignedResourceId };
252
296
  /**
253
297
  * Stores one template through the mutator, on the same templates list the middle layer reads.
254
298
  *
255
- * The middle layer has no way to store an arbitrary document — it only saves a project — so a
256
- * test that needs a specific document writes it here, exactly as `saveProjectAsTemplate` and
257
- * `copyShare` do.
299
+ * The middle layer's own import stores a document with no provenance, so a test that needs a
300
+ * specific stored template — a source project label, a sender — writes it here, exactly as
301
+ * `saveProjectAsTemplate` and `copyShare` do.
258
302
  */
259
303
  async function storeTemplate(
260
304
  ml: MiddleLayer,