@milaboratories/pl-middle-layer 1.66.19 → 1.67.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.
Files changed (130) hide show
  1. package/dist/block_registry/index.cjs +3 -0
  2. package/dist/block_registry/index.d.ts +2 -1
  3. package/dist/block_registry/index.js +2 -1
  4. package/dist/block_registry/location_provider.cjs +117 -0
  5. package/dist/block_registry/location_provider.cjs.map +1 -0
  6. package/dist/block_registry/location_provider.js +114 -0
  7. package/dist/block_registry/location_provider.js.map +1 -0
  8. package/dist/block_registry/registry.cjs +13 -0
  9. package/dist/block_registry/registry.cjs.map +1 -1
  10. package/dist/block_registry/registry.d.ts +14 -1
  11. package/dist/block_registry/registry.d.ts.map +1 -1
  12. package/dist/block_registry/registry.js +13 -0
  13. package/dist/block_registry/registry.js.map +1 -1
  14. package/dist/block_registry/template_provider.cjs +104 -0
  15. package/dist/block_registry/template_provider.cjs.map +1 -0
  16. package/dist/block_registry/template_provider.d.ts +62 -0
  17. package/dist/block_registry/template_provider.d.ts.map +1 -0
  18. package/dist/block_registry/template_provider.js +103 -0
  19. package/dist/block_registry/template_provider.js.map +1 -0
  20. package/dist/block_registry/watcher.cjs +3 -3
  21. package/dist/block_registry/watcher.js +2 -2
  22. package/dist/dev_env/util.js +2 -2
  23. package/dist/dev_env/util.js.map +1 -1
  24. package/dist/index.cjs +10 -0
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.js +5 -1
  27. package/dist/js_render/computable_context.cjs +1 -1
  28. package/dist/js_render/computable_context.js +1 -1
  29. package/dist/middle_layer/driver_kit.d.ts +1 -1
  30. package/dist/middle_layer/middle_layer.cjs +144 -2
  31. package/dist/middle_layer/middle_layer.cjs.map +1 -1
  32. package/dist/middle_layer/middle_layer.d.ts +67 -1
  33. package/dist/middle_layer/middle_layer.d.ts.map +1 -1
  34. package/dist/middle_layer/middle_layer.js +146 -4
  35. package/dist/middle_layer/middle_layer.js.map +1 -1
  36. package/dist/middle_layer/project.cjs +1 -0
  37. package/dist/middle_layer/project.cjs.map +1 -1
  38. package/dist/middle_layer/project.d.ts +2 -2
  39. package/dist/middle_layer/project.js +4 -4
  40. package/dist/middle_layer/project.js.map +1 -1
  41. package/dist/middle_layer/sharing_list.d.ts +1 -1
  42. package/dist/model/args.cjs +9 -28
  43. package/dist/model/args.cjs.map +1 -1
  44. package/dist/model/args.js +9 -28
  45. package/dist/model/args.js.map +1 -1
  46. package/dist/model/index.cjs +7 -0
  47. package/dist/model/index.d.ts +4 -1
  48. package/dist/model/index.js +4 -1
  49. package/dist/model/project_helper.cjs +111 -0
  50. package/dist/model/project_helper.cjs.map +1 -1
  51. package/dist/model/project_helper.d.ts +76 -1
  52. package/dist/model/project_helper.d.ts.map +1 -1
  53. package/dist/model/project_helper.js +111 -0
  54. package/dist/model/project_helper.js.map +1 -1
  55. package/dist/model/project_model.d.ts +1 -1
  56. package/dist/model/sharing_model.d.ts +1 -1
  57. package/dist/model/template_apply.cjs +67 -0
  58. package/dist/model/template_apply.cjs.map +1 -0
  59. package/dist/model/template_apply.d.ts +50 -0
  60. package/dist/model/template_apply.d.ts.map +1 -0
  61. package/dist/model/template_apply.js +64 -0
  62. package/dist/model/template_apply.js.map +1 -0
  63. package/dist/model/template_export.cjs +82 -0
  64. package/dist/model/template_export.cjs.map +1 -0
  65. package/dist/model/template_export.d.ts +9 -0
  66. package/dist/model/template_export.d.ts.map +1 -0
  67. package/dist/model/template_export.js +82 -0
  68. package/dist/model/template_export.js.map +1 -0
  69. package/dist/model/template_parser.cjs +74 -0
  70. package/dist/model/template_parser.cjs.map +1 -0
  71. package/dist/model/template_parser.d.ts +43 -0
  72. package/dist/model/template_parser.d.ts.map +1 -0
  73. package/dist/model/template_parser.js +72 -0
  74. package/dist/model/template_parser.js.map +1 -0
  75. package/dist/model/template_resolve.cjs +134 -0
  76. package/dist/model/template_resolve.cjs.map +1 -0
  77. package/dist/model/template_resolve.d.ts +193 -0
  78. package/dist/model/template_resolve.d.ts.map +1 -0
  79. package/dist/model/template_resolve.js +133 -0
  80. package/dist/model/template_resolve.js.map +1 -0
  81. package/dist/model/template_serializer.cjs +162 -0
  82. package/dist/model/template_serializer.cjs.map +1 -0
  83. package/dist/model/template_serializer.d.ts +17 -0
  84. package/dist/model/template_serializer.d.ts.map +1 -0
  85. package/dist/model/template_serializer.js +157 -0
  86. package/dist/model/template_serializer.js.map +1 -0
  87. package/dist/mutator/block-pack/block_pack.cjs +1 -1
  88. package/dist/mutator/block-pack/block_pack.js +1 -1
  89. package/dist/mutator/migration.cjs +1 -1
  90. package/dist/mutator/migration.js +1 -1
  91. package/dist/mutator/project.cjs +35 -5
  92. package/dist/mutator/project.cjs.map +1 -1
  93. package/dist/mutator/project.d.ts.map +1 -1
  94. package/dist/mutator/project.js +35 -5
  95. package/dist/mutator/project.js.map +1 -1
  96. package/dist/mutator/template_construct.cjs +77 -0
  97. package/dist/mutator/template_construct.cjs.map +1 -0
  98. package/dist/mutator/template_construct.js +77 -0
  99. package/dist/mutator/template_construct.js.map +1 -0
  100. package/dist/network_check/template.js +3 -3
  101. package/dist/network_check/template.js.map +1 -1
  102. package/package.json +17 -17
  103. package/src/block_registry/index.ts +1 -0
  104. package/src/block_registry/location_provider.test.ts +236 -0
  105. package/src/block_registry/location_provider.ts +124 -0
  106. package/src/block_registry/registry.ts +22 -0
  107. package/src/block_registry/template_provider.test.ts +275 -0
  108. package/src/block_registry/template_provider.ts +168 -0
  109. package/src/middle_layer/middle_layer.ts +192 -1
  110. package/src/middle_layer/project.ts +1 -1
  111. package/src/model/args.test.ts +26 -1
  112. package/src/model/args.ts +16 -31
  113. package/src/model/index.ts +21 -0
  114. package/src/model/project_helper.ts +238 -0
  115. package/src/model/project_helper_params_init.test.ts +249 -0
  116. package/src/model/template_apply.test.ts +65 -0
  117. package/src/model/template_apply.ts +128 -0
  118. package/src/model/template_export.test.ts +255 -0
  119. package/src/model/template_export.ts +136 -0
  120. package/src/model/template_parser.test.ts +264 -0
  121. package/src/model/template_parser.ts +108 -0
  122. package/src/model/template_resolve.test.ts +397 -0
  123. package/src/model/template_resolve.ts +346 -0
  124. package/src/model/template_serializer.test.ts +398 -0
  125. package/src/model/template_serializer.ts +232 -0
  126. package/src/model/template_serializer_fixtures.test.ts +274 -0
  127. package/src/mutator/project-v3.test.ts +11 -11
  128. package/src/mutator/project.ts +92 -5
  129. package/src/mutator/template_construct.test.ts +456 -0
  130. package/src/mutator/template_construct.ts +150 -0
@@ -0,0 +1,124 @@
1
+ import fsp from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import type { BlockPackLocationReference } from "@milaboratories/pl-model-common";
5
+ import { parseBlockPackLocation } from "@milaboratories/pl-model-common";
6
+ import type { BlockPackSpec } from "@milaboratories/pl-model-middle-layer";
7
+ import type { BlockPackDescriptionAbsolute } from "@platforma-sdk/block-tools";
8
+ import { loadPackDescription, loadPackDescriptionFromManifest } from "@platforma-sdk/block-tools";
9
+ import type { LocationResolution } from "../model/template_resolve";
10
+
11
+ /**
12
+ * Read the block a template entry's `location` points at.
13
+ *
14
+ * The filesystem half of {@link BlockPackProvider}, and the reason a project built from
15
+ * locally developed blocks can be exported and applied without publishing anything. No
16
+ * registry is involved and nothing is searched: the entry named a place.
17
+ *
18
+ * Only `file:` is served. The document's grammar admits any scheme so that a template
19
+ * stays readable by a consumer that can fetch more than this one can, and an unknown
20
+ * scheme is reported rather than treated as a missing block — "I cannot read this" and
21
+ * "there is nothing there" send the reader to different places.
22
+ */
23
+ export async function resolveBlockPackLocation(
24
+ location: BlockPackLocationReference,
25
+ ): Promise<LocationResolution> {
26
+ if (parseBlockPackLocation(location).scheme !== "file") {
27
+ return { ok: false, reason: "unsupported-scheme" };
28
+ }
29
+
30
+ // The value is a URL, never a path: `fileURLToPath` is what turns `%20` back into the
31
+ // space that was in the folder name. Reading `pathname` directly would look right and
32
+ // fail on any path with a space in it.
33
+ const dir = fileURLToPath(location);
34
+
35
+ const found = await readPackAt(dir);
36
+ if (found === undefined) {
37
+ return (await exists(dir))
38
+ ? { ok: false, reason: "not-a-block" }
39
+ : { ok: false, reason: "not-found" };
40
+ }
41
+
42
+ return { ok: true, spec: found.spec, title: found.description.meta.title };
43
+ }
44
+
45
+ /** One readable block pack: which spec addresses it, and what it declares. */
46
+ type FoundPack = {
47
+ readonly spec: BlockPackSpec;
48
+ readonly description: BlockPackDescriptionAbsolute;
49
+ };
50
+
51
+ /**
52
+ * Identify what kind of block pack is at `dir`, by looking rather than by being told.
53
+ *
54
+ * Two layouts can sit behind one `location`, and they anchor differently — a packed
55
+ * block at the folder holding its `manifest.json`, a source block at the folder holding
56
+ * the `package.json` that names its components. The document deliberately records only
57
+ * the URI, so the layout is established here, where the filesystem is: encoding it in
58
+ * the file instead would freeze today's two shapes into the format, and a template
59
+ * written last month would name a layout that has since moved.
60
+ *
61
+ * A packed layout is checked first because it is the more specific one: a facade
62
+ * package's own folder holds `package.json` and, once packed, `block-pack/` beside it
63
+ * — never `manifest.json` directly.
64
+ *
65
+ * When neither matches, the two conventional subfolders are tried. Those are the same
66
+ * names the dev-block scanner probes, so a hand-written entry may name the block's own
67
+ * folder rather than the package inside it. Export never emits these: it writes the
68
+ * anchor the loader accepts, so there is nothing to guess on a round trip.
69
+ */
70
+ async function readPackAt(dir: string): Promise<FoundPack | undefined> {
71
+ const direct = await readPackExactlyAt(dir);
72
+ if (direct !== undefined) return direct;
73
+
74
+ for (const subfolder of ["block", "meta"]) {
75
+ const nested = await readPackExactlyAt(path.join(dir, subfolder));
76
+ if (nested !== undefined) return nested;
77
+ }
78
+
79
+ return undefined;
80
+ }
81
+
82
+ async function readPackExactlyAt(dir: string): Promise<FoundPack | undefined> {
83
+ if (await exists(path.join(dir, "manifest.json"))) {
84
+ const description = await loadPackDescriptionFromManifest(dir);
85
+ return {
86
+ // The pack directory is what the preparer expects to be handed, and it is where
87
+ // the manifest was just read from — not something reconstructed from a parent.
88
+ spec: { type: "from-pack-v2", packUrl: pathToFileURL(dir).href },
89
+ description,
90
+ };
91
+ }
92
+
93
+ if (await hasBlockDescription(path.join(dir, "package.json"))) {
94
+ const description = await loadPackDescription(dir);
95
+ return { spec: { type: "dev-v2", folder: dir }, description };
96
+ }
97
+
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * Whether this `package.json` describes a block at all.
103
+ *
104
+ * Checked before handing the folder to the loader so that an ordinary npm package —
105
+ * anything with a `package.json` and no block description — comes back as "not a
106
+ * block" rather than as a thrown parse failure from inside the loader.
107
+ */
108
+ async function hasBlockDescription(packageJsonPath: string): Promise<boolean> {
109
+ try {
110
+ const parsed: unknown = JSON.parse(await fsp.readFile(packageJsonPath, "utf-8"));
111
+ return typeof parsed === "object" && parsed !== null && "block" in parsed;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ async function exists(target: string): Promise<boolean> {
118
+ try {
119
+ await fsp.stat(target);
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
@@ -14,11 +14,13 @@ import type {
14
14
  BlockPackId,
15
15
  BlockPackListing,
16
16
  BlockPackOverview,
17
+ BlockPackFromRegistryV2,
17
18
  RegistryEntry,
18
19
  RegistryStatus,
19
20
  SingleBlockPackOverview,
20
21
  } from "@milaboratories/pl-model-middle-layer";
21
22
  import { AnyChannel, StableChannel } from "@milaboratories/pl-model-middle-layer";
23
+ import type { BlockKindReference } from "@milaboratories/pl-model-common";
22
24
 
23
25
  async function getFileContent(path: string) {
24
26
  try {
@@ -301,4 +303,24 @@ export class BlockPackRegistry {
301
303
  const reg = this.v2Provider.getRegistry(regSpec.url);
302
304
  return await reg.getSpecificOverview(blockId, channel);
303
305
  }
306
+
307
+ /**
308
+ * Resolve a block-kind reference (`{name}@{selector}`) against a `remote-v2`
309
+ * registry to a concrete `from-registry-v2` block spec. Thin facade cloned
310
+ * from {@link getOverview}: resolve `registryId` → assert `remote-v2` →
311
+ * delegate to the reader's pure-core-backed `resolveKind`. All version-math
312
+ * lives in `@platforma-sdk/block-tools` (no backend involvement).
313
+ */
314
+ public async resolveKind(
315
+ registryId: string,
316
+ ref: BlockKindReference,
317
+ { allowUnstable }: { allowUnstable: boolean },
318
+ ): Promise<BlockPackFromRegistryV2> {
319
+ const regSpec = this.registries.find((reg) => reg.id === registryId)?.spec;
320
+ if (!regSpec) throw new Error(`Registry with id "${registryId}" not found`);
321
+ if (regSpec.type !== "remote-v2")
322
+ throw new Error(`Only "remote-v2" registries support block-kind resolution.`);
323
+ const reg = this.v2Provider.getRegistry(regSpec.url);
324
+ return await reg.resolveKind(ref, { allowUnstable });
325
+ }
304
326
  }
@@ -0,0 +1,275 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { KindResolutionError } from "@platforma-sdk/block-tools";
3
+ import type {
4
+ BlockPackFromRegistryV2,
5
+ BlockPackId,
6
+ RegistryEntry,
7
+ SingleBlockPackOverview,
8
+ } from "@milaboratories/pl-model-middle-layer";
9
+ import type { BlockKindSelectorReference } from "@milaboratories/pl-model-common";
10
+ import type { MiLogger } from "@milaboratories/ts-helpers";
11
+ import type { KindAwareRegistry } from "./template_provider";
12
+ import { kindCapableRegistryIds, templateBlockPackProvider } from "./template_provider";
13
+
14
+ /**
15
+ * The registry-backed provider, driven against a fake registry.
16
+ *
17
+ * Everything specific to this adapter is in what it does with two registry answers:
18
+ * which registries it asks, in what order, which failure it reports when they disagree,
19
+ * and where the block's title comes from. None of that needs a registry to check.
20
+ */
21
+
22
+ const KIND = "@platforma-open/milaboratories.demo.kind@^1.0.0" as BlockKindSelectorReference;
23
+
24
+ const blockId = (name: string, version = "1.2.3"): BlockPackId => ({
25
+ organization: "milaboratories",
26
+ name,
27
+ version,
28
+ });
29
+
30
+ const specIn = (registryUrl: string, id: BlockPackId): BlockPackFromRegistryV2 => ({
31
+ type: "from-registry-v2",
32
+ registryUrl,
33
+ id,
34
+ channel: "stable",
35
+ });
36
+
37
+ const overviewOf = (registryUrl: string, id: BlockPackId, title: string): SingleBlockPackOverview =>
38
+ ({ id, meta: { title }, spec: specIn(registryUrl, id) }) as unknown as SingleBlockPackOverview;
39
+
40
+ const silent: MiLogger = {
41
+ info: () => {},
42
+ warn: () => {},
43
+ error: () => {},
44
+ } as unknown as MiLogger;
45
+
46
+ /**
47
+ * A registry whose per-registry answers are scripted.
48
+ *
49
+ * @param answers Keyed by registry id. A `KindResolutionError` reason stands for "this
50
+ * registry cannot satisfy the kind"; anything else thrown stands for a read failure
51
+ */
52
+ function fakeRegistry(answers: {
53
+ kind?: Record<string, BlockPackId | KindResolutionError | Error>;
54
+ overview?: Record<string, Record<string, string>>;
55
+ }) {
56
+ const kindCalls: { registryId: string; ref: string; allowUnstable: boolean }[] = [];
57
+ const overviewCalls: { registryId: string; id: BlockPackId; channel: string }[] = [];
58
+
59
+ const registry: KindAwareRegistry = {
60
+ resolveKind: (registryId, ref, options) => {
61
+ kindCalls.push({ registryId, ref, allowUnstable: options.allowUnstable });
62
+ const answer = answers.kind?.[registryId];
63
+ if (answer === undefined) {
64
+ return Promise.reject(new KindResolutionError("no-matching-kind-version", ref));
65
+ }
66
+ if (answer instanceof Error) return Promise.reject(answer);
67
+ return Promise.resolve(specIn(`https://${registryId}`, answer));
68
+ },
69
+ getOverview: (registryId, id, channel) => {
70
+ overviewCalls.push({ registryId, id, channel });
71
+ const title = answers.overview?.[registryId]?.[id.name];
72
+ if (title === undefined) return Promise.reject(new Error("no such manifest"));
73
+ return Promise.resolve(overviewOf(`https://${registryId}`, id, title));
74
+ },
75
+ };
76
+
77
+ return { registry, kindCalls, overviewCalls };
78
+ }
79
+
80
+ const providerOver = (registry: KindAwareRegistry, registryIds: string[]) =>
81
+ templateBlockPackProvider({ registry, registryIds, logger: silent });
82
+
83
+ describe("kindCapableRegistryIds", () => {
84
+ const entries: RegistryEntry[] = [
85
+ { id: "main", spec: { type: "remote-v2", url: "https://main" } },
86
+ { id: "local:{/w/blocks}", spec: { type: "local-dev", path: "/w/blocks" } },
87
+ { id: "extra", spec: { type: "remote-v2", url: "https://extra" } },
88
+ ];
89
+
90
+ test("keeps the remote registries, in configured order", () => {
91
+ expect(kindCapableRegistryIds(entries)).toEqual(["main", "extra"]);
92
+ });
93
+
94
+ test("drops a dev registry, which can never answer a kind", () => {
95
+ // Dev packets have no manifest and no kind publication, and every template entry
96
+ // names a kind — so asking is not a fallback, it is a guaranteed failure that would
97
+ // muddy the reported reason.
98
+ expect(kindCapableRegistryIds([entries[1]])).toEqual([]);
99
+ });
100
+ });
101
+
102
+ describe("byKind", () => {
103
+ test("resolves through the registry and reports the block's published title", async () => {
104
+ const { registry, kindCalls, overviewCalls } = fakeRegistry({
105
+ kind: { main: blockId("demo") },
106
+ overview: { main: { demo: "Demo Block" } },
107
+ });
108
+
109
+ const outcome = await providerOver(registry, ["main"]).byKind(KIND, { allowUnstable: false });
110
+
111
+ expect(outcome).toEqual({
112
+ ok: true,
113
+ spec: specIn("https://main", blockId("demo")),
114
+ title: "Demo Block",
115
+ });
116
+ // The projection first, then the manifest of the block it picked.
117
+ expect(kindCalls).toEqual([{ registryId: "main", ref: KIND, allowUnstable: false }]);
118
+ expect(overviewCalls).toEqual([{ registryId: "main", id: blockId("demo"), channel: "stable" }]);
119
+ });
120
+
121
+ test("passes the selector through unchanged", async () => {
122
+ // The facade's parameter is typed as an exact reference, but resolution parses the
123
+ // version segment as a selector — so `^1.0.0` must arrive verbatim, not normalized.
124
+ const { registry, kindCalls } = fakeRegistry({
125
+ kind: { main: blockId("demo") },
126
+ overview: { main: { demo: "Demo Block" } },
127
+ });
128
+
129
+ await providerOver(registry, ["main"]).byKind(KIND, { allowUnstable: true });
130
+
131
+ expect(kindCalls[0].ref).toBe("@platforma-open/milaboratories.demo.kind@^1.0.0");
132
+ expect(kindCalls[0].allowUnstable).toBe(true);
133
+ });
134
+
135
+ test("falls through to the next registry, first hit winning", async () => {
136
+ const { registry, kindCalls } = fakeRegistry({
137
+ kind: { extra: blockId("demo") },
138
+ overview: { extra: { demo: "Demo Block" } },
139
+ });
140
+
141
+ const outcome = await providerOver(registry, ["main", "extra"]).byKind(KIND, {
142
+ allowUnstable: false,
143
+ });
144
+
145
+ expect(outcome.ok).toBe(true);
146
+ expect(kindCalls.map((c) => c.registryId)).toEqual(["main", "extra"]);
147
+ });
148
+
149
+ test("stops at the first registry that answers", async () => {
150
+ const { registry, kindCalls } = fakeRegistry({
151
+ kind: { main: blockId("demo"), extra: blockId("demo") },
152
+ overview: { main: { demo: "From Main" }, extra: { demo: "From Extra" } },
153
+ });
154
+
155
+ const outcome = await providerOver(registry, ["main", "extra"]).byKind(KIND, {
156
+ allowUnstable: false,
157
+ });
158
+
159
+ expect(outcome.ok && outcome.title).toBe("From Main");
160
+ expect(kindCalls.map((c) => c.registryId)).toEqual(["main"]);
161
+ });
162
+
163
+ test("reports the failure that got furthest when registries disagree", async () => {
164
+ // One registry has never heard of the kind, the other has it but only as a
165
+ // pre-release. Reporting the first would tell the reader to check their spelling,
166
+ // when the actual way out is to import again with unstable allowed.
167
+ const { registry } = fakeRegistry({
168
+ kind: {
169
+ main: new KindResolutionError("no-matching-kind-version", KIND),
170
+ extra: new KindResolutionError("no-stable-implementation", KIND),
171
+ },
172
+ });
173
+
174
+ const outcome = await providerOver(registry, ["main", "extra"]).byKind(KIND, {
175
+ allowUnstable: false,
176
+ });
177
+
178
+ expect(outcome).toEqual({ ok: false, reason: "no-stable-implementation" });
179
+ });
180
+
181
+ test("no registry knowing the kind is not-found, not an error", async () => {
182
+ const { registry } = fakeRegistry({});
183
+
184
+ const outcome = await providerOver(registry, ["main", "extra"]).byKind(KIND, {
185
+ allowUnstable: false,
186
+ });
187
+
188
+ expect(outcome).toEqual({ ok: false, reason: "no-matching-kind-version" });
189
+ });
190
+
191
+ test("no registries at all is not-found rather than a crash", async () => {
192
+ // Reachable: an environment configured with only dev registries.
193
+ const { registry, kindCalls } = fakeRegistry({});
194
+
195
+ const outcome = await providerOver(registry, []).byKind(KIND, { allowUnstable: false });
196
+
197
+ expect(outcome).toEqual({ ok: false, reason: "no-matching-kind-version" });
198
+ expect(kindCalls).toEqual([]);
199
+ });
200
+
201
+ test("an unreadable registry propagates instead of reading as not-found", async () => {
202
+ // An outage is not a statement about the file, and reporting it as "no such kind"
203
+ // would send the reader to edit a file that is correct.
204
+ const { registry } = fakeRegistry({ kind: { main: new Error("ECONNREFUSED") } });
205
+
206
+ await expect(
207
+ providerOver(registry, ["main"]).byKind(KIND, { allowUnstable: false }),
208
+ ).rejects.toThrow("ECONNREFUSED");
209
+ });
210
+
211
+ test("a picked block whose manifest cannot be read propagates too", async () => {
212
+ // The kind resolved, so this is not a resolution failure — the registry is
213
+ // inconsistent or unreachable, and either way the file is not at fault.
214
+ const { registry } = fakeRegistry({ kind: { main: blockId("demo") } });
215
+
216
+ await expect(
217
+ providerOver(registry, ["main"]).byKind(KIND, { allowUnstable: false }),
218
+ ).rejects.toThrow("no such manifest");
219
+ });
220
+ });
221
+
222
+ describe("byExactVersion", () => {
223
+ test("reads the pinned version's manifest for both its spec and its title", async () => {
224
+ const { registry, overviewCalls } = fakeRegistry({
225
+ overview: { main: { demo: "Demo Block" } },
226
+ });
227
+
228
+ const outcome = await providerOver(registry, ["main"]).byExactVersion(blockId("demo", "2.0.1"));
229
+
230
+ expect(outcome).toEqual({
231
+ ok: true,
232
+ spec: specIn("https://main", blockId("demo", "2.0.1")),
233
+ title: "Demo Block",
234
+ });
235
+ expect(overviewCalls[0].channel).toBe("stable");
236
+ });
237
+
238
+ test("tries every registry before giving up", async () => {
239
+ const { registry, overviewCalls } = fakeRegistry({ overview: { extra: { demo: "Demo" } } });
240
+
241
+ const outcome = await providerOver(registry, ["main", "extra"]).byExactVersion(blockId("demo"));
242
+
243
+ expect(outcome.ok).toBe(true);
244
+ expect(overviewCalls.map((c) => c.registryId)).toEqual(["main", "extra"]);
245
+ });
246
+
247
+ test("a version no registry has is reported, not thrown", async () => {
248
+ // The entry pinned it, so this is a statement about the file — and resolution turns
249
+ // it into the message offering both ways out.
250
+ const { registry } = fakeRegistry({});
251
+
252
+ expect(await providerOver(registry, ["main"]).byExactVersion(blockId("gone", "9.9.9"))).toEqual(
253
+ { ok: false, reason: "no-such-block-version" },
254
+ );
255
+ });
256
+
257
+ test("every registry that could not answer is logged", async () => {
258
+ // The reader throws the same way for an absent block and an unreadable one, so the
259
+ // fallback cannot tell them apart. The log is the only place the reason survives.
260
+ const lines: string[] = [];
261
+ const logger = { info: (m: string) => lines.push(m), warn: () => {}, error: () => {} };
262
+ const { registry } = fakeRegistry({});
263
+
264
+ await templateBlockPackProvider({
265
+ registry,
266
+ registryIds: ["main", "extra"],
267
+ logger: logger as unknown as MiLogger,
268
+ }).byExactVersion(blockId("gone", "9.9.9"));
269
+
270
+ expect(lines).toHaveLength(2);
271
+ expect(lines[0]).toContain("milaboratories/gone 9.9.9");
272
+ expect(lines[0]).toContain("'main'");
273
+ expect(lines[0]).toContain("no such manifest");
274
+ });
275
+ });
@@ -0,0 +1,168 @@
1
+ import { KindResolutionError } from "@platforma-sdk/block-tools";
2
+ import type {
3
+ BlockPackFromRegistryV2,
4
+ BlockPackId,
5
+ RegistryEntry,
6
+ SingleBlockPackOverview,
7
+ } from "@milaboratories/pl-model-middle-layer";
8
+ import { StableChannel } from "@milaboratories/pl-model-middle-layer";
9
+ import type { BlockKindReference } from "@milaboratories/pl-model-common";
10
+ import type { MiLogger } from "@milaboratories/ts-helpers";
11
+ import { ensureError } from "@platforma-sdk/model";
12
+ import type { BlockPackProvider, ExactResolution, KindResolution } from "../model/template_resolve";
13
+ import { resolveBlockPackLocation } from "./location_provider";
14
+
15
+ /**
16
+ * The part of {@link BlockPackRegistry} a template's provider uses.
17
+ *
18
+ * Structural, so the provider can be exercised without a registry, a network or a
19
+ * configured environment — and so it is obvious that these two calls are the entire
20
+ * dependency.
21
+ */
22
+ export type KindAwareRegistry = {
23
+ resolveKind: (
24
+ registryId: string,
25
+ ref: BlockKindReference,
26
+ options: { allowUnstable: boolean },
27
+ ) => Promise<BlockPackFromRegistryV2>;
28
+ getOverview: (
29
+ registryId: string,
30
+ blockId: BlockPackId,
31
+ channel: string,
32
+ ) => Promise<SingleBlockPackOverview>;
33
+ };
34
+
35
+ /**
36
+ * The ids of the configured registries a template can be resolved against, in the order
37
+ * they should be consulted.
38
+ *
39
+ * Answers the standing "which registry" question, and answers it by capability rather
40
+ * than by preference: kinds are published to the `kinds/` tree of a `remote-v2`
41
+ * registry, so no other registry type can satisfy an entry at all. A `local-dev`
42
+ * registry serves dev packets, which have no manifest and no kind publication — every
43
+ * template entry names a kind, so such a registry can never answer one.
44
+ *
45
+ * Configured order is kept: it is the order the environment chose, and the first
46
+ * registry that has the kind wins. Nothing here dedupes a kind published to two
47
+ * registries — with the search stopping at the first hit, the earlier registry is
48
+ * simply the answer.
49
+ */
50
+ export function kindCapableRegistryIds(entries: readonly RegistryEntry[]): string[] {
51
+ return entries.filter((entry) => entry.spec.type === "remote-v2").map((entry) => entry.id);
52
+ }
53
+
54
+ /**
55
+ * A {@link BlockPackProvider} over the configured block registries.
56
+ *
57
+ * The adapter between the import path's port and what the registry layer already does:
58
+ * `resolveKind` walks a kind's projection and picks an implementing block,
59
+ * `getOverview` reads one block's manifest. Both live in the registry; the port exists
60
+ * so that resolution can be tested without one, and so that which registries to consult
61
+ * stays an environment decision.
62
+ *
63
+ * Both routes also return the block's published title, because both have to read a
64
+ * manifest to produce a spec anyway and nothing downstream can recover it. On the pinned
65
+ * route it is free; on the kind route it costs one manifest read after the projection.
66
+ *
67
+ * @param registryIds Registries to consult, in order — see {@link kindCapableRegistryIds}
68
+ * @param logger Where a registry that could not answer is recorded. A read failure and
69
+ * an absent block are indistinguishable here (the reader throws for both), so the
70
+ * fallback to the next registry has to swallow the reason; this is the only place it
71
+ * survives
72
+ */
73
+ export function templateBlockPackProvider(deps: {
74
+ readonly registry: KindAwareRegistry;
75
+ readonly registryIds: readonly string[];
76
+ readonly logger: MiLogger;
77
+ }): BlockPackProvider {
78
+ const { registry, registryIds, logger } = deps;
79
+
80
+ return {
81
+ byKind: async (kind, options): Promise<KindResolution> => {
82
+ let furthest: KindResolutionFailure | undefined;
83
+
84
+ for (const registryId of registryIds) {
85
+ try {
86
+ // The selector brand is widened here, deliberately and in one place. The
87
+ // facade types this parameter as an exact `{name}@X.Y.Z` reference, but its
88
+ // implementation passes the version segment to the selector parser, so
89
+ // `~`/`^` resolve correctly — the type is narrower than the behaviour. Until
90
+ // the facade takes the selector brand, the cast is the honest way to say so.
91
+ const spec = await registry.resolveKind(
92
+ registryId,
93
+ kind as unknown as BlockKindReference,
94
+ options,
95
+ );
96
+ // Read back the manifest of the block the projection picked, in the channel it
97
+ // was picked from. `channel` is optional on the spec and the resolver always
98
+ // fills it, so the fallback is unreachable — it exists because the channel only
99
+ // decides where future update suggestions come from, and `stable` is the answer
100
+ // that surprises nobody.
101
+ const overview = await registry.getOverview(
102
+ registryId,
103
+ spec.id,
104
+ spec.channel ?? StableChannel,
105
+ );
106
+ return { ok: true, spec, title: overview.meta.title };
107
+ } catch (e) {
108
+ if (!(e instanceof KindResolutionError)) {
109
+ // Not an answer about this kind: an unreadable registry, or a registry that
110
+ // does not serve kinds at all. Falling through would report "no such kind"
111
+ // for what is really an outage, so it propagates instead. (The second case
112
+ // is unreachable through `kindCapableRegistryIds`, which filters by type.)
113
+ throw e;
114
+ }
115
+ furthest = furthestFailure(furthest, e.reason);
116
+ }
117
+ }
118
+
119
+ return { ok: false, reason: furthest ?? "no-matching-kind-version" };
120
+ },
121
+
122
+ // No registry is consulted for a located entry, and none of this adapter's
123
+ // registry knowledge applies to it — which is why it is a straight delegation.
124
+ byLocation: resolveBlockPackLocation,
125
+
126
+ byExactVersion: async (id): Promise<ExactResolution> => {
127
+ for (const registryId of registryIds) {
128
+ try {
129
+ // `stable` is recorded on the spec as the channel to watch for updates in.
130
+ // The entry pinned this exact version, so nothing is being chosen here — the
131
+ // channel only decides where a future update suggestion would come from.
132
+ const overview = await registry.getOverview(registryId, id, StableChannel);
133
+ return { ok: true, spec: overview.spec, title: overview.meta.title };
134
+ } catch (e) {
135
+ logger.info(
136
+ `template apply: block ${id.organization}/${id.name} ${id.version} not read ` +
137
+ `from registry '${registryId}': ${ensureError(e).message}`,
138
+ );
139
+ }
140
+ }
141
+
142
+ return { ok: false, reason: "no-such-block-version" };
143
+ },
144
+ };
145
+ }
146
+
147
+ type KindResolutionFailure = Extract<KindResolution, { ok: false }>["reason"];
148
+
149
+ /**
150
+ * The more informative of two failures across registries.
151
+ *
152
+ * With several registries consulted, the reasons can differ — one has never heard of the
153
+ * kind, another has it but only as a pre-release. Reporting the first would tell the
154
+ * reader to check their spelling when the real answer is "import again with unstable
155
+ * allowed", so the one that got furthest wins.
156
+ */
157
+ function furthestFailure(
158
+ current: KindResolutionFailure | undefined,
159
+ next: KindResolutionFailure,
160
+ ): KindResolutionFailure {
161
+ const rank: Record<KindResolutionFailure, number> = {
162
+ "no-matching-kind-version": 0,
163
+ "no-implementation": 1,
164
+ "no-stable-implementation": 2,
165
+ };
166
+ if (current === undefined) return next;
167
+ return rank[next] > rank[current] ? next : current;
168
+ }