Chapter 04 · CRUD with Client and Server SDKs: Reads, Writes, Updates, Deletes, Preconditions, and Converters

Build an Idempotent CRUD Service and Verify Error Handling, Retry, Preconditions, and Observability

Build an AtlasMart CRUD service that survives duplicate requests and concurrent edits by combining stable operation IDs, conditional writes, idempotent outcomes, structured errors, correlation IDs, and deterministic verification.

Beginner → Advanced105–135 minutesAtlasMart emulator-first CRUD labFirebase CLI 15.30.0 · Web SDK 12.19.0 · Admin Node 14.4.0 · Node.js 22+Firestore Standard Native Core semantics unless explicitly labeled EnterpriseLast reviewed: September 2026

Learning outcomes

Network requests are not exactly-once delivery. An AtlasMart client can time out after the server commits, retry the same request, and accidentally create a second side effect if the backend treats every attempt as new. Concurrent edits can also turn a stale update into silent data loss. A production CRUD service therefore needs stable operation identity, conditional mutation, explicit retry classification, and enough telemetry to reconstruct what happened.

01

Design a stable idempotency key/operation document so duplicate requests converge to one logical result.

02

Use create-if-absent and lastUpdateTime preconditions to expose duplicates and stale writes.

03

Separate retryable transport/service failures from permanent validation, authorization, and conflict failures.

04

Emit structured logs with correlation/operation IDs without leaking credentials or sensitive document bodies.

05

Run a deterministic failure matrix proving duplicate retry, stale precondition, Rules denial, and cleanup behavior.

Chapter 04 baseline reviewed 15 September 2026

The lab continues Chapters 01–03 without changing the environment: project demo-atlasmart-firestore; Firestore emulator 127.0.0.1:8080; Auth emulator 127.0.0.1:9099; Emulator UI 127.0.0.1:4000; Firebase CLI 15.30.0; Firebase JavaScript SDK 12.19.0; Firebase Admin Node.js SDK 14.4.0 (which currently carries @google-cloud/firestore 9.1.0); Node.js 22 or newer. The default teaching surface is Firestore Standard edition, Native mode, Core operations, default database (default), emulator-first. Enterprise/Pipeline/MongoDB-compatibility behavior is labeled rather than generalized.

Trust, execution, and evidence note

Browser/mobile SDK requests are untrusted requests evaluated by Firebase Authentication context plus Cloud Firestore Security Rules. Admin/server client libraries are privileged and bypass Firestore Security Rules; production access is governed by IAM/ADC and by authorization logic in your server application. The emulator demonstrates these trust paths and CRUD semantics, but it does not prove production IAM, regional latency, billing, quotas, contention, or service availability. Expected failures are described by invariant/error family rather than fabricated captured output.

1. Idempotency starts with a logical operation ID, not with “retry twice”

An idempotent operation can be attempted multiple times with the same logical input and converge to the same intended state/result. AtlasMart assigns the client/server workflow a stable operationId such as a UUID generated once before retry. The server records crudOperations/{operationId} with a normalized request fingerprint and final result metadata. A second attempt with the same ID and same fingerprint returns the stored result; the same ID with different input is rejected as a key-reuse error.

Field Purpose
operationId Stable identity across transport retries
requestHash Detect same key reused with different semantic input
actorId Bind result to verified caller
targetPath Auditable resource identity
status/result Return same logical result on duplicate request
createdAt/completedAt Operational evidence and retention policy

2. Create-if-absent is a useful primitive, but multi-document workflows need an atomic design

Server create() gives an existence precondition: only one attempt can create a particular document path. If the business operation is “create product p-1001 once,” the product ID itself can be an idempotency boundary. If the workflow writes both the product and an operation ledger, use a transaction/batch design that preserves the intended atomic relationship instead of performing two unrelated writes and hoping retries repair them.

Trusted Node · simple create-if-absent outcome normalization
async function createProductOnce({ productId, product }) {  const ref = db.doc(`products/${productId}`);  try {    const wr = await ref.create({ ...product, schemaVersion: 2, createdAt: FieldValue.serverTimestamp() });    return { kind: "created", path: ref.path, updateTime: wr.updateTime.toDate().toISOString() };  } catch (err) {    if (isAlreadyExists(err)) {      const snap = await ref.get();      return { kind: "already-exists", path: ref.path, updateTime: snap.updateTime?.toDate().toISOString() ?? null };    }    throw err;  }}

isAlreadyExists should normalize the current SDK's structured error code rather than scrape human-readable error text. The exact code surface can evolve; pin the SDK and test it.

3. Optimistic update: return a conflict instead of overwriting a newer edit

An edit form can load product version T1, wait while another operator commits T2, then submit stale data. Blind update() produces last-writer-wins behavior. If the UI/service contract says “apply only if unchanged since I loaded it,” return the observed updateTime (or an application version) with the edit token, and require it on the trusted server update.

Trusted Node · conditional update with correlation ID
async function patchProduct({ actor, productId, patch, observedUpdateTime, correlationId }) {  authorizeSellerEdit(actor, productId);  const safePatch = parseProductPatch(patch);  const ref = db.doc(`products/${productId}`);  try {    const result = await ref.update(      { ...safePatch, updatedAt: FieldValue.serverTimestamp() },      { lastUpdateTime: observedUpdateTime }    );    log({ level: "info", event: "product.patch.committed", correlationId, actorId: actor.id, path: ref.path, updateTime: result.updateTime });    return { status: 200, updateTime: result.updateTime };  } catch (err) {    if (isFailedPrecondition(err)) return { status: 409, code: "STALE_VERSION" };    throw err;  }}

A 409-style application conflict tells the caller to reload/reconcile. Do not automatically retry a stale semantic edit with a newly fetched version unless the operation is commutative and the product contract explicitly allows it.

4. Retry policy is classification, backoff, and budget—not “catch everything”

Retries help transient failures but can amplify outages or duplicate non-idempotent work. Classify errors by layer. Validation and authorization failures are permanent until input/policy changes. Stale preconditions are conflicts requiring reconciliation. Already-exists may be a successful duplicate outcome for an idempotent create. Transport/unavailable/resource-exhausted classes can be retryable when the operation is idempotent and the retry budget has not expired. Use bounded exponential backoff with jitter and a deadline rather than infinite loops.

Failure class Automatic retry? Application outcome
Invalid input No 4xx validation error with field-safe reason
Not authenticated / forbidden No Auth/authz error; security event if suspicious
Already exists on idempotent create Usually normalize, not blind retry Return existing logical result after verification
Failed precondition / stale version No automatic semantic retry Conflict; reload/reconcile
Transient unavailable/deadline/transport Possibly Retry only idempotent operation within bounded budget

5. Structured observability: enough to reconstruct, not enough to leak secrets

Every request gets a correlationId; every idempotent mutation gets an operationId. Logs record verified actor ID, target path, operation name, attempt number, normalized outcome/error class, latency, emulator/production environment, SDK/service version, and resulting update time when available. Do not log Auth tokens, service-account keys, App Check debug tokens, full private profile documents, payment data, or arbitrary request bodies.

Structured log shapes · success, duplicate, conflict, denial
{"event":"product.patch.committed","correlationId":"c-101","operationId":"op-9001","actorId":"seller-7","path":"products/p-1001","attempt":1,"outcome":"committed"}{"event":"product.create.duplicate","correlationId":"c-102","operationId":"op-9002","path":"products/p-1002","attempt":2,"outcome":"existing-result"}{"event":"product.patch.conflict","correlationId":"c-103","operationId":"op-9003","path":"products/p-1001","attempt":1,"outcome":"stale-version"}{"event":"profile.update.denied","correlationId":"c-104","actorId":"u-bob","path":"profiles/u-alice","outcome":"rules-denied"}

6. Hands-on failure matrix

Continue the same local AtlasMart workspace. Keep firebase emulators:start --only auth,firestore running. Admin/server examples set FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 and GCLOUD_PROJECT=demo-atlasmart-firestore; browser examples connect the modular Web SDK explicitly to the Firestore/Auth emulators. Use only synthetic Chapter 04 documents such as profiles/u-alice, products/p-1001, crudOperations/<operationId>, and named test users. Cleanup must target only those paths.

deterministic Chapter 04 acceptance matrix
A. First create with productId=p-1001 -> created exactly once.B. Repeat same create request -> normalized duplicate/existing result; no second product.C. Reuse operationId with different request hash -> reject key reuse.D. Read product updateTime T1; perform another write; submit patch with T1 -> stale conflict.E. Web client u-bob writes profiles/u-alice -> Rules denial.F. Privileged Admin write to server-managed field after application authorization -> succeeds in emulator.G. Submit invalid price type -> runtime validation failure before database mutation.H. Inject a simulated retryable transport wrapper around an idempotent operation -> bounded attempts only.I. Delete test product with correct precondition; stale delete precondition -> conflict.J. Cleanup verifies only Chapter 04 fixtures removed; no recursive production-like wildcard deletion.

Measure latency only as local emulator evidence and label it accordingly. Do not publish those timings as production p95/p99. For a production benchmark, disclose region, edition/mode, SDK/runtime, network, concurrency, cache state, document/index shape, and warmup before interpreting latency.

7. Deliberately wrong approach: retry every exception with a new operation ID

This guarantees duplicate business work under ambiguous network failures. If the first attempt committed but the response was lost, a new operation ID tells the server the retry is a brand-new operation. The repair is to generate the logical operation ID once and reuse it across transport retries, make the mutation idempotent/conditional, classify errors, and preserve the original correlation trail.

A second wrong pattern is “on stale version, fetch latest and overwrite automatically.” That turns a detected conflict back into silent lost-update behavior. Reconciliation is a business decision, not a transport retry.

Knowledge check

Check your understanding

  1. Why should a retry reuse the same operation ID?
  2. What does create-if-absent protect, and what does it not automatically protect?
  3. Why should a stale lastUpdateTime failure normally surface as a conflict?
  4. Which failures are poor candidates for automatic retry?
  5. What identifiers should connect logs across attempts without logging secrets?
Review the answers

1. The operation ID represents one logical mutation; reusing it lets the server detect/normalize duplicate delivery rather than create a second side effect.

2. It prevents a second creation at that document path. It does not by itself make a larger multi-document business workflow atomic or authorize the caller.

3. Because another writer changed the state the caller edited. Silently retrying against the new version can overwrite information the caller never saw.

4. Validation errors, authentication/authorization denials, and semantic conflicts such as stale versions should not be blindly retried.

5. Use a request/correlation ID plus a stable idempotency/operation ID, along with verified actor ID and target path where safe.

Summary and next step

Chapter 04 now gives AtlasMart a complete CRUD contract: precise write semantics, field transforms, distinct client/server trust paths, typed/validated schema evolution, conditional writes, idempotent retry behavior, and structured evidence. Chapter 05 can build queries on top of data whose mutation semantics are no longer ambiguous.

Next: Equality, Inequality, in/not-in, array-contains/array-contains-any, and Compound Query Semantics.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.