@platforma-sdk/block-tools 2.12.13 → 2.14.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 (69) hide show
  1. package/dist/cli.js +12 -12
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +804 -720
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/cmd/build-kind-manifest.d.ts +7 -0
  6. package/dist/config-CtXMWNgm.js +3 -0
  7. package/dist/config-CtXMWNgm.js.map +1 -0
  8. package/dist/{config-CiC347sB.mjs → config-Dx45aFJ_.mjs} +792 -456
  9. package/dist/config-Dx45aFJ_.mjs.map +1 -0
  10. package/dist/index.js +1 -1
  11. package/dist/index.mjs +15 -15
  12. package/dist/structure/engine/api.d.ts +1 -1
  13. package/dist/structure/rules/kind-package-json.d.ts +3 -0
  14. package/dist/structure/rules/kind.d.ts +1 -0
  15. package/dist/v2/build_kind_dist.d.ts +25 -0
  16. package/dist/v2/index.d.ts +4 -0
  17. package/dist/v2/kind/resolve-refs.d.ts +63 -0
  18. package/dist/v2/kind/version-match.d.ts +23 -0
  19. package/dist/v2/model/block_description.d.ts +8 -1
  20. package/dist/v2/publish-block.d.ts +23 -0
  21. package/dist/v2/registry/index.d.ts +2 -0
  22. package/dist/v2/registry/kind_resolver.d.ts +121 -0
  23. package/dist/v2/registry/registry.d.ts +23 -0
  24. package/dist/v2/registry/registry_reader.d.ts +34 -1
  25. package/dist/v2/registry/schema_kinds.d.ts +632 -0
  26. package/dist/v2/registry/schema_public.d.ts +72 -0
  27. package/package.json +9 -9
  28. package/src/cli.test.ts +2 -1
  29. package/src/cli.ts +2 -0
  30. package/src/cmd/build-kind-manifest.ts +41 -0
  31. package/src/cmd/publish.ts +10 -1
  32. package/src/cmd/software/build.ts +17 -1
  33. package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -0
  34. package/src/structure/engine/api.ts +1 -1
  35. package/src/structure/engine/ctx.ts +1 -0
  36. package/src/structure/engine/discovery.ts +16 -2
  37. package/src/structure/rules/block-package-json.ts +18 -0
  38. package/src/structure/rules/kind-package-json.ts +117 -0
  39. package/src/structure/rules/kind.ts +29 -0
  40. package/src/structure/rules/model-package-json.ts +8 -0
  41. package/src/structure/rules/model.ts +15 -13
  42. package/src/structure/rules/shared/pascal-case.ts +1 -1
  43. package/src/structure/structure-definition.ts +2 -0
  44. package/src/structure/templates/static/kind/.oxfmtrc.json +4 -0
  45. package/src/structure/templates/static/kind/.oxlintrc.json +3 -0
  46. package/src/structure/templates/static/kind/src/index.ts +49 -0
  47. package/src/structure/templates/static/kind/tsconfig.json +10 -0
  48. package/src/structure/templates/text/model/src/index.tpl.ts +18 -0
  49. package/src/v2/build_dist.ts +35 -0
  50. package/src/v2/build_kind_dist.ts +104 -0
  51. package/src/v2/from_pack_v2.test.ts +2 -2
  52. package/src/v2/index.ts +4 -0
  53. package/src/v2/kind/resolve-refs.ts +150 -0
  54. package/src/v2/kind/version-match.test.ts +60 -0
  55. package/src/v2/kind/version-match.ts +55 -0
  56. package/src/v2/model/block_description.ts +15 -4
  57. package/src/v2/publish-block.ts +60 -0
  58. package/src/v2/registry/index.ts +2 -0
  59. package/src/v2/registry/kind_resolver.test.ts +123 -0
  60. package/src/v2/registry/kind_resolver.ts +190 -0
  61. package/src/v2/registry/registry.test.ts +247 -1
  62. package/src/v2/registry/registry.ts +227 -4
  63. package/src/v2/registry/registry_reader.ts +68 -0
  64. package/src/v2/registry/schema_kinds.test.ts +65 -0
  65. package/src/v2/registry/schema_kinds.ts +169 -0
  66. package/dist/config-BmJWc8sR.js +0 -3
  67. package/dist/config-BmJWc8sR.js.map +0 -1
  68. package/dist/config-CiC347sB.mjs.map +0 -1
  69. package/src/structure/templates/static/model/src/index.ts +0 -13
@@ -10,6 +10,7 @@ import {
10
10
  blockPackIdToString,
11
11
  BlockPackManifest,
12
12
  } from "@milaboratories/pl-model-middle-layer";
13
+ import { parseKindRef } from "@milaboratories/pl-model-common";
13
14
  import {
14
15
  GlobalUpdateSeedInFile,
15
16
  GlobalUpdateSeedOutFile,
@@ -37,6 +38,17 @@ import {
37
38
  MainPrefix,
38
39
  PackageManifestPattern,
39
40
  } from "./schema_public";
41
+ import {
42
+ KindsPrefix,
43
+ KindManifest,
44
+ KindManifestFileName,
45
+ KindOverview,
46
+ KindOverviewPathPattern,
47
+ kindContentPrefix,
48
+ kindOverviewPath,
49
+ npmNameToKindPath,
50
+ } from "./schema_kinds";
51
+ import type { KindImplementer, KindVersionOverview } from "./schema_kinds";
40
52
  import type { RelativeContentReader } from "../model";
41
53
  import { addRelativePathPrefix } from "../model";
42
54
  import { randomUUID } from "node:crypto";
@@ -56,6 +68,30 @@ type PackageUpdateInfo = {
56
68
  versions: Set<string>;
57
69
  };
58
70
 
71
+ /**
72
+ * What one kind overview (`kinds/{org}/{name}/overview.json`) will be rewritten to.
73
+ *
74
+ * Filled while the block manifests are already parsed in the main `updateRegistry` pass — no
75
+ * extra reads. Two shapes, because the pass has two modes and they read the existing file
76
+ * differently:
77
+ *
78
+ * - `rebuild: false` — read-modify-write. `replacedBlockIds` names the block-pack ids (with
79
+ * version) whose manifests were re-read this pass; their existing `implementers` entries are
80
+ * dropped so the fresh ones in `add` take their place instead of duplicating them.
81
+ * - `rebuild: true` — force mode. The scan re-enumerates every live kind reference, so the
82
+ * existing content is discarded rather than filtered, and there is nothing to name.
83
+ *
84
+ * A discriminant rather than `Set<string> | null`: the mode is a fact about the pass, and
85
+ * encoding it as an absent collection makes "rebuild everything" and "replace nothing" look
86
+ * the same at the use site.
87
+ */
88
+ type KindOverviewMerge = {
89
+ readonly add: KindImplementer[];
90
+ } & (
91
+ | { readonly rebuild: true }
92
+ | { readonly rebuild: false; readonly replacedBlockIds: Set<string> }
93
+ );
94
+
59
95
  export class BlockRegistryV2 {
60
96
  private readonly gzipAsync = promisify(gzip);
61
97
  private readonly gunzipAsync = promisify(gunzip);
@@ -130,7 +166,28 @@ export class BlockRegistryV2 {
130
166
 
131
167
  // reading update requests
132
168
  const packagesToUpdate = new Map<string, PackageUpdateInfo>();
169
+ // planned kind-overview rewrites, keyed by kinds/{org}/{name}/overview.json
170
+ const kindOverviewMerges = new Map<string, KindOverviewMerge>();
133
171
  const seedPaths: string[] = [];
172
+
173
+ /**
174
+ * Get-or-create the planned rewrite for one kind overview, recording `blockId` as
175
+ * re-read this pass so the RMW below drops its stale entry before merging.
176
+ *
177
+ * In force mode the plan is a rebuild and there is nothing to record — including for the
178
+ * plans the force pre-seed already created, which this must leave as rebuilds.
179
+ */
180
+ const mergeIntoKindOverview = (ovPath: string, blockId: string): KindOverviewMerge => {
181
+ const plan =
182
+ kindOverviewMerges.get(ovPath) ??
183
+ (mode === "force"
184
+ ? { rebuild: true as const, add: [] }
185
+ : { rebuild: false as const, replacedBlockIds: new Set<string>(), add: [] });
186
+ if (!plan.rebuild) plan.replacedBlockIds.add(blockId);
187
+ kindOverviewMerges.set(ovPath, plan);
188
+ return plan;
189
+ };
190
+
134
191
  const rawSeedPaths = await this.storage.listFiles(VersionUpdatesPrefix);
135
192
 
136
193
  const addVersionToBeUpdated = ({ organization, name, version }: BlockPackId) => {
@@ -169,6 +226,16 @@ export class BlockRegistryV2 {
169
226
  const added = addVersionToBeUpdated({ organization, name, version });
170
227
  this.logger.info(` - ${organization}:${name}:${version} force_added:${added}`);
171
228
  }
229
+
230
+ // Seed every existing kind overview empty so kinds orphaned by
231
+ // migration/removal are rewritten (or deleted) this pass. The block scan
232
+ // above already re-enumerates every live kind ref (refs live inside block
233
+ // manifests); this LIST exists solely to reset orphans.
234
+ const kindPaths = await this.storage.listFiles(KindsPrefix);
235
+ for (const rel of kindPaths) {
236
+ if (!KindOverviewPathPattern.test(rel)) continue;
237
+ kindOverviewMerges.set(KindsPrefix + rel, { rebuild: true, add: [] });
238
+ }
172
239
  }
173
240
 
174
241
  // loading global overview
@@ -219,10 +286,19 @@ export class BlockRegistryV2 {
219
286
  );
220
287
 
221
288
  // removing versions that we will update
289
+ const droppedVersions = packageOverview.versions.filter((e) =>
290
+ packageInfo.versions.has(e.description.id.version),
291
+ );
222
292
  const newVersions = packageOverview.versions.filter(
223
293
  (e) => !packageInfo.versions.has(e.description.id.version),
224
294
  );
225
295
 
296
+ for (const e of droppedVersions) {
297
+ if (!e.description.kind) continue;
298
+ const ovPath = kindOverviewPath(npmNameToKindPath(parseKindRef(e.description.kind).name));
299
+ mergeIntoKindOverview(ovPath, blockPackIdToString(e.description.id));
300
+ }
301
+
226
302
  // reading new entries
227
303
  for (const [v] of packageInfo.versions.entries()) {
228
304
  const version = v.toString();
@@ -244,14 +320,26 @@ export class BlockRegistryV2 {
244
320
  }
245
321
  });
246
322
  // pushing the overview
323
+ const description = BlockPackManifest.parse(
324
+ JSON.parse(manifestContent.toString("utf8")),
325
+ ).description;
247
326
  newVersions.push({
248
- description: addRelativePathPrefix(
249
- BlockPackManifest.parse(JSON.parse(manifestContent.toString("utf8"))).description,
250
- version,
251
- ),
327
+ description: addRelativePathPrefix(description, version),
252
328
  manifestSha256: sha256,
253
329
  channels,
254
330
  });
331
+
332
+ // accumulate the kind-overview projection from this block's kind ref
333
+ // (both inputs already in hand: parsed manifest + listed channels).
334
+ if (description.kind) {
335
+ const { name: kindNpmName, version: kindVersion } = parseKindRef(description.kind);
336
+ const ovPath = kindOverviewPath(npmNameToKindPath(kindNpmName));
337
+ mergeIntoKindOverview(ovPath, blockPackIdToString(id)).add.push({
338
+ id,
339
+ kindVersion,
340
+ channels,
341
+ });
342
+ }
255
343
  }
256
344
 
257
345
  // sorting entries according to version
@@ -315,6 +403,61 @@ export class BlockRegistryV2 {
315
403
  });
316
404
  }
317
405
 
406
+ // projecting kind overviews (kinds/{org}/{name}/overview.json)
407
+ for (const [ovPath, plan] of kindOverviewMerges) {
408
+ // A rebuild reads nothing: the scan re-enumerated every live kind reference, so keeping
409
+ // any of the existing entries could only resurrect one the scan no longer sees.
410
+ const kept = plan.rebuild
411
+ ? []
412
+ : ((await this.getKindOverviewAt(ovPath))?.implementers ?? []).filter(
413
+ (e) => !plan.replacedBlockIds.has(blockPackIdToString(e.id)),
414
+ );
415
+ const merged = [...kept, ...plan.add];
416
+
417
+ if (merged.length === 0) {
418
+ if (mode !== "dry-run") await this.storage.deleteFiles(ovPath);
419
+ this.logger.info(`Kind overview ${ovPath} orphaned — removed`);
420
+ continue;
421
+ }
422
+
423
+ // bucket implementers by kind version, then compute the newest block per
424
+ // channel (+ derived AnyChannel), mirroring the package latestByChannel.
425
+ const byKindVersion = new Map<string, KindImplementer[]>();
426
+ for (const impl of merged) {
427
+ const bucket = byKindVersion.get(impl.kindVersion);
428
+ if (bucket) bucket.push(impl);
429
+ else byKindVersion.set(impl.kindVersion, [impl]);
430
+ }
431
+
432
+ const kindVersions: KindVersionOverview[] = [...byKindVersion.entries()]
433
+ .map(([kindVersion, impls]) => {
434
+ const allChannels = new Set<string>();
435
+ for (const i of impls) for (const c of i.channels) allChannels.add(c);
436
+ const latestByChannel = Object.fromEntries(
437
+ [...allChannels, AnyChannel].map((c) => {
438
+ const candidate = impls
439
+ .filter((i) => c === AnyChannel || i.channels.indexOf(c) !== -1)
440
+ .sort((a, b) => compareSemver(b.id.version, a.id.version))[0];
441
+ if (!candidate) throw new Error("Assertion error");
442
+ return [c, candidate.id];
443
+ }),
444
+ );
445
+ return { kindVersion, latestByChannel };
446
+ })
447
+ .sort((e1, e2) => compareSemver(e2.kindVersion, e1.kindVersion));
448
+
449
+ const overviewData = {
450
+ schema: "v1",
451
+ implementers: merged,
452
+ kindVersions,
453
+ } satisfies KindOverview;
454
+ if (mode !== "dry-run")
455
+ await this.storage.putFile(ovPath, Buffer.from(JSON.stringify(overviewData)));
456
+ this.logger.info(
457
+ `Kind overview ${ovPath} updated (${merged.length} implementers, ${kindVersions.length} kind versions)`,
458
+ );
459
+ }
460
+
318
461
  // writing global overview
319
462
  if (mode !== "dry-run") {
320
463
  const overviewData = JSON.stringify({
@@ -384,6 +527,13 @@ export class BlockRegistryV2 {
384
527
  return parseGlobalOverviewReg(JSON.parse(content.toString()));
385
528
  }
386
529
 
530
+ /** Read+parse a kind overview by its absolute `kinds/{org}/{name}/overview.json` path. */
531
+ private async getKindOverviewAt(path: string): Promise<KindOverview | undefined> {
532
+ const content = await this.storage.getFile(path);
533
+ if (content === undefined) return undefined;
534
+ return KindOverview.parse(JSON.parse(content.toString()));
535
+ }
536
+
387
537
  private async marchChanged(id: BlockPackId) {
388
538
  // adding update seed
389
539
  const seed = randomUUID();
@@ -501,4 +651,77 @@ export class BlockRegistryV2 {
501
651
 
502
652
  await this.marchChanged(manifest.description.id);
503
653
  }
654
+
655
+ /**
656
+ * Publish a block *kind* into the `kinds/` tree — idempotent, immutable,
657
+ * content-first. Cloned from {@link publishPackage} with two differences:
658
+ *
659
+ * 1. NET-NEW source-hash guard: kind versions are immutable. If a manifest
660
+ * already exists with the same `sourceHash` this is a no-op; with a
661
+ * different `sourceHash` it hard-fails. (No such guard exists for
662
+ * blocks — `publishPackage` still overwrites same-version content.)
663
+ * 2. NO `marchChanged`: the kind projection is derived from *block*
664
+ * manifests by the reconciler, so kind content publish drops no ticket —
665
+ * it rides the block's publish ticket. A kind with zero implementing
666
+ * blocks is deliberately invisible (no `overview.json`).
667
+ *
668
+ * The path is derived from the manifest's full npm package name
669
+ * (`kind.name`) via `npmNameToKindPath` (inside `kindContentPrefix`) — the
670
+ * SAME derivation the reconciler and readers use, so content and overview
671
+ * always co-locate under one `{org}/{name}` folder. There is no separate
672
+ * `organization` field on the identity.
673
+ */
674
+ public async publishKind(
675
+ kindManifest: KindManifest,
676
+ fileReader: RelativeContentReader,
677
+ ): Promise<void> {
678
+ const { name: npmName, version } = kindManifest.kind;
679
+ const prefix = kindContentPrefix(npmName, version);
680
+
681
+ // NET-NEW source-hash immutability guard (absent / equal / differ).
682
+ const existing = await this.storage.getFile(`${prefix}/${KindManifestFileName}`);
683
+ if (existing !== undefined) {
684
+ const prev = KindManifest.parse(JSON.parse(existing.toString()));
685
+ if (prev.sourceHash === kindManifest.sourceHash) {
686
+ this.logger.info(
687
+ `Kind ${npmName}@${version} already published with identical source — no-op`,
688
+ );
689
+ return; // idempotent
690
+ }
691
+ throw new Error(
692
+ `Immutable kind version republished with different content: ${prefix} ` +
693
+ `(stored sourceHash ${prev.sourceHash} != ${kindManifest.sourceHash})`,
694
+ );
695
+ }
696
+
697
+ // content-first upload, per-file size + sha256 verify (mirrors publishPackage)
698
+ for (const f of kindManifest.files) {
699
+ const bytes = await fileReader(f.name);
700
+ if (bytes.length !== f.size)
701
+ throw new Error(
702
+ `Actual file size don't match the manifest for ${f.name} (actual = ${bytes.length}; manifest = ${f.size})`,
703
+ );
704
+ const sha256 = await calculateSha256(bytes);
705
+ if (sha256 !== f.sha256.toUpperCase())
706
+ throw new Error(
707
+ `Actual file SHA-256 don't match the manifest for ${f.name} (actual = ${sha256}; manifest = ${f.sha256.toUpperCase()})`,
708
+ );
709
+
710
+ const dst = `${prefix}/${f.name}`;
711
+ this.logger.info(`Uploading ${f.name} -> ${dst} ...`);
712
+ await this.storage.putFile(dst, bytes);
713
+ }
714
+
715
+ // manifest LAST = commit marker; stamp firstUploadTimestamp when absent.
716
+ const toStore = KindManifest.parse({
717
+ ...kindManifest,
718
+ firstUploadTimestamp: kindManifest.firstUploadTimestamp ?? Date.now(),
719
+ } satisfies KindManifest);
720
+ const manifestDst = `${prefix}/${KindManifestFileName}`;
721
+ this.logger.info(`Uploading kind manifest to ${manifestDst} ...`);
722
+ await this.storage.putFile(manifestDst, Buffer.from(JSON.stringify(toStore)));
723
+
724
+ // NO `marchChanged`: the kind projection is derived from block manifests, so this
725
+ // publish drops no ticket of its own — see the note on this method.
726
+ }
504
727
  }
@@ -6,12 +6,15 @@ import type {
6
6
  UpdateSuggestions,
7
7
  SingleBlockPackOverview,
8
8
  BlockPackOverviewNoRegistryId,
9
+ BlockPackFromRegistryV2,
9
10
  } from "@milaboratories/pl-model-middle-layer";
10
11
  import {
11
12
  blockPackIdNoVersionEquals,
12
13
  BlockPackManifest,
13
14
  AnyChannel,
14
15
  } from "@milaboratories/pl-model-middle-layer";
16
+ import type { BlockKindReference } from "@milaboratories/pl-model-common";
17
+ import { parseKindRef } from "@milaboratories/pl-model-common";
15
18
  import type { FolderReader } from "../../io";
16
19
  import canonicalize from "canonicalize";
17
20
  import {
@@ -22,6 +25,8 @@ import {
22
25
  packageContentPrefixInsideV2,
23
26
  parseGlobalOverviewReg,
24
27
  } from "./schema_public";
28
+ import { KindsPrefix, KindOverview, KindOverviewFileName, npmNameToKindPath } from "./schema_kinds";
29
+ import { resolveKind as resolveKindOverview, KindResolutionError } from "./kind_resolver";
25
30
  import type { BlockComponentsAbsoluteUrl } from "../model";
26
31
  import { blockComponentsManifestToAbsoluteUrl, embedBlockPackMetaBytes } from "../model";
27
32
  import { LRUCache } from "lru-cache";
@@ -65,6 +70,8 @@ export function inferUpdateSuggestions(currentVersion: string, availableVersions
65
70
 
66
71
  export class RegistryV2Reader {
67
72
  private readonly v2RootFolderReader: FolderReader;
73
+ /** Rooted at `kinds/`, a sibling of `v2/` — the kind projection tree. */
74
+ private readonly kindsRootFolderReader: FolderReader;
68
75
  private readonly ops: RegistryV2ReaderOps;
69
76
 
70
77
  constructor(
@@ -72,6 +79,7 @@ export class RegistryV2Reader {
72
79
  ops?: Partial<RegistryV2ReaderOps>,
73
80
  ) {
74
81
  this.v2RootFolderReader = registryReader.relativeReader(MainPrefix);
82
+ this.kindsRootFolderReader = registryReader.relativeReader(KindsPrefix);
75
83
  this.ops = { ...DefaultRegistryV2ReaderOps, ...ops };
76
84
  }
77
85
 
@@ -259,4 +267,64 @@ export class RegistryV2Reader {
259
267
  public async getComponents(id: BlockPackId): Promise<BlockComponentsAbsoluteUrl> {
260
268
  return await this.componentsCache.forceFetch(canonicalize(id)!, { context: id });
261
269
  }
270
+
271
+ /**
272
+ * Read the kind projection at `kinds/{org}/{name}/overview.json` for a kind
273
+ * npm package name. Single, no-LIST read against the `kinds/` sibling tree
274
+ * (rooted separately from `v2/`). Returns `undefined` when the overview is
275
+ * absent — a kind with zero implementing blocks has no overview by design.
276
+ *
277
+ * Client-side selector→version resolution is left to the §6 resolution
278
+ * concern; this method only fetches and parses.
279
+ */
280
+ public async getKindOverview(
281
+ kindNpmName: string,
282
+ options?: { signal?: AbortSignal },
283
+ ): Promise<KindOverview | undefined> {
284
+ const { org, name } = npmNameToKindPath(kindNpmName);
285
+ const relPath = `${org}/${name}/${KindOverviewFileName}`;
286
+ try {
287
+ return await retry(async () => {
288
+ const bytes = await this.kindsRootFolderReader.readFile(relPath, {
289
+ signal: options?.signal,
290
+ });
291
+ return KindOverview.parse(JSON.parse(Buffer.from(bytes).toString()));
292
+ }, Retry2TimesWithDelay);
293
+ } catch {
294
+ // Absent overview (or unreadable) → treat as "no such kind projection".
295
+ return undefined;
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Resolve a kind reference (`{name}@{selector}`) to a concrete implementing
301
+ * block, returning the **byte-identical** `from-registry-v2` spec shape that
302
+ * {@link getSpecificOverview} emits — so the resolved block flows through the
303
+ * unchanged add-block / `getComponents` / `prepareBlockPack` path.
304
+ *
305
+ * One projection read ({@link getKindOverview}) + client-side semver
306
+ * ({@link resolveKindOverview}); kind and its implementing blocks share this
307
+ * one registry, so no cross-registry intersection is needed. Throws a typed
308
+ * {@link KindResolutionError} on any failure (absent projection or no
309
+ * satisfying version/implementation) — the caller maps it to a spec error.
310
+ */
311
+ public async resolveKind(
312
+ ref: BlockKindReference,
313
+ { allowUnstable }: { allowUnstable: boolean },
314
+ options?: { signal?: AbortSignal },
315
+ ): Promise<BlockPackFromRegistryV2> {
316
+ const { name, version } = parseKindRef(ref);
317
+ const overview = await this.getKindOverview(name, options);
318
+ if (overview === undefined) throw new KindResolutionError("no-matching-kind-version", ref);
319
+
320
+ const r = resolveKindOverview(overview, version, { allowUnstable });
321
+ if (!r.ok) throw new KindResolutionError(r.reason, ref);
322
+
323
+ return {
324
+ type: "from-registry-v2",
325
+ id: r.blockId,
326
+ registryUrl: this.registryReader.rootUrl.toString(),
327
+ channel: r.channel,
328
+ };
329
+ }
262
330
  }
@@ -0,0 +1,65 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { formatKindRef, parseKindRef } from "@milaboratories/pl-model-common";
3
+ import { kindContentPrefix, kindOverviewPath, npmNameToKindPath } from "./schema_kinds";
4
+
5
+ /**
6
+ * Seam-2 regression guard: a kind's reference is the FULL npm package name +
7
+ * version, and EVERYONE derives the S3 `{org, name}` from that npm name via the
8
+ * single `npmNameToKindPath` helper. If content and overview ever derive the
9
+ * folder differently again, this round-trip fails.
10
+ */
11
+ describe("kind reference / path derivation round-trip", () => {
12
+ const npmName = "@platforma-open/milaboratories.mixcr-clonotyping.kind";
13
+ const version = "1.2.0";
14
+
15
+ test("npmName -> formatKindRef -> parseKindRef preserves name and version", () => {
16
+ const ref = formatKindRef({ name: npmName, version });
17
+ expect(ref).toBe(`${npmName}@${version}`);
18
+
19
+ // Split on the LAST '@', so the scoped npm name (which itself starts with
20
+ // '@') survives intact.
21
+ const parsed = parseKindRef(ref);
22
+ expect(parsed).toEqual({ name: npmName, version });
23
+ });
24
+
25
+ test("content path and overview path share the same {org, name} folder", () => {
26
+ const ref = formatKindRef({ name: npmName, version });
27
+ const parsed = parseKindRef(ref);
28
+
29
+ // The SINGLE derivation everyone uses.
30
+ const loc = npmNameToKindPath(parsed.name);
31
+
32
+ const contentPrefix = kindContentPrefix(parsed.name, parsed.version);
33
+ const overviewPath = kindOverviewPath(loc);
34
+
35
+ // Strip the version segment / the overview filename to expose the folder.
36
+ const contentDir = contentPrefix.slice(0, contentPrefix.lastIndexOf("/"));
37
+ const overviewDir = overviewPath.slice(0, overviewPath.lastIndexOf("/"));
38
+
39
+ expect(contentDir).toBe(overviewDir);
40
+ // kindContentPrefix derives {org,name} internally from the SAME npm name, so
41
+ // the co-located folder is exactly `kinds/{org}/{name}`.
42
+ expect(contentDir).toBe(`kinds/${loc.org}/${loc.name}`);
43
+ expect(contentPrefix).toBe(`kinds/${loc.org}/${loc.name}/${version}`);
44
+ });
45
+
46
+ test("npmNameToKindPath delegates to parsePackageName (scope dropped, 1st dotted segment = org, 2nd = name, .kind ignored)", () => {
47
+ // Scoped form: the npm scope (`@platforma-open/`) is dropped, then the dotted
48
+ // body yields org = 1st segment, name = 2nd, and the trailing `.kind` marker
49
+ // is simply not matched by the canonical parser.
50
+ expect(npmNameToKindPath("@platforma-open/milaboratories.mixcr-clonotyping.kind")).toEqual({
51
+ org: "milaboratories",
52
+ name: "mixcr-clonotyping",
53
+ });
54
+ // Bare dotted form: org = 1st segment, name = 2nd, `.kind` ignored.
55
+ expect(npmNameToKindPath("platforma-open.mixcr-clonotyping.kind")).toEqual({
56
+ org: "platforma-open",
57
+ name: "mixcr-clonotyping",
58
+ });
59
+ // Delegation means the derivation is identical whether or not the `.kind`
60
+ // marker is present — the trailing segment never participates in the split.
61
+ expect(npmNameToKindPath("@platforma-open/milaboratories.mixcr-clonotyping")).toEqual(
62
+ npmNameToKindPath("@platforma-open/milaboratories.mixcr-clonotyping.kind"),
63
+ );
64
+ });
65
+ });
@@ -0,0 +1,169 @@
1
+ import { BlockPackId, SemVer, Sha256Schema } from "@milaboratories/pl-model-middle-layer";
2
+ // @todo: don't use zod
3
+ import { z } from "zod";
4
+ import { parsePackageName } from "../source_package";
5
+
6
+ /**
7
+ * Registry tree for block *kinds*, a sibling of {@link MainPrefix} (`v2/`).
8
+ *
9
+ * Layout mirrors the package tree one level down:
10
+ * kinds/{org}/{name}/{version}/manifest.json — immutable kind content (publishKind)
11
+ * kinds/{org}/{name}/{version}/kind.d.ts — kind artifacts
12
+ * kinds/{org}/{name}/overview.json — projection over implementing blocks (reconciler)
13
+ */
14
+ export const KindsPrefix = "kinds/";
15
+
16
+ export const KindOverviewFileName = "overview.json";
17
+ export const KindManifestFileName = "manifest.json";
18
+
19
+ /** Location of a kind inside the `kinds/` tree, split into path segments. */
20
+ export interface KindPathLocation {
21
+ org: string;
22
+ name: string;
23
+ }
24
+
25
+ /**
26
+ * `kinds/{org}/{name}/{version}` — the immutable per-version content folder,
27
+ * counterpart of {@link packageContentPrefix}.
28
+ *
29
+ * The `{org, name}` segments are DERIVED from the kind's full npm package name
30
+ * via {@link npmNameToKindPath} — the SAME helper {@link kindOverviewPath} and
31
+ * the reconciler use. This is what guarantees a kind's per-version content and
32
+ * its `overview.json` projection land under one identical `{org}/{name}` folder.
33
+ */
34
+ export function kindContentPrefix(npmName: string, version: string): string {
35
+ const { org, name } = npmNameToKindPath(npmName);
36
+ return `${KindsPrefix}${org}/${name}/${version}`;
37
+ }
38
+
39
+ /**
40
+ * `kinds/{org}/{name}/overview.json` — the reconciler-maintained projection of
41
+ * every block that implements this kind, bucketed by kind version.
42
+ */
43
+ export function kindOverviewPath(loc: KindPathLocation): string {
44
+ return `${KindsPrefix}${loc.org}/${loc.name}/${KindOverviewFileName}`;
45
+ }
46
+
47
+ /**
48
+ * Matches a `kinds/`-relative overview path (`{org}/{name}/overview.json`).
49
+ * Used by the force-mode orphan seed to enumerate every existing overview.
50
+ */
51
+ export const KindOverviewPathPattern = new RegExp(
52
+ `^(?<org>[^/]+)/(?<name>[^/]+)/${KindOverviewFileName.replace(".", "\\.")}$`,
53
+ );
54
+
55
+ /**
56
+ * Kind npm package name → `{org, name}` path segments.
57
+ *
58
+ * DELEGATES to {@link parsePackageName} — the canonical block npm-name parser
59
+ * (`source_package.ts`) — so kind registry paths (`kinds/{org}/{name}/…`) mirror
60
+ * block paths (`v2/{org}/{name}/…`) byte-for-byte and cannot drift. That parser
61
+ * drops the npm scope (`@platforma-open/`), takes the 1st dotted segment as the
62
+ * organization and the 2nd as the name, and ignores any trailing segment (the
63
+ * kind's `.kind` marker simply is not matched):
64
+ * - `@platforma-open/milaboratories.mixcr-clonotyping.kind` → `{ org: "milaboratories", name: "mixcr-clonotyping" }`
65
+ * - `milaboratories.mixcr-clonotyping.kind` → `{ org: "milaboratories", name: "mixcr-clonotyping" }`
66
+ *
67
+ * This helper is DISTINCT from `parseKindRef` (the `{name}@{version}` reference
68
+ * codec in `block_kind_ref.ts`): `parseKindRef` splits a reference into
69
+ * name/version; `npmNameToKindPath` splits the *name* half into path segments.
70
+ */
71
+ export function npmNameToKindPath(npmName: string): KindPathLocation {
72
+ const { organization, name } = parsePackageName(npmName);
73
+ return { org: organization, name };
74
+ }
75
+
76
+ /**
77
+ * On-wire kind identity, mirroring the `kind` block written by the build side
78
+ * (`build_kind_dist.ts` `KindManifestIdentity`).
79
+ *
80
+ * `name` is the FULL npm package name of the kind (e.g.
81
+ * `@platforma-open/milaboratories.mixcr-clonotyping.kind`). There is no separate
82
+ * `organization` field — the S3 `{org, name}` path is always derived from this
83
+ * npm name via {@link npmNameToKindPath}, so the content publisher and the
84
+ * overview reconciler resolve to the same folder.
85
+ */
86
+ export const KindManifestIdentity = z
87
+ .object({
88
+ name: z.string(),
89
+ version: SemVer,
90
+ })
91
+ .passthrough();
92
+ export type KindManifestIdentity = z.infer<typeof KindManifestIdentity>;
93
+
94
+ export const KindManifestFileInfo = z
95
+ .object({
96
+ name: z.string(),
97
+ size: z.number(),
98
+ sha256: Sha256Schema,
99
+ })
100
+ .passthrough();
101
+ export type KindManifestFileInfo = z.infer<typeof KindManifestFileInfo>;
102
+
103
+ /**
104
+ * Kind content manifest, as produced by `build_kind_dist.ts` and stored (LAST,
105
+ * as the commit marker) at `kinds/{org}/{name}/{version}/manifest.json`.
106
+ *
107
+ * `sourceHash` is the sorted-`src/`-tree digest computed at build time (NOT the
108
+ * per-file `calculateSha256`); it is the comparand for the publish-time
109
+ * source-hash immutability guard. `firstUploadTimestamp` is stamped by
110
+ * `publishKind` when the manifest is first written to the registry.
111
+ */
112
+ export const KindManifest = z
113
+ .object({
114
+ schema: z.literal("v1"),
115
+ kind: KindManifestIdentity,
116
+ sourceHash: z.string(),
117
+ files: z.array(KindManifestFileInfo),
118
+ /** Build-time timestamp carried from `build_kind_dist.ts`. */
119
+ timestamp: z.number().optional(),
120
+ /** Stamped by `publishKind` on first upload to the registry. */
121
+ firstUploadTimestamp: z.number().optional(),
122
+ })
123
+ .passthrough();
124
+ export type KindManifest = z.infer<typeof KindManifest>;
125
+
126
+ /**
127
+ * One implementing block version for some kind version. Flat and
128
+ * RMW-friendly: the reconciler filters this list by `(id)` to drop stale
129
+ * entries before re-adding fresh ones.
130
+ */
131
+ export const KindImplementer = z
132
+ .object({
133
+ /** Full id of the implementing block, incl. its version. */
134
+ id: BlockPackId,
135
+ /** Kind version this block implements. */
136
+ kindVersion: SemVer,
137
+ /** Channels the block version is published to. */
138
+ channels: z.array(z.string()).default(() => []),
139
+ })
140
+ .passthrough();
141
+ export type KindImplementer = z.infer<typeof KindImplementer>;
142
+
143
+ /**
144
+ * Per-kind-version projection: the newest implementing block per channel,
145
+ * including the derived `any` channel. Mirrors the package overview's
146
+ * `latestByChannel` + `AnyChannel` computation.
147
+ */
148
+ export const KindVersionOverview = z
149
+ .object({
150
+ kindVersion: SemVer,
151
+ latestByChannel: z.record(z.string(), BlockPackId),
152
+ })
153
+ .passthrough();
154
+ export type KindVersionOverview = z.infer<typeof KindVersionOverview>;
155
+
156
+ /**
157
+ * Reconciler-maintained projection at `kinds/{org}/{name}/overview.json`.
158
+ *
159
+ * `implementers` is the flat, RMW source of truth; `kindVersions` is the
160
+ * derived, reader-facing view (kind versions × newest implementer per channel).
161
+ */
162
+ export const KindOverview = z
163
+ .object({
164
+ schema: z.literal("v1"),
165
+ implementers: z.array(KindImplementer),
166
+ kindVersions: z.array(KindVersionOverview),
167
+ })
168
+ .passthrough();
169
+ export type KindOverview = z.infer<typeof KindOverview>;