feat(events): targeted leases, value sets and searchable sources (Phase 12b)
The core half of Phase 12b, and the half Phase 12a did not need. A targeted
lease is a shape `core.lease` did not have.
Every lease before this named a SINGLE value, so the lease id WAS the target and
none of the four callables took one. `Spawner.MaxCount` is not that shape: it is
one capability over thousands of spawners, and a reservation on the id alone
would let one run turning up one spawner refuse every other run every other
spawner. So a lease may declare a `target`, the callables are handed it, and the
ledger ref becomes `<lease id>#<target>` -- which puts the two-events-one-target
refusal at the granularity the world actually has while leaving it coming from
the same unique index it always did.
Extending core rather than giving the module a lease verb of its own is what §F
decided in Phase 8 ("the verb is core's"): a lease verb per module would
re-implement `maxDurationMs` and the conflict check once per module, advisory
everywhere and wrong in the first one that forgot. Half that objection no longer
holds -- the target check comes free from the index whichever verb reserves the
row -- and the other half still does.
Three readers of a lease ref, not one. `cleanup.restoreLease` and
`ledger.normalise` both looked a lease up by the whole `row.ref`, and both were
correct for exactly as long as a ref was a bare id. Left alone, a targeted row
would have missed in both -- cleanup reporting "no module registers the lease"
and refusing to restore a world that really was changed, which is the worst
failure this table has. All three now go through `eventLeaseForRef`.
`values` closes a `string` lease's set. `min`/`max` bound the numeric types and
nothing bounded `string`, so the only check on a string lease's value was the
game side's -- a refusal arriving unattended, mid-run, from a step nobody is
watching. Refused on any other type: a set beside `min`/`max` would be a second
bound with no rule about which wins.
Option sources become searchable, and the first one that needed it forced this
phase's shape. `resolveOptionSource(id)` took no argument and every source
answered a flat list bounded at 2,000; module-uo's spawner target is 6,707 spawn
points, so a flat list would have dropped two thirds of the world and said
nothing about which two thirds -- the failure 12a named for decoration, arriving
for real. `resolve({ q })` is additive: every source is passed a term, none is
required to read one, and a `searchable` flag says which do, because inferring it
from a truncated answer reads correctly right up until a small deployment's list
happens to fit.
`MODULE_API_VERSION` stays 1.10.0, amended IN PLACE (org lead, 2026-09-07) -- the
shape every phase since P10 has used while this workstream sits on `edge`.
The swagger regeneration carries one incidental change: the committed spec said
the session cookie is `rg_rig`, which is neither the documented default nor what
this repo's own `server/.env` sets. It was generated somewhere with that env var
set. The regeneration corrects it to `rg_token`.
2010 pass, 0 fail (89 DB-skipped), with `modules/uo` parked as the core suite
requires. Six new tests cover the targeted-lease shape, both refusal directions,
the value set, and the search term.
Refs: docs/link/v7.md §11, docs/website/MODULE_API.md, EVENTS_PLAN.md Phase 12b
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -50,6 +50,12 @@
|
||||
|
||||
const registries = require('../modules/registries')
|
||||
|
||||
// `event_run_resources.ref` is VARCHAR(190). A targeted lease composes its ref
|
||||
// from the lease id and the target, so this is the one place a caller can push a
|
||||
// ref past the column — and the ledger's own rule applies: refuse, never
|
||||
// truncate, because a truncated ref is a restore pointed at another object.
|
||||
const MAX_LEASE_REF = 190
|
||||
|
||||
|
||||
/**
|
||||
* Turn the `value` param's text into whatever the named lease says it holds.
|
||||
@@ -60,7 +66,19 @@ const registries = require('../modules/registries')
|
||||
*/
|
||||
function coerceLeaseValue(lease, raw) {
|
||||
const text = String(raw === undefined || raw === null ? '' : raw).trim()
|
||||
if (lease.type === 'string') return { ok: true, value: text }
|
||||
if (lease.type === 'string') {
|
||||
// A string lease with a declared value set is bounded here, at authoring
|
||||
// time, exactly as a numeric one is by its range (Phase 12b). Without it the
|
||||
// only check on the value is the game side's, and that refusal arrives
|
||||
// unattended, mid-run, from a step nobody is watching.
|
||||
if (lease.values && !lease.values.includes(text)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${lease.label} accepts ${lease.values.join(', ')}, and "${raw}" is none of them`,
|
||||
}
|
||||
}
|
||||
return { ok: true, value: text }
|
||||
}
|
||||
if (lease.type === 'bool') {
|
||||
if (['true', '1', 'yes', 'on'].includes(text.toLowerCase())) return { ok: true, value: true }
|
||||
if (['false', '0', 'no', 'off'].includes(text.toLowerCase())) return { ok: true, value: false }
|
||||
@@ -305,6 +323,25 @@ const ACTIONS = [
|
||||
example: '3.0',
|
||||
description: 'What to hold it at, in whatever type the lease declares.',
|
||||
},
|
||||
{
|
||||
// **Optional here, required by the LEASE** (Phase 12b), and the two are
|
||||
// not the same statement. A param's `required` is a property of the
|
||||
// action, and this action serves both a config key (which has no target)
|
||||
// and an object property (which cannot be named without one) — so the
|
||||
// field is declared optional and `perform` refuses a targeted lease with
|
||||
// nothing in it, in the lease's own words.
|
||||
//
|
||||
// It carries no `source` for the same reason: the values behind it are
|
||||
// the chosen LEASE's, and a param declares one source for all time. The
|
||||
// lease's own `target.source` is what the authoring form reads once the
|
||||
// author has picked a lease, which is the only moment the right list is
|
||||
// knowable.
|
||||
name: 'target',
|
||||
type: 'string',
|
||||
required: false,
|
||||
example: '003f11b8-9bfa-4587-991e-ca263004efe6',
|
||||
description: 'Which one, for a value that exists on many things. Leave empty otherwise.',
|
||||
},
|
||||
{
|
||||
name: 'minutes',
|
||||
type: 'int',
|
||||
@@ -350,6 +387,26 @@ const ACTIONS = [
|
||||
const coerced = coerceLeaseValue(lease, params.value)
|
||||
if (!coerced.ok) return { ok: false, retry: false, error: coerced.error }
|
||||
|
||||
// **The target is checked before anything else about the world is read**
|
||||
// (Phase 12b), because both of its failures are authoring mistakes rather
|
||||
// than outages: a targeted lease with no target names nothing, and a target
|
||||
// on a lease that has none is an author who has confused two fields. Both
|
||||
// are `retry: false` — the second attempt has the same params.
|
||||
const targetRaw = params.target === undefined || params.target === null ? '' : String(params.target).trim()
|
||||
if (lease.target && !targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} needs a ${lease.target.label.toLowerCase()}` }
|
||||
}
|
||||
if (!lease.target && targetRaw) {
|
||||
return { ok: false, retry: false, error: `${lease.label} is a single value and takes no target` }
|
||||
}
|
||||
const target = lease.target ? targetRaw : null
|
||||
const ref = registries.leaseRef(lease.id, target)
|
||||
// Refused rather than truncated, on the ledger's own rule for a resource
|
||||
// ref: a truncated ref is a restore pointed at the wrong object.
|
||||
if (ref.length > MAX_LEASE_REF) {
|
||||
return { ok: false, retry: false, error: `that target is too long to record (${ref.length} of ${MAX_LEASE_REF})` }
|
||||
}
|
||||
|
||||
const minutes = Number(params.minutes)
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) {
|
||||
return { ok: false, retry: false, error: `"${params.minutes}" is not a number of minutes` }
|
||||
@@ -368,11 +425,22 @@ const ACTIONS = [
|
||||
// allowed. What it deliberately does not do is reserve the target — a
|
||||
// verify that took a lease would be a dry run that changed something, and
|
||||
// it would then refuse the real run that followed it.
|
||||
//
|
||||
// It also does not check that the TARGET exists, and that is the same
|
||||
// rule rather than an exception: asking the game side whether a spawner is
|
||||
// there is a live read the shard may be down for, and a dry run that fails
|
||||
// because a shard is restarting would make `verified_at` a property of the
|
||||
// moment rather than of the version (§K).
|
||||
if (verify) return { ok: true }
|
||||
|
||||
const baseline = await lease.read()
|
||||
const baseline = await lease.read({ target })
|
||||
if (!baseline || baseline.ok !== true) {
|
||||
return { ok: false, error: `could not read the current value of ${lease.label}` }
|
||||
return {
|
||||
ok: false,
|
||||
error: baseline && baseline.error
|
||||
? String(baseline.error)
|
||||
: `could not read the current value of ${lease.label}`,
|
||||
}
|
||||
}
|
||||
|
||||
const until = new Date(Date.now() + ms)
|
||||
@@ -381,8 +449,20 @@ const ACTIONS = [
|
||||
stepId,
|
||||
owner: lease.owner || 'core',
|
||||
kind: 'override',
|
||||
ref: lease.id,
|
||||
payload: { target: lease.id, baseline: baseline.value, applied: coerced.value, until: until.toISOString() },
|
||||
// **The ref carries the target, and that is what makes the unique index
|
||||
// right rather than merely present.** Reserved under the lease id alone,
|
||||
// an event turning up one spawner would lock every other run out of every
|
||||
// other spawner — a conflict check that refuses correct work is as wrong
|
||||
// as one that permits a collision, and on a shard with 6,707 spawners it
|
||||
// is the failure an operator would actually meet.
|
||||
ref,
|
||||
payload: {
|
||||
target: ref,
|
||||
leaseTarget: target,
|
||||
baseline: baseline.value,
|
||||
applied: coerced.value,
|
||||
until: until.toISOString(),
|
||||
},
|
||||
leaseUntil: until,
|
||||
})
|
||||
if (!reserved.ok) {
|
||||
@@ -401,7 +481,7 @@ const ACTIONS = [
|
||||
// rather than one stuck changed indefinitely.
|
||||
let applied
|
||||
try {
|
||||
applied = await lease.apply(coerced.value, until)
|
||||
applied = await lease.apply(coerced.value, until, { target })
|
||||
} catch (err) {
|
||||
applied = { ok: false, error: err.message }
|
||||
}
|
||||
@@ -451,7 +531,11 @@ const ACTIONS = [
|
||||
for (const row of resources || []) {
|
||||
if (row.kind !== 'override') continue
|
||||
|
||||
const lease = registries.eventLease(row.ref)
|
||||
// Resolved through the ref parser rather than by a bare map lookup: a
|
||||
// targeted row's ref is `<id>#<target>` and `eventLease` would miss it,
|
||||
// which would silently report every property lease still in force.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
|
||||
if (!lease || typeof lease.inForce !== 'function') {
|
||||
inForce.push(row.ref)
|
||||
@@ -460,7 +544,7 @@ const ACTIONS = [
|
||||
|
||||
let answer
|
||||
try {
|
||||
answer = await lease.inForce({ ref: row.ref, payload: row.payload || null })
|
||||
answer = await lease.inForce({ ref: row.ref, target: found.target, payload: row.payload || null })
|
||||
} catch (err) {
|
||||
answer = null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user