Unified · Split
| @@ -0,0 +1,29 @@ | ||
| 1 | +# pure plugin reconciler poc | |
| 2 | + | |
| 3 | +this branch isolates the framework-free implementation from the three-way plugin runtime experiment. | |
| 4 | + | |
| 5 | +## shape | |
| 6 | + | |
| 7 | +- an iterative capability graph plans activation and targeted restarts | |
| 8 | +- plugin instances own explicit lifo finalizer stacks | |
| 9 | +- contributions are staged per plugin and published as frozen snapshots | |
| 10 | +- failed candidates are cleaned up without replacing the live composition | |
| 11 | +- cleanup attempts every dependent-first instance and aggregates failures | |
| 12 | + | |
| 13 | +this is the smallest and fastest planner in the experiment. for production, its graph algorithm should drive effect scopes rather than keeping manual finalizer ownership. | |
| 14 | + | |
| 15 | +## proved | |
| 16 | + | |
| 17 | +- 18 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, provider-value replacement, unusual ids and slots, cleanup failures, immutable snapshots, and a 20,000-plugin stack-safe chain | |
| 18 | +- manifest validation keeps mobile declarative | |
| 19 | +- the demo exercises activation, replacement, teardown, and a 250-plugin chain | |
| 20 | + | |
| 21 | +## run | |
| 22 | + | |
| 23 | +```sh | |
| 24 | +vp test run packages/plugin-runtime-poc/src | |
| 25 | +vp run --filter @t3tools/plugin-runtime-pure-poc typecheck | |
| 26 | +vp run --filter @t3tools/plugin-runtime-pure-poc demo | |
| 27 | +``` | |
| 28 | + | |
| 29 | +this is an isolated runtime poc. it does not load packages, add a sandbox, expose plugin rpc, or wire product ui. | |
| @@ -0,0 +1,17 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@t3tools/plugin-runtime-pure-poc", | |
| 3 | + "private": true, | |
| 4 | + "type": "module", | |
| 5 | + "scripts": { | |
| 6 | + "test": "vp test run", | |
| 7 | + "typecheck": "tsgo --noEmit", | |
| 8 | + "demo": "node src/demo.ts" | |
| 9 | + }, | |
| 10 | + "dependencies": { | |
| 11 | + "effect": "4.0.0-beta.103" | |
| 12 | + }, | |
| 13 | + "devDependencies": { | |
| 14 | + "@types/node": "24.12.4", | |
| 15 | + "vite-plus": "catalog:" | |
| 16 | + } | |
| 17 | +} | |
| @@ -0,0 +1,41 @@ | ||
| 1 | +export interface Contribution { | |
| 2 | + readonly id: string; | |
| 3 | + readonly label: string; | |
| 4 | +} | |
| 5 | + | |
| 6 | +export interface PluginDefinition { | |
| 7 | + readonly id: string; | |
| 8 | + readonly version: string; | |
| 9 | + readonly requires?: ReadonlyArray<string>; | |
| 10 | + readonly provides?: Readonly<Record<string, unknown>>; | |
| 11 | + readonly activate: (context: PluginActivationContext) => void | Promise<void>; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export interface PluginActivationContext { | |
| 15 | + readonly resolve: <Service>(capability: string) => Service; | |
| 16 | + readonly register: (slot: string, contribution: Contribution) => void; | |
| 17 | + readonly onDispose: (finalizer: () => void | Promise<void>) => void; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export interface PluginRuntimeSnapshot { | |
| 21 | + readonly active: ReadonlyArray<string>; | |
| 22 | + readonly blocked: Readonly<Record<string, string>>; | |
| 23 | + readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export interface PluginRuntime { | |
| 27 | + readonly reconcile: ( | |
| 28 | + definitions: ReadonlyArray<PluginDefinition>, | |
| 29 | + ) => Promise<PluginRuntimeSnapshot>; | |
| 30 | + readonly snapshot: () => PluginRuntimeSnapshot; | |
| 31 | + readonly dispose: () => Promise<void>; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export interface PluginRuntimeOptions { | |
| 35 | + readonly onLifecycle?: (event: { | |
| 36 | + readonly phase: "activate" | "deactivate"; | |
| 37 | + readonly pluginId: string; | |
| 38 | + }) => void; | |
| 39 | +} | |
| 40 | + | |
| 41 | +export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime; | |
| @@ -0,0 +1,77 @@ | ||
| 1 | +import type { PluginDefinition, PluginRuntimeSnapshot } from "./contract.ts"; | |
| 2 | +import { createPureRuntime } from "./pureRuntime.ts"; | |
| 3 | + | |
| 4 | +const commandLabels = (snapshot: PluginRuntimeSnapshot) => | |
| 5 | + snapshot.contributions.commands?.map((command) => command.label) ?? []; | |
| 6 | + | |
| 7 | +const database = (version: string): PluginDefinition => ({ | |
| 8 | + id: "demo.database", | |
| 9 | + version, | |
| 10 | + provides: { "demo.database@1": { version } }, | |
| 11 | + activate(context) { | |
| 12 | + context.register("status", { id: "database", label: `database ${version}` }); | |
| 13 | + }, | |
| 14 | +}); | |
| 15 | + | |
| 16 | +const issues: PluginDefinition = { | |
| 17 | + id: "demo.issues", | |
| 18 | + version: "1.0.0", | |
| 19 | + requires: ["demo.database@1"], | |
| 20 | + activate(context) { | |
| 21 | + const service = context.resolve<{ readonly version: string }>("demo.database@1"); | |
| 22 | + context.register("commands", { | |
| 23 | + id: "create-issue", | |
| 24 | + label: `create issue with database ${service.version}`, | |
| 25 | + }); | |
| 26 | + }, | |
| 27 | +}; | |
| 28 | + | |
| 29 | +const chain = (length: number): ReadonlyArray<PluginDefinition> => | |
| 30 | + Array.from({ length }, (_, index) => ({ | |
| 31 | + id: `benchmark.plugin.${index}`, | |
| 32 | + version: "1.0.0", | |
| 33 | + ...(index === 0 ? {} : { requires: [`benchmark.capability.${index - 1}`] }), | |
| 34 | + provides: { [`benchmark.capability.${index}`]: index }, | |
| 35 | + activate() {}, | |
| 36 | + })); | |
| 37 | + | |
| 38 | +const lifecycle: Array<string> = []; | |
| 39 | +const runtime = createPureRuntime({ | |
| 40 | + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | |
| 41 | +}); | |
| 42 | +const initial = await runtime.reconcile([issues, database("1.0.0")]); | |
| 43 | +const upgraded = await runtime.reconcile([issues, database("2.0.0")]); | |
| 44 | +await runtime.reconcile([]); | |
| 45 | + | |
| 46 | +const benchmark = createPureRuntime(); | |
| 47 | +const benchmarkDefinitions = chain(250); | |
| 48 | +const startedAt = process.hrtime.bigint(); | |
| 49 | +await benchmark.reconcile(benchmarkDefinitions); | |
| 50 | +const activationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; | |
| 51 | +const noopStartedAt = process.hrtime.bigint(); | |
| 52 | +await benchmark.reconcile(benchmarkDefinitions); | |
| 53 | +const noopMs = Number(process.hrtime.bigint() - noopStartedAt) / 1_000_000; | |
| 54 | +const upgradedDefinitions = benchmarkDefinitions.map((definition, index) => | |
| 55 | + index === 0 ? { ...definition, version: "2.0.0" } : definition, | |
| 56 | +); | |
| 57 | +const restartStartedAt = process.hrtime.bigint(); | |
| 58 | +await benchmark.reconcile(upgradedDefinitions); | |
| 59 | +const restartMs = Number(process.hrtime.bigint() - restartStartedAt) / 1_000_000; | |
| 60 | +await benchmark.dispose(); | |
| 61 | + | |
| 62 | +process.stdout.write( | |
| 63 | + `${JSON.stringify( | |
| 64 | + { | |
| 65 | + variant: "pure-reconciler", | |
| 66 | + initialCommands: commandLabels(initial), | |
| 67 | + upgradedCommands: commandLabels(upgraded), | |
| 68 | + lifecycle, | |
| 69 | + chain250ActivationMs: Math.round(activationMs * 100) / 100, | |
| 70 | + chain250NoopMs: Math.round(noopMs * 100) / 100, | |
| 71 | + chain250RootRestartMs: Math.round(restartMs * 100) / 100, | |
| 72 | + }, | |
| 73 | + null, | |
| 74 | + 2, | |
| 75 | + )}\n`, | |
| 76 | +); | |
| 77 | +await runtime.dispose(); | |
| @@ -0,0 +1,63 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | +import { describe, expect, it } from "vite-plus/test"; | |
| 3 | + | |
| 4 | +import { PluginManifest } from "./manifest.ts"; | |
| 5 | + | |
| 6 | +const decodeManifest = Schema.decodeUnknownSync(PluginManifest); | |
| 7 | + | |
| 8 | +const validManifest = { | |
| 9 | + id: "com.acme.linear", | |
| 10 | + version: "1.2.0", | |
| 11 | + engines: { t3: "^0.1.0" }, | |
| 12 | + entrypoints: { | |
| 13 | + server: "./dist/server.js", | |
| 14 | + web: "./dist/web.js", | |
| 15 | + }, | |
| 16 | + requires: ["t3.commands@1", "t3.secrets@1"], | |
| 17 | + provides: ["com.acme.linear@1"], | |
| 18 | + permissions: ["network:https://api.linear.app", "secrets:linear-token"], | |
| 19 | + contributes: { | |
| 20 | + commands: ["linear.create-issue"], | |
| 21 | + settings: ["linear.settings"], | |
| 22 | + views: ["thread.right-panel"], | |
| 23 | + }, | |
| 24 | +}; | |
| 25 | + | |
| 26 | +describe("PluginManifest", () => { | |
| 27 | + it("decodes a namespaced multi-surface plugin manifest", () => { | |
| 28 | + expect(decodeManifest(validManifest)).toEqual(validManifest); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("rejects unnamespaced plugin and contribution ids", () => { | |
| 32 | + expect(() => decodeManifest({ ...validManifest, id: "linear" })).toThrow(); | |
| 33 | + expect(() => | |
| 34 | + decodeManifest({ | |
| 35 | + ...validManifest, | |
| 36 | + contributes: { ...validManifest.contributes, commands: ["create-issue"] }, | |
| 37 | + }), | |
| 38 | + ).toThrow(); | |
| 39 | + }); | |
| 40 | + | |
| 41 | + it("rejects malformed versions and capability ids", () => { | |
| 42 | + expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); | |
| 43 | + expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("keeps mobile declarative by excluding a mobile executable entrypoint", () => { | |
| 47 | + const decoded = decodeManifest({ | |
| 48 | + ...validManifest, | |
| 49 | + surfaces: ["web", "desktop", "mobile"], | |
| 50 | + contributes: { ...validManifest.contributes, mobileCards: ["linear.summary"] }, | |
| 51 | + }); | |
| 52 | + | |
| 53 | + expect(decoded.surfaces).toEqual(["web", "desktop", "mobile"]); | |
| 54 | + expect(decoded.contributes.mobileCards).toEqual(["linear.summary"]); | |
| 55 | + expect(decoded.entrypoints).not.toHaveProperty("mobile"); | |
| 56 | + expect(() => | |
| 57 | + decodeManifest({ | |
| 58 | + ...validManifest, | |
| 59 | + entrypoints: { ...validManifest.entrypoints, mobile: "./dist/mobile.js" }, | |
| 60 | + }), | |
| 61 | + ).toThrow(); | |
| 62 | + }); | |
| 63 | +}); | |
| @@ -0,0 +1,42 @@ | ||
| 1 | +import * as Schema from "effect/Schema"; | |
| 2 | + | |
| 3 | +const NamespacedId = Schema.String.check( | |
| 4 | + Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/), | |
| 5 | +); | |
| 6 | + | |
| 7 | +const SemanticVersion = Schema.String.check( | |
| 8 | + Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/), | |
| 9 | +); | |
| 10 | + | |
| 11 | +const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/)); | |
| 12 | + | |
| 13 | +const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/)); | |
| 14 | +const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); | |
| 15 | + | |
| 16 | +const ContributionCatalog = Schema.Struct({ | |
| 17 | + commands: Schema.optional(Schema.Array(NamespacedId)), | |
| 18 | + settings: Schema.optional(Schema.Array(NamespacedId)), | |
| 19 | + views: Schema.optional(Schema.Array(NamespacedId)), | |
| 20 | + mobileCards: Schema.optional(Schema.Array(NamespacedId)), | |
| 21 | +}); | |
| 22 | + | |
| 23 | +export const PluginManifest = Schema.Struct({ | |
| 24 | + id: NamespacedId, | |
| 25 | + version: SemanticVersion, | |
| 26 | + engines: Schema.Struct({ | |
| 27 | + t3: Schema.String.check(Schema.isNonEmpty()), | |
| 28 | + }), | |
| 29 | + surfaces: Schema.optional(Schema.Array(Schema.Literals(["web", "desktop", "mobile"]))), | |
| 30 | + entrypoints: Schema.Struct({ | |
| 31 | + server: Schema.optional(RelativeEntrypoint), | |
| 32 | + web: Schema.optional(RelativeEntrypoint), | |
| 33 | + desktop: Schema.optional(RelativeEntrypoint), | |
| 34 | + }).annotate({ parseOptions: { onExcessProperty: "error" } }), | |
| 35 | + requires: Schema.optional(Schema.Array(CapabilityId)), | |
| 36 | + optional: Schema.optional(Schema.Array(CapabilityId)), | |
| 37 | + provides: Schema.optional(Schema.Array(CapabilityId)), | |
| 38 | + permissions: Schema.optional(Schema.Array(Permission)), | |
| 39 | + contributes: ContributionCatalog, | |
| 40 | +}); | |
| 41 | + | |
| 42 | +export type PluginManifest = typeof PluginManifest.Type; | |
| @@ -0,0 +1,29 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import type { PluginDefinition } from "./contract.ts"; | |
| 4 | +import { createPureRuntime } from "./pureRuntime.ts"; | |
| 5 | +import { defineRuntimeContract } from "./runtimeContract.ts"; | |
| 6 | + | |
| 7 | +defineRuntimeContract("pure reconciliation runtime", createPureRuntime); | |
| 8 | + | |
| 9 | +describe("pure reconciliation planner", () => { | |
| 10 | + it("plans a deep acyclic dependency chain without using the call stack", async () => { | |
| 11 | + const runtime = createPureRuntime(); | |
| 12 | + const pluginCount = 20_000; | |
| 13 | + const definitions: Array<PluginDefinition> = Array.from( | |
| 14 | + { length: pluginCount }, | |
| 15 | + (_, index) => ({ | |
| 16 | + id: `plugin-${index}`, | |
| 17 | + version: "1.0.0", | |
| 18 | + ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }), | |
| 19 | + provides: { [`capability-${index}`]: index }, | |
| 20 | + activate() {}, | |
| 21 | + }), | |
| 22 | + ); | |
| 23 | + | |
| 24 | + const snapshot = await runtime.reconcile(definitions.toReversed()); | |
| 25 | + | |
| 26 | + expect(snapshot.active).toHaveLength(pluginCount); | |
| 27 | + await runtime.dispose(); | |
| 28 | + }); | |
| 29 | +}); | |
| @@ -0,0 +1,378 @@ | ||
| 1 | +import type { | |
| 2 | + Contribution, | |
| 3 | + PluginDefinition, | |
| 4 | + PluginRuntime, | |
| 5 | + PluginRuntimeFactory, | |
| 6 | + PluginRuntimeSnapshot, | |
| 7 | +} from "./contract.ts"; | |
| 8 | + | |
| 9 | +type Finalizer = () => void | Promise<void>; | |
| 10 | + | |
| 11 | +interface PluginInstance { | |
| 12 | + readonly definition: PluginDefinition; | |
| 13 | + readonly contributions: Map<string, Array<Contribution>>; | |
| 14 | + readonly finalizers: Array<Finalizer>; | |
| 15 | + activated: boolean; | |
| 16 | +} | |
| 17 | + | |
| 18 | +interface Composition { | |
| 19 | + readonly instances: ReadonlyArray<PluginInstance>; | |
| 20 | + readonly snapshot: PluginRuntimeSnapshot; | |
| 21 | +} | |
| 22 | + | |
| 23 | +interface CompositionPlan { | |
| 24 | + readonly active: ReadonlyArray<PluginDefinition>; | |
| 25 | + readonly blocked: Readonly<Record<string, string>>; | |
| 26 | + readonly providers: ReadonlyMap<string, PluginDefinition>; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const createSnapshot = ( | |
| 30 | + instances: ReadonlyArray<PluginInstance>, | |
| 31 | + blocked: Readonly<Record<string, string>>, | |
| 32 | +): PluginRuntimeSnapshot => { | |
| 33 | + const contributions: Record<string, Array<Contribution>> = Object.create(null); | |
| 34 | + | |
| 35 | + for (const instance of instances) { | |
| 36 | + for (const [slot, registrations] of instance.contributions) { | |
| 37 | + const items = Object.hasOwn(contributions, slot) ? contributions[slot] : []; | |
| 38 | + if (items === undefined) continue; | |
| 39 | + items.push(...registrations); | |
| 40 | + contributions[slot] = items; | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + const frozenContributions: Record<string, ReadonlyArray<Contribution>> = Object.create(null); | |
| 45 | + for (const [slot, registrations] of Object.entries(contributions)) { | |
| 46 | + frozenContributions[slot] = Object.freeze([...registrations]); | |
| 47 | + } | |
| 48 | + | |
| 49 | + const frozenBlocked: Record<string, string> = Object.assign(Object.create(null), blocked); | |
| 50 | + | |
| 51 | + return Object.freeze({ | |
| 52 | + active: Object.freeze(instances.map(({ definition }) => definition.id)), | |
| 53 | + blocked: Object.freeze(frozenBlocked), | |
| 54 | + contributions: Object.freeze(frozenContributions), | |
| 55 | + }); | |
| 56 | +}; | |
| 57 | + | |
| 58 | +const emptyComposition = (): Composition => ({ | |
| 59 | + instances: [], | |
| 60 | + snapshot: createSnapshot([], {}), | |
| 61 | +}); | |
| 62 | + | |
| 63 | +const planComposition = (definitions: ReadonlyArray<PluginDefinition>): CompositionPlan => { | |
| 64 | + const byId = new Map<string, PluginDefinition>(); | |
| 65 | + const providers = new Map<string, PluginDefinition>(); | |
| 66 | + | |
| 67 | + for (const definition of definitions) { | |
| 68 | + if (byId.has(definition.id)) { | |
| 69 | + throw new Error(`duplicate plugin id: ${definition.id}`); | |
| 70 | + } | |
| 71 | + byId.set(definition.id, definition); | |
| 72 | + | |
| 73 | + for (const capability of Object.keys(definition.provides ?? {})) { | |
| 74 | + const previous = providers.get(capability); | |
| 75 | + if (previous !== undefined) { | |
| 76 | + throw new Error(`duplicate provider for ${capability}: ${previous.id}, ${definition.id}`); | |
| 77 | + } | |
| 78 | + providers.set(capability, definition); | |
| 79 | + } | |
| 80 | + } | |
| 81 | + | |
| 82 | + interface VisitFrame { | |
| 83 | + readonly definition: PluginDefinition; | |
| 84 | + requirementIndex: number; | |
| 85 | + reason: string | undefined; | |
| 86 | + } | |
| 87 | + | |
| 88 | + const states = new Map<string, "visiting" | "visited">(); | |
| 89 | + const path: Array<string> = []; | |
| 90 | + const pathIndexes = new Map<string, number>(); | |
| 91 | + const active: Array<PluginDefinition> = []; | |
| 92 | + const blocked: Record<string, string> = Object.create(null); | |
| 93 | + | |
| 94 | + for (const root of definitions) { | |
| 95 | + if (states.has(root.id)) continue; | |
| 96 | + | |
| 97 | + const stack: Array<VisitFrame> = [{ definition: root, requirementIndex: 0, reason: undefined }]; | |
| 98 | + states.set(root.id, "visiting"); | |
| 99 | + pathIndexes.set(root.id, path.length); | |
| 100 | + path.push(root.id); | |
| 101 | + | |
| 102 | + while (stack.length > 0) { | |
| 103 | + const frame = stack.at(-1); | |
| 104 | + if (frame === undefined) break; | |
| 105 | + const requirements = frame.definition.requires ?? []; | |
| 106 | + const capability = requirements[frame.requirementIndex]; | |
| 107 | + | |
| 108 | + if (capability !== undefined) { | |
| 109 | + const provider = providers.get(capability); | |
| 110 | + if (provider === undefined) { | |
| 111 | + frame.reason ??= `missing dependency: ${capability}`; | |
| 112 | + frame.requirementIndex += 1; | |
| 113 | + continue; | |
| 114 | + } | |
| 115 | + | |
| 116 | + const state = states.get(provider.id); | |
| 117 | + if (state === "visiting") { | |
| 118 | + const cycleStart = pathIndexes.get(provider.id); | |
| 119 | + if (cycleStart === undefined) throw new Error(`missing dependency path: ${provider.id}`); | |
| 120 | + const cycle = [...path.slice(cycleStart), provider.id]; | |
| 121 | + throw new Error(`dependency cycle detected: ${cycle.join(" -> ")}`); | |
| 122 | + } | |
| 123 | + if (state === undefined) { | |
| 124 | + stack.push({ definition: provider, requirementIndex: 0, reason: undefined }); | |
| 125 | + states.set(provider.id, "visiting"); | |
| 126 | + pathIndexes.set(provider.id, path.length); | |
| 127 | + path.push(provider.id); | |
| 128 | + continue; | |
| 129 | + } | |
| 130 | + | |
| 131 | + if (Object.hasOwn(blocked, provider.id)) { | |
| 132 | + frame.reason ??= `dependency unavailable: ${capability}`; | |
| 133 | + } | |
| 134 | + frame.requirementIndex += 1; | |
| 135 | + continue; | |
| 136 | + } | |
| 137 | + | |
| 138 | + stack.pop(); | |
| 139 | + path.pop(); | |
| 140 | + pathIndexes.delete(frame.definition.id); | |
| 141 | + states.set(frame.definition.id, "visited"); | |
| 142 | + if (frame.reason === undefined) { | |
| 143 | + active.push(frame.definition); | |
| 144 | + } else { | |
| 145 | + blocked[frame.definition.id] = frame.reason; | |
| 146 | + } | |
| 147 | + } | |
| 148 | + } | |
| 149 | + return { active, blocked, providers }; | |
| 150 | +}; | |
| 151 | + | |
| 152 | +const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => { | |
| 153 | + const previousRequires = previous.requires ?? []; | |
| 154 | + const desiredRequires = desired.requires ?? []; | |
| 155 | + const previousProvidedServices = previous.provides ?? {}; | |
| 156 | + const desiredProvidedServices = desired.provides ?? {}; | |
| 157 | + const previousProvides = Object.keys(previousProvidedServices); | |
| 158 | + const desiredProvides = Object.keys(desiredProvidedServices); | |
| 159 | + | |
| 160 | + return ( | |
| 161 | + previous.version === desired.version && | |
| 162 | + previousRequires.length === desiredRequires.length && | |
| 163 | + previousRequires.every((capability, index) => capability === desiredRequires[index]) && | |
| 164 | + previousProvides.length === desiredProvides.length && | |
| 165 | + previousProvides.every( | |
| 166 | + (capability, index) => | |
| 167 | + capability === desiredProvides[index] && | |
| 168 | + Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]), | |
| 169 | + ) | |
| 170 | + ); | |
| 171 | +}; | |
| 172 | + | |
| 173 | +const messageOf = (error: unknown) => | |
| 174 | + error instanceof Error ? error.message : "plugin activation failed"; | |
| 175 | + | |
| 176 | +export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { | |
| 177 | + let current = emptyComposition(); | |
| 178 | + let disposed = false; | |
| 179 | + let transition = Promise.resolve(); | |
| 180 | + | |
| 181 | + const deactivate = async (instance: PluginInstance): Promise<Array<unknown>> => { | |
| 182 | + const errors: Array<unknown> = []; | |
| 183 | + | |
| 184 | + if (instance.activated) { | |
| 185 | + try { | |
| 186 | + options.onLifecycle?.({ | |
| 187 | + phase: "deactivate", | |
| 188 | + pluginId: instance.definition.id, | |
| 189 | + }); | |
| 190 | + } catch (error) { | |
| 191 | + errors.push(error); | |
| 192 | + } | |
| 193 | + } | |
| 194 | + | |
| 195 | + for (let index = instance.finalizers.length - 1; index >= 0; index -= 1) { | |
| 196 | + const finalizer = instance.finalizers[index]; | |
| 197 | + if (finalizer === undefined) continue; | |
| 198 | + try { | |
| 199 | + await finalizer(); | |
| 200 | + } catch (error) { | |
| 201 | + errors.push(error); | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + return errors; | |
| 206 | + }; | |
| 207 | + | |
| 208 | + const deactivateAll = async ( | |
| 209 | + instances: ReadonlyArray<PluginInstance>, | |
| 210 | + ): Promise<Array<unknown>> => { | |
| 211 | + const errors: Array<unknown> = []; | |
| 212 | + for (let index = instances.length - 1; index >= 0; index -= 1) { | |
| 213 | + const instance = instances[index]; | |
| 214 | + if (instance !== undefined) errors.push(...(await deactivate(instance))); | |
| 215 | + } | |
| 216 | + return errors; | |
| 217 | + }; | |
| 218 | + | |
| 219 | + const stage = async ( | |
| 220 | + definitions: ReadonlyArray<PluginDefinition>, | |
| 221 | + previous: Composition, | |
| 222 | + ): Promise<Composition> => { | |
| 223 | + const plan = planComposition(definitions); | |
| 224 | + const previousById = new Map( | |
| 225 | + previous.instances.map((instance) => [instance.definition.id, instance]), | |
| 226 | + ); | |
| 227 | + const restart = new Set<string>(); | |
| 228 | + | |
| 229 | + for (const definition of plan.active) { | |
| 230 | + const existing = previousById.get(definition.id); | |
| 231 | + if (existing === undefined || !sameDefinitionShape(existing.definition, definition)) { | |
| 232 | + restart.add(definition.id); | |
| 233 | + } | |
| 234 | + } | |
| 235 | + | |
| 236 | + for (const definition of plan.active) { | |
| 237 | + if ( | |
| 238 | + (definition.requires ?? []).some((capability) => { | |
| 239 | + const provider = plan.providers.get(capability); | |
| 240 | + return provider !== undefined && restart.has(provider.id); | |
| 241 | + }) | |
| 242 | + ) { | |
| 243 | + restart.add(definition.id); | |
| 244 | + } | |
| 245 | + } | |
| 246 | + | |
| 247 | + const services = new Map<string, unknown>(); | |
| 248 | + const staged: Array<PluginInstance> = []; | |
| 249 | + const stagedById = new Map<string, PluginInstance>(); | |
| 250 | + | |
| 251 | + for (const definition of plan.active) { | |
| 252 | + if (restart.has(definition.id)) continue; | |
| 253 | + const instance = previousById.get(definition.id); | |
| 254 | + if (instance === undefined) { | |
| 255 | + throw new Error(`missing reusable plugin instance: ${definition.id}`); | |
| 256 | + } | |
| 257 | + for (const [capability, service] of Object.entries(instance.definition.provides ?? {})) { | |
| 258 | + services.set(capability, service); | |
| 259 | + } | |
| 260 | + } | |
| 261 | + | |
| 262 | + try { | |
| 263 | + for (const definition of plan.active) { | |
| 264 | + if (!restart.has(definition.id)) continue; | |
| 265 | + | |
| 266 | + const instance: PluginInstance = { | |
| 267 | + definition, | |
| 268 | + contributions: new Map(), | |
| 269 | + finalizers: [], | |
| 270 | + activated: false, | |
| 271 | + }; | |
| 272 | + staged.push(instance); | |
| 273 | + stagedById.set(definition.id, instance); | |
| 274 | + | |
| 275 | + let activating = true; | |
| 276 | + const assertActivating = () => { | |
| 277 | + if (!activating) { | |
| 278 | + throw new Error(`activation context for ${definition.id} is no longer active`); | |
| 279 | + } | |
| 280 | + }; | |
| 281 | + | |
| 282 | + try { | |
| 283 | + await definition.activate({ | |
| 284 | + resolve: <Service>(capability: string): Service => { | |
| 285 | + assertActivating(); | |
| 286 | + if (!services.has(capability)) { | |
| 287 | + throw new Error(`plugin ${definition.id} could not resolve ${capability}`); | |
| 288 | + } | |
| 289 | + return services.get(capability) as Service; | |
| 290 | + }, | |
| 291 | + register: (slot, contribution) => { | |
| 292 | + assertActivating(); | |
| 293 | + const items = instance.contributions.get(slot) ?? []; | |
| 294 | + items.push(Object.freeze({ id: contribution.id, label: contribution.label })); | |
| 295 | + instance.contributions.set(slot, items); | |
| 296 | + }, | |
| 297 | + onDispose: (finalizer) => { | |
| 298 | + assertActivating(); | |
| 299 | + instance.finalizers.push(finalizer); | |
| 300 | + }, | |
| 301 | + }); | |
| 302 | + } finally { | |
| 303 | + activating = false; | |
| 304 | + } | |
| 305 | + | |
| 306 | + instance.activated = true; | |
| 307 | + options.onLifecycle?.({ phase: "activate", pluginId: definition.id }); | |
| 308 | + for (const [capability, service] of Object.entries(definition.provides ?? {})) { | |
| 309 | + services.set(capability, service); | |
| 310 | + } | |
| 311 | + } | |
| 312 | + | |
| 313 | + const instances = plan.active.map((definition) => { | |
| 314 | + const instance = stagedById.get(definition.id) ?? previousById.get(definition.id); | |
| 315 | + if (instance === undefined) { | |
| 316 | + throw new Error(`missing plugin instance after staging: ${definition.id}`); | |
| 317 | + } | |
| 318 | + return instance; | |
| 319 | + }); | |
| 320 | + return { | |
| 321 | + instances, | |
| 322 | + snapshot: createSnapshot(instances, plan.blocked), | |
| 323 | + }; | |
| 324 | + } catch (activationError) { | |
| 325 | + const cleanupErrors = await deactivateAll(staged); | |
| 326 | + if (cleanupErrors.length > 0) { | |
| 327 | + throw new Error( | |
| 328 | + `${messageOf(activationError)}; ${cleanupErrors.length} rollback finalizer(s) also failed`, | |
| 329 | + { cause: activationError }, | |
| 330 | + ); | |
| 331 | + } | |
| 332 | + throw activationError; | |
| 333 | + } | |
| 334 | + }; | |
| 335 | + | |
| 336 | + const runExclusive = <Result>(operation: () => Promise<Result>): Promise<Result> => { | |
| 337 | + const result = transition.then(operation); | |
| 338 | + transition = result.then( | |
| 339 | + () => undefined, | |
| 340 | + () => undefined, | |
| 341 | + ); | |
| 342 | + return result; | |
| 343 | + }; | |
| 344 | + | |
| 345 | + return { | |
| 346 | + reconcile: (definitions) => { | |
| 347 | + const desired = [...definitions]; | |
| 348 | + return runExclusive(async () => { | |
| 349 | + if (disposed) throw new Error("plugin runtime is disposed"); | |
| 350 | + | |
| 351 | + const previous = current; | |
| 352 | + const candidate = await stage(desired, previous); | |
| 353 | + current = candidate; | |
| 354 | + | |
| 355 | + const retained = new Set(candidate.instances); | |
| 356 | + const retired = previous.instances.filter((instance) => !retained.has(instance)); | |
| 357 | + const cleanupErrors = await deactivateAll(retired); | |
| 358 | + if (cleanupErrors.length > 0) { | |
| 359 | + throw new AggregateError(cleanupErrors, "previous composition deactivation failed"); | |
| 360 | + } | |
| 361 | + return candidate.snapshot; | |
| 362 | + }); | |
| 363 | + }, | |
| 364 | + snapshot: () => current.snapshot, | |
| 365 | + dispose: () => | |
| 366 | + runExclusive(async () => { | |
| 367 | + if (disposed) return; | |
| 368 | + disposed = true; | |
| 369 | + | |
| 370 | + const previous = current; | |
| 371 | + current = emptyComposition(); | |
| 372 | + const cleanupErrors = await deactivateAll(previous.instances); | |
| 373 | + if (cleanupErrors.length > 0) { | |
| 374 | + throw new AggregateError(cleanupErrors, "plugin runtime disposal failed"); | |
| 375 | + } | |
| 376 | + }), | |
| 377 | + }; | |
| 378 | +}; | |
| @@ -0,0 +1,331 @@ | ||
| 1 | +import { describe, expect, it } from "vite-plus/test"; | |
| 2 | + | |
| 3 | +import type { PluginDefinition, PluginRuntimeFactory, PluginRuntimeSnapshot } from "./contract.ts"; | |
| 4 | + | |
| 5 | +const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) => | |
| 6 | + snapshot.contributions[slot]?.map((item) => item.label) ?? []; | |
| 7 | + | |
| 8 | +const provider = (version = "1.0.0"): PluginDefinition => ({ | |
| 9 | + id: "acme.database", | |
| 10 | + version, | |
| 11 | + provides: { "acme.database@1": { name: `database-${version}` } }, | |
| 12 | + activate(context) { | |
| 13 | + context.register("status", { id: "database", label: `database ${version}` }); | |
| 14 | + }, | |
| 15 | +}); | |
| 16 | + | |
| 17 | +const consumer = (): PluginDefinition => ({ | |
| 18 | + id: "acme.issues", | |
| 19 | + version: "1.0.0", | |
| 20 | + requires: ["acme.database@1"], | |
| 21 | + activate(context) { | |
| 22 | + const database = context.resolve<{ readonly name: string }>("acme.database@1"); | |
| 23 | + context.register("commands", { id: "create-issue", label: database.name }); | |
| 24 | + }, | |
| 25 | +}); | |
| 26 | + | |
| 27 | +export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) { | |
| 28 | + describe(name, () => { | |
| 29 | + it("activates providers before consumers regardless of manifest order", async () => { | |
| 30 | + const runtime = createRuntime(); | |
| 31 | + | |
| 32 | + const snapshot = await runtime.reconcile([consumer(), provider()]); | |
| 33 | + | |
| 34 | + expect(snapshot.active).toEqual(["acme.database", "acme.issues"]); | |
| 35 | + expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]); | |
| 36 | + await runtime.dispose(); | |
| 37 | + }); | |
| 38 | + | |
| 39 | + it("blocks missing dependencies without blocking independent plugins", async () => { | |
| 40 | + const runtime = createRuntime(); | |
| 41 | + const independent: PluginDefinition = { | |
| 42 | + id: "acme.clock", | |
| 43 | + version: "1.0.0", | |
| 44 | + activate(context) { | |
| 45 | + context.register("status", { id: "clock", label: "clock" }); | |
| 46 | + }, | |
| 47 | + }; | |
| 48 | + | |
| 49 | + const snapshot = await runtime.reconcile([consumer(), independent]); | |
| 50 | + | |
| 51 | + expect(snapshot.active).toEqual(["acme.clock"]); | |
| 52 | + expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1"); | |
| 53 | + expect(contributionLabels(snapshot, "status")).toEqual(["clock"]); | |
| 54 | + await runtime.dispose(); | |
| 55 | + }); | |
| 56 | + | |
| 57 | + it("deactivates dependents before providers", async () => { | |
| 58 | + const lifecycle: Array<string> = []; | |
| 59 | + const runtime = createRuntime({ | |
| 60 | + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | |
| 61 | + }); | |
| 62 | + await runtime.reconcile([provider(), consumer()]); | |
| 63 | + lifecycle.length = 0; | |
| 64 | + | |
| 65 | + await runtime.reconcile([]); | |
| 66 | + | |
| 67 | + expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]); | |
| 68 | + await runtime.dispose(); | |
| 69 | + }); | |
| 70 | + | |
| 71 | + it("runs plugin finalizers in reverse registration order", async () => { | |
| 72 | + const disposed: Array<string> = []; | |
| 73 | + const runtime = createRuntime(); | |
| 74 | + await runtime.reconcile([ | |
| 75 | + { | |
| 76 | + id: "acme.finalizers", | |
| 77 | + version: "1.0.0", | |
| 78 | + activate(context) { | |
| 79 | + context.onDispose(() => { | |
| 80 | + disposed.push("first"); | |
| 81 | + }); | |
| 82 | + context.onDispose(() => { | |
| 83 | + disposed.push("second"); | |
| 84 | + }); | |
| 85 | + }, | |
| 86 | + }, | |
| 87 | + ]); | |
| 88 | + | |
| 89 | + await runtime.reconcile([]); | |
| 90 | + | |
| 91 | + expect(disposed).toEqual(["second", "first"]); | |
| 92 | + await runtime.dispose(); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it("keeps unchanged plugin scopes alive across reconciliation", async () => { | |
| 96 | + let activations = 0; | |
| 97 | + let disposals = 0; | |
| 98 | + const stable: PluginDefinition = { | |
| 99 | + id: "acme.stable", | |
| 100 | + version: "1.0.0", | |
| 101 | + activate(context) { | |
| 102 | + activations += 1; | |
| 103 | + context.onDispose(() => { | |
| 104 | + disposals += 1; | |
| 105 | + }); | |
| 106 | + }, | |
| 107 | + }; | |
| 108 | + const runtime = createRuntime(); | |
| 109 | + | |
| 110 | + await runtime.reconcile([stable]); | |
| 111 | + await runtime.reconcile([stable]); | |
| 112 | + | |
| 113 | + expect(activations).toBe(1); | |
| 114 | + expect(disposals).toBe(0); | |
| 115 | + await runtime.dispose(); | |
| 116 | + expect(disposals).toBe(1); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + it("restarts a changed provider and its dependents without touching independent plugins", async () => { | |
| 120 | + let independentActivations = 0; | |
| 121 | + const lifecycle: Array<string> = []; | |
| 122 | + const independent: PluginDefinition = { | |
| 123 | + id: "acme.independent", | |
| 124 | + version: "1.0.0", | |
| 125 | + activate() { | |
| 126 | + independentActivations += 1; | |
| 127 | + }, | |
| 128 | + }; | |
| 129 | + const runtime = createRuntime({ | |
| 130 | + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | |
| 131 | + }); | |
| 132 | + await runtime.reconcile([consumer(), independent, provider()]); | |
| 133 | + lifecycle.length = 0; | |
| 134 | + | |
| 135 | + const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]); | |
| 136 | + | |
| 137 | + expect(independentActivations).toBe(1); | |
| 138 | + expect(lifecycle).not.toContain("activate:acme.independent"); | |
| 139 | + expect(lifecycle).not.toContain("deactivate:acme.independent"); | |
| 140 | + expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ | |
| 141 | + "activate:acme.database", | |
| 142 | + "activate:acme.issues", | |
| 143 | + ]); | |
| 144 | + expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([ | |
| 145 | + "deactivate:acme.issues", | |
| 146 | + "deactivate:acme.database", | |
| 147 | + ]); | |
| 148 | + expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]); | |
| 149 | + await runtime.dispose(); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + it("returns deeply frozen snapshots", async () => { | |
| 153 | + const runtime = createRuntime(); | |
| 154 | + | |
| 155 | + const snapshot = await runtime.reconcile([provider()]); | |
| 156 | + const status = snapshot.contributions.status; | |
| 157 | + | |
| 158 | + expect(Object.isFrozen(snapshot)).toBe(true); | |
| 159 | + expect(Object.isFrozen(snapshot.active)).toBe(true); | |
| 160 | + expect(Object.isFrozen(snapshot.blocked)).toBe(true); | |
| 161 | + expect(Object.isFrozen(snapshot.contributions)).toBe(true); | |
| 162 | + expect(Object.isFrozen(status)).toBe(true); | |
| 163 | + expect(Object.isFrozen(status?.[0])).toBe(true); | |
| 164 | + await runtime.dispose(); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + it("treats every plugin id and contribution slot as data", async () => { | |
| 168 | + const runtime = createRuntime(); | |
| 169 | + const unusualProvider: PluginDefinition = { | |
| 170 | + id: "toString", | |
| 171 | + version: "1.0.0", | |
| 172 | + provides: { "acme.unusual@1": "available" }, | |
| 173 | + activate(context) { | |
| 174 | + context.register("__proto__", { id: "provider", label: "provider" }); | |
| 175 | + }, | |
| 176 | + }; | |
| 177 | + const unusualConsumer: PluginDefinition = { | |
| 178 | + id: "constructor", | |
| 179 | + version: "1.0.0", | |
| 180 | + requires: ["acme.unusual@1"], | |
| 181 | + activate(context) { | |
| 182 | + context.resolve("acme.unusual@1"); | |
| 183 | + context.register("toString", { id: "consumer", label: "consumer" }); | |
| 184 | + }, | |
| 185 | + }; | |
| 186 | + const unusualBlocked: PluginDefinition = { | |
| 187 | + id: "__proto__", | |
| 188 | + version: "1.0.0", | |
| 189 | + requires: ["acme.missing@1"], | |
| 190 | + activate() { | |
| 191 | + throw new Error("blocked plugin activated"); | |
| 192 | + }, | |
| 193 | + }; | |
| 194 | + | |
| 195 | + const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]); | |
| 196 | + | |
| 197 | + expect(snapshot.active).toEqual(["toString", "constructor"]); | |
| 198 | + expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true); | |
| 199 | + expect(snapshot.blocked.__proto__).toContain("acme.missing@1"); | |
| 200 | + expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true); | |
| 201 | + expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true); | |
| 202 | + expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]); | |
| 203 | + expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]); | |
| 204 | + await runtime.dispose(); | |
| 205 | + }); | |
| 206 | + | |
| 207 | + it("restarts dependents when a provided value changes", async () => { | |
| 208 | + const lifecycle: Array<string> = []; | |
| 209 | + const runtime = createRuntime({ | |
| 210 | + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | |
| 211 | + }); | |
| 212 | + const serviceProvider = (name: string): PluginDefinition => ({ | |
| 213 | + id: "acme.service", | |
| 214 | + version: "1.0.0", | |
| 215 | + provides: { "acme.service@1": { name } }, | |
| 216 | + activate() {}, | |
| 217 | + }); | |
| 218 | + const serviceConsumer: PluginDefinition = { | |
| 219 | + id: "acme.service-consumer", | |
| 220 | + version: "1.0.0", | |
| 221 | + requires: ["acme.service@1"], | |
| 222 | + activate(context) { | |
| 223 | + const service = context.resolve<{ readonly name: string }>("acme.service@1"); | |
| 224 | + context.register("commands", { id: "service", label: service.name }); | |
| 225 | + }, | |
| 226 | + }; | |
| 227 | + await runtime.reconcile([serviceProvider("first"), serviceConsumer]); | |
| 228 | + lifecycle.length = 0; | |
| 229 | + | |
| 230 | + const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]); | |
| 231 | + | |
| 232 | + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); | |
| 233 | + expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([ | |
| 234 | + "activate:acme.service", | |
| 235 | + "activate:acme.service-consumer", | |
| 236 | + ]); | |
| 237 | + await runtime.dispose(); | |
| 238 | + }); | |
| 239 | + | |
| 240 | + it("does not report a failed candidate as activated", async () => { | |
| 241 | + const lifecycle: Array<string> = []; | |
| 242 | + const runtime = createRuntime({ | |
| 243 | + onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), | |
| 244 | + }); | |
| 245 | + const broken: PluginDefinition = { | |
| 246 | + id: "acme.broken", | |
| 247 | + version: "1.0.0", | |
| 248 | + activate() { | |
| 249 | + throw new Error("activation failed"); | |
| 250 | + }, | |
| 251 | + }; | |
| 252 | + | |
| 253 | + await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed"); | |
| 254 | + | |
| 255 | + expect(lifecycle).toEqual([]); | |
| 256 | + await runtime.dispose(); | |
| 257 | + }); | |
| 258 | + | |
| 259 | + it("attempts every plugin cleanup when one finalizer fails", async () => { | |
| 260 | + const disposed: Array<string> = []; | |
| 261 | + const runtime = createRuntime(); | |
| 262 | + await runtime.reconcile([ | |
| 263 | + { | |
| 264 | + id: "acme.first-cleanup", | |
| 265 | + version: "1.0.0", | |
| 266 | + activate(context) { | |
| 267 | + context.onDispose(() => { | |
| 268 | + disposed.push("first"); | |
| 269 | + }); | |
| 270 | + }, | |
| 271 | + }, | |
| 272 | + { | |
| 273 | + id: "acme.second-cleanup", | |
| 274 | + version: "1.0.0", | |
| 275 | + activate(context) { | |
| 276 | + context.onDispose(() => { | |
| 277 | + disposed.push("second"); | |
| 278 | + throw new Error("cleanup failed"); | |
| 279 | + }); | |
| 280 | + }, | |
| 281 | + }, | |
| 282 | + ]); | |
| 283 | + | |
| 284 | + await expect(runtime.dispose()).rejects.toThrow(); | |
| 285 | + | |
| 286 | + expect(disposed).toEqual(["second", "first"]); | |
| 287 | + }); | |
| 288 | + | |
| 289 | + it("keeps the old plugin active when a replacement fails", async () => { | |
| 290 | + const runtime = createRuntime(); | |
| 291 | + await runtime.reconcile([provider()]); | |
| 292 | + const broken: PluginDefinition = { | |
| 293 | + ...provider("2.0.0"), | |
| 294 | + async activate(context) { | |
| 295 | + context.register("status", { id: "database", label: "database 2.0.0" }); | |
| 296 | + throw new Error("candidate failed"); | |
| 297 | + }, | |
| 298 | + }; | |
| 299 | + | |
| 300 | + await expect(runtime.reconcile([broken])).rejects.toThrow("candidate failed"); | |
| 301 | + | |
| 302 | + expect(runtime.snapshot().active).toEqual(["acme.database"]); | |
| 303 | + expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]); | |
| 304 | + await runtime.dispose(); | |
| 305 | + }); | |
| 306 | + | |
| 307 | + it("rejects dependency cycles without disturbing the current composition", async () => { | |
| 308 | + const runtime = createRuntime(); | |
| 309 | + await runtime.reconcile([provider()]); | |
| 310 | + const alpha: PluginDefinition = { | |
| 311 | + id: "acme.alpha", | |
| 312 | + version: "1.0.0", | |
| 313 | + requires: ["acme.beta@1"], | |
| 314 | + provides: { "acme.alpha@1": true }, | |
| 315 | + activate() {}, | |
| 316 | + }; | |
| 317 | + const beta: PluginDefinition = { | |
| 318 | + id: "acme.beta", | |
| 319 | + version: "1.0.0", | |
| 320 | + requires: ["acme.alpha@1"], | |
| 321 | + provides: { "acme.beta@1": true }, | |
| 322 | + activate() {}, | |
| 323 | + }; | |
| 324 | + | |
| 325 | + await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle"); | |
| 326 | + | |
| 327 | + expect(runtime.snapshot().active).toEqual(["acme.database"]); | |
| 328 | + await runtime.dispose(); | |
| 329 | + }); | |
| 330 | + }); | |
| 331 | +} | |
| @@ -0,0 +1,7 @@ | ||
| 1 | +{ | |
| 2 | + "extends": "../../tsconfig.base.json", | |
| 3 | + "compilerOptions": { | |
| 4 | + "types": ["node"] | |
| 5 | + }, | |
| 6 | + "include": ["src"] | |
| 7 | +} | |
| @@ -830,6 +830,19 @@ importers: · 829 unmodified lines | ||
| 830 | 830 | specifier: 'catalog:' |
| 831 | 831 | version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 832 | 832 | |
| 833 | + packages/plugin-runtime-poc: | |
| 834 | + dependencies: | |
| 835 | + effect: | |
| 836 | + specifier: 4.0.0-beta.103 | |
| 837 | + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) | |
| 838 | + devDependencies: | |
| 839 | + '@types/node': | |
| 840 | + specifier: 24.12.4 | |
| 841 | + version: 24.12.4 | |
| 842 | + vite-plus: | |
| 843 | + specifier: 'catalog:' | |
| 844 | + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 845 | + | |
| 833 | 846 | packages/shared: |
| 834 | 847 | dependencies: |
| 835 | 848 | '@noble/curves': |