feat(server): plan 3j groundwork — scheduler onto CronRegistrarService, SDK-contract confirmation, repository fitness

This commit is contained in:
jubnl
2026-09-24 01:16:10 +02:00
parent 14721e9658
commit a51aedbc62
3 changed files with 400 additions and 9 deletions
@@ -4,6 +4,7 @@ import { MikroORM } from '@mikro-orm/core';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { withRequestContext } from '../database/request-context';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { logError } from '../audit/audit-log.logger';
import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
@@ -47,6 +48,19 @@ import type { PluginActionDescriptor, PluginActionResult, PluginActionScope } fr
// any embedded space — the string is interpolated into the egress guard and the CSP.
const EGRESS_HOST_RE = /^(\*\.[a-z0-9-]+(\.[a-z0-9-]+)+|[a-z0-9-]+(\.[a-z0-9-]+)*)$/i;
// Plan 3j Task 0 (R-scheduler): the persistent per-plugin scheduler sweep + the GDPR
// erasure drain used to run on a bare, unregistered `setInterval` — no
// `CronRegistrarService` registration, no request context, no test-mode no-op. Six-field
// (seconds-resolution) cron expression: every :00 and :30 of every minute, the nearest
// exact equivalent to the legacy `setInterval(fn, 30_000)` cadence the `cron` package's
// `CronJob` supports (confirmed: it parses an optional leading seconds field). The one
// observable difference: the legacy interval fired 30s after PROCESS START and then
// every 30s from there (a rolling cadence); this fires at wall-clock :00/:30 boundaries
// (an absolute cadence) — still exactly once every 30 seconds, never coarser, just no
// longer phase-locked to boot time. See task-0-report.md for the full "For the user" note.
const PLUGIN_SCHEDULER_SWEEP_NAME = 'plugin-scheduler-sweep';
const PLUGIN_SCHEDULER_SWEEP_CRON = '*/30 * * * * *';
/**
* Remove `<plugins>/<id>` whether it is a real directory, a POSIX symlink or a
@@ -169,8 +183,6 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// author's source auto-reloads. Empty unless dev-link is used.
private readonly linkWatchers = new Map<string, fs.FSWatcher>();
// Sweeps plugin_scheduled_tasks for due callbacks and fires them on active plugins.
private schedulerSweep: ReturnType<typeof setInterval> | null = null;
// Coalesces overlapping erasure drains (the sweep and enqueue both trigger one).
private drainInFlight: Promise<void> | null = null;
@@ -211,6 +223,17 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// does needs this, the same lesson `CronRegistrarService` already learned
// (task-2-fix-report.md's "Concerns").
@Optional() private readonly orm?: MikroORM,
// Plan 3j Task 0 (R-scheduler): the one path this codebase schedules a cron through
// (server/CLAUDE.md). `@Optional()` for the same reason `uow`/`orm` are — the wide
// hand-construction surface this class has across the test suite (plugin-host.ts,
// boot-registry-order.test.ts, settings-isolation.test.ts, plugin-runtime.boot-no-
// orm.test.ts) never passes a 9th positional arg; a hand-built instance without one
// simply never registers the sweep (no log — mirrors how an absent `hostFactory`/
// `registry` produces no boot-time log either, only a failure at first use, and
// registering nothing is exactly what happens today too whenever a test never lets
// the 30s interval actually fire). Nest always injects the real one in production
// (PluginsRuntimeModule imports SchedulingModule).
@Optional() private readonly registrar?: CronRegistrarService,
) {}
private get db() {
@@ -258,8 +281,28 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// boot must NEVER block app init, even in a context without plugin tables
// (e.g. a slimmed-down test app that only imports AdminModule).
try {
await discoverPlugins(this.db);
const installed = await this.installedDepRows();
// Plan 3j Task 0 (R-scheduler, inventory §8): `discoverPlugins` + this dependency-
// order read are raw better-sqlite3 today and need no request context to run
// correctly, but the MOMENT either becomes a repository call (Task 2/3) this
// window needs one — landing the wrap now, ahead of that conversion, is the whole
// point of doing this in Task 0. Wrapped ONLY when an ORM is available: the
// no-ORM branch below still needs `order` computed (from THESE two reads) to name
// the skipped plugins in its own log line, so running them unwrapped when `this.
// orm` is absent preserves that already-tested behavior exactly (RT-BOOT-NOORM-001/
// 002) rather than gating discovery itself on the ORM's presence — a broader
// restructure (option (b): wrap the WHOLE onApplicationBootstrap body in one call,
// matching CronRegistrarService.runOnBoot's shape) was considered and rejected for
// this reason; see task-0-report.md.
let installed: Map<string, PluginDepRow>;
if (this.orm) {
installed = await withRequestContext(this.orm, async () => {
await discoverPlugins(this.db);
return this.installedDepRows();
});
} else {
await discoverPlugins(this.db);
installed = await this.installedDepRows();
}
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
let order: string[];
try {
@@ -302,16 +345,22 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
// Fire due scheduled tasks (persistent, userless) on a coarse tick — the
// scheduler is minute-granularity by contract, so 30s precision is plenty and
// cheap. Unref'd so it never holds the process open.
this.schedulerSweep = setInterval(() => {
// cheap. Plan 3j Task 0 (R-scheduler): registered through CronRegistrarService
// instead of a bare setInterval, so this tick gets the registrar's own
// withRequestContext wrap (wrappedTick) for free — the SAME choke point every
// other domain's cron already gets — and its test-mode no-op (register() is a
// no-op under NODE_ENV=test, isEnabled() false). No bespoke wrapping code needed
// here: PR4–PR10 stay raw better-sqlite3 for now (Task 2 converts their bodies on
// top of this landed registration) and their calling shape below is byte-for-byte
// what the old setInterval callback ran.
this.registrar?.register(PLUGIN_SCHEDULER_SWEEP_NAME, PLUGIN_SCHEDULER_SWEEP_CRON, () => {
// R1.5: a timer callback cannot await. Both helpers already swallow their own
// errors (each body is wrapped in try/catch); the .catch below is defense-in-
// depth against a future regression, same shape as the pruneErrorLog call in
// the onLog hook above.
void this.fireDueScheduled().catch(() => { /* fireDueScheduled already handles its own errors — this is a backstop */ });
void this.drainUserErasures();
}, 30_000);
this.schedulerSweep.unref?.();
});
}
/** Fire every scheduled task that is due on an ACTIVE plugin; re-arm recurring
@@ -497,7 +546,13 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
setPluginEventSink(null);
setUserDeletedSink(null);
setStagedRestoreApplier(null);
if (this.schedulerSweep) { clearInterval(this.schedulerSweep); this.schedulerSweep = null; }
// Plan 3j Task 0: the registrar now owns the sweep's timer lifecycle
// (CronRegistrarService.onApplicationShutdown unregisters every job it holds
// regardless), but this explicit unregister keeps a module-level teardown
// symmetric with the setup above rather than relying solely on a LATER Nest
// shutdown hook — a no-op both when no registrar was ever provided and when
// nothing was ever registered (e.g. plugins disabled).
this.registrar?.unregister(PLUGIN_SCHEDULER_SWEEP_NAME);
for (const w of this.linkWatchers.values()) {
try { w.close(); } catch { /* ignore */ }
}
@@ -37,6 +37,7 @@ import { AtlasModule } from '../atlas/atlas.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { JournalRpcModule } from '../journey/journal-rpc.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
/**
* The plugin execution half (#plugins, M1/M2): the process supervisor, the
@@ -62,6 +63,12 @@ import { JournalRpcModule } from '../journey/journal-rpc.module';
// module: the domains would then have to import this one back and close a cycle.
PluginGuardsModule,
PluginOAuthModule,
// Plan 3j Task 0 (R-scheduler): PluginRuntimeService now registers its persistent
// scheduler sweep + GDPR erasure drain through CronRegistrarService, which lives
// in a deliberately non-@Global module (server/CLAUDE.md: the one cron path) — so
// it must be imported explicitly here, same as every other job-owning module
// (place-shadow.module.ts is the precedent).
SchedulingModule,
WeatherModule, TagsModule, CategoriesModule, BudgetModule, ReservationsModule,
TodoModule, PackingModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, LlmParseModule,
FilesModule, CollabModule, VacayModule, TripsModule, PlacesModule,
@@ -0,0 +1,329 @@
/**
* Plan 3j Task 0 (R-scheduler, plan3j-sql-inventory.md §8 — "the plan's single
* biggest finding"): `PluginRuntimeService`'s persistent per-plugin scheduler sweep
* (`fireDueScheduled`) and its GDPR erasure drain (`drainUserErasures`) used to run
* on a bare, unregistered `setInterval(fn, 30_000)` inside `onApplicationBootstrap`
* — no `CronRegistrarService` registration, no `withRequestContext` wrap, no
* test-mode no-op, and (per program rule 12) the one cron-shaped tick in this
* codebase found bypassing the registrar entirely. Fixed by registering the SAME
* two calls through `CronRegistrarService.register()` instead, at the same cadence
* (a six-field, every-30-seconds cron expression — the `cron` package supports an
* optional leading seconds field; confirmed by reading its README and pinned by the
* cadence assertion below, PLUGIN_SCHEDULER_SWEEP_CRON in plugin-runtime.service.ts).
* PR4–PR10 (the sweep/drain's own SQL) stay raw
* better-sqlite3 in this task — only WHO calls them and WHETHER a request context
* wraps the call changes here; Task 2 converts their statement bodies on top of
* this landed registration.
*
* Two mandatory tests (task-0-brief.md deliverable 2):
*
* (a) RATCHET (`SCHED-SWEEP-001`/`-002`) — the sweep is registered through
* `CronRegistrarService.register()`, not a bare `setInterval`: mocking `cron`'s
* `CronJob.from` the same way `cron-registrar.service.test.ts` does and
* asserting exactly one job was captured, with the expected name and cadence,
* is itself the first mutation-proof (revert to a bare `setInterval` and
* `CronJob.from` is never called for this sweep — `h.jobs` stays empty and the
* length assertion fails before this file gets anywhere near firing a tick).
* Firing that captured tick from a bare context — nothing this file's own test
* body sets up ahead of the call — must not log `cannotUseGlobalContext`/
* "global EntityManager" (the literal assertion task-0-brief.md deliverable
* 2(a) asks for) AND must POSITIVELY show a request context was active when
* `fireDueScheduled`/`drainUserErasures` ran (`demo-seed-request-context.
* test.ts`'s own M2 lesson: a log-line-only check cannot distinguish a wrapped
* call from an unwrapped one when the wrapped body doesn't yet touch the ORM —
* true here today, since PR4–PR10 haven't converted). A second, harsher
* mutation-proof (`SCHED-SWEEP-002`): a `CronRegistrarService` built WITHOUT an
* ORM fails the SAME sweep's tick closed with the registrar's own distinct
* "no MikroORM available" error (`CRONREG-010`'s exact proof, reproduced here
* against `PluginRuntimeService`'s real registration) — proving this sweep
* genuinely routes through the registrar's one choke point rather than a
* bespoke wrapper of its own that might swallow the missing-context case.
*
* (b) CADENCE/BEHAVIOUR PARITY (`SCHED-SWEEP-003`) — a due one-shot task, a due
* recurring task and a queued GDPR erasure are seeded; firing ONE tick through
* the REAL registered path (not the private methods called directly) proves:
* the one-shot row is deleted BEFORE its delivery is attempted, the recurring
* row is re-armed (a later `due_at`) BEFORE its delivery is attempted (crash-
* safety ordering — asserted AT THE MOMENT delivery fires, not just as an
* end-state, so a future reordering is caught even if delivery itself no-ops),
* and the queued erasure row drains once the plugin ACKs — the SAME
* assertions the legacy `setInterval`-driven sweep produced, now proven
* through `CronRegistrarService.register()`.
*
* A fourth case (`SCHED-SWEEP-004`) covers task-0-brief.md deliverable 3, the
* `onApplicationBootstrap` pre-loop wrap decision: `discoverPlugins`+
* `installedDepRows` (both raw SQL today, needing no context to run correctly —
* option (a) wraps them together, ONLY when an ORM is available, rather than
* gating the whole method body on one, which would have broken
* `plugin-runtime.boot-no-orm.test.ts`'s already-pinned no-ORM boot behavior; see
* plugin-runtime.service.ts's own comment at the wrap site) already ran inside a
* request context by the time this file's shared `runtime` finished booting.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { RequestContext } from '@mikro-orm/core';
import { SchedulerRegistry } from '@nestjs/schedule';
const h = vi.hoisted(() => ({
jobs: [] as Array<{
cronTime: string;
timeZone?: string;
start?: boolean;
onTick: () => unknown;
stopped: boolean;
stop(): void;
}>,
}));
// Same mock shape as tests/unit/nest/cron-registrar.service.test.ts: capture what
// CronRegistrarService.register() hands CronJob.from (including the REAL wrappedTick,
// since register() itself is not mocked) without arming any real timer.
vi.mock('cron', () => ({
CronJob: {
from: (opts: { cronTime: string; timeZone?: string; start?: boolean; onTick: () => unknown }) => {
const job = { ...opts, stopped: false, stop() { this.stopped = true; } };
h.jobs.push(job);
return job;
},
},
}));
import { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { createTestAddonsService } from '../../helpers/test-addons';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
import { Users } from '../../../src/db/entities/Users.entity';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let runtime: PluginRuntimeService;
const prevEnabled = process.env.TREK_PLUGINS_ENABLED;
const prevDir = process.env.TREK_PLUGINS_DIR;
const prevDataDir = process.env.TREK_PLUGINS_DATA_DIR;
let codeRoot: string;
let dataRoot: string;
let preLoopContextSeen: boolean | undefined;
async function buildRuntime(registrar?: CronRegistrarService, orm?: TestOrm['orm']): Promise<PluginRuntimeService> {
const dbs = new DatabaseService(testDb);
const audit = new AuditService(t.repo(AuditLog), t.repo(Users));
const addons = await createTestAddonsService(testDb, dbs);
const userSettings = new PluginUserSettingsService(dbs);
return new PluginRuntimeService(dbs, audit, addons, userSettings, undefined, undefined, undefined, orm, registrar);
}
beforeAll(async () => {
// Global context disallowed — the production setting (matches CRONREG's own D6
// suite and plugin-runtime.boot-no-orm.test.ts), so a genuinely unwrapped call
// really does throw rather than happening to work under a permissive default.
t = await createTestOrm(testDb, { allowGlobalContext: false });
process.env.TREK_PLUGINS_ENABLED = 'true';
// No plugins on disk and none `enabled` in the seeded row set below — discovery
// and the activate loop find nothing to do, so this file only exercises the
// scheduler registration, never a real child spawn (plugin-runtime.test.ts /
// dev-link.test.ts already cover the spawn path). Real (empty) temp dirs rather
// than the server/data default, so discovery is deterministic across environments.
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-sweep-code-'));
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-sweep-data-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
const registry = new SchedulerRegistry();
const registrar = new CronRegistrarService(registry, { isTest: () => false } as RuntimeEnvService, t.orm);
runtime = await buildRuntime(registrar, t.orm);
// Plan 3j Task 0 deliverable 3 (the onApplicationBootstrap pre-loop wrap): spied
// BEFORE the one onApplicationBootstrap call this file drives, so SCHED-SWEEP-004
// can assert a request context was already active when installedDepRows (the
// second of the two pre-loop reads discoverPlugins+installedDepRows share ONE
// withRequestContext wrap around, option (a) — see plugin-runtime.service.ts)
// ran, proving the wrap decision without needing either read to touch the ORM
// yet (raw SQL until Task 2/3 convert them).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const preLoopSpy = vi.spyOn(runtime as any, 'installedDepRows').mockImplementation(async function (this: PluginRuntimeService) {
preLoopContextSeen = RequestContext.currentRequestContext() !== undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (Object.getPrototypeOf(runtime) as any).installedDepRows.apply(this);
});
await runtime.onApplicationBootstrap();
preLoopSpy.mockRestore();
});
afterAll(async () => {
if (prevEnabled === undefined) delete process.env.TREK_PLUGINS_ENABLED; else process.env.TREK_PLUGINS_ENABLED = prevEnabled;
if (prevDir === undefined) delete process.env.TREK_PLUGINS_DIR; else process.env.TREK_PLUGINS_DIR = prevDir;
if (prevDataDir === undefined) delete process.env.TREK_PLUGINS_DATA_DIR; else process.env.TREK_PLUGINS_DATA_DIR = prevDataDir;
await runtime.onModuleDestroy();
await t.close();
testDb.close();
fs.rmSync(codeRoot, { recursive: true, force: true });
fs.rmSync(dataRoot, { recursive: true, force: true });
});
const settle = async () => {
// drainUserErasures is fire-and-forget from the tick's own body (parity with the
// legacy `void this.drainUserErasures();`), so its awaited chain (runDrainOnce's
// `await this.supervisor.deliverUserErasure(...)`) is still resolving after
// `onTick()`'s own promise settles — same reasoning boot-sweeps-request-
// context.test.ts's docstring gives for its own extra `setImmediate` waits.
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
};
describe('PluginRuntimeService scheduler sweep — registered through CronRegistrarService (Plan 3j Task 0, R-scheduler)', () => {
it('SCHED-SWEEP-001: registered via CronJob.from (not a bare setInterval) at the exact legacy cadence; firing the captured tick from a bare context logs no cannotUseGlobalContext line and DOES run fireDueScheduled/drainUserErasures inside a live request context', async () => {
expect(h.jobs).toHaveLength(1);
expect(h.jobs[0].cronTime).toBe('*/30 * * * * *');
expect(h.jobs[0].start).toBe(true);
const contextSeen: Record<string, boolean> = {};
// Positive proof (demo-seed-request-context.test.ts's own M2 lesson): a
// log-line-only check can't distinguish "wrapped, body just doesn't touch the
// ORM yet" from "never wrapped at all" — both produce zero error lines today,
// since PR4-PR10 stay raw SQL in this task. Recording
// RequestContext.currentRequestContext() at the instant each private method is
// entered is the real signal (the same one MikroORM's allowGlobalContext gate
// reads at query time), and it is what a genuine `withRequestContext` wrap
// controls, independent of what the wrapped body happens to read.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fireSpy = vi.spyOn(runtime as any, 'fireDueScheduled').mockImplementation(async function (this: PluginRuntimeService, ...args: unknown[]) {
contextSeen.fireDueScheduled = RequestContext.currentRequestContext() !== undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (Object.getPrototypeOf(runtime) as any).fireDueScheduled.apply(this, args);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const drainSpy = vi.spyOn(runtime as any, 'drainUserErasures').mockImplementation(async function (this: PluginRuntimeService, ...args: unknown[]) {
contextSeen.drainUserErasures = RequestContext.currentRequestContext() !== undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (Object.getPrototypeOf(runtime) as any).drainUserErasures.apply(this, args);
});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await h.jobs[0].onTick();
await settle();
expect(fireSpy).toHaveBeenCalledTimes(1);
expect(drainSpy).toHaveBeenCalledTimes(1);
expect(contextSeen.fireDueScheduled).toBe(true);
expect(contextSeen.drainUserErasures).toBe(true);
const suspicious = errSpy.mock.calls
.map((args) => args.map(String).join(' '))
.filter((line) => /cannotUseGlobalContext|global EntityManager/i.test(line));
expect(suspicious).toEqual([]);
} finally {
fireSpy.mockRestore();
drainSpy.mockRestore();
errSpy.mockRestore();
}
});
it('SCHED-SWEEP-002: without an ORM behind the registrar, the SAME sweep\'s tick throws the registrar\'s own "no MikroORM available" error rather than running unwrapped (CRONREG-010, reproduced against the real PluginRuntimeService registration)', async () => {
const registry2 = new SchedulerRegistry();
const registrar2 = new CronRegistrarService(registry2, { isTest: () => false } as RuntimeEnvService); // no orm arg
const runtime2 = await buildRuntime(registrar2, undefined);
const before = h.jobs.length;
try {
await runtime2.onApplicationBootstrap();
expect(h.jobs).toHaveLength(before + 1);
const job2 = h.jobs[before];
let bodyRan = false;
// Prove the fail-closed error comes from the registrar's own wrapper, not a
// side effect of the tick body itself ever having run.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(runtime2 as any, 'fireDueScheduled').mockImplementation(async () => { bodyRan = true; });
await expect(job2.onTick()).rejects.toThrow(/no MikroORM available/i);
expect(bodyRan).toBe(false);
} finally {
await runtime2.onModuleDestroy();
}
});
it('SCHED-SWEEP-003: a due one-shot task deletes-before-fires, a due recurring task re-arms-before-fires, and a queued GDPR erasure drains on ACK — fired through the REAL registered tick', async () => {
const pluginId = 'sched-sweep-fixture';
const now = Date.now();
const oneShotSeedDueAt = now - 5_000;
const recurringSeedDueAt = now - 5_000;
// runDrainOnce's own first pass reaps a `plugin_user_erasure_queue` row as an
// "orphan" once its plugin_id has no matching `plugins` row AND no data dir on
// disk — a real installed plugin always has (at least) a `plugins` row, so this
// fixture needs one too, or the erasure row would be reaped before ever
// reaching the ACK-gated delivery path this test is actually proving.
testDb.prepare("INSERT INTO plugins (id, name) VALUES (?, ?)").run(pluginId, pluginId);
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)')
.run(pluginId, 'oneshot', oneShotSeedDueAt, 'null', null);
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)')
.run(pluginId, 'recurring', recurringSeedDueAt, 'null', 60_000);
testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?,?)').run(pluginId, 4242);
// Mark the plugin active without a real child spawn — the same technique
// tests/unit/plugins/supervisor-lifecycle.test.ts already uses to drive
// PluginSupervisor's status-gated methods directly.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supervisor = (runtime as any).supervisor;
supervisor.running.set(pluginId, { id: pluginId, status: 'active' });
const invokeCalls: Array<{ method: string; payload: unknown }> = [];
const invokeSpy = vi.spyOn(supervisor, 'invoke').mockImplementation((...args: unknown[]) => {
const [id, method, payload] = args as [string, string, unknown];
invokeCalls.push({ method, payload });
if (method === 'invoke.scheduled') {
const name = (payload as { name: string }).name;
// Crash-safety ordering, asserted AT delivery time (not just end-state):
// the row's disposition write must already have landed by the moment
// delivery is attempted, since a crash here must never double-fire.
if (name === 'oneshot') {
const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'oneshot');
expect(row).toBeUndefined(); // already deleted before delivery
} else if (name === 'recurring') {
const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'recurring') as { due_at: number } | undefined;
expect(row).toBeDefined();
expect(row!.due_at).toBeGreaterThan(recurringSeedDueAt); // already re-armed before delivery
}
}
return Promise.resolve({});
});
try {
await h.jobs[0].onTick();
await settle();
// Same-shape delivery calls the legacy setInterval-driven sweep made.
expect(invokeCalls.filter((c) => c.method === 'invoke.scheduled').map((c) => (c.payload as { name: string }).name).sort())
.toEqual(['oneshot', 'recurring']);
expect(invokeCalls).toContainEqual({ method: 'invoke.deleteUserData', payload: { userId: 4242 } });
const oneShotRow = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'oneshot');
expect(oneShotRow).toBeUndefined();
const recurringRow = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'recurring') as { due_at: number } | undefined;
expect(recurringRow).toBeDefined();
// Re-armed to Date.now() (read fresh, at fire time — not the seed timestamp)
// + every_ms: bounded rather than exact, since real wall-clock time elapses
// between seeding the row and the tick firing it.
expect(recurringRow!.due_at).toBeGreaterThanOrEqual(now + 60_000);
expect(recurringRow!.due_at).toBeLessThan(now + 60_000 + 30_000);
const erasureRow = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 4242);
expect(erasureRow).toBeUndefined(); // drained on ACK
} finally {
invokeSpy.mockRestore();
supervisor.running.delete(pluginId);
}
});
it('SCHED-SWEEP-004: the onApplicationBootstrap pre-loop reads (discoverPlugins + installedDepRows, option (a) — plugin-runtime.service.ts) already ran inside a request context', () => {
// Recorded in beforeAll, before this file's one onApplicationBootstrap call —
// see that block's own comment. A regression that drops the wrap (or narrows it
// to only one of the two reads while calling them separately, unwrapped) fails
// this assertion directly, independent of PR4-PR53's own conversion state.
expect(preLoopContextSeen).toBe(true);
});
});