Compare commits
10 Commits
edge
...
feat/event
| Author | SHA1 | Date | |
|---|---|---|---|
| 224e2b4dbc | |||
| cc1f49af29 | |||
| 1a76b98d76 | |||
| a169a463a5 | |||
| 90ba8cca16 | |||
| b5616f359c | |||
| e4f088e90b | |||
| 702ab89ae2 | |||
| 9e1f591b25 | |||
| efa9db7330 |
@@ -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'
|
||||
|
||||
@@ -166,7 +166,12 @@ export default function Dashboard() {
|
||||
className="sans"
|
||||
style={{ display: 'flex', gap: 14, alignItems: 'center', padding: '13px 18px', borderBottom: '1px solid var(--line-soft)', fontSize: '0.86rem' }}
|
||||
>
|
||||
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', width: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
||||
{/* `minWidth` rather than `width`: the column still lines up for core's
|
||||
own short action names, and a longer one — a module's namespaced
|
||||
action, say `rust.account.unlink.staff` — grows the box instead of
|
||||
overflowing it and printing on top of the detail beside it. Found
|
||||
on a live dashboard with a module installed. */}
|
||||
<span style={{ flex: 'none', color: 'var(--accent)', fontSize: '0.66rem', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', minWidth: 110, fontFamily: 'ui-monospace,Menlo,monospace' }}>
|
||||
{a.action}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--text)' }}>{formatDetail(a)}</span>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -320,6 +320,16 @@ async function sweep() {
|
||||
// cleanup, whose counters were spent deliberately.
|
||||
const candidates = await resourcesDb.runsNeedingCleanup(CLEANUP_RUN_BATCH, MAX_REVERT_ATTEMPTS)
|
||||
let swept = 0
|
||||
|
||||
// **A finished run with nothing left to give back is complete** (MODULE_API
|
||||
// 1.11.0). The scan above joins on an unresolved row, and until `expired` no
|
||||
// run could end `pending` without one. Now a zone can expire while its run is
|
||||
// still going — `expireResource` leaves a live run's status to its terminal
|
||||
// path — and when that run ends, nothing in the scan would ever select it:
|
||||
// `pending` for ever. Found by the step-2 walk, run 46.
|
||||
for (const runId of await resourcesDb.finishedRunsWithNothingLeft(CLEANUP_RUN_BATCH)) {
|
||||
if (await runsDb.setCleanupStatus(runId, 'complete', ['pending'])) swept += 1
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!runsDb.TERMINAL.includes(candidate.status)) continue
|
||||
try {
|
||||
@@ -427,6 +437,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 +502,5 @@ module.exports = {
|
||||
sweep,
|
||||
reconcileModule,
|
||||
reconcileAll,
|
||||
expireResource,
|
||||
}
|
||||
|
||||
@@ -103,9 +103,18 @@ function phaseLabel(spec, phaseId) {
|
||||
return (phase && (phase.label || phase.id)) || null
|
||||
}
|
||||
|
||||
/** One calendar entry, from a materialised run. */
|
||||
/**
|
||||
* One calendar entry, from a materialised run.
|
||||
*
|
||||
* **`runId` is published because the event page already publishes it** on every
|
||||
* occurrence, and `?run=` takes it. The calendar was the one public shape that
|
||||
* named a run without saying which, so a client holding a run id from elsewhere
|
||||
* (a module's map marker) had no way to find its event but to fetch every event
|
||||
* page. A projection has none: nothing is committed to it.
|
||||
*/
|
||||
const publicRunEntry = (run) => ({
|
||||
kind: 'run',
|
||||
runId: run.id,
|
||||
title: run.definition_title,
|
||||
slug: run.definition_slug,
|
||||
seriesName: run.series_name || null,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -366,8 +402,30 @@ async function runsNeedingCleanup(limit = 25, maxAttempts = 3) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finished runs still marked `pending` that have no unresolved row at all — a
|
||||
* run whose last resource the game expired while it was still going (MODULE_API
|
||||
* 1.11.0). The sweep settles them `complete`; `runsNeedingCleanup` cannot see
|
||||
* them because it joins on an unresolved row.
|
||||
*/
|
||||
async function finishedRunsWithNothingLeft(limit = 25) {
|
||||
const rows = await query(
|
||||
`SELECT r.id FROM event_runs r
|
||||
WHERE r.status IN ('completed', 'cancelled', 'failed', 'missed')
|
||||
AND r.cleanup_status = 'pending'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM event_run_resources res
|
||||
WHERE res.run_id = r.id AND res.status IN (?, ?, ?, ?, ?))
|
||||
ORDER BY r.id
|
||||
LIMIT ?`,
|
||||
[...UNRESOLVED, Number(limit)],
|
||||
)
|
||||
return rows.map((row) => Number(row.id))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STEP_KIND,
|
||||
finishedRunsWithNothingLeft,
|
||||
HELD,
|
||||
UNRESOLVED,
|
||||
reserve,
|
||||
@@ -385,5 +443,7 @@ module.exports = {
|
||||
liveForModule,
|
||||
modulesWithLiveRows,
|
||||
markOrphaned,
|
||||
expirableByTarget,
|
||||
markExpired,
|
||||
runsNeedingCleanup,
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
// through modules/install.js, the same fetch-verify-unpack path the admin panel
|
||||
// uses, under the same host allowlist.
|
||||
//
|
||||
// **A site runs one module** (org lead, 2026-09-23), and install.js enforces it
|
||||
// for this path too: a declaration naming a second module installs the first,
|
||||
// and the second is refused — logged and kept for the admin screen like any
|
||||
// other failed entry, never fatal to the boot.
|
||||
//
|
||||
// Three things this file deliberately does not do:
|
||||
//
|
||||
// - **It does not decide whether a module RUNS.** Resolution owns what is on
|
||||
|
||||
@@ -290,6 +290,28 @@ function isInstalled(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every module on the volume, by id — the directories the loader would scan.
|
||||
*
|
||||
* The loader's own rule, restated: a directory whose name is a module id and
|
||||
* which holds a `module.json`. That excludes an install's scratch directory
|
||||
* (`.install-*`) and a swap's aside copy (`<id>.replaced-*`), neither of which
|
||||
* is a module and both of which can briefly exist beside one.
|
||||
*/
|
||||
function installedIds() {
|
||||
let entries
|
||||
try {
|
||||
entries = fs.readdirSync(loader.dir(), { withFileTypes: true })
|
||||
} catch {
|
||||
return [] // no modules directory is the normal case for a bare core
|
||||
}
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && ID.test(e.name))
|
||||
.filter((e) => fs.existsSync(path.join(loader.dir(), e.name, 'module.json')))
|
||||
.map((e) => e.name)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute path of a module's `purge.sql`, or null.
|
||||
*
|
||||
@@ -348,6 +370,25 @@ async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
|
||||
)
|
||||
}
|
||||
|
||||
// One module per site (org lead, 2026-09-23). A site is one game, and the
|
||||
// contract has singletons that assume it: `registerTeamProvider` holds ONE
|
||||
// value per deployment, and a second module registering one fails its whole
|
||||
// load — with modules loaded alphabetically, installing `rust` beside `uo`
|
||||
// would have taken `uo` down, not `rust`. So an install is an UPGRADE of the
|
||||
// module already here, or it is refused before a byte is downloaded.
|
||||
//
|
||||
// Refused here rather than in the admin controller so the declared module set
|
||||
// (modules/declared.js) gets the same answer: an environment naming two
|
||||
// modules installs the first and is told why the second was not.
|
||||
const others = installedIds().filter((id) => id !== manifest.id)
|
||||
if (others.length) {
|
||||
throw new InstallError(
|
||||
`this site already runs the module "${others.join('", "')}", and a site runs one module. ` +
|
||||
`Upgrade it with its own release, or remove it before installing "${manifest.id}".`,
|
||||
{ status: 409 },
|
||||
)
|
||||
}
|
||||
|
||||
const target = moduleDir(manifest.id)
|
||||
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
|
||||
const tarball = path.join(scratch, 'bundle.tar.gz')
|
||||
@@ -444,6 +485,7 @@ module.exports = {
|
||||
removeDir,
|
||||
moduleDir,
|
||||
isInstalled,
|
||||
installedIds,
|
||||
purgeFile,
|
||||
MAX_MANIFEST_BYTES,
|
||||
MAX_ARTIFACT_BYTES,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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" } } } } */
|
||||
|
||||
@@ -41,11 +41,12 @@ modulesRouter.post(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Modules']
|
||||
// #swagger.summary = 'Install or upgrade a module from a release install-manifest URL'
|
||||
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.'
|
||||
// #swagger.description = 'Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["url"], properties: { url: { type: "string", description: "https URL of the release install manifest, on an allowed host" } } } } } } */
|
||||
/* #swagger.responses[201] = { description: 'Installed — restart to mount it', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||
/* #swagger.responses[400] = { description: 'The URL, the manifest, the hash or the archive was refused', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[409] = { description: 'A different module is already installed; a site runs one module', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[502] = { description: 'The source host could not be reached or answered badly', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
adminOnly,
|
||||
body('url').isString().trim().isLength({ min: 1, max: 2048 }),
|
||||
|
||||
@@ -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",
|
||||
@@ -7211,7 +7211,7 @@
|
||||
"Admin · Modules"
|
||||
],
|
||||
"summary": "Install or upgrade a module from a release install-manifest URL",
|
||||
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart.",
|
||||
"description": "Downloads the artifact the manifest names, verifies its sha256, inspects the archive in full and unpacks it onto the modules volume. The module mounts on the next restart. A site runs ONE module: installing a module other than the one already on the volume is refused with 409 before anything is downloaded, and only an upgrade of the installed module is accepted.",
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Installed — restart to mount it",
|
||||
@@ -7234,6 +7234,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "A different module is already installed; a site runs one module",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
},
|
||||
@@ -26295,6 +26305,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"runId": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"example": "integer"
|
||||
},
|
||||
"example": {
|
||||
"type": "number",
|
||||
"example": 3692
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"example": "Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it."
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1247,6 +1247,12 @@ const doc = {
|
||||
'One calendar entry. `kind` says which of two things it is: a `run` is a materialised occurrence, a `projected` entry is arithmetic past the materialisation horizon — a forecast with nothing committed to it, which a client should draw as such.',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['run', 'projected'], example: 'run' },
|
||||
runId: {
|
||||
type: 'integer',
|
||||
example: 3692,
|
||||
description:
|
||||
'Runs only: the run this entry is, the same id `PublicEventOccurrence.runId` carries and `/events/{slug}?run=` takes. A projected entry has none, because nothing is committed to it.',
|
||||
},
|
||||
title: { type: 'string', example: 'The Yew Invasion' },
|
||||
slug: { type: 'string', example: 'the-yew-invasion' },
|
||||
seriesName: { type: 'string', nullable: true, example: 'The Yew Campaign' },
|
||||
|
||||
@@ -101,6 +101,7 @@ beforeEach(() => {
|
||||
...new Set([...store.rows.values()].filter((r) => ['pending', 'confirmed'].includes(r.status)).map((r) => r.owner_module)),
|
||||
]
|
||||
resourcesDb.runsNeedingCleanup = async () => store.candidates || []
|
||||
resourcesDb.finishedRunsWithNothingLeft = async () => store.nothingLeft || []
|
||||
|
||||
runsDb.setCleanupStatus = async (id, to, from = null) => {
|
||||
if (from && !from.includes(store.cleanupStatus)) return false
|
||||
@@ -618,3 +619,130 @@ 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')
|
||||
})
|
||||
|
||||
test('a run whose last resource expired while it ran is settled complete when it ends', async () => {
|
||||
// The step-2 walk's run 46: the zone expired while the run was still going, so
|
||||
// expireResource left its status to the terminal path — and the sweep's scan,
|
||||
// which joins on an unresolved row, would never have selected it. `pending`
|
||||
// for ever, on a ledger with nothing left in it.
|
||||
store.cleanupStatus = 'pending'
|
||||
store.candidates = []
|
||||
store.nothingLeft = [RUN.id]
|
||||
assert.equal(await cleanup.sweep(), 1)
|
||||
assert.equal(store.cleanupStatus, 'complete')
|
||||
|
||||
// And never over an incomplete one — that is a human's to clear.
|
||||
store.cleanupStatus = 'incomplete'
|
||||
await cleanup.sweep()
|
||||
assert.equal(store.cleanupStatus, 'incomplete')
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -164,10 +164,32 @@ test('a calendar entry carries no operational field at all', async () => {
|
||||
// The whole security property of this file, asserted positively: the entry has
|
||||
// exactly these keys and gaining one is a deliberate act.
|
||||
assert.deepEqual(Object.keys(entry).sort(), [
|
||||
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
|
||||
'kind', 'live', 'runId', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone',
|
||||
'title',
|
||||
])
|
||||
})
|
||||
|
||||
test('a run entry names its run, and a projection names none', async () => {
|
||||
// Rust phase 15, D125: a map marker carries core's run id, and the app finds
|
||||
// the event it belongs to from this calendar. The id is the one the event page
|
||||
// already publishes on each occurrence.
|
||||
store.definitions[0].spec = {
|
||||
...SPEC,
|
||||
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
|
||||
}
|
||||
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-09-15', now: NOW })
|
||||
const runs = result.entries.filter((e) => e.kind === 'run')
|
||||
const projected = result.entries.filter((e) => e.kind === 'projected')
|
||||
assert.equal(runs.length, 1)
|
||||
assert.equal(runs[0].runId, store.runs[0].id)
|
||||
assert.ok(projected.length > 0, 'the weekly schedule must forecast past the one run')
|
||||
for (const entry of projected) assert.equal('runId' in entry, false)
|
||||
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
const occurrences = [page.event.current, page.event.next, ...page.event.upcoming, ...page.event.past]
|
||||
assert.ok(occurrences.some((o) => o && o.runId === runs[0].runId))
|
||||
})
|
||||
|
||||
test('the default window reaches back as well as forward', async () => {
|
||||
// §I: this route is "upcoming, live and recent". The default used to start at
|
||||
// `now`, which left no room for the third word — an event that finished an hour
|
||||
|
||||
@@ -395,6 +395,79 @@ test('a failed upgrade leaves the previous version in place', async () => {
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
|
||||
})
|
||||
|
||||
// ── One module per site ────────────────────────────────────────────────────
|
||||
|
||||
/** A second, different module's manifest and artifact, served beside the first. */
|
||||
function otherModuleRoutes(id = 'rust') {
|
||||
const tarball = bundle({ id })
|
||||
const artifact = `https://releases.example.com/mod/${id}-1.0.0.tar.gz`
|
||||
const url = `https://releases.example.com/mod/${id}-1.0.0.json`
|
||||
return {
|
||||
url,
|
||||
routes: {
|
||||
[url]: manifestFor(tarball, { id, name: id, artifact: `${id}-1.0.0.tar.gz`, url: artifact }),
|
||||
[artifact]: tarball,
|
||||
},
|
||||
artifact,
|
||||
}
|
||||
}
|
||||
|
||||
test('a second, different module is refused before anything is downloaded', async () => {
|
||||
const first = goodRoutes()
|
||||
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
|
||||
|
||||
const other = otherModuleRoutes('rust')
|
||||
const fetchImpl = fakeFetch(other.routes)
|
||||
await assert.rejects(
|
||||
() => install.install({ url: other.url, hosts: HOSTS, fetchImpl }),
|
||||
(err) => {
|
||||
assert.equal(err.name, 'InstallError')
|
||||
// 409: nothing is wrong with the URL; the SITE is not in a state to take it.
|
||||
assert.equal(err.status, 409)
|
||||
assert.match(err.message, /already runs the module "uo"/)
|
||||
assert.match(err.message, /remove it before installing "rust"/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
// Refused on the manifest alone: the artifact was never fetched, and the
|
||||
// volume holds exactly what it held before.
|
||||
assert.ok(!fetchImpl.seen.includes(other.artifact), 'the artifact was not downloaded')
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
|
||||
})
|
||||
|
||||
test('the same module is still an upgrade, and removing it frees the site for another', async () => {
|
||||
const first = goodRoutes()
|
||||
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
|
||||
|
||||
// An upgrade of what is installed is exactly what the rule allows.
|
||||
const second = goodRoutes({ version: '2.0.0', manifest: { version: '2.0.0' } })
|
||||
second.routes[MANIFEST_URL] = manifestFor(second.tarball, { version: '2.0.0' })
|
||||
const upgraded = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(second.routes) })
|
||||
assert.equal(upgraded.replaced, true)
|
||||
|
||||
// And once it is gone, the site takes a different one.
|
||||
await install.removeDir('uo')
|
||||
const other = otherModuleRoutes('rust')
|
||||
const result = await install.install({ url: other.url, hosts: HOSTS, fetchImpl: fakeFetch(other.routes) })
|
||||
assert.equal(result.id, 'rust')
|
||||
assert.deepEqual(install.installedIds(), ['rust'])
|
||||
})
|
||||
|
||||
test('what counts as installed is what the loader would scan', () => {
|
||||
// A real module, an install's scratch directory, a swap's aside copy and a
|
||||
// directory with no module.json. Only the first is a module.
|
||||
fs.mkdirSync(path.join(tmpRoot, 'uo'))
|
||||
fs.writeFileSync(path.join(tmpRoot, 'uo', 'module.json'), '{}')
|
||||
fs.mkdirSync(path.join(tmpRoot, '.install-rust-abc'))
|
||||
fs.writeFileSync(path.join(tmpRoot, '.install-rust-abc', 'module.json'), '{}')
|
||||
fs.mkdirSync(path.join(tmpRoot, 'uo.replaced-123'))
|
||||
fs.writeFileSync(path.join(tmpRoot, 'uo.replaced-123', 'module.json'), '{}')
|
||||
fs.mkdirSync(path.join(tmpRoot, 'notes'))
|
||||
|
||||
assert.deepEqual(install.installedIds(), ['uo'])
|
||||
})
|
||||
|
||||
// ── The volume ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('moduleDir refuses an id that is not one', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user