@platforma-sdk/block-tools 2.13.0 → 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.
- package/dist/cli.js +12 -12
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +727 -648
- package/dist/cli.mjs.map +1 -1
- package/dist/cmd/build-kind-manifest.d.ts +7 -0
- package/dist/config-CtXMWNgm.js +3 -0
- package/dist/config-CtXMWNgm.js.map +1 -0
- package/dist/{config-CiC347sB.mjs → config-Dx45aFJ_.mjs} +792 -456
- package/dist/config-Dx45aFJ_.mjs.map +1 -0
- package/dist/index.js +1 -1
- package/dist/index.mjs +15 -15
- package/dist/structure/engine/api.d.ts +1 -1
- package/dist/structure/rules/kind-package-json.d.ts +3 -0
- package/dist/structure/rules/kind.d.ts +1 -0
- package/dist/v2/build_kind_dist.d.ts +25 -0
- package/dist/v2/index.d.ts +4 -0
- package/dist/v2/kind/resolve-refs.d.ts +63 -0
- package/dist/v2/kind/version-match.d.ts +23 -0
- package/dist/v2/model/block_description.d.ts +8 -1
- package/dist/v2/publish-block.d.ts +23 -0
- package/dist/v2/registry/index.d.ts +2 -0
- package/dist/v2/registry/kind_resolver.d.ts +121 -0
- package/dist/v2/registry/registry.d.ts +23 -0
- package/dist/v2/registry/registry_reader.d.ts +34 -1
- package/dist/v2/registry/schema_kinds.d.ts +632 -0
- package/dist/v2/registry/schema_public.d.ts +72 -0
- package/package.json +10 -10
- package/src/cli.test.ts +2 -1
- package/src/cli.ts +2 -0
- package/src/cmd/build-kind-manifest.ts +41 -0
- package/src/cmd/publish.ts +10 -1
- package/src/structure/__tests__/model-package-json.snapshot.test.ts +1 -0
- package/src/structure/engine/api.ts +1 -1
- package/src/structure/engine/ctx.ts +1 -0
- package/src/structure/engine/discovery.ts +16 -2
- package/src/structure/rules/block-package-json.ts +18 -0
- package/src/structure/rules/kind-package-json.ts +117 -0
- package/src/structure/rules/kind.ts +29 -0
- package/src/structure/rules/model-package-json.ts +8 -0
- package/src/structure/rules/model.ts +15 -13
- package/src/structure/rules/shared/pascal-case.ts +1 -1
- package/src/structure/structure-definition.ts +2 -0
- package/src/structure/templates/static/kind/.oxfmtrc.json +4 -0
- package/src/structure/templates/static/kind/.oxlintrc.json +3 -0
- package/src/structure/templates/static/kind/src/index.ts +49 -0
- package/src/structure/templates/static/kind/tsconfig.json +10 -0
- package/src/structure/templates/text/model/src/index.tpl.ts +18 -0
- package/src/v2/build_dist.ts +35 -0
- package/src/v2/build_kind_dist.ts +104 -0
- package/src/v2/from_pack_v2.test.ts +2 -2
- package/src/v2/index.ts +4 -0
- package/src/v2/kind/resolve-refs.ts +150 -0
- package/src/v2/kind/version-match.test.ts +60 -0
- package/src/v2/kind/version-match.ts +55 -0
- package/src/v2/model/block_description.ts +15 -4
- package/src/v2/publish-block.ts +60 -0
- package/src/v2/registry/index.ts +2 -0
- package/src/v2/registry/kind_resolver.test.ts +123 -0
- package/src/v2/registry/kind_resolver.ts +190 -0
- package/src/v2/registry/registry.test.ts +247 -1
- package/src/v2/registry/registry.ts +227 -4
- package/src/v2/registry/registry_reader.ts +68 -0
- package/src/v2/registry/schema_kinds.test.ts +65 -0
- package/src/v2/registry/schema_kinds.ts +169 -0
- package/dist/config-BmJWc8sR.js +0 -3
- package/dist/config-BmJWc8sR.js.map +0 -1
- package/dist/config-CiC347sB.mjs.map +0 -1
- 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>;
|