Context
The remotes-api read path is complete (#12134 autoLoad-awaits-registration, #12170 api stores skip the default Pipeline). Apps with a remotes-api backend still have no backend-aware mutation: data.Store's add/insert/remove (from collection.Base) are synchronous and local-only. To support full CRUD UIs over a websocket/ajax backend (the immediate driver is a consuming app's CRUD dialogs), we need a backend-first mutation layer. Designed in operator dialogue 2026-05-29.
The Problem
Store mutators are sync (local). A CRUD UI needs: on create/update/delete, call the backend first, and only reflect the change locally if the backend confirms. Making add/insert/remove async would break their synchronous, return-value contract for every existing caller. So the backend-aware operations must be a new async layer that delegates to the sync primitives on success.
The Architectural Reality
- The api RPC dispatch is already method-generic — no plumbing required:
remotes/Api.register() auto-generates an app-side stub for every method in the descriptor (plain → generateRemote; parser/normalizer/pipeline-configured → per-method Pipeline+Rpc).
- Both converge on
Neo.manager.rpc.Message.onMessage({method, service, params}) (src/manager/rpc/Message.mjs:61), which routes by method+type. read is not special; create/update/destroy dispatch identically once defined in the descriptor.
data/connection/Rpc already exposes create/read/update + a generic execute(method) (src/data/connection/Rpc.mjs), so destroy needs no wrapper (execute('destroy', …)).
data/Record already tracks dirty state (isModified, model.trackModifiedFields, original-data snapshot) → update can send a field delta.
- Sync mutators stay untouched:
collection.Base add(180)/insert(1256)/remove(1395); Store overrides add(258)/insert(857). The new layer wraps, never replaces them.
- Naming:
destroy() is instance-teardown on core.Base(499)/collection.Base(673)/Store(537) and createRecord() is the obj→record converter (Store:597) — hence the remote* prefix: collision-free and it signals the backend round-trip.
The Fix
Add three async methods to Neo.data.Store and extend the api config from {read} to {create, read, update, destroy}:
async remoteCreate(data) — if api.create: await the stub; success → add(response.data) (server-authoritative; add() re-sorts when a sorter is present, so position is correct); failure → reject + fire event. No api.create → local add(data).
async remoteUpdate(record) — if api.update: send the trackModifiedFields delta; success → apply the server response + clear modified (commit); failure → reject, leave the record modified. No api.update → local commit.
async remoteDestroy(record) — if api.destroy: await the stub ({[keyProperty]: id}); success → remove(record); failure → reject + event. No api.destroy → local remove.
A shared private helper resolves api[verb], calls the stub via me.trap(...), and normalizes success/failure.
Error contract: reject (for await … catch callers, e.g. a dialog Save handler) and fire a store event (for passive observers).
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Neo.data.Store#remoteCreate / #remoteUpdate / #remoteDestroy (new public async methods) |
src/data/Store.mjs (new) |
backend-first; gate the sync mutation on success; reject + event on failure |
absent api.* verb → local sync op |
JSDoc per method |
unit tests + consuming-app dogfood |
Neo.data.Store#api (config) |
src/data/Store.mjs api_ |
extends {read} → {create, read, update, destroy} |
{read}-only stores still valid |
JSDoc on api_ |
— |
Decision Record impact
none — no ADR governs data.Store; this extends its public surface additively.
Acceptance Criteria
Out of Scope
- Pipeline ↔ remotes-api unification (the store-level
me.pipeline stays its own world).
- Optimistic-update mode (pessimistic/backend-first is the default; optimistic-with-rollback is a later opt-in config).
- Batch (array) mutation — single-record first.
remoteInsert(index, data) for position-sensitive unsorted stores — deferred until a real case (sorted stores re-sort via add(), making it moot).
Avoided Traps
- Overloading sync
add/insert/remove to be backend-first → would force them async, breaking the synchronous contract for every existing caller. Rejected in favor of a new async layer.
- Naming the delete hook
destroy → collides with instance teardown (core.Base.destroy). Hence remoteDestroy.
- Routing through the Pipeline universe (which already has
create/update) → that's the unification, deliberately deferred; the api RPC backbone already supports CRUD without it.
Related
- #12134, #12170 — the remotes-api read path this builds on.
- Consuming-app CRUD wiring is tracked in that app's own tracker (gitlab); it depends on this landing first.
Handoff Retrieval Hints
- Design dialogue 2026-05-29 (operator-led): the "api path is method-generic" exploration, the
remote* naming decision, and the backend-first + server-authoritative + reject/event contract.
- Anchors:
src/data/connection/Rpc.mjs, src/manager/rpc/Message.mjs:61, src/remotes/Api.mjs register().
Context
The remotes-api read path is complete (#12134 autoLoad-awaits-registration, #12170 api stores skip the default Pipeline). Apps with a remotes-api backend still have no backend-aware mutation:
data.Store'sadd/insert/remove(fromcollection.Base) are synchronous and local-only. To support full CRUD UIs over a websocket/ajax backend (the immediate driver is a consuming app's CRUD dialogs), we need a backend-first mutation layer. Designed in operator dialogue 2026-05-29.The Problem
Store mutators are sync (local). A CRUD UI needs: on create/update/delete, call the backend first, and only reflect the change locally if the backend confirms. Making
add/insert/removeasync would break their synchronous, return-value contract for every existing caller. So the backend-aware operations must be a new async layer that delegates to the sync primitives on success.The Architectural Reality
remotes/Api.register()auto-generates an app-side stub for every method in the descriptor (plain →generateRemote; parser/normalizer/pipeline-configured → per-methodPipeline+Rpc).Neo.manager.rpc.Message.onMessage({method, service, params})(src/manager/rpc/Message.mjs:61), which routes bymethod+type.readis not special;create/update/destroydispatch identically once defined in the descriptor.data/connection/Rpcalready exposescreate/read/update+ a genericexecute(method)(src/data/connection/Rpc.mjs), sodestroyneeds no wrapper (execute('destroy', …)).data/Recordalready tracks dirty state (isModified,model.trackModifiedFields, original-data snapshot) → update can send a field delta.collection.Baseadd(180)/insert(1256)/remove(1395);Storeoverridesadd(258)/insert(857). The new layer wraps, never replaces them.destroy()is instance-teardown oncore.Base(499)/collection.Base(673)/Store(537) andcreateRecord()is the obj→record converter (Store:597) — hence theremote*prefix: collision-free and it signals the backend round-trip.The Fix
Add three async methods to
Neo.data.Storeand extend theapiconfig from{read}to{create, read, update, destroy}:async remoteCreate(data)— ifapi.create:awaitthe stub; success →add(response.data)(server-authoritative;add()re-sorts when a sorter is present, so position is correct); failure → reject + fire event. Noapi.create→ localadd(data).async remoteUpdate(record)— ifapi.update: send thetrackModifiedFieldsdelta; success → apply the server response + clear modified (commit); failure → reject, leave the record modified. Noapi.update→ local commit.async remoteDestroy(record)— ifapi.destroy:awaitthe stub ({[keyProperty]: id}); success →remove(record); failure → reject + event. Noapi.destroy→ localremove.A shared private helper resolves
api[verb], calls the stub viame.trap(...), and normalizes success/failure.Error contract: reject (for
await … catchcallers, e.g. a dialog Save handler) and fire a store event (for passive observers).Contract Ledger
Neo.data.Store#remoteCreate/#remoteUpdate/#remoteDestroy(new public async methods)src/data/Store.mjs(new)api.*verb → local sync opNeo.data.Store#api(config)src/data/Store.mjsapi_{read}→{create, read, update, destroy}{read}-only stores still validapi_Decision Record impact
none— no ADR governsdata.Store; this extends its public surface additively.Acceptance Criteria
remoteCreate/remoteUpdate/remoteDestroyondata.Store, async, backend-first.add, destroy→remove, update→commit delta).api.*verb → graceful local-only op.updatesends thetrackModifiedFieldsdelta.api:{read}) and non-api stores are unaffected (regression guard).Out of Scope
me.pipelinestays its own world).remoteInsert(index, data)for position-sensitive unsorted stores — deferred until a real case (sorted stores re-sort viaadd(), making it moot).Avoided Traps
add/insert/removeto be backend-first → would force them async, breaking the synchronous contract for every existing caller. Rejected in favor of a new async layer.destroy→ collides with instance teardown (core.Base.destroy). HenceremoteDestroy.create/update) → that's the unification, deliberately deferred; the api RPC backbone already supports CRUD without it.Related
Handoff Retrieval Hints
remote*naming decision, and the backend-first + server-authoritative + reject/event contract.src/data/connection/Rpc.mjs,src/manager/rpc/Message.mjs:61,src/remotes/Api.mjsregister().