@omelhorsite/sdk 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +112 -130
  2. package/dist/index.js +4800 -610
  3. package/dist/types/client.d.ts +60 -3
  4. package/dist/types/http.d.ts +444 -19
  5. package/dist/types/index.d.ts +5 -1
  6. package/dist/types/internal/attachments.d.ts +17 -0
  7. package/dist/types/internal/helpers.d.ts +9 -0
  8. package/dist/types/listing.d.ts +90 -0
  9. package/dist/types/resources/account.d.ts +71 -5
  10. package/dist/types/resources/admin/authorizedApplications.d.ts +133 -0
  11. package/dist/types/resources/admin/chests.d.ts +51 -0
  12. package/dist/types/resources/admin/eventAlerts.d.ts +60 -0
  13. package/dist/types/resources/admin/identities.d.ts +99 -0
  14. package/dist/types/resources/admin/index.d.ts +77 -0
  15. package/dist/types/resources/admin/jobs.d.ts +97 -0
  16. package/dist/types/resources/admin/myOauthApplications.d.ts +282 -0
  17. package/dist/types/resources/admin/notepads.d.ts +49 -0
  18. package/dist/types/resources/admin/oauthApplications.d.ts +309 -0
  19. package/dist/types/resources/admin/quotas.d.ts +114 -0
  20. package/dist/types/resources/admin/shortLinks.d.ts +201 -0
  21. package/dist/types/resources/admin/types.d.ts +222 -0
  22. package/dist/types/resources/admin/vocalSeparations.d.ts +98 -0
  23. package/dist/types/resources/admin.d.ts +1841 -0
  24. package/dist/types/resources/auth/index.d.ts +39 -0
  25. package/dist/types/resources/auth/passkeys.d.ts +652 -0
  26. package/dist/types/resources/auth/sessions.d.ts +844 -0
  27. package/dist/types/resources/chests.d.ts +54 -3
  28. package/dist/types/resources/content/analysis.d.ts +62 -0
  29. package/dist/types/resources/content/blogs.d.ts +471 -0
  30. package/dist/types/resources/content/feedbacks.d.ts +263 -0
  31. package/dist/types/resources/content/index.d.ts +52 -0
  32. package/dist/types/resources/content/intel/articles.d.ts +230 -0
  33. package/dist/types/resources/content/intel/config.d.ts +135 -0
  34. package/dist/types/resources/content/intel/index.d.ts +53 -0
  35. package/dist/types/resources/content/intel/items.d.ts +91 -0
  36. package/dist/types/resources/content/intel/reports.d.ts +108 -0
  37. package/dist/types/resources/content/intel/scripts.d.ts +152 -0
  38. package/dist/types/resources/content/intel/sources.d.ts +236 -0
  39. package/dist/types/resources/content/intel/stats.d.ts +105 -0
  40. package/dist/types/resources/content/intel/types.d.ts +86 -0
  41. package/dist/types/resources/content/jokes.d.ts +93 -0
  42. package/dist/types/resources/content/notifications.d.ts +147 -0
  43. package/dist/types/resources/content/serviceUsages.d.ts +73 -0
  44. package/dist/types/resources/content/servicesStatus.d.ts +194 -0
  45. package/dist/types/resources/content/siteConfig.d.ts +43 -0
  46. package/dist/types/resources/content/spaceInvaders.d.ts +154 -0
  47. package/dist/types/resources/content.d.ts +2775 -0
  48. package/dist/types/resources/dynamicQrs.d.ts +39 -3
  49. package/dist/types/resources/forms.d.ts +176 -35
  50. package/dist/types/resources/index.d.ts +19 -8
  51. package/dist/types/resources/ipLookup.d.ts +20 -4
  52. package/dist/types/resources/jobs.d.ts +69 -25
  53. package/dist/types/resources/library/annotations.d.ts +162 -0
  54. package/dist/types/resources/library/books.d.ts +534 -0
  55. package/dist/types/resources/library/chat.d.ts +259 -0
  56. package/dist/types/resources/library/index.d.ts +39 -0
  57. package/dist/types/resources/library/shelves.d.ts +237 -0
  58. package/dist/types/resources/library/types.d.ts +120 -0
  59. package/dist/types/resources/library.d.ts +1433 -0
  60. package/dist/types/resources/linkTrees.d.ts +142 -30
  61. package/dist/types/resources/media.d.ts +351 -0
  62. package/dist/types/resources/movies/addons.d.ts +388 -0
  63. package/dist/types/resources/movies/collections.d.ts +352 -0
  64. package/dist/types/resources/movies/index.d.ts +29 -0
  65. package/dist/types/resources/movies/types.d.ts +50 -0
  66. package/dist/types/resources/movies/watchProgress.d.ts +277 -0
  67. package/dist/types/resources/movies.d.ts +1160 -0
  68. package/dist/types/resources/music/artists.d.ts +1068 -0
  69. package/dist/types/resources/music/imports.d.ts +941 -0
  70. package/dist/types/resources/music/index.d.ts +61 -0
  71. package/dist/types/resources/music/playlists.d.ts +1027 -0
  72. package/dist/types/resources/music/social.d.ts +1132 -0
  73. package/dist/types/resources/music/songs.d.ts +1167 -0
  74. package/dist/types/resources/notepads.d.ts +4 -1
  75. package/dist/types/resources/quotas.d.ts +7 -1
  76. package/dist/types/resources/realtime.d.ts +855 -0
  77. package/dist/types/resources/shortLinks.d.ts +49 -5
  78. package/dist/types/resources/social/groupChats.d.ts +458 -0
  79. package/dist/types/resources/social/index.d.ts +19 -0
  80. package/dist/types/resources/social/messages.d.ts +424 -0
  81. package/dist/types/resources/social/relationships.d.ts +322 -0
  82. package/dist/types/resources/social/types.d.ts +37 -0
  83. package/dist/types/resources/social.d.ts +1348 -0
  84. package/dist/types/resources/storage/upload.d.ts +158 -11
  85. package/dist/types/resources/storage.d.ts +111 -27
  86. package/dist/types/resources/tickets.d.ts +101 -13
  87. package/dist/types/resources/tools/backgroundRemoval.d.ts +18 -3
  88. package/dist/types/resources/tools/captions.d.ts +448 -21
  89. package/dist/types/resources/tools/downloader.d.ts +21 -0
  90. package/dist/types/resources/tools/index.d.ts +57 -15
  91. package/dist/types/resources/tools/jumpstyle.d.ts +50 -17
  92. package/dist/types/resources/tools/transcription.d.ts +35 -13
  93. package/dist/types/resources/tools/upscale.d.ts +23 -3
  94. package/dist/types/resources/tools/vocalSeparation.d.ts +30 -13
  95. package/dist/types/types.d.ts +249 -17
  96. package/package.json +2 -1
@@ -14,15 +14,25 @@ import type { TokenSet } from "./auth/tokens";
14
14
  import { ApiClient, type TokenProvider } from "./http";
15
15
  import { type LocalNamespace } from "./local/index";
16
16
  import { AccountNamespace } from "./resources/account";
17
+ import { AdminNamespace } from "./resources/admin";
18
+ import { PasskeysNamespace } from "./resources/auth/passkeys";
19
+ import { AuthSessionsNamespace } from "./resources/auth/sessions";
17
20
  import { ChestsNamespace } from "./resources/chests";
21
+ import { ContentNamespace } from "./resources/content";
18
22
  import { DynamicQrsNamespace } from "./resources/dynamicQrs";
19
23
  import { FormsNamespace } from "./resources/forms";
20
24
  import { IpLookupNamespace } from "./resources/ipLookup";
21
25
  import { JobsNamespace } from "./resources/jobs";
26
+ import { LibraryNamespace } from "./resources/library";
22
27
  import { LinkTreesNamespace } from "./resources/linkTrees";
28
+ import { MediaNamespace } from "./resources/media";
29
+ import { MoviesNamespace } from "./resources/movies";
30
+ import { MusicNamespace } from "./resources/music/index";
23
31
  import { NotepadsNamespace } from "./resources/notepads";
24
32
  import { QuotasNamespace } from "./resources/quotas";
33
+ import { RealtimeNamespace } from "./resources/realtime";
25
34
  import { ShortLinksNamespace } from "./resources/shortLinks";
35
+ import { SocialNamespace } from "./resources/social";
26
36
  import { StorageNamespace } from "./resources/storage";
27
37
  import { TicketsNamespace } from "./resources/tickets";
28
38
  import { ToolsNamespace } from "./resources/tools/index";
@@ -87,10 +97,32 @@ export interface OmsOptions {
87
97
  * const oms = new Oms({ token: "..." });
88
98
  * const me = await oms.account.me();
89
99
  * const link = await oms.shortLinks.create({ url: "https://example.com" });
100
+ * const songs = await oms.music.songs.list({ artist: "Nina Simone" });
101
+ * const cable = oms.realtime.connect({ token, socket: (u) => new WebSocket(u) });
90
102
  * ```
91
103
  *
92
- * Isolate-safe: no `node:*`, no environment, no stdout. A file is always a
93
- * value (`Blob` / `Uint8Array` / `ReadableStream`), never a path.
104
+ * ## Two credentials, two namespaces
105
+ *
106
+ * `oms.sessions` mints the opaque `Session` token the website and the mobile
107
+ * apps use, which carries the whole account. `oms.auth` runs OAuth, whose
108
+ * tokens are scoped and, because `enforce_oauth_scope!` denies by omission,
109
+ * cannot reach most of the API at all. `oms.realtime` accepts only the first
110
+ * kind. Picking the wrong one shows up as a `403 insufficient_scope`, or on the
111
+ * cable as a connection that is silently anonymous.
112
+ *
113
+ * ## Isolate-safe
114
+ *
115
+ * No `node:*`, no environment, no stdout, and `fetch` is injectable. A file is
116
+ * normally a VALUE (`Blob` / `Uint8Array` / `ReadableStream`), never a path,
117
+ * because turning a path into bytes is the host's job.
118
+ *
119
+ * The one exception is React Native, which cannot produce a usable `Blob` from
120
+ * a picked file: there, pass the picker's `{ uri, name, type }`
121
+ * ({@link NativeFile}) straight into a form bag and the transport appends it
122
+ * verbatim for RN's networking layer to stream off disk. That works on
123
+ * multipart endpoints only - the storage direct-upload path has to read and MD5
124
+ * the bytes, so it rejects a `NativeFile` with a message saying so rather than
125
+ * failing at the object store.
94
126
  */
95
127
  export declare class Oms {
96
128
  /**
@@ -99,8 +131,12 @@ export declare class Oms {
99
131
  * the SDK to add one call.
100
132
  */
101
133
  readonly http: ApiClient;
102
- /** Signing in, refreshing, revoking, and the RFC 8628 device grant. */
134
+ /** OAuth: refreshing, revoking, the RFC 8628 device grant, OIDC discovery. */
103
135
  readonly auth: AuthNamespace;
136
+ /** Password sign-in, sign-up, and the emailed six-digit ceremonies. */
137
+ readonly sessions: AuthSessionsNamespace;
138
+ /** WebAuthn credentials: register one, sign in with one, list, revoke. */
139
+ readonly passkeys: PasskeysNamespace;
104
140
  /** The signed-in user, their profile, and their usage report. */
105
141
  readonly account: AccountNamespace;
106
142
  /** Support tickets and their message threads. */
@@ -127,6 +163,27 @@ export declare class Oms {
127
163
  readonly quotas: QuotasNamespace;
128
164
  /** The metered media tools, each with its own daily quota. */
129
165
  readonly tools: ToolsNamespace;
166
+ /** Songs, artists, playlists, imports, jams and the music assistant. */
167
+ readonly music: MusicNamespace;
168
+ /** The canonical bytes of the music library, by media id. */
169
+ readonly media: MediaNamespace;
170
+ /** Direct messages, friendships and blocks, group chats. */
171
+ readonly social: SocialNamespace;
172
+ /** The book library: uploads, shelves, annotations, the study assistant. */
173
+ readonly library: LibraryNamespace;
174
+ /** Stremio addons, collections and watch progress. Session credential only. */
175
+ readonly movies: MoviesNamespace;
176
+ /** Blogs, notifications, feedback, jokes, site config and the status page. */
177
+ readonly content: ContentNamespace;
178
+ /** OAuth client registration for anyone, plus the `/admin/*` routes. */
179
+ readonly admin: AdminNamespace;
180
+ /**
181
+ * The ActionCable connection: playback handoff, jams, notifications, job
182
+ * progress. Opens nothing until {@link RealtimeNamespace.connect} is called,
183
+ * and wants a `Session` token rather than the client's own credential - see
184
+ * that method for why.
185
+ */
186
+ readonly realtime: RealtimeNamespace;
130
187
  /**
131
188
  * Pure client-side helpers that touch no network and need no credential
132
189
  * (password generation, QR encoding). Also exported standalone as `local`,
@@ -5,10 +5,122 @@
5
5
  * calls `fetch`, `AbortController`, `FormData`, `Blob` and `setTimeout` only -
6
6
  * all of which a Cloudflare Worker provides. `fetch` itself is injected, so a
7
7
  * host can wrap it (proxy, cache, test double) without patching a global.
8
+ *
9
+ * ## Three runtimes, and where they stop agreeing
10
+ *
11
+ * The same transport runs in a browser (the Next.js frontend), in React Native
12
+ * (the Expo app) and in Bun (the CLI and the MCP server). They agree on
13
+ * `fetch`, `AbortController`, `Headers`, `FormData` and `setTimeout`. They do
14
+ * NOT agree on three things, each of which has a named capability check here
15
+ * rather than a `try` and a shrug:
16
+ *
17
+ * | capability | browser | React Native | Worker / Bun |
18
+ * | --------------------------------- | ------- | ------------ | ------------ |
19
+ * | `supportsResponseStreaming()` | yes | **no** | yes |
20
+ * | `supportsUploadProgress()` (XHR) | yes | yes | **no** |
21
+ * | `supportsNativeFormDataFiles()` | no | **yes** | no |
22
+ *
23
+ * Read them at the point of use, not once at module load: a host may install a
24
+ * polyfill after the SDK is imported, and in RN the fetch implementation is
25
+ * swapped by libraries often enough that a cached answer goes stale.
8
26
  */
9
- import { type FetchLike, type FileInput, type FileOutput, type QueryParams, type RequestOptions, type ResolvedRetry, type RetryOptions } from "./types";
27
+ import { type FetchLike, type FileInput, type FileOutput, type NativeFile, type QueryParams, type RequestOptions, type ResolvedRetry, type RetryOptions } from "./types";
10
28
  /** Production API root. Override only for a local backend or a test double. */
11
29
  export declare const DEFAULT_BASE_URL = "https://backend.omelhorsite.pt";
30
+ /**
31
+ * What the runtime this code woke up in can actually do.
32
+ *
33
+ * Answered by probing the globals, never by sniffing a platform name, and
34
+ * recomputed on every call so a polyfill installed after import is seen. See
35
+ * the table in the module note for how the three clients score.
36
+ */
37
+ export interface TransportCapabilities {
38
+ /** `fetch` hands back a readable body that can be consumed as it arrives. */
39
+ readonly responseStreaming: boolean;
40
+ /** `XMLHttpRequest` is present, so byte-level upload progress is reachable. */
41
+ readonly uploadProgress: boolean;
42
+ /** `FormData` is React Native's, so a {@link NativeFile} can be appended. */
43
+ readonly nativeFormDataFiles: boolean;
44
+ }
45
+ /** All three capability probes at once. */
46
+ export declare function transportCapabilities(): TransportCapabilities;
47
+ /**
48
+ * Whether a response body can be read INCREMENTALLY on this runtime.
49
+ *
50
+ * This is the question a caller has to answer before it offers a token-by-token
51
+ * UI, and it has a hard "no" on React Native. RN's `fetch` is its
52
+ * `XMLHttpRequest` with a whatwg-fetch shim over it: the whole body is
53
+ * accumulated by the native layer and handed over at the end, `response.body`
54
+ * is `undefined`, and there is no `ReadableStream` in the runtime at all -
55
+ * confirmed by grep over `oms-music/src`, which mentions neither
56
+ * `ReadableStream` nor `getReader` anywhere.
57
+ *
58
+ * That is not a gap to polyfill. Nothing that runs in JS can make the native
59
+ * networking layer emit partial bodies, and the shims that claim to (RN's
60
+ * `textStreaming` blob response type, `react-native-fetch-api`) either need
61
+ * `XMLHttpRequest`'s `onprogress` plumbed by hand or a different fetch
62
+ * altogether. Which is why this is a capability check and not a feature.
63
+ *
64
+ * ## What a caller does with the answer
65
+ *
66
+ * Pick a PRESENTATION, not a protocol. Both paths hit the same endpoint and
67
+ * read the same bytes; the difference is whether they arrive in pieces:
68
+ *
69
+ * ```ts
70
+ * if (supportsResponseStreaming()) {
71
+ * for await (const chunk of oms.http.streamText("POST", path, { body })) render(chunk);
72
+ * } else {
73
+ * const whole = await oms.http.raw("POST", path, { body }).then((r) => r.text());
74
+ * render(whole); // one paint, after the server is done
75
+ * }
76
+ * ```
77
+ *
78
+ * {@link ApiClient.streamText} already contains that fork, so the usual answer
79
+ * is to call it and use this only to decide what the UI PROMISES: a typing
80
+ * indicator that never types is worse than a spinner that admits it is waiting.
81
+ *
82
+ * The consumer this was designed against is the frontend's `BookChatService`,
83
+ * which reads an SSE-shaped body (`data: {"delta": "..."}` lines) off
84
+ * `POST /books/:id/chat`. Framing the lines is the domain's job; this and
85
+ * `streamText` only promise decoded text in order.
86
+ *
87
+ * The three probes are all needed. `ReadableStream` existing does not mean
88
+ * `fetch` produces one (a polyfilled global over RN's fetch is exactly that
89
+ * case), `Response.prototype` having `body` does not mean it is non-null on a
90
+ * given response - `streamText` still checks the instance - and `TextDecoder`
91
+ * is what turns the chunks into text on the way out.
92
+ */
93
+ export declare function supportsResponseStreaming(): boolean;
94
+ /**
95
+ * Whether `XMLHttpRequest` is available, which is the only route to byte-level
96
+ * UPLOAD progress in any of the three clients.
97
+ *
98
+ * True in a browser and in React Native (RN's networking is XHR underneath and
99
+ * `xhr.upload.onprogress` fires there); false in a Worker-class isolate and in
100
+ * Bun's server runtime. See {@link Progress} for the whole argument and
101
+ * `UploadManagerOptions.fetch` for the recipe - this only reports whether that
102
+ * recipe can be used here, so a UI can decide between a real bar and a
103
+ * per-file tick.
104
+ *
105
+ * The SDK never uses XHR itself. It cannot: the core has to load in an isolate.
106
+ */
107
+ export declare function supportsUploadProgress(): boolean;
108
+ /**
109
+ * Whether this runtime's `FormData` accepts a {@link NativeFile} descriptor.
110
+ *
111
+ * React Native's `FormData` is not the web one. `append(name, value)` stores
112
+ * the value as it is, and `getParts()` later reads `value.uri`, `value.name`
113
+ * and `value.type` off it to build a file part that the native layer streams
114
+ * from disk. The web `FormData` instead coerces any non-`Blob` to a string, so
115
+ * the same object silently becomes the literal text `"[object Object]"` in a
116
+ * text field - a 200 response and an upload that never happened.
117
+ *
118
+ * Detected by the presence of `getParts` on the prototype, with
119
+ * `navigator.product === "ReactNative"` as the second opinion. Not by a
120
+ * `Platform.OS` import, which would drag `react-native` into a package that has
121
+ * to load in a Worker.
122
+ */
123
+ export declare function supportsNativeFormDataFiles(): boolean;
12
124
  /**
13
125
  * Supplies the bearer token for each request.
14
126
  *
@@ -59,9 +171,23 @@ export interface ApiClientOptions {
59
171
  readonly sessionCookie?: boolean;
60
172
  /** Headers merged into every request, below per-call headers. */
61
173
  readonly headers?: Record<string, string>;
62
- /** Default deadline for one call, retries included. `0` disables it. */
174
+ /**
175
+ * Default deadline for ONE ATTEMPT, not for the whole call. `0` disables it.
176
+ *
177
+ * Each attempt gets a fresh one, so a call that retries can take up to
178
+ * `maxAttempts` times this plus the backoff. It also stops at the response
179
+ * headers rather than at the last byte, which is what lets `raw()` and
180
+ * `streamText()` hold a body open for longer. See {@link RequestOptions.timeoutMs}.
181
+ */
63
182
  readonly timeoutMs?: number;
64
- /** Default backoff policy, or `false` to never retry. */
183
+ /**
184
+ * Shape of the default backoff: how many attempts, how long between them.
185
+ *
186
+ * It does not widen WHICH requests are eligible - that stays "safe methods,
187
+ * plus a 429 on anything". Only a per-call `retry` can put a mutator in
188
+ * scope. `false` turns retrying off completely, for every method and every
189
+ * status. See the retry policy on {@link ApiClient}.
190
+ */
65
191
  readonly retry?: RetryOptions | false;
66
192
  /**
67
193
  * Value for the `X-Oms-Client` header, e.g. `"oms-cli/0.3.1"`. The SDK does
@@ -72,8 +198,15 @@ export interface ApiClientOptions {
72
198
  }
73
199
  /** Body accepted by `post`/`patch`. `undefined` sends no body at all. */
74
200
  export type JsonBody = unknown;
75
- /** One field of a multipart form. */
76
- export type FormFieldValue = string | number | boolean | FileInput | null | undefined;
201
+ /**
202
+ * One field of a multipart form.
203
+ *
204
+ * {@link NativeFile} is here so that a React Native caller can pass the object
205
+ * its picker returned straight through - `form.image = picked` - without
206
+ * wrapping it. It is appended verbatim on RN and rejected loudly elsewhere; see
207
+ * {@link buildFormData}.
208
+ */
209
+ export type FormFieldValue = string | number | boolean | FileInput | NativeFile | null | undefined;
77
210
  /**
78
211
  * Fields of a multipart form. An array value is appended once per entry with a
79
212
  * `[]` suffix, which is how Rails reads a list (`clips[]`).
@@ -86,16 +219,88 @@ export interface GetOptions extends RequestOptions {
86
219
  /**
87
220
  * HTTP client for the omelhorsite API.
88
221
  *
89
- * Retry policy, deliberately narrow:
90
- * - a `fetch` rejection (DNS, TLS, reset) is retried;
91
- * - `5xx` is retried;
92
- * - `429` is retried, waiting exactly what `Retry-After` asked for;
93
- * - every other `4xx` fails immediately, on any method.
222
+ * ## Retry policy
223
+ *
224
+ * What may be retried depends on WHY the attempt failed and on WHICH method
225
+ * made it, because those two together decide whether a replay can duplicate
226
+ * work the server already did:
227
+ *
228
+ * | outcome | GET / HEAD | POST / PATCH / PUT / DELETE |
229
+ * | ------------------------------------ | ---------- | --------------------------- |
230
+ * | `fetch` rejection (DNS, TLS, reset) | retried | **not** retried |
231
+ * | `5xx` | retried | **not** retried |
232
+ * | `429` | retried | retried |
233
+ * | every other `4xx` | fails | fails |
234
+ *
235
+ * The first two rows are ambiguous: a reset connection and a 502 from a proxy
236
+ * both mean "no usable answer", never "nothing happened". A `POST` that the
237
+ * server committed before the response was lost is indistinguishable from one
238
+ * it never saw, so replaying it is how one `create` call becomes two records.
239
+ * Safe methods carry no such risk, which is the whole reason the split exists.
240
+ *
241
+ * `429` is the exception, and it is safe on every method because of how this
242
+ * particular backend produces one. It comes either from `Rack::Attack`, which
243
+ * answers from middleware before the router is reached, or from a
244
+ * `too_many_requests!` guard that every controller places BEFORE the write it
245
+ * protects. A 429 is therefore proof the request was refused rather than
246
+ * performed, and waiting out `Retry-After` and trying again is exactly right.
247
+ *
248
+ * ## Opting a mutator back in
249
+ *
250
+ * Pass a `retry` object AT THE CALL SITE and it applies whatever the method:
251
+ *
252
+ * ```ts
253
+ * await oms.tools.downloader.create(input, { retry: {} }); // POST, retried
254
+ * ```
255
+ *
256
+ * A client-wide `new Oms({ retry })` deliberately does NOT do this. It sets the
257
+ * SHAPE of the backoff (attempts, delays, jitter) for whatever is eligible; it
258
+ * is not a statement about any one endpoint, and "every POST in this process
259
+ * may be replayed" is not a decision anybody makes correctly in a constructor.
260
+ * `retry: false` still disables everything, `429` included, and stays the right
261
+ * answer for a call whose failure mode is minting something (a short link, a
262
+ * notepad) under a fresh random identifier on each attempt.
263
+ *
264
+ * This is narrower than the SDK's 0.2.0 behaviour, which retried 5xx on every
265
+ * verb. Nothing in `resources/` relied on that: every call site that mentions
266
+ * retry is turning it OFF.
267
+ *
268
+ * ## Why this and not the mobile app's rule
269
+ *
270
+ * `oms-music/src/api/retryPolicy.ts` states the rule the app arrived at after
271
+ * the empty-Home bug: **retry for TIME, never for a RESPONSE**. A transport
272
+ * failure (a `fetch` that throws, DNS, the iOS radio still waking) is time and
273
+ * is worth asking again; a `4xx` is an answer and asking again does not change
274
+ * it; a `5xx` gets one extra go for a deploy or a blinking proxy; `429` gets
275
+ * none. Two attempts, `error.status >= 500` as the whole predicate.
276
+ *
277
+ * That rule is right for what it governs and wrong to copy here, for one
278
+ * reason: it sits under react-query, where every retried operation is a QUERY.
279
+ * "Repeat any transport error" is safe when the thing being repeated is a read.
280
+ * This SDK carries the writes too, and the app's own mutations do NOT go
281
+ * through that policy. Applying it verbatim would replay a `POST /songs` whose
282
+ * answer was lost on a lift ride and mint the record twice, which is precisely
283
+ * the failure the method split above exists to prevent. So the SDK keeps the
284
+ * axis the app does not have to think about (the METHOD) and agrees with it on
285
+ * the axis it does: a `4xx` is never retried anywhere.
286
+ *
287
+ * On `429` the two genuinely disagree, and the disagreement is deliberate on
288
+ * both sides. The app declines because its ceiling-aware screens would rather
289
+ * show "slow down" at once than sit on a hidden `Retry-After` sleep, and
290
+ * because react-query re-throws a parked error before the network is even
291
+ * consulted. The SDK retries because it also serves the CLI and the MCP server,
292
+ * where the right move on a rate limit is to wait the header out and continue,
293
+ * and because this backend's `429` provably precedes the write (Rack::Attack in
294
+ * middleware, `too_many_requests!` ahead of every guarded action). A mobile
295
+ * host that wants the app's behaviour asks for it per call - `retry: false` -
296
+ * or, at the client, gets the closest match with `new Oms({ retry: { maxAttempts: 2 } })`:
297
+ * one extra attempt, safe methods only, exactly the app's shape.
94
298
  *
95
- * The policy applies to all verbs, `POST` included. That is the documented
96
- * behaviour, and it means a `POST` that the server processed before dying with
97
- * a 502 can be replayed. Pass `retry: false` on any create where a duplicate
98
- * is worse than a failure.
299
+ * One consequence to design around rather than discover: a retried `429` obeys
300
+ * `Retry-After`, this API sets it from a one-minute window, and `timeoutMs`
301
+ * bounds ONE attempt rather than the call. A rate-limited call can therefore
302
+ * take minutes of wall clock. Anything with a user watching it should pass a
303
+ * `signal` it can abort, or turn retrying off.
99
304
  */
100
305
  export declare class ApiClient {
101
306
  /** API root with no trailing slash. */
@@ -137,13 +342,79 @@ export declare class ApiClient {
137
342
  * media file or an SSE endpoint. Retry and auth still apply.
138
343
  *
139
344
  * The caller owns the body and must consume or cancel it.
345
+ *
346
+ * `response.body` is NOT a promise this method can make: React Native has no
347
+ * `ReadableStream` and leaves it undefined, so any code reaching for
348
+ * `.body.getReader()` here works in the browser and the CLI and throws on a
349
+ * phone. Test with {@link supportsResponseStreaming} first, or use
350
+ * {@link ApiClient.streamText}, which contains that fork already.
140
351
  */
141
352
  raw(method: string, path: string, options?: GetOptions & {
142
353
  body?: BodyInit;
143
354
  }): Promise<Response>;
355
+ /**
356
+ * Reads a response body as text, in pieces where the runtime allows it and in
357
+ * one piece where it does not.
358
+ *
359
+ * This is the primitive a streaming endpoint is built on. It promises exactly
360
+ * two things - decoded text, in order, with nothing lost - and deliberately
361
+ * promises nothing about chunk boundaries, because they are not the same on
362
+ * the three clients:
363
+ *
364
+ * - browser and Bun: one yield per network chunk, as it lands;
365
+ * - React Native: ONE yield, containing everything, after the server has
366
+ * finished. `fetch` there cannot do better - see
367
+ * {@link supportsResponseStreaming} - and pretending otherwise by slicing
368
+ * the finished body into fake chunks would only make a UI claim to be live.
369
+ *
370
+ * So a caller must not assume a chunk is a frame, a line, or a whole
371
+ * anything: a `data:` line can arrive split across two chunks, and on RN a
372
+ * hundred of them arrive as one string. Buffer, then split on your own
373
+ * delimiter. `BookChatService` in the frontend is the reference reader (SSE
374
+ * `data:` lines carrying `{ delta, done, error }`) and shows the shape of it;
375
+ * framing belongs to the domain module, not here.
376
+ *
377
+ * ## The silence limit exists because of an outage
378
+ *
379
+ * A stalled sidecar answered `200` and then said nothing for two minutes at a
380
+ * time, and `await reader.read()` has no deadline of its own, so the chat
381
+ * panel span for as long as the tab stayed open. `timeoutMs` does not help:
382
+ * it is disposed once the headers arrive (it has to be, or no stream could
383
+ * outlive it). `silenceTimeoutMs` bounds the gap BETWEEN chunks instead and
384
+ * raises {@link OmsTimeoutError} when nothing arrives for that long. It has
385
+ * to sit well clear of a cold model's first token; 45s is the number the
386
+ * frontend settled on and the default here.
387
+ *
388
+ * Pass `0` to disable it, and mean it: an unbounded read is a spinner with no
389
+ * way out. It does not apply on the buffered path, where the single `text()`
390
+ * read is covered by the caller's `signal`.
391
+ *
392
+ * The body is always released: the reader is cancelled on every exit,
393
+ * including an abandoned `for await` (a `break` runs the generator's
394
+ * `finally`). An abandoned reader holds the connection open.
395
+ *
396
+ * @example
397
+ * ```ts
398
+ * let buffer = "";
399
+ * for await (const chunk of oms.http.streamText("POST", `/books/${id}/chat`, { body })) {
400
+ * buffer += chunk;
401
+ * // ... pull whole lines out of `buffer`, leave the partial one behind
402
+ * }
403
+ * ```
404
+ */
405
+ streamText(method: string, path: string, options?: GetOptions & {
406
+ body?: JsonBody;
407
+ silenceTimeoutMs?: number;
408
+ }): AsyncGenerator<string, void, undefined>;
144
409
  /**
145
410
  * `GET` that reads the whole response as a {@link FileOutput}, taking the
146
411
  * filename from `Content-Disposition` when the server sent one.
412
+ *
413
+ * Reads the body into memory in every runtime, React Native included, where
414
+ * `Response#blob()` needs RN's `BlobModule` and produces a Blob whose bytes
415
+ * live on the native side. For a large media file on a phone that is the
416
+ * wrong tool: ask for a signed URL and hand it to the player or to a native
417
+ * downloader instead of pulling it through JavaScript.
147
418
  */
148
419
  download(path: string, options?: GetOptions): Promise<FileOutput>;
149
420
  /** Absolute URL for a path, with the query string applied. */
@@ -165,24 +436,167 @@ export declare abstract class Resource {
165
436
  protected readonly http: ApiClient;
166
437
  constructor(http: ApiClient);
167
438
  }
439
+ /**
440
+ * The backend's null sentinel: a single backspace character, `U+0008`.
441
+ *
442
+ * A query string has no way to say `null`. `?parent_id=` is the empty string,
443
+ * `?parent_id=null` is the four-letter word "null", and omitting the key
444
+ * entirely says something else again. So the API picked a character no real
445
+ * value ever contains and decodes it back to `nil` on arrival:
446
+ * `CrudActions#define_option_param_getter` runs
447
+ * `value.transform_values! { |v| v == "\b" ? nil : v }` over every filter
448
+ * bucket, and `GroupChatsController`, `GroupChatMessagesController` and
449
+ * `BookServices::Creator` each repeat the same test on the fields they read by
450
+ * hand.
451
+ *
452
+ * Where that `nil` lands is what makes it worth having. `Searchable.exact_search`
453
+ * is `where(params)`, so `exact_search[parent_id]` set to the sentinel becomes
454
+ * `WHERE parent_id IS NULL` - the only way to ask for the storage root nodes,
455
+ * for a comment with no parent, for anything unassigned.
456
+ *
457
+ * Exported because it is part of the wire format, not because you should need
458
+ * it: {@link encodeQuery} writes it for you whenever a query value is `null`.
459
+ */
460
+ export declare const NULL_SENTINEL = "\b";
168
461
  /**
169
462
  * Encodes query parameters the way Rails parses them.
170
463
  *
171
464
  * - `{ page: 2 }` -> `page=2`
172
465
  * - `{ ids: ["a", "b"] }` -> `ids%5B%5D=a&ids%5B%5D=b`
173
466
  * - `{ search: { status: "open" } }` -> `search%5Bstatus%5D=open`
174
- * - `undefined` / `null` values are dropped, never sent as an empty string.
467
+ * - `{ since: new Date(...) }` -> `since=2026-08-29T09%3A00%3A00.000Z`
468
+ * - `{ parent_id: null }` -> `parent_id=%08`, the null sentinel
469
+ * - `undefined` values are dropped entirely.
470
+ *
471
+ * ## Why `null` and `undefined` are not the same thing here
472
+ *
473
+ * They are the two answers to two different questions, and a query string can
474
+ * only express one of them without help:
475
+ *
476
+ * - `undefined` means "I am not filtering on this column". Dropping the key is
477
+ * the correct encoding.
478
+ * - `null` means "filter where this column IS NULL". There is no literal for
479
+ * that in a URL, so it goes out as {@link NULL_SENTINEL} and the backend
480
+ * turns it back into `nil`.
481
+ *
482
+ * Encoding `null` as "drop the key" - which this function used to do - is the
483
+ * dangerous direction. The filter simply vanishes and `Searchable.exact_search`
484
+ * returns early (`return self unless params.present?`), so the endpoint answers
485
+ * with the UNFILTERED set: a request for the root of somebody's drive comes
486
+ * back as their entire tree. That failure has bitten this API before, from the
487
+ * other end, and is why `CrudActions#reject_unknown_filter_keys!` now 400s on a
488
+ * key it does not recognise rather than quietly widening the query.
489
+ *
490
+ * The sentinel is written at every depth, even though the server only decodes
491
+ * it one level inside a filter bucket (`search`, `exact_search`, `modifiers`,
492
+ * `extra_options`). Anywhere else it stays the literal character and matches no
493
+ * row - an empty result, which is the safe way to be wrong. Compare that with
494
+ * dropping the key, which is an over-broad result nobody notices.
495
+ *
496
+ * ## Dates
497
+ *
498
+ * A `Date` reaches `typeof value === "object"` like anything else, and
499
+ * `Object.entries(new Date())` is `[]`, so before this branch existed a date
500
+ * filter did not merely arrive malformed - the key disappeared and the listing
501
+ * came back unfiltered. ISO-8601 is what the server reads:
502
+ * `QuerySearcher#date_search` runs `String#to_date_safe` (`Date.parse`) over
503
+ * the value and turns an unparseable one into `nil`, which silently drops that
504
+ * side of the range. Bodies need no equivalent branch, because `JSON.stringify`
505
+ * already calls `Date.prototype.toJSON` and emits the same string.
506
+ *
507
+ * ## The brackets are percent-encoded, and that is load-bearing
508
+ *
509
+ * `search[title]` goes out as `search%5Btitle%5D`, never as raw `[` and `]`.
510
+ * Both parse identically in Rails, so this looks like the kind of noise someone
511
+ * tidies away on a quiet afternoon. It is not, and the reason is iOS.
512
+ *
513
+ * `[` and `]` are not legal in a URI query (RFC 3986 reserves them for the host
514
+ * component). Every browser tolerates them; Apple's URL stack does not. Give
515
+ * `NSURL`/`URLComponents` a string with a raw bracket in the query and it
516
+ * re-percent-encodes THE WHOLE QUERY to make it valid - including the `%` signs
517
+ * of anything already encoded. `search[title]=Caf%C3%A9` comes back out as
518
+ * `search%5Btitle%5D=Caf%25C3%25A9`, and the server dutifully searches for the
519
+ * literal text `Caf%C3%A9`, which matches nothing. The failure is a silent
520
+ * empty list on one platform, which is the most expensive kind to find.
521
+ *
522
+ * `encodeURIComponent` handles this by encoding brackets already; the point of
523
+ * writing it down is that nothing here may "simplify" the encoder into
524
+ * `encodeURI`, a template literal, or a hand-built `key[sub]=value`, all of
525
+ * which emit raw brackets. `test/react-native.test.ts` pins it.
526
+ *
527
+ * @throws {TypeError} for an invalid `Date`. `toISOString()` would throw a bare
528
+ * `RangeError` from deep inside the transport; failing loudly at the boundary
529
+ * beats sending `Invalid Date` and getting an unfiltered page back.
175
530
  */
176
531
  export declare function encodeQuery(params: QueryParams): string;
532
+ /**
533
+ * Methods whose replay cannot duplicate work, so a lost answer may simply be
534
+ * asked for again.
535
+ *
536
+ * `PUT` and `DELETE` are idempotent by RFC 9110 and are still absent, because
537
+ * idempotent is not the same as harmless to replay HERE. Rails' `destroy` is
538
+ * behind a `find_by` that answers `404` the second time, so a `DELETE` retried
539
+ * after a torn connection reports "not found" for a row it deleted perfectly
540
+ * well - a success turned into an error the caller then acts on.
541
+ */
542
+ export declare const SAFE_METHODS: ReadonlySet<string>;
543
+ /** True when {@link SAFE_METHODS} contains `method`, case-insensitively. */
544
+ export declare function isSafeMethod(method: string): boolean;
177
545
  /**
178
546
  * Builds the `modifiers[page]` string the backend expects (`"2:100"`).
179
- * Page size is clamped to the server maximum so a caller cannot silently ask
180
- * for more and get 500 back without knowing.
547
+ *
548
+ * Both halves go through `resolvePageSize` / `resolvePageNumber`, which is the
549
+ * same pair `createPage` uses. That is the point: the size on the wire and the
550
+ * size `Paginated.pageSize` reports have to be one number, or a caller who asks
551
+ * for 1200 gets the server's 500 rows measured against their own 1200 and is
552
+ * told `hasMore: false` while 700 rows sit behind it.
553
+ *
554
+ * @throws {TypeError} for a page size that is not a finite number of at least
555
+ * 1. `"1:NaN"` on the wire reads as size `0` in
556
+ * `QueryModifier#apply_pagination`, which skips `limit`/`offset` altogether
557
+ * and returns the entire table.
181
558
  */
182
559
  export declare function pageModifier(page?: number, pageSize?: number): string;
183
- /** Turns a {@link FormFields} bag into a `FormData`, buffering any streams. */
560
+ /**
561
+ * Turns a {@link FormFields} bag into a `FormData`, buffering any streams.
562
+ *
563
+ * `null` and `undefined` fields are OMITTED, which is the opposite of what
564
+ * {@link encodeQuery} does with `null` and is right for the same reason it is
565
+ * right there: what the receiver reads from an absent field. A multipart body
566
+ * only ever feeds a create here, and an absent field means `params[:x]` is
567
+ * `nil` - already the value a sentinel would decode to, so writing one would
568
+ * add a step that changes nothing. In a query the absent key means "no filter",
569
+ * which is a different answer entirely.
570
+ *
571
+ * The one thing this cannot express is clearing a column through a multipart
572
+ * UPDATE, where absent means "leave it alone". No endpoint takes one - the SDK
573
+ * sends multipart to the tools and to `POST /books` only - and if one appears,
574
+ * pass {@link NULL_SENTINEL} as the field value explicitly.
575
+ *
576
+ * ## React Native files
577
+ *
578
+ * A {@link NativeFile} - the `{ uri, name, type }` a picker returns - is
579
+ * appended VERBATIM, as the object it is. That is the whole trick: RN's
580
+ * `FormData` keeps the value untouched and its `getParts()` turns an entry with
581
+ * a `uri` into a file part that the native layer streams off disk. Converting
582
+ * it to anything first is what breaks it.
583
+ *
584
+ * On a runtime whose `FormData` is the web one, the same object would be
585
+ * coerced to the string `"[object Object]"` and uploaded as a text field: a
586
+ * 200, a stored record, and no file. So it throws there instead
587
+ * ({@link supportsNativeFormDataFiles}), and for the same reason any other
588
+ * unrecognised object throws rather than being stringified - a descriptor that
589
+ * lost its `name` in transit is a typo, not a form value.
590
+ */
184
591
  export declare function buildFormData(fields: FormFields): Promise<FormData>;
185
- /** Narrows an unknown form value to a {@link FileInput}. */
592
+ /**
593
+ * Narrows an unknown form value to a {@link FileInput}.
594
+ *
595
+ * A {@link NativeFile} in `data` counts: it is a legitimate `FileInput` on RN,
596
+ * it just cannot be turned into bytes. Callers that need bytes should test with
597
+ * `isNativeFile(input.data)` rather than trusting this, or let
598
+ * `readFileInput` throw the message that explains the way out.
599
+ */
186
600
  export declare function isFileInput(value: unknown): value is FileInput;
187
601
  /** Fills in whatever a partial {@link RetryOptions} left out. */
188
602
  export declare function resolveRetry(options: RetryOptions | undefined): ResolvedRetry;
@@ -193,6 +607,17 @@ export declare function backoffDelay(attempt: number, retry: ResolvedRetry): num
193
607
  * `setTimeout`, which a Worker isolate provides.
194
608
  */
195
609
  export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
610
+ /**
611
+ * How long {@link ApiClient.streamText} will wait for the NEXT chunk before it
612
+ * gives up: 45 seconds, the number the frontend's `BookChatService` arrived at.
613
+ *
614
+ * It is a silence limit, not a total: a stream that keeps producing runs as
615
+ * long as it likes. The value has to clear a cold model's first token (the
616
+ * weights are paged in before anything is generated) while still being shorter
617
+ * than a user's patience. The proxy usually drops a silent stream first; this
618
+ * is the backstop for when nothing else closes the connection.
619
+ */
620
+ export declare const DEFAULT_STREAM_SILENCE_MS = 45000;
196
621
  /**
197
622
  * Combines a deadline with the caller's signal into one signal.
198
623
  *
@@ -14,7 +14,10 @@
14
14
  * 1. It runs in a Cloudflare-Worker-class isolate. No `node:*`, no `process`,
15
15
  * no filesystem, no stdout. `fetch` is injectable through the constructor.
16
16
  * 2. Files are values - `Blob`, `Uint8Array`, `ReadableStream` - never paths.
17
- * Turning a path into bytes is the host's job.
17
+ * Turning a path into bytes is the host's job. React Native is the single
18
+ * exception, and only on multipart endpoints: a picked `{ uri, name, type }`
19
+ * (`NativeFile`) goes to the wire verbatim because RN's `FormData` streams
20
+ * it off disk and a `Blob` there uploads truncated.
18
21
  * 3. The TYPES are the public interface. In code mode a model reads the `.d.ts`
19
22
  * and nothing else, so a name or a JSDoc line carries as much weight as the
20
23
  * behaviour behind it.
@@ -26,6 +29,7 @@ export * from "./auth/index";
26
29
  export * from "./client";
27
30
  export * from "./errors";
28
31
  export * from "./http";
32
+ export * from "./listing";
29
33
  export * from "./local/index";
30
34
  export * from "./resources/index";
31
35
  export * from "./types";