// ── Event definitions — the save path ────────────────────────────────────── // // EVENTS.md §D and "Versioning, and editing a live event". A definition is // operator-authored data, and this file is the boundary that decides whether a // version of it may exist. The authoring UI (Phase 3, then Phase 13) will // re-check some of this for the sake of a good inline error; that second copy is // expected to drift, so this one is the one that decides, and a definition // arriving by any other route gets the same answer. // // **The three rules with teeth, and each is a rule about time rather than about // shape:** // // 1. Publishing SNAPSHOTS. It copies the working spec into an immutable // `event_versions` row and points `current_version_id` at it. Editing // afterwards is free and does not touch the row a live run pinned. // 2. A dormant step blocks a PUBLISH and never a SAVE. An uninstalled module // must not make an author's work uneditable, and it must not let a version be // published that names a verb nobody can perform. // 3. Archiving is what "delete" means here. `event_runs.version_id` RESTRICTs, so // a hard delete of a definition that has ever run is refused by the database // anyway — and the row's history is the thing an audit reads. const db = require('./eventDefinitions.db') const versionsDb = require('./eventVersions.db') const runsDb = require('./eventRuns.db') const seriesDb = require('./eventSeries.db') const spec = require('../../events/spec') const { slugify, uniqueSlug } = require('../teams/teamSlug') const { cleanBody } = require('../../utils/sanitizeHtml') const MAX_TITLE = 200 const MAX_SUMMARY = 500 const MAX_URL = 500 const MAX_CONCURRENCY_KEY = 190 // A day either side of §D's default. Below a minute the grace window cannot // survive a single slow boot; above a day a "missed" occurrence would start // silently the following afternoon, which is the exact behaviour §E forbids. const MIN_GRACE_SECONDS = 60 const MAX_GRACE_SECONDS = 86_400 /** * IANA zone names, checked against the platform's own database rather than a * list. `Intl.DateTimeFormat` throws `RangeError` on an unknown zone, and Node * ships the full tzdata — so this is the same check Phase 4's occurrence * arithmetic will make, asked one screen earlier where an operator can fix it. */ function isTimezone(tz) { try { Intl.DateTimeFormat(undefined, { timeZone: tz }) return true } catch { return false } } const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) const trimOrNull = (v, max) => { if (v === undefined || v === null) return null const s = String(v).trim() return s === '' ? null : s.slice(0, max) } /** * Validate an incoming definition against the registries and the schema. * * `{ ok: true, definition }` with a normalised row ready for insert/update, or * `{ ok: false, errors }` listing every problem rather than the first. * * `existing` is the row being edited, or null on a create. It is what lets a * dormant step survive: the ids already in the saved spec widen what the spec * validator will accept, so an uninstall is never destructive after the fact. */ async function validate(input, { existing = null } = {}) { const errors = [] const body = isPlainObject(input) ? input : {} const title = trimOrNull(body.title, MAX_TITLE) if (!title) errors.push('title is required') const summary = trimOrNull(body.summary, MAX_SUMMARY) const imageUrl = trimOrNull(body.imageUrl, MAX_URL) const concurrencyKey = trimOrNull(body.concurrencyKey, MAX_CONCURRENCY_KEY) // The storyline. Sanitized on write, exactly as a wiki page and a forum post // are: it is author-supplied HTML that ends up on a public page. const storyline = body.body === undefined || body.body === null ? null : cleanBody(String(body.body)) const timezone = trimOrNull(body.timezone, 64) || existing?.timezone || 'UTC' if (!isTimezone(timezone)) errors.push(`timezone: "${timezone}" is not an IANA zone name`) const graceRaw = body.graceSeconds === undefined ? (existing?.grace_seconds ?? 900) : body.graceSeconds const graceSeconds = Number(graceRaw) if ( !Number.isInteger(graceSeconds) || graceSeconds < MIN_GRACE_SECONDS || graceSeconds > MAX_GRACE_SECONDS ) { errors.push(`graceSeconds must be an integer ${MIN_GRACE_SECONDS}..${MAX_GRACE_SECONDS}`) } let seriesId = null if (body.seriesId !== undefined && body.seriesId !== null && body.seriesId !== '') { seriesId = Number(body.seriesId) if (!Number.isInteger(seriesId) || seriesId < 1) { errors.push('seriesId must be an integer') seriesId = null } else if (!(await seriesDb.exists(seriesId))) { // Checked here as well as by the foreign key, because a 1452 reaching a // controller is a 500 and this is a 400 an author can act on. errors.push(`seriesId ${seriesId} does not exist`) seriesId = null } } else if (existing) { seriesId = existing.series_id } const seriesOrderRaw = body.seriesOrder === undefined ? (existing?.series_order ?? 0) : body.seriesOrder const seriesOrder = Number(seriesOrderRaw) if (!Number.isInteger(seriesOrder)) errors.push('seriesOrder must be an integer') // ── the spec ── const rawSpec = body.spec === undefined ? existing?.spec ?? spec.emptySpec() : body.spec const known = existing?.spec ? spec.actionIdsIn(existing.spec) : [] const checked = spec.validate(rawSpec, { knownActionIds: known }) if (!checked.ok) errors.push(...checked.errors) // ── the slug ── // // Derived from the title on create and FROZEN afterwards, like a Team's: the // public event page lives at it, and a retitle must not break a link somebody // posted in Discord. An author who genuinely needs a different address makes a // new definition. let slug = existing?.slug || null if (!slug) { const stem = slugify(title || '') || 'event' const taken = (await db.list()).map((d) => d.slug) slug = uniqueSlug(stem, taken, { fallback: 'event' }) } if (errors.length) return { ok: false, errors } return { ok: true, definition: { title, slug, summary, body: storyline, image_url: imageUrl, owner_module: existing?.owner_module ?? null, series_id: seriesId, series_order: seriesOrder, concurrency_key: concurrencyKey, grace_seconds: graceSeconds, timezone, spec: checked.spec, }, } } /** Create a draft. */ async function create(input, userId) { const result = await validate(input) if (!result.ok) return result const id = await db.insert({ ...result.definition, created_by: userId }) return { ok: true, id, definition: await db.getById(id) } } /** * Edit a definition. * * An `archived` definition is not editable. That is the one state check here, * and it is a real one rather than a formality: archiving is how a definition is * retired, and a retired definition that can still be edited is a definition * somebody will edit and then wonder why it never runs. */ async function save(id, input, userId) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') { return { ok: false, status: 409, errors: ['an archived definition cannot be edited'] } } const result = await validate(input, { existing }) if (!result.ok) return result await db.update(id, { ...result.definition, updated_by: userId }) return { ok: true, id, definition: await db.getById(id) } } /** * Publish: snapshot the working spec into an immutable version and go `ready`. * * The spec is re-validated here against the registries as they stand RIGHT NOW, * not trusted from the save that wrote it. A module uninstalled between the two * is the whole reason: the save was legitimate, and publishing a version whose * steps name a verb nobody can perform would be a run that fails at dispatch * with the world half-changed. */ async function publish(id, userId) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') { return { ok: false, status: 409, errors: ['an archived definition cannot be published'] } } const checked = spec.validate(existing.spec, { knownActionIds: spec.actionIdsIn(existing.spec), }) if (!checked.ok) return { ok: false, status: 400, errors: checked.errors } const publishable = spec.publishable(checked.spec) if (!publishable.ok) { return { ok: false, status: 409, errors: [ `cannot publish: no module registers ${publishable.dormant.join(', ')}`, ], } } if (!checked.spec.phases.some((p) => p.steps.length)) { // An empty event publishes cleanly and then does nothing, which looks like a // broken run rather than an empty one. Refusing costs an author one click and // saves an operator a diagnosis. return { ok: false, status: 400, errors: ['cannot publish: no phase has any steps'] } } const version = await versionsDb.nextVersion(id) const versionId = await versionsDb.insert(id, version, checked.spec, userId) await db.markReady(id, versionId, userId) return { ok: true, versionId, version, definition: await db.getById(id) } } /** * Archive. * * Refused while a run of this definition is still in flight — not because the * database would object (it would not; archiving is an UPDATE), but because the * screen the run is on reads its title and state from here, and retiring a * definition mid-run makes the console describe something that is no longer * supposed to exist. Cancel the run, then archive. */ async function archive(id, userId) { const existing = await db.getById(id) if (!existing) return { ok: false, status: 404, errors: ['no such event definition'] } if (existing.state === 'archived') return { ok: true, definition: existing } const active = await runsDb.countActiveForDefinition(id) if (active > 0) { return { ok: false, status: 409, errors: [`cannot archive: ${active} run(s) of this definition are still in flight`], } } await db.archive(id, userId) return { ok: true, definition: await db.getById(id) } } module.exports = { validate, create, save, publish, archive, isTimezone, MIN_GRACE_SECONDS, MAX_GRACE_SECONDS, }