Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { compareSemver, requiresReinstall } from "./reinstall-criteria.js";

View File

@@ -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.

View File

@@ -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);
});
});
});

View File

@@ -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;
}