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 | specifier: 'catalog:' | 830 | specifier: 'catalog:' |
| 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) | 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 | packages/shared: | 846 | packages/shared: |
| 834 | dependencies: | 847 | dependencies: |
| 835 | '@noble/curves': | 848 | '@noble/curves': |