@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
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { util } from "@platforma-sdk/package-builder-lib";
|
|
4
|
+
import { calculateSha256 } from "../util";
|
|
5
|
+
import type { KindManifest, KindManifestFileInfo } from "./registry/schema_kinds";
|
|
6
|
+
|
|
7
|
+
export interface BuildKindDistOptions {
|
|
8
|
+
/** Kind package directory (the dir containing `package.json`, `src/`, `dist/`). */
|
|
9
|
+
modulePath?: string;
|
|
10
|
+
/** Source directory hashed into `sourceHash`, relative to `modulePath`. */
|
|
11
|
+
srcDir?: string;
|
|
12
|
+
/** Output directory holding the compiled bundle and receiving `manifest.json`. */
|
|
13
|
+
dst?: string;
|
|
14
|
+
/** Compiled entry file name inside `dst` (rolldown emits `kind.js`). */
|
|
15
|
+
entryFileName?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The on-wire kind manifest shape (identity, per-file info, and the manifest
|
|
19
|
+
// itself) is owned by the registry schema module — the single source of truth
|
|
20
|
+
// shared by the build side (this producer) and the registry read/write path.
|
|
21
|
+
export type {
|
|
22
|
+
KindManifest,
|
|
23
|
+
KindManifestIdentity,
|
|
24
|
+
KindManifestFileInfo,
|
|
25
|
+
} from "./registry/schema_kinds";
|
|
26
|
+
|
|
27
|
+
export const KindManifestFile = "manifest.json";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the kind's identity from its own `package.json` — the SINGLE source of
|
|
31
|
+
* truth. The block-kind build bakes the SAME `{name, version}` into the emitted
|
|
32
|
+
* bundle (via `define`), so the manifest identity here and the runtime
|
|
33
|
+
* descriptor's identity are guaranteed to agree. Reading package.json directly
|
|
34
|
+
* (rather than importing the ESM bundle) keeps this authoritative and free of a
|
|
35
|
+
* dynamic-import dependency on the compiled output.
|
|
36
|
+
*/
|
|
37
|
+
async function readKindPackageIdentity(
|
|
38
|
+
modulePath: string,
|
|
39
|
+
): Promise<{ name: string; version: string }> {
|
|
40
|
+
const pkgPath = path.resolve(modulePath, "package.json");
|
|
41
|
+
let raw: string;
|
|
42
|
+
try {
|
|
43
|
+
raw = await fsp.readFile(pkgPath, "utf-8");
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`Cannot read kind package.json at ${pkgPath}.`);
|
|
46
|
+
}
|
|
47
|
+
const pkg = JSON.parse(raw) as { name?: unknown; version?: unknown };
|
|
48
|
+
if (typeof pkg.name !== "string" || typeof pkg.version !== "string") {
|
|
49
|
+
throw new Error(`Kind package.json at ${pkgPath} must declare string "name" and "version".`);
|
|
50
|
+
}
|
|
51
|
+
return { name: pkg.name, version: pkg.version };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Commander-free core: bundle-in, manifest-out. Reads the kind's npm package
|
|
56
|
+
* `name`/`version` from its `package.json`, computes one sha256 over the `src/`
|
|
57
|
+
* tree (upper-case), and writes `manifest.json` LAST as the commit marker (the
|
|
58
|
+
* build_dist.ts convention: readers treat the manifest's presence as "the dist
|
|
59
|
+
* is complete").
|
|
60
|
+
*
|
|
61
|
+
* Shaped commander-free so the deferred publish-time source-hash guard can
|
|
62
|
+
* `import` and reuse both the hash computation and this manifest shape without
|
|
63
|
+
* CLI coupling.
|
|
64
|
+
*/
|
|
65
|
+
export async function buildKindDist(opts: BuildKindDistOptions = {}): Promise<KindManifest> {
|
|
66
|
+
const modulePath = path.resolve(opts.modulePath ?? ".");
|
|
67
|
+
const srcDir = path.resolve(modulePath, opts.srcDir ?? "src");
|
|
68
|
+
const dst = path.resolve(modulePath, opts.dst ?? "dist");
|
|
69
|
+
const entryFileName = opts.entryFileName ?? "kind.js";
|
|
70
|
+
|
|
71
|
+
const { name, version } = await readKindPackageIdentity(modulePath);
|
|
72
|
+
|
|
73
|
+
const sourceHash = util.hashDirSync(srcDir).digest("hex").toUpperCase();
|
|
74
|
+
|
|
75
|
+
const artifactNames = [entryFileName, "kind.d.ts"];
|
|
76
|
+
const files: KindManifestFileInfo[] = [];
|
|
77
|
+
for (const artifact of artifactNames) {
|
|
78
|
+
const abs = path.resolve(dst, artifact);
|
|
79
|
+
let bytes: Buffer;
|
|
80
|
+
try {
|
|
81
|
+
bytes = await fsp.readFile(abs);
|
|
82
|
+
} catch (err: unknown) {
|
|
83
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Kind build is incomplete: ${artifact} is missing from ${dst}. Run the kind's build before building its manifest.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
files.push({ name: artifact, size: bytes.length, sha256: await calculateSha256(bytes) });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const manifest: KindManifest = {
|
|
94
|
+
schema: "v1",
|
|
95
|
+
kind: { name, version },
|
|
96
|
+
sourceHash,
|
|
97
|
+
files,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// manifest.json written LAST — commit marker.
|
|
102
|
+
await fsp.writeFile(path.resolve(dst, KindManifestFile), JSON.stringify(manifest));
|
|
103
|
+
return manifest;
|
|
104
|
+
}
|
|
@@ -80,12 +80,12 @@ test("loadPackDescriptionFromManifest resolves manifest entries to absolute FS p
|
|
|
80
80
|
expect(existsSync(description.components.ui.folder)).toBe(true);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
test("loadPackDescriptionFromManifest works against a real packed sum-numbers
|
|
83
|
+
test("loadPackDescriptionFromManifest works against a real packed sum-numbers", async () => {
|
|
84
84
|
// Runs when the block has been built+packed; skips gracefully in a clean
|
|
85
85
|
// checkout where block-pack/ is gitignored. Guards against manifest-shape
|
|
86
86
|
// drift the hermetic fixture above can't catch.
|
|
87
87
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
88
|
-
const real = path.resolve(here, "../../../../etc/blocks/sum-numbers
|
|
88
|
+
const real = path.resolve(here, "../../../../etc/blocks/sum-numbers/block");
|
|
89
89
|
if (!existsSync(path.join(real, "block-pack", "manifest.json"))) return;
|
|
90
90
|
|
|
91
91
|
const description = await loadPackDescriptionFromManifest(path.join(real, "block-pack"));
|
package/src/v2/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export * from "./model";
|
|
2
2
|
export * from "./build_dist";
|
|
3
|
+
export * from "./build_kind_dist";
|
|
4
|
+
export * from "./publish-block";
|
|
5
|
+
export * from "./kind/resolve-refs";
|
|
6
|
+
export * from "./kind/version-match";
|
|
3
7
|
export * from "./source_package";
|
|
4
8
|
export * from "./resolve_to_registry";
|
|
5
9
|
export * from "./registry";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fsp from "node:fs/promises";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { formatKindRef, type BlockKindReference } from "@milaboratories/pl-model-common";
|
|
6
|
+
import type { BlockPackManifest } from "@milaboratories/pl-model-middle-layer";
|
|
7
|
+
import { KindManifest } from "../registry/schema_kinds";
|
|
8
|
+
import type { RelativeContentReader } from "../model";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Kind → block ref resolution. The single reader of the facade's kind dependency.
|
|
12
|
+
*
|
|
13
|
+
* A kind is a fourth block component alongside model, ui and workflow, and the facade
|
|
14
|
+
* depends on it DIRECTLY — not transitively through one of the other three. Its concrete
|
|
15
|
+
* version is not read from that dependency either: the facade's range may be
|
|
16
|
+
* `workspace:*` or `catalog:`, so the authority is the kind's own built manifest.
|
|
17
|
+
*
|
|
18
|
+
* Keeping every "how the facade names and locates its kind" assumption in this one module
|
|
19
|
+
* means the publish gate and the orchestrator never touch dependency-shape details.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** npm package-name suffix marking a package as a block kind (schema_kinds convention). */
|
|
23
|
+
const KIND_PACKAGE_SUFFIX = ".kind";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The kind reference the MODEL was compiled against, as baked into the block manifest
|
|
27
|
+
* when the block was built.
|
|
28
|
+
*
|
|
29
|
+
* It sits at the TOP LEVEL of the description — `description.kind`, a `{name}@{version}`
|
|
30
|
+
* string (declared in `block_description.ts`, read back by the registry's reconciler) —
|
|
31
|
+
* rather than under the model component, because the build lifts the model's
|
|
32
|
+
* container-level `kind` up when it writes the manifest. `undefined` means the block
|
|
33
|
+
* declares no kind.
|
|
34
|
+
*/
|
|
35
|
+
export function readModelCompiledKindRef(
|
|
36
|
+
manifest: BlockPackManifest,
|
|
37
|
+
): BlockKindReference | undefined {
|
|
38
|
+
return manifest.description.kind;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A kind dependency as declared in the facade's `package.json`. */
|
|
42
|
+
export interface FacadeKindDependency {
|
|
43
|
+
/** npm package name of the kind (ends in `.kind`). */
|
|
44
|
+
npmName: string;
|
|
45
|
+
/** Raw version range as written in `package.json` (e.g. `^1.2.3`, `workspace:*`). */
|
|
46
|
+
range: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The facade-side kind dependency, read from the facade's `package.json`.
|
|
51
|
+
*
|
|
52
|
+
* The facade depends on its kind DIRECTLY: the `.kind`-suffixed entry is a direct
|
|
53
|
+
* dependency of the facade, not something reached transitively through
|
|
54
|
+
* `model/package.json`. We scan both `dependencies` and
|
|
55
|
+
* `devDependencies` for the single `.kind` entry — the slim facade keeps the
|
|
56
|
+
* kind (like its model/ui/workflow siblings) as a build-time `workspace:*`
|
|
57
|
+
* devDep, and its concrete version is resolved from the kind's built manifest
|
|
58
|
+
* downstream, so the range written here (`workspace:*`/`catalog:`/a semver
|
|
59
|
+
* range) is not load-bearing.
|
|
60
|
+
*
|
|
61
|
+
* Returns `undefined` when no kind dependency is declared.
|
|
62
|
+
*/
|
|
63
|
+
export function readFacadeKindDependency(facadeDir: string): FacadeKindDependency | undefined {
|
|
64
|
+
const pkgPath = path.join(facadeDir, "package.json");
|
|
65
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
|
|
66
|
+
dependencies?: Record<string, string>;
|
|
67
|
+
devDependencies?: Record<string, string>;
|
|
68
|
+
};
|
|
69
|
+
const deps = { ...pkg.devDependencies, ...pkg.dependencies };
|
|
70
|
+
const entry = Object.entries(deps).find(([name]) => name.endsWith(KIND_PACKAGE_SUFFIX));
|
|
71
|
+
if (!entry) return undefined;
|
|
72
|
+
return { npmName: entry[0], range: entry[1] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolved, publishable kind artifacts: the concrete reference, the kind's
|
|
77
|
+
* content manifest, and a reader rooted at the kind's `dist/` folder.
|
|
78
|
+
*/
|
|
79
|
+
export interface ResolvedFacadeKind {
|
|
80
|
+
/** Concrete `{name}@{version}` reference of the kind the facade actually ships. */
|
|
81
|
+
ref: BlockKindReference;
|
|
82
|
+
/** The kind content manifest (`build_kind_dist` output) read from the kind's dist. */
|
|
83
|
+
manifest: KindManifest;
|
|
84
|
+
/** Reads the manifest's `files[].name` relative to the kind's `dist/`. */
|
|
85
|
+
fileReader: RelativeContentReader;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the facade's kind dependency to concrete publishable artifacts.
|
|
90
|
+
*
|
|
91
|
+
* Resolves the kind npm package from the facade's perspective, reads its
|
|
92
|
+
* built `dist/manifest.json` (the authoritative concrete version — the facade
|
|
93
|
+
* `package.json` range may be `workspace:*`/`catalog:` and carry no concrete
|
|
94
|
+
* version), and returns a `dist/`-rooted file reader for `publishKind`.
|
|
95
|
+
*
|
|
96
|
+
* The concrete version comes from the built manifest, so a `workspace:*`/`catalog:`
|
|
97
|
+
* range in the facade's `package.json` resolves cleanly. Resolution uses standard node resolution from the facade dir; if the
|
|
98
|
+
* package or its manifest cannot be found it throws with a pointed message
|
|
99
|
+
* rather than silently skipping.
|
|
100
|
+
*/
|
|
101
|
+
export async function resolveFacadeKind(
|
|
102
|
+
facadeDir: string,
|
|
103
|
+
npmName: string,
|
|
104
|
+
): Promise<ResolvedFacadeKind> {
|
|
105
|
+
const kindPkgDir = resolveKindPackageDir(facadeDir, npmName);
|
|
106
|
+
const distDir = path.join(kindPkgDir, "dist");
|
|
107
|
+
|
|
108
|
+
let manifestRaw: string;
|
|
109
|
+
try {
|
|
110
|
+
manifestRaw = await fsp.readFile(path.join(distDir, "manifest.json"), "utf-8");
|
|
111
|
+
} catch {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Kind package "${npmName}" resolved to ${kindPkgDir} but has no dist/manifest.json. ` +
|
|
114
|
+
`Build the kind (ts-builder build --target block-kind && block-tools build-kind-manifest) before publishing.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const manifest = KindManifest.parse(JSON.parse(manifestRaw));
|
|
118
|
+
|
|
119
|
+
const ref = formatKindRef({ name: manifest.kind.name, version: manifest.kind.version });
|
|
120
|
+
const fileReader: RelativeContentReader = async (relativePath) =>
|
|
121
|
+
Buffer.from(await fsp.readFile(path.resolve(distDir, relativePath)));
|
|
122
|
+
|
|
123
|
+
return { ref, manifest, fileReader };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve the directory of an installed kind npm package from the facade's location. */
|
|
127
|
+
function resolveKindPackageDir(facadeDir: string, npmName: string): string {
|
|
128
|
+
const req = createRequire(path.join(facadeDir, "package.json"));
|
|
129
|
+
// Prefer the package's own package.json (deterministic package root).
|
|
130
|
+
try {
|
|
131
|
+
return path.dirname(req.resolve(`${npmName}/package.json`));
|
|
132
|
+
} catch {
|
|
133
|
+
// Fall back to the package entry point, then walk up to the folder holding package.json.
|
|
134
|
+
try {
|
|
135
|
+
let dir = path.dirname(req.resolve(npmName));
|
|
136
|
+
for (let i = 0; i < 6; i++) {
|
|
137
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
138
|
+
const parent = path.dirname(dir);
|
|
139
|
+
if (parent === dir) break;
|
|
140
|
+
dir = parent;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
/* fall through to the throw below */
|
|
144
|
+
}
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Cannot resolve kind package "${npmName}" from ${facadeDir}. ` +
|
|
147
|
+
`Ensure it is installed as a dependency of the facade.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { formatKindRef } from "@milaboratories/pl-model-common";
|
|
3
|
+
import { checkKindVersionMatch, KindVersionMismatchError } from "./version-match";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `checkKindVersionMatch` is the pure pre-publish gate: the model's
|
|
7
|
+
* compiled-against kind and the facade's declared kind must be the SAME kind at
|
|
8
|
+
* the SAME version. Success is "did not throw". The full `{org, name}` identity is
|
|
9
|
+
* compared, not just the terminal name.
|
|
10
|
+
*/
|
|
11
|
+
describe("checkKindVersionMatch", () => {
|
|
12
|
+
const name = "@platforma-open/milaboratories.demo.kind";
|
|
13
|
+
|
|
14
|
+
test("identical name + version passes", () => {
|
|
15
|
+
expect(() =>
|
|
16
|
+
checkKindVersionMatch(
|
|
17
|
+
formatKindRef({ name, version: "1.2.3" }),
|
|
18
|
+
formatKindRef({ name, version: "1.2.3" }),
|
|
19
|
+
),
|
|
20
|
+
).not.toThrow();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("same kind, different version -> version mismatch", () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
checkKindVersionMatch(
|
|
26
|
+
formatKindRef({ name, version: "1.2.3" }),
|
|
27
|
+
formatKindRef({ name, version: "1.2.4" }),
|
|
28
|
+
),
|
|
29
|
+
).toThrow(KindVersionMismatchError);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("different name segment at the same version -> name mismatch", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
checkKindVersionMatch(
|
|
35
|
+
formatKindRef({ name: "@platforma-open/milaboratories.demo.kind", version: "1.2.3" }),
|
|
36
|
+
formatKindRef({ name: "@platforma-open/milaboratories.other.kind", version: "1.2.3" }),
|
|
37
|
+
),
|
|
38
|
+
).toThrow(KindVersionMismatchError);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("different org at the same version -> name mismatch (org IS compared)", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
checkKindVersionMatch(
|
|
44
|
+
formatKindRef({ name: "@platforma-open/milaboratories.demo.kind", version: "1.2.3" }),
|
|
45
|
+
formatKindRef({ name: "@platforma-open/acme.demo.kind", version: "1.2.3" }),
|
|
46
|
+
),
|
|
47
|
+
).toThrow(KindVersionMismatchError);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("an incidental npm-scope difference is normalized away", () => {
|
|
51
|
+
// Scoped vs. bare npm name: `npmNameToKindPath` drops the scope, so both
|
|
52
|
+
// resolve to the same `{org, name}` and the same kind matches.
|
|
53
|
+
expect(() =>
|
|
54
|
+
checkKindVersionMatch(
|
|
55
|
+
formatKindRef({ name: "@platforma-open/milaboratories.demo.kind", version: "1.2.3" }),
|
|
56
|
+
formatKindRef({ name: "milaboratories.demo.kind", version: "1.2.3" }),
|
|
57
|
+
),
|
|
58
|
+
).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { parseKindRef, type BlockKindReference } from "@milaboratories/pl-model-common";
|
|
2
|
+
import { npmNameToKindPath } from "../registry/schema_kinds";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Thrown when the kind version the model was compiled against does not match
|
|
6
|
+
* the kind version the facade ships. Typed so the publish command (and future
|
|
7
|
+
* callers) can distinguish this hard-fail from generic errors.
|
|
8
|
+
*/
|
|
9
|
+
export class KindVersionMismatchError extends Error {
|
|
10
|
+
constructor(message: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "KindVersionMismatchError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pure pre-publish gate: assert the model's compiled-against kind and the
|
|
18
|
+
* facade's declared kind are the same kind at the same version. Throws
|
|
19
|
+
* {@link KindVersionMismatchError} on mismatch; success is "did not throw".
|
|
20
|
+
*
|
|
21
|
+
* No I/O — both inputs are already-resolved `{name}@{version}` references, so a
|
|
22
|
+
* mismatch aborts before any S3 write (a strict superset of "abort before the
|
|
23
|
+
* facade publish"). Reuses the SINGLE `{name}@{version}` codec `parseKindRef`
|
|
24
|
+
* from `block_kind_ref.ts` (§3) — it is NOT redefined here.
|
|
25
|
+
*
|
|
26
|
+
* @param modelKindRef reference the model was compiled against (`description.kind`)
|
|
27
|
+
* @param facadeKindDep concrete reference the facade ships (from resolve-refs)
|
|
28
|
+
*/
|
|
29
|
+
export function checkKindVersionMatch(
|
|
30
|
+
modelKindRef: BlockKindReference,
|
|
31
|
+
facadeKindDep: BlockKindReference,
|
|
32
|
+
): void {
|
|
33
|
+
const m = parseKindRef(modelKindRef);
|
|
34
|
+
const f = parseKindRef(facadeKindDep);
|
|
35
|
+
|
|
36
|
+
// Version is the load-bearing comparison. Exact-match, no semver range: the
|
|
37
|
+
// facade reference is already normalized to a concrete version by resolve-refs
|
|
38
|
+
// (which resolves `workspace:*`/`catalog:` via the kind's built manifest), so
|
|
39
|
+
// a range never reaches here.
|
|
40
|
+
if (m.version !== f.version) {
|
|
41
|
+
throw new KindVersionMismatchError(
|
|
42
|
+
`Kind version mismatch: model compiled against ${modelKindRef}, ` +
|
|
43
|
+
`facade declares ${facadeKindDep}. Rebuild the model against the declared kind.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const mLoc = npmNameToKindPath(m.name);
|
|
48
|
+
const fLoc = npmNameToKindPath(f.name);
|
|
49
|
+
if (mLoc.org !== fLoc.org || mLoc.name !== fLoc.name) {
|
|
50
|
+
throw new KindVersionMismatchError(
|
|
51
|
+
`Kind name mismatch: model compiled against ${modelKindRef}, ` +
|
|
52
|
+
`facade declares ${facadeKindDep}.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -22,7 +22,14 @@ export type BlockPackDescriptionAbsolute = BlockPackDescription<
|
|
|
22
22
|
* Resolves a raw `package.json`-form block-pack description against the
|
|
23
23
|
* module root: workflow/model/ui paths via node module resolution, text and
|
|
24
24
|
* binary fields in `meta` via `mapLocalToAbsolute`. Reads the resolved model
|
|
25
|
-
* file to extract feature flags from its
|
|
25
|
+
* file to extract feature flags and the kind reference from its
|
|
26
|
+
* `BlockConfigContainer`.
|
|
27
|
+
*
|
|
28
|
+
* Both of those come from the built model rather than from `package.json`,
|
|
29
|
+
* because both are decided when the model is compiled. Carrying the kind here
|
|
30
|
+
* means a description means the same thing whichever side it was loaded from —
|
|
31
|
+
* a manifest already records it, and a caller should not have to know that a
|
|
32
|
+
* source package does not and re-read the model to find out.
|
|
26
33
|
*/
|
|
27
34
|
export async function resolveBlockPackDescription(
|
|
28
35
|
raw: BlockPackDescriptionRaw,
|
|
@@ -30,14 +37,18 @@ export async function resolveBlockPackDescription(
|
|
|
30
37
|
): Promise<BlockPackDescriptionAbsolute> {
|
|
31
38
|
const components = resolveBlockComponents(raw.components, root);
|
|
32
39
|
const meta = await resolveBlockPackMeta(raw.meta, root);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
);
|
|
40
|
+
const container = JSON.parse(
|
|
41
|
+
await fsp.readFile(components.model.file, "utf-8"),
|
|
42
|
+
) as BlockConfigContainer;
|
|
43
|
+
const cfg = extractConfigGeneric(container);
|
|
36
44
|
return {
|
|
37
45
|
...raw,
|
|
38
46
|
components,
|
|
39
47
|
meta,
|
|
40
48
|
featureFlags: cfg.featureFlags,
|
|
49
|
+
// The kind sits at the container level, above the render envelope, so it
|
|
50
|
+
// survives `extractConfigGeneric` normalizing that envelope away.
|
|
51
|
+
...(container.kind !== undefined ? { kind: container.kind } : {}),
|
|
41
52
|
};
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { BlockPackManifest } from "@milaboratories/pl-model-middle-layer";
|
|
2
|
+
import type { BlockRegistryV2 } from "./registry/registry";
|
|
3
|
+
import type { RelativeContentReader } from "./model";
|
|
4
|
+
import {
|
|
5
|
+
readModelCompiledKindRef,
|
|
6
|
+
readFacadeKindDependency,
|
|
7
|
+
resolveFacadeKind,
|
|
8
|
+
} from "./kind/resolve-refs";
|
|
9
|
+
import { checkKindVersionMatch } from "./kind/version-match";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Publish a block package, kind-first.
|
|
13
|
+
*
|
|
14
|
+
* This is the single forward-compat seam the deferred kind/template phases
|
|
15
|
+
* extend. It sequences exactly three steps around the existing facade publish:
|
|
16
|
+
*
|
|
17
|
+
* 1. resolve both kind refs — every assumption about how the facade names and locates
|
|
18
|
+
* its kind lives in `resolve-refs`, so this step has no shape details of its own,
|
|
19
|
+
* 2. run the pure version-match gate — hard-fails before ANY S3 write,
|
|
20
|
+
* 3. `publishKind` (kind content → `kinds/` tree, idempotent, source-hash
|
|
21
|
+
* guarded), then
|
|
22
|
+
* 4. `publishPackage` — the facade block, byte-for-byte unchanged.
|
|
23
|
+
*
|
|
24
|
+
* A block that declares no kind (`description.kind` absent) publishes exactly as
|
|
25
|
+
* before: the gate and `publishKind` are skipped entirely.
|
|
26
|
+
*
|
|
27
|
+
* The channel marker / coords write / refresh tail stays in the publish command
|
|
28
|
+
* and runs only after this resolves.
|
|
29
|
+
*/
|
|
30
|
+
export async function publishBlock(
|
|
31
|
+
registry: BlockRegistryV2,
|
|
32
|
+
manifest: BlockPackManifest,
|
|
33
|
+
facadeDir: string,
|
|
34
|
+
fileReader: RelativeContentReader,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const modelKindRef = readModelCompiledKindRef(manifest);
|
|
37
|
+
|
|
38
|
+
if (modelKindRef !== undefined) {
|
|
39
|
+
// 1. resolve the facade-declared kind dependency
|
|
40
|
+
const facadeDep = readFacadeKindDependency(facadeDir);
|
|
41
|
+
if (facadeDep === undefined) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Model was compiled against kind ${modelKindRef}, but the facade declares ` +
|
|
44
|
+
`no kind dependency in its package.json.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Resolve to concrete artifacts (concrete version + kind content + reader).
|
|
49
|
+
const resolvedKind = await resolveFacadeKind(facadeDir, facadeDep.npmName);
|
|
50
|
+
|
|
51
|
+
// 2. pure gate — throws KindVersionMismatchError before any S3 write
|
|
52
|
+
checkKindVersionMatch(modelKindRef, resolvedKind.ref);
|
|
53
|
+
|
|
54
|
+
// 3. kind FIRST — idempotent, source-hash-guarded write to the kinds/ tree
|
|
55
|
+
await registry.publishKind(resolvedKind.manifest, resolvedKind.fileReader);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 4. facade — unchanged behavior
|
|
59
|
+
await registry.publishPackage(manifest, fileReader);
|
|
60
|
+
}
|
package/src/v2/registry/index.ts
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import * as semver from "semver";
|
|
3
|
+
import { AnyChannel, StableChannel } from "@milaboratories/pl-model-middle-layer";
|
|
4
|
+
import type { BlockPackId } from "@milaboratories/pl-model-middle-layer";
|
|
5
|
+
import { parseSelector, resolveKind, selectorToRange } from "./kind_resolver";
|
|
6
|
+
import type { KindOverview } from "./schema_kinds";
|
|
7
|
+
|
|
8
|
+
const block = (version: string): BlockPackId => ({
|
|
9
|
+
organization: "acme",
|
|
10
|
+
name: "blk",
|
|
11
|
+
version,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Implementing block ids, one per kind version. Block versions are independent
|
|
15
|
+
// of the kind versions they implement.
|
|
16
|
+
const b100 = block("3.0.0"); // implements kind 1.0.0 (stable)
|
|
17
|
+
const b120 = block("3.1.0"); // implements kind 1.2.0 (stable)
|
|
18
|
+
const b130 = block("3.2.0"); // implements kind 1.3.0 (stable)
|
|
19
|
+
const b200 = block("4.0.0"); // implements kind 2.0.0 (unstable only)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Overview with a multi-step version ladder inside one major, so patch-tier and
|
|
23
|
+
* minor-tier resolution land on different versions, and a top kind version
|
|
24
|
+
* (2.0.0) whose only implementer sits off the stable channel — the
|
|
25
|
+
* `no-stable-implementation` fixture.
|
|
26
|
+
*/
|
|
27
|
+
const overview: KindOverview = {
|
|
28
|
+
schema: "v1",
|
|
29
|
+
implementers: [
|
|
30
|
+
{ id: b100, kindVersion: "1.0.0", channels: [StableChannel] },
|
|
31
|
+
{ id: b120, kindVersion: "1.2.0", channels: [StableChannel] },
|
|
32
|
+
{ id: b130, kindVersion: "1.3.0", channels: [StableChannel] },
|
|
33
|
+
{ id: b200, kindVersion: "2.0.0", channels: ["unstable"] },
|
|
34
|
+
],
|
|
35
|
+
kindVersions: [
|
|
36
|
+
{ kindVersion: "1.0.0", latestByChannel: { [StableChannel]: b100, [AnyChannel]: b100 } },
|
|
37
|
+
{ kindVersion: "1.2.0", latestByChannel: { [StableChannel]: b120, [AnyChannel]: b120 } },
|
|
38
|
+
{ kindVersion: "1.3.0", latestByChannel: { [StableChannel]: b130, [AnyChannel]: b130 } },
|
|
39
|
+
{ kindVersion: "2.0.0", latestByChannel: { [AnyChannel]: b200 } },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe("selector parsing / range mapping", () => {
|
|
44
|
+
test("leading operator selects the tier; bare version is exact", () => {
|
|
45
|
+
expect(parseSelector("1.2.0")).toEqual({ op: "exact", version: "1.2.0" });
|
|
46
|
+
expect(parseSelector("~1.2.0")).toEqual({ op: "patch", version: "1.2.0" });
|
|
47
|
+
expect(parseSelector("^1.2.0")).toEqual({ op: "minor", version: "1.2.0" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("selectorToRange emits the explicit range for each tier", () => {
|
|
51
|
+
expect(selectorToRange({ op: "exact", version: "1.2.0" })).toBe("=1.2.0");
|
|
52
|
+
expect(selectorToRange({ op: "patch", version: "1.2.0" })).toBe(">=1.2.0 <1.3.0-0");
|
|
53
|
+
expect(selectorToRange({ op: "minor", version: "1.0.0" })).toBe(">=1.0.0 <2.0.0-0");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("at or above 1.0.0 the explicit ranges are equivalent to npm ~ and ^", () => {
|
|
57
|
+
for (const version of ["1.0.0", "1.2.3", "2.0.0", "10.4.0", "1.2.0-rc.1"]) {
|
|
58
|
+
expect(semver.validRange(selectorToRange({ op: "patch", version }))).toBe(
|
|
59
|
+
semver.validRange(`~${version}`),
|
|
60
|
+
);
|
|
61
|
+
expect(semver.validRange(selectorToRange({ op: "minor", version }))).toBe(
|
|
62
|
+
semver.validRange(`^${version}`),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// npm collapses `^0.2.0` and `~0.2.0` to the same range, treating a 0.x minor
|
|
68
|
+
// as the breaking boundary. The kind scheme puts params-breaks on the major at
|
|
69
|
+
// every major, so the two tiers must stay distinct for a 0.x kind.
|
|
70
|
+
test("below 1.0.0 the tiers stay distinct, unlike npm ~ and ^", () => {
|
|
71
|
+
expect(selectorToRange({ op: "patch", version: "0.2.0" })).toBe(">=0.2.0 <0.3.0-0");
|
|
72
|
+
expect(selectorToRange({ op: "minor", version: "0.2.0" })).toBe(">=0.2.0 <1.0.0-0");
|
|
73
|
+
|
|
74
|
+
expect(semver.validRange("^0.2.0")).toBe(semver.validRange("~0.2.0"));
|
|
75
|
+
expect(semver.satisfies("0.3.0", selectorToRange({ op: "minor", version: "0.2.0" }))).toBe(
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
expect(semver.satisfies("0.3.0", selectorToRange({ op: "patch", version: "0.2.0" }))).toBe(
|
|
79
|
+
false,
|
|
80
|
+
);
|
|
81
|
+
expect(semver.satisfies("1.0.0", selectorToRange({ op: "minor", version: "0.2.0" }))).toBe(
|
|
82
|
+
false,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("an unparseable version is rejected at range construction", () => {
|
|
87
|
+
expect(() => selectorToRange({ op: "minor", version: "latest" })).toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("resolveKind", () => {
|
|
92
|
+
test("exact @X.Y.Z hit -> that kind version's stable block", () => {
|
|
93
|
+
const r = resolveKind(overview, "1.2.0", { allowUnstable: false });
|
|
94
|
+
expect(r).toEqual({ ok: true, blockId: b120, channel: StableChannel });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("minor float ^1.0.0 picks the newest matching kind version (1.3.0)", () => {
|
|
98
|
+
const r = resolveKind(overview, "^1.0.0", { allowUnstable: false });
|
|
99
|
+
// ^1.0.0 == >=1.0.0 <2.0.0 -> maxSatisfying is 1.3.0.
|
|
100
|
+
expect(r).toEqual({ ok: true, blockId: b130, channel: StableChannel });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("patch float ~1.2.0 stays within the minor line", () => {
|
|
104
|
+
const r = resolveKind(overview, "~1.2.0", { allowUnstable: false });
|
|
105
|
+
// ~1.2.0 == >=1.2.0 <1.3.0 -> maxSatisfying is 1.2.0.
|
|
106
|
+
expect(r).toEqual({ ok: true, blockId: b120, channel: StableChannel });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("kind version exists but only an unstable implementer -> no-stable-implementation", () => {
|
|
110
|
+
const r = resolveKind(overview, "2.0.0", { allowUnstable: false });
|
|
111
|
+
expect(r).toEqual({ ok: false, reason: "no-stable-implementation" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("allowUnstable lifts the same 2.0.0 to the any channel", () => {
|
|
115
|
+
const r = resolveKind(overview, "2.0.0", { allowUnstable: true });
|
|
116
|
+
expect(r).toEqual({ ok: true, blockId: b200, channel: AnyChannel });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("selector satisfying zero kind versions -> no-matching-kind-version", () => {
|
|
120
|
+
const r = resolveKind(overview, "5.0.0", { allowUnstable: false });
|
|
121
|
+
expect(r).toEqual({ ok: false, reason: "no-matching-kind-version" });
|
|
122
|
+
});
|
|
123
|
+
});
|