diff --git a/client/src/modules/version.js b/client/src/modules/version.js index c2f0754..ca9fe7d 100644 --- a/client/src/modules/version.js +++ b/client/src/modules/version.js @@ -11,6 +11,10 @@ // that the two files can drift, so a test asserts they agree // (client/test/moduleRegistry.test.js) rather than trusting a bump to remember // both. +// 1.11.0 — `ctx.events.expired({ kind, ref })` and the ledger's `expired` status +// (Rust PLAN_FIXES D183): a module may say the game ended a ledgered resource at +// its own deadline. Server-side only; the run console, which is core's own page, +// shows the new status. This file bumps for the reason at the top. // 1.10.0 — the event contract opens to modules (EVENTS.md §F, EVENTS_PLAN.md // Phase 7): a module may register event actions, budget dimensions, leases and // param option sources. All four are server-side registrations and nothing on @@ -74,4 +78,4 @@ // but the two halves state ONE version: a module declares a single coreApi range // and is served one chunk, so a client that claimed 1.0.0 while the server // answered 1.1.0 would be two answers to one question. -export const MODULE_API_VERSION = '1.10.0' +export const MODULE_API_VERSION = '1.11.0' diff --git a/client/src/routes/admin/views/EventRun.jsx b/client/src/routes/admin/views/EventRun.jsx index 78398f6..69c3e59 100644 --- a/client/src/routes/admin/views/EventRun.jsx +++ b/client/src/routes/admin/views/EventRun.jsx @@ -59,7 +59,7 @@ const STATUS_COLOR = { completed: '#8fc79a', } -// The six ledger statuses, in the two groups that matter to a reader: green is +// The seven ledger statuses, in the two groups that matter to a reader: green is // resolved, amber wants a person. `orphaned` and `drifted` are amber rather than // red because neither is a fault — one thing vanished, the other was taken by // somebody with every right to take it — and red is reserved for "this did not @@ -71,6 +71,9 @@ const RESOURCE_COLOR = { reverting: '#d9c184', drifted: '#d9c184', orphaned: '#d9c184', + // Green, like `reverted`: the game ended it at the deadline it was given, which + // is the plan working (MODULE_API 1.11.0). `orphaned` is the amber one. + expired: '#8fc79a', } const RESOURCE_WORD = { @@ -80,6 +83,7 @@ const RESOURCE_WORD = { reverted: 'given back', orphaned: 'gone', drifted: 'someone else moved it', + expired: 'ended by the game on time', } const STEP_COLOR = { diff --git a/server/db/schema.sql b/server/db/schema.sql index 2ef8de3..560aab2 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -2560,7 +2560,7 @@ CREATE TABLE IF NOT EXISTS event_run_resources ( -- defensible. This column is core's copy of that promise, for the console and -- for the boot-time check. lease_until DATETIME NULL, - status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted') + status ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending', -- Bounded like a step's `attempts`, and for the same reason: a revert that can -- never succeed must become visible rather than cycling for ever. Engagement @@ -2587,6 +2587,14 @@ CREATE TABLE IF NOT EXISTS event_run_resources ( INDEX idx_evres_live (status, lease_until) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- MODULE_API 1.11.0 (Rust PLAN_FIXES D183): `expired`, the game ending a resource +-- at its own deadline, for databases created before it. Terminal, so `live_marker` +-- releases the target. MODIFY has no IF NOT EXISTS form, but re-declaring the same +-- ENUM is an idempotent no-op -- checked against MariaDB 11.8 with the stored +-- `live_marker` column depending on it -- so it is safe on every boot. +ALTER TABLE event_run_resources MODIFY COLUMN status + ENUM('pending','confirmed','reverting','reverted','orphaned','drifted','expired') NOT NULL DEFAULT 'pending'; + -- §K's last bound: "a scheduled definition that has never been verified is the -- case worth refusing to start". A version is immutable, so a dry run that passed -- against it stays true — which is what makes the pass a property of the VERSION diff --git a/server/engagement-triggers.json b/server/engagement-triggers.json index 1d87575..156fa8e 100644 --- a/server/engagement-triggers.json +++ b/server/engagement-triggers.json @@ -1,6 +1,6 @@ { "_comment": "Generated event-trigger inventory - the authoritative freeze of CORE's engagement contract (docs/website/ENGAGEMENT.md 4.3). Regenerate with `npm run engagement:manifest` in website/server. A renamed variable, a changed type or a widened ceiling breaks stored templates and rules, so the diff here is the review signal. A module ships its own copy in its bundle; this file never contains one.", - "moduleApiVersion": "1.10.0", + "moduleApiVersion": "1.11.0", "triggers": [ { "id": "event.phase.changed", diff --git a/server/src/events/cleanup.js b/server/src/events/cleanup.js index b9878b2..fe886b6 100644 --- a/server/src/events/cleanup.js +++ b/server/src/events/cleanup.js @@ -427,6 +427,64 @@ async function reconcileAll() { return out } +// The bounds on what a module may name. They are the columns' own: a longer +// value cannot be a row, so it is refused rather than truncated into a match +// against somebody else's. +const MAX_KIND = 64 +const MAX_REF = 190 + +/** + * The game ended one of this module's resources by itself, as it was told to — + * `ctx.events.expired({ kind, ref })`, MODULE_API 1.11.0 (Rust PLAN_FIXES D183). + * + * **Expired is not orphaned, and the difference is the reason this exists.** A + * Rust zone is created with a deadline and the game erases it when the deadline + * passes, without being asked again — the fail-safe that makes an unattended + * world change defensible. Until a module could say so, core learned of it only + * through reconcile, which files it as `orphaned`: a thing that vanished while + * nobody was looking, amber on the console and still claimable for a revert. + * An expiry is the plan working, so it is terminal and green like `reverted`. + * + * `owner` is bound by the loader, never taken from the module's arguments, for + * `reconcile`'s reason: without the binding a module could close another + * module's rows. Answers `{ expired }`; nothing matching is `{ expired: 0 }`, + * because a module hears expiries of things no run ledgered too. + */ +async function expireResource(owner, { kind, ref } = {}) { + if (typeof kind !== 'string' || !kind || kind.length > MAX_KIND) return { expired: 0 } + if (typeof ref !== 'string' || !ref || ref.length > MAX_REF) return { expired: 0 } + + const rows = await resourcesDb.expirableByTarget(owner, kind, ref) + const runs = new Set() + let expired = 0 + + for (const row of rows) { + if (!(await resourcesDb.markExpired(row.id, 'the game ended it at its deadline'))) continue + expired += 1 + runs.add(row.run_id) + await logDb.write({ + runId: row.run_id, + kind: 'resource.expired', + detail: { module: owner, resource: `${row.kind}:${row.ref}` }, + }) + } + + // A finished run whose last unresolved row this was has nothing left to clean + // up. The sweep would settle a `pending` one by itself, but not an + // `incomplete` one — that status takes a run out of the sweep's scan, and it + // would go on saying "not finished" about a ledger that is. A run still in + // flight is left alone: its own terminal path computes the status. + for (const runId of runs) { + const run = await runsDb.getById(runId) + if (!run || !runsDb.TERMINAL.includes(run.status)) continue + if ((await resourcesDb.unresolvedCount(runId)) === 0) { + await runsDb.setCleanupStatus(runId, 'complete', ['pending', 'incomplete']) + } + } + + return { expired } +} + module.exports = { MAX_REVERT_ATTEMPTS, classifyRevert, @@ -434,4 +492,5 @@ module.exports = { sweep, reconcileModule, reconcileAll, + expireResource, } diff --git a/server/src/model/events/eventRunLog.db.js b/server/src/model/events/eventRunLog.db.js index c5b4e0d..88891ba 100644 --- a/server/src/model/events/eventRunLog.db.js +++ b/server/src/model/events/eventRunLog.db.js @@ -53,6 +53,9 @@ const KINDS = [ // question is about the world. 'resource.recorded', // a step reported what it created or borrowed, and it is ledgered 'resource.orphaned', // a module reports a ledgered resource is no longer in force + // MODULE_API 1.11.0's one (Rust PLAN_FIXES D183): the game ended a resource at + // its own deadline, which is the plan working rather than something vanishing. + 'resource.expired', // a module reports the game ended a ledgered resource by itself 'cleanup.reverted', // a group of resources came back 'cleanup.failed', // a group did not, with the reason and how it was left 'cleanup.swept', // one pass over a run's ledger, and what it found diff --git a/server/src/model/events/eventRunResources.db.js b/server/src/model/events/eventRunResources.db.js index badd823..4e68195 100644 --- a/server/src/model/events/eventRunResources.db.js +++ b/server/src/model/events/eventRunResources.db.js @@ -327,6 +327,42 @@ async function markOrphaned(id, detail = null) { ) } +/** + * The rows one module holds for one target that the game may have let go of on + * its own — what `ctx.events.expired` looks for (MODULE_API 1.11.0). + * + * `orphaned` is included on purpose: a reconcile that ran before the module heard + * the expiry filed the row as vanished, and the expiry is the better answer. + * `reverting` is not: a revert already in flight finds the thing gone, which §L + * calls a success, and it settles as `reverted` on its own. + */ +async function expirableByTarget(owner, kind, ref) { + const rows = await query( + `SELECT ${COLUMNS} FROM event_run_resources + WHERE owner_module = ? AND kind = ? AND ref = ? + AND status IN ('pending', 'confirmed', 'orphaned') + ORDER BY id`, + [owner, kind, ref], + ) + return rows.map(hydrate) +} + +/** + * The game ended it by itself, as it was told to — a zone reaching its deadline. + * Terminal like `reverted`, so teardown never tries to give it back, and unlike + * `orphaned`, which is a thing that vanished with nobody asking. Guarded on the + * status so a row a revert has just claimed is left to that revert. + */ +async function markExpired(id, detail = null) { + const result = await query( + `UPDATE event_run_resources + SET status = 'expired', last_error = ? + WHERE id = ? AND status IN ('pending', 'confirmed', 'orphaned')`, + [detail === null ? null : String(detail).slice(0, 500), id], + ) + return (result.affectedRows || 0) > 0 +} + /** * Terminal runs that still owe the world something — the cleanup leg's scan. * @@ -385,5 +421,7 @@ module.exports = { liveForModule, modulesWithLiveRows, markOrphaned, + expirableByTarget, + markExpired, runsNeedingCleanup, } diff --git a/server/src/modules/loader.js b/server/src/modules/loader.js index 7503186..da50330 100644 --- a/server/src/modules/loader.js +++ b/server/src/modules/loader.js @@ -261,6 +261,27 @@ function buildCtx(id, moduleRoot) { (err) => { log.error('ctx.events.reconcile failed', { module: id, message: err.message }) }, ) }, + // MODULE_API 1.11.0 (Rust PLAN_FIXES D183). The game ended one of this + // module's ledgered resources at its own deadline — a Rust zone erased when + // its time was up. Recorded as `expired`: terminal, never given back, and a + // different sentence on the console from `orphaned`, which is reconcile's + // word for a thing that vanished with nobody asking. + // + // `id` bound, fire-and-forget and returns undefined, all three for + // `reconcile`'s reasons directly above. A `{ kind, ref }` that names no + // live row is not an error: a module hears expiries of things no run ever + // ledgered, and telling it so would be noise it can do nothing with. + expired: (resource) => { + // eslint-disable-next-line global-require + require('../events/cleanup') + .expireResource(id, resource || {}) + .then( + (summary) => { + if (summary && summary.expired) log.info('event resource expired', { module: id, ...summary }) + }, + (err) => { log.error('ctx.events.expired failed', { module: id, message: err.message }) }, + ) + }, }, // The in-app sink (§5.1) — a module writing the inbox directly, without a // rule. Live from Phase 7; it threw until the `user_notifications` table diff --git a/server/src/modules/version.js b/server/src/modules/version.js index caa2042..f8a5b35 100644 --- a/server/src/modules/version.js +++ b/server/src/modules/version.js @@ -9,6 +9,18 @@ // Deliberately separate from PROTOCOL_VERSION (which versions the shard wire and // has nothing to say about a website module) and from any module's own version. +// 1.11.0 — `ctx.events.expired({ kind, ref })`, and `expired` as a resource +// ledger status (docs/website/EVENTS.md §L; Rust PLAN_FIXES D183). A game that +// ends something at its own deadline — a Rust zone erased when its time is up — +// could until now reach core only through `ctx.events.reconcile()`, which files +// it `orphaned`: amber, "it vanished", and still claimable for a revert. The new +// call files it as the plan working: terminal, green, never taken back. +// +// Additions only, so minor, and checked against the one module already built on +// 1.10.0: Module-uo never calls it, reads no ledger status, and every existing +// status keeps its meaning — `expired` joins neither `HELD` nor `UNRESOLVED`, so +// the sweep, the manual retry and `cleanup_status` see exactly what they saw. + // 1.10.0 — the event contract opens to modules: `api.registerEventActions`, // `api.registerEventBudgets`, `api.registerEventLeases` and // `api.registerEventOptionSources` (docs/website/EVENTS.md §F, EVENTS_PLAN.md @@ -155,6 +167,6 @@ // an admin action a module performs belongs in core's one audit log, the // extension slot needs the user its prefix names, and §2.7 forbids a module // reading core's `APP_BASE_URL` for itself. Additions only, so minor. -const MODULE_API_VERSION = '1.10.0' +const MODULE_API_VERSION = '1.11.0' module.exports = { MODULE_API_VERSION } diff --git a/server/src/router/v1/admin/events.router.js b/server/src/router/v1/admin/events.router.js index 29015b1..2bbe1d6 100644 --- a/server/src/router/v1/admin/events.router.js +++ b/server/src/router/v1/admin/events.router.js @@ -237,7 +237,7 @@ eventsRouter.get( '/runs/:runId', // #swagger.tags = ['Admin · Events'] // #swagger.summary = 'One run: its status, health, cleanup state and every step with its params and idempotency key' - // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.' + // #swagger.description = 'The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as "is at least", `present` as "is present" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The run, its steps, the status counts, the phase gates, the cap meter, the resource ledger and the participants', content: { "application/json": { schema: { type: "object", properties: { run: { type: "object", additionalProperties: true }, steps: { type: "array", items: { type: "object", additionalProperties: true } }, counts: { type: "object", additionalProperties: true }, gates: { type: "array", items: { type: "object", additionalProperties: true } }, budget: { type: "array", items: { type: "object", additionalProperties: true } }, resources: { type: "array", items: { type: "object", additionalProperties: true } }, unresolvedResources: { type: "integer" }, participants: { type: "array", items: { type: "object", additionalProperties: true } } } } } } } */ /* #swagger.responses[404] = { description: 'No such run', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index d3099d0..b1bc629 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -4271,7 +4271,7 @@ "Admin · Events" ], "summary": "One run: its status, health, cleanup state and every step with its params and idempotency key", - "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, and `orphaned` means the module reports it is gone. `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.", + "description": "The run console. `counts` summarises the step list by status. Steps carry the idempotency key core minted at materialisation — stable across every attempt, which is what lets the game side recognise a repeat. `gates` is the diagnosis panel (Phase 5): one entry per phase that authored an advance condition, already rendered in the condition builder’s own words — `gte` as \"is at least\", `present` as \"is present\" — with the tally, how long it has waited, and the last related firing whether or not it matched. A phase is waiting on its gate only once every one of its steps is terminal; `stalled` means an `on` gate has waited past EVENT_PHASE_STALL_MS, which is visibility and never a timeout — nothing advances a phase but its condition or a human. `budget` is the cap meter (Phase 6), and `resources` is the cleanup ledger (Phase 8): every object this run created and every value it borrowed, with what became of each — `confirmed` is still out there, `reverted` came back, `drifted` means somebody moved it and core left it alone, `orphaned` means the module reports it is gone, and `expired` means the game ended it at its own deadline, as it was told to (terminal, like `reverted`). `unresolvedResources` counts the ones still wanting something, including a placeholder left standing by a lost acknowledgement, which is why it can exceed the length of the list. `participants` is who took part (Phase 10), best first, as a module reported them: `memberKey` is module-opaque, `userId` is filled in only where the module could link the player to an account, and `rank` is null until `core.results.publish` has ranked them — a run whose participants are collected but unranked is a real and visible state, not an error. The run itself carries `resultsPublishedAt`, which is when that table was last published.", "parameters": [ { "name": "runId", diff --git a/server/test/eventCleanup.test.js b/server/test/eventCleanup.test.js index b1eb4ae..27202c7 100644 --- a/server/test/eventCleanup.test.js +++ b/server/test/eventCleanup.test.js @@ -618,3 +618,113 @@ test('reconcileAll asks every module that owns a live row', async () => { // orphaned by a module that is not there to be asked. assert.equal(out.other.unanswered, 1) }) + +// ── Expiry (MODULE_API 1.11.0, Rust PLAN_FIXES D183) ───────────────────────── +// +// A game that ends something at its own deadline — a Rust zone erased when its +// time is up — says so through `ctx.events.expired`. The properties: +// +// • **expired is terminal**: the sweep never tries to give it back, and it is +// not one of the statuses that makes a run's cleanup unfinished +// • **it is not orphaned**, and it wins over orphaned: a reconcile that ran +// before the module heard the expiry filed the row as vanished +// • **a module closes only its own rows**, and only the target it named +// • **a revert already in flight is left to finish** — it finds nothing, which +// §L calls a success, and settles as `reverted` by itself +// • **a finished run whose last row this was is complete**, even one the sweep +// had given up on as `incomplete` + +function stubExpiry() { + resourcesDb.expirableByTarget = async (owner, kind, ref) => + [...store.rows.values()] + .filter((r) => r.owner_module === owner && r.kind === kind && r.ref === ref) + .filter((r) => ['pending', 'confirmed', 'orphaned'].includes(r.status)) + .map((r) => ({ ...r })) + resourcesDb.markExpired = async (id, detail = null) => { + const r = store.rows.get(id) + if (!r || !['pending', 'confirmed', 'orphaned'].includes(r.status)) return false + Object.assign(r, { status: 'expired', last_error: detail }) + return true + } + store.run = { ...RUN } + runsDb.getById = async (id) => (id === RUN.id ? store.run : null) +} + +test('an expired resource is terminal: the sweep never tries to give it back', async () => { + stubExpiry() + let reverts = 0 + registerAction({ revert: async () => { reverts += 1; return { ok: true } } }) + addStep(1, 'demo.spawn') + addResource({ kind: 'zone', ref: '3:rg-7-1' }) + + assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: '3:rg-7-1' }), { expired: 1 }) + const row = [...store.rows.values()][0] + assert.equal(row.status, 'expired') + assert.ok(store.log.some((e) => e.kind === 'resource.expired' && e.detail.resource === 'zone:3:rg-7-1')) + + const summary = await cleanup.cleanupRun(RUN) + assert.equal(summary.attempted, 0) + assert.equal(reverts, 0) + assert.equal(row.status, 'expired') + assert.equal(store.cleanupStatus, 'complete') +}) + +test('an expiry wins over orphaned, and leaves a revert in flight alone', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'a', status: 'orphaned' }) + addResource({ kind: 'zone', ref: 'b', status: 'reverting' }) + addResource({ kind: 'zone', ref: 'c', status: 'reverted' }) + + for (const ref of ['a', 'b', 'c']) await cleanup.expireResource('demo', { kind: 'zone', ref }) + const status = (ref) => [...store.rows.values()].find((r) => r.ref === ref).status + assert.equal(status('a'), 'expired') + assert.equal(status('b'), 'reverting') + assert.equal(status('c'), 'reverted') +}) + +test('a module closes only its own rows, and only the target it named', async () => { + stubExpiry() + addResource({ owner_module: 'demo', kind: 'zone', ref: 'x' }) + addResource({ owner_module: 'other', kind: 'zone', ref: 'x' }) + addResource({ owner_module: 'demo', kind: 'npc', ref: 'x' }) + + assert.deepEqual(await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }), { expired: 1 }) + const rows = [...store.rows.values()] + assert.deepEqual(rows.map((r) => r.status), ['expired', 'confirmed', 'confirmed']) +}) + +test('an expiry nobody ledgered, or a malformed one, is not an error', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'x' }) + for (const bad of [undefined, {}, { kind: 'zone' }, { ref: 'x' }, { kind: 1, ref: 'x' }, { kind: 'zone', ref: 'y' }, + { kind: 'zone', ref: 'r'.repeat(191) }]) { + assert.deepEqual(await cleanup.expireResource('demo', bad), { expired: 0 }, JSON.stringify(bad)) + } + assert.equal([...store.rows.values()][0].status, 'confirmed') + assert.equal(store.log.length, 0) +}) + +test('the last row of a finished run expiring completes an incomplete cleanup', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'x' }) + store.cleanupStatus = 'incomplete' + await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }) + assert.equal(store.cleanupStatus, 'complete') +}) + +test('a run still in flight keeps its cleanup status, and so does one with rows left', async () => { + stubExpiry() + addResource({ kind: 'zone', ref: 'x' }) + store.run = { ...RUN, status: 'running' } + store.cleanupStatus = 'pending' + await cleanup.expireResource('demo', { kind: 'zone', ref: 'x' }) + assert.equal(store.cleanupStatus, 'pending') + + store.rows.clear() + addResource({ kind: 'zone', ref: 'y' }) + addResource({ kind: 'zone', ref: 'z' }) + store.run = { ...RUN } + store.cleanupStatus = 'incomplete' + await cleanup.expireResource('demo', { kind: 'zone', ref: 'y' }) + assert.equal(store.cleanupStatus, 'incomplete') +}) diff --git a/server/test/eventModuleContract.test.js b/server/test/eventModuleContract.test.js index 6c4fbd5..3d9c496 100644 --- a/server/test/eventModuleContract.test.js +++ b/server/test/eventModuleContract.test.js @@ -38,6 +38,7 @@ const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const express = require('express') +const semver = require('../src/modules/semver') const db = require('../src/utils/db') const registries = require('../src/modules/registries') @@ -99,13 +100,19 @@ beforeEach(() => { // ── The seam is open ─────────────────────────────────────────────────────── -test('the version a module declares against is 1.10.0', () => { +test('the version a module declares against satisfies ^1.10.0', () => { // Not decoration. `coreApi: "^1.10.0"` on every module below is what makes // these tests fail loudly rather than quietly if the bump is ever reverted — // the loader would refuse the manifest and every assertion would become "the // module did not register", which is the same failure the seam closing would - // produce. Asserting the number here says which of the two it was. - assert.equal(MODULE_API_VERSION, '1.10.0') + // produce. Asserting the range here says which of the two it was. + // + // A range, not the number, since 1.11.0 (`ctx.events.expired`, Rust + // PLAN_FIXES D183): an additive bump must keep every module written against + // 1.10.0 loading — Module-uo declares exactly `^1.10.0` — and this is the + // assertion that says it does. The number itself is pinned in version.js. + assert.ok(semver.satisfies(MODULE_API_VERSION, '^1.10.0'), MODULE_API_VERSION) + assert.equal(MODULE_API_VERSION, '1.11.0') }) test('a module registers actions, budgets, leases and option sources', () => { diff --git a/server/test/moduleLoader.test.js b/server/test/moduleLoader.test.js index 90b5b7b..f97c5b9 100644 --- a/server/test/moduleLoader.test.js +++ b/server/test/moduleLoader.test.js @@ -495,6 +495,7 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { fs.writeFileSync(${JSON.stringify(seen)}, JSON.stringify({ keys: Object.keys(ctx).sort(), middleware: Object.keys(ctx.middleware).sort(), + events: Object.keys(ctx.events).sort(), moduleId: ctx.moduleId, mutable, })) @@ -530,10 +531,42 @@ test('ctx exposes exactly the documented surface, and is frozen', () => { assert.deepEqual(probe.middleware, [ 'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate', ]) + // API 1.10.0 gave `events` its `reconcile`, and 1.11.0 its `expired` (Rust + // PLAN_FIXES D183): the game ended a ledgered resource at its own deadline. + assert.deepEqual(probe.events, ['emit', 'expired', 'reconcile']) assert.equal(probe.moduleId, 'probe') assert.equal(probe.mutable, false, 'ctx members must be frozen') }) +test("ctx.events.expired closes the calling module's rows, whatever it passes", async () => { + // The owner is bound by the loader, never read from the arguments — the same + // rule `reconcile` keeps. Without it a module could mark another module's + // resources expired and take them off the teardown list. + const cleanup = require('../src/events/cleanup') + const original = cleanup.expireResource + const calls = [] + cleanup.expireResource = async (owner, resource) => { + calls.push({ owner, resource }) + return { expired: 1 } + } + try { + writeModule('zoner', { + server: `module.exports = (ctx) => { + const result = ctx.events.expired({ kind: 'zone', ref: '3:rg-1', owner: 'someone-else', owner_module: 'x' }) + if (result !== undefined) throw new Error('expired must return undefined') + }`, + }) + assert.equal(stateOf(freshLoader(tmpRoot), 'zoner').state, 'registered') + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(calls.length, 1) + assert.equal(calls[0].owner, 'zoner') + assert.equal(calls[0].resource.kind, 'zone') + assert.equal(calls[0].resource.ref, '3:rg-1') + } finally { + cleanup.expireResource = original + } +}) + // ── Lifecycle hooks ──────────────────────────────────────────────────────── test('a lifecycle hook must be a function, and may be registered once', () => {