---
id: 12173
title: Add backend-first remoteCreate/remoteUpdate/remoteDestroy to data.Store
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
  - core
assignees:
  - neo-opus-ada
createdAt: '2026-05-29T15:50:26Z'
updatedAt: '2026-05-29T16:31:36Z'
githubUrl: 'https://github.com/neomjs/neo/issues/12173'
author: neo-opus-ada
commentsCount: 0
parentIssue: null
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-29T16:31:36Z'
---
# Add backend-first remoteCreate/remoteUpdate/remoteDestroy to data.Store

## 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

- [ ] `remoteCreate`/`remoteUpdate`/`remoteDestroy` on `data.Store`, async, backend-first.
- [ ] Success delegates to the sync primitive with the server-authoritative response (create→`add`, destroy→`remove`, update→commit delta).
- [ ] Failure rejects AND fires a store event; the local store stays consistent (no partial mutation).
- [ ] Absent `api.*` verb → graceful local-only op.
- [ ] `update` sends the `trackModifiedFields` delta.
- [ ] Read-only (`api:{read}`) and non-api stores are unaffected (regression guard).
- [ ] Unit coverage for each verb's success + failure + no-api-fallback paths.

## 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()`.

## Timeline

- 2026-05-29T15:50:27Z @neo-opus-ada added the `enhancement` label
- 2026-05-29T15:50:28Z @neo-opus-ada added the `ai` label
- 2026-05-29T15:50:28Z @neo-opus-ada added the `architecture` label
- 2026-05-29T15:50:28Z @neo-opus-ada added the `core` label
- 2026-05-29T15:57:49Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-29T16:10:45Z @neo-opus-ada cross-referenced by PR #12174
- 2026-05-29T16:31:37Z @tobiu referenced in commit `8fec68f` - "feat(data): backend-first remoteCreate/remoteUpdate/remoteDestroy on Store (#12173) (#12174)

Adds an async, backend-first CRUD layer over the remotes-api. The sync mutators
(add/insert/remove) are unchanged; the new methods call `api.{create,update,destroy}`
first and only mutate locally on success (applying the server-authoritative response),
reject + fire a `mutationFailed` event on failure, and fall back to the local op when
the matching `api` verb is not configured. `remoteUpdate` keys the payload by the
record's `keyProperty`.

No RPC plumbing was needed — the api dispatch is already method-generic (`register()`
auto-stubs every descriptor method; `Neo.manager.rpc.Message.onMessage` routes by
method+type), so `create`/`update`/`destroy` dispatch like `read`.

Adds StoreRemoteCrud.spec.mjs — 7 cases covering success / backend-failure / no-api
fallback across the three verbs (fake api service in the namespace; no real ws).

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-29T16:31:37Z @tobiu closed this issue
- 2026-05-29T16:36:47Z @neo-opus-ada cross-referenced by #12175

