Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { compareSemver, requiresReinstall } from "./reinstall-criteria.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# `reinstall-criteria`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Decides whether a manifest update from `prev` to `next` requires a full
|
||||
reinstall path (`uninstalled → installing → installed → starting → started`)
|
||||
versus a hot-reload (`stopped → starting`). The criteria mirror design §4
|
||||
exactly: a `schemaVersion` bump, new scopes/events/jobs/secrets, or any
|
||||
`dependsOn` diff force the full path; a strict version downgrade throws
|
||||
`LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` because the ledger would
|
||||
then point at a higher schemaVersion than the running code can satisfy.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure `requiresReinstall(prev, next): boolean` — no I/O, no global state,
|
||||
no clock dependency.
|
||||
- Per-field detection of added items only (removals are intentionally not a
|
||||
reinstall trigger — the host can drop them in hot-reload).
|
||||
- Embedded `compareSemver(a, b)` implementing loose semver compare
|
||||
(`X.Y.Z` form, prerelease/build metadata ignored). The manifest
|
||||
validator in `api-manifest` already rejects non-semver values, so this
|
||||
is the only consumer.
|
||||
- Throws `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` with `prev` /
|
||||
`next` versions in `error.context` for downgrade attempts.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { requiresReinstall } from "@suaveplan/api-lifecycle/reinstall-criteria";
|
||||
|
||||
if (requiresReinstall(prev, next)) {
|
||||
// walk the full install path
|
||||
} else {
|
||||
// hot reload via stopped → starting
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wrapping the call in admin tooling that wants to surface the specific
|
||||
trigger reason:
|
||||
|
||||
```ts
|
||||
import { compareSemver, requiresReinstall } from "@suaveplan/api-lifecycle/reinstall-criteria";
|
||||
|
||||
function explain(prev: PluginManifest, next: PluginManifest): string {
|
||||
if (compareSemver(next.version, prev.version) < 0) return "version downgrade";
|
||||
if ((next.database?.schemaVersion ?? 0) > (prev.database?.schemaVersion ?? 0)) {
|
||||
return "schemaVersion bumped";
|
||||
}
|
||||
return requiresReinstall(prev, next) ? "manifest changed materially" : "hot reload OK";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `requiresReinstall(prev: PluginManifest, next: PluginManifest): boolean`
|
||||
- `compareSemver(a: string, b: string): -1 | 0 | 1`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The "added-items" detection uses `Set`s built from `prev` so the comparison
|
||||
is O(n) per field. `dependsOn` is compared via a key-union walk so a
|
||||
removed-but-then-readded dep with a different version is still caught.
|
||||
Removing items intentionally does not trigger a reinstall — design §4
|
||||
defines this as a hot-reload-safe operation; the host driver drops the
|
||||
corresponding capability registrations in the `installed → starting`
|
||||
side-effect chain.
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { LifecycleError } from "../error-codes/error-codes.js";
|
||||
import { compareSemver, requiresReinstall } from "./reinstall-criteria.js";
|
||||
|
||||
function manifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
||||
return {
|
||||
name: "billing",
|
||||
version: "0.1.0",
|
||||
apiHost: "1.x",
|
||||
isolation: "trusted",
|
||||
trustLevel: "first-party",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reinstall-criteria", () => {
|
||||
describe("compareSemver", () => {
|
||||
it("orders patch versions", { timeout: 5000 }, () => {
|
||||
expect(compareSemver("1.0.0", "1.0.1")).toBe(-1);
|
||||
expect(compareSemver("1.0.1", "1.0.0")).toBe(1);
|
||||
expect(compareSemver("1.0.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("orders minor versions", { timeout: 5000 }, () => {
|
||||
expect(compareSemver("1.1.0", "1.2.0")).toBe(-1);
|
||||
expect(compareSemver("1.2.0", "1.1.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("orders major versions", { timeout: 5000 }, () => {
|
||||
expect(compareSemver("1.0.0", "2.0.0")).toBe(-1);
|
||||
});
|
||||
|
||||
it("ignores prerelease and build metadata", { timeout: 5000 }, () => {
|
||||
expect(compareSemver("1.0.0-rc.1", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("1.0.0+build.1", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
it("handles missing segments as zeros", { timeout: 5000 }, () => {
|
||||
expect(compareSemver("1", "1.0.0")).toBe(0);
|
||||
expect(compareSemver("1.0", "1.0.1")).toBe(-1);
|
||||
// Reverse: b shorter than a — exercises the `bp[i] ?? 0` branch.
|
||||
expect(compareSemver("1.0.0", "1")).toBe(0);
|
||||
expect(compareSemver("1.0.1", "1.0")).toBe(1);
|
||||
});
|
||||
|
||||
it("treats non-numeric segments as zero", { timeout: 5000 }, () => {
|
||||
expect(compareSemver("a.b.c", "0.0.0")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresReinstall", () => {
|
||||
it("identical manifest returns false", { timeout: 5000 }, () => {
|
||||
const m = manifest({ scopes: [{ name: "x" }] });
|
||||
expect(requiresReinstall(m, m)).toBe(false);
|
||||
});
|
||||
|
||||
it("schemaVersion bump returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({
|
||||
database: { required: true, schemaVersion: 1 },
|
||||
});
|
||||
const next = manifest({
|
||||
database: { required: true, schemaVersion: 2 },
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("schemaVersion unchanged returns false", { timeout: 5000 }, () => {
|
||||
const prev = manifest({
|
||||
database: { required: true, schemaVersion: 1 },
|
||||
});
|
||||
const next = manifest({
|
||||
database: { required: true, schemaVersion: 1 },
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(false);
|
||||
});
|
||||
|
||||
it("new scope returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({ scopes: [{ name: "read" }] });
|
||||
const next = manifest({
|
||||
scopes: [{ name: "read" }, { name: "write" }],
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("new publish topic returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({ events: { publishes: ["a"] } });
|
||||
const next = manifest({ events: { publishes: ["a", "b"] } });
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("new subscribe topic returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({ events: { subscribes: ["a"] } });
|
||||
const next = manifest({ events: { subscribes: ["a", "b"] } });
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("new cron job returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({
|
||||
jobs: { cron: [{ name: "x", expression: "* * * * *" }] },
|
||||
});
|
||||
const next = manifest({
|
||||
jobs: {
|
||||
cron: [
|
||||
{ name: "x", expression: "* * * * *" },
|
||||
{ name: "y", expression: "* * * * *" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("new queued job returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({
|
||||
jobs: { queued: [{ name: "a" }] },
|
||||
});
|
||||
const next = manifest({
|
||||
jobs: { queued: [{ name: "a" }, { name: "b" }] },
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("new secret returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({
|
||||
secrets: [{ name: "STRIPE", required: true }],
|
||||
});
|
||||
const next = manifest({
|
||||
secrets: [
|
||||
{ name: "STRIPE", required: true },
|
||||
{ name: "PAYPAL", required: false },
|
||||
],
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("dependsOn change returns true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({ dependsOn: { core: "1.x" } });
|
||||
const next = manifest({ dependsOn: { core: "2.x" } });
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("dependsOn additions return true", { timeout: 5000 }, () => {
|
||||
const prev = manifest({ dependsOn: { core: "1.x" } });
|
||||
const next = manifest({
|
||||
dependsOn: { core: "1.x", auth: "1.x" },
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("version downgrade throws", { timeout: 5000 }, () => {
|
||||
const prev = manifest({ version: "1.0.0" });
|
||||
const next = manifest({ version: "0.9.0" });
|
||||
try {
|
||||
requiresReinstall(prev, next);
|
||||
expect.fail("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(LifecycleError);
|
||||
const e = err as LifecycleError;
|
||||
expect(e.code).toBe(
|
||||
"LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE"
|
||||
);
|
||||
expect(e.context).toEqual({ prev: "1.0.0", next: "0.9.0" });
|
||||
}
|
||||
});
|
||||
|
||||
it("no schemaVersion in either manifest treats as zero", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(requiresReinstall(manifest(), manifest())).toBe(false);
|
||||
});
|
||||
|
||||
it("manifest with empty events block returns false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const prev = manifest({
|
||||
events: { publishes: [], subscribes: [] },
|
||||
});
|
||||
const next = manifest({
|
||||
events: { publishes: [], subscribes: [] },
|
||||
});
|
||||
expect(requiresReinstall(prev, next)).toBe(false);
|
||||
});
|
||||
|
||||
it("removing an item is NOT a reinstall trigger", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const prev = manifest({
|
||||
scopes: [{ name: "read" }, { name: "write" }],
|
||||
});
|
||||
const next = manifest({ scopes: [{ name: "read" }] });
|
||||
expect(requiresReinstall(prev, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
||||
import { LIFECYCLE_CODES, LifecycleError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Loose semver comparison. Returns -1 if `a < b`, 1 if `a > b`, 0 if equal.
|
||||
* Honours the `X.Y.Z` shape and ignores prerelease/build metadata
|
||||
* (everything from the first `-` or `+`).
|
||||
*
|
||||
* Robust enough for the manifest-version compare needed by
|
||||
* {@link requiresReinstall} — Stage-2's `api-manifest` validator will
|
||||
* already have rejected non-semver values.
|
||||
*/
|
||||
export function compareSemver(a: string, b: string): -1 | 0 | 1 {
|
||||
const norm = (s: string): number[] => {
|
||||
const sep = s.search(/[-+]/);
|
||||
const core = sep === -1 ? s : s.slice(0, sep);
|
||||
return core.split(".").map((part) => Number.parseInt(part, 10) || 0);
|
||||
};
|
||||
const ap = norm(a);
|
||||
const bp = norm(b);
|
||||
const len = Math.max(ap.length, bp.length);
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const ai = (ap[i] ?? 0) as number;
|
||||
const bi = (bp[i] ?? 0) as number;
|
||||
if (ai < bi) return -1;
|
||||
if (ai > bi) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function setOfNames(
|
||||
items: ReadonlyArray<{ readonly name: string }> | undefined
|
||||
): Set<string> {
|
||||
return new Set((items ?? []).map((it) => it.name));
|
||||
}
|
||||
|
||||
function hasNewItems<T extends { readonly name: string }>(
|
||||
prev: ReadonlyArray<T> | undefined,
|
||||
next: ReadonlyArray<T> | undefined
|
||||
): boolean {
|
||||
const prevSet = setOfNames(prev);
|
||||
for (const item of next ?? []) {
|
||||
if (!prevSet.has(item.name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasNewStrings(
|
||||
prev: readonly string[] | undefined,
|
||||
next: readonly string[] | undefined
|
||||
): boolean {
|
||||
const prevSet = new Set(prev ?? []);
|
||||
for (const item of next ?? []) {
|
||||
if (!prevSet.has(item)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function dependsOnDiffers(
|
||||
prev: Readonly<Record<string, string>> | undefined,
|
||||
next: Readonly<Record<string, string>> | undefined
|
||||
): boolean {
|
||||
const a = prev ?? {};
|
||||
const b = next ?? {};
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const k of keys) {
|
||||
if (a[k] !== b[k]) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the transition from `prev` to `next` requires a full
|
||||
* reinstall (versus a hot reload). Per design §4:
|
||||
* - `next.database.schemaVersion > prev.database.schemaVersion`
|
||||
* - new entries in `scopes`, `events.publishes`, `events.subscribes`,
|
||||
* `jobs.cron`, `jobs.queued`, or `secrets`
|
||||
* - any change to `dependsOn`
|
||||
*
|
||||
* If `next.version` is strictly lower than `prev.version`, throws
|
||||
* `LIFECYCLE.REINSTALL_REQUIRED_VERSION_DOWNGRADE` — downgrades are
|
||||
* disallowed (the ledger would then point at a higher schemaVersion than
|
||||
* the running code can satisfy).
|
||||
*/
|
||||
export function requiresReinstall(
|
||||
prev: PluginManifest,
|
||||
next: PluginManifest
|
||||
): boolean {
|
||||
if (compareSemver(next.version, prev.version) < 0) {
|
||||
throw new LifecycleError(
|
||||
LIFECYCLE_CODES.REINSTALL_REQUIRED_VERSION_DOWNGRADE,
|
||||
`Cannot reinstall: next.version (${next.version}) is lower than prev.version (${prev.version})`,
|
||||
{ context: { prev: prev.version, next: next.version } }
|
||||
);
|
||||
}
|
||||
|
||||
const prevSchema = prev.database?.schemaVersion ?? 0;
|
||||
const nextSchema = next.database?.schemaVersion ?? 0;
|
||||
if (nextSchema > prevSchema) return true;
|
||||
|
||||
if (hasNewItems(prev.scopes, next.scopes)) return true;
|
||||
if (hasNewStrings(prev.events?.publishes, next.events?.publishes))
|
||||
return true;
|
||||
if (hasNewStrings(prev.events?.subscribes, next.events?.subscribes))
|
||||
return true;
|
||||
if (hasNewItems(prev.jobs?.cron, next.jobs?.cron)) return true;
|
||||
if (hasNewItems(prev.jobs?.queued, next.jobs?.queued)) return true;
|
||||
if (hasNewItems(prev.secrets, next.secrets)) return true;
|
||||
if (dependsOnDiffers(prev.dependsOn, next.dependsOn)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user