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:
@@ -396,6 +396,56 @@ test('a lease whose module is uninstalled is unresolved, never assumed restored'
|
||||
assert.match([...store.rows.values()][0].last_error, /no module registers the lease "demo.rate"/)
|
||||
})
|
||||
|
||||
test('a targeted lease is restored through the lease its ref NAMES, target in hand', async () => {
|
||||
// Phase 12b. A targeted row's ref is `<lease id>#<target>`, and both readers of
|
||||
// one — this sweep and `ledger.normalise` — looked their lease up by the whole
|
||||
// string. Left alone, every property lease would have come back "no module
|
||||
// registers the lease", which is the worst answer this table has: it leaves a
|
||||
// spawner turned up for good AND blames an uninstalled module for it.
|
||||
let seen = null
|
||||
registerLease({
|
||||
id: 'demo.spawner.count',
|
||||
type: 'int',
|
||||
min: 0,
|
||||
max: 50,
|
||||
target: { label: 'Which spawner' },
|
||||
restore: async (baseline, opts) => { seen = { baseline, opts }; return { ok: true } },
|
||||
})
|
||||
addResource({
|
||||
kind: 'override',
|
||||
ref: 'demo.spawner.count#003f11b8-9bfa',
|
||||
step_id: null,
|
||||
payload: { baseline: 3, applied: 30 },
|
||||
})
|
||||
|
||||
const summary = await cleanup.cleanupRun(RUN)
|
||||
assert.equal(summary.reverted, 1)
|
||||
assert.equal(seen.baseline, 3)
|
||||
assert.equal(seen.opts.expected, 30)
|
||||
// The half that makes the restore land on the right object rather than on some
|
||||
// other run's spawner: the module is handed the target, not asked to parse the
|
||||
// ref core composed.
|
||||
assert.equal(seen.opts.target, '003f11b8-9bfa')
|
||||
})
|
||||
|
||||
test('an unregistered TARGETED lease still names itself, ref and all', async () => {
|
||||
// The fail-closed direction is unchanged by the parser: a row whose module is
|
||||
// gone stays unresolved with the reason on it, and the reason quotes the ref
|
||||
// the operator will actually see in the console rather than the bare id.
|
||||
addResource({
|
||||
kind: 'override',
|
||||
ref: 'demo.spawner.count#003f11b8-9bfa',
|
||||
step_id: null,
|
||||
payload: { baseline: 3, applied: 30 },
|
||||
})
|
||||
const summary = await cleanup.cleanupRun(RUN)
|
||||
assert.equal(summary.failed, 1)
|
||||
assert.match(
|
||||
[...store.rows.values()][0].last_error,
|
||||
/no module registers the lease "demo\.spawner\.count#003f11b8-9bfa"/,
|
||||
)
|
||||
})
|
||||
|
||||
// ── The sweep ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('the sweep only touches TERMINAL runs', async () => {
|
||||
|
||||
@@ -795,3 +795,196 @@ test('a dry run of core.lease checks everything and takes nothing', async () =>
|
||||
)
|
||||
assert.equal(bad.outcome, 'terminal')
|
||||
})
|
||||
|
||||
// ── Targeted leases and searchable sources (Phase 12b) ──────────────────────
|
||||
|
||||
test('a targeted lease is a family of values, and the target rides in the ref', async () => {
|
||||
// The shape §F did not have before this phase. Every lease until now named ONE
|
||||
// value, so the lease id WAS the target and the callables needed none. An
|
||||
// object property is one lease over thousands of objects, and the reservation
|
||||
// has to distinguish them or a run turning up one spawner locks out every other
|
||||
// run and every other spawner.
|
||||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||
const seen = []
|
||||
api.registerEventOptionSources([{
|
||||
id: 'demo.options.seen', label: 'Seen',
|
||||
async resolve() { return seen.map((s, i) => ({ value: String(i), label: s })) },
|
||||
}])
|
||||
api.registerEventLeases([{
|
||||
id: 'demo.spawner.count',
|
||||
label: 'Spawner count',
|
||||
type: 'int', min: 0, max: 50,
|
||||
maxDurationMs: 3600000,
|
||||
target: { label: 'Which spawner', source: 'demo.options.seen', example: 'abc' },
|
||||
async read(a) { seen.push('read:' + JSON.stringify(a)); return { ok: true, value: 3 } },
|
||||
async apply(v, until, a) { seen.push('apply:' + JSON.stringify(a)); return { ok: true } },
|
||||
async restore() { return { ok: true } },
|
||||
}])
|
||||
}`))
|
||||
registries.registerCore()
|
||||
|
||||
const lease = registries.eventLease('demo.spawner.count')
|
||||
assert.deepEqual(lease.target, {
|
||||
label: 'Which spawner',
|
||||
source: 'demo.options.seen',
|
||||
example: 'abc',
|
||||
description: '',
|
||||
})
|
||||
|
||||
// The ref composes, and the parser takes it apart again — the property every
|
||||
// reader of a ledgered lease depends on.
|
||||
const ref = registries.leaseRef('demo.spawner.count', '0x40001234')
|
||||
assert.equal(ref, 'demo.spawner.count#0x40001234')
|
||||
assert.deepEqual(registries.parseLeaseRef(ref), {
|
||||
id: 'demo.spawner.count',
|
||||
target: '0x40001234',
|
||||
})
|
||||
assert.equal(registries.eventLeaseForRef(ref).lease.id, 'demo.spawner.count')
|
||||
assert.equal(registries.eventLeaseForRef(ref).target, '0x40001234')
|
||||
|
||||
// An untargeted ref still parses to a bare id, so nothing about the leases that
|
||||
// existed before this phase changes.
|
||||
assert.deepEqual(registries.parseLeaseRef('demo.rate'), { id: 'demo.rate', target: null })
|
||||
})
|
||||
|
||||
test('a targeted lease refuses an empty target, and an untargeted one refuses a target', async () => {
|
||||
// Both are authoring mistakes rather than outages, so both are terminal: the
|
||||
// second attempt has exactly the same params, and a retry loop against a form
|
||||
// error is a run that pauses for nothing.
|
||||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||
api.registerEventLeases([
|
||||
{
|
||||
id: 'demo.spawner.count', label: 'Spawner count', type: 'int', min: 0, max: 50,
|
||||
maxDurationMs: 3600000,
|
||||
target: { label: 'Which spawner' },
|
||||
async read() { return { ok: true, value: 3 } },
|
||||
async apply() { return { ok: true } },
|
||||
async restore() { return { ok: true } },
|
||||
},
|
||||
{
|
||||
id: 'demo.rate.gain', label: 'Gain rate', type: 'float', min: 0.5, max: 5,
|
||||
maxDurationMs: 3600000,
|
||||
async read() { return { ok: true, value: 1 } },
|
||||
async apply() { return { ok: true } },
|
||||
async restore() { return { ok: true } },
|
||||
},
|
||||
])
|
||||
}`))
|
||||
registries.registerCore()
|
||||
|
||||
const noTarget = await dispatch.dispatchStep(
|
||||
step('core.lease', { lease: 'demo.spawner.count', value: '9', minutes: 10 }),
|
||||
{ run: RUN, verify: true },
|
||||
)
|
||||
assert.equal(noTarget.outcome, 'terminal')
|
||||
assert.match(noTarget.error, /needs a which spawner/i)
|
||||
|
||||
const spuriousTarget = await dispatch.dispatchStep(
|
||||
step('core.lease', { lease: 'demo.rate.gain', value: '3', minutes: 10, target: '0x1' }),
|
||||
{ run: RUN, verify: true },
|
||||
)
|
||||
assert.equal(spuriousTarget.outcome, 'terminal')
|
||||
assert.match(spuriousTarget.error, /takes no target/)
|
||||
})
|
||||
|
||||
test('a string lease with a declared value set is bounded at authoring time', async () => {
|
||||
// `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 that arrives
|
||||
// unattended, mid-run. The set closes that at the form.
|
||||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||
api.registerEventLeases([{
|
||||
id: 'demo.season.status', label: 'Seasonal status', type: 'string',
|
||||
values: ['Inactive', 'Active', 'Seasonal'],
|
||||
maxDurationMs: 3600000,
|
||||
target: { label: 'Which event' },
|
||||
async read() { return { ok: true, value: 'Inactive' } },
|
||||
async apply() { return { ok: true } },
|
||||
async restore() { return { ok: true } },
|
||||
}])
|
||||
}`))
|
||||
registries.registerCore()
|
||||
|
||||
const bad = await dispatch.dispatchStep(
|
||||
step('core.lease', { lease: 'demo.season.status', value: 'On', minutes: 10, target: 'Fellowship' }),
|
||||
{ run: RUN, verify: true },
|
||||
)
|
||||
assert.equal(bad.outcome, 'terminal')
|
||||
assert.match(bad.error, /Inactive, Active, Seasonal/)
|
||||
|
||||
const good = await dispatch.dispatchStep(
|
||||
step('core.lease', { lease: 'demo.season.status', value: 'Active', minutes: 10, target: 'Fellowship' }),
|
||||
{ run: RUN, verify: true },
|
||||
)
|
||||
assert.equal(good.outcome, 'done')
|
||||
})
|
||||
|
||||
test('a values list belongs to a string lease and to no other type', () => {
|
||||
// Closed like `type` itself, and for the same reason: a set on an int lease
|
||||
// would be a second bound beside `min`/`max` with no rule about which wins.
|
||||
const api = registries.stage('demo')
|
||||
assert.throws(
|
||||
() => api.registerEventLeases([{
|
||||
id: 'demo.n', label: 'N', type: 'int', min: 0, max: 5, values: ['1', '2'],
|
||||
maxDurationMs: 1000,
|
||||
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
|
||||
}]),
|
||||
/values is for string leases/,
|
||||
)
|
||||
assert.throws(
|
||||
() => api.registerEventLeases([{
|
||||
id: 'demo.s', label: 'S', type: 'string', values: [],
|
||||
maxDurationMs: 1000,
|
||||
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
|
||||
}]),
|
||||
/non-empty array/,
|
||||
)
|
||||
assert.throws(
|
||||
() => api.registerEventLeases([{
|
||||
id: 'demo.t', label: 'T', type: 'bool', target: { source: 'nope' },
|
||||
maxDurationMs: 1000,
|
||||
read: async () => ({ ok: true }), apply: async () => ({ ok: true }), restore: async () => ({ ok: true }),
|
||||
}]),
|
||||
/target has no label/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a source is passed the search term whether or not it reads one', async () => {
|
||||
// Additive on purpose: `resolve({ q })` reaches every source, and one that
|
||||
// ignores the argument answers exactly as it did before this existed. Only the
|
||||
// sources too large for a dropdown have to care, and `searchable` is what says
|
||||
// which those are — inferring it from a truncated answer would read correctly
|
||||
// right up until a small shard's list happens to fit.
|
||||
assertRegistered(loadModule('demo', `module.exports = (ctx, api) => {
|
||||
api.registerEventOptionSources([
|
||||
{
|
||||
id: 'demo.options.big', label: 'Big', searchable: true,
|
||||
async resolve({ q } = {}) {
|
||||
const all = ['alpha', 'beta', 'gamma']
|
||||
const hits = q ? all.filter((n) => n.includes(q)) : all
|
||||
return hits.map((n) => ({ value: n, label: n }))
|
||||
},
|
||||
},
|
||||
{ id: 'demo.options.small', label: 'Small', async resolve() { return [{ value: 'only' }] } },
|
||||
])
|
||||
}`))
|
||||
|
||||
const all = await registries.resolveOptionSource('demo.options.big')
|
||||
assert.deepEqual(all.options.map((o) => o.value), ['alpha', 'beta', 'gamma'])
|
||||
assert.equal(all.searchable, true)
|
||||
|
||||
const narrowed = await registries.resolveOptionSource('demo.options.big', { q: 'et' })
|
||||
assert.deepEqual(narrowed.options.map((o) => o.value), ['beta'])
|
||||
assert.equal(narrowed.q, 'et')
|
||||
|
||||
// The source that ignores it is unchanged, and says it is not searchable so the
|
||||
// form renders a select rather than a box that appears to filter and does not.
|
||||
const small = await registries.resolveOptionSource('demo.options.small', { q: 'anything' })
|
||||
assert.deepEqual(small.options, [{ value: 'only', label: 'only' }])
|
||||
assert.equal(small.searchable, false)
|
||||
|
||||
// Bounded rather than refused: a term this long is a paste, and truncating it
|
||||
// still answers something where refusing would blank the form mid-keystroke.
|
||||
const long = await registries.resolveOptionSource('demo.options.big', { q: 'x'.repeat(400) })
|
||||
assert.equal(long.ok, true)
|
||||
assert.equal(long.q.length, 120)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user