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
|
||||
}
|
||||
|
||||
@@ -100,7 +100,12 @@ function classifyRevert(raw, what) {
|
||||
|
||||
/** Call a lease's `restore`, under a deadline, never throwing. */
|
||||
async function restoreLease(row) {
|
||||
const lease = registries.eventLease(row.ref)
|
||||
// Through the ref parser, because a targeted lease's ref is `<id>#<target>`
|
||||
// (Phase 12b). A bare map lookup would miss every property lease and report
|
||||
// "no module registers" for one that is registered — leaving a spawner turned
|
||||
// up for good and blaming an uninstalled module for it.
|
||||
const found = registries.eventLeaseForRef(row.ref)
|
||||
const lease = found && found.lease
|
||||
if (!lease) {
|
||||
// The module that owned it is uninstalled or failed to boot. Not a failure to
|
||||
// retry away — nothing will change until an operator reinstalls it — and not
|
||||
@@ -113,7 +118,12 @@ async function restoreLease(row) {
|
||||
let raw
|
||||
try {
|
||||
raw = await withDeadline(
|
||||
() => lease.restore(payload.baseline, { expected: payload.applied, runId: row.run_id }),
|
||||
() =>
|
||||
lease.restore(payload.baseline, {
|
||||
expected: payload.applied,
|
||||
runId: row.run_id,
|
||||
target: found.target,
|
||||
}),
|
||||
DEFAULT_REVERT_BUDGET_MS,
|
||||
row.ref,
|
||||
)
|
||||
|
||||
@@ -98,7 +98,10 @@ function normalise(entry, actionId) {
|
||||
// an `override` through the lease registry — that is the split §F draws — so a
|
||||
// ref naming nothing registered is a resource core would be recording with no
|
||||
// way to undo it, which is the promise rule 2 exists to stop core making.
|
||||
if (kind === 'override' && !registries.eventLease(ref)) {
|
||||
// Through the ref parser: a targeted lease's ref carries its target after a
|
||||
// `#` (Phase 12b), and matching the whole string against the registry would
|
||||
// reject a lease that IS registered.
|
||||
if (kind === 'override' && !registries.eventLeaseForRef(ref)) {
|
||||
return { ok: false, reason: `${actionId} reported a lease "${ref}" no module registers` }
|
||||
}
|
||||
|
||||
|
||||
@@ -488,10 +488,49 @@ const allEventLeases = () =>
|
||||
/** One lease, callables included. `core.lease` and the cleanup sweep read it. */
|
||||
const eventLease = (id) => eventLeases.get(id) || null
|
||||
|
||||
// How a targeted lease's reservation ref is written, and the one place that
|
||||
// knows it. `#` is safe as the separator because `EVENT_ID` admits only
|
||||
// `[a-z0-9_.]`, so no lease id can contain one and the split is unambiguous
|
||||
// however odd a module's target string is.
|
||||
const LEASE_TARGET_SEP = '#'
|
||||
|
||||
/** Compose the ref an `override` row is reserved under. */
|
||||
const leaseRef = (id, target) =>
|
||||
target === null || target === undefined || target === '' ? id : `${id}${LEASE_TARGET_SEP}${target}`
|
||||
|
||||
/**
|
||||
* Split an `override` row's ref back into the lease and the target it names.
|
||||
*
|
||||
* **Every reader of a ledgered lease needs this, not just the one that wrote
|
||||
* it.** `cleanup.restoreLease` and `ledger.normalise` both look a lease up by
|
||||
* `row.ref`, and both were correct for exactly as long as a ref was a bare lease
|
||||
* id. A targeted row would have missed in both — cleanup reporting a lease no
|
||||
* module registers and refusing to restore a world that really was changed,
|
||||
* which is the worst failure this table has.
|
||||
*/
|
||||
function parseLeaseRef(ref) {
|
||||
const text = String(ref === undefined || ref === null ? '' : ref)
|
||||
const at = text.indexOf(LEASE_TARGET_SEP)
|
||||
if (at < 0) return { id: text, target: null }
|
||||
return { id: text.slice(0, at), target: text.slice(at + 1) }
|
||||
}
|
||||
|
||||
/** The lease an `override` row names, target included. Null when nothing registers it. */
|
||||
function eventLeaseForRef(ref) {
|
||||
const { id, target } = parseLeaseRef(ref)
|
||||
const lease = eventLeases.get(id) || null
|
||||
return lease ? { lease, target } : null
|
||||
}
|
||||
|
||||
/** Every option source WITHOUT its resolver — the authoring form's list. */
|
||||
const allEventOptionSources = () =>
|
||||
[...eventOptionSources.values()].map(({ resolve, ...rest }) => rest)
|
||||
|
||||
// The longest search term a source is asked to honour. Bounded rather than
|
||||
// refused: a term this long is a paste rather than a search, and truncating it
|
||||
// still answers something, where refusing would blank a form mid-keystroke.
|
||||
const MAX_OPTION_QUERY = 120
|
||||
|
||||
/**
|
||||
* Resolve one option source, or say why not. Never throws.
|
||||
*
|
||||
@@ -509,12 +548,25 @@ const allEventOptionSources = () =>
|
||||
* rather than to nothing — a dropdown of blank rows is a worse field than the
|
||||
* text box it replaced.
|
||||
*/
|
||||
async function resolveOptionSource(id) {
|
||||
async function resolveOptionSource(id, { q = '' } = {}) {
|
||||
const entry = eventOptionSources.get(id)
|
||||
if (!entry) return { ok: false, reason: `no module registers the option source "${id}"` }
|
||||
// **The search term is passed to every source and required of none** (Phase
|
||||
// 12b). A source answers a list; one whose catalog is larger than a dropdown
|
||||
// can hold answers a list NARROWED BY a term, and the two are the same
|
||||
// callable because the difference is the source's business rather than the
|
||||
// form's. A resolver that ignores the argument behaves exactly as it did
|
||||
// before this existed, which is what made this additive.
|
||||
//
|
||||
// It exists because the first source with more entries than `MAX_OPTIONS` is
|
||||
// Phase 12b's spawner target — 6,707 spawn points against a 2,000 bound. Every
|
||||
// source before it fitted, so the bound had only ever truncated in theory; a
|
||||
// dropdown quietly missing two thirds of the world is the same failure 12a
|
||||
// named for decoration, and truncation cannot be the answer to it.
|
||||
const term = String(q || '').trim().slice(0, MAX_OPTION_QUERY)
|
||||
let raw
|
||||
try {
|
||||
raw = await entry.resolve()
|
||||
raw = await entry.resolve({ q: term })
|
||||
} catch (err) {
|
||||
log.error('option source resolver failed', {
|
||||
source: id,
|
||||
@@ -534,7 +586,11 @@ async function resolveOptionSource(id) {
|
||||
if (o.group) option.group = String(o.group)
|
||||
options.push(option)
|
||||
}
|
||||
return { ok: true, id, label: entry.label, owner: entry.owner, options }
|
||||
// `searchable` is reported so the authoring form knows which field is a
|
||||
// typeahead and which is a plain select. Without it P13 would have to infer it
|
||||
// from whether a truncated list came back, which is a guess that reads
|
||||
// correctly right up until a small shard's spawner list fits.
|
||||
return { ok: true, id, label: entry.label, owner: entry.owner, searchable: entry.searchable, options, q: term }
|
||||
}
|
||||
|
||||
// ── Shape checks, run the moment a registrant calls ────────────────────────
|
||||
@@ -1212,6 +1268,61 @@ function checkEventLeaseShape(entry) {
|
||||
throw new Error(`registerEventLeases: ${l.id} inForce must be a function`)
|
||||
}
|
||||
|
||||
// **A TARGETED lease is a family of values rather than one** (Phase 12b).
|
||||
//
|
||||
// Every lease before this one named a single value — a config key, a rate —
|
||||
// and the lease id WAS the target, which is why the four callables took none
|
||||
// and why the reservation ref was the id alone. An object property is not that
|
||||
// shape: `Spawner.MaxCount` is one lease over thousands of spawners, and two
|
||||
// runs turning up two DIFFERENT spawners must both be allowed while two runs
|
||||
// turning up the same one must not.
|
||||
//
|
||||
// So a lease may declare a target, and when it does core adds one to the
|
||||
// reservation ref (`<id>#<target>`) and hands it to every callable. The
|
||||
// two-events-one-target refusal then comes from the same unique index it
|
||||
// always did, at the granularity the world actually has. **Core still owns the
|
||||
// duration bound and the conflict check** — which is the whole of why §F put
|
||||
// the verb in core, and the reason this is an extension of `core.lease` rather
|
||||
// than a lease verb of the module's own.
|
||||
let target = null
|
||||
if (l.target !== undefined && l.target !== null) {
|
||||
const t = l.target
|
||||
if (typeof t !== 'object' || Array.isArray(t)) {
|
||||
throw new Error(`registerEventLeases: ${l.id} target must be an object`)
|
||||
}
|
||||
if (!t.label) throw new Error(`registerEventLeases: ${l.id} target has no label`)
|
||||
// The source is checked for shape only, never for existence, on
|
||||
// `resolveOptionSource`'s own argument: a source that cannot answer degrades
|
||||
// its field to free text rather than blocking the form, and a source
|
||||
// registered by a module that boots later must not make this one throw.
|
||||
if (t.source !== undefined && !OPTION_SOURCE_ID.test(t.source || '')) {
|
||||
throw new Error(`registerEventLeases: ${l.id} target names a bad option source "${t.source}"`)
|
||||
}
|
||||
target = {
|
||||
label: String(t.label),
|
||||
source: t.source || null,
|
||||
example: t.example === undefined ? null : String(t.example),
|
||||
description: t.description || '',
|
||||
}
|
||||
}
|
||||
|
||||
// **A closed set of values, for the type that has no range** (Phase 12b).
|
||||
// `min`/`max` bound the numeric types and nothing bounded `string`, so a string
|
||||
// lease was free text validated only by the game side — which means an invalid
|
||||
// value is a refusal at DISPATCH, unattended, halfway through a run, instead of
|
||||
// a refusal on the form. Optional, because a genuinely free-text lease still
|
||||
// exists; enforced by `coerceLeaseValue` when present.
|
||||
let values = null
|
||||
if (l.values !== undefined && l.values !== null) {
|
||||
if (!Array.isArray(l.values) || !l.values.length) {
|
||||
throw new Error(`registerEventLeases: ${l.id} values must be a non-empty array`)
|
||||
}
|
||||
if (l.type !== 'string') {
|
||||
throw new Error(`registerEventLeases: ${l.id} is ${l.type}; values is for string leases`)
|
||||
}
|
||||
values = l.values.map((v) => String(v))
|
||||
}
|
||||
|
||||
return {
|
||||
id: l.id,
|
||||
label: l.label,
|
||||
@@ -1219,6 +1330,8 @@ function checkEventLeaseShape(entry) {
|
||||
type: l.type,
|
||||
min,
|
||||
max,
|
||||
values,
|
||||
target,
|
||||
maxDurationMs,
|
||||
read: l.read,
|
||||
apply: l.apply,
|
||||
@@ -1250,7 +1363,18 @@ function checkEventOptionSourceShape(entry) {
|
||||
if (typeof s.resolve !== 'function') {
|
||||
throw new Error(`registerEventOptionSources: ${s.id} has no resolve()`)
|
||||
}
|
||||
return { id: s.id, label: s.label, description: s.description || '', resolve: s.resolve }
|
||||
// **A declaration, not an inference** (Phase 12b). `resolve({ q })` is passed a
|
||||
// term whether or not the source reads it, so nothing here can tell from the
|
||||
// callable alone whether a term would narrow the answer. The module says, and
|
||||
// the authoring form renders a typeahead rather than a select for the ones
|
||||
// that do.
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
description: s.description || '',
|
||||
searchable: s.searchable === true,
|
||||
resolve: s.resolve,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1898,6 +2022,9 @@ module.exports = {
|
||||
isEventBudget,
|
||||
allEventLeases,
|
||||
eventLease,
|
||||
eventLeaseForRef,
|
||||
parseLeaseRef,
|
||||
leaseRef,
|
||||
allEventOptionSources,
|
||||
resolveOptionSource,
|
||||
allEngagementSeeds,
|
||||
|
||||
@@ -533,7 +533,12 @@ exports.actions = async (_req, res) => {
|
||||
* accepts. The registry answers it, so it names no game noun here.
|
||||
*/
|
||||
exports.options = async (req, res) => {
|
||||
const result = await registries.resolveOptionSource(String(req.params.sourceId || ''))
|
||||
// `q` is passed straight through and bounded by the registry, not here: the
|
||||
// registry is what every caller of a source goes through, and a bound written
|
||||
// on the route would be a bound the next caller does not have.
|
||||
const result = await registries.resolveOptionSource(String(req.params.sourceId || ''), {
|
||||
q: String(req.query.q || ''),
|
||||
})
|
||||
return res.json(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,9 +72,13 @@ eventsRouter.get(
|
||||
'/catalog/options/:sourceId',
|
||||
// #swagger.tags = ['Admin · Events']
|
||||
// #swagger.summary = 'Resolve the values behind a param option source'
|
||||
// #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it.'
|
||||
// #swagger.description = 'EVENTS.md F, Param option sources (Phase 7). A param may declare a `source`, and this is what answers it: the module that registered the source resolves the list, so an authoring field is a dropdown of real landmarks or creatures rather than a text box an operator can typo. A refusal comes back as a 200 with `ok: false` and a `reason` -- deliberately, because a source that cannot answer degrades its field to free text with a visible warning rather than blocking the form, and an authoring screen a sidecar outage can make unusable is a worse failure than the typo the dropdown prevents. Values are resolved per request rather than cached in the catalog, because a source can be slow or down and must not take the whole catalog with it. Phase 12b adds the optional `q`: a source whose catalog is larger than a dropdown can hold (the first is the spawner target, 6,707 entries against a 2,000 bound) narrows its answer by it, and one that ignores it answers exactly as before. `searchable` on the response says which is which, so the form renders a typeahead rather than a select.'
|
||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
|
||||
// A single-key `schema` on purpose: swagger-autogen renders a two-key one as an
|
||||
// object schema whose properties are `type` and `maxLength`, which documents a
|
||||
// query parameter that takes a JSON object. The bound is stated in the description.
|
||||
/* #swagger.parameters['q'] = { in: 'query', description: 'Narrow the list. Honoured only by a source that declares itself searchable; ignored, never refused, by the rest. Bounded to 120 characters.', required: false, schema: { type: 'string' } } */
|
||||
/* #swagger.responses[200] = { description: 'The options, or the reason there are none', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, id: { type: "string" }, label: { type: "string" }, owner: { type: "string" }, searchable: { type: "boolean" }, q: { type: "string" }, reason: { type: "string" }, options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, group: { type: "string" } } } } } } } } } */
|
||||
/* #swagger.responses[403] = { description: 'Not staff', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
controller.options,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user