From 7a47eea8647b21af5386c874dddece8f5c378388 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Tue, 8 Sep 2026 18:16:36 -0400 Subject: [PATCH 1/2] v10 slice 1: define()/mutate() JS layer, codegen spec, native stubs 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 --- CHANGELOG.md | 70 ++ README.md | 473 ++++++------- .../backgroundupload/UploaderModule.kt | 215 ++---- ios/RNFileUploader.mm | 82 ++- package.json | 1 + src/NativeRNFileUploader.ts | 71 +- src/__tests__/client.test.ts | 520 +++++++++++++++ src/__tests__/delivery.test.ts | 627 ++++++++++++++++++ src/__tests__/index.test.ts | 260 -------- src/__tests__/registry.test.ts | 488 ++++++++++++++ src/__typetests__/define.ts | 230 +++++++ src/delivery.ts | 260 ++++++++ src/index.ts | 354 +++++----- src/registry.ts | 371 +++++++++++ src/types.ts | 400 ++++++----- 15 files changed, 3352 insertions(+), 1070 deletions(-) create mode 100644 src/__tests__/client.test.ts create mode 100644 src/__tests__/delivery.test.ts delete mode 100644 src/__tests__/index.test.ts create mode 100644 src/__tests__/registry.test.ts create mode 100644 src/__typetests__/define.ts create mode 100644 src/delivery.ts create mode 100644 src/registry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a22f35b0..7703760a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,73 @@ +## 10.0.0 (unreleased) + +The library now owns a durable request queue. A consumer describes each +request kind one time with `define()`, enqueues instances with `mutate()`, +and receives every outcome through the definition's handlers, on this launch +or a later one. Outcomes are journaled natively before JS hears about them +and acknowledged only after the handler's promise resolves. See the README's +"Usage" and "Reliable delivery" sections. + +This release is built in slices. The JS layer, the codegen spec, and native +stubs land first; the Android and iOS queues follow. Until they land, every +queue method rejects with `E_NOT_IMPLEMENTED`. + +Breaking: +- **`startUpload` and `getAllUploads` are removed.** `define()` + `mutate()` + replace the first; the synchronous `getRequests(filter?)` replaces the + second. +- **The v9 event names are removed.** `addListener` takes `'state'`, + `'progress'`, or `'attempt'`. Terminal outcomes go to the definition's + `onSuccess` / `onError`; a `cancelled` outcome calls no handler. +- **`getUnacknowledgedEvents` and `ackEvents` are internal.** The library + drains the journal after `configure()` and acknowledges after each handler + settles. +- **`cancelUpload` and `removeUpload` fold into `cancel(id)`.** A live entry + settles `cancelled` and is forgotten after its ack; a settled entry is + forgotten now, row and bytes. +- **Per-upload `wifiOnly` becomes `setWifiOnly(enabled)`** on the queue, + persisted natively. +- **`progress` carries `{ id, bytesSent, totalBytes }`** instead of a + percentage. +- **`configure()` must be called at boot, after every `define()`.** It starts + the replay of journaled outcomes. It also takes `lifetimeMs`, `retry`, and a + `headers` provider that runs at `mutate()`. +- **`ErrorKind` gains `'truncated'`.** With a `response` parser set and a body + over the 1 MB cap, `onError` fires with it instead of `onSuccess`. + +Added: +- **`createUploadClient()`**: builds a client with its own definitions and + settings. The default export is one client. +- **`define({ key, request, response?, onSuccess?, onError? })`**: `vars` + infer from the `request` parameter, the handler data type from the + `response` return. A duplicate key replaces the definition and warns in + development. +- **`mutate(vars, { id? })`**: runs `request(vars)` once, merges the configured + headers under the descriptor's, validates the descriptor (exactly one of + `data` / `form` / `file`; `parts` only with `file`; parts must tile the + file; no field outside the descriptor shape), defaults `expiresAt` to now + + `lifetimeMs`, and resolves when the entry is durable. `vars` are capped at + 4 KB. A definition whose `request` takes no vars calls `mutate()` with no + arguments. +- **Request bodies**: JSON (`data`), multipart (`form`), whole file (`file`), + and chunked (`file` + `parts`). All under one entry shape and one id. +- **Delivery rules**: dedupe by event id; an outcome for an id waits for that + id's in-flight `mutate()`; an outcome whose key has no definition stays + unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`; + a handler that has not settled after 30 s logs a warning. +- **`pause()` / `resume()`** for the whole queue, **`updateHeaders(patch)`** to + re-auth parked entries, and the **`attempt`** event with one row per HTTP + attempt before interpretation. + +Removed: +- `startUpload`, `startChunkedUpload` (native), `cancelUpload`, + `removeUpload`, `getAllUploads`, the public `getUnacknowledgedEvents` / + `ackEvents`, and the `progress` / `error` / `completed` / `cancelled` event + names, with their `ProgressData`, `CompletedData`, `ErrorData`, + `CancelledData`, `EventData`, `TerminalEventData`, `JournaledEvent`, + `UploadSnapshot`, `UploadOptions`, `ChunkedUploadOptions`, + `StartUploadOptions`, `AndroidOnlyUploadOptions`, and `RawUploadOptions` + types. + ## 9.0.0 Chunked uploads move into the library: one file, many part requests, one upload diff --git a/README.md b/README.md index 2947d31e..cae49820 100644 --- a/README.md +++ b/README.md @@ -51,248 +51,266 @@ generated header is Objective-C++ only — so the handler lives on `RNBackground # Usage -```js -import Upload from 'react-native-background-upload'; - -// Optional. Call one time at app startup to set the Android notification text. -// The library keeps the text in native storage. Thus a worker relaunched with -// no JS shows the same text. If you do not call configure(), the library uses -// default text and makes its own channel. The call does nothing on iOS. -Upload.configure({ android: { notificationTitle: 'Uploading…' } }); - -// Listeners are global. Every event carries the upload's id. -Upload.addListener('progress', ({ id, progress }) => {}); -// responseCode/responseBody are set for simple uploads only. A chunked -// 'completed' carries neither, because no single response represents N parts. -Upload.addListener('completed', ({ id, responseCode, responseBody }) => {}); -Upload.addListener('error', ({ id, error, errorKind, responseCode }) => {}); -Upload.addListener('cancelled', ({ id, cancelReason }) => {}); - -const uploadId = await Upload.startUpload({ - type: 'raw', - url: 'https://myservice.com/path/to/post', - path: 'file://path/to/file/on/device', - method: 'POST', - headers: { 'content-type': 'application/octet-stream' }, - // Optional. Non-2xx responses to treat as success (for example, an - // idempotent create that conflicts). Each other non-2xx response is an - // 'error' with errorKind 'http'. - accept: [{ status: 409, bodyIncludes: 'already completed' }], +The library owns a durable queue of HTTP requests: JSON bodies, multipart +forms, whole files, and chunked files. You describe each request kind one +time with `define()`, enqueue instances with `mutate()`, and receive every +outcome through the definition's handlers. Outcomes survive app death, +because the native side journals them before it tells JS. + +```ts +import { createUploadClient } from 'react-native-background-upload'; + +export const uploads = createUploadClient(); + +// One definition per request kind. `request` runs one time, at mutate(). +// `vars` must be JSON and at most 4 KB; native persists them next to the entry. +// Declare the vars as a `type` alias: an `interface` fails the Json constraint. +type AddCommentVars = { siteId: string; noteId: string; comment: string }; +export const addComment = uploads.define({ + key: 'note.comment.add', // persisted with every entry; rename with care + request: ({ siteId, noteId, comment }: AddCommentVars) => ({ + url: `https://api.example.com/sites/${siteId}/notes/${noteId}/comments`, + data: { comment }, // JSON body. Default method is POST. + }), + // Parses the JSON body before onSuccess. Zod users pass schema.parse. + response: (raw) => (raw as { content: Comment[] }).content, + onSuccess: (content, { noteId }, meta) => { + // Runs after the server accepted the request, possibly on a later launch. + store.dispatch(commentsLoaded({ noteId, content })); + }, + onError: (error, vars, meta) => { + // error.errorKind: 'http' | 'network' | 'file' | 'expired' | 'truncated' | 'unknown' + }, }); -``` -## Chunked uploads - -A `type: 'chunked'` upload sends one file as many part requests but stays one -logical upload: one id, one event stream, byte-weighted `progress`, and -`completed` only when every part has been accepted. You author the parts — URL, -headers, byte range — once, at creation; the library owns the transport and -never constructs or edits a protocol field. Author the ranges with `chunkPlan` -so the part count you tell your server and the parts the library sends derive -from the same array: - -```js -const size = (await stat(path)).size; -const ranges = Upload.chunkPlan(size, { min: 8 * 2 ** 20, max: 20 * 2 ** 20 }); -// Tell your server ranges.length parts, then: -await Upload.startUpload({ - type: 'chunked', - id: myDurableId, // required - path, // see file ownership below - parts: ranges.map((range, i) => ({ - url: partUrl(i + 1), - headers: { - Authorization: token, - 'Content-Type': 'application/octet-stream', - 'Content-Range': `bytes ${range.start}-${range.end - 1}/${size}`, - }, - range, // bytes, end exclusive - })), - accept: [{ status: 409, bodyIncludes: 'already completed' }], - expiresAt: Date.now() + 14 * 24 * 60 * 60 * 1000, // required, epoch ms +// At boot, after every define() call. Replay of journaled outcomes starts here. +uploads.configure({ + headers: () => ({ Authorization: `Bearer ${currentToken()}` }), + android: { notificationTitle: 'Uploading', notificationChannel: 'uploads' }, }); + +// Anywhere. Resolves when the entry is durable, never on the network. +const { id } = await addComment.mutate( + { siteId, noteId, comment }, + { id: localCommentId }, // optional; makes a re-dispatch idempotent +); ``` -**File ownership.** The library takes the file: an O(1) rename into its own -directory at `startUpload`. Nothing your app does afterward (cache sweeps, -logout cleanup) can destroy the bytes mid-upload. The file is deleted in -exactly one case — a `completed` event has been acknowledged via `ackEvents`. -Copy the file first if you need it afterward. - -**Resume is re-calling `startUpload`.** The parts are persisted in a native -manifest, so crash recovery, resume after `cancelUpload`, resume after expiry, -and refreshing auth headers are all the same call: `startUpload` again with the -same id and the same part ranges/URLs. Parts already accepted are skipped; the -rest continue with the new call's headers and `expiresAt` (this is how a fresh -token reaches parts that stalled on 401). Once a manifest exists, `path` is -ignored — the library's owned bytes are the source of truth. - -**Recreate is the same call with different parts.** When the old server upload -is dead (for example, swept server-side), author fresh part URLs and call -`startUpload` with the same id and the new parts array. The owned bytes are -kept, the parts are replaced, and every part resets to unsent; the new ranges -must tile the same total size. A recreate is accepted only while the upload is -not running — stalled on a terminal error, expired, or cancelled. While it is -running, a differing parts array is rejected: that is a consumer bug, not a -recreate. - -**Lifetime.** Within `expiresAt`, transient failures (network, 5xx) retry on -exponential backoff with no attempt cap. Past it, the library journals an -`error` with `errorKind: 'expired'` and stops — keeping the manifest and bytes, -so you can resume the same server upload with a later `expiresAt`, or recreate -under a new one. When neither is wanted, release them with `removeUpload`. - -Choosing a value: `expiresAt` is when your app *hears about* a stuck upload, -not when data is lost — bytes survive expiry. Pick something well inside your -backend's own cleanup horizon so expiry fires while the server upload is still -resumable, and generous enough for real offline stretches. The OpenSpace -backend prunes incomplete multipart uploads 31 days after creation -(`UploadPartCleanup`); Diana passes 14 days, leaving a 17-day window where an -expired upload can still resume the same server uploadId. +Handlers must be idempotent. The library acknowledges an outcome only after +the handler's promise resolves, so a crash before that point redelivers the +outcome at the next launch. -# Reliable delivery +## The request descriptor -Terminal events (`completed` / `error` / `cancelled`) are journaled natively -*before* they are emitted, so they survive app death, JS reloads, and background -relaunches. Events stay in the journal until you acknowledge them. Drain it on -every app start: - -```js -const events = await Upload.getUnacknowledgedEvents(); -for (const e of events) { - // e: { eventId, id, type, timestamp, responseCode?, responseBody?, - // responseHeaders?, error?, errorKind?, cancelReason? } - handleOutcome(e); -} -await Upload.ackEvents(events.map((e) => e.eventId)); +`request(vars)` returns a plain object. Exactly one body kind is required. +A field outside this table makes `mutate()` reject and name the field, because +TypeScript does not flag a misspelled key on an inferred arrow return. -// Then reconcile anything still in flight: -const live = await Upload.getAllUploads(); // [{ id, state, ... }] +| Field | Notes | +| --- | --- | +| `url` | Required unless `parts` is set. | +| `method` | `POST` (default), `PUT`, `PATCH`, `DELETE`, `GET`. With `parts` it applies to every part. | +| `headers` | Merged over `configure().headers()`. Every chunked part inherits the result. | +| `data` | JSON body. | +| `form` | `multipart/form-data`: `[{ name, contentType, string }]` or `[{ name, contentType, path, fileName? }]`. File parts are copied. | +| `file` | Whole file body. Copied. Moved when `parts` is set. | +| `parts` | Chunked over `file`: `[{ url, headers?, range: { start, end } }]`, bytes, end exclusive, tiling the file from 0. | +| `accept` | Non-2xx responses to treat as success: `[{ status, bodyIncludes? }]`. | +| `expiresAt` | Epoch ms. Default now + `lifetimeMs` (14 days). Past it: `error` with `errorKind: 'expired'`. | +| `retry` | Per-request override of the `configure()` retry defaults. | +| `android` | `{ noNotification?: boolean }`. See Silent uploads. | + +### Chunked uploads + +A descriptor with `file` and `parts` sends one file as many part requests but +stays one entry: one id, byte-weighted `progress`, and `completed` only when +every part is accepted. Author the ranges with `chunkPlan` so the part count +you tell your server and the parts the library sends derive from one array. + +```ts +type CaptureFileVars = { path: string; size: number; uploadId: string }; + +const captureFile = uploads.define({ + key: 'capture.file', + request: ({ path, size, uploadId }: CaptureFileVars) => { + const ranges = uploads.chunkPlan(size, { min: 8 * 2 ** 20, max: 20 * 2 ** 20 }); + return { + method: 'PUT', + file: path, // moved into the library directory + // Derive each part URL from its index. The part count you tell the + // server and the parts sent here then come from the one chunkPlan call. + parts: ranges.map((range, i) => ({ + url: partUrl(uploadId, i + 1), // part numbers are 1-indexed + headers: { 'Content-Range': `${range.start}-${range.end - 1}/${size}` }, + range, + })), + accept: [{ status: 409, bodyIncludes: 'already completed' }], + // A 404 on a part means the server-side multipart is gone. Make it + // terminal so onError can recreate under a fresh server upload id. + retry: { terminalHttp: { exempt: [] } }, + }; + }, + onError: (error, vars) => { /* recreate: mutate() again with new parts */ }, +}); ``` -Notes: -- **`completed` fires only for 2xx** (or a response matching the request's - `accept` rules). Every other HTTP response is an `error` with - `errorKind: 'http'` and the response attached — a 400 is an error, not a - completion. -- `errorKind` is `'http' | 'network' | 'file' | 'expired' | 'unknown'`. Retry - transport failures; treat client errors as terminal; `expired` means a chunked - upload's `expiresAt` passed (see Chunked uploads for recovery). -- `cancelReason` distinguishes a user cancel (`'user'`) from a system kill - (`'system'`). -- Duplicate journal entries for one upload id are possible if the process dies at - the wrong moment (Android may re-run the worker) — dedupe by `id`, keep latest. -- Android: `getAllUploads()` reflects only live/recent work (WorkManager prunes - finished work after ~a day). The journal is the source of truth for outcomes. +**File ownership.** A chunked `file` is moved into the library's directory at +`mutate()`; a single `file` body and every `form` part path are copied. Bytes +are deleted after a `completed` outcome is acknowledged, or on `cancel()`. +Nothing else deletes them. + +**Same id, again.** `mutate()` with an id that exists follows the v9 rules. +Same parts or body: resume; new headers and `expiresAt` replace the stored +ones, and a settled entry reopens and settles once more. Settled entry with +different parts: recreate over the same bytes (the new parts must tile the +same size). Running entry with different parts: reject. + +### Silent uploads (Android) + +`android: { noNotification: true }` runs the request without a progress +notification. That notification is also the worker's foreground-service +notification, so a silent request runs as an ordinary background worker and +the OS may defer or restart it. Reserve it for small payloads. + +# Reliable delivery + +1. **Write-ahead.** Entry, descriptor, and staged body persist before any + attempt. `mutate()` resolves when the write lands. +2. **Journal before emit, ack after the handler.** Every terminal outcome is + journaled natively, then delivered. The library acknowledges after the + handler's promise resolves. A rejection, or app death before the ack, + redelivers at the next launch. A handler that has not settled after 30 s + gets a console warning and keeps waiting. +3. **One outcome per settle cycle.** `pause()` produces none. A same-id + `mutate()` on a settled entry reopens it, and it settles once more. +4. **Never before `mutate()` resolves.** Delivery for an id waits for the + caller's promise. +5. **Replay starts after `configure()`.** Outcomes journaled by a dead session + deliver then. Call every `define()` first. +6. **Unknown key is loud.** An outcome whose key has no definition stays + unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`. +7. **Completed entries are forgotten after ack.** Row and bytes go. An `error` + or `expired` entry keeps both until `cancel()` or a same-id `mutate()`. + +Retry classes: + +| Attempt outcome | Action | +| --- | --- | +| 2xx, or an `accept` rule matches | settle `completed` | +| network failure, 5xx, 408, 429 | exponential backoff (base 1 s, max 2 h, jitter 0.2) until `expiresAt` | +| 401, 403 | park as `awaiting-auth`; resume on `updateHeaders()` | +| other 4xx not in `retry.terminalHttp.exempt` | settle `error` with `errorKind: 'http'` | +| 4xx in `exempt` (default `[404]`) | as transient | +| payload missing on disk | settle `error` with `errorKind: 'file'` | +| `expiresAt` passed | settle `error` with `errorKind: 'expired'`; bytes kept | +| response body over 1 MB with a `response` parser | `onError` with `errorKind: 'truncated'`; the entry is `completed` | + +Every attempt sends an `X-Request-Id` header, minted per attempt. `Meta.requestId` +carries the last one. # API -All methods are on the default export. +`createUploadClient()` returns a client. The default export is one client; +an app needs one. + +### `define(definition): { key, mutate }` + +```ts +type Definition = + | { + key: string; + request: (vars: V) => RequestDescriptor; + response: (raw: unknown) => T; // JSON-parsed body, or undefined when there is none + onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; + onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; + } + | { + key: string; + request: (vars: V) => RequestDescriptor; + response?: undefined; + onSuccess?: (data: RawResponse, vars: V, meta: Meta) => void | Promise; + onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; + }; +``` -### `configure(options): void` -One-time setup — call at app startup. `options.android` sets the upload -notification's text and identity: -`notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. The config is -persisted natively, so a worker relaunched by WorkManager with no JS running -shows the same text. Optional: omitted fields keep the library defaults (each -call replaces the whole config). A no-op on iOS, which has no library -notification. - -### `startUpload(options): Promise` -Starts an upload; resolves to its id. Discriminated on `options.type`: `'raw'` -sends the whole file as one request body, `'chunked'` sends the authored parts -(see Chunked uploads). Rejects (or, for malformed chunked input, throws -synchronously) only on bad options — transport failures and HTTP error responses -arrive later as `error` events, not a rejection. - -**Idempotent for every upload, always.** Calling `startUpload` again with an id -that is already pending or running is never an error: a raw upload resolves with -the same id instead of starting a duplicate; a chunked upload reconciles — parts -already accepted are skipped, the rest continue with the new call's headers. No -pre-dispatch dedupe is needed on your side. - -Options for `type: 'raw'`: - -| Option | Type | Notes | -| --- | --- | --- | -| `url` | string | Required. | -| `path` | string | Required. Local file path (`file://…`). URIs are not escaped for you. | -| `method` | string | Default `POST`. | -| `headers` | object | HTTP headers. | -| `id` | string | Defaults to a generated UUID. | -| `wifiOnly` | boolean | Wait for wifi before/while uploading. | -| `accept` | AcceptRule[] | Non-2xx responses to treat as success — see Accept rules. | -| `android` | object | Optional. `noNotification` (default false) — see Silent uploads. Notification text is set once via `configure()`, not per upload. | - -Options for `type: 'chunked'`: - -| Option | Type | Notes | -| --- | --- | --- | -| `id` | string | Required — your durable id. | -| `path` | string | Required. The library takes ownership of the file — see Chunked uploads. | -| `parts` | array | Required. `{ url, headers, range: { start, end } }` per part; ranges in bytes, end exclusive. Sent verbatim as PUTs. | -| `expiresAt` | number | Required, epoch ms. Past it: terminal `error` with `errorKind: 'expired'`. | -| `accept` | AcceptRule[] | See Accept rules. | -| `wifiOnly` | boolean | Wait for wifi before/while uploading. | -| `android` | object | Same as raw. | - -#### Accept rules - -`accept: Array<{ status: number, bodyIncludes?: string }>` — non-2xx responses -to treat as success, for both upload types. `bodyIncludes` narrows a rule by -response-body substring, for servers where one status carries several meanings -distinguishable only by message. A response matching a rule completes the -request (for chunked, marks the part accepted); any other non-2xx is an `error` -with `errorKind: 'http'`. - -#### Silent uploads (Android) - -`android: { noNotification: true }` uploads a file without posting a progress -notification, so the shade only shows the uploads a user actually asked to watch. - -That notification is also the worker's foreground-service notification, so a -silent upload runs as an ordinary background worker instead. The OS is then free -to defer it, or to stop it mid-flight and let WorkManager re-run it later. Keep -the notification for anything that takes real time to upload; reserve -`noNotification` for small payloads a restart would cost nothing. - -All uploads share one notification (identified by the configured -`notificationId`), and its progress bar reports every in-flight upload — silent -ones included. - -### `cancelUpload(uploadId): Promise` -Cancels an upload. Fires a `cancelled` event with `cancelReason: 'user'`. For a -chunked upload this cancels in-flight requests but keeps the manifest and bytes — -the next `startUpload` with the same id resumes it (there is no separate pause -API). - -### `removeUpload(uploadId): Promise` -Releases an upload's native manifest and bytes. Every terminal outcome other -than an acked `completed` (expired, error, cancelled) keeps both so you can -resume or recreate; call this once neither is wanted. +`V` infers from the `request` parameter annotation, `T` from the `response` +return type. Without `response`, `onSuccess` receives the `RawResponse` +(`{ status?, headers?, body?, bodyTruncated }`), and an `onSuccess` annotated +with any other type is a compile error. `V` must be a `type` alias with +mutable arrays: an `interface` or a `readonly T[]` field fails the `Json` +constraint, and the compiler error names `null` rather than the cause. A +`request` that declares no parameter gives `V = null`, and `mutate()` then +takes no arguments. When `response` is set and +the body was truncated, `onError` gets `errorKind: 'truncated'`. When +`response` throws, `onError` gets `errorKind: 'unknown'` with the thrown +message; the entry still settles as completed. A key that is already defined +is replaced, with a warning in development. A `cancelled` outcome calls no +handler. + +`Meta` is `{ id, key, at, attempts, requestId? }`; `at` is the native outcome +time. + +### `mutate(vars, { id? }): Promise<{ id }>` + +Runs `request(vars)` once, merges `configure().headers()` under the +descriptor's headers, validates the descriptor, defaults `expiresAt`, and +persists the entry. Resolves with the id when the write lands. Rejects on a +malformed descriptor, an unknown descriptor field, a missing file, or `vars` +over 4 KB. Only `vars` are capped. `id` defaults to a UUID. For a definition +whose `request` takes no vars, call `mutate()` with no arguments; native stores +`null`. -### `chunkPlan(sizeBytes, { min?, max? }): Array<{ start, end }>` -Splits a byte count into contiguous, end-exclusive ranges: a deterministic -greedy walk of `max`-sized chunks (default 20MB), with a final remainder smaller -than `min` (default 8MB) absorbed into the previous chunk. A file smaller than -`min` is a single chunk. Pure and deterministic on purpose: call it once and -derive both your server's part count and the `parts` array from the same result, -so the two can never disagree. +### `configure(options): void` -### `addListener(eventType, listener): EventSubscription` -`addListener(event: 'progress' | 'error' | 'completed' | 'cancelled', callback)`. -Listeners are global — there is no per-upload subscription; every event carries -the upload's `id`, so discriminate on it. Call `.remove()` on the result to -unsubscribe. +Call one time at boot, after every `define()`. Starts replay of journaled +outcomes. A second call updates the settings and does not replay again. -### `getUnacknowledgedEvents(): Promise` -Terminal events not yet acknowledged, including ones that fired while JS was dead. +| Option | Notes | +| --- | --- | +| `lifetimeMs` | Default `expiresAt` distance. Default 14 days. | +| `retry` | `{ backoff?: { baseMs, maxMs, jitter }, terminalHttp?: { exempt } }`. Each of the two objects is optional, but one you give must be complete. Defaults 1 s, 2 h, 0.2, `[404]`. | +| `headers` | `() => Record`, called at `mutate()`. The descriptor merges over it. | +| `android` | Notification text and identity: `notificationId/Title/TitleNoWifi/TitleNoInternet/Channel`. Persisted natively. | + +### `pause(): Promise` and `resume(): Promise` +Whole-queue pause. No outcome is produced; live rows show `paused`. + +### `cancel(id): Promise` +A live entry settles `cancelled` with reason `user` and is forgotten after +its ack. A settled entry is forgotten now, row and bytes. + +### `setWifiOnly(enabled): Promise` +Persisted natively. Applies to queued and future entries. + +### `updateHeaders(patch): Promise` +Merges the patch into every queued and parked entry's headers, then resumes +the entries parked on `awaiting-auth`. This is how a fresh token reaches +requests that stalled on 401. + +### `getRequests(filter?): RequestRow[]` +Synchronous. Live rows from native's in-memory index, so it works offline. +`filter` is `{ key?, id? }`. A row is +`{ id, key, vars, state, bytesSent, totalBytes, attempts, updatedAt }`, with +`state` one of `queued | running | awaiting-auth | paused | completed | error | cancelled`. +`vars` is typed `Json`, because a row does not know its definition. Narrow +it before reading a field, for example to cancel every entry of one capture: + +```ts +uploads + .getRequests({ key: 'capture.file' }) + .filter((row) => (row.vars as { captureId?: string }).captureId === captureId) + .forEach((row) => uploads.cancel(row.id)); +``` -### `ackEvents(eventIds: string[]): Promise` -Removes journaled events once processed. +### `chunkPlan(sizeBytes, { min?, max? }): Array<{ start, end }>` +Splits a byte count into contiguous, end-exclusive ranges: a deterministic +greedy walk of `max`-sized chunks (default 20 MB), with a final remainder +smaller than `min` (default 8 MB) absorbed into the previous chunk. A file +smaller than `min` is a single chunk. Also a module export. -### `getAllUploads(): Promise` -Uploads the OS still knows about, for boot-time reconciliation. +### `addListener(event, listener): EventSubscription` +See Events. Listeners are global; every event carries the entry's `id`. Call +`.remove()` on the result to unsubscribe. ### `android.addNotificationListener(listener)` Fires when the Android progress notification is pressed. No event data. @@ -301,10 +319,11 @@ Fires when the Android progress notification is pressed. No event data. | Event | Data | | --- | --- | -| `progress` | `{ id, progress: 0-100 }` | -| `completed` | `{ id, responseCode?, responseBody?, responseHeaders?, eventId? }` — response fields on simple uploads only; a chunked `completed` carries none (no single response represents N parts) | -| `error` | `{ id, error, errorKind?, partIndex?, responseCode?, responseBody?, responseHeaders? }` | -| `cancelled` | `{ id, cancelReason?: 'user' | 'system' }` | +| `state` | A full `RequestRow`, one per transition, plus `reason: 'unhandled-key'` for an outcome whose key has no definition. A consumer's reducer is one upsert. | +| `progress` | `{ id, bytesSent, totalBytes }`, byte-weighted across a chunked upload's parts. | +| `attempt` | One HTTP attempt before interpretation: `{ id, key, requestId, attempt, url, method, partIndex?, outcome, httpCode?, responseBody? (4 KB cap), responseBodyTruncated?, responseHeaders?, errorKind?, errorMessage?, cancelReason?, at }`. | + +Terminal outcomes do not appear here. They go to the definition's handlers. # Contributing diff --git a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt index 206253d7..2c0ad81a 100644 --- a/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt +++ b/android/src/main/java/ai/openspace/backgroundupload/UploaderModule.kt @@ -11,12 +11,12 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.google.gson.Gson import java.io.File import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.util.UUID /** @@ -32,8 +32,11 @@ class UploaderModule(context: ReactApplicationContext) : const val TAG = "RNFileUploader.UploaderModule" const val WORKER_TAG = "RNFileUploader" // WorkInfo exposes tags but not the unique-work name, so the upload id is - // also stored as a prefixed tag to recover it in getAllUploads. + // also stored as a prefixed tag to recover it from a WorkInfo row. const val ID_TAG_PREFIX = "RNFileUploaderId:" + // v10 slice 1 ships the JS layer alone. Every queue method rejects with + // this code until slice 2 builds the Android queue and executor. + const val E_NOT_IMPLEMENTED = "E_NOT_IMPLEMENTED" // The live module, so EventReporter can reach the codegen emitters — they are // protected on the generated spec, so only this class may call them. Null @@ -65,13 +68,18 @@ class UploaderModule(context: ReactApplicationContext) : // MARK: - Event emission (called by EventReporter) - fun emitProgressEvent(params: WritableMap) = safeEmit { emitOnProgress(params) } + // The v9 workers still report through these. The v10 spec has no per-outcome + // emitters and a different progress shape ({ id, bytesSent, totalBytes }), so + // until slice 2 rewires the workers to onState/onProgress/onSettled, the live + // v9 payloads are dropped here. Terminal outcomes are journaled first, so + // nothing durable is lost. + fun emitProgressEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit - fun emitCompletedEvent(params: WritableMap) = safeEmit { emitOnCompleted(params) } + fun emitCompletedEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit - fun emitErrorEvent(params: WritableMap) = safeEmit { emitOnError(params) } + fun emitErrorEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit - fun emitCancelledEvent(params: WritableMap) = safeEmit { emitOnCancelled(params) } + fun emitCancelledEvent(@Suppress("UNUSED_PARAMETER") params: WritableMap) = Unit fun emitNotificationEvent(params: WritableMap) = safeEmit { emitOnNotification(params) } @@ -139,92 +147,49 @@ class UploaderModule(context: ReactApplicationContext) : /** - * Enumerates the uploads that WorkManager still knows about, as - * [{ id, state }]. Chunked uploads also carry the aggregate - * { bytesSent, totalBytes }, and they are listed from their durable - * manifests, even after WorkManager prunes finished work (in roughly a day). - * Terminal outcomes must be read from getUnacknowledgedEvents, which is - * durable until acknowledged. + * Synchronous. The live rows of the v10 queue. Slice 2 serializes them from + * the in-memory index; until then the queue is empty. */ - override fun getAllUploads(promise: Promise) { - try { - val manifests = ChunkedManifestStore.get(reactApplicationContext).all() - .associateBy { it.id } - // Several WorkInfo rows can exist for one upload id. Finished chains - // linger until they are pruned (in roughly a day), and APPEND_OR_REPLACE - // resumes add rows. Thus the rows are grouped, and each id gets exactly - // ONE entry, like iOS (one row per upload, with the same state - // vocabulary). - val statesById = workManager.getWorkInfosByTag(WORKER_TAG).get() - .groupBy( - { info -> - info.tags.firstOrNull { it.startsWith(ID_TAG_PREFIX) } - ?.removePrefix(ID_TAG_PREFIX) - }, - { it.state }, - ) - val arr = Arguments.createArray() - for ((id, states) in statesById) { - if (id == null || id in manifests) continue - arr.pushMap(Arguments.createMap().apply { - putString("id", id) - putString("state", simpleUploadState(states)) - }) - } - // Chunked uploads are listed from their durable manifests, which outlive - // the WorkManager rows. Only a live row contributes (running or pending). - // A lingering finished row never speaks for a manifest that is really - // "finished, awaiting its ack" or "stalled, awaiting a startUpload - // resume". - for (manifest in manifests.values) { - arr.pushMap(Arguments.createMap().apply { - putString("id", manifest.id) - putString( - "state", - chunkedUploadState(statesById[manifest.id].orEmpty(), manifest.allAccepted), - ) - putChunkedBytes(manifest) - }) - } - promise.resolve(arr) - } catch (exc: Throwable) { - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } + override fun getRequests(): WritableArray = Arguments.createArray() /** * Saves the notification configuration (see [NotificationConfig]). Thus a * worker that WorkManager relaunches with no JS can read it. Each call * replaces the full configuration. An omitted field goes back to the library - * default. + * default. The v10 `lifetimeMs` and `retry` fields ride along in the same + * map; slice 2 persists them next to the queue. */ override fun configure(options: ReadableMap) { NotificationConfig.save(reactApplicationContext, NotificationConfig.fromReadableMap(options)) } - /* - * Starts a file upload. - * Returns a promise with the string ID of the upload. - */ - override fun startUpload(options: ReadableMap, promise: Promise) { - try { - val id = enqueueUpload(options) - promise.resolve(id) - } catch (exc: Throwable) { - if (exc !is Upload.MissingOptionException) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - } - promise.reject(exc) - } - } + // MARK: - v10 queue (stubs until slice 2) + + private fun notImplemented(promise: Promise, method: String) = + promise.reject(E_NOT_IMPLEMENTED, "RNFileUploader.$method: the Android queue is not built yet") + + /** Persists { id, key, vars, descriptor } and schedules it. Slice 2. */ + override fun enqueue(entry: ReadableMap, promise: Promise) = notImplemented(promise, "enqueue") + + override fun pause(promise: Promise) = notImplemented(promise, "pause") + + override fun resume(promise: Promise) = notImplemented(promise, "resume") + + override fun cancel(id: String, promise: Promise) = notImplemented(promise, "cancel") + + override fun setWifiOnly(enabled: Boolean, promise: Promise) = notImplemented(promise, "setWifiOnly") + + override fun updateHeaders(patch: ReadableMap, promise: Promise) = notImplemented(promise, "updateHeaders") + + + // MARK: - v9 enqueue paths, kept for slice 2 to wire behind enqueue() /** * @return the id of the enqueued upload */ + @Suppress("unused") private fun enqueueUpload(options: ReadableMap): String { val upload = Upload.fromReadableMap(options) val data = Gson().toJson(upload) @@ -262,18 +227,7 @@ class UploaderModule(context: ReactApplicationContext) : * recovery, a resume after a stop, and a resume with fresh auth are all this * same call. */ - override fun startChunkedUpload(options: ReadableMap, promise: Promise) { - try { - promise.resolve(enqueueChunkedUpload(options)) - } catch (exc: Throwable) { - if (exc !is IllegalArgumentException) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - } - promise.reject(exc) - } - } - + @Suppress("unused") private fun enqueueChunkedUpload(options: ReadableMap): String { val store = ChunkedManifestStore.get(reactApplicationContext) val id = options.getString("id") @@ -346,6 +300,7 @@ class UploaderModule(context: ReactApplicationContext) : return id } + @Suppress("unused") private fun takeOwnership(source: File, blob: File) { if (!source.exists()) { // A crash between the rename and the manifest save leaves the bytes at @@ -359,84 +314,6 @@ class UploaderModule(context: ReactApplicationContext) : // renameTo cannot cross filesystems. Files.move falls back to copy+delete. Files.move(source.toPath(), blob.toPath(), StandardCopyOption.REPLACE_EXISTING) } - - - /** - * Releases an upload's stored state. It cancels the scheduled or running - * work, then deletes the chunked manifest and the moved bytes. It is safe on - * any id. A simple upload has nothing stored, so the call reduces to the - * work cancel. There is deliberately no 'cancelled' event. This is an - * explicit release by the consumer, not an outcome that the consumer awaits. - */ - override fun removeUpload(id: String, promise: Promise) { - try { - workManager.cancelUniqueWork(id) - // No user-cancel mark was set, so a running worker's stop handler - // reports nothing. The consume call clears a stale mark from a prior - // life. - UserCancellations.consume(id) - ChunkedManifestStore.get(reactApplicationContext).remove(id) - promise.resolve(null) - } catch (exc: Throwable) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } - - - /* - * Cancels file upload - * Accepts upload ID as a first argument, this upload will be cancelled - * Event "cancelled" will be fired when upload is cancelled. - */ - override fun cancelUpload(id: String, promise: Promise) { - try { - val activeStates = workManager.getWorkInfosForUniqueWork(id).get() - .map { it.state } - .filter { !it.isFinished } - - if (activeStates.isEmpty()) { - // Nothing to cancel. Drop any mark so a later upload reusing this id - // can't be misreported as a user cancel. - UserCancellations.consume(id) - promise.resolve(false) - - return - } - - // Record the intent BEFORE the cancel. Then a running worker's stop - // handler can tell this apart from a system stop, and it reports - // cancelReason 'user'. - UserCancellations.mark(id) - workManager.cancelUniqueWork(id) - - if (cancelReportsFromModule(activeStates)) { - // No worker ever started: the rows are only ENQUEUED, or BLOCKED - // behind an appended chain. Thus no stop handler will ever run, and - // nothing else would ever report this cancellation. A consumer would - // then await this upload's outcome forever. Report it here instead, - // and consume the mark so it cannot leak. - UserCancellations.consume(id) - EventReporter.journalAndEmit( - reactApplicationContext, - EventJournal.Entry( - eventId = UUID.randomUUID().toString(), - uploadId = id, - type = "cancelled", - timestamp = System.currentTimeMillis(), - cancelReason = "user", - ), - ) - } - - promise.resolve(true) - } catch (exc: Throwable) { - exc.printStackTrace() - Log.e(TAG, exc.message, exc) - promise.reject(exc) - } - } } /** @@ -479,14 +356,6 @@ internal fun cancelReportsFromModule(unfinishedStates: List): Bo internal fun hasQueuedSuccessor(states: List): Boolean = states.any { !it.isFinished && it != WorkInfo.State.RUNNING } -// The aggregate byte fields for a chunked upload's snapshot. bytesSent counts -// accepted parts only. That is the durable number, and it has meaning even in -// a process where the worker does not run. -private fun WritableMap.putChunkedBytes(manifest: ChunkedManifest) { - putDouble("bytesSent", manifest.acceptedBytes.toDouble()) - putDouble("totalBytes", manifest.totalBytes.toDouble()) -} - /** * One state for a chunked upload id, from all its WorkInfo rows plus the * durable manifest. A live row wins. With no live row, the manifest speaks. diff --git a/ios/RNFileUploader.mm b/ios/RNFileUploader.mm index 3bf3c038..c0d0ae3b 100644 --- a/ios/RNFileUploader.mm +++ b/ios/RNFileUploader.mm @@ -66,38 +66,69 @@ + (NSString *)moduleName #pragma mark - Exported methods -// configure() carries the Android notification configuration. iOS background -// uploads have no library-owned notification. Thus there is nothing to save. +// v10 slice 1 ships the JS layer alone. Every queue method rejects with this +// code until slice 3 builds the iOS queue and executor. +static NSString *const kNotImplemented = @"E_NOT_IMPLEMENTED"; + +static void RejectNotImplemented(RCTPromiseRejectBlock reject, NSString *method) +{ + reject(kNotImplemented, + [NSString stringWithFormat:@"RNFileUploader.%@: the iOS queue is not built yet", method], + nil); +} + +// configure() carries { lifetimeMs, retry, ...androidNotificationConfig }. iOS +// background uploads have no library-owned notification, and slice 3 persists +// the queue settings. Thus there is nothing to save yet. - (void)configure:(NSDictionary *)options { } -- (void)startUpload:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)enqueue:(NSDictionary *)entry + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + RejectNotImplemented(reject, @"enqueue"); +} + +- (void)pause:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + RejectNotImplemented(reject, @"pause"); +} + +- (void)resume:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared startUpload:options resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"resume"); } -- (void)startChunkedUpload:(NSDictionary *)options - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)cancel:(NSString *)id + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared startChunkedUpload:options resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"cancel"); } -- (void)cancelUpload:(NSString *)id - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)setWifiOnly:(BOOL)enabled + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared cancelUpload:id resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"setWifiOnly"); } -- (void)removeUpload:(NSString *)id - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject +- (void)updateHeaders:(NSDictionary *)patch + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { - [RNBackgroundUpload.shared removeUpload:id resolve:resolve reject:reject]; + RejectNotImplemented(reject, @"updateHeaders"); +} + +// Synchronous. The live rows of the v10 queue. Slice 3 serializes them from +// the in-memory index; until then the queue is empty. +- (NSArray *)getRequests +{ + return @[]; } - (void)getUnacknowledgedEvents:(RCTPromiseResolveBlock)resolve @@ -113,12 +144,6 @@ - (void)ackEvents:(NSArray *)ids [RNBackgroundUpload.shared ackEvents:ids resolve:resolve reject:reject]; } -- (void)getAllUploads:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject -{ - [RNBackgroundUpload.shared getAllUploads:resolve reject:reject]; -} - #pragma mark - RNFileUploaderEventDelegate // Called synchronously on the URLSession delegate queue. That is safe and @@ -142,24 +167,25 @@ - (void)safeEmit:(void (^)(RNFileUploader *emitter))block } } +// The v9 Swift engine still reports through the delegate. The v10 spec has no +// per-outcome emitters and a different progress shape ({ id, bytesSent, +// totalBytes }), so until slice 3 rewires the engine to onState/onProgress/ +// onSettled, the live v9 payloads are dropped here. Terminal outcomes are +// journaled first, so nothing durable is lost. - (void)emitProgress:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnProgress:body]; }]; } - (void)emitCompleted:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnCompleted:body]; }]; } - (void)emitError:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnError:body]; }]; } - (void)emitCancelled:(NSDictionary *)body { - [self safeEmit:^(RNFileUploader *emitter) { [emitter emitOnCancelled:body]; }]; } @end diff --git a/package.json b/package.json index 102f21d5..95cf6e22 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "files": [ "src", "!src/__tests__", + "!src/__typetests__", "android", "!android/build", "!android/.gradle", diff --git a/src/NativeRNFileUploader.ts b/src/NativeRNFileUploader.ts index ff35ac28..07de5bdc 100644 --- a/src/NativeRNFileUploader.ts +++ b/src/NativeRNFileUploader.ts @@ -2,42 +2,57 @@ import { type CodegenTypes, type TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; // Codegen TurboModule spec (New Architecture). The typed public API lives in -// ./types and is applied at the JS edge in ./index; here the dynamic-shaped -// payloads (the options dict, journaled events, upload snapshots, and the -// terminal event payloads that carry header maps + optional fields) are declared -// as UnsafeObject because codegen can't model index signatures, Partial<>, or -// intersections. index.ts casts them back to the precise ./types shapes. +// ./types and is applied at the JS edge in ./index, ./registry and ./delivery. +// The dynamic-shaped payloads (the entry, queue rows, settled events, attempt +// events) are declared as UnsafeObject because codegen can't model index +// signatures, Partial<>, or unions. The JS layer casts them back to the +// precise ./types shapes. export interface Spec extends TurboModule { - // One-time notification configuration. Android persists it, so a headless - // WorkManager relaunch (no JS) can read it. It does nothing on iOS, because - // iOS has no library notification. + // Queue-wide settings: { lifetimeMs, retry, ...androidNotificationConfig }. + // Android persists the notification config, so a headless WorkManager + // relaunch (no JS) can read it. Each call replaces the full configuration. configure(options: CodegenTypes.UnsafeObject): void; - startUpload(options: CodegenTypes.UnsafeObject): Promise; - // Chunked uploads get their own entry point for two reasons. Codegen cannot - // model the raw/chunked discriminated union. And the native implementations - // share no parsing: startUpload dispatches one request, while - // startChunkedUpload creates or reconciles a durable part manifest. index.ts - // keeps the single public startUpload and routes on options.type. - startChunkedUpload(options: CodegenTypes.UnsafeObject): Promise; - cancelUpload(id: string): Promise; - // Releases the manifest and the bytes of a non-completed upload. A completed - // upload releases itself when you acknowledge its terminal event. - removeUpload(id: string): Promise; + // Persists { id, key, vars, descriptor } and schedules it. Resolves with the + // entry's id once the write has landed, never on the network. A same-id call + // follows the v9 resume rules: same body resumes, different parts on a + // settled entry recreate, different parts on a running entry reject. + enqueue(entry: CodegenTypes.UnsafeObject): Promise; + // Whole-queue pause. No outcome is produced; live rows move to 'paused'. + pause(): Promise; + resume(): Promise; + // A live entry settles 'cancelled' (user) and is forgotten after its ack. A + // settled entry is forgotten now, row and bytes. + cancel(id: string): Promise; + // Persisted natively. Applies to queued and future entries. + setWifiOnly(enabled: boolean): Promise; + // Merges the patch into every queued and parked entry's headers, then + // resumes the entries parked on 'awaiting-auth'. + updateHeaders(patch: CodegenTypes.UnsafeObject): Promise; + // Synchronous. Serialized from the in-memory index that native keeps + // current on every state change, never from disk. Live entries only. + getRequests(): CodegenTypes.UnsafeObject[]; + // Settled outcomes that JS has not acknowledged, in the onSettled shape, + // including ones journaled while JS was dead. Internal: ./delivery drains + // them after configure(). getUnacknowledgedEvents(): Promise; + // Removes journaled outcomes by eventId. An acknowledged 'completed' is the + // one moment native deletes the entry's row and bytes. ackEvents(ids: string[]): Promise; - getAllUploads(): Promise; - // Events. progress fires on both platforms with a fixed shape; the terminal - // events carry variant payloads (header maps, optional fields) so they're - // UnsafeObject. notification is Android-only (tapping the progress - // notification) and simply never fires on iOS. + // Events. state carries a full RequestRow per transition. progress is + // byte-weighted across a chunked upload's parts. attempt is one HTTP attempt + // before interpretation. settled is the journaled terminal outcome, emitted + // after the journal write; ./delivery routes it to the definition's + // handlers. notification is Android-only (tapping the progress notification) + // and never fires on iOS. + readonly onState: CodegenTypes.EventEmitter; readonly onProgress: CodegenTypes.EventEmitter<{ id: string; - progress: number; + bytesSent: number; + totalBytes: number; }>; - readonly onError: CodegenTypes.EventEmitter; - readonly onCancelled: CodegenTypes.EventEmitter; - readonly onCompleted: CodegenTypes.EventEmitter; + readonly onAttempt: CodegenTypes.EventEmitter; + readonly onSettled: CodegenTypes.EventEmitter; readonly onNotification: CodegenTypes.EventEmitter; } diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts new file mode 100644 index 00000000..d5e828f8 --- /dev/null +++ b/src/__tests__/client.test.ts @@ -0,0 +1,520 @@ +// Define all mocks inside the factory (no outer references) to avoid the +// import-hoisting TDZ trap, then grab handles from the mocked module below. +// The library reaches native through TurboModuleRegistry.getEnforcing, so that +// is what has to be stubbed. The codegen event emitters are plain functions +// that take a handler and return a subscription; the mock records the handlers +// so a test can fire an event. remove() drops that one subscription, as RN's +// EventEmitter does, so the same handler registered twice stays once. +jest.mock('react-native', () => { + const handlers: Record void>> = {}; + const emitter = (name: string) => + jest.fn((handler: (e: unknown) => void) => { + const entry = (e: unknown) => handler(e); + (handlers[name] ??= []).push(entry); + return { + remove: jest.fn(() => { + handlers[name] = handlers[name]!.filter((h) => h !== entry); + }), + }; + }); + const nativeModule = { + configure: jest.fn(), + enqueue: jest.fn(async (entry: { id: string }) => entry.id), + pause: jest.fn(async () => undefined), + resume: jest.fn(async () => undefined), + cancel: jest.fn(async () => undefined), + setWifiOnly: jest.fn(async () => undefined), + updateHeaders: jest.fn(async () => undefined), + getRequests: jest.fn(() => []), + getUnacknowledgedEvents: jest.fn(async () => []), + ackEvents: jest.fn(async () => true), + onState: emitter('state'), + onProgress: emitter('progress'), + onAttempt: emitter('attempt'), + onSettled: emitter('settled'), + onNotification: emitter('notification'), + __handlers: handlers, + }; + return { + Platform: { OS: 'ios' }, + TurboModuleRegistry: { + getEnforcing: jest.fn(() => nativeModule), + get: jest.fn(() => nativeModule), + }, + }; +}); + +import { TurboModuleRegistry } from 'react-native'; +import Upload, { chunkPlan, createUploadClient } from '../index'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Same object the module captured at import time. +const native = (TurboModuleRegistry as any).getEnforcing('RNFileUploader'); +const fire = (name: string, event: unknown) => + (native.__handlers[name] ?? []).forEach((h: (e: unknown) => void) => + h(event), + ); + +const flush = async (rounds = 5): Promise => { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +const rows = [ + { + id: 'a', + key: 'k1', + vars: null, + state: 'queued', + bytesSent: 0, + totalBytes: 0, + attempts: 0, + updatedAt: 1, + }, + { + id: 'b', + key: 'k2', + vars: null, + state: 'running', + bytesSent: 1, + totalBytes: 2, + attempts: 1, + updatedAt: 2, + }, + { + id: 'c', + key: 'k1', + vars: null, + state: 'error', + bytesSent: 0, + totalBytes: 0, + attempts: 3, + updatedAt: 3, + }, +]; + +beforeEach(() => { + jest.clearAllMocks(); + Object.keys(native.__handlers).forEach((k) => delete native.__handlers[k]); +}); + +describe('client shape', () => { + it('exposes the v10 surface and nothing from v9', () => { + const client = createUploadClient(); + expect(Object.keys(client).sort()).toEqual( + [ + 'addListener', + 'android', + 'cancel', + 'chunkPlan', + 'configure', + 'define', + 'getRequests', + 'pause', + 'resume', + 'setWifiOnly', + 'updateHeaders', + ].sort(), + ); + expect(Object.keys(client.android)).toEqual(['addNotificationListener']); + expect(client.chunkPlan).toBe(chunkPlan); + }); + + it('exports a default client with the same shape', () => { + expect(Object.keys(Upload).sort()).toEqual( + Object.keys(createUploadClient()).sort(), + ); + }); + + it("keeps each client's definitions separate", async () => { + const a = createUploadClient(); + const b = createUploadClient(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + a.define({ + key: 'shared', + request: (_v: null) => ({ url: 'https://x', data: 1 }), + }); + b.define({ + key: 'shared', + request: (_v: null) => ({ url: 'https://x', data: 1 }), + }); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('configure', () => { + it('forwards lifetimeMs, retry and the flattened android config', () => { + const client = createUploadClient(); + const retry = { terminalHttp: { exempt: [] } }; + client.configure({ + lifetimeMs: 1000, + retry, + headers: () => ({}), + android: { notificationTitle: 'Backing up', notificationChannel: 'ch' }, + }); + expect(native.configure).toHaveBeenCalledWith({ + lifetimeMs: 1000, + retry, + notificationTitle: 'Backing up', + notificationChannel: 'ch', + }); + }); + + it('sends the 14 day default lifetime and no retry when neither is given', () => { + createUploadClient().configure({}); + expect(native.configure).toHaveBeenCalledWith({ + lifetimeMs: 14 * 24 * 60 * 60 * 1000, + }); + expect(native.configure.mock.calls[0][0]).not.toHaveProperty('retry'); + }); + + it('rejects a non-positive lifetime', () => { + expect(() => createUploadClient().configure({ lifetimeMs: 0 })).toThrow( + /lifetimeMs/, + ); + expect(() => createUploadClient().configure({ lifetimeMs: NaN })).toThrow( + /lifetimeMs/, + ); + }); + + it('starts replay once; a second call updates settings without replaying', async () => { + const client = createUploadClient(); + client.configure({}); + client.configure({ lifetimeMs: 5 }); + await flush(); + expect(native.getUnacknowledgedEvents).toHaveBeenCalledTimes(1); + expect(native.onSettled).toHaveBeenCalledTimes(1); + expect(native.configure).toHaveBeenCalledTimes(2); + }); + + it('does not touch the journal before configure()', async () => { + createUploadClient(); + await flush(); + expect(native.getUnacknowledgedEvents).not.toHaveBeenCalled(); + expect(native.onSettled).not.toHaveBeenCalled(); + }); + + it('applies the headers provider and lifetime to later mutates', async () => { + const client = createUploadClient(); + const send = client.define({ + key: 'k', + request: (_v: null) => ({ + url: 'https://x', + data: 1, + headers: { B: '2' }, + }), + }); + client.configure({ lifetimeMs: 1000, headers: () => ({ A: '1' }) }); + const before = Date.now(); + await send.mutate(null); + const entry = native.enqueue.mock.calls.at(-1)![0]; + expect(entry.descriptor.headers).toEqual({ A: '1', B: '2' }); + expect(entry.descriptor.expiresAt).toBeGreaterThanOrEqual(before + 1000); + expect(entry.descriptor.expiresAt).toBeLessThan(before + 1000 + 5000); + }); +}); + +describe('queue control forwards', () => { + const client = createUploadClient(); + + it('pause', async () => { + await client.pause(); + expect(native.pause).toHaveBeenCalledTimes(1); + }); + + it('resume', async () => { + await client.resume(); + expect(native.resume).toHaveBeenCalledTimes(1); + }); + + it('cancel', async () => { + await client.cancel('u1'); + expect(native.cancel).toHaveBeenCalledWith('u1'); + }); + + it('setWifiOnly', async () => { + await client.setWifiOnly(true); + expect(native.setWifiOnly).toHaveBeenCalledWith(true); + }); + + it('updateHeaders', async () => { + await client.updateHeaders({ Authorization: 'Bearer t' }); + expect(native.updateHeaders).toHaveBeenCalledWith({ + Authorization: 'Bearer t', + }); + }); + + it('propagates a native rejection', async () => { + native.pause.mockRejectedValueOnce(new Error('E_NOT_IMPLEMENTED')); + await expect(client.pause()).rejects.toThrow('E_NOT_IMPLEMENTED'); + }); +}); + +describe('getRequests', () => { + const client = createUploadClient(); + + it('returns the native rows synchronously', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests()).toEqual(rows); + }); + + it('filters by key', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ key: 'k1' }).map((r) => r.id)).toEqual([ + 'a', + 'c', + ]); + }); + + it('filters by id', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ id: 'b' }).map((r) => r.id)).toEqual(['b']); + }); + + it('applies both filters together', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ key: 'k1', id: 'b' })).toEqual([]); + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({ key: 'k1', id: 'c' }).map((r) => r.id)).toEqual( + ['c'], + ); + }); + + it('treats an empty filter as no filter', () => { + native.getRequests.mockReturnValueOnce(rows); + expect(client.getRequests({})).toEqual(rows); + }); +}); + +describe('addListener', () => { + it('maps progress and attempt to their native emitters', () => { + const client = createUploadClient(); + const progress = jest.fn(); + const attempt = jest.fn(); + client.addListener('progress', progress); + client.addListener('attempt', attempt); + expect(native.onProgress).toHaveBeenCalledWith(progress); + expect(native.onAttempt).toHaveBeenCalledWith(attempt); + fire('progress', { id: 'u1', bytesSent: 1, totalBytes: 2 }); + fire('attempt', { id: 'u1', outcome: 'error' }); + expect(progress).toHaveBeenCalledWith({ + id: 'u1', + bytesSent: 1, + totalBytes: 2, + }); + expect(attempt).toHaveBeenCalledWith({ id: 'u1', outcome: 'error' }); + }); + + it('delivers native state rows and the JS unhandled-key rows to state listeners', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createUploadClient(); + const state = jest.fn(); + const subscription = client.addListener('state', state); + expect(native.onState).toHaveBeenCalledWith(state); + fire('state', rows[0]); + expect(state).toHaveBeenCalledWith(rows[0]); + + client.configure({}); + await flush(); + fire('settled', { + eventId: 'e1', + id: 'x', + key: 'nobody', + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + response: { bodyTruncated: false }, + state: 'completed', + }); + await flush(); + expect(state).toHaveBeenLastCalledWith( + expect.objectContaining({ + id: 'x', + key: 'nobody', + reason: 'unhandled-key', + }), + ); + expect(native.ackEvents).not.toHaveBeenCalled(); + + // remove() drops both sources. + subscription.remove(); + expect(native.__handlers.state).toEqual([]); + state.mockClear(); + fire('settled', { + eventId: 'e2', + id: 'y', + key: 'nobody', + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + state: 'completed', + }); + await flush(); + expect(state).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('gives each subscription of one state listener its own removal', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createUploadClient(); + const state = jest.fn(); + const first = client.addListener('state', state); + client.addListener('state', state); + client.configure({}); + await flush(); + const settled = (eventId: string) => ({ + eventId, + id: 'x', + key: 'nobody', + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + response: { bodyTruncated: false }, + state: 'completed', + }); + fire('state', rows[0]); + fire('settled', settled('e1')); + await flush(); + // Two subscriptions, two deliveries from each source. + expect(state.mock.calls.filter(([e]) => e === rows[0])).toHaveLength(2); + expect( + state.mock.calls.filter(([e]) => e.reason === 'unhandled-key'), + ).toHaveLength(2); + + first.remove(); + state.mockClear(); + fire('state', rows[0]); + fire('settled', settled('e2')); + await flush(); + // The surviving subscription still gets both sources, once each. + expect(state.mock.calls.filter(([e]) => e === rows[0])).toHaveLength(1); + expect( + state.mock.calls.filter(([e]) => e.reason === 'unhandled-key'), + ).toHaveLength(1); + warn.mockRestore(); + }); + + it('rejects an unknown event name', () => { + const client = createUploadClient(); + expect(() => (client.addListener as any)('completed', jest.fn())).toThrow( + /unknown event completed/, + ); + }); + + it('android.addNotificationListener subscribes to onNotification', () => { + const client = createUploadClient(); + const listener = jest.fn(); + client.android.addNotificationListener(listener); + expect(native.onNotification).toHaveBeenCalled(); + fire('notification', {}); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(); + }); +}); + +describe('end to end', () => { + it('mutate, then a settled outcome, runs the handler and acks', async () => { + const client = createUploadClient(); + const onSuccess = jest.fn(); + const create = client.define({ + key: 'item.create', + request: ({ n }: { n: number }) => ({ + url: `https://x/${n}`, + data: { n }, + }), + response: (raw) => (raw as { id: string }).id, + onSuccess, + }); + client.configure({ headers: () => ({ Authorization: 'Bearer t' }) }); + await flush(); + const { id } = await create.mutate({ n: 3 }, { id: 'local-3' }); + expect(id).toBe('local-3'); + expect(native.enqueue).toHaveBeenCalledWith({ + id: 'local-3', + key: 'item.create', + vars: { n: 3 }, + descriptor: { + url: 'https://x/3', + data: { n: 3 }, + headers: { Authorization: 'Bearer t' }, + expiresAt: expect.any(Number), + }, + }); + fire('settled', { + eventId: 'e1', + id: 'local-3', + key: 'item.create', + vars: { n: 3 }, + at: 10, + attempts: 1, + requestId: 'r1', + kind: 'completed', + response: { status: 201, body: '{"id":"srv-9"}', bodyTruncated: false }, + state: 'completed', + }); + await flush(); + expect(onSuccess).toHaveBeenCalledWith( + 'srv-9', + { n: 3 }, + { + id: 'local-3', + key: 'item.create', + at: 10, + attempts: 1, + requestId: 'r1', + }, + ); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('runs the handler only after the caller of mutate() has the id', async () => { + const client = createUploadClient(); + const order: string[] = []; + const create = client.define({ + key: 'item.create', + request: ({ n }: { n: number }) => ({ url: `https://x/${n}`, data: { n } }), + onError: (_error, _vars, meta) => { + order.push(`handler ${meta.id}`); + }, + }); + client.configure({}); + await flush(); + let resolveEnqueue!: (id: string) => void; + native.enqueue.mockImplementationOnce( + (entry: { id: string }) => + new Promise((resolve) => { + resolveEnqueue = () => resolve(entry.id); + }), + ); + const pending = create.mutate({ n: 1 }).then(({ id }) => { + order.push(`caller ${id}`); + return id; + }); + await flush(); + const { id } = native.enqueue.mock.calls.at(-1)![0]; + // Native settles the entry before the enqueue promise resolves. + fire('settled', { + eventId: 'e1', + id, + key: 'item.create', + vars: { n: 1 }, + at: 10, + attempts: 1, + kind: 'error', + error: { errorKind: 'file', message: 'gone' }, + state: 'error', + }); + resolveEnqueue(id); + await pending; + // Delivery yields a macrotask after the enqueue promise; wait it out. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(order).toEqual([`caller ${id}`, `handler ${id}`]); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); diff --git a/src/__tests__/delivery.test.ts b/src/__tests__/delivery.test.ts new file mode 100644 index 00000000..c880b16c --- /dev/null +++ b/src/__tests__/delivery.test.ts @@ -0,0 +1,627 @@ +import { createDelivery, type SettledEvent } from '../delivery'; +import type { AnyDefinition } from '../registry'; +import type { StateEvent } from '../types'; + +const flush = async (rounds = 5): Promise => { + for (let i = 0; i < rounds; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +// Delivery yields one macrotask after a tracked mutate() settles, and Node +// does not run a setTimeout(0) inside a few setImmediate rounds. The wait is +// generous so a millisecond boundary between the two timers cannot reorder them. +const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 20)); + +const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const completed = (over: Partial = {}): SettledEvent => ({ + eventId: 'e1', + id: 'u1', + key: 'k', + vars: { n: 1 }, + at: 1000, + attempts: 2, + requestId: 'req-9', + kind: 'completed', + response: { status: 200, body: '{"ok":true}', bodyTruncated: false }, + state: 'completed', + ...over, +}); + +const setup = ( + definitions: Record = {}, + journal: SettledEvent[] = [], + handlerWarningMs?: number, +) => { + const handlers: Array<(e: unknown) => void> = []; + const native = { + getUnacknowledgedEvents: jest.fn(async () => journal), + ackEvents: jest.fn(async () => true), + onSettled: jest.fn((handler: (e: unknown) => void) => { + handlers.push(handler); + return { remove: jest.fn() } as never; + }), + }; + const stateEvents: StateEvent[] = []; + const warn = jest.fn(); + const delivery = createDelivery({ + native, + lookup: (key) => definitions[key], + emitState: (e) => stateEvents.push(e), + warn, + handlerWarningMs, + }); + const emit = (event: SettledEvent) => handlers.forEach((h) => h(event)); + return { ...delivery, native, emit, stateEvents, warn, handlers }; +}; + +describe('replay', () => { + it('does nothing before start()', async () => { + const onSuccess = jest.fn(); + const { native } = setup( + { k: { key: 'k', request: jest.fn(), onSuccess } }, + [completed()], + ); + await flush(); + expect(native.getUnacknowledgedEvents).not.toHaveBeenCalled(); + expect(native.onSettled).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it('drains the journal after start() and acks each delivered event', async () => { + const onSuccess = jest.fn(); + const { start, native } = setup( + { k: { key: 'k', request: jest.fn(), onSuccess } }, + [completed({ eventId: 'a' }), completed({ eventId: 'b', id: 'u2' })], + ); + start(); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(2); + expect(native.ackEvents).toHaveBeenCalledWith(['a']); + expect(native.ackEvents).toHaveBeenCalledWith(['b']); + }); + + it('subscribes to onSettled once and does not replay on a second start()', async () => { + const { start, native } = setup({}, []); + start(); + start(); + await flush(); + expect(native.onSettled).toHaveBeenCalledTimes(1); + expect(native.getUnacknowledgedEvents).toHaveBeenCalledTimes(1); + }); + + it('buffers live events that arrive during the drain until the journal has delivered', async () => { + const order: string[] = []; + const journalGate = deferred(); + const { start, native, emit } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: (_d: unknown, _v: unknown, meta: { id: string }) => { + order.push(meta.id); + }, + }, + }); + native.getUnacknowledgedEvents.mockReturnValueOnce(journalGate.promise); + start(); + emit(completed({ eventId: 'live', id: 'live-id' })); + await flush(); + expect(order).toEqual([]); + journalGate.resolve([completed({ eventId: 'old', id: 'old-id' })]); + await flush(); + expect(order).toEqual(['old-id', 'live-id']); + }); + + it('delivers a live event straight away once live', async () => { + const onSuccess = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + emit(completed({ eventId: 'x' })); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['x']); + }); + + it('still goes live when the drain fails, and warns', async () => { + const onSuccess = jest.fn(); + const { start, emit, native, warn } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + native.getUnacknowledgedEvents.mockRejectedValueOnce(new Error('disk')); + start(); + await flush(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/getUnacknowledgedEvents failed/), + expect.any(Error), + ); + emit(completed()); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); +}); + +describe('dedupe', () => { + it('delivers one eventId once, even when journaled and emitted live', async () => { + const onSuccess = jest.fn(); + const { start, emit, native } = setup( + { k: { key: 'k', request: jest.fn(), onSuccess } }, + [completed({ eventId: 'same' })], + ); + start(); + emit(completed({ eventId: 'same' })); + await flush(); + emit(completed({ eventId: 'same' })); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledTimes(1); + }); + + it('drops and warns on an event without an eventId', async () => { + const onSuccess = jest.fn(); + const { start, emit, warn } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + emit({ ...completed(), eventId: undefined as unknown as string }); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/without an eventId/), + expect.anything(), + ); + }); +}); + +describe('ordering against mutate()', () => { + it('waits for the in-flight mutate() of the same id before delivering', async () => { + const onSuccess = jest.fn(); + const { start, emit, trackMutate } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const enqueue = deferred(); + trackMutate('u1', enqueue.promise); + emit(completed()); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + enqueue.resolve('u1'); + await tick(); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('runs the handler after every continuation on the mutate() promise, not just after enqueue', async () => { + // The registry registers trackMutate before mutate() awaits the same + // promise, and the caller awaits mutate() after that. Both reactions are + // microtasks that run before the handler. + const order: string[] = []; + const { start, emit, trackMutate } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: () => { + order.push('handler'); + }, + }, + }); + start(); + await flush(); + const enqueue = deferred(); + trackMutate('u1', enqueue.promise); + const mutateResult = enqueue.promise.then((id) => ({ id })); + void mutateResult.then(({ id }) => order.push(`caller got ${id}`)); + emit(completed()); + enqueue.resolve('u1'); + await mutateResult; + // No flush between the resolve and this read: the caller's continuation + // has to run first on its own. + await tick(); + expect(order).toEqual(['caller got u1', 'handler']); + }); + + it('delivers once a tracked mutate() rejects', async () => { + const onSuccess = jest.fn(); + const { start, emit, trackMutate } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const enqueue = deferred(); + trackMutate('u1', enqueue.promise); + emit(completed()); + enqueue.reject(new Error('nope')); + await tick(); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not wait on a mutate() for a different id', async () => { + const onSuccess = jest.fn(); + const { start, emit, trackMutate } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + trackMutate('other', deferred().promise); + emit(completed()); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); +}); + +describe('unknown key', () => { + it('emits an unhandled-key state row and does not ack', async () => { + const { start, emit, native, stateEvents, warn } = setup({}); + start(); + await flush(); + emit( + completed({ + key: 'gone', + state: 'completed', + bytesSent: 10, + totalBytes: 10, + }), + ); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + expect(stateEvents).toEqual([ + { + id: 'u1', + key: 'gone', + vars: { n: 1 }, + state: 'completed', + bytesSent: 10, + totalBytes: 10, + attempts: 2, + updatedAt: 1000, + reason: 'unhandled-key', + }, + ]); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/no definition for key "gone"/), + ); + }); + + it('defaults the byte counters to 0 when the event has none', async () => { + const { start, emit, stateEvents } = setup({}); + start(); + await flush(); + emit(completed({ key: 'gone' })); + await flush(); + expect(stateEvents[0]).toMatchObject({ bytesSent: 0, totalBytes: 0 }); + }); +}); + +describe('completed', () => { + it('passes the RawResponse to onSuccess when there is no parser, with vars and meta', async () => { + const onSuccess = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const event = completed(); + emit(event); + await flush(); + expect(onSuccess).toHaveBeenCalledWith( + event.response, + { n: 1 }, + { + id: 'u1', + key: 'k', + at: 1000, + attempts: 2, + requestId: 'req-9', + }, + ); + }); + + it('parses the JSON body, runs the parser, and passes its result to onSuccess', async () => { + const onSuccess = jest.fn(); + const response = jest.fn((raw: unknown) => (raw as { ok: boolean }).ok); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), response, onSuccess }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(response).toHaveBeenCalledWith({ ok: true }); + expect(onSuccess).toHaveBeenCalledWith(true, { n: 1 }, expect.anything()); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('gives the parser undefined when the body is absent or empty', async () => { + const response = jest.fn(() => 'parsed'); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), response }, + }); + start(); + await flush(); + emit(completed({ eventId: 'a', response: { bodyTruncated: false } })); + emit( + completed({ eventId: 'b', response: { body: '', bodyTruncated: false } }), + ); + await flush(); + expect(response).toHaveBeenCalledTimes(2); + expect(response).toHaveBeenNthCalledWith(1, undefined); + expect(response).toHaveBeenNthCalledWith(2, undefined); + }); + + it('calls onError with errorKind truncated when a parser is set and the body was cut', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const response = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), response, onSuccess, onError }, + }); + start(); + await flush(); + const raw = { status: 200, body: '{"partial', bodyTruncated: true }; + emit(completed({ response: raw })); + await flush(); + expect(response).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + { + errorKind: 'truncated', + message: expect.stringMatching(/1 MB/), + response: raw, + }, + { n: 1 }, + expect.anything(), + ); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('passes a truncated body to onSuccess untouched when there is no parser', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess, onError }, + }); + start(); + await flush(); + const raw = { status: 200, body: 'x', bodyTruncated: true }; + emit(completed({ response: raw })); + await flush(); + expect(onSuccess).toHaveBeenCalledWith(raw, { n: 1 }, expect.anything()); + expect(onError).not.toHaveBeenCalled(); + }); + + it('routes a parser throw to onError as unknown and still acks', async () => { + const onSuccess = jest.fn(); + const onError = jest.fn(); + const { start, emit, native } = setup({ + k: { + key: 'k', + request: jest.fn(), + response: () => { + throw new Error('bad shape'); + }, + onSuccess, + onError, + }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + { errorKind: 'unknown', message: 'bad shape' }, + { n: 1 }, + expect.anything(), + ); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('treats a body that is not JSON as a parser failure', async () => { + const onError = jest.fn(); + const { start, emit } = setup({ + k: { key: 'k', request: jest.fn(), response: (x) => x, onError }, + }); + start(); + await flush(); + emit(completed({ response: { body: 'not json', bodyTruncated: false } })); + await flush(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ errorKind: 'unknown' }), + { n: 1 }, + expect.anything(), + ); + }); + + it('acks a completed outcome whose definition has no onSuccess', async () => { + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn() }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); + +describe('error', () => { + it('calls onError with the outcome error, vars and meta, then acks', async () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onError, onSuccess }, + }); + start(); + await flush(); + const error = { + errorKind: 'http' as const, + message: '422', + response: { status: 422, body: '{}', bodyTruncated: false }, + }; + emit( + completed({ kind: 'error', state: 'error', response: undefined, error }), + ); + await flush(); + expect(onError).toHaveBeenCalledWith( + error, + { n: 1 }, + expect.objectContaining({ id: 'u1' }), + ); + expect(onSuccess).not.toHaveBeenCalled(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); + +describe('cancelled', () => { + it('calls no handler and acks', async () => { + const onError = jest.fn(); + const onSuccess = jest.fn(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onError, onSuccess }, + }); + start(); + await flush(); + emit( + completed({ + kind: 'cancelled', + state: 'cancelled', + response: undefined, + cancelReason: 'user', + }), + ); + await flush(); + expect(onError).not.toHaveBeenCalled(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); +}); + +describe('ack after the handler', () => { + it('acks only after the handler promise resolves', async () => { + const gate = deferred(); + const { start, emit, native } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess: () => gate.promise }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + gate.resolve(); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('does not ack when the handler rejects, and warns', async () => { + const { start, emit, native, warn } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: async () => { + throw new Error('handler down'); + }, + }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/handler for u1 rejected/), + expect.any(Error), + ); + }); + + it('does not ack when the handler throws synchronously', async () => { + const { start, emit, native } = setup({ + k: { + key: 'k', + request: jest.fn(), + onSuccess: () => { + throw new Error('sync'); + }, + }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + expect(native.ackEvents).not.toHaveBeenCalled(); + }); + + it('warns when ackEvents itself fails', async () => { + const { start, emit, native, warn } = setup({ + k: { key: 'k', request: jest.fn() }, + }); + native.ackEvents.mockRejectedValueOnce(new Error('journal locked')); + start(); + await flush(); + emit(completed()); + await flush(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/ackEvents failed for e1/), + expect.any(Error), + ); + }); +}); + +describe('slow handler warning', () => { + beforeEach(() => { + jest.useFakeTimers({ doNotFake: ['setImmediate', 'nextTick'] }); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('warns once at 30 s when the handler has not settled', async () => { + const gate = deferred(); + const { start, emit, warn, native } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess: () => gate.promise }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + await jest.advanceTimersByTimeAsync(29_999); + expect(warn).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/"k" handler for u1 has not settled after 30 s/), + ); + await jest.advanceTimersByTimeAsync(60_000); + expect(warn).toHaveBeenCalledTimes(1); + expect(native.ackEvents).not.toHaveBeenCalled(); + gate.resolve(); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + }); + + it('does not warn when the handler settles in time', async () => { + const { start, emit, warn } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess: async () => undefined }, + }); + start(); + await flush(); + emit(completed()); + await flush(); + await jest.advanceTimersByTimeAsync(60_000); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts deleted file mode 100644 index 220fe533..00000000 --- a/src/__tests__/index.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -// Define all mocks inside the factory (no outer references) to avoid the -// import-hoisting TDZ trap, then grab handles from the mocked module below. -// The library reaches native through TurboModuleRegistry.getEnforcing, so that -// is what has to be stubbed — the codegen event emitters are plain functions -// that take a handler and return a subscription. -jest.mock('react-native', () => { - const subscription = { remove: jest.fn() }; - const nativeModule = { - configure: jest.fn(), - startUpload: jest.fn(async () => 'id-1'), - startChunkedUpload: jest.fn(async () => 'id-2'), - cancelUpload: jest.fn(async () => true), - removeUpload: jest.fn(async () => undefined), - getUnacknowledgedEvents: jest.fn(async () => [ - { - eventId: 'e1', - id: 'u1', - type: 'completed', - timestamp: 1, - responseCode: 200, - }, - ]), - ackEvents: jest.fn(async () => true), - getAllUploads: jest.fn(async () => [{ id: 'u1', state: 'running' }]), - onProgress: jest.fn(() => subscription), - onError: jest.fn(() => subscription), - onCancelled: jest.fn(() => subscription), - onCompleted: jest.fn(() => subscription), - onNotification: jest.fn(() => subscription), - }; - return { - Platform: { OS: 'ios' }, - TurboModuleRegistry: { - getEnforcing: jest.fn(() => nativeModule), - get: jest.fn(() => nativeModule), - }, - }; -}); - -import { TurboModuleRegistry } from 'react-native'; -import Upload from '../index'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ -// Same object the module captured at import time. -const native = (TurboModuleRegistry as any).getEnforcing('RNFileUploader'); - -describe('journal + query API', () => { - it('getUnacknowledgedEvents returns the native events', async () => { - const events = await Upload.getUnacknowledgedEvents(); - expect(events[0].eventId).toBe('e1'); - expect(events[0].type).toBe('completed'); - }); - - it('ackEvents forwards the ids to native', async () => { - await Upload.ackEvents(['e1', 'e2']); - expect(native.ackEvents).toHaveBeenCalledWith(['e1', 'e2']); - }); - - it('getAllUploads returns the native snapshots', async () => { - const uploads = await Upload.getAllUploads(); - expect(uploads[0]).toEqual({ id: 'u1', state: 'running' }); - }); -}); - -describe('configure', () => { - it('forwards the android notification config to native, flattened', () => { - Upload.configure({ - android: { notificationTitle: 'Backing up…', notificationChannel: 'ch' }, - }); - expect(native.configure).toHaveBeenCalledWith({ - notificationTitle: 'Backing up…', - notificationChannel: 'ch', - }); - }); -}); - -describe('startUpload', () => { - it('prefixes the file path on iOS and forwards options', async () => { - await Upload.startUpload({ - url: 'https://example.com/up', - path: '/tmp/f.bin', - method: 'POST', - type: 'raw', - accept: [{ status: 409, bodyIncludes: 'already completed' }], - }); - expect(native.startUpload).toHaveBeenCalledWith( - expect.objectContaining({ - url: 'https://example.com/up', - path: 'file:///tmp/f.bin', - accept: [{ status: 409, bodyIncludes: 'already completed' }], - }), - ); - }); - - it('forwards android.noNotification but no notification text', async () => { - await Upload.startUpload({ - url: 'https://example.com/up', - path: '/tmp/f.bin', - method: 'POST', - type: 'raw', - android: { noNotification: true }, - }); - const options = native.startUpload.mock.calls.at(-1)![0]; - expect(options.noNotification).toBe(true); - // configure() owns the notification text. startUpload never carries it. - expect(options).not.toHaveProperty('notificationTitle'); - expect(options).not.toHaveProperty('notificationId'); - }); -}); - -describe('startUpload (chunked)', () => { - const chunked = { - type: 'chunked' as const, - id: 'u1', - path: '/tmp/f.bin', - parts: [ - { - url: 'https://example.com/up?partNum=1', - headers: { 'Content-Range': 'bytes 0-9/20' }, - range: { start: 0, end: 10 }, - }, - { - url: 'https://example.com/up?partNum=2', - headers: { 'Content-Range': 'bytes 10-19/20' }, - range: { start: 10, end: 20 }, - }, - ], - expiresAt: 1735689600000, - }; - - it('routes to startChunkedUpload, not startUpload', async () => { - native.startUpload.mockClear(); - await Upload.startUpload(chunked); - expect(native.startUpload).not.toHaveBeenCalled(); - expect(native.startChunkedUpload).toHaveBeenCalledWith( - expect.objectContaining({ - id: 'u1', - path: 'file:///tmp/f.bin', - parts: chunked.parts, - expiresAt: chunked.expiresAt, - }), - ); - }); - - it('rejects an empty id', () => { - expect(() => Upload.startUpload({ ...chunked, id: '' })).toThrow( - /non-empty id/, - ); - }); - - it('rejects empty parts', () => { - expect(() => Upload.startUpload({ ...chunked, parts: [] })).toThrow( - /non-empty/, - ); - }); - - it.each([ - { start: -1, end: 10 }, - { start: 10, end: 10 }, - { start: 11, end: 10 }, - { start: 0.5, end: 10 }, - { start: 0, end: NaN }, - ])('rejects range %p', (range) => { - const parts = [{ ...chunked.parts[0], range }]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /0 <= start < end/, - ); - }); - - it.each([NaN, Infinity, 0, -5])('rejects expiresAt %p', (expiresAt) => { - expect(() => Upload.startUpload({ ...chunked, expiresAt })).toThrow( - /expiresAt/, - ); - }); - - it('rejects a nonzero first start', () => { - const parts = [ - { ...chunked.parts[0], range: { start: 5, end: 10 } }, - { ...chunked.parts[1], range: { start: 10, end: 20 } }, - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.range\.start must be 0/, - ); - }); - - it('rejects a gap between parts', () => { - const parts = [ - { ...chunked.parts[0], range: { start: 0, end: 8 } }, - { ...chunked.parts[1], range: { start: 10, end: 20 } }, - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /no gaps or overlaps/, - ); - }); - - it('rejects overlapping parts', () => { - const parts = [ - { ...chunked.parts[0], range: { start: 0, end: 12 } }, - { ...chunked.parts[1], range: { start: 10, end: 20 } }, - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /no gaps or overlaps/, - ); - }); - - it('rejects out-of-order parts', () => { - const parts = [chunked.parts[1], chunked.parts[0]]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.range\.start must be 0/, - ); - }); - - it('rejects an empty part url', () => { - const parts = [{ ...chunked.parts[0], url: '' }, chunked.parts[1]]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.url must be a non-empty string/, - ); - }); - - it('rejects non-object part headers', () => { - const parts = [ - { ...chunked.parts[0], headers: 'nope' as never }, - chunked.parts[1], - ]; - expect(() => Upload.startUpload({ ...chunked, parts })).toThrow( - /parts\[0\]\.headers must be a plain object/, - ); - }); - - it('throws before reaching native', () => { - native.startChunkedUpload.mockClear(); - expect(() => Upload.startUpload({ ...chunked, parts: [] })).toThrow(); - expect(native.startChunkedUpload).not.toHaveBeenCalled(); - }); -}); - -describe('removeUpload', () => { - it('forwards the id to native', async () => { - await Upload.removeUpload('u1'); - expect(native.removeUpload).toHaveBeenCalledWith('u1'); - }); -}); - -describe('addListener', () => { - it('subscribes to the matching codegen emitter', () => { - Upload.addListener('progress', jest.fn()); - expect(native.onProgress).toHaveBeenCalled(); - }); - - it('delivers events for every upload', () => { - const cb = jest.fn(); - Upload.addListener('completed', cb); - const handler = native.onCompleted.mock.calls.at(-1)![0] as ( - data: unknown, - ) => void; - handler({ id: 'u1', responseCode: 200 }); - handler({ id: 'someone-else', responseCode: 200 }); - expect(cb).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts new file mode 100644 index 00000000..594283f7 --- /dev/null +++ b/src/__tests__/registry.test.ts @@ -0,0 +1,488 @@ +import { + createRegistry, + DEFAULT_LIFETIME_MS, + MAX_VARS_BYTES, + utf8ByteLength, + uuidV4, + type AnyDefinition, + type EnqueueEntry, + type Settings, +} from '../registry'; + +const NOW = 1_700_000_000_000; + +const setup = (settings: Partial = {}) => { + const enqueue = jest.fn(async (entry: EnqueueEntry) => entry.id); + const definitions = new Map(); + const trackMutate = jest.fn(); + const warn = jest.fn(); + const current: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS, ...settings }; + const registry = createRegistry({ + native: { enqueue }, + definitions, + getSettings: () => current, + trackMutate, + warn, + now: () => NOW, + }); + const lastEntry = (): EnqueueEntry => enqueue.mock.calls.at(-1)![0]; + return { ...registry, enqueue, definitions, trackMutate, warn, lastEntry }; +}; + +const jsonPost = (vars: { n: number }) => ({ + url: `https://example.com/items/${vars.n}`, + data: { n: vars.n }, +}); + +describe('define', () => { + it('registers the definition under its key and returns { key, mutate }', () => { + const { define, definitions } = setup(); + const defined = define({ key: 'item.create', request: jsonPost }); + expect(defined.key).toBe('item.create'); + expect(typeof defined.mutate).toBe('function'); + expect(definitions.get('item.create')?.key).toBe('item.create'); + }); + + it('replaces a duplicate key and warns', () => { + const { define, definitions, warn } = setup(); + const first = { key: 'dup', request: jsonPost }; + const second = { key: 'dup', request: jsonPost, onSuccess: jest.fn() }; + define(first); + expect(warn).not.toHaveBeenCalled(); + define(second); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/"dup" is already defined/); + expect(definitions.get('dup')).toBe(second); + }); + + it('runs the replacement request() from a mutate on the earlier handle', async () => { + const { define, lastEntry } = setup(); + const old = define({ key: 'k', request: jsonPost }); + define({ + key: 'k', + request: (vars: { n: number }) => ({ url: 'https://new', data: vars }), + }); + await old.mutate({ n: 1 }); + expect(lastEntry().descriptor.url).toBe('https://new'); + }); + + it('rejects an empty key or a missing request', () => { + const { define } = setup(); + expect(() => define({ key: '', request: jsonPost })).toThrow(/key/); + expect(() => + define({ key: 'x', request: undefined as unknown as typeof jsonPost }), + ).toThrow(/request/); + }); +}); + +describe('mutate', () => { + it('runs request(vars) exactly once and enqueues { id, key, vars, descriptor }', async () => { + const { define, enqueue, lastEntry } = setup(); + const request = jest.fn(jsonPost); + const create = define({ key: 'item.create', request }); + const result = await create.mutate({ n: 7 }, { id: 'local-7' }); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith({ n: 7 }); + expect(enqueue).toHaveBeenCalledTimes(1); + expect(lastEntry()).toMatchObject({ + id: 'local-7', + key: 'item.create', + vars: { n: 7 }, + descriptor: { url: 'https://example.com/items/7', data: { n: 7 } }, + }); + expect(result).toEqual({ id: 'local-7' }); + }); + + it('stores null vars for a mutate() with no arguments', async () => { + const request = jest.fn(() => ({ url: 'https://x', data: null })); + const { define, lastEntry } = setup(); + const ping = define({ key: 'ping', request }); + await ping.mutate(); + expect(request).toHaveBeenCalledWith(null); + expect(lastEntry().vars).toBeNull(); + await ping.mutate(undefined, { id: 'fixed' }); + expect(lastEntry()).toMatchObject({ id: 'fixed', vars: null }); + }); + + it('resolves with the id native returns', async () => { + const { define, enqueue } = setup(); + enqueue.mockResolvedValueOnce('native-id'); + const create = define({ key: 'k', request: jsonPost }); + await expect(create.mutate({ n: 1 }, { id: 'mine' })).resolves.toEqual({ + id: 'native-id', + }); + }); + + it('generates a UUID v4 when no id is given', async () => { + const { define, lastEntry } = setup(); + const create = define({ key: 'k', request: jsonPost }); + const { id } = await create.mutate({ n: 1 }); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(lastEntry().id).toBe(id); + }); + + it('hands the enqueue promise to trackMutate under the entry id', async () => { + const { define, trackMutate } = setup(); + const create = define({ key: 'k', request: jsonPost }); + await create.mutate({ n: 1 }, { id: 'abc' }); + expect(trackMutate).toHaveBeenCalledWith('abc', expect.any(Promise)); + }); + + it('rejects when native enqueue rejects', async () => { + const { define, enqueue } = setup(); + enqueue.mockRejectedValueOnce(new Error('E_NOT_IMPLEMENTED')); + const create = define({ key: 'k', request: jsonPost }); + await expect(create.mutate({ n: 1 })).rejects.toThrow('E_NOT_IMPLEMENTED'); + }); + + it('rejects when request() throws, without reaching native', async () => { + const { define, enqueue } = setup(); + const boom = define({ + key: 'k', + request: (_vars: null) => { + throw new Error('no url yet'); + }, + }); + await expect(boom.mutate(null)).rejects.toThrow('no url yet'); + expect(enqueue).not.toHaveBeenCalled(); + }); + + describe('vars cap', () => { + it('accepts vars at exactly the cap and rejects one byte over', async () => { + const { define, enqueue } = setup(); + const send = define({ + key: 'k', + request: (_vars: { s: string }) => ({ url: 'https://x', data: null }), + }); + // JSON.stringify({ s }) adds {"s":""} = 8 bytes around the payload. + const fits = 'a'.repeat(MAX_VARS_BYTES - 8); + await expect(send.mutate({ s: fits })).resolves.toBeDefined(); + await expect(send.mutate({ s: fits + 'a' })).rejects.toThrow( + /vars for "k" is 4097 bytes; the limit is 4096/, + ); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('counts UTF-8 bytes, not UTF-16 code units', async () => { + const { define } = setup(); + const send = define({ + key: 'k', + request: (_vars: { s: string }) => ({ url: 'https://x', data: null }), + }); + // 1400 three-byte characters = 4200 bytes but only 1400 code units. + await expect(send.mutate({ s: '€'.repeat(1400) })).rejects.toThrow( + /4208 bytes/, + ); + expect(utf8ByteLength('a')).toBe(1); + expect(utf8ByteLength('é')).toBe(2); + expect(utf8ByteLength('€')).toBe(3); + expect(utf8ByteLength('\u{1F600}')).toBe(4); + }); + }); + + describe('descriptor validation', () => { + const mutateWith = (descriptor: unknown) => { + const { define, enqueue } = setup(); + const d = define({ + key: 'k', + request: (_vars: null) => descriptor as ReturnType, + }); + return { promise: d.mutate(null), enqueue }; + }; + + it('requires exactly one body kind', async () => { + await expect(mutateWith({ url: 'https://x' }).promise).rejects.toThrow( + /exactly one of data, form, file; got none/, + ); + await expect( + mutateWith({ url: 'https://x', data: {}, file: '/f' }).promise, + ).rejects.toThrow(/exactly one of data, form, file; got data, file/); + }); + + it('accepts each body kind alone', async () => { + await expect( + mutateWith({ url: 'https://x', data: null }).promise, + ).resolves.toBeDefined(); + await expect( + mutateWith({ url: 'https://x', file: '/f' }).promise, + ).resolves.toBeDefined(); + await expect( + mutateWith({ + url: 'https://x', + form: [ + { name: 'meta', contentType: 'application/json', string: '{}' }, + { name: 'photo', contentType: 'image/jpeg', path: '/p.jpg' }, + ], + }).promise, + ).resolves.toBeDefined(); + }); + + it('rejects a form part without exactly one of string, path', async () => { + await expect( + mutateWith({ + url: 'https://x', + form: [{ name: 'a', contentType: 'text/plain' }], + }).promise, + ).rejects.toThrow(/form\[0\] must set exactly one of string, path/); + }); + + it('allows parts only with file', async () => { + await expect( + mutateWith({ + data: {}, + parts: [{ url: 'https://p', range: { start: 0, end: 1 } }], + }).promise, + ).rejects.toThrow(/parts requires file/); + }); + + it('requires url unless parts is set', async () => { + await expect(mutateWith({ data: {} }).promise).rejects.toThrow( + /url is required unless parts is set/, + ); + await expect(mutateWith({ url: '', data: {} }).promise).rejects.toThrow( + /url must be a non-empty string/, + ); + await expect( + mutateWith({ + file: '/f', + parts: [{ url: 'https://p', range: { start: 0, end: 1 } }], + }).promise, + ).resolves.toBeDefined(); + }); + + it('rejects a method outside the union', async () => { + await expect( + mutateWith({ url: 'https://x', data: {}, method: 'FETCH' }).promise, + ).rejects.toThrow(/method must be one of/); + }); + + it.each([NaN, Infinity, 0, -5])( + 'rejects expiresAt %p', + async (expiresAt) => { + await expect( + mutateWith({ url: 'https://x', data: {}, expiresAt }).promise, + ).rejects.toThrow(/expiresAt/); + }, + ); + + it('rejects a non-object descriptor', async () => { + await expect(mutateWith(undefined).promise).rejects.toThrow( + /request\(\) must return a descriptor object/, + ); + }); + + it('rejects an unknown descriptor field and suggests the nearest known one', async () => { + await expect( + mutateWith({ url: 'https://x', data: {}, header: { A: 'b' } }).promise, + ).rejects.toThrow( + 'mutate: unknown descriptor field "header". Did you mean "headers"?', + ); + await expect( + mutateWith({ url: 'https://x', data: {}, expiryAt: 5 }).promise, + ).rejects.toThrow(/unknown descriptor field "expiryAt".*"expiresAt"/); + await expect( + mutateWith({ url: 'https://x', data: {}, timeout: 5 }).promise, + ).rejects.toThrow(/^mutate: unknown descriptor field "timeout".$/); + }); + + it('rejects an unknown field on a part or a form part', async () => { + await expect( + mutateWith({ + file: '/f', + parts: [ + { url: 'https://p', header: {}, range: { start: 0, end: 1 } }, + ], + }).promise, + ).rejects.toThrow(/unknown parts\[0\] field "header".*"headers"/); + await expect( + mutateWith({ + url: 'https://x', + form: [ + { + name: 'photo', + contentType: 'image/jpeg', + path: '/p.jpg', + filename: 'a.jpg', + }, + ], + }).promise, + ).rejects.toThrow(/unknown form\[0\] field "filename".*"fileName"/); + }); + + it('never reaches native on a rejected descriptor', async () => { + const { promise, enqueue } = mutateWith({ data: {} }); + await expect(promise).rejects.toThrow(); + expect(enqueue).not.toHaveBeenCalled(); + }); + }); + + describe('parts tiling (v9 rules)', () => { + const parts = [ + { url: 'https://p/1', range: { start: 0, end: 10 } }, + { url: 'https://p/2', range: { start: 10, end: 20 } }, + ]; + const chunked = (override: unknown[]) => { + const { define } = setup(); + return define({ + key: 'k', + request: (_vars: null) => ({ + file: '/f', + parts: override as typeof parts, + }), + }).mutate(null); + }; + + it('accepts a tiled plan', async () => { + await expect(chunked(parts)).resolves.toBeDefined(); + }); + + it('rejects empty parts', async () => { + await expect(chunked([])).rejects.toThrow(/non-empty array/); + }); + + it.each([ + { start: -1, end: 10 }, + { start: 10, end: 10 }, + { start: 11, end: 10 }, + { start: 0.5, end: 10 }, + { start: 0, end: NaN }, + ])('rejects range %p', async (range) => { + await expect(chunked([{ ...parts[0], range }])).rejects.toThrow( + /0 <= start < end/, + ); + }); + + it('rejects a nonzero first start', async () => { + await expect( + chunked([{ ...parts[0], range: { start: 5, end: 10 } }, parts[1]]), + ).rejects.toThrow(/parts\[0\]\.range\.start must be 0/); + }); + + it('rejects a gap', async () => { + await expect( + chunked([{ ...parts[0], range: { start: 0, end: 8 } }, parts[1]]), + ).rejects.toThrow(/no gaps or overlaps/); + }); + + it('rejects an overlap', async () => { + await expect( + chunked([{ ...parts[0], range: { start: 0, end: 12 } }, parts[1]]), + ).rejects.toThrow(/no gaps or overlaps/); + }); + + it('rejects out-of-order parts', async () => { + await expect(chunked([parts[1], parts[0]])).rejects.toThrow( + /parts\[0\]\.range\.start must be 0/, + ); + }); + + it('rejects an empty part url', async () => { + await expect( + chunked([{ ...parts[0], url: '' }, parts[1]]), + ).rejects.toThrow(/parts\[0\]\.url must be a non-empty string/); + }); + + it('rejects non-object part headers', async () => { + await expect( + chunked([{ ...parts[0], headers: 'nope' }, parts[1]]), + ).rejects.toThrow(/parts\[0\]\.headers must be a plain object/); + }); + }); + + describe('headers', () => { + it('merges the descriptor headers over the configured provider', async () => { + const headers = jest.fn(() => ({ + Authorization: 'Bearer old', + 'X-App': 'app', + })); + const { define, lastEntry } = setup({ headers }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ + url: 'https://x', + data: {}, + headers: { Authorization: 'Bearer mine', 'Content-Type': 'a/b' }, + }), + }); + await send.mutate(null); + expect(headers).toHaveBeenCalledTimes(1); + expect(lastEntry().descriptor.headers).toEqual({ + Authorization: 'Bearer mine', + 'X-App': 'app', + 'Content-Type': 'a/b', + }); + }); + + it('sends the provider headers alone when the descriptor has none', async () => { + const { define, lastEntry } = setup({ headers: () => ({ A: '1' }) }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.headers).toEqual({ A: '1' }); + }); + + it('sends an empty header map with no provider and no descriptor headers', async () => { + const { define, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.headers).toEqual({}); + }); + }); + + describe('expiresAt', () => { + it('defaults to now + lifetimeMs', async () => { + const { define, lastEntry } = setup({ lifetimeMs: 1000 }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.expiresAt).toBe(NOW + 1000); + }); + + it('defaults the lifetime to 14 days', async () => { + const { define, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: null) => ({ url: 'https://x', data: {} }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.expiresAt).toBe( + NOW + 14 * 24 * 60 * 60 * 1000, + ); + }); + + it('keeps an explicit expiresAt', async () => { + const { define, lastEntry } = setup(); + const send = define({ + key: 'k', + request: (_vars: null) => ({ + url: 'https://x', + data: {}, + expiresAt: 42, + }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.expiresAt).toBe(42); + }); + }); +}); + +describe('uuidV4', () => { + it('produces distinct RFC 4122 v4 strings', () => { + const ids = new Set(Array.from({ length: 100 }, uuidV4)); + expect(ids.size).toBe(100); + ids.forEach((id) => + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), + ); + }); +}); diff --git a/src/__typetests__/define.ts b/src/__typetests__/define.ts new file mode 100644 index 00000000..97296ba7 --- /dev/null +++ b/src/__typetests__/define.ts @@ -0,0 +1,230 @@ +// Type-level tests. tsc compiles this file with the library (yarn typecheck); +// nothing here runs. Each @ts-expect-error line fails the build when the +// error it expects goes away. +import type { Meta, OutcomeError, RawResponse, UploadClient } from '../types'; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B + ? 1 + : 2 + ? true + : false; +const expectType = (_actual: Expected): void => {}; +const assertEqual = ( + _proof: Equal extends true ? true : never, +) => {}; + +declare const client: UploadClient; + +type AddCommentVars = { + siteId: string; + customFieldNoteId: string; + comment: string; +}; +type Comment = { id: string; text: string }; + +// vars infer from the request parameter, data from the response parser. +const addComment = client.define({ + key: 'comment.add', + request: ({ siteId, customFieldNoteId, comment }: AddCommentVars) => ({ + url: `https://api/sites/${siteId}/notes/${customFieldNoteId}/comments`, + data: { comment }, + }), + response: (raw) => (raw as { content: Comment[] }).content, + onSuccess: (content, vars, meta) => { + expectType(content); + expectType(vars); + expectType(meta); + assertEqual(true); + assertEqual(true); + }, + onError: (error, vars) => { + expectType(error); + expectType(vars); + }, +}); + +// The vars type flows into mutate(). +void addComment.mutate({ siteId: 's', customFieldNoteId: 'n', comment: 'hi' }); +void addComment.mutate( + { siteId: 's', customFieldNoteId: 'n', comment: 'hi' }, + { id: 'local-1' }, +); +expectType>( + addComment.mutate({ siteId: 's', customFieldNoteId: 'n', comment: 'hi' }), +); +expectType(addComment.key); + +// Wrong vars are a type error. +// @ts-expect-error siteId must be a string +void addComment.mutate({ siteId: 1, customFieldNoteId: 'n', comment: 'hi' }); +// @ts-expect-error comment is required +void addComment.mutate({ siteId: 's', customFieldNoteId: 'n' }); +void addComment.mutate({ + siteId: 's', + customFieldNoteId: 'n', + comment: 'hi', + // @ts-expect-error unknown field + extra: 1, +}); + +// Without a response parser, onSuccess receives the RawResponse. +const putFile = client.define({ + key: 'file.put', + request: ({ path, url }: { path: string; url: string }) => ({ + url, + method: 'PUT', + file: path, + }), + onSuccess: (_data) => { + assertEqual(true); + }, +}); +void putFile.mutate({ path: '/tmp/a', url: 'https://x' }); + +// vars must be JSON: no functions, no Dates, no undefined fields. +client.define({ + key: 'bad.vars', + // @ts-expect-error a function is not Json + request: (_vars: { cb: () => void }) => ({ url: 'https://x', data: null }), +}); +client.define({ + key: 'bad.vars.date', + // @ts-expect-error a Date is not Json + request: (_vars: { when: Date }) => ({ url: 'https://x', data: null }), +}); + +// The descriptor is checked against RequestDescriptor. +client.define({ + key: 'bad.descriptor', + // @ts-expect-error method must be one of the union + request: (_vars: { a: string }) => ({ + url: 'https://x', + data: 1, + method: 'FETCH', + }), +}); + +// A parser that throws away its input still fixes T. +const ping = client.define({ + key: 'ping', + request: (_vars: null) => ({ url: 'https://x', data: null }), + response: () => 42 as const, + onSuccess: (_data) => { + assertEqual(true); + }, +}); +void ping.mutate(null); + +// An onSuccess annotated with another type, and no response parser, does not +// compile. At runtime the handler would receive the RawResponse. +type Dto = { total: number }; +client.define({ + key: 'annotated.no.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + // @ts-expect-error onSuccess wants a Dto but there is no response parser + onSuccess: (data: Dto) => { + void data.total; + }, +}); +declare const handleDto: (data: Dto, vars: { a: string }, meta: Meta) => void; +client.define({ + key: 'named.no.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + // @ts-expect-error a named handler typed for a Dto also needs the parser + onSuccess: handleDto, +}); +// With the parser, the same handler compiles. +client.define({ + key: 'named.with.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + response: (raw) => raw as Dto, + onSuccess: handleDto, +}); +// An onSuccess that spells out RawResponse compiles without a parser. +client.define({ + key: 'raw.annotated', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + onSuccess: (data: RawResponse) => { + void data.bodyTruncated; + }, +}); +// A definition with only onError infers too. +const errorOnly = client.define({ + key: 'error.only', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + onError: (_error, _vars) => { + assertEqual(true); + }, +}); +void errorOnly.mutate({ a: 'x' }); + +// A zod-style parser fixes T from its return type. +declare const schema: { parse: (input: unknown) => Dto }; +const parsed = client.define({ + key: 'zod', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + response: schema.parse, + onSuccess: (_data) => { + assertEqual(true); + }, +}); +void parsed.mutate({ a: 'x' }); + +// An async parser is typed honestly: onSuccess sees the Promise. +client.define({ + key: 'async.parser', + request: (_vars: { a: string }) => ({ url: 'https://x', data: null }), + response: async (raw) => raw as Dto, + onSuccess: (_data) => { + assertEqual>(true); + }, +}); + +// A request that takes no vars gives mutate() no arguments, and rejects a +// stray value. +const noVars = client.define({ + key: 'no.vars', + request: () => ({ url: 'https://x', data: null }), + onSuccess: (_data, _vars) => { + assertEqual(true); + assertEqual(true); + }, +}); +void noVars.mutate(); +void noVars.mutate(null); +void noVars.mutate(undefined, { id: 'fixed' }); +// @ts-expect-error a no-vars definition takes no vars +void noVars.mutate('anything goes'); +// @ts-expect-error a no-vars definition takes no vars +void noVars.mutate({ arbitrary: [1, 2, 3] }); + +// Known limits of the Json constraint. An interface has no implicit index +// signature, and a readonly array is not a Json[]. Use a type alias with +// mutable arrays. These lines pin the limit so a change to it shows up here. +interface InterfaceVars { + a: string; +} +client.define({ + key: 'interface.vars', + // @ts-expect-error an interface does not satisfy Json; use a type alias + request: (_vars: InterfaceVars) => ({ url: 'https://x', data: null }), +}); +client.define({ + key: 'readonly.vars', + // @ts-expect-error readonly string[] is not a Json[] + request: (_vars: { ids: readonly string[] }) => ({ + url: 'https://x', + data: null, + }), +}); +// Aliases with optional fields, nested aliases and mutable arrays pass. +type NestedVars = { inner: { b: number }; ids: string[]; note?: string }; +const nested = client.define({ + key: 'nested.vars', + request: (_vars: NestedVars) => ({ url: 'https://x', data: null }), +}); +void nested.mutate({ inner: { b: 1 }, ids: ['x'] }); + +// The client's define is the same overloaded signature. +declare const define: typeof client.define; +expectType(define); diff --git a/src/delivery.ts b/src/delivery.ts new file mode 100644 index 00000000..cdc12493 --- /dev/null +++ b/src/delivery.ts @@ -0,0 +1,260 @@ +import type { EventSubscription } from 'react-native'; +import type { Spec } from './NativeRNFileUploader'; +import type { AnyDefinition } from './registry'; +import type { + Json, + Meta, + Outcome, + RawResponse, + RequestState, + StateEvent, +} from './types'; + +/** + * The journaled terminal outcome that native emits on `onSettled` and returns + * from `getUnacknowledgedEvents()`. Both carry the same shape, so a live event + * and a replayed one take the same path here. + */ +export type SettledEvent = { + eventId: string; + id: string; + key: string; + vars: Json; + /** Native outcome time, epoch ms. */ + at: number; + attempts: number; + requestId?: string; + /** The entry's real state, for the unhandled-key row. */ + state: RequestState; + bytesSent?: number; + totalBytes?: number; +} & Outcome; + +export const HANDLER_WARNING_MS = 30_000; + +type DeliveryDeps = { + native: Pick; + lookup: (key: string) => AnyDefinition | undefined; + /** Feeds the client's `state` listeners. */ + emitState: (event: StateEvent) => void; + warn?: (message: string, ...rest: unknown[]) => void; + handlerWarningMs?: number; +}; + +export type Delivery = { + /** Subscribes, drains the journal, then goes live. A second call is a no-op. */ + start: () => void; + /** Delivery for `id` waits until this promise has settled. */ + trackMutate: (id: string, pending: Promise) => void; +}; + +const errorMessage = (e: unknown): string => + e instanceof Error ? e.message : String(e); + +/** + * Routes settled outcomes to the definitions' handlers and acknowledges them + * afterwards. Rules: journal before emit is native's job; here it is dedupe by + * eventId, wait for the id's in-flight mutate(), look up the key, run the + * handler, ack after its promise resolves. An unknown key or a rejected + * handler leaves the outcome unacknowledged, so native redelivers it at the + * next launch. + */ +export const createDelivery = ({ + native, + lookup, + emitState, + warn = console.warn, + handlerWarningMs = HANDLER_WARNING_MS, +}: DeliveryDeps): Delivery => { + const seen = new Set(); + const pendingMutates = new Map>(); + let subscription: EventSubscription | undefined; + // Live events that arrive while the journal drains wait here, so replayed + // outcomes deliver first. + const buffer: SettledEvent[] = []; + let live = false; + + const trackMutate = (id: string, pending: Promise): void => { + const settled = pending.then( + () => undefined, + () => undefined, + ); + const prior = pendingMutates.get(id); + const chain = prior ? prior.then(() => settled) : settled; + pendingMutates.set(id, chain); + void chain.then(() => { + if (pendingMutates.get(id) === chain) { + pendingMutates.delete(id); + } + }); + }; + + const ack = async (eventId: string): Promise => { + try { + await native.ackEvents([eventId]); + } catch (e) { + warn(`delivery: ackEvents failed for ${eventId}`, e); + } + }; + + const unhandledRow = (event: SettledEvent): StateEvent => ({ + id: event.id, + key: event.key, + vars: event.vars, + state: event.state, + bytesSent: event.bytesSent ?? 0, + totalBytes: event.totalBytes ?? 0, + attempts: event.attempts, + updatedAt: event.at, + reason: 'unhandled-key', + }); + + const invoke = async ( + event: SettledEvent, + definition: AnyDefinition, + meta: Meta, + ): Promise => { + const { vars } = event; + if (event.kind === 'error') { + await definition.onError?.(event.error, vars, meta); + return; + } + if (event.kind !== 'completed') { + return; + } + const response: RawResponse = event.response ?? { bodyTruncated: false }; + if (!definition.response) { + await definition.onSuccess?.(response, vars, meta); + return; + } + if (response.bodyTruncated) { + await definition.onError?.( + { + errorKind: 'truncated', + message: + 'the response body exceeded the 1 MB cap, so it was not parsed', + response, + }, + vars, + meta, + ); + return; + } + let data: unknown; + try { + const parsed: unknown = + response.body === undefined || response.body === '' + ? undefined + : JSON.parse(response.body); + data = definition.response(parsed); + } catch (e) { + await definition.onError?.( + { errorKind: 'unknown', message: errorMessage(e) }, + vars, + meta, + ); + return; + } + await definition.onSuccess?.(data, vars, meta); + }; + + /** True when the handler settled, so the outcome may be acknowledged. */ + const runHandler = async ( + event: SettledEvent, + definition: AnyDefinition, + ): Promise => { + const meta: Meta = { + id: event.id, + key: event.key, + at: event.at, + attempts: event.attempts, + requestId: event.requestId, + }; + const timer = setTimeout(() => { + warn( + `delivery: the "${event.key}" handler for ${ + event.id + } has not settled after ${ + handlerWarningMs / 1000 + } s. The outcome stays unacknowledged until it does.`, + ); + }, handlerWarningMs); + try { + await invoke(event, definition, meta); + return true; + } catch (e) { + warn( + `delivery: the "${event.key}" handler for ${event.id} rejected. The outcome stays unacknowledged and redelivers at the next launch.`, + e, + ); + return false; + } finally { + clearTimeout(timer); + } + }; + + const deliver = async (event: SettledEvent): Promise => { + if (typeof event?.eventId !== 'string') { + warn('delivery: dropped a settled event without an eventId', event); + return; + } + if (seen.has(event.eventId)) { + return; + } + seen.add(event.eventId); + const pending = pendingMutates.get(event.id); + if (pending) { + await pending; + // The enqueue promise settles before mutate()'s own await and before + // the caller's continuation, both microtasks. A macrotask puts the + // handler after them, so the caller has the id before any handler + // sees it. + await new Promise((resolve) => setTimeout(resolve, 0)); + } + const definition = lookup(event.key); + if (!definition) { + warn( + `delivery: no definition for key "${event.key}" (id ${event.id}). The outcome stays unacknowledged.`, + ); + emitState(unhandledRow(event)); + return; + } + if (event.kind === 'cancelled') { + await ack(event.eventId); + return; + } + if (await runHandler(event, definition)) { + await ack(event.eventId); + } + }; + + const start = (): void => { + if (subscription) { + return; + } + // Subscribe first, so nothing that settles during the drain is missed. + // The eventId dedupe absorbs an event that shows up in both. + subscription = native.onSettled((raw) => { + const event = raw as SettledEvent; + if (live) { + void deliver(event); + } else { + buffer.push(event); + } + }); + void native + .getUnacknowledgedEvents() + .then( + (events) => { + (events as SettledEvent[]).forEach((event) => void deliver(event)); + }, + (e) => warn('delivery: getUnacknowledgedEvents failed', e), + ) + .then(() => { + live = true; + buffer.splice(0).forEach((event) => void deliver(event)); + }); + }; + + return { start, trackMutate }; +}; diff --git a/src/index.ts b/src/index.ts index 28bc49e1..47df09f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,233 +1,183 @@ /** - * Handles HTTP background file uploads from an iOS or Android device. + * Durable HTTP requests and file uploads from an iOS or Android device. The + * consumer defines request kinds with define(), enqueues them with mutate(), + * and receives every outcome through the definition's handlers. */ -import { Platform } from 'react-native'; import type { EventSubscription } from 'react-native'; import NativeRNFileUploader from './NativeRNFileUploader'; +import { chunkPlan } from './chunkPlan'; +import { createDelivery } from './delivery'; import { + createRegistry, + DEFAULT_LIFETIME_MS, + type AnyDefinition, + type Settings, +} from './registry'; +import type { AddListener, - ChunkedUploadOptions, ConfigureOptions, - JournaledEvent, - StartUploadOptions, - UploadId, - UploadSnapshot, + RequestRow, + StateEvent, + UploadClient, } from './types'; -import { chunkPlan } from './chunkPlan'; export * from './types'; export * from './chunkPlan'; -const fileURIPrefix = 'file://'; - /** - * One-time library configuration. Call it at app startup, before an upload - * starts. Android keeps the notification configuration in native storage. Thus - * a worker that WorkManager relaunches with no JS shows the same notification - * text. The call is optional: a field that you do not configure keeps the - * library default. Each call replaces the full configuration. The call does - * nothing on iOS, because iOS has no library notification. + * Builds one client over the native queue. Each client has its own + * definitions and settings. An app needs one; the default export is one. */ -const configure = ({ android }: ConfigureOptions): void => { - NativeRNFileUploader.configure({ ...android }); -}; +export const createUploadClient = (): UploadClient => { + const native = NativeRNFileUploader; + const settings: Settings = { lifetimeMs: DEFAULT_LIFETIME_MS }; + const definitions = new Map(); + // One entry per subscription, not per function, so the same listener + // registered twice is removed one subscription at a time. + const stateListeners = new Set<{ listener: (event: StateEvent) => void }>(); -const normalizePath = (path: string): string => { - if (!path.startsWith(fileURIPrefix)) { - path = fileURIPrefix + path; - } - // Android native takes a plain filesystem path. iOS takes a file:// URL. - return Platform.OS === 'android' ? path.replace(fileURIPrefix, '') : path; -}; + const delivery = createDelivery({ + native, + lookup: (key) => definitions.get(key), + emitState: (event) => + stateListeners.forEach(({ listener }) => listener(event)), + }); + const { define } = createRegistry({ + native, + definitions, + getSettings: () => settings, + trackMutate: delivery.trackMutate, + }); -// Reject malformed chunked input before it crosses the bridge. Then native -// never creates a manifest for an upload that cannot complete. -const validateChunkedOptions = (options: ChunkedUploadOptions): void => { - if (!options.id) { - throw new Error('startUpload: a chunked upload requires a non-empty id'); - } - if (!Array.isArray(options.parts) || options.parts.length === 0) { - throw new Error('startUpload: parts must be a non-empty array'); - } - // The parts must tile the file from byte 0. They must be sorted in - // ascending order, with no gaps and no overlaps. Each plan from chunkPlan - // obeys this by construction. - let expectedStart = 0; - options.parts.forEach(({ url, headers, range }, i) => { - if (typeof url !== 'string' || url.length === 0) { - throw new Error(`startUpload: parts[${i}].url must be a non-empty string`); - } - if ( - headers !== undefined && - (typeof headers !== 'object' || - headers === null || - Array.isArray(headers)) - ) { - throw new Error( - `startUpload: parts[${i}].headers must be a plain object when present`, - ); - } - if ( - !range || - !Number.isInteger(range.start) || - !Number.isInteger(range.end) || - range.start < 0 || - range.start >= range.end - ) { + /** + * One-time setup. Call it at boot, after every define() call. Stores the + * lifetime, the headers provider and the retry defaults, forwards the + * lifetime, retry and Android notification settings to native, then starts + * replaying journaled outcomes. A second call updates the settings and does + * not replay again. Each call replaces the full configuration. + */ + const configure = (options: ConfigureOptions): void => { + const lifetimeMs = options.lifetimeMs ?? DEFAULT_LIFETIME_MS; + if (!Number.isFinite(lifetimeMs) || lifetimeMs <= 0) { throw new Error( - `startUpload: parts[${i}].range must satisfy 0 <= start < end, got ${JSON.stringify( - range, - )}`, + `configure: lifetimeMs must be a positive number, got ${options.lifetimeMs}`, ); } - if (range.start !== expectedStart) { - throw new Error( - i === 0 - ? `startUpload: parts[0].range.start must be 0, got ${range.start}` - : `startUpload: parts must be sorted ascending and tile the file with no gaps or overlaps — parts[${i}].range.start is ${range.start} but parts[${i - 1}].range.end is ${expectedStart}`, - ); + settings.lifetimeMs = lifetimeMs; + settings.headers = options.headers; + settings.retry = options.retry; + const forwarded: Record = { + lifetimeMs, + ...options.android, + }; + if (options.retry !== undefined) { + forwarded.retry = options.retry; } - expectedStart = range.end; - }); - if (!Number.isFinite(options.expiresAt) || options.expiresAt <= 0) { - throw new Error( - `startUpload: expiresAt must be a finite epoch-ms timestamp, got ${options.expiresAt}`, - ); - } -}; - -/** - * Starts an upload to an HTTP endpoint. The behavior depends on options.type. - * 'raw' sends the whole file as one request body. 'chunked' sends the parts - * that the consumer authored (see ChunkedUploadOptions). Returns a promise - * that resolves to the upload's string id. Malformed chunked input throws - * synchronously. Other bad options (for example, a missing or invalid url or - * path) reject. Transport failures and HTTP error responses arrive later as - * 'error' events. - * - * A new call with the same id and identical parts is never an error, at any - * time. The library reconciles: it skips the parts that the server accepted, - * and the other parts continue with the new call's headers. A call with a - * different parts array is a recreate. The library accepts a recreate when - * the upload is stopped, and replaces the bytes. It rejects a recreate while - * the upload runs. - */ -const startUpload = (options: StartUploadOptions): Promise => { - if (options.type === 'chunked') { - validateChunkedOptions(options); - const { path, android, ...rest } = options; - return NativeRNFileUploader.startChunkedUpload({ - ...rest, - ...android, - path: normalizePath(path), - }); - } - - const { path, android, ...rest } = options; - return NativeRNFileUploader.startUpload({ - ...rest, - ...android, - path: normalizePath(path), - }); -}; + native.configure(forwarded); + delivery.start(); + }; -/** - * Releases an upload's native manifest and bytes. Each terminal outcome other - * than an acknowledged 'completed' (expired, error, cancelled) keeps both. - * This lets the consumer resume or recreate the upload. Call this function - * when you want neither. On a raw upload id, it cancels the in-flight request - * and deliberately emits no terminal event. - */ -const removeUpload = (uploadId: string): Promise => - NativeRNFileUploader.removeUpload(uploadId); + /** Pauses the whole queue. No outcome is produced; live rows show 'paused'. */ + const pause = (): Promise => native.pause(); -/** - * Cancels active upload by string ID of the upload. - * - * Upload ID is returned in a promise after a call to startUpload method, - * use it to cancel started upload. - * Event "cancelled" will be fired when upload is cancelled. - * On iOS, resolves true if a matching in-flight upload was found and cancelled, - * false if there was nothing to cancel. Android always resolves true — the - * WorkManager cancel is fire-and-forget and does not report whether it matched. - */ -const cancelUpload = (cancelUploadId: string): Promise => - NativeRNFileUploader.cancelUpload(cancelUploadId); + /** Resumes a paused queue. */ + const resume = (): Promise => native.resume(); -/** - * Listens for one event type across all uploads. Use `data.id` to identify - * the upload. - * Events (id is always the upload ID): - * progress - { id, progress: 0-100 } - * error - { id, error, errorKind?, responseCode?, responseBody?, responseHeaders? } - * cancelled - { id, cancelReason?: 'user' | 'system' } - * completed - { id, responseCode, responseBody, responseHeaders?, eventId? } - */ -const addListener = (( - eventType: 'progress' | 'error' | 'completed' | 'cancelled', - // The payload shape varies per event; the public AddListener overloads carry - // the precise contract, so the internal forwarder stays untyped. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - listener: (data: any) => void, -): EventSubscription => { - switch (eventType) { - case 'progress': - return NativeRNFileUploader.onProgress(listener); - case 'error': - return NativeRNFileUploader.onError(listener); - case 'cancelled': - return NativeRNFileUploader.onCancelled(listener); - case 'completed': - return NativeRNFileUploader.onCompleted(listener); - default: - throw new Error(`Unknown upload event: ${eventType}`); - } -}) as AddListener; + /** + * On a live entry: settles it 'cancelled' with reason 'user', then forgets + * it after the ack. On a settled entry: forgets it now, row and bytes. + */ + const cancel = (id: string): Promise => native.cancel(id); -/** - * Terminal events (completed/error/cancelled) are journaled natively before being - * emitted, so they survive the app being killed or JS reloading. Read them on - * startup, process each, then acknowledge — unacknowledged events are re-delivered - * here on every call until you ack them. - * - * Note: `completed` fires only for 2xx (or a request's `accept` rules); other HTTP - * responses arrive as `error` with `errorKind: 'http'` and the response attached. - */ -const getUnacknowledgedEvents = async (): Promise => - (await NativeRNFileUploader.getUnacknowledgedEvents()) as JournaledEvent[]; + /** Persisted natively. Applies to queued and future entries. */ + const setWifiOnly = (enabled: boolean): Promise => + native.setWifiOnly(enabled); -/** Removes journaled events by eventId once you've processed them. */ -const ackEvents = (eventIds: string[]): Promise => - NativeRNFileUploader.ackEvents(eventIds); + /** + * Merges the patch into the headers of every queued and parked entry, then + * resumes the entries parked on 'awaiting-auth'. This is how a fresh token + * reaches requests that stalled on 401. + */ + const updateHeaders = (patch: Record): Promise => + native.updateHeaders(patch); -/** - * Enumerates uploads the OS still knows about, for reconciling in-flight work on - * boot. Terminal outcomes come from getUnacknowledgedEvents (durable), not here: - * on Android finished work is pruned after ~a day, and on iOS only live tasks are - * listed. - */ -const getAllUploads = async (): Promise => - (await NativeRNFileUploader.getAllUploads()) as UploadSnapshot[]; + /** + * The live rows of the queue, read synchronously from native's in-memory + * index. Works offline. Completed entries leave after their ack. + */ + const getRequests = (filter?: { + key?: string; + id?: string; + }): RequestRow[] => { + const rows = native.getRequests() as RequestRow[]; + if (!filter) { + return rows; + } + return rows.filter( + (row) => + (filter.key === undefined || row.key === filter.key) && + (filter.id === undefined || row.id === filter.id), + ); + }; -const android = { /** - * When the upload progress notification is pressed, it will open the app and fire this event. - * Android only — never fires on iOS. - * @param listener + * Listens for one event type across all requests. Listeners are global; use + * the event's id to tell requests apart. 'state' carries a full RequestRow + * per transition, plus a row with reason 'unhandled-key' for an outcome + * whose key has no definition. 'progress' is byte-weighted. 'attempt' is + * one HTTP attempt before interpretation. */ - addNotificationListener: (listener: () => void): EventSubscription => - NativeRNFileUploader.onNotification(() => listener()), -}; + const addListener = (( + event: 'state' | 'progress' | 'attempt', + // The public AddListener overloads carry the precise payload per event. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + listener: (data: any) => void, + ): EventSubscription => { + switch (event) { + case 'state': { + const entry = { listener }; + stateListeners.add(entry); + // One subscription covers the native rows and the JS-synthesized + // unhandled-key rows, so remove() has to drop both. + const subscription = native.onState(listener); + const removeNative = subscription.remove.bind(subscription); + subscription.remove = () => { + stateListeners.delete(entry); + removeNative(); + }; + return subscription; + } + case 'progress': + return native.onProgress(listener); + case 'attempt': + return native.onAttempt(listener); + default: + throw new Error(`addListener: unknown event ${String(event)}`); + } + }) as AddListener; -export default { - configure, - startUpload, - cancelUpload, - removeUpload, - addListener, - getUnacknowledgedEvents, - ackEvents, - getAllUploads, - chunkPlan, - android, + const android = { + /** + * Fires when the Android upload notification is pressed. It never fires + * on iOS. + */ + addNotificationListener: (listener: () => void): EventSubscription => + native.onNotification(() => listener()), + }; + + return { + configure, + define, + pause, + resume, + cancel, + setWifiOnly, + updateHeaders, + getRequests, + addListener, + chunkPlan, + android, + }; }; + +export default createUploadClient(); diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 00000000..03843c4a --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,371 @@ +import type { Spec } from './NativeRNFileUploader'; +import type { + Define, + Defined, + Definition, + FormPart, + Json, + Method, + Part, + RequestDescriptor, + RetryPolicy, +} from './types'; + +export const DEFAULT_LIFETIME_MS = 14 * 24 * 60 * 60 * 1000; +/** `vars` are persisted natively next to every entry. Only they are capped. */ +export const MAX_VARS_BYTES = 4096; + +const METHODS: readonly Method[] = ['POST', 'PUT', 'PATCH', 'DELETE', 'GET']; + +const DESCRIPTOR_KEYS = [ + 'url', + 'method', + 'headers', + 'data', + 'form', + 'file', + 'parts', + 'accept', + 'expiresAt', + 'retry', + 'android', +]; +const PART_KEYS = ['url', 'headers', 'range']; +const FORM_PART_KEYS = ['name', 'contentType', 'string', 'path', 'fileName']; + +/** The JS-side settings that `configure()` stores. */ +export type Settings = { + lifetimeMs: number; + headers?: () => Record; + retry?: Partial; +}; + +/** What crosses to native `enqueue()`. */ +export type EnqueueEntry = { + id: string; + key: string; + vars: Json; + descriptor: RequestDescriptor; +}; + +// The registry stores definitions of every shape under one map. The generic +// parameters are enforced at define() and re-applied by the delivery layer. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyDefinition = Definition; + +type RegistryDeps = { + native: Pick; + definitions: Map; + getSettings: () => Settings; + /** Called with every enqueue promise, so delivery can wait for it. */ + trackMutate: (id: string, pending: Promise) => void; + warn?: (message: string) => void; + now?: () => number; +}; + +export type Registry = { + define: Define; +}; + +declare const __DEV__: boolean | undefined; +declare const process: { env?: { NODE_ENV?: string } } | undefined; + +/** React Native sets `__DEV__`. Other hosts (Jest) fall back to NODE_ENV. */ +export const isDev = (): boolean => { + if (typeof __DEV__ === 'boolean') { + return __DEV__; + } + return ( + typeof process === 'undefined' || process?.env?.NODE_ENV !== 'production' + ); +}; + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** UTF-8 length of a string that JSON.stringify produced. */ +export const utf8ByteLength = (s: string): number => { + let bytes = 0; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + // A surrogate pair encodes one 4-byte code point. + bytes += 4; + i++; + } else { + bytes += 3; + } + } + return bytes; +}; + +/** RFC 4122 version 4, from Math.random. Ids only need to be unique per app. */ +export const uuidV4 = (): string => + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }); + +const editDistance = (a: string, b: string): number => { + let prev = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i++) { + const row = [i]; + for (let j = 1; j <= b.length; j++) { + const same = a[i - 1] === b[j - 1] ? 0 : 1; + row[j] = Math.min( + (prev[j] ?? 0) + 1, + (row[j - 1] ?? 0) + 1, + (prev[j - 1] ?? 0) + same, + ); + } + prev = row; + } + return prev[b.length] ?? 0; +}; + +// TypeScript does not check an inferred arrow return for excess properties, +// so `header:` in place of `headers:` compiles. Rejecting unknown keys here +// turns that silent drop into a mutate() rejection. +const rejectUnknownKeys = ( + value: Record, + known: readonly string[], + where: string, +): void => { + Object.keys(value).forEach((key) => { + if (known.includes(key)) { + return; + } + const guess = known.find( + (candidate) => + editDistance(key.toLowerCase(), candidate.toLowerCase()) <= 2, + ); + throw new Error( + `mutate: unknown ${where} field "${key}".${ + guess ? ` Did you mean "${guess}"?` : '' + }`, + ); + }); +}; + +// The parts must tile the file from byte 0. They must be sorted in ascending +// order, with no gaps and no overlaps. Each plan from chunkPlan obeys this by +// construction. +const validateParts = (parts: unknown): void => { + if (!Array.isArray(parts) || parts.length === 0) { + throw new Error('mutate: parts must be a non-empty array'); + } + let expectedStart = 0; + (parts as Part[]).forEach((part, i) => { + if (!isPlainObject(part)) { + throw new Error(`mutate: parts[${i}] must be an object`); + } + rejectUnknownKeys(part, PART_KEYS, `parts[${i}]`); + const { url, headers, range } = part; + if (typeof url !== 'string' || url.length === 0) { + throw new Error(`mutate: parts[${i}].url must be a non-empty string`); + } + if (headers !== undefined && !isPlainObject(headers)) { + throw new Error( + `mutate: parts[${i}].headers must be a plain object when present`, + ); + } + if ( + !range || + !Number.isInteger(range.start) || + !Number.isInteger(range.end) || + range.start < 0 || + range.start >= range.end + ) { + throw new Error( + `mutate: parts[${i}].range must satisfy 0 <= start < end, got ${JSON.stringify( + range, + )}`, + ); + } + if (range.start !== expectedStart) { + throw new Error( + i === 0 + ? `mutate: parts[0].range.start must be 0, got ${range.start}` + : `mutate: parts must be sorted ascending and tile the file with no gaps or overlaps. parts[${i}].range.start is ${ + range.start + } but parts[${i - 1}].range.end is ${expectedStart}`, + ); + } + expectedStart = range.end; + }); +}; + +const validateForm = (form: unknown): void => { + if (!Array.isArray(form) || form.length === 0) { + throw new Error('mutate: form must be a non-empty array'); + } + (form as FormPart[]).forEach((part, i) => { + if (!isPlainObject(part)) { + throw new Error(`mutate: form[${i}] must be an object`); + } + rejectUnknownKeys(part, FORM_PART_KEYS, `form[${i}]`); + if (typeof part.name !== 'string' || part.name.length === 0) { + throw new Error(`mutate: form[${i}].name must be a non-empty string`); + } + if (typeof part.contentType !== 'string' || part.contentType.length === 0) { + throw new Error( + `mutate: form[${i}].contentType must be a non-empty string`, + ); + } + const hasString = typeof (part as { string?: unknown }).string === 'string'; + const hasPath = typeof (part as { path?: unknown }).path === 'string'; + if (hasString === hasPath) { + throw new Error( + `mutate: form[${i}] must set exactly one of string, path`, + ); + } + }); +}; + +/** + * Rejects a malformed descriptor before it crosses the bridge. Then native + * never persists an entry that cannot run. + */ +export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { + if (!isPlainObject(descriptor)) { + throw new Error('mutate: request() must return a descriptor object'); + } + rejectUnknownKeys(descriptor, DESCRIPTOR_KEYS, 'descriptor'); + const d = descriptor as RequestDescriptor; + const kinds = (['data', 'form', 'file'] as const).filter( + (kind) => d[kind] !== undefined, + ); + if (kinds.length !== 1) { + throw new Error( + `mutate: the descriptor must set exactly one of data, form, file; got ${ + kinds.length === 0 ? 'none' : kinds.join(', ') + }`, + ); + } + if (d.parts !== undefined && d.file === undefined) { + throw new Error('mutate: parts requires file'); + } + if (d.url === undefined) { + if (d.parts === undefined) { + throw new Error('mutate: url is required unless parts is set'); + } + } else if (typeof d.url !== 'string' || d.url.length === 0) { + throw new Error('mutate: url must be a non-empty string'); + } + if (d.method !== undefined && !METHODS.includes(d.method)) { + throw new Error( + `mutate: method must be one of ${METHODS.join(', ')}, got ${String( + d.method, + )}`, + ); + } + if (d.headers !== undefined && !isPlainObject(d.headers)) { + throw new Error('mutate: headers must be a plain object when present'); + } + if (d.file !== undefined && (typeof d.file !== 'string' || !d.file)) { + throw new Error('mutate: file must be a non-empty path'); + } + if (d.form !== undefined) { + validateForm(d.form); + } + if (d.parts !== undefined) { + validateParts(d.parts); + } + if ( + d.expiresAt !== undefined && + (!Number.isFinite(d.expiresAt) || d.expiresAt <= 0) + ) { + throw new Error( + `mutate: expiresAt must be a finite epoch-ms timestamp, got ${d.expiresAt}`, + ); + } + return d; +}; + +/** + * Holds the definitions of one client and builds `define()`. Every `mutate()` + * validates `vars` and the descriptor, merges the configured headers under the + * descriptor's, defaults `expiresAt`, and hands the entry to native. + */ +export const createRegistry = ({ + native, + definitions, + getSettings, + trackMutate, + warn = console.warn, + now = Date.now, +}: RegistryDeps): Registry => { + // The overloads on Define keep the with-parser and without-parser shapes + // apart for callers. One implementation serves both. + const define = (( + definition: Definition, + ): Defined => { + const { key } = definition; + if (typeof key !== 'string' || key.length === 0) { + throw new Error('define: key must be a non-empty string'); + } + if (typeof definition.request !== 'function') { + throw new Error(`define: "${key}" needs a request function`); + } + // Hot reload re-evaluates modules, so a duplicate key replaces instead of + // throwing. The warning catches two modules that share a key by mistake. + if (definitions.has(key) && isDev()) { + warn( + `define: "${key}" is already defined. The new definition replaces it.`, + ); + } + definitions.set(key, definition); + + const mutate = async ( + input: V | undefined, + options?: { id?: string }, + ): Promise<{ id: string }> => { + // A no-vars definition calls mutate() with nothing; native stores null. + const vars = (input === undefined ? null : input) as V; + const serialized = JSON.stringify(vars); + if (serialized === undefined) { + throw new Error('mutate: vars must be a JSON value'); + } + const bytes = utf8ByteLength(serialized); + if (bytes > MAX_VARS_BYTES) { + throw new Error( + `mutate: vars for "${key}" is ${bytes} bytes; the limit is ${MAX_VARS_BYTES}`, + ); + } + if (options?.id !== undefined && !options.id) { + throw new Error('mutate: id must be a non-empty string when given'); + } + // The current definition under this key, so a replaced definition's + // request() is the one that runs. + const current = (definitions.get(key) ?? definition) as Definition; + const descriptor = validateDescriptor(current.request(vars)); + const settings = getSettings(); + const provided = settings.headers?.() ?? {}; + if (!isPlainObject(provided)) { + throw new Error('mutate: configure().headers() must return an object'); + } + const entry: EnqueueEntry = { + id: options?.id ?? uuidV4(), + key, + vars, + descriptor: { + ...descriptor, + headers: { ...provided, ...descriptor.headers }, + expiresAt: descriptor.expiresAt ?? now() + settings.lifetimeMs, + }, + }; + const pending = native.enqueue(entry); + trackMutate(entry.id, pending); + return { id: await pending }; + }; + + return { key, mutate } as Defined; + }) as Define; + + return { define }; +}; diff --git a/src/types.ts b/src/types.ts index 77c186ea..deba6bcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,170 +1,254 @@ -import { EventSubscription } from 'react-native'; +import type { EventSubscription } from 'react-native'; -export interface EventData { - id: string; -} +/** + * Any JSON value. `vars` and `data` must be JSON, because native persists them. + * A vars type has to be a `type` alias, not an `interface`: only aliases get + * the implicit index signature that this recursive type asks for. Fields must + * be mutable arrays, not `readonly T[]`. + */ +export type Json = + | string + | number + | boolean + | null + | Json[] + | { [k: string]: Json }; -export interface ProgressData extends EventData { - progress: number; -} +export type Method = 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'GET'; /** - * `expired` means that the upload's `expiresAt` time passed before the server - * accepted every part. The library keeps the manifest and the bytes. Thus a - * new `startUpload` call with a later deadline resumes the upload. + * Why a request failed. `http` means the server answered and the status was + * not accepted. `network` is a transport failure. `file` means the payload is + * missing on disk, so a retry can never succeed. `expired` means `expiresAt` + * passed. `truncated` means the response body hit the 1 MB cap, so the + * `response` parser could not run. `unknown` covers a parser throw. */ -export type ErrorKind = 'http' | 'network' | 'file' | 'expired' | 'unknown'; +export type ErrorKind = + | 'http' + | 'network' + | 'file' + | 'expired' + | 'truncated' + | 'unknown'; +/** `user` for an explicit `cancel()`. `system` for an OS-initiated stop. */ export type CancelReason = 'user' | 'system'; -export type UploadId = string; +/** + * A non-2xx response to treat as success. `bodyIncludes` narrows the rule by + * a response-body substring. This is necessary when one status has several + * meanings, and only the message shows the difference. + */ +export type AcceptRule = { status: number; bodyIncludes?: string }; /** - * Fields carried by every terminal event (`completed` / `error` / `cancelled`). - * - * The native side emits the journal entry itself, so a live terminal event is - * the very same object `getUnacknowledgedEvents()` returns — `eventId` included, - * which is what lets you `ackEvents([eventId])` immediately after handling a - * live event instead of waiting to rediscover it on the next launch. + * One multipart/form-data field. A `path` part is a file. The library copies + * the file into its own directory at `mutate()`. */ -export interface TerminalEventData extends EventData { - eventId: string; - type: 'completed' | 'error' | 'cancelled'; - /** Epoch milliseconds, stamped natively when the outcome occurred. */ - timestamp: number; - /** - * The response, when one was received. Absent for a transport failure (the - * request never reached the server), so always narrow before using it. - */ - responseCode?: number; - responseBody?: string; - /** True when `responseBody` hit the 64KB cap and was truncated. */ - responseBodyTruncated?: boolean; - responseHeaders?: Record; -} +export type FormPart = { name: string; contentType: string } & ( + | { string: string } + | { path: string; fileName?: string } +); -/** A 2xx response, or one that matched an `accept` rule on the request. */ -export interface CompletedData extends TerminalEventData { - type: 'completed'; -} +/** + * One part of a chunked upload. The library sends the file bytes + * [range.start, range.end) as the body of a request to `url`. `headers` merge + * over the descriptor's headers. The range end is exclusive. + */ +export type Part = { + url: string; + headers?: Record; + range: { start: number; end: number }; +}; -export interface ErrorData extends TerminalEventData { - type: 'error'; - error: string; - /** - * Why it failed. `http` means the server responded and the status was not - * accepted (the response fields above are populated). `file` means the payload - * is missing or unreadable on disk, so retrying can never succeed. - */ - errorKind?: ErrorKind; - /** - * Chunked uploads: the index into `parts` of the failing part, when one - * part's response caused the error. - */ - partIndex?: number; -} +export type RetryPolicy = { + backoff: { baseMs: number; maxMs: number; jitter: number }; + /** HTTP statuses in the 4xx range that retry instead of settling. */ + terminalHttp: { exempt: number[] }; +}; -export interface CancelledData extends TerminalEventData { - type: 'cancelled'; - /** `user` for an explicit `cancelUpload`; `system` for an OS-initiated stop. */ - cancelReason?: CancelReason; -} +/** What `request(vars)` returns. Native persists it next to `vars`. */ +export type RequestDescriptor = { + /** Required unless `parts` is set. */ + url?: string; + /** Default POST. With `parts` it applies to every part. */ + method?: Method; + /** Merged over `configure().headers()`. Every part inherits the result. */ + headers?: Record; + /** JSON body. Exactly one of `data`, `form`, `file` must be set. */ + data?: Json; + /** multipart/form-data body. */ + form?: FormPart[]; + /** Whole file body. Copied. Moved when `parts` is set. */ + file?: string; + /** Chunked over `file`. Each part sends its own byte range. */ + parts?: Part[]; + accept?: AcceptRule[]; + /** Epoch ms. Default now + `lifetimeMs`. */ + expiresAt?: number; + retry?: Partial; + android?: { noNotification?: boolean }; +}; /** - * A terminal event journaled natively before being emitted, so it survives app - * death and JS reloads. Read via `getUnacknowledgedEvents`, process, then - * acknowledge via `ackEvents`. Discriminate on `type`. + * The last response of a completed request. `status` is absent for a chunked + * completion, because no single response represents N parts. `body` holds up + * to 1 MB; `bodyTruncated` says whether the cap cut it. */ -export type JournaledEvent = CompletedData | ErrorData | CancelledData; - -/** A snapshot of an upload the OS still knows about (from getAllUploads). */ -export interface UploadSnapshot { - id: UploadId; - state: 'pending' | 'running' | 'completed' | 'error' | 'cancelled'; - /** iOS: bytes sent so far. Android: a chunked upload's accepted bytes. */ - bytesSent?: number; - /** The total payload bytes. On iOS always; on Android for chunked uploads. */ - totalBytes?: number; -} +export type RawResponse = { + status?: number; + headers?: Record; + body?: string; + bodyTruncated: boolean; +}; -export type UploadOptions = { - url: string; - path: string; - method: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'; - id?: string; - headers?: { - [index: string]: string; - }; - // Whether the upload should wait for wifi before starting - wifiOnly?: boolean; - accept?: AcceptRule[]; - // Android options that change behavior. Notification text is not a - // per-upload option. Set it one time with configure(). - android?: Partial; -} & RawUploadOptions; +/** + * `response` is set for `http` and `truncated`. `partIndex` is the index of + * the failing part of a chunked upload. + */ +export type OutcomeError = { + errorKind: ErrorKind; + message: string; + response?: RawResponse; + partIndex?: number; +}; /** - * A non-2xx response to treat as success. `bodyIncludes` narrows the rule by - * a response-body substring. This is necessary when one status has several - * meanings, and only the message shows the difference (our backend's 409). A - * non-2xx response that matches no rule emits an 'error' event with errorKind - * 'http'. + * Handler context. `at` is the native outcome time. `requestId` is the last + * attempt's X-Request-Id. */ -export type AcceptRule = { - status: number; - bodyIncludes?: string; +export type Meta = { + id: string; + key: string; + at: number; + attempts: number; + requestId?: string; }; -export type ChunkedUploadOptions = { - type: 'chunked'; - /** Required. The consumer's durable id. */ +export type Outcome = + | { kind: 'completed'; response: RawResponse } + | { kind: 'error'; error: OutcomeError } + | { kind: 'cancelled'; cancelReason: CancelReason }; + +export type RequestState = + | 'queued' + | 'running' + | 'awaiting-auth' + | 'paused' + | 'completed' + | 'error' + | 'cancelled'; + +/** + * One row of the native queue, as `getRequests()` and `state` events carry it. + * `vars` is `Json`, because the row does not know its definition. Narrow it + * with a cast before reading a field: `(row.vars as { captureId?: string })`. + */ +export type RequestRow = { id: string; - /** - * The single source file. The library takes ownership: at startUpload it - * renames the file into the library's own directory (an O(1) move). It - * deletes the file only after you acknowledge a 'completed' terminal event. - * If you must keep the file, copy it first. A keep-the-file mode is - * deliberately not part of the library. - */ - path: string; - /** - * The consumer authors this one time. The library sends the file bytes - * [range.start, range.end) as the body of a PUT to `url`, with `headers` - * unchanged. The library never derives or edits a protocol field. - */ - parts: Array<{ - url: string; - /** These headers include Content-Range, Content-Type, and auth. */ - headers: Record; - /** Byte offsets. The end is exclusive. */ - range: { start: number; end: number }; - }>; - accept?: AcceptRule[]; - /** Epoch ms. Required. After this time: terminal error, errorKind 'expired'. */ - expiresAt: number; - wifiOnly?: boolean; - android?: Partial; + key: string; + vars: Json; + state: RequestState; + bytesSent: number; + totalBytes: number; + attempts: number; + updatedAt: number; }; -export type StartUploadOptions = UploadOptions | ChunkedUploadOptions; +/** + * A `state` event. `reason: 'unhandled-key'` reports an outcome whose key has + * no definition. The row carries the entry's real state, and the library keeps + * the entry unacknowledged. + */ +export type StateEvent = RequestRow & { reason?: 'unhandled-key' }; -export type AndroidOnlyUploadOptions = { - /** - * Uploads this file without a progress notification. Default false. - * - * The notification is what puts the upload's worker in foreground mode, which - * is how it survives Doze and memory pressure, so a silent upload is easier - * for the OS to defer or stop and re-run. Reserve it for payloads small enough - * that a restart costs nothing, and keep it off for anything a user would - * expect to see progress for. - */ - noNotification?: boolean; +export type ProgressEvent = { + id: string; + bytesSent: number; + totalBytes: number; +}; + +/** One HTTP attempt, before the library interprets it. Response body is capped at 4 KB. */ +export type AttemptEvent = { + id: string; + key: string; + requestId: string; + attempt: number; + url: string; + method: Method; + partIndex?: number; + outcome: 'completed' | 'error' | 'cancelled'; + httpCode?: number; + responseBody?: string; + responseBodyTruncated?: boolean; + responseHeaders?: Record; + errorKind?: ErrorKind; + errorMessage?: string; + cancelReason?: CancelReason; + /** Native stamp, epoch ms. */ + at: number; }; -export type RawUploadOptions = { - type: 'raw'; +type DefinitionBase = { + /** Persisted with every entry, so rename it with care. */ + key: string; + /** Runs one time, at `mutate()`. */ + request: (vars: V) => RequestDescriptor; + onError?: (error: OutcomeError, vars: V, meta: Meta) => void | Promise; +}; + +/** A definition with a parser. `onSuccess` receives what `response` returns. */ +export type DefinitionWithResponse = DefinitionBase & { + /** Parses the JSON body (`undefined` when there is none) before `onSuccess`. */ + response: (raw: unknown) => T; + onSuccess?: (data: T, vars: V, meta: Meta) => void | Promise; +}; + +/** A definition without a parser. `onSuccess` receives the `RawResponse`. */ +export type DefinitionWithoutResponse = DefinitionBase & { + response?: undefined; + onSuccess?: (data: RawResponse, vars: V, meta: Meta) => void | Promise; +}; + +/** + * One request kind. `key` is persisted with every entry, so rename it with + * care. `request` runs one time, at `mutate()`. `response` parses the JSON + * body before `onSuccess`. Without `response`, `onSuccess` receives the + * `RawResponse`, and the two shapes are kept apart so that an `onSuccess` + * annotated with another type does not compile. + */ +export type Definition = + | DefinitionWithResponse + | DefinitionWithoutResponse; + +/** + * What `define()` returns. `mutate()` resolves when native has persisted the + * entry. When `V` is `null` (a `request` that takes no vars), `mutate()` takes + * no arguments. `T` is carried so a `Defined` names the response type its + * handlers see, even though `mutate()` itself does not use it. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type Defined = { + key: string; + mutate: [V] extends [null] + ? (vars?: null, options?: { id?: string }) => Promise<{ id: string }> + : (vars: V, options?: { id?: string }) => Promise<{ id: string }>; }; +/** + * `define()`. `V` infers from the `request` parameter and defaults to `null` + * when `request` declares none. `T` infers from the `response` return type. + * Without `response`, the handlers see the `RawResponse`. + */ +export interface Define { + ( + definition: DefinitionWithResponse, + ): Defined; + ( + definition: DefinitionWithoutResponse, + ): Defined; +} + /** * The text and the identity of the Android upload progress notification. Set * it one time with `configure()`. The library keeps it in native storage. Thus @@ -181,24 +265,36 @@ export type AndroidNotificationConfig = { }; export type ConfigureOptions = { + /** Default 14 days. Sets the default `expiresAt` of every entry. */ + lifetimeMs?: number; + /** Defaults: base 1 s, max 2 h, jitter 0.2, exempt [404]. */ + retry?: Partial; + /** Called at `mutate()`. The descriptor's headers merge over the result. */ + headers?: () => Record; android?: Partial; }; export interface AddListener { - ( - event: 'progress', - callback: (data: ProgressData) => void, - ): EventSubscription; - - (event: 'error', callback: (data: ErrorData) => void): EventSubscription; - - ( - event: 'completed', - callback: (data: CompletedData) => void, - ): EventSubscription; - - ( - event: 'cancelled', - callback: (data: CancelledData) => void, - ): EventSubscription; + (event: 'state', listener: (e: StateEvent) => void): EventSubscription; + (event: 'progress', listener: (e: ProgressEvent) => void): EventSubscription; + (event: 'attempt', listener: (e: AttemptEvent) => void): EventSubscription; } + +export type UploadClient = { + configure: (options: ConfigureOptions) => void; + define: Define; + pause: () => Promise; + resume: () => Promise; + cancel: (id: string) => Promise; + setWifiOnly: (enabled: boolean) => Promise; + updateHeaders: (patch: Record) => Promise; + getRequests: (filter?: { key?: string; id?: string }) => RequestRow[]; + addListener: AddListener; + chunkPlan: ( + sizeBytes: number, + opts?: { min?: number; max?: number }, + ) => Array<{ start: number; end: number }>; + android: { + addNotificationListener: (listener: () => void) => EventSubscription; + }; +}; From 4250d16392843321b096beb69d42a89546d132d6 Mon Sep 17 00:00:00 2001 From: Dylan Murphy Date: Wed, 9 Sep 2026 10:43:16 -0400 Subject: [PATCH 2/2] Address slice 1 review: per-id delivery order, cancelled ack, validation 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 --- CHANGELOG.md | 9 +-- README.md | 2 +- src/__tests__/client.test.ts | 43 +++++++++++++ src/__tests__/delivery.test.ts | 108 ++++++++++++++++++++++++++++++++ src/__tests__/registry.test.ts | 90 ++++++++++++++++++++++++++- src/delivery.ts | 90 ++++++++++++++++++++------- src/index.ts | 15 ++++- src/registry.ts | 109 +++++++++++++++++++++++++++++++-- src/types.ts | 5 +- 9 files changed, 434 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7703760a..e61f4513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,8 +50,9 @@ Added: arguments. - **Request bodies**: JSON (`data`), multipart (`form`), whole file (`file`), and chunked (`file` + `parts`). All under one entry shape and one id. -- **Delivery rules**: dedupe by event id; an outcome for an id waits for that - id's in-flight `mutate()`; an outcome whose key has no definition stays +- **Delivery rules**: dedupe by event id; the outcomes of one id deliver in + order, one handler at a time; an outcome for an id waits for that id's + in-flight `mutate()`; an outcome whose key has no definition stays unacknowledged and reaches `state` listeners with `reason: 'unhandled-key'`; a handler that has not settled after 30 s logs a warning. - **`pause()` / `resume()`** for the whole queue, **`updateHeaders(patch)`** to @@ -65,8 +66,8 @@ Removed: names, with their `ProgressData`, `CompletedData`, `ErrorData`, `CancelledData`, `EventData`, `TerminalEventData`, `JournaledEvent`, `UploadSnapshot`, `UploadOptions`, `ChunkedUploadOptions`, - `StartUploadOptions`, `AndroidOnlyUploadOptions`, and `RawUploadOptions` - types. + `StartUploadOptions`, `AndroidOnlyUploadOptions`, `RawUploadOptions`, and + `UploadId` types. ## 9.0.0 diff --git a/README.md b/README.md index cae49820..ccd7e4a3 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ TypeScript does not flag a misspelled key on an inferred arrow return. | --- | --- | | `url` | Required unless `parts` is set. | | `method` | `POST` (default), `PUT`, `PATCH`, `DELETE`, `GET`. With `parts` it applies to every part. | -| `headers` | Merged over `configure().headers()`. Every chunked part inherits the result. | +| `headers` | Merged over `configure().headers()`, names matched without regard to case. Every chunked part inherits the result. | | `data` | JSON body. | | `form` | `multipart/form-data`: `[{ name, contentType, string }]` or `[{ name, contentType, path, fileName? }]`. File parts are copied. | | `file` | Whole file body. Copied. Moved when `parts` is set. | diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index d5e828f8..22954cf8 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -400,6 +400,49 @@ describe('addListener', () => { warn.mockRestore(); }); + it('keeps the other state listeners and later deliveries when one listener throws', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createUploadClient(); + const second = jest.fn(); + const onSuccess = jest.fn(); + client.define({ + key: 'known', + request: (_v: null) => ({ url: 'https://x', data: 1 }), + onSuccess, + }); + client.addListener('state', () => { + throw new Error('listener down'); + }); + client.addListener('state', second); + client.configure({}); + await flush(); + const settled = (eventId: string, key: string) => ({ + eventId, + id: 'x', + key, + vars: null, + at: 5, + attempts: 1, + kind: 'completed', + response: { bodyTruncated: false }, + state: 'completed', + }); + fire('settled', settled('e1', 'nobody')); + await flush(); + expect(second).toHaveBeenCalledWith( + expect.objectContaining({ id: 'x', reason: 'unhandled-key' }), + ); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/state listener threw/), + expect.any(Error), + ); + fire('settled', settled('e2', 'known')); + await flush(); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['e2']); + warn.mockRestore(); + }); + it('rejects an unknown event name', () => { const client = createUploadClient(); expect(() => (client.addListener as any)('completed', jest.fn())).toThrow( diff --git a/src/__tests__/delivery.test.ts b/src/__tests__/delivery.test.ts index c880b16c..c3b0ab4c 100644 --- a/src/__tests__/delivery.test.ts +++ b/src/__tests__/delivery.test.ts @@ -264,7 +264,115 @@ describe('ordering against mutate()', () => { }); }); +describe('ordering per id', () => { + it('runs the outcomes of one id one at a time, in order, without blocking another id', async () => { + const gate = deferred(); + const started: string[] = []; + const { start, native } = setup( + { + k: { + key: 'k', + request: jest.fn(), + onSuccess: ( + _d: unknown, + _v: unknown, + meta: { id: string; at: number }, + ) => { + started.push(`${meta.id}@${meta.at}`); + return meta.at === 1 ? gate.promise : undefined; + }, + }, + }, + [ + completed({ eventId: 'a', id: 'u1', at: 1 }), + completed({ eventId: 'b', id: 'u1', at: 2 }), + completed({ eventId: 'c', id: 'u2', at: 3 }), + ], + ); + start(); + await flush(); + expect(started).toEqual(['u1@1', 'u2@3']); + expect(native.ackEvents).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['c']); + gate.resolve(); + await flush(); + expect(started).toEqual(['u1@1', 'u2@3', 'u1@2']); + expect(native.ackEvents).toHaveBeenCalledWith(['a']); + expect(native.ackEvents).toHaveBeenCalledWith(['b']); + }); + + it('still delivers the next outcome of an id after the previous handler rejected', async () => { + const onSuccess = jest.fn(); + const { start, native } = setup( + { + k: { + key: 'k', + request: jest.fn(), + onSuccess: (_d: unknown, _v: unknown, meta: { at: number }) => { + onSuccess(meta.at); + if (meta.at === 1) { + throw new Error('first down'); + } + }, + }, + }, + [completed({ eventId: 'a', at: 1 }), completed({ eventId: 'b', at: 2 })], + ); + start(); + await flush(); + expect(onSuccess.mock.calls).toEqual([[1], [2]]); + expect(native.ackEvents).toHaveBeenCalledTimes(1); + expect(native.ackEvents).toHaveBeenCalledWith(['b']); + }); +}); + +describe('malformed event', () => { + it('drops a v9-shaped entry, warns once for its eventId, and neither acks nor emits', async () => { + const onSuccess = jest.fn(); + const { start, emit, native, warn, stateEvents } = setup({ + k: { key: 'k', request: jest.fn(), onSuccess }, + }); + start(); + await flush(); + const legacy = { + eventId: 'v9-1', + id: 'u1', + type: 'completed', + timestamp: 5, + } as unknown as SettledEvent; + emit(legacy); + emit(legacy); + await flush(); + expect(onSuccess).not.toHaveBeenCalled(); + expect(native.ackEvents).not.toHaveBeenCalled(); + expect(stateEvents).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/malformed settled event v9-1/), + legacy, + ); + }); +}); + describe('unknown key', () => { + it('acks a cancelled outcome whose key has no definition and emits no row', async () => { + const { start, emit, native, stateEvents } = setup({}); + start(); + await flush(); + emit( + completed({ + key: 'gone', + kind: 'cancelled', + state: 'cancelled', + response: undefined, + cancelReason: 'user', + }), + ); + await flush(); + expect(native.ackEvents).toHaveBeenCalledWith(['e1']); + expect(stateEvents).toEqual([]); + }); + it('emits an unhandled-key state row and does not ack', async () => { const { start, emit, native, stateEvents, warn } = setup({}); start(); diff --git a/src/__tests__/registry.test.ts b/src/__tests__/registry.test.ts index 594283f7..dd1d7a16 100644 --- a/src/__tests__/registry.test.ts +++ b/src/__tests__/registry.test.ts @@ -104,12 +104,12 @@ describe('mutate', () => { expect(lastEntry()).toMatchObject({ id: 'fixed', vars: null }); }); - it('resolves with the id native returns', async () => { + it('resolves with the entry id, not the id native returns', async () => { const { define, enqueue } = setup(); enqueue.mockResolvedValueOnce('native-id'); const create = define({ key: 'k', request: jsonPost }); await expect(create.mutate({ n: 1 }, { id: 'mine' })).resolves.toEqual({ - id: 'native-id', + id: 'mine', }); }); @@ -311,6 +311,76 @@ describe('mutate', () => { ).rejects.toThrow(/unknown form\[0\] field "filename".*"fileName"/); }); + it('rejects an unknown field inside retry, accept, android or a part range', async () => { + const base = { url: 'https://x', data: {} }; + await expect( + mutateWith({ ...base, retry: { terminalHTTP: {} } }).promise, + ).rejects.toThrow(/unknown retry field "terminalHTTP".*"terminalHttp"/); + await expect( + mutateWith({ ...base, retry: { backoff: { base: 1 } } }).promise, + ).rejects.toThrow(/unknown retry\.backoff field "base".*"baseMs"/); + await expect( + mutateWith({ ...base, retry: { terminalHttp: { exmpt: [] } } }).promise, + ).rejects.toThrow(/unknown retry\.terminalHttp field "exmpt".*"exempt"/); + await expect( + mutateWith({ ...base, accept: [{ statsu: 409 }] }).promise, + ).rejects.toThrow(/unknown accept\[0\] field "statsu".*"status"/); + await expect( + mutateWith({ ...base, android: { noNotifications: true } }).promise, + ).rejects.toThrow(/unknown android field "noNotifications"/); + await expect( + mutateWith({ + file: '/f', + parts: [{ url: 'https://p', range: { start: 0, end: 1, length: 1 } }], + }).promise, + ).rejects.toThrow(/unknown parts\[0\]\.range field "length"/); + }); + + it('rejects the wrong value shape inside retry, accept and android', async () => { + const base = { url: 'https://x', data: {} }; + await expect(mutateWith({ ...base, accept: {} }).promise).rejects.toThrow( + /accept must be an array/, + ); + await expect( + mutateWith({ ...base, accept: [{ status: '409' }] }).promise, + ).rejects.toThrow(/accept\[0\]\.status must be a number/); + await expect( + mutateWith({ ...base, accept: [{ status: 409, bodyIncludes: 5 }] }) + .promise, + ).rejects.toThrow(/accept\[0\]\.bodyIncludes must be a string/); + await expect(mutateWith({ ...base, retry: [] }).promise).rejects.toThrow( + /retry must be a plain object/, + ); + await expect( + mutateWith({ ...base, retry: { terminalHttp: { exempt: [404, 'x'] } } }) + .promise, + ).rejects.toThrow( + /retry\.terminalHttp\.exempt must be an array of numbers/, + ); + await expect( + mutateWith({ ...base, android: { noNotification: 'yes' } }).promise, + ).rejects.toThrow(/android\.noNotification must be a boolean/); + }); + + it('accepts valid retry, accept and android shapes', async () => { + await expect( + mutateWith({ + url: 'https://x', + data: {}, + retry: { + backoff: { baseMs: 1000, maxMs: 60_000, jitter: 0.2 }, + terminalHttp: { exempt: [] }, + }, + accept: [{ status: 409 }, { status: 400, bodyIncludes: 'dup' }], + android: { noNotification: true }, + }).promise, + ).resolves.toBeDefined(); + await expect( + mutateWith({ url: 'https://x', data: {}, retry: {}, accept: [] }) + .promise, + ).resolves.toBeDefined(); + }); + it('never reaches native on a rejected descriptor', async () => { const { promise, enqueue } = mutateWith({ data: {} }); await expect(promise).rejects.toThrow(); @@ -415,6 +485,22 @@ describe('mutate', () => { }); }); + it('matches header names without regard to case and keeps the descriptor spelling', async () => { + const { define, lastEntry } = setup({ + headers: () => ({ Authorization: 'a' }), + }); + const send = define({ + key: 'k', + request: (_vars: null) => ({ + url: 'https://x', + data: {}, + headers: { authorization: 'b' }, + }), + }); + await send.mutate(null); + expect(lastEntry().descriptor.headers).toEqual({ authorization: 'b' }); + }); + it('sends the provider headers alone when the descriptor has none', async () => { const { define, lastEntry } = setup({ headers: () => ({ A: '1' }) }); const send = define({ diff --git a/src/delivery.ts b/src/delivery.ts index cdc12493..38b56d25 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -54,8 +54,9 @@ const errorMessage = (e: unknown): string => /** * Routes settled outcomes to the definitions' handlers and acknowledges them * afterwards. Rules: journal before emit is native's job; here it is dedupe by - * eventId, wait for the id's in-flight mutate(), look up the key, run the - * handler, ack after its promise resolves. An unknown key or a rejected + * eventId, drop a malformed event, run one id's outcomes in order, wait for + * the id's in-flight mutate(), ack a cancelled outcome, look up the key, run + * the handler, ack after its promise resolves. An unknown key or a rejected * handler leaves the outcome unacknowledged, so native redelivers it at the * next launch. */ @@ -68,6 +69,9 @@ export const createDelivery = ({ }: DeliveryDeps): Delivery => { const seen = new Set(); const pendingMutates = new Map>(); + // The tail of each id's delivery chain. Outcomes of one id run in order, + // one handler at a time. Different ids run concurrently. + const lanes = new Map>(); let subscription: EventSubscription | undefined; // Live events that arrive while the journal drains wait here, so replayed // outcomes deliver first. @@ -193,24 +197,43 @@ export const createDelivery = ({ } }; - const deliver = async (event: SettledEvent): Promise => { - if (typeof event?.eventId !== 'string') { - warn('delivery: dropped a settled event without an eventId', event); + /** Runs `task` after the previous delivery for `id` has settled. */ + const enqueueForId = (id: string, task: () => Promise): void => { + const prior = lanes.get(id) ?? Promise.resolve(); + const next = prior.then(task).catch((e) => { + warn(`delivery: unexpected failure while delivering for ${id}`, e); + }); + lanes.set(id, next); + void next.then(() => { + if (lanes.get(id) === next) { + lanes.delete(id); + } + }); + }; + + /** Delivery for `id` waits until the caller of mutate() has the id. */ + const waitForMutate = async (id: string): Promise => { + const pending = pendingMutates.get(id); + if (!pending) { return; } - if (seen.has(event.eventId)) { + await pending; + // The enqueue promise settles before mutate()'s own await and before + // the caller's continuation, both microtasks. A macrotask puts the + // handler after them, so the caller has the id before any handler + // sees it. + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + /** The outcome's own steps, run inside its id's lane. */ + const route = async (event: SettledEvent): Promise => { + await waitForMutate(event.id); + // A cancelled outcome has no handler, so it acks whether or not the key + // is still defined. + if (event.kind === 'cancelled') { + await ack(event.eventId); return; } - seen.add(event.eventId); - const pending = pendingMutates.get(event.id); - if (pending) { - await pending; - // The enqueue promise settles before mutate()'s own await and before - // the caller's continuation, both microtasks. A macrotask puts the - // handler after them, so the caller has the id before any handler - // sees it. - await new Promise((resolve) => setTimeout(resolve, 0)); - } const definition = lookup(event.key); if (!definition) { warn( @@ -219,13 +242,36 @@ export const createDelivery = ({ emitState(unhandledRow(event)); return; } - if (event.kind === 'cancelled') { + if (await runHandler(event, definition)) { await ack(event.eventId); + } + }; + + const isWellFormed = (event: SettledEvent): boolean => + typeof event.key === 'string' && + typeof event.kind === 'string' && + typeof event.id === 'string'; + + const deliver = (event: SettledEvent): void => { + if (typeof event?.eventId !== 'string') { + warn('delivery: dropped a settled event without an eventId', event); return; } - if (await runHandler(event, definition)) { - await ack(event.eventId); + if (seen.has(event.eventId)) { + return; + } + seen.add(event.eventId); + // A journal written by an older native build can carry another shape. + // Such an entry is neither acked nor routed. The seen set keeps the + // warning to one per eventId. + if (!isWellFormed(event)) { + warn( + `delivery: dropped a malformed settled event ${event.eventId}. It has no string key, kind and id.`, + event, + ); + return; } + enqueueForId(event.id, () => route(event)); }; const start = (): void => { @@ -237,7 +283,7 @@ export const createDelivery = ({ subscription = native.onSettled((raw) => { const event = raw as SettledEvent; if (live) { - void deliver(event); + deliver(event); } else { buffer.push(event); } @@ -246,13 +292,13 @@ export const createDelivery = ({ .getUnacknowledgedEvents() .then( (events) => { - (events as SettledEvent[]).forEach((event) => void deliver(event)); + (events as SettledEvent[]).forEach(deliver); }, (e) => warn('delivery: getUnacknowledgedEvents failed', e), ) .then(() => { live = true; - buffer.splice(0).forEach((event) => void deliver(event)); + buffer.splice(0).forEach(deliver); }); }; diff --git a/src/index.ts b/src/index.ts index 47df09f2..1881edf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,11 +36,22 @@ export const createUploadClient = (): UploadClient => { // registered twice is removed one subscription at a time. const stateListeners = new Set<{ listener: (event: StateEvent) => void }>(); + // A listener that throws must not stop the others or fail the delivery + // that produced the row. + const emitState = (event: StateEvent): void => { + stateListeners.forEach(({ listener }) => { + try { + listener(event); + } catch (e) { + console.warn('addListener: a state listener threw', e); + } + }); + }; + const delivery = createDelivery({ native, lookup: (key) => definitions.get(key), - emitState: (event) => - stateListeners.forEach(({ listener }) => listener(event)), + emitState, }); const { define } = createRegistry({ native, diff --git a/src/registry.ts b/src/registry.ts index 03843c4a..91d4a204 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -31,7 +31,13 @@ const DESCRIPTOR_KEYS = [ 'android', ]; const PART_KEYS = ['url', 'headers', 'range']; +const RANGE_KEYS = ['start', 'end']; const FORM_PART_KEYS = ['name', 'contentType', 'string', 'path', 'fileName']; +const RETRY_KEYS = ['backoff', 'terminalHttp']; +const BACKOFF_KEYS = ['baseMs', 'maxMs', 'jitter']; +const TERMINAL_HTTP_KEYS = ['exempt']; +const ACCEPT_RULE_KEYS = ['status', 'bodyIncludes']; +const ANDROID_KEYS = ['noNotification']; /** The JS-side settings that `configure()` stores. */ export type Settings = { @@ -174,8 +180,11 @@ const validateParts = (parts: unknown): void => { `mutate: parts[${i}].headers must be a plain object when present`, ); } + if (!isPlainObject(range)) { + throw new Error(`mutate: parts[${i}].range must be an object`); + } + rejectUnknownKeys(range, RANGE_KEYS, `parts[${i}].range`); if ( - !range || !Number.isInteger(range.start) || !Number.isInteger(range.end) || range.start < 0 || @@ -227,9 +236,86 @@ const validateForm = (form: unknown): void => { }); }; +const requireObject = ( + value: unknown, + where: string, +): Record => { + if (!isPlainObject(value)) { + throw new Error(`mutate: ${where} must be a plain object`); + } + return value; +}; + +const validateRetry = (retry: unknown): void => { + const r = requireObject(retry, 'retry'); + rejectUnknownKeys(r, RETRY_KEYS, 'retry'); + if (r.backoff !== undefined) { + const backoff = requireObject(r.backoff, 'retry.backoff'); + rejectUnknownKeys(backoff, BACKOFF_KEYS, 'retry.backoff'); + } + if (r.terminalHttp !== undefined) { + const terminal = requireObject(r.terminalHttp, 'retry.terminalHttp'); + rejectUnknownKeys(terminal, TERMINAL_HTTP_KEYS, 'retry.terminalHttp'); + const { exempt } = terminal; + if ( + !Array.isArray(exempt) || + !exempt.every((status) => typeof status === 'number') + ) { + throw new Error( + 'mutate: retry.terminalHttp.exempt must be an array of numbers', + ); + } + } +}; + +const validateAccept = (accept: unknown): void => { + if (!Array.isArray(accept)) { + throw new Error('mutate: accept must be an array'); + } + accept.forEach((rule, i) => { + const r = requireObject(rule, `accept[${i}]`); + rejectUnknownKeys(r, ACCEPT_RULE_KEYS, `accept[${i}]`); + if (typeof r.status !== 'number') { + throw new Error(`mutate: accept[${i}].status must be a number`); + } + if (r.bodyIncludes !== undefined && typeof r.bodyIncludes !== 'string') { + throw new Error(`mutate: accept[${i}].bodyIncludes must be a string`); + } + }); +}; + +const validateAndroid = (android: unknown): void => { + const a = requireObject(android, 'android'); + rejectUnknownKeys(a, ANDROID_KEYS, 'android'); + if (a.noNotification !== undefined && typeof a.noNotification !== 'boolean') { + throw new Error('mutate: android.noNotification must be a boolean'); + } +}; + +/** + * The descriptor's headers over the provider's. Names match without regard + * to case, and the descriptor's spelling is the one kept. + */ +const mergeHeaders = ( + provided: Record, + own: Record = {}, +): Record => { + const overridden = new Set( + Object.keys(own).map((name) => name.toLowerCase()), + ); + const merged: Record = {}; + Object.entries(provided).forEach(([name, value]) => { + if (!overridden.has(name.toLowerCase())) { + merged[name] = value; + } + }); + return { ...merged, ...own }; +}; + /** * Rejects a malformed descriptor before it crosses the bridge. Then native - * never persists an entry that cannot run. + * never persists an entry that cannot run. Nested objects are checked for + * unknown keys too, so a misspelled field cannot be dropped in silence. */ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { if (!isPlainObject(descriptor)) { @@ -276,6 +362,15 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { if (d.parts !== undefined) { validateParts(d.parts); } + if (d.retry !== undefined) { + validateRetry(d.retry); + } + if (d.accept !== undefined) { + validateAccept(d.accept); + } + if (d.android !== undefined) { + validateAndroid(d.android); + } if ( d.expiresAt !== undefined && (!Number.isFinite(d.expiresAt) || d.expiresAt <= 0) @@ -290,7 +385,8 @@ export const validateDescriptor = (descriptor: unknown): RequestDescriptor => { /** * Holds the definitions of one client and builds `define()`. Every `mutate()` * validates `vars` and the descriptor, merges the configured headers under the - * descriptor's, defaults `expiresAt`, and hands the entry to native. + * descriptor's (names matched without regard to case), defaults `expiresAt`, + * hands the entry to native, and resolves with the entry's own id. */ export const createRegistry = ({ native, @@ -355,13 +451,16 @@ export const createRegistry = ({ vars, descriptor: { ...descriptor, - headers: { ...provided, ...descriptor.headers }, + headers: mergeHeaders(provided, descriptor.headers), expiresAt: descriptor.expiresAt ?? now() + settings.lifetimeMs, }, }; const pending = native.enqueue(entry); trackMutate(entry.id, pending); - return { id: await pending }; + // The JS id is the one the caller may have chosen and the one the + // entry carries. Native's return value is not trusted for it. + await pending; + return { id: entry.id }; }; return { key, mutate } as Defined; diff --git a/src/types.ts b/src/types.ts index deba6bcf..5ec1cf53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,7 +73,10 @@ export type RequestDescriptor = { url?: string; /** Default POST. With `parts` it applies to every part. */ method?: Method; - /** Merged over `configure().headers()`. Every part inherits the result. */ + /** + * Merged over `configure().headers()`, with names matched without regard + * to case. Every part inherits the result. + */ headers?: Record; /** JSON body. Exactly one of `data`, `form`, `file` must be set. */ data?: Json;