// ── Event runs — creating an occurrence ──────────────────────────────────── // // EVENTS.md §E. Phase 1 creates a run row and materialises the first phase's // steps. **It does not start anything**: there is no runner until Phase 2, so a // row created here sits at `scheduled` indefinitely. That is the correct // behaviour for this phase and it has to be VISIBLE as such rather than looking // broken, which is why `create()` answers with the row and the admin surface // renders the status verbatim. // // Two properties this file owns, both of which are the reason it exists before // the runner rather than with it: // // - **Materialisation is `INSERT IGNORE` against the occurrence key.** Two // attempts at one occurrence produce one row and an honest answer, not a // duplicate-key error a caller has to interpret. The unique index — not a // claim — is what makes "one run per occurrence per scope" true (§E). // - **The idempotency key is minted with the step row and never varies by // attempt.** It is a function of identity, so it can only be stable if it is // stamped where the identity is created. const db = require('./eventRuns.db') const stepsDb = require('./eventRunSteps.db') const logDb = require('./eventRunLog.db') const definitionsDb = require('./eventDefinitions.db') const versionsDb = require('./eventVersions.db') const MAX_SCOPE = 190 /** * Render a definition's `concurrency_key` template against a run's params. * * `invasion:{region}` with `{ region: 'Yew' }` becomes `invasion:Yew` (§E). A * placeholder with no matching param is left standing rather than replaced with * an empty string: `invasion:` would collide with every other unrendered key on * the deployment, which is the opposite of what a concurrency key is for, and a * literal `invasion:{region}` in the column is a visible mistake. */ function renderConcurrencyKey(template, params) { if (!template) return null return String(template) .replace(/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g, (whole, name) => { const value = params && params[name] return value === undefined || value === null || value === '' ? whole : String(value) }) .slice(0, MAX_SCOPE) } /** * Create one occurrence of a definition and materialise its first phase. * * `scheduledFor` defaults to now — "start now" is an occurrence whose instant is * the present, not a separate concept, which is what keeps the runner's one * materialise/advance path honest now that Phase 4 has put recurrence on top. * * **Phase 4's expansion calls this, rather than a second insert path beside it.** * That is deliberate: every check here — the definition is still `ready`, the * version still has phases, the concurrency key renders, the first phase's steps * are materialised with their idempotency keys — is one a scheduled occurrence * needs at least as much as a hand-started one, because there is nobody watching * when it happens. The `INSERT IGNORE` answering `created: false` is what makes * it safe to call on every tick for every occurrence inside the horizon. */ async function create( definitionId, { scope = '', scheduledFor = null, rehearsal = false, params = null, source = 'manual' } = {}, userId, ) { const definition = await definitionsDb.getById(definitionId) if (!definition) return { ok: false, status: 404, errors: ['no such event definition'] } if (definition.state !== 'ready') { return { ok: false, status: 409, errors: [`a ${definition.state} definition has no published version to run`], } } if (!definition.current_version_id) { return { ok: false, status: 409, errors: ['this definition has no published version'] } } const version = await versionsDb.getById(definition.current_version_id) if (!version?.spec?.phases?.length) { return { ok: false, status: 409, errors: ['the published version has no phases'] } } const scopeValue = String(scope || '').slice(0, MAX_SCOPE) const when = scheduledFor ? new Date(scheduledFor) : new Date() if (Number.isNaN(when.getTime())) { return { ok: false, status: 400, errors: ['scheduledFor is not a date'] } } const runId = await db.materialise({ definition_id: definitionId, version_id: version.id, scope: scopeValue, // Stored as UTC. The definition's zone is what an occurrence is COMPUTED in // (Phase 4); what is stored is the instant. scheduled_for: when, timezone: definition.timezone, concurrency_key: renderConcurrencyKey(definition.concurrency_key, params), params, rehearsal, started_by: userId, }) if (runId === null) { // The occurrence already existed. Not an error — it is what the unique index // is for — so the existing row is the answer. const existing = await db.findOccurrence(definitionId, scopeValue, when) return { ok: true, created: false, run: existing } } await logDb.write({ runId, kind: 'run.created', detail: { definitionId, versionId: version.id, version: version.version, scope: scopeValue, rehearsal: Boolean(rehearsal), // 'manual' is an admin pressing start; 'schedule' is the runner expanding // a recurrence (Phase 4). Both produce the same row, and the log is the // only place the difference is recorded — `started_by` is NULL for both a // scheduled occurrence and one started by a since-deleted account. source, by: userId, }, }) // The first phase's steps, materialised at creation rather than at start. // Phase 2 materialises each LATER phase as the run enters it; doing the first // one here is what makes a Phase 1 run row inspectable — an operator can see // the steps that would run, with their params and their idempotency keys, // before there is anything to run them. const first = version.spec.phases[0] await stepsDb.materialisePhase(runId, first.key, first.steps || []) await logDb.write({ runId, kind: 'phase.entered', phase: first.key, detail: { steps: (first.steps || []).length } }) return { ok: true, created: true, run: await db.getById(runId) } } /** A run, its steps and its status counts — what the run console reads. */ async function detail(runId) { const run = await db.getById(runId) if (!run) return null const [steps, counts] = await Promise.all([stepsDb.listForRun(runId), stepsDb.statusCounts(runId)]) return { run, steps, counts } } module.exports = { create, detail, renderConcurrencyKey }