Context
PR #12134 fixed an autoLoad boot race for api-backed stores by making Store.load() await Neo.remotes.Api.ready() before giving up on a missing service stub. While dogfooding that fix in a real remotes-api app (an api-backed Store with autoLoad: true over a websocket), the store still never loaded: service stub registered, ws connected, no Api is not defined error, and zero RPC frames on the wire. The root cause is upstream of #12134's fix — and it makes that fix unreachable for the exact case it targets.
The Problem
Store.load() branches on data source in this order:
if (me.pipeline) { }
else if (me.api) { }
else { }
The else if (me.api) branch is dead code for every store, because every store always has a pipeline instance. beforeSetPipeline() only skips the default-instance path for the url case (!value && me.url && !me.api); for an api store (no url) it falls through to:
return ClassSystemUtil.beforeSetInstance(value, Pipeline, {store: me});
and ClassSystemUtil.beforeSetInstance() mints a default instance for a falsy value:
if (!config && DefaultClass) { config = Neo.create(DefaultClass, defaultValues) }
So pipeline_: null → a real Pipeline → if (me.pipeline) always wins → the api RPC path never executes → autoLoad produces no request. Manual service.read() calls work because they bypass Store.load() entirely, which is why this stayed latent until an api-backed store relied on autoLoad.
The Architectural Reality
src/data/Store.mjs — beforeSetPipeline() (~L440), load() branch order (if (me.pipeline) ~L911 vs else if (me.api) ~L993), pipeline_: null config (~L158).
src/util/ClassSystem.mjs — beforeSetInstance() default-instance creation (if (!config && DefaultClass)).
- Pipeline (connection→parser→normalizer) and remotes-api (RPC stubs via
Neo.currentWorker.promiseMessage) are two parallel, not-yet-combined data-loading universes. The only bridge today is Neo.remotes.Api.register() minting a per-method Pipeline+Rpc for methods that declare parser/normalizer/pipeline. Store-level api loading does not flow through a store pipeline.
The Fix
Stop minting a default Pipeline for api-configured stores in beforeSetPipeline(), so me.pipeline stays null and load() reaches the else if (me.api) branch (which already carries the #12134 fix):
beforeSetPipeline(value, oldValue) {
let me = this;
if (me.api) return null;
oldValue?.destroy();
if (!value && me.url) { }
return ClassSystemUtil.beforeSetInstance(value, Pipeline, {store: me});
}
Order-independent by design: Neo's reactive config getter resolves me.api on demand from configSymbol (Neo.createConfig get-path; initConfig populates the full merged config into configSymbol before processConfigs), so the guard reads the correct api value regardless of which reactive config is processed first.
Variant considered (operator-flagged trade-off)
if (me.api) return null enforces a strict api XOR pipeline invariant: a store that already has an api cannot be switched to a pipeline at runtime without nulling api first. The alternative if (!value && me.api) return null would block only the auto-default (preserving runtime api→pipeline switching via an explicit pipeline value), at the cost of the clean XOR invariant. The runtime-switch case was weighed as ~0.001% likely; defaulting to the strict form.
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Neo.data.Store#pipeline (config) |
Store.beforeSetPipeline() src/data/Store.mjs |
api-configured store no longer auto-mints a default Pipeline; pipeline stays null |
strict: api store never gets a pipeline (runtime switch requires nulling api first) |
JSDoc on pipeline_ + api_ |
regression repro: api store autoLoad:true → zero RPC pre-fix |
Neo.data.Store#load() (api branch ~L993) |
src/data/Store.mjs |
becomes reachable for api stores; fires the remotes-api RPC |
n/a |
— |
#12134 fix goes live |
Decision Record impact
none — no ADR governs the data.Store Pipeline architecture (the learn/agentos/decisions/* pipeline mentions are unrelated infra/graph ADRs).
Acceptance Criteria
Out of Scope
- Unifying the Pipeline and remotes-api universes (routing remotes through connection→parser→normalizer). Separate, larger refactor — see Related.
- Changing
load() branch order.
- The
!value && me.api permissive variant (documented above; not the chosen form).
Avoided Traps
- Flipping
load() branch order (if (me.api) before if (me.pipeline)): treats the symptom, leaves a spurious Pipeline + onPipelinePush listener on every api store, and entrenches "api wins" instead of "api stores have no pipeline." Rejected in favor of fixing the cause in beforeSetPipeline().
- Assuming a config-ordering hazard when
beforeSetPipeline reads me.api: Neo resolves reactive configs on demand from configSymbol, so cross-config reads inside hooks are order-independent. No ordering guard needed.
Related
- #12132 / #12134 — autoLoad awaits remotes-api registration (the fix this unblocks).
- Follow-up (to file): unify Pipeline + remotes-api data-loading paths (epic-scale).
Handoff Retrieval Hints
- Commit anchor:
be50f4f6d (#12134) — the api branch this makes reachable.
- Semantic query:
"api store autoLoad pipeline shadows api branch beforeSetPipeline default instance"
Context
PR #12134 fixed an autoLoad boot race for api-backed stores by making
Store.load()awaitNeo.remotes.Api.ready()before giving up on a missing service stub. While dogfooding that fix in a real remotes-api app (an api-backedStorewithautoLoad: trueover a websocket), the store still never loaded: service stub registered, ws connected, noApi is not definederror, and zero RPC frames on the wire. The root cause is upstream of #12134's fix — and it makes that fix unreachable for the exact case it targets.The Problem
Store.load()branches on data source in this order:if (me.pipeline) { /* connection→parser→normalizer */ } else if (me.api) { /* remotes-api RPC — the #12134 fix lives here */ } else { /* url */ }The
else if (me.api)branch is dead code for every store, because every store always has apipelineinstance.beforeSetPipeline()only skips the default-instance path for the url case (!value && me.url && !me.api); for an api store (nourl) it falls through to:return ClassSystemUtil.beforeSetInstance(value, Pipeline, {store: me});and
ClassSystemUtil.beforeSetInstance()mints a default instance for a falsy value:if (!config && DefaultClass) { config = Neo.create(DefaultClass, defaultValues) }So
pipeline_: null→ a realPipeline→if (me.pipeline)always wins → the api RPC path never executes →autoLoadproduces no request. Manualservice.read()calls work because they bypassStore.load()entirely, which is why this stayed latent until an api-backed store relied onautoLoad.The Architectural Reality
src/data/Store.mjs—beforeSetPipeline()(~L440),load()branch order (if (me.pipeline)~L911 vselse if (me.api)~L993),pipeline_: nullconfig (~L158).src/util/ClassSystem.mjs—beforeSetInstance()default-instance creation (if (!config && DefaultClass)).Neo.currentWorker.promiseMessage) are two parallel, not-yet-combined data-loading universes. The only bridge today isNeo.remotes.Api.register()minting a per-methodPipeline+Rpcfor methods that declareparser/normalizer/pipeline. Store-level api loading does not flow through a storepipeline.The Fix
Stop minting a default
Pipelinefor api-configured stores inbeforeSetPipeline(), some.pipelinestaysnullandload()reaches theelse if (me.api)branch (which already carries the #12134 fix):beforeSetPipeline(value, oldValue) { let me = this; if (me.api) return null; // api stores load via remotes-api, never a default pipeline oldValue?.destroy(); if (!value && me.url) { /* … url pipeline … */ } return ClassSystemUtil.beforeSetInstance(value, Pipeline, {store: me}); }Order-independent by design: Neo's reactive config getter resolves
me.apion demand fromconfigSymbol(Neo.createConfigget-path;initConfigpopulates the full merged config intoconfigSymbolbeforeprocessConfigs), so the guard reads the correctapivalue regardless of which reactive config is processed first.Variant considered (operator-flagged trade-off)
if (me.api) return nullenforces a strict api XOR pipeline invariant: a store that already has anapicannot be switched to apipelineat runtime without nullingapifirst. The alternativeif (!value && me.api) return nullwould block only the auto-default (preserving runtime api→pipeline switching via an explicit pipeline value), at the cost of the clean XOR invariant. The runtime-switch case was weighed as ~0.001% likely; defaulting to the strict form.Contract Ledger
Neo.data.Store#pipeline(config)Store.beforeSetPipeline()src/data/Store.mjsPipeline;pipelinestaysnullapifirst)pipeline_+api_autoLoad:true→ zero RPC pre-fixNeo.data.Store#load()(api branch ~L993)src/data/Store.mjsDecision Record impact
none— no ADR governs thedata.StorePipeline architecture (thelearn/agentos/decisions/*pipeline mentions are unrelated infra/graph ADRs).Acceptance Criteria
beforeSetPipeline()returnsnull(no defaultPipeline) whenme.apiis set.StorewithautoLoad: truehaspipeline === nullafter construction.load()reaches theelse if (me.api)branch and fires the remotes-api RPC.autoLoadstore reaching the api branch (regression lock).Out of Scope
load()branch order.!value && me.apipermissive variant (documented above; not the chosen form).Avoided Traps
load()branch order (if (me.api)beforeif (me.pipeline)): treats the symptom, leaves a spuriousPipeline+onPipelinePushlistener on every api store, and entrenches "api wins" instead of "api stores have no pipeline." Rejected in favor of fixing the cause inbeforeSetPipeline().beforeSetPipelinereadsme.api: Neo resolves reactive configs on demand fromconfigSymbol, so cross-config reads inside hooks are order-independent. No ordering guard needed.Related
Handoff Retrieval Hints
be50f4f6d(#12134) — the api branch this makes reachable."api store autoLoad pipeline shadows api branch beforeSetPipeline default instance"