Repository navigation
Conversation
dmurphy5
force-pushed
the
dylan/v10-6-wifi-pause
branch
from
September 28, 2026 17:11
e69e46b to
af6b2a4
Compare
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>
A DELETE, or a POST whose meaning is in the URL, has no body. Diana has several of these today (delete comment, delete attachment, convert photo, watchers). The validator now allows at most one of data, form, file, and none is valid. Docs and the test follow. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
vars is now any object or null, and data is any JSON-serializable value. Generated OpenAPI request types and DTOs have optional fields, object-typed values, and nullable strings, none of which satisfy a recursive Json type. TypeScript cannot prove serializability for those shapes, so mutate() validates at runtime: a cycle, a function, a Date, or a value that does not serialize to an object is rejected with a named error. The 4 KB cap stays. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Native enqueue() is one synchronous disk write, so a promise that never
settles is a native bug. mutate() now races it against a timer, 10 s by
default (configure({ enqueueTimeoutMs })). On timeout it rejects with an
error naming the key and id and logs a warning, and delivery for that id
is not held open. If native did persist the entry, its outcome still
reaches the handlers, and a same-id retry resumes rather than duplicates.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ative contract
From the JS interface review. The 4 KB vars cap was a leftover from the
old ctx field; the generated-ops design puts the request body in vars, so
the default is 1 MB and configure({ maxVarsBytes }) sets it. Meta gains
deliveries, native's count of how many times an outcome was delivered, so
an app can decide a poison policy; the library never gives up on its own.
The response parser receives vars, so a decoder that needs an id runs in
the parser path, where a throw is a terminal onError, not a replay.
The codegen spec comments now pin the contract the native slices
implement: enqueue resolves after every staged copy is on disk and rejects
with E_RUNNING, E_FILE_MISSING, or E_STORAGE; same-id rules for a changed
body; the exact set of rows getRequests returns; cancel of an unknown id
is a no-op; ackEvents is void and idempotent; updateHeaders bumps a header
generation; attempt events are live-only. RequestRow gains nextAttemptAt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Findings from the Android and iOS review that land on the JS side:
- vars and data cross the bridge as JSON strings (varsJson, dataJson).
React Native on iOS drops null-valued object keys, so an object form
loses fields such as { status: null }. A "null" body is a real body; a
bodiless request omits data.
- A GET with a body is rejected at mutate(). DELETE with a body stays.
- E_INVALID joins the enqueue rejection codes for input native cannot
send: non-http(s) URL, bad header names or values, GET with a body,
parts that do not tile the moved file.
- deliveries counts deliveries that reached a JS listener; an outcome
journaled with no listener starts at 0 and is not emitted live.
- A different url or method is a different body. attempts count the
current generation. cancel of a settled entry also forgets its
unacknowledged outcomes. updateHeaders also replaces same-named part
headers. A paused entry past expiresAt settles at resume.
- AttemptEvent.outcome narrows to completed | error; pause, cancel, and
supersede emit no attempt event.
- The watchdog text says a timeout means native did not answer, and that
staging a large file body takes time.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e written Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Android implements the v10 contract pinned in the codegen spec. The v9 chunked manifest store generalizes into QueueStore: one durable entry per mutate() (id, key, vars, descriptor, staged body, state, attempts, bytes, expiresAt, header generation, deliveries) written tmp+fsync+rename before any attempt. BodyStaging writes JSON and multipart bodies to library files, copies single-file bodies, and moves chunked sources as v9 did. WorkManager runs one worker per entry. WorkerOps applies the retry table from the plan: accept rules with bodyIncludes, transient network/5xx/408/ 429 with jittered backoff and nextAttemptAt, 401/403 parking with a header generation that updateHeaders bumps, per-request exempt lists, expiry at expiresAt with bytes kept. The transfer semaphore (4) and chunked window (3) stay. Pause gates the queue; cancel settles a live entry and forgets a settled one; ack is void and idempotent and forgets only a matching generation. The journal writes every outcome before onSettled emits it; onState carries a full RequestRow, onProgress is throttled 1 s / 10 min, onAttempt is live-only. RequestIndex backs the synchronous getRequests. First launch imports v9 journal entries as legacy rows and cancels v9 work. Logic lives in JVM-testable classes (QueueController, WorkerOps, EnqueueRules, EntryTransitions); the module and workers are thin shells. 217 unit tests, including crash-mid-write cases for the store. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reliability: begin() applies an own-generation journal record instead of re-running, so a crash between the journal write and the store update cannot double-send. A failed journal write on settle holds the record in memory and retries; nothing is acked that was not written. cancel() stops the work in a finally block, journals before it saves, and applies an existing record of its generation rather than adding a second outcome. Listening state and the deliveries count are decided under one journal lock, and a live settle goes to the listener that drained, not the newest module instance. Contract: vars and data arrive as JSON text; "null" is a body; GET with a body rejects E_INVALID. Attempt events are completed or error only; pause, cancel, and supersede emit none. A settled entry's cancel forgets its unacked outcomes. attempts reset only on reopen. Under pause only an accepted response settles. A same-id enqueue over a legacy row adopts its v9 manifest. The response body cap applies while streaming. A prune never deletes an eventId a row or a new record names. Rows carry live bytesSent. Platform: a headless run stopped at JobScheduler's 10-minute limit takes the transient path with backoff. Header validation messages carry the name and offset, never the value. README documents the headless limit and the allowBackup requirement. Simplification: one EntryRun attempt path shared by simple and chunked transfers; classifyFailure is the only failure rule; dead code removed. Tests: 289 (was 217), with a TransferHost seam for the run loop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test cleared the listener with stopListening(listener), but from the second iteration on the listener was the previous iteration's owner, so nothing was cleared. When the settle won the race, the journal stamped one live delivery and the drain counted a second one. CI failed about one run in five. Now each iteration clears whatever listener is set and asserts that none is set before the race starts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
When the cancel record was journaled but the entry save then failed, cancel() rejected with E_STORAGE. The cancel had already happened: the worker stopped, the cancelled outcome went to JS, and the ack, the next cancel(), or the boot sweep applies the record. A rejection told JS that nothing changed. Now cancel() resolves in that case, as iOS does. It rejects only when the journal write fails, and then nothing changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
iOS implements the same contract over a background URLSession. QueueStore generalizes the v9 chunked manifest into one durable entry per mutate(), written with fsync before any task is created. BodyStaging writes JSON and multipart bodies to files, copies single-file bodies, and moves chunked sources, so every upload comes from a file as the background session requires. QueueCoordinator owns the entries: enqueue with the same-id rules and E_RUNNING/E_FILE_MISSING/E_STORAGE codes, one task per attempt keyed by (id, generation, attempt), TaskMap reconciliation at relaunch and through handleEventsForBackgroundURLSession, the retry table with delayed tasks for backoff, 401/403 parking with header generations, expiry, pause, cancel, and forget-after-ack. The journal writes before onSettled emits; onState, onProgress (throttled), and onAttempt follow the TypeScript payload types. RequestIndex backs the synchronous getRequests. First launch imports v9 journal entries as legacy rows. The pure half of the module is a SwiftPM package (ios/Package.swift) so `cd ios && swift test` runs 138 host-side tests, including crash-mid-write. The podspec excludes the package files and test sources from the pod. The example app builds for the simulator. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Reliability: reconcile applies unacked same-generation records to a live row before the task loop, so a failed entry save after the journal write cannot re-issue a settled request. deliveries counts only deliveries that reached a JS listener: journaled at 0 with no listener, not emitted live. A chunked part with a pending replay holds its slot for the grace period. A completion that lands before reconcile still advances the attempt ordinal. A part-file build error settles error/file only when the blob is missing or short; otherwise it refills after backoff. The background completion handler releases as soon as the awaited replay lands, and the grace timer only closes the wait it opened. Contract: vars and data arrive as JSON text; NSNull handling for data is gone; E_INVALID covers non-http(s) URLs, bad header names or values, and GET with a body. url and method are part of the body fingerprint. attempts reset only on reopen. A paused entry past expiresAt settles at resume; the expiry check runs after classification. A same-id mutate on a waiting retry retries now. updateHeaders patches part headers. Rows carry live bytesSent; legacy rows report 0/0. Attempt events are completed or error. Simplification: dead fields removed (TaskMap.Meta.accept, isChunkedPart, fileUnreadable, lifetimeMs, descriptorJSON, allDormantManifests). Tests: 170 (was 138), including relaunch and pending-replay cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Owner decision, matching Android: a cancel whose journal write fails rejects with E_STORAGE and changes nothing; the caller may call again. The hold-and-retry path stays for settle outcomes only. A cancel of a settled entry now forgets atomically: the id directory is set aside by one rename, the journal files are deleted, and a failure puts the row back and rejects. A crash between the two steps is finished or undone at launch. 178 tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A background URLSession raises an Objective-C exception, not an error, when uploadTask(with:fromFile:) cannot read the file. Swift cannot catch it, so the app ends. v9.0.1 fixed this for the v9 code (Sentry DIANA-19VW). This applies the same guard to the v10 transport. The transport now throws when the session cannot open the file. Each caller decides what that means: - A simple entry whose staged body is gone settles error 'file', as the missing-body check before it already does. - A simple entry whose body still exists is unreadable for now (data protection while the device is locked). It goes back to queued and is issued again after a backoff. - A chunked part follows the failed part-file rule: a short blob settles error 'file'; otherwise the window refills after a backoff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The example app is the device test harness. It is rewritten on the v10
API: definitions at module scope (JSON POST, multipart, chunked with
chunkPlan, bodiless DELETE, no-vars GET), configure() with a headers
provider, a queue list from getRequests() and the state and progress
feeds, an attempt log, and controls for every step of the two device
scripts: cancel, pause, resume, wifiOnly, updateHeaders with a typed
value, same-id resume and replace, a short expiresAt, and a section of
outcomes journaled before this launch for the kill-and-relaunch check.
The local Express server answers by path segment (401, 404, 409, 503,
slow, oversize) and writes chunked parts at their offsets. The stale
Podfile.lock is regenerated for RN 0.84.1, the AppDelegate wires the
background completion handler, and the Android manifest declares and
requests POST_NOTIFICATIONS. The example README carries the device
script in UI terms.
Library: createUploadClient({ native }) accepts a fake TurboModule, and
react-native-background-upload/src/testing exports createFakeNative(),
an in-memory Spec with settle(), seeded rows, recorded calls, and ack
tracking, so a consumer tests definitions and handlers against the real
registry and delivery. CI gains a macOS job that runs swift test.
Docs: CHANGELOG 10.0.0 covers all slices; README gets an "Upgrading from
v9" section, iOS platform notes, a "Testing your definitions" section,
and a corrected AppDelegate snippet (@import for .m, header search path
for .mm). Version 10.0.0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both changes come from the Diana readiness audit. Diana's Wi-Fi setting
applies to captures only, and Diana pauses captures only while field
note traffic keeps flowing. A queue-wide gate cannot express either.
RequestDescriptor.wifiOnly overrides the queue setting for that entry;
an entry that does not set it follows setWifiOnly, so toggling the
setting still moves queued captures. Both natives evaluate the
constraint per attempt and persist the per-entry value.
pause(scope) and resume(scope) take { keys?: string[] }. No keys means
the global gate, as before. Keys add to or remove from a persisted set.
An entry is paused when the gate is on or its key is in the set; each
entry that moves emits one state event; a scoped resume does not free
an entry the other gate still holds; an entry enqueued into a paused
scope starts paused. Pause produces no outcome and no attempt event.
The fake native in src/testing models both. Tests: JS 189, Android 307,
iOS 201.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The promotion PR says the script measures the time from mutate() to the first send, which decides the deferred in-process fast path. The step now exists, on both platforms, using the log clocks the harness prints. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dmurphy5
force-pushed
the
dylan/v10-6-wifi-pause
branch
from
October 6, 2026 17:40
af6b2a4 to
aa3edd8
Compare
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dmurphy5
marked this pull request as ready for review
October 6, 2026 20:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR merges the whole v10 stack into
masterand becomes release 10.0.0. It is the last step, after the five stacked PRs are reviewed and the device script passes on both platforms.What v10 changes, in one picture
flowchart LR subgraph v9["v9: the app owns the queue"] A1[App code] --> Q1[App's own upload list<br/>retry loop, crash repair] Q1 --> N1[Library: send one upload] N1 -. events, lost if app closed .-> A1 end subgraph v10["v10: the library owns the queue"] A2[App code: define once, mutate] --> N2[Library: durable queue<br/>send, retry, park on 401, journal] N2 -- results, delivered now or at next launch --> A2 endWhy. Every upload reliability bug of the last year had the same cause: the app's list and the library's list could disagree, and the library treated every HTTP response as final. Diana compensated with about 2,000 lines of code. In v10 the library keeps the only list, on disk, and owns retry. The app describes each request kind once and reacts to results.
What an app does now.
define()each request kind with a name, a request builder, and success and failure handlers.mutate(variables). The promise resolves when the request is safe on disk.The stacked PRs
The stack is built on master's 9.0.1 fixes. v10 keeps both: the iOS guard for a file the system cannot open, and WorkManager 2.12.0 for the Android 15 time budget.
Before merging
example/RNBGUExample/README.md, "Device test script"). Step 15 (Android) and step 18 (iOS) measure the time frommutate()to the first send; a median under about 500 ms means no follow-up fast path is needed.v10.0.0on the merge commit. Tagv10.0.0-rc.2already marks the candidate for Diana's cutover branch.After merging
Diana moves all upload code to v10 in one PR, following the migration guide, then runs an internal soak before release.
🤖 Generated with Claude Code