mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user