fix(server): plan 3a re-review follow-ups — plugin boot activation and the airports backfill inside a request context; the supervisor answers the child before failing closed; BOOT-SWEEP-001 drives the real boot hooks; ws adapter seam pinned

This commit is contained in:
jubnl
2026-09-22 17:39:38 +02:00
parent 761cd14b2b
commit 46bb11f508
10 changed files with 370 additions and 54 deletions
+6 -2
View File
@@ -2,14 +2,18 @@ import { Module } from '@nestjs/common';
import { AirportsController } from './airports.controller';
import { AirportsService } from './airports.service';
import { DatabaseModule } from '../database/database.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { AirportsMcp } from './airports.mcp';
/** Airports domain (L2 leaf module). Registered in AppModule.
* DatabaseModule is imported explicitly: the flight-endpoint backfill needs the
* connection, and @Global only reaches modules that are in the graph — which
* the airports e2e TestingModule is not. */
* the airports e2e TestingModule is not. SchedulingModule (task-6-rereview.md
* M1) is imported for the same reason — it is deliberately NOT @Global — so
* the boot backfill can route through CronRegistrarService.runOnBoot instead
* of running outside a request context. */
@Module({
imports: [DatabaseModule],
imports: [DatabaseModule, SchedulingModule],
controllers: [AirportsController],
providers: [AirportsService, AirportsMcp],
})
+20 -6
View File
@@ -2,6 +2,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import type { Airport } from '@trek/shared';
import { searchAirports, findByIata, load } from './airports.data';
import { DatabaseService } from '../database/database.service';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
/**
* The in-container face of the airport dataset, plus the flight-endpoint
@@ -11,17 +12,30 @@ import { DatabaseService } from '../database/database.service';
* require() back into services/ — a cycle that only held because `db` happened
* to be initialised by then. It runs on application bootstrap now: it repairs
* rows, nothing boots on it, and the container is fully built by that point.
*
* task-6-rereview.md M1: raw SQL only today, but this is the same boot-sweep
* shape C1 found repository-backed elsewhere, so it goes through the one
* choke point (`CronRegistrarService.runOnBoot`) every other one-off boot
* sweep uses now, rather than running outside a request context directly.
* `runOnBoot` has no `isEnabled()` gate (unlike `register()`), so — parity
* with the pre-existing behavior — the backfill still runs in every test
* harness that boots this service with a MikroORM, exactly as it did before.
*/
@Injectable()
export class AirportsService implements OnApplicationBootstrap {
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
private readonly registrar: CronRegistrarService,
) {}
async onApplicationBootstrap(): Promise<void> {
try {
await this.backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
await this.registrar.runOnBoot('airports-flight-endpoints-boot', async () => {
try {
await this.backfillFlightEndpoints();
} catch (err) {
console.error('[DB] Flight endpoint backfill failed:', err);
}
});
}
search(query: string): Airport[] {
@@ -3,6 +3,8 @@ import semver from 'semver';
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 { logError } from '../audit/audit-log.logger';
import { pluginsEnabled } from './kill-switch';
import { setPluginEventSink } from '../../plugin-event-sink';
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
@@ -186,11 +188,14 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
private readonly userSettings: PluginUserSettingsService,
private readonly registry?: PluginRegistryService,
private readonly hostFactory?: PluginRpcHostFactory,
// LAST, and optional only because TypeScript forbids a required parameter after
// an optional one — Nest always injects it (OrmModule is global). A hand-built
// Optional only because TypeScript forbids a required parameter after an
// optional one — Nest always injects it (OrmModule is global). A hand-built
// test instance that exercises a transaction must pass one; setOperatorEgressHosts
// refuses rather than writing outside a transaction (same shape as hostFactory).
private readonly uow?: UnitOfWork,
// `@Optional()` (task-6-rereview.md M2 — harmonised with the `orm?` param two
// lines below, which got one in the previous fix wave for exactly this reason;
// a plain `?` is TS-only and does not tell Nest's DI a provider may be absent).
@Optional() private readonly uow?: UnitOfWork,
// D6 (task-2-review.md C3): the supervisor wraps every plugin RPC dispatch in a
// request context built from this. Nest always injects it (MikroOrmCoreModule is
// global); a hand-built test instance that dispatches through a real
@@ -264,15 +269,45 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
// own gate still refuses to spawn, so nothing boots into a broken state.
order = enabledIds;
}
for (const id of order) {
this.activate(id).catch((e) => {
// A plugin whose required addon is off (or a dependency is missing) at boot
// must not stay marked enabled — reconcile the row so the UI reflects reality.
if (e instanceof PluginDependencyError || e instanceof DependencyCycleError) {
this.deactivate(id).catch(() => {});
}
/* other failures: status is persisted as error by the supervisor hook */
});
// task-6-rereview.md I1: assertActivatable (inside activate) reads
// AddonsService.isAddonEnabled — repository-backed — so this boot loop needs
// the same request-context wrap every other D6 choke point got, or an
// enabled plugin with a non-empty requiredAddons throws cannotUseGlobalContext
// here, silently: the .catch below only reconciles PluginDependencyError /
// DependencyCycleError, and the outer try/catch around this whole block eats
// everything else.
// `activate()` itself stays unwrapped (its HTTP callers already run inside a
// request); only this boot-time call site needs it. Fail closed the same way
// the boot RULING (task-6-rereview.md §2) settled for CronRegistrarService.
// runOnBoot: no ORM means skip loudly and never call activate — never abort
// app.init() over a partially-wired test graph.
// task-6-rereview.md I1: assertActivatable (inside activate) reads
// AddonsService.isAddonEnabled — repository-backed — so this boot loop needs
// the same request-context wrap every other D6 choke point got, or an
// enabled plugin with a non-empty requiredAddons throws cannotUseGlobalContext
// here, silently: the .catch below only reconciles PluginDependencyError /
// DependencyCycleError, and the outer try/catch around this whole block eats
// everything else.
// `activate()` itself stays unwrapped (its HTTP callers already run inside a
// request); only this boot-time call site needs it. Fail closed the same way
// the boot RULING (task-6-rereview.md §2) settled for CronRegistrarService.
// runOnBoot: no ORM means skip loudly and never call activate — never abort
// app.init() over a partially-wired test graph.
if (!this.orm) {
if (order.length) {
logError(`PluginRuntimeService.onApplicationBootstrap: no MikroORM available — plugin(s) not activated: ${order.join(', ')}`);
}
} else {
for (const id of order) {
withRequestContext(this.orm, () => this.activate(id)).catch((e) => {
// A plugin whose required addon is off (or a dependency is missing) at boot
// must not stay marked enabled — reconcile the row so the UI reflects reality.
if (e instanceof PluginDependencyError || e instanceof DependencyCycleError) {
this.deactivate(id).catch(() => {});
}
/* other failures: status is persisted as error by the supervisor hook */
});
}
}
} catch {
/* discovery/boot must never block app init */
@@ -585,6 +585,16 @@ export class PluginSupervisor {
// that dispatch through this path now pass a thunk (`sharedTestOrm`).
const orm = this.resolveOrm?.();
if (!orm) {
// task-6-rereview.md §5 RULING: the child's `pending` map
// (plugin-host-entry.ts) has no timeout, so leaving this 'req'
// unanswered hangs the plugin's ctx.* promise forever — and
// handleChildMessage above downgrades the throw below to one
// plugin-scoped log line, never surfacing it as a host-wiring
// failure. Answer the child first, same shape as the rate-limiter
// refusal a few lines up, THEN throw for host-side visibility. The
// dispatch still never runs either way — this only changes whether
// the caller ever hears back.
sup.child?.send({ k: 'res', id: req.id, ok: false, error: { code: 'HOST_ERROR', message: 'no ORM available to build a request context' } } satisfies RpcError);
throw new Error('PluginSupervisor: no ORM available to build a request context for this RPC dispatch');
}
const res = await withRequestContext(orm, () => sup.rpcHost.dispatch(req, actingUserId));