Skip to content

v10 slice 1: define()/mutate() JS layer, codegen spec, native stubs - #41

Draft
dmurphy5 wants to merge 2 commits into
masterfrom
dylan/v10-1-js-layer
Draft

v10 slice 1: define()/mutate() JS layer, codegen spec, native stubs#41
dmurphy5 wants to merge 2 commits into
masterfrom
dylan/v10-1-js-layer

Conversation

@dmurphy5

@dmurphy5 dmurphy5 commented Sep 8, 2026

Copy link
Copy Markdown

Summary

This PR is slice 1 of v10. It replaces the JS surface with a durable mutation registry. It contains no engine code.

The model is TanStack Query's setMutationDefaults + mutate. A consumer calls define() once per request kind, at boot. The definition holds a request builder, an optional response parser, and onSuccess / onError handlers. Call sites pass only variables to mutate(). The native queue owns durability, retry, and delivery. Handlers register every boot, so an outcome that lands while the app is dead still reaches its handler.

const addComment = uploads.define({
  key: 'customFieldNote.comment.add',
  request: ({ siteId, customFieldNoteId, comment }: AddCommentVars) => ({
    url: urls.CustomFieldNoteComments.addComment1({ siteId, customFieldNoteId }),
    data: { comment },
  }),
  response: (raw) => (raw as { content: object[] }).content,
  onSuccess: (content, { customFieldNoteId }) => {
    const comments = content.map((c) => decodeCustomFieldNoteComment(c, customFieldNoteId));
    dispatch(Actions.customFieldNoteActivity.fetchCommentsOk({ commentsByFieldNoteId: { [customFieldNoteId]: comments } }));
  },
});

// call site: resolves when the entry is durable, never on the network
await addComment.mutate({ siteId, customFieldNoteId, comment }, { id: localId });

What the JS layer does

  1. define() registers a key. Types infer from the definition: vars from the request parameter, the onSuccess data type from the response parser. Without a parser, onSuccess receives the RawResponse. A second define() with the same key replaces the first and warns in dev, so hot reload works.
  2. mutate(vars, { id? }) runs request(vars) one time, merges configure().headers() under the descriptor's headers, validates the descriptor (one body kind; parts only with file; parts tile the file from byte 0; vars at most 4 KB), defaults expiresAt to now + 14 days, and calls native enqueue. It resolves when native has persisted the entry.
  3. Delivery starts at configure(). It drains the native journal, then subscribes to live outcomes. It dedupes by eventId. Delivery for an id waits for the caller's mutate() promise. It routes to the definition, parses the body, calls the handler, awaits the promise, then acks. A handler that throws is not acked, so it runs again at next boot. A handler that runs past 30 s logs a warning. An outcome with no definition is reported on the state feed with reason: 'unhandled-key' and stays unacked.
  4. configure({ lifetimeMs, retry, headers, android }) is called once at boot after every define().
  5. Queue control: pause(), resume(), cancel(id), setWifiOnly(), updateHeaders(patch), and a synchronous getRequests() for the boot rebuild of a UI projection.

A chunked example

Chunked uploads are a request kind, not a separate API. A descriptor with file and parts routes to the chunked engine. chunkPlan() stays, so the part count told to the server and the parts array derive from one result.

export const captureFile = uploads.define({
  key: 'capture.file',
  request: ({ siteId, uploadId, path, size, contentType }: CaptureFileVars) => ({
    file: DocumentPath.pathString(path),
    method: 'PUT',
    headers: { 'Content-Type': contentType },
    parts: uploads.chunkPlan(size, { min: MIN_CHUNK_SIZE, max: MAX_CHUNK_SIZE }).map(({ start, end }, i) => ({
      url: urls.recording.file.upload(siteId, uploadId, i + 1),
      headers: { 'Content-Range': `${start}-${end - 1}/${size}` },
      range: { start, end },
    })),
    accept: [{ status: 409, bodyIncludes: 'already completed' }],
    retry: { terminalHttp: { exempt: [] } },   // a part 404 means the S3 multipart is gone
  }),
  onSuccess: (_response, { captureId, fileId }) =>
    dispatch(Actions.recording.fileUploaded({ captureId, fileId, role: 'file' })),
  onError: (error, { captureId, fileId }) => { /* expired, 404 recreate, or uploadFailed */ },
});

Codegen spec and native stubs

The spec gains enqueue, pause, resume, cancel, setWifiOnly, updateHeaders, a synchronous getRequests, and the onState, onProgress, onAttempt, onSettled emitters. configure, getUnacknowledgedEvents, ackEvents, and onNotification stay. Removed: startUpload, startChunkedUpload, cancelUpload, removeUpload, getAllUploads, and the v9 per-outcome emitters.

Both native modules stub the new methods. They reject with E_NOT_IMPLEMENTED. getRequests returns an empty array. The v9 engines and their Kotlin unit tests stay in place, so the Android and iOS slices above this PR can wire them to the new spec. The package compiles and CI passes alone. Until the engine slices land, this branch cannot send a request.

The example app still uses the v9 API. CI only lints it. The hardening slice updates it.

Removed from the public surface

startUpload, addListener('progress' | 'error' | 'completed' | 'cancelled'), getUnacknowledgedEvents, ackEvents, getAllUploads, cancelUpload, removeUpload, per-upload wifiOnly, and the v9 event and option types. The CHANGELOG lists each with its replacement.

Known limits

  • A vars type must be a type alias, not an interface, and arrays must be mutable. The recursive Json constraint requires it. This is documented on the Json type.
  • No file:// normalization in JS. The engine slices accept both forms.

Test Plan

What's required for testing (prerequisites)?

Node 20, yarn, JDK 17 for the Android unit tests.

What are the steps to reproduce (after prerequisites)?

yarn typecheck      # clean, includes src/__typetests__
yarn test           # Test Suites: 4 passed; Tests: 122 passed
yarn lint:ci        # 0 errors (3 pre-existing warnings in example/)
cd example/RNBGUExample/android && JAVA_HOME=<jdk17> ./gradlew :react-native-background-upload:testDebugUnitTest
                    # BUILD SUCCESSFUL

The JS tests cover: define replace-on-duplicate, vars cap, one body kind, parts tiling, header merge order, expiresAt default, id passthrough, replay after configure, buffering during the drain, eventId dedupe, wait-for-mutate ordering, unknown key, completed with parser, truncated body, parser throw, error and cancelled routing, handler rejection keeps the entry unacked, the 30 s warning, per-id delivery order, cancelled ack without a definition, malformed-event drop, nested descriptor validation, case-insensitive header merge, a throwing state listener, getRequests filter, and listener mapping. Type tests prove vars and response inference and that a mistyped handler does not compile.

Compatibility

OS Implemented
iOS ❌ stubs
Android ❌ stubs

Checklist

  • I have tested this on a device and a simulator (not applicable: no engine in this slice)
  • I added the documentation in README.md
  • I updated the typed files (TS)
  • I've added Detox End-to-End Test(s)
  • I've created a snack to demonstrate the changes

🤖 Generated with Claude Code

dmurphy5 and others added 2 commits September 8, 2026 18:16
The public surface becomes a durable mutation registry modeled on TanStack
Query's setMutationDefaults + mutate. A consumer calls define() once per
request kind at boot, with a request builder, an optional response parser,
and onSuccess/onError handlers. Call sites pass only variables to mutate().
The native queue owns durability, retry, and delivery.

JS: src/registry.ts (define, mutate, descriptor validation, header merge,
vars cap, ids), src/delivery.ts (journal replay after configure(), eventId
dedupe, wait-for-mutate ordering, handler routing, ack after the handler's
promise, 30 s warning, unhandled-key reporting), src/index.ts
(createUploadClient), src/types.ts. 113 tests plus type tests.

Codegen spec: enqueue, pause, resume, cancel, setWifiOnly, updateHeaders,
synchronous getRequests, onState/onProgress/onAttempt/onSettled emitters.
Removed: startUpload, startChunkedUpload, cancelUpload, removeUpload,
getAllUploads, and the v9 per-outcome emitters.

Native: both modules stub the new methods with E_NOT_IMPLEMENTED so the
package compiles and CI passes alone. The v9 engines stay in place for the
Android and iOS slices to wire up.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review findings on the JS layer, each with a test:
- Outcomes of one id now deliver in order, one handler at a time, through a
  per-id promise lane. Different ids stay concurrent.
- A cancelled outcome acks before the definition lookup, so an entry whose
  key was renamed still frees its bytes.
- A settled event without a string key, kind and id is dropped with one
  warning per eventId, neither acked nor emitted (a v9-shaped journal).
- mutate() resolves with the entry id it tracked, not native's return.
- Nested descriptor objects (retry, accept, android, part range) reject
  unknown keys and wrong value shapes, so a typo cannot silently fall back
  to the transient default.
- Header merge matches names without regard to case; the descriptor's
  spelling and value win.
- A throwing state listener is caught and warned, and does not block the
  other listeners or the ack.
- CHANGELOG lists the removed UploadId type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant