Skip to content

💥 Scope engine state to the operation that owns it #323

Description

@taras

architecture.md (#314) states the rule this issue carries out, in full:

All state is scoped to the operation that owns it, so it is torn down when
the operation is torn down: created inside the run it describes, provided via
context. No module-scoped registries — not as collections, not hidden inside
library objects that accumulate. One exception: metadata an author declares
at module evaluation, about a value the author owns, may live on that value.

Five declarations in packages/core are on the wrong side of it. Each is one
table per process, shared by every run the process performs, keyed by whatever
happens to reach it — nothing in a caller's scope can see it, override it, or
clear it.

Measured against f413545:

Site Declaration
src/component-failures.ts:27 const printing = new WeakSet<FunctionComponent>()
src/errors.ts:63 const segmentCauses = new WeakMap<ErrorSegment, unknown>()
src/validate.ts:10 const ajv = new Ajv({…})
src/validate.ts:85-86 compiledCache, compiledReturnsCache
src/components/parse-schema.ts:22 const ajv = new Ajv({…})

Nothing else in the repository matches: packages/web already builds a fresh
Ajv per call through createServerAjv(), and every other module-scoped Set
or Map in packages/ is a constant lookup table built from its own contents
(EACH_PROPS, RESERVED_STRUCTURAL, the secret-scanner word lists), which
answers the same question forever and accumulates nothing.

The three conversions

1. printing takes the exception, not context. printErrors(fn) runs while
a component module is evaluated — outside any operation — and what it records is
what an author declared about a function the author owns. That is precisely the
case the rule's last sentence names, and this is its one open application. The
mark moves onto the function object under a module-private Symbol() — not
Symbol.for, so nothing outside the module can forge it — defined
non-enumerable so a component that is copied, wrapped, or inspected does not
carry the declaration along by accident, and read with Object.hasOwn.

2. segmentCauses becomes a run-scoped registry provided via context. It is
created where the execution begins and read by the operation that builds a
DocumentationError, so the cause is attached before any observer can see the
failure. DocumentationError's constructor cannot reach a scope, so the read
moves to a builder operation that runs before it. Expansion driven directly — a
test, a tool describing a document — has no execution around it, so the
outermost expandSegments call opens the registry for exactly its own lifetime.

3. Both Ajv instances become run-scoped, and the two caches go. This is the
rule's "hidden inside library objects that accumulate" clause, and it is
checkable rather than a matter of opinion. In ajv@8.20.0, Ajv#_cache is a
plain Map, and _addSchema runs

let sch = this._cache.get(schema);
if (sch !== undefined) return sch;

this._cache.set(sch.schema, sch);
if (addSchema && !baseId.startsWith("#")) {  }

— the set is unconditional and precedes the addUsedSchema guard the engine
already sets to false. So a module-scoped instance holds a strong reference to
every schema object any run ever compiled, plus its compiled SchemaEnv, for
the life of the process; fresh schema objects arrive with every run.

The same Map also makes the sharing observable rather than merely wasteful: it
is keyed by schema object identity, so a schema object mutated between two
runs gets run 1's stale validator in run 2. The two WeakMap caches in
validate.ts have exactly that shape too, which is why they go rather than
moving: Ajv memoizes by schema object within a run already, so a per-run
instance is the whole cache.

The lint rule

local/no-module-scoped-registry reports a Map, Set, WeakMap or WeakSet
constructed at module scope — declared, exported, assigned later, held inside
another module-scoped value, or standing as a static class field — with the
remedy "create it inside the operation that owns it and provide it via context."

It accepts three shapes that are not registries: a table built from its own
contents (a constant), an instance field (the object's lifetime, not the
module's), and a table handed straight to a call (the module keeps no handle).

It does not catch a module-scoped new Ajv(…) — it matches collection
constructors, and the accumulation there is inside a library object rather than
in a table this repository wrote. The rule text covers it anyway: "not hidden
inside library objects that accumulate." A reviewer has to hold that half; the
lint rule holds the other.

Discipline

Every conversion gets a discriminating test, including one proving that two
sequential executions share no state — what run 1 recorded must not answer for
run 2. No behavior change otherwise: test counts identical except for the new
tests. Nothing this issue adds may itself be module-scoped, so the new code has
to pass the rule it brings in.

Breaking

Scoping the validate.ts compiler to the execution makes its readers
operations, and four of them are published from packages/core/mod.ts:
compilePropsSchema, compileReturnsSchema, validateProps,
validateReturnValue. prepareElicitation is published too and becomes one for
the same reason. A caller writes yield* where it previously called; there is
no other change to what any of them does.

Out of scope

<Retry>, <Result as>, suspension and the error middleware Js api stay
defined and unbuilt. No semantics change and no vocabulary change.

Context: #314, #319.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions