Simplify plugin runtime and cleanup lifecycle

This commit is contained in:
Dotta
2026-03-13 16:58:29 -05:00
parent 80cdbdbd47
commit 12ccfc2c9a
21 changed files with 120 additions and 838 deletions

View File

@@ -112,60 +112,6 @@ function passesFilter(event: PluginEvent, filter: EventFilter | null): boolean {
return true;
}
// ---------------------------------------------------------------------------
// Company availability checker
// ---------------------------------------------------------------------------
/**
* Callback that checks whether a plugin is enabled for a given company.
*
* The event bus calls this during `emit()` to enforce company-scoped delivery:
* events are only delivered to a plugin if the plugin is enabled for the
* company that owns the event.
*
* Implementations should be fast — the bus caches results internally with a
* short TTL so the checker is not invoked on every single event.
*
* @param pluginKey The plugin registry key — the string passed to `forPlugin()`
* (e.g. `"acme.linear"`). This is the same key used throughout the bus
* internally and should not be confused with a numeric or UUID plugin ID.
* @param companyId UUID of the company to check availability for.
*
* Return `true` if the plugin is enabled (or if no settings row exists, i.e.
* default-enabled), `false` if the company has explicitly disabled the plugin.
*/
export type CompanyAvailabilityChecker = (
pluginKey: string,
companyId: string,
) => Promise<boolean>;
/**
* Options for {@link createPluginEventBus}.
*/
export interface PluginEventBusOptions {
/**
* Optional checker that gates event delivery per company.
*
* When provided, the bus will skip delivery to a plugin if the checker
* returns `false` for the `(pluginKey, event.companyId)` pair, where
* `pluginKey` is the registry key supplied to `forPlugin()`. Results are
* cached with a short TTL (30 s) to avoid excessive lookups.
*
* When omitted, no company-scoping is applied (useful in tests).
*/
isPluginEnabledForCompany?: CompanyAvailabilityChecker;
}
// Default cache TTL in milliseconds (30 seconds).
const AVAILABILITY_CACHE_TTL_MS = 30_000;
// Maximum number of entries in the availability cache before it is cleared.
// Prevents unbounded memory growth in long-running processes with many unique
// (pluginKey, companyId) pairs. A full clear is intentionally simple — the
// cache is advisory (performance only) and a miss merely triggers one extra
// async lookup.
const MAX_AVAILABILITY_CACHE_SIZE = 10_000;
// ---------------------------------------------------------------------------
// Event bus factory
// ---------------------------------------------------------------------------
@@ -200,40 +146,10 @@ const MAX_AVAILABILITY_CACHE_SIZE = 10_000;
* });
* ```
*/
export function createPluginEventBus(options?: PluginEventBusOptions): PluginEventBus {
const checker = options?.isPluginEnabledForCompany ?? null;
export function createPluginEventBus(): PluginEventBus {
// Subscription registry: pluginKey → list of subscriptions
const registry = new Map<string, Subscription[]>();
// Short-TTL cache for company availability lookups: "pluginKey\0companyId" → { enabled, expiresAt }
const availabilityCache = new Map<string, { enabled: boolean; expiresAt: number }>();
function cacheKey(pluginKey: string, companyId: string): string {
return `${pluginKey}\0${companyId}`;
}
/**
* Check whether a plugin is enabled for a company, using the cached result
* when available and falling back to the injected checker.
*/
async function isEnabledForCompany(pluginKey: string, companyId: string): Promise<boolean> {
if (!checker) return true;
const key = cacheKey(pluginKey, companyId);
const cached = availabilityCache.get(key);
if (cached && cached.expiresAt > Date.now()) {
return cached.enabled;
}
const enabled = await checker(pluginKey, companyId);
if (availabilityCache.size >= MAX_AVAILABILITY_CACHE_SIZE) {
availabilityCache.clear();
}
availabilityCache.set(key, { enabled, expiresAt: Date.now() + AVAILABILITY_CACHE_TTL_MS });
return enabled;
}
/**
* Retrieve or create the subscription list for a plugin.
*/
@@ -257,26 +173,7 @@ export function createPluginEventBus(options?: PluginEventBusOptions): PluginEve
const errors: Array<{ pluginId: string; error: unknown }> = [];
const promises: Promise<void>[] = [];
// Pre-compute company availability for all registered plugins when the
// event carries a companyId and a checker is configured. This batches
// the (potentially async) lookups so we don't interleave them with
// handler dispatch.
let disabledPlugins: Set<string> | null = null;
if (checker && event.companyId) {
const pluginKeys = Array.from(registry.keys());
const checks = await Promise.all(
pluginKeys.map(async (pluginKey) => ({
pluginKey,
enabled: await isEnabledForCompany(pluginKey, event.companyId!),
})),
);
disabledPlugins = new Set(checks.filter((c) => !c.enabled).map((c) => c.pluginKey));
}
for (const [pluginId, subs] of registry) {
// Skip delivery to plugins that are disabled for this company.
if (disabledPlugins?.has(pluginId)) continue;
for (const sub of subs) {
if (!matchesPattern(event.eventType, sub.eventPattern)) continue;
if (!passesFilter(event, sub.filter)) continue;