naro-js 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # naro-js
2
2
 
3
- Auth and data for your [Naro](https://naro.sh) project, from the browser or
3
+ Auth, data and files for your [Naro](https://naro.sh) project, from the browser or
4
4
  the server. A project's backend brings its own database; this client signs
5
5
  users in and reads and writes it through the project's policies. No SQL, no
6
6
  database credentials, no dependencies.
@@ -78,6 +78,45 @@ await naro.from("posts").delete().eq("id", 10);
78
78
  - Admin accounts, closed sign-ups, sign-in by username and the password
79
79
  length — see [Admin accounts and closed sign-ups](#admin-accounts-and-closed-sign-ups).
80
80
  - Email and social sign-in — see [Sign-in flows](#sign-in-flows).
81
+ - Files — see [Storage](#storage).
82
+
83
+ ## Policies
84
+
85
+ What a request may do is decided by the table's policies — Postgres's
86
+ row-level security, as in Supabase. A table without a policy refuses every
87
+ anon and signed-in request (only the service key gets through), so write a
88
+ table's policies in the migration that creates it: `create policy` after the
89
+ `create table`, in the same file. `naro db push` applies them with the table,
90
+ all or nothing.
91
+
92
+ ```sql
93
+ -- migrations/0002_todos.sql: each signed-in user reads and changes their own rows.
94
+ create table todos (
95
+ id integer primary key,
96
+ user_id uuid not null,
97
+ title text not null
98
+ );
99
+ create policy "own rows" on todos for all to authenticated
100
+ using ((select auth.uid()) = user_id)
101
+ with check ((select auth.uid()) = user_id);
102
+
103
+ -- migrations/0003_posts.sql: anyone reads, signed in or not.
104
+ create table posts (id integer primary key, title text not null);
105
+ create policy "read" on posts for select to anon, authenticated using (true);
106
+
107
+ -- migrations/0004_reports.sql: an app only its admins sign in to. First run
108
+ -- naro backend auth signups off and create the admin accounts: authenticated
109
+ -- then means exactly those accounts.
110
+ create table reports (id integer primary key, body text not null);
111
+ create policy "admins" on reports for all to authenticated using (true) with check (true);
112
+ ```
113
+
114
+ A request no policy lets through, and a written row that fails a check,
115
+ answer `403 PERMISSION_DENIED`; rows a policy's `using` does not pass are left
116
+ out without an error. The `naro-cli` README lists what a
117
+ migration accepts and refuses (`DISABLE ROW LEVEL SECURITY`, `GRANT`,
118
+ `REVOKE` and `ALTER POLICY` are refused), the warnings it answers with, and
119
+ `naro policy set`, a shorthand for the common rules.
81
120
 
82
121
  ## Admin accounts and closed sign-ups
83
122
 
@@ -413,4 +452,118 @@ await naro.from("posts").select().contains("tags", ["a", "b"]);
413
452
  - Limits: groups nest 4 deep (`not` counts); one request holds at most 100
414
453
  conditions, 4 of them `contains` / `containedBy` / `overlaps`; one
415
454
  statement binds at most 100 values (a select's limit and offset are two of
416
- them) and compiles to at most 100,000 bytes of SQL.
455
+ them) and compiles to at most 100,000 bytes of SQL. A `like` or `ilike`
456
+ pattern is at most 50 bytes of UTF-8, which is D1's limit; in `like`, a
457
+ literal `?` or `[` counts as 3. A longer one is `400 INVALID_QUERY` before
458
+ the database is asked.
459
+
460
+ ## Storage
461
+
462
+ Files your users upload — avatars, attachments, documents — go in the
463
+ project's Storage: Cloudflare R2 behind the same URL, under policies on
464
+ `storage.objects` written as in Supabase. Turn it on with
465
+ `naro storage enable` (the backend must be on), make buckets with
466
+ `naro storage bucket create avatars --public`, and write the policies in a
467
+ migration that `naro db push` applies:
468
+
469
+ ```sql
470
+ create policy "avatars are readable" on storage.objects
471
+ for select to public using (bucket_id = 'avatars');
472
+ create policy "upload into own folder" on storage.objects
473
+ for insert to authenticated
474
+ with check (bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text);
475
+ ```
476
+
477
+ ```ts
478
+ const avatars = naro.storage.from("avatars");
479
+ await avatars.upload(`${user.id}/me.png`, file, { upsert: true });
480
+ const { data: image } = avatars.getPublicUrl(`${user.id}/me.png`); // no request
481
+ const { data: files } = await avatars.list(user.id, { limit: 20 });
482
+ await avatars.remove([`${user.id}/old.png`]);
483
+ const { data: link } = await naro.storage
484
+ .from("docs")
485
+ .createSignedUrl("team-1/plan.pdf", 600);
486
+ ```
487
+
488
+ - `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`,
489
+ `createSignedUrl` and `getPublicUrl` take supabase-js's arguments and
490
+ answer its shapes (`FileObject` in snake_case), as `{ data, error }`.
491
+ One exception: supabase-js's `info` answers a camelCase object
492
+ (`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
493
+ answers the same snake_case `FileObject` as `list`, with the size and type
494
+ in `metadata.size` and `metadata.mimetype`.
495
+ - An upload sends the file's own bytes, typed as the file is (`Blob.type`,
496
+ or `application/octet-stream`; a string is `text/plain;charset=utf-8`).
497
+ The body is a `Blob`, `File`, `ArrayBuffer`, typed array or string — not
498
+ a stream or `FormData`, and not a buffer transferred away (detached),
499
+ which has no bytes left: `INVALID_BODY`.
500
+ - One request carries 50 MiB. A larger body goes in parts by itself, up to
501
+ the plan's largest object: 16–64 MiB parts, four at a time, each tried
502
+ again up to three times after `NETWORK_ERROR`, a `5xx` or
503
+ `OBJECT_RATE_LIMITED` (waiting 1, 2, then 4 seconds, or the seconds the
504
+ 429 says) — not after `507 STORAGE_QUOTA_EXCEEDED`, which lasts until
505
+ files are deleted. A part that still fails fails the upload with its
506
+ error, and the upload is called off. The last step is never tried again: if its
507
+ answer is lost (`NETWORK_ERROR`), ask `info(path)` whether the file is
508
+ there; if the backend refuses it, the upload is called off too and the
509
+ refusal is the answer. A part answered `UPLOAD_NOT_FOUND` belongs to an
510
+ upload that has ended — one left unsent for about an hour has. An upload in
511
+ parts must finish within 24 hours of its start (R2 ends unfinished
512
+ multipart uploads after a day). Replacing a file in parts (`update`, or
513
+ `upload` with `upsert` over a file that is there) needs room in the
514
+ project's capacity for the old file and the new one until it completes: if
515
+ the two together are over it, the upload answers
516
+ `507 STORAGE_QUOTA_EXCEEDED` before any part is sent.
517
+ - `onProgress: (sentBytes, totalBytes) => void` is called after each part
518
+ of an upload over 50 MiB, the last time with both the same. An upload of
519
+ 50 MiB or less is one request and does not call it. A `File` is sliced
520
+ into the parts, never read whole first. What the callback throws — or an
521
+ `async` one rejects with — does not stop the upload.
522
+
523
+ - `signal: AbortSignal` stops an upload (`upload` and `update`): what has
524
+ not been answered yet is called off, and the upload answers `ABORTED`
525
+ ("The upload was stopped before it finished."). An upload in parts is
526
+ called off on the backend too — no part starts after the signal, and
527
+ `onProgress` is not called again — so its name and the space it reserved
528
+ are free at once, not after the hour its lock lasts. Its first step is
529
+ never cut short (a signal during it is acted on once it answers), and once
530
+ its last step is sent the signal stops nothing: that step's answer is the
531
+ upload's. A signal already aborted sends nothing. A page closed or a
532
+ network lost mid-upload sends no abort: the name waits for the lock.
533
+
534
+ ```ts
535
+ const stop = new AbortController();
536
+ cancelButton.onclick = () => stop.abort();
537
+ await naro.storage.from("videos").upload(`${user.id}/talk.mp4`, file, {
538
+ onProgress: (sent, total) => setPercent(Math.round((sent / total) * 100)),
539
+ signal: stop.signal,
540
+ });
541
+ ```
542
+
543
+ - A `contentType` or `cacheControl` no header can carry (a CR, LF or NUL
544
+ inside, or a character past U+00FF) is answered before a request, as the
545
+ backend answers one it cannot read: `INVALID_CONTENT_TYPE`, and
546
+ `INVALID_REQUEST` with `details.path` `cache-control`.
547
+ - supabase-js's `upload(path, file)` sends `multipart/form-data`, which Naro
548
+ refuses with `400 INVALID_REQUEST`. Without naro-js, send the file's bytes
549
+ as the body of `POST /_naro/v1/storage/object/<bucket>/<path>`.
550
+ - Refused before a request, with the backend's own words (`INVALID_PATH`):
551
+ an empty path, a `/` at either end, `//`, a `.` or `..` segment, a lone
552
+ surrogate (`is not valid UTF-8`: no URL can carry it), and a bucket named
553
+ `""`, `.`, `..`, with a lone surrogate or a word the storage routes use
554
+ (`The bucket name is not valid.`). Everything else is the backend's to
555
+ check.
556
+ - `exists` answers `false` for `OBJECT_NOT_FOUND` and passes every other
557
+ error on. `getPublicUrl` makes no request and answers `{ data, error }`.
558
+ - `objectRoute(bucket, path)` names an object the way the client does:
559
+ `{ ok: true, encoded }` with `<bucket>/<path>` as the object routes carry
560
+ it (each segment `encodeURIComponent`-ed), or `{ ok: false, error }` with
561
+ the client's own `INVALID_PATH` (type `ObjectRouteResult`). A tool that
562
+ streams an object itself builds `/_naro/v1/storage/object/<encoded>` from
563
+ it and gets the URL and the refusals the client would — `naro storage cp`
564
+ downloads that way.
565
+ - `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
566
+ `details.retryAfter`, in seconds.
567
+ - Not yet: `move`, `copy`, `createSignedUrls`, `list`'s `search`, resuming
568
+ an upload in another session, and bucket methods (buckets are made with
569
+ `naro storage bucket`).
package/dist/client.d.ts CHANGED
@@ -2,6 +2,7 @@ import { AuthClient } from "./auth/client.js";
2
2
  import { type AuthStorage } from "./auth/storage.js";
3
3
  import { type FetchLike } from "./http.js";
4
4
  import { TableBuilder } from "./query/builder.js";
5
+ import { StorageClient } from "./storage/client.js";
5
6
  import type { DatabaseShape, InsertOf, RowOf, TableName, TablesOf, UntypedDatabase, UpdateOf } from "./types.js";
6
7
  type CommonOptions = {
7
8
  /**
@@ -51,6 +52,12 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
51
52
  * service key, `auth.admin` manages users.
52
53
  */
53
54
  readonly auth: AuthClient;
55
+ /**
56
+ * Files in the project's Naro Storage (first-release design 2026-10-06
57
+ * §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
58
+ * them, as supabase-js does.
59
+ */
60
+ readonly storage: StorageClient;
54
61
  constructor(options: ClientOptions);
55
62
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
56
63
  from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
package/dist/errors.d.ts CHANGED
@@ -25,6 +25,11 @@ export declare function networkError(cause: unknown): NaroError;
25
25
  * code exchange a page runs on load (PR #68 review 10). It is called off.
26
26
  */
27
27
  export declare function requestTimeout(ms: number): NaroError;
28
+ /**
29
+ * An upload the caller's `signal` stopped (PR #111 review r1): what was not
30
+ * answered yet was called off. Never tried again.
31
+ */
32
+ export declare function uploadAborted(): NaroError;
28
33
  /** An answer, but not one a Naro worker gives: HTML, empty, or the wrong shape. */
29
34
  export declare function invalidResponse(status: number): NaroError;
30
35
  /**
package/dist/http.d.ts CHANGED
@@ -15,7 +15,7 @@ export type HttpConfig = {
15
15
  tokens: TokenStore;
16
16
  };
17
17
  export type CallOptions = {
18
- method: "GET" | "POST" | "DELETE";
18
+ method: "GET" | "POST" | "PUT" | "DELETE";
19
19
  /** Below `/_naro/v1`, e.g. `/data/query`. */
20
20
  path: string;
21
21
  /** Sent as JSON. */
@@ -53,10 +53,44 @@ export type CallResult = {
53
53
  * §11.1), but only if it is still the token this request carried.
54
54
  */
55
55
  export declare function call(config: HttpConfig, options: CallOptions): Promise<CallResult>;
56
+ export type SendOptions = {
57
+ method: "GET" | "POST" | "PUT";
58
+ /** Below `/_naro/v1`, e.g. `/storage/object/avatars/me.png`. */
59
+ path: string;
60
+ headers?: Record<string, string>;
61
+ body?: BodyInit;
62
+ withToken: boolean;
63
+ /**
64
+ * An upload's `signal` (PR #111 review r1): it calls the request off until
65
+ * its answer comes — ABORTED, not NETWORK_ERROR — and never the reading
66
+ * of an answer that came, which may be a success.
67
+ */
68
+ signal?: AbortSignal | undefined;
69
+ };
70
+ /**
71
+ * A failure carries the answer's HTTP status, or null when nothing answered
72
+ * (NETWORK_ERROR): a multipart upload tries a part again on a 5xx, whatever
73
+ * the body said (Storage 3-1f, plan C7).
74
+ */
75
+ export type SendResult = {
76
+ error: NaroError;
77
+ status: number | null;
78
+ } | {
79
+ error: null;
80
+ response: Response;
81
+ };
82
+ /**
83
+ * A request whose body or answer is not JSON (Storage, first-release design
84
+ * 2026-10-06 §8.1): an upload's bytes go as they are, a download's come
85
+ * back unread. The same `apikey`, bearer token and no cookies as `call`;
86
+ * a failing answer is read the way `call` reads one — the worker's error
87
+ * envelope, or INVALID_RESPONSE — and every case resolves.
88
+ */
89
+ export declare function send(config: HttpConfig, options: SendOptions): Promise<SendResult>;
56
90
  /**
57
- * The worker's two 429s. `RATE_LIMITED` is the sign-in routes' per-address
58
- * limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the project over its daily
59
- * database write limit — Data API writes, sign-up and creating or deleting
60
- * users wait for 00:00 UTC, reads still work.
91
+ * The worker's 429s that say how long to wait. `RATE_LIMITED` is the
92
+ * sign-in routes' per-address limiter; `WRITE_QUOTA_EXCEEDED` (PR C) is the
93
+ * project over its daily database write limit — Data API writes, sign-up
94
+ * and creating or deleting users wait for 00:00 UTC, reads still work.
61
95
  */
62
96
  export declare const RETRY_AFTER_CODES: ReadonlySet<string>;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ export type { AuthClient } from "./auth/client.js";
4
4
  export type { AuthStorage } from "./auth/storage.js";
5
5
  export type { NaroError } from "./errors.js";
6
6
  export type { FetchLike } from "./http.js";
7
+ export type { StorageClient, StorageFileApi } from "./storage/client.js";
8
+ export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
9
+ export type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
7
10
  export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
8
11
  export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
9
12
  export type { AdminUser, AdminUserAttributes, AdminUserListResponse, AdminUserResponse, AuthActionResponse, AuthErrorCode, AuthFlowType, AuthResponse, DatabaseShape, EmailRedirectOptions, OAuthProvider, OAuthResponse, SignInWithOAuthOptions, UrlSessionResponse, UserAttributes, GenericTable, GenericView, NaroResponse, OrderOptions, PageParams, Session, SessionResponse, SignInCredentials, SignOutResponse, SignUpCredentials, UntypedDatabase, User, UserResponse, } from "./types.js";
package/dist/index.js CHANGED
@@ -14,6 +14,12 @@ function requestTimeout(ms) {
14
14
  message: `The Naro backend did not answer within ${String(Math.round(ms / 1e3))} seconds. Check the network and try again.`
15
15
  };
16
16
  }
17
+ function uploadAborted() {
18
+ return {
19
+ code: "ABORTED",
20
+ message: "The upload was stopped before it finished."
21
+ };
22
+ }
17
23
  function invalidResponse(status) {
18
24
  return {
19
25
  code: "INVALID_RESPONSE",
@@ -158,15 +164,79 @@ async function call(config, options) {
158
164
  const answer = { status, headers: responseHeaders, body, token };
159
165
  const error = errorFromBody(status, body);
160
166
  if (error === null) return { error: null, answer };
167
+ return {
168
+ error: await settled(config, error, responseHeaders, token),
169
+ answer
170
+ };
171
+ }
172
+ async function settled(config, error, headers, token) {
161
173
  if (error.code === "INVALID_SESSION" && token !== null)
162
174
  await config.tokens.clearIf(token);
163
- if (RETRY_AFTER_CODES.has(error.code))
164
- return { error: withRetryAfter(error, responseHeaders), answer };
165
- return { error, answer };
175
+ return RETRY_AFTER_CODES.has(error.code) ? withRetryAfter(error, headers) : error;
176
+ }
177
+ async function send(config, options) {
178
+ const token = options.withToken ? await config.tokens.get() : null;
179
+ const headers = {
180
+ ...options.headers,
181
+ apikey: config.apiKey
182
+ };
183
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
184
+ const init = {
185
+ method: options.method,
186
+ headers,
187
+ credentials: "omit"
188
+ };
189
+ if (options.body !== void 0) init.body = options.body;
190
+ const { signal } = options;
191
+ if (signal?.aborted) return { error: uploadAborted(), status: null };
192
+ const controller = signal ? new AbortController() : null;
193
+ const stop = () => controller?.abort();
194
+ if (controller) init.signal = controller.signal;
195
+ signal?.addEventListener("abort", stop);
196
+ let response;
197
+ try {
198
+ const fetchImpl = config.fetch ?? globalThis.fetch;
199
+ response = await fetchImpl(
200
+ `${config.baseUrl}/_naro/v1${options.path}`,
201
+ init
202
+ );
203
+ } catch (error2) {
204
+ return {
205
+ error: signal?.aborted ? uploadAborted() : networkError(error2),
206
+ status: null
207
+ };
208
+ } finally {
209
+ signal?.removeEventListener("abort", stop);
210
+ }
211
+ const { status } = response;
212
+ if (status >= 200 && status < 300) return { error: null, response };
213
+ let text;
214
+ try {
215
+ text = await response.text();
216
+ } catch (error2) {
217
+ return { error: networkError(error2), status: null };
218
+ }
219
+ let body;
220
+ if (text.trim() !== "") {
221
+ try {
222
+ body = JSON.parse(text);
223
+ } catch {
224
+ return { error: invalidResponse(status), status };
225
+ }
226
+ }
227
+ const error = errorFromBody(status, body) ?? invalidResponse(status);
228
+ return {
229
+ error: await settled(config, error, response.headers, token),
230
+ status
231
+ };
166
232
  }
167
233
  var RETRY_AFTER_CODES = /* @__PURE__ */ new Set([
168
234
  "RATE_LIMITED",
169
- "WRITE_QUOTA_EXCEEDED"
235
+ "WRITE_QUOTA_EXCEEDED",
236
+ // Storage (original spec §13): one object written twice within a second
237
+ // (`x-retry-after: 1`), and the daily operation limit (3-1d).
238
+ "OBJECT_RATE_LIMITED",
239
+ "STORAGE_OPS_QUOTA_EXCEEDED"
170
240
  ]);
171
241
  function withRetryAfter(error, headers) {
172
242
  const header = headers.get("x-retry-after")?.trim() ?? "";
@@ -1756,6 +1826,545 @@ var TableBuilder = class {
1756
1826
  }
1757
1827
  };
1758
1828
 
1829
+ // src/storage/answer.ts
1830
+ function fail(error) {
1831
+ return { data: null, error };
1832
+ }
1833
+ var isUploadResult = (value) => isRecord(value) && typeof value.id === "string" && typeof value.path === "string" && typeof value.fullPath === "string";
1834
+ function dataOf2(status, body, guard) {
1835
+ if (isRecord(body) && Object.hasOwn(body, "data") && guard(body.data))
1836
+ return { data: body.data, error: null };
1837
+ return fail(invalidResponse(status));
1838
+ }
1839
+ async function readJson(response, guard) {
1840
+ let text;
1841
+ try {
1842
+ text = await response.text();
1843
+ } catch (error) {
1844
+ return fail(networkError(error));
1845
+ }
1846
+ let body;
1847
+ try {
1848
+ body = JSON.parse(text);
1849
+ } catch {
1850
+ return fail(invalidResponse(response.status));
1851
+ }
1852
+ return dataOf2(response.status, body, guard);
1853
+ }
1854
+
1855
+ // src/storage/multipart.ts
1856
+ var ROUTE = "/storage/upload/multipart";
1857
+ var CONCURRENCY = 4;
1858
+ var ATTEMPTS = 4;
1859
+ var BACKOFF_MS = [1e3, 2e3, 4e3];
1860
+ var UPLOAD_ID = /^[\w-][\w.-]*$/;
1861
+ var positive = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1862
+ var planFor = (size) => (value) => isRecord(value) && typeof value.uploadId === "string" && UPLOAD_ID.test(value.uploadId) && positive(value.partSize) && positive(value.partCount) && (value.partCount - 1) * value.partSize < size && value.partCount * value.partSize >= size;
1863
+ var partAnswer = (n) => (value) => isRecord(value) && value.partNumber === n && typeof value.etag === "string" && value.etag !== "";
1864
+ var retryable = (error, status) => error.code === "NETWORK_ERROR" || error.code === "OBJECT_RATE_LIMITED" || status !== null && status >= 500 && error.code !== "STORAGE_QUOTA_EXCEEDED";
1865
+ function waitAfter(error, attempt2) {
1866
+ const after = isRecord(error.details) ? error.details.retryAfter : void 0;
1867
+ if (typeof after === "number") return after * 1e3;
1868
+ if (error.code === "OBJECT_RATE_LIMITED") return 1e3;
1869
+ return BACKOFF_MS[attempt2 - 1] ?? 4e3;
1870
+ }
1871
+ var sleepFor = (ms, signal) => new Promise((done) => {
1872
+ if (signal?.aborted) {
1873
+ done();
1874
+ return;
1875
+ }
1876
+ const end = () => {
1877
+ clearTimeout(timer);
1878
+ signal?.removeEventListener("abort", end);
1879
+ done();
1880
+ };
1881
+ const timer = setTimeout(end, ms);
1882
+ signal?.addEventListener("abort", end);
1883
+ });
1884
+ async function uploadInParts(a) {
1885
+ const { signal } = a;
1886
+ const sleep = a.sleep ?? sleepFor;
1887
+ if (signal?.aborted) return fail(uploadAborted());
1888
+ const started = await call(a.http, {
1889
+ method: a.method,
1890
+ path: ROUTE,
1891
+ body: {
1892
+ bucket: a.bucket,
1893
+ path: a.path,
1894
+ size: a.size,
1895
+ contentType: a.contentType,
1896
+ cacheControl: a.cacheControl,
1897
+ // PUT always replaces: the worker refuses `upsert` on it.
1898
+ ...a.method === "POST" && a.upsert ? { upsert: true } : {}
1899
+ },
1900
+ withToken: true
1901
+ });
1902
+ if (started.error !== null) return fail(started.error);
1903
+ const plan = dataOf2(
1904
+ started.answer.status,
1905
+ started.answer.body,
1906
+ planFor(a.size)
1907
+ );
1908
+ if (plan.error !== null) return fail(plan.error);
1909
+ const { uploadId, partSize, partCount } = plan.data;
1910
+ const base = `${ROUTE}/${uploadId}`;
1911
+ const progress = (sentBytes2) => {
1912
+ if (signal?.aborted) return;
1913
+ try {
1914
+ const result = a.onProgress?.(sentBytes2, a.size);
1915
+ if (isThenable(result)) result.then(void 0, () => void 0);
1916
+ } catch {
1917
+ }
1918
+ };
1919
+ const callOff = async () => {
1920
+ await call(a.http, { method: "DELETE", path: base, withToken: true }).catch(
1921
+ () => void 0
1922
+ );
1923
+ };
1924
+ const etags = [];
1925
+ const failure = {
1926
+ error: signal?.aborted ? uploadAborted() : null
1927
+ };
1928
+ let next = 1;
1929
+ let sentBytes = 0;
1930
+ const onAbort = () => {
1931
+ failure.error ??= uploadAborted();
1932
+ };
1933
+ const tryPart = async (n, bytes) => {
1934
+ const sent = await send(a.http, {
1935
+ method: "PUT",
1936
+ path: `${base}/${String(n)}`,
1937
+ headers: { "content-type": "application/octet-stream" },
1938
+ body: bytes,
1939
+ withToken: true,
1940
+ signal
1941
+ });
1942
+ if (sent.error !== null) return sent;
1943
+ const read = await readJson(sent.response, partAnswer(n));
1944
+ if (read.error !== null)
1945
+ return { error: read.error, status: sent.response.status };
1946
+ return { etag: read.data.etag };
1947
+ };
1948
+ const sendPart = async (n) => {
1949
+ const from = (n - 1) * partSize;
1950
+ const to = Math.min(from + partSize, a.size);
1951
+ const bytes = a.body instanceof Uint8Array ? a.body.subarray(from, to) : a.body.slice(from, to);
1952
+ for (let attempt2 = 1; ; attempt2 += 1) {
1953
+ const tried = await tryPart(n, bytes);
1954
+ if ("etag" in tried) {
1955
+ etags[n - 1] = tried.etag;
1956
+ sentBytes += to - from;
1957
+ progress(sentBytes);
1958
+ return null;
1959
+ }
1960
+ if (attempt2 === ATTEMPTS || failure.error !== null || !retryable(tried.error, tried.status))
1961
+ return tried.error;
1962
+ await sleep(waitAfter(tried.error, attempt2), signal);
1963
+ if (failure.error !== null) return tried.error;
1964
+ }
1965
+ };
1966
+ const worker = async () => {
1967
+ while (failure.error === null && next <= partCount) {
1968
+ const n = next;
1969
+ next += 1;
1970
+ const error = await sendPart(n);
1971
+ if (error !== null) failure.error ??= error;
1972
+ }
1973
+ };
1974
+ signal?.addEventListener("abort", onAbort);
1975
+ await Promise.all(
1976
+ Array.from({ length: Math.min(CONCURRENCY, partCount) }, worker)
1977
+ );
1978
+ signal?.removeEventListener("abort", onAbort);
1979
+ if (failure.error !== null) {
1980
+ await callOff();
1981
+ return fail(failure.error);
1982
+ }
1983
+ const completed = await call(a.http, {
1984
+ method: "POST",
1985
+ path: `${base}/complete`,
1986
+ body: {
1987
+ parts: etags.map((etag, i) => ({ partNumber: i + 1, etag }))
1988
+ },
1989
+ withToken: true
1990
+ });
1991
+ if (completed.error !== null) {
1992
+ if (completed.answer !== null && completed.error.code !== "INVALID_RESPONSE")
1993
+ await callOff();
1994
+ return fail(completed.error);
1995
+ }
1996
+ return dataOf2(completed.answer.status, completed.answer.body, isUploadResult);
1997
+ }
1998
+ var isThenable = (value) => typeof value === "object" && value !== null && typeof value.then === "function";
1999
+
2000
+ // src/storage/path.ts
2001
+ var MAX_SINGLE_UPLOAD_BYTES = 52428800;
2002
+ var STORAGE_MESSAGES = {
2003
+ bucket: "The bucket name is not valid.",
2004
+ request: "The storage request is not valid.",
2005
+ body: "upload takes a Blob, File, ArrayBuffer, typed array or string."
2006
+ };
2007
+ var RESERVED_BUCKETS = /* @__PURE__ */ new Set([
2008
+ "authenticated",
2009
+ "copy",
2010
+ "info",
2011
+ "list",
2012
+ "move",
2013
+ "public",
2014
+ "render",
2015
+ "sign",
2016
+ "upload"
2017
+ ]);
2018
+ var DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
2019
+ var LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
2020
+ var OCTET_STREAM = "application/octet-stream";
2021
+ var UTF8_TEXT = "text/plain;charset=utf-8";
2022
+ function invalidPath(reason) {
2023
+ return {
2024
+ code: "INVALID_PATH",
2025
+ message: `The object path is not valid: it ${reason}.`
2026
+ };
2027
+ }
2028
+ function encodePath(path) {
2029
+ if (typeof path !== "string" || path === "")
2030
+ return { ok: false, error: invalidPath("is empty") };
2031
+ if (LONE_SURROGATE.test(path))
2032
+ return { ok: false, error: invalidPath("is not valid UTF-8") };
2033
+ if (path.startsWith("/") || path.endsWith("/"))
2034
+ return { ok: false, error: invalidPath("starts or ends with /") };
2035
+ if (path.includes("//"))
2036
+ return { ok: false, error: invalidPath("has an empty segment (//)") };
2037
+ const segments = path.split("/");
2038
+ if (segments.some((segment) => segment === "." || segment === ".."))
2039
+ return { ok: false, error: invalidPath("has a . or .. segment") };
2040
+ return { ok: true, encoded: segments.map(encodeURIComponent).join("/") };
2041
+ }
2042
+ function downloadQuery(download) {
2043
+ if (download === true) return { ok: true, encoded: "?download" };
2044
+ if (typeof download !== "string") return { ok: true, encoded: "" };
2045
+ if (LONE_SURROGATE.test(download))
2046
+ return {
2047
+ ok: false,
2048
+ error: {
2049
+ code: "INVALID_REQUEST",
2050
+ message: STORAGE_MESSAGES.request,
2051
+ details: { path: "download", reason: DOWNLOAD_NAME_REASON }
2052
+ }
2053
+ };
2054
+ return { ok: true, encoded: `?download=${encodeURIComponent(download)}` };
2055
+ }
2056
+ function encodeBucket(bucket) {
2057
+ if (typeof bucket !== "string" || bucket === "" || bucket === "." || bucket === ".." || LONE_SURROGATE.test(bucket) || RESERVED_BUCKETS.has(bucket))
2058
+ return {
2059
+ ok: false,
2060
+ error: { code: "INVALID_PATH", message: STORAGE_MESSAGES.bucket }
2061
+ };
2062
+ return { ok: true, encoded: encodeURIComponent(bucket) };
2063
+ }
2064
+ function objectRoute(bucket, path) {
2065
+ const encodedBucket = encodeBucket(bucket);
2066
+ if (!encodedBucket.ok) return encodedBucket;
2067
+ const encodedPath = encodePath(path);
2068
+ if (!encodedPath.ok) return encodedPath;
2069
+ return {
2070
+ ok: true,
2071
+ encoded: `${encodedBucket.encoded}/${encodedPath.encoded}`
2072
+ };
2073
+ }
2074
+ var isArrayBuffer = (value) => Object.prototype.toString.call(value) === "[object ArrayBuffer]";
2075
+ function isDetached(buffer) {
2076
+ if (!isArrayBuffer(buffer)) return false;
2077
+ const flag = buffer.detached;
2078
+ if (typeof flag === "boolean") return flag;
2079
+ if (buffer.byteLength > 0) return false;
2080
+ try {
2081
+ buffer.slice(0);
2082
+ return false;
2083
+ } catch {
2084
+ return true;
2085
+ }
2086
+ }
2087
+ var CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
2088
+ var MAX_TYPE_IN_MESSAGE = 256;
2089
+ function unsendable(value) {
2090
+ return /[\0\r\n]|[^\0-\xff]/.test(
2091
+ value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "")
2092
+ );
2093
+ }
2094
+ function headerProblem(contentType, cacheControl) {
2095
+ if (typeof contentType === "string" && unsendable(contentType)) {
2096
+ let shown = contentType;
2097
+ if (shown.length > MAX_TYPE_IN_MESSAGE) {
2098
+ shown = shown.slice(0, MAX_TYPE_IN_MESSAGE);
2099
+ if (/[\ud800-\udbff]$/.test(shown)) shown = shown.slice(0, -1);
2100
+ }
2101
+ return {
2102
+ code: "INVALID_CONTENT_TYPE",
2103
+ message: `Content-Type ${shown} is not a media type like image/png.`
2104
+ };
2105
+ }
2106
+ if (unsendable(cacheControl))
2107
+ return {
2108
+ code: "INVALID_REQUEST",
2109
+ message: STORAGE_MESSAGES.request,
2110
+ details: { path: "cache-control", reason: CACHE_CONTROL_REASON }
2111
+ };
2112
+ return null;
2113
+ }
2114
+ function payloadOf(body, contentType) {
2115
+ if (typeof body === "string") {
2116
+ const bytes = new TextEncoder().encode(body);
2117
+ return {
2118
+ ok: true,
2119
+ body: bytes,
2120
+ size: bytes.byteLength,
2121
+ type: contentType ?? UTF8_TEXT
2122
+ };
2123
+ }
2124
+ if (typeof Blob === "function" && body instanceof Blob)
2125
+ return {
2126
+ ok: true,
2127
+ body,
2128
+ size: body.size,
2129
+ type: contentType ?? (body.type === "" ? OCTET_STREAM : body.type)
2130
+ };
2131
+ if (isArrayBuffer(body) && !isDetached(body))
2132
+ return {
2133
+ ok: true,
2134
+ body,
2135
+ size: body.byteLength,
2136
+ type: contentType ?? OCTET_STREAM
2137
+ };
2138
+ if (ArrayBuffer.isView(body) && !isDetached(body.buffer)) {
2139
+ const { buffer, byteOffset, byteLength } = body;
2140
+ const bytes = isArrayBuffer(buffer) ? new Uint8Array(buffer, byteOffset, byteLength) : Uint8Array.from(new Uint8Array(buffer, byteOffset, byteLength));
2141
+ return {
2142
+ ok: true,
2143
+ body: bytes,
2144
+ size: byteLength,
2145
+ type: contentType ?? OCTET_STREAM
2146
+ };
2147
+ }
2148
+ return {
2149
+ ok: false,
2150
+ error: { code: "INVALID_BODY", message: STORAGE_MESSAGES.body }
2151
+ };
2152
+ }
2153
+
2154
+ // src/storage/client.ts
2155
+ var SIGNED_PREFIX = "/_naro/v1/storage/object/sign/";
2156
+ var isFileObject = (value) => isRecord(value) && typeof value.id === "string" && typeof value.name === "string" && isRecord(value.metadata);
2157
+ var isEntry2 = (value) => isRecord(value) && typeof value.name === "string" && (value.id === null || isFileObject(value));
2158
+ var isSigned = (value) => isRecord(value) && typeof value.signedUrl === "string" && value.signedUrl.startsWith(SIGNED_PREFIX);
2159
+ function listOf(guard) {
2160
+ return (value) => Array.isArray(value) && value.every(guard);
2161
+ }
2162
+ var StorageFileApi = class {
2163
+ #http;
2164
+ #auth;
2165
+ #bucket;
2166
+ constructor(http, auth, bucket) {
2167
+ this.#http = http;
2168
+ this.#auth = auth;
2169
+ this.#bucket = bucket;
2170
+ }
2171
+ /** `/storage/object/<route><bucket>/<path>`, or the refusal no request needs. */
2172
+ #objectPath(route, path) {
2173
+ const target = objectRoute(this.#bucket, path);
2174
+ if (!target.ok) return target;
2175
+ return { ok: true, path: `/storage/object/${route}${target.encoded}` };
2176
+ }
2177
+ /** `/storage/object/<route><bucket>`, for the routes that take names in the body. */
2178
+ #bucketPath(route) {
2179
+ const bucket = encodeBucket(this.#bucket);
2180
+ if (!bucket.ok) return bucket;
2181
+ return { ok: true, path: `/storage/object/${route}${bucket.encoded}` };
2182
+ }
2183
+ async #json(request, guard) {
2184
+ await this.#auth.ready();
2185
+ const result = await call(this.#http, { ...request, withToken: true });
2186
+ if (result.error !== null) return fail(result.error);
2187
+ return dataOf2(result.answer.status, result.answer.body, guard);
2188
+ }
2189
+ async #write(method, path, body, options, upsert) {
2190
+ const target = this.#objectPath("", path);
2191
+ if (!target.ok) return fail(target.error);
2192
+ const payload = payloadOf(body, options.contentType);
2193
+ if (!payload.ok) return fail(payload.error);
2194
+ const cache = `max-age=${options.cacheControl ?? "3600"}`;
2195
+ const unreadable = headerProblem(options.contentType, cache);
2196
+ if (unreadable !== null) return fail(unreadable);
2197
+ const { signal } = options;
2198
+ if (signal?.aborted) return fail(uploadAborted());
2199
+ await this.#auth.ready();
2200
+ if (payload.size > MAX_SINGLE_UPLOAD_BYTES)
2201
+ return uploadInParts({
2202
+ http: this.#http,
2203
+ method,
2204
+ bucket: this.#bucket,
2205
+ path,
2206
+ // No copy: a Blob stays lazy, a Uint8Array is viewed, an ArrayBuffer wrapped.
2207
+ body: isArrayBuffer(payload.body) ? new Uint8Array(payload.body) : payload.body,
2208
+ size: payload.size,
2209
+ contentType: payload.type,
2210
+ cacheControl: cache,
2211
+ upsert,
2212
+ ...options.onProgress ? { onProgress: options.onProgress } : {},
2213
+ signal
2214
+ });
2215
+ const headers = {
2216
+ "content-type": payload.type,
2217
+ "cache-control": cache
2218
+ };
2219
+ if (upsert) headers["x-upsert"] = "true";
2220
+ const sent = await send(this.#http, {
2221
+ method,
2222
+ path: target.path,
2223
+ headers,
2224
+ body: payload.body,
2225
+ withToken: true,
2226
+ // The fetch stops with the body: the worker undoes its claim at once.
2227
+ signal
2228
+ });
2229
+ if (sent.error !== null) return fail(sent.error);
2230
+ return readJson(sent.response, isUploadResult);
2231
+ }
2232
+ /**
2233
+ * A new object; with `upsert: true`, one of the same name is replaced.
2234
+ * `null` options, as a JS caller may pass, are no options.
2235
+ */
2236
+ upload(path, body, options = {}) {
2237
+ const given = options ?? {};
2238
+ return this.#write("POST", path, body, given, given.upsert === true);
2239
+ }
2240
+ /** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
2241
+ update(path, body, options = {}) {
2242
+ return this.#write("PUT", path, body, options ?? {}, false);
2243
+ }
2244
+ /**
2245
+ * The object's bytes, as a Blob of the type the worker serves it under:
2246
+ * the stored type for the images, audio, video, PDF, plain text, CSV,
2247
+ * JSON and SVG it serves as themselves, `application/octet-stream` for
2248
+ * anything else (HTML, scripts, archives, unknown types — `info` and
2249
+ * `list` hold the stored type).
2250
+ */
2251
+ async download(path) {
2252
+ const target = this.#objectPath("", path);
2253
+ if (!target.ok) return fail(target.error);
2254
+ await this.#auth.ready();
2255
+ const sent = await send(this.#http, {
2256
+ method: "GET",
2257
+ path: target.path,
2258
+ withToken: true
2259
+ });
2260
+ if (sent.error !== null) return fail(sent.error);
2261
+ try {
2262
+ return { data: await sent.response.blob(), error: null };
2263
+ } catch (error) {
2264
+ return fail(networkError(error));
2265
+ }
2266
+ }
2267
+ async info(path) {
2268
+ const target = this.#objectPath("info/", path);
2269
+ if (!target.ok) return fail(target.error);
2270
+ return this.#json({ method: "GET", path: target.path }, isFileObject);
2271
+ }
2272
+ /**
2273
+ * Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
2274
+ * every other error comes back as it is (plan C1 — asked with `info`,
2275
+ * not HEAD, whose answer has no body to tell a missing bucket from a
2276
+ * missing object).
2277
+ */
2278
+ async exists(path) {
2279
+ const found = await this.info(path);
2280
+ if (found.error === null) return { data: true, error: null };
2281
+ if (found.error.code === "OBJECT_NOT_FOUND")
2282
+ return { data: false, error: null };
2283
+ return fail(found.error);
2284
+ }
2285
+ /**
2286
+ * What is directly below `prefix` (the whole bucket by default): folders
2287
+ * first, then files. The options go to the worker as they are given.
2288
+ */
2289
+ async list(prefix, options = {}) {
2290
+ const target = this.#bucketPath("list/");
2291
+ if (!target.ok) return fail(target.error);
2292
+ return this.#json(
2293
+ {
2294
+ method: "POST",
2295
+ path: target.path,
2296
+ body: { ...prefix === void 0 ? {} : { prefix }, ...options }
2297
+ },
2298
+ listOf(isEntry2)
2299
+ );
2300
+ }
2301
+ /** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
2302
+ async remove(paths) {
2303
+ const target = this.#bucketPath("");
2304
+ if (!target.ok) return fail(target.error);
2305
+ return this.#json(
2306
+ { method: "DELETE", path: target.path, body: { prefixes: paths } },
2307
+ listOf(isFileObject)
2308
+ );
2309
+ }
2310
+ /**
2311
+ * A link to the object for `expiresIn` seconds (1–604,800). It stops
2312
+ * working once the object is replaced or removed.
2313
+ */
2314
+ async createSignedUrl(path, expiresIn, options = {}) {
2315
+ const target = this.#objectPath("sign/", path);
2316
+ if (!target.ok) return fail(target.error);
2317
+ const { download } = options ?? {};
2318
+ const signed = await this.#json(
2319
+ {
2320
+ method: "POST",
2321
+ path: target.path,
2322
+ body: {
2323
+ expiresIn,
2324
+ ...download === void 0 || download === false ? {} : { download }
2325
+ }
2326
+ },
2327
+ isSigned
2328
+ );
2329
+ if (signed.error !== null) return signed;
2330
+ return {
2331
+ data: { signedUrl: `${this.#http.baseUrl}${signed.data.signedUrl}` },
2332
+ error: null
2333
+ };
2334
+ }
2335
+ /**
2336
+ * A public bucket's URL for the object. No request is made, so whether
2337
+ * the bucket is public is not checked (as in supabase-js); a path no URL
2338
+ * can carry is INVALID_PATH (plan C3), and a download name none can carry
2339
+ * is the worker's INVALID_REQUEST for it.
2340
+ */
2341
+ getPublicUrl(path, options = {}) {
2342
+ const target = this.#objectPath("public/", path);
2343
+ if (!target.ok) return fail(target.error);
2344
+ const { download } = options ?? {};
2345
+ const query = downloadQuery(download);
2346
+ if (!query.ok) return fail(query.error);
2347
+ return {
2348
+ data: {
2349
+ publicUrl: `${this.#http.baseUrl}/_naro/v1${target.path}${query.encoded}`
2350
+ },
2351
+ error: null
2352
+ };
2353
+ }
2354
+ };
2355
+ var StorageClient = class {
2356
+ #http;
2357
+ #auth;
2358
+ constructor(http, auth) {
2359
+ this.#http = http;
2360
+ this.#auth = auth;
2361
+ }
2362
+ /** The objects of one bucket. */
2363
+ from(bucket) {
2364
+ return new StorageFileApi(this.#http, this.#auth, bucket);
2365
+ }
2366
+ };
2367
+
1759
2368
  // src/client.ts
1760
2369
  var CONFIG_MESSAGES = {
1761
2370
  url: "createClient needs the project url, like https://my-app.naro.sh.",
@@ -1815,9 +2424,13 @@ var NaroClient = class {
1815
2424
  * service key, `auth.admin` manages users.
1816
2425
  */
1817
2426
  auth;
2427
+ /**
2428
+ * Files in the project's Naro Storage (first-release design 2026-10-06
2429
+ * §8.1): `storage.from(bucket)` uploads, downloads, lists and removes
2430
+ * them, as supabase-js does.
2431
+ */
2432
+ storage;
1818
2433
  #run;
1819
- // naro.storage — Phase 3 (spec §18): R2 storage lands here, beside `auth`
1820
- // and `from()`. Deliberately absent in Phase 1: not implemented, not typed.
1821
2434
  constructor(options) {
1822
2435
  const baseUrl = baseUrlOf(options.url);
1823
2436
  const { key: apiKey, service } = apiKeyOf(options);
@@ -1842,6 +2455,7 @@ var NaroClient = class {
1842
2455
  serviceKey: service
1843
2456
  });
1844
2457
  this.#run = queryRunner(http, this.auth);
2458
+ this.storage = new StorageClient(http, this.auth);
1845
2459
  }
1846
2460
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
1847
2461
  from(table) {
@@ -1854,5 +2468,6 @@ function createClient(options) {
1854
2468
  export {
1855
2469
  NaroClient,
1856
2470
  createClient,
2471
+ objectRoute,
1857
2472
  quoteFilterValue
1858
2473
  };
@@ -0,0 +1,9 @@
1
+ import { type NaroError } from "../errors.js";
2
+ import type { NaroResponse } from "../types.js";
3
+ import type { UploadResult } from "./types.js";
4
+ export declare function fail<T>(error: NaroError): NaroResponse<T>;
5
+ export declare const isUploadResult: (value: unknown) => value is UploadResult;
6
+ /** `{ data }` of a worker's JSON answer that passes `guard`, else INVALID_RESPONSE. */
7
+ export declare function dataOf<T>(status: number, body: unknown, guard: (value: unknown) => value is T): NaroResponse<T>;
8
+ /** A 2xx answer `send` handed back unread, read as `dataOf` reads one. */
9
+ export declare function readJson<T>(response: Response, guard: (value: unknown) => value is T): Promise<NaroResponse<T>>;
@@ -0,0 +1,65 @@
1
+ import type { AuthClient } from "../auth/client.js";
2
+ import { type HttpConfig } from "../http.js";
3
+ import type { NaroResponse } from "../types.js";
4
+ import type { DownloadOption, FileObject, FolderEntry, ListOptions, UpdateOptions, UploadBody, UploadOptions, UploadResult } from "./types.js";
5
+ /** One bucket's objects: supabase-js's StorageFileApi. */
6
+ export declare class StorageFileApi {
7
+ #private;
8
+ constructor(http: HttpConfig, auth: AuthClient, bucket: string);
9
+ /**
10
+ * A new object; with `upsert: true`, one of the same name is replaced.
11
+ * `null` options, as a JS caller may pass, are no options.
12
+ */
13
+ upload(path: string, body: UploadBody, options?: UploadOptions): Promise<NaroResponse<UploadResult>>;
14
+ /** Replaces an object that is there (`OBJECT_NOT_FOUND` otherwise). */
15
+ update(path: string, body: UploadBody, options?: UpdateOptions): Promise<NaroResponse<UploadResult>>;
16
+ /**
17
+ * The object's bytes, as a Blob of the type the worker serves it under:
18
+ * the stored type for the images, audio, video, PDF, plain text, CSV,
19
+ * JSON and SVG it serves as themselves, `application/octet-stream` for
20
+ * anything else (HTML, scripts, archives, unknown types — `info` and
21
+ * `list` hold the stored type).
22
+ */
23
+ download(path: string): Promise<NaroResponse<Blob>>;
24
+ info(path: string): Promise<NaroResponse<FileObject>>;
25
+ /**
26
+ * Whether the caller can see the object: `OBJECT_NOT_FOUND` is false,
27
+ * every other error comes back as it is (plan C1 — asked with `info`,
28
+ * not HEAD, whose answer has no body to tell a missing bucket from a
29
+ * missing object).
30
+ */
31
+ exists(path: string): Promise<NaroResponse<boolean>>;
32
+ /**
33
+ * What is directly below `prefix` (the whole bucket by default): folders
34
+ * first, then files. The options go to the worker as they are given.
35
+ */
36
+ list(prefix?: string, options?: ListOptions): Promise<NaroResponse<(FileObject | FolderEntry)[]>>;
37
+ /** Removes the objects named — 1 to 1,000 — and answers the ones it removed. */
38
+ remove(paths: string[]): Promise<NaroResponse<FileObject[]>>;
39
+ /**
40
+ * A link to the object for `expiresIn` seconds (1–604,800). It stops
41
+ * working once the object is replaced or removed.
42
+ */
43
+ createSignedUrl(path: string, expiresIn: number, options?: DownloadOption): Promise<NaroResponse<{
44
+ signedUrl: string;
45
+ }>>;
46
+ /**
47
+ * A public bucket's URL for the object. No request is made, so whether
48
+ * the bucket is public is not checked (as in supabase-js); a path no URL
49
+ * can carry is INVALID_PATH (plan C3), and a download name none can carry
50
+ * is the worker's INVALID_REQUEST for it.
51
+ */
52
+ getPublicUrl(path: string, options?: DownloadOption): NaroResponse<{
53
+ publicUrl: string;
54
+ }>;
55
+ }
56
+ /**
57
+ * `naro.storage`. Buckets are made with `naro storage bucket create` or the
58
+ * MCP server, never from a client (original spec §6).
59
+ */
60
+ export declare class StorageClient {
61
+ #private;
62
+ constructor(http: HttpConfig, auth: AuthClient);
63
+ /** The objects of one bucket. */
64
+ from(bucket: string): StorageFileApi;
65
+ }
@@ -0,0 +1,23 @@
1
+ import { type HttpConfig } from "../http.js";
2
+ import type { NaroResponse } from "../types.js";
3
+ import type { UploadResult } from "./types.js";
4
+ export type PartsArgs = {
5
+ http: HttpConfig;
6
+ /** POST for `upload` (a new name, or `upsert`), PUT for `update`. */
7
+ method: "POST" | "PUT";
8
+ bucket: string;
9
+ /** The object's name as given: the start carries it in JSON, not a URL. */
10
+ path: string;
11
+ body: Blob | Uint8Array<ArrayBuffer>;
12
+ size: number;
13
+ contentType: string;
14
+ /** As the upload's header says it: `max-age=<seconds>`. */
15
+ cacheControl: string;
16
+ upsert: boolean;
17
+ onProgress?: (sentBytes: number, totalBytes: number) => void;
18
+ /** The caller's: stops the upload until the complete is sent. */
19
+ signal?: AbortSignal | undefined;
20
+ /** How a retry waits — `ms`, or until the signal aborts; tests pass their own. */
21
+ sleep?: (ms: number, signal: AbortSignal | undefined) => Promise<void>;
22
+ };
23
+ export declare function uploadInParts(a: PartsArgs): Promise<NaroResponse<UploadResult>>;
@@ -0,0 +1,87 @@
1
+ import type { NaroError } from "../errors.js";
2
+ /**
3
+ * The most one upload request carries (original spec §9.1): 50 MiB.
4
+ * data-core's MAX_SINGLE_UPLOAD_BYTES — storage/wire.test.ts holds them
5
+ * equal. Over it, the edge cuts the body off and the browser often cannot
6
+ * read the answer: the SDK sends a larger body in parts (Storage 3-1f).
7
+ */
8
+ export declare const MAX_SINGLE_UPLOAD_BYTES = 52428800;
9
+ /** The worker's words (data-core's catalog) for what the SDK refuses itself. */
10
+ export declare const STORAGE_MESSAGES: {
11
+ readonly bucket: "The bucket name is not valid.";
12
+ readonly request: "The storage request is not valid.";
13
+ readonly body: "upload takes a Blob, File, ArrayBuffer, typed array or string.";
14
+ };
15
+ /**
16
+ * The words the worker's routes take as the first segment after `/object/`
17
+ * (data-core's STORAGE_RESERVED_WORDS): as a bucket, the URL would name
18
+ * another route. No bucket can have one of these names.
19
+ */
20
+ export declare const RESERVED_BUCKETS: ReadonlySet<string>;
21
+ /** Why the worker refuses a `download` name (serve.ts's DOWNLOAD_NAME_REASON). */
22
+ export declare const DOWNLOAD_NAME_REASON = "must be a file name of up to 255 bytes";
23
+ export type Encoded = {
24
+ ok: true;
25
+ encoded: string;
26
+ } | {
27
+ ok: false;
28
+ error: NaroError;
29
+ };
30
+ export declare function invalidPath(reason: string): NaroError;
31
+ /**
32
+ * An object path as a URL carries it, each segment encoded once and `/`
33
+ * kept as the separator — or the worker's INVALID_PATH for the paths no
34
+ * URL can carry as they are (original spec §13, plan C2): an empty name, a
35
+ * `/` at either end, `//`, a `.` or `..` segment (WHATWG URL parsing folds
36
+ * `..` and `%2E%2E` alike, so the worker would get another path), and a
37
+ * lone surrogate (`encodeURIComponent` throws on one). Every other rule is
38
+ * the worker's alone — one set of rules, in one place.
39
+ */
40
+ export declare function encodePath(path: string): Encoded;
41
+ /**
42
+ * `?download` for the object's own name, `?download=<name>` for another, or
43
+ * the worker's own INVALID_REQUEST for a name `encodeURIComponent` throws on
44
+ * (a lone surrogate: the sign route refuses the same name in its body). Every
45
+ * other rule on the name is the worker's alone.
46
+ */
47
+ export declare function downloadQuery(download: boolean | string | undefined): Encoded;
48
+ /**
49
+ * A bucket name as the URL's segment — encoded (a valid name is unchanged
50
+ * by it), or INVALID_PATH for the names that would not reach a bucket:
51
+ * empty, `.`, `..`, a lone surrogate and the routes' own words.
52
+ */
53
+ export declare function encodeBucket(bucket: string): Encoded;
54
+ /**
55
+ * `<bucket>/<path>` as the object routes carry it — the bucket by
56
+ * `encodeBucket`, the path by `encodePath` — or the INVALID_PATH either is
57
+ * refused with. Every object call builds its URL from it, and the package
58
+ * exports it for a tool that streams an object itself (naro-cli's
59
+ * download): it names the same URL, and is refused the same names.
60
+ */
61
+ export declare function objectRoute(bucket: string, path: string): Encoded;
62
+ /**
63
+ * `instanceof ArrayBuffer` is false for one made in another realm (an
64
+ * iframe, a vm context); the tag is the same in every realm.
65
+ */
66
+ export declare const isArrayBuffer: (value: unknown) => value is ArrayBuffer;
67
+ /** Why the worker refuses a cache-control it cannot read (its upload.ts). */
68
+ export declare const CACHE_CONTROL_REASON = "must be max-age=<seconds> from 0 to 31536000";
69
+ /**
70
+ * The upload's `contentType` and `cacheControl` as headers, or the worker's
71
+ * answer for a value it cannot read when no header can carry it (PR #90
72
+ * review 1): INVALID_CONTENT_TYPE with data-core's words (the type cut at
73
+ * 256 UTF-16 units, as `storageName` cuts it), and INVALID_REQUEST
74
+ * `cache-control`. Every other value is the worker's to judge.
75
+ */
76
+ export declare function headerProblem(contentType: string | undefined, cacheControl: string): NaroError | null;
77
+ export type Payload = {
78
+ ok: true;
79
+ body: Blob | ArrayBuffer | Uint8Array<ArrayBuffer>;
80
+ size: number;
81
+ type: string;
82
+ } | {
83
+ ok: false;
84
+ error: NaroError;
85
+ };
86
+ /** The upload's body, its bytes and its type — or INVALID_BODY. */
87
+ export declare function payloadOf(body: unknown, contentType: string | undefined): Payload;
@@ -0,0 +1,77 @@
1
+ /** An object as the worker answers it. */
2
+ export type FileObject = {
3
+ id: string;
4
+ /** The full path from `info` and `remove`; the name below the prefix in `list`. */
5
+ name: string;
6
+ bucket_id: string;
7
+ /** Who uploaded it; null for the anon or the service key. */
8
+ owner: string | null;
9
+ owner_id: string | null;
10
+ /** The bytes' etag. */
11
+ version: string;
12
+ created_at: string;
13
+ updated_at: string;
14
+ /** `eTag`, `size`, `mimetype`, `cacheControl`, `lastModified`, `contentLength`, `httpStatusCode`. */
15
+ metadata: Record<string, unknown>;
16
+ };
17
+ /** A folder in a listing: a name and nothing else. Folders come first. */
18
+ export type FolderEntry = {
19
+ name: string;
20
+ id: null;
21
+ created_at: null;
22
+ updated_at: null;
23
+ metadata: null;
24
+ };
25
+ /**
26
+ * What an upload sends: bytes whose length `fetch` knows, so it sets
27
+ * `Content-Length`. A string goes as UTF-8. Not a stream, not `FormData`.
28
+ */
29
+ export type UploadBody = Blob | ArrayBuffer | ArrayBufferView | string;
30
+ export type UploadOptions = {
31
+ /** Default: the Blob's type, `text/plain;charset=utf-8` for a string, else `application/octet-stream`. */
32
+ contentType?: string;
33
+ /** Seconds a browser may keep it, as text. Default `"3600"`. */
34
+ cacheControl?: string;
35
+ /** Replace an object of the same name. Default false. */
36
+ upsert?: boolean;
37
+ /**
38
+ * Called each time a part of an upload over 50 MiB is sent: the bytes
39
+ * sent so far and the body's size, ending with both the same. An upload
40
+ * of 50 MiB or less is one request and never calls it. What it throws is
41
+ * ignored: the upload goes on.
42
+ */
43
+ onProgress?: (sentBytes: number, totalBytes: number) => void;
44
+ /**
45
+ * Stops the upload: what has not been answered yet is called off, and the
46
+ * upload answers `ABORTED` ("The upload was stopped before it finished.").
47
+ * One over 50 MiB is called off on the backend too, so its name and the
48
+ * space it reserved are free at once — not after the hour its lock lasts;
49
+ * no part starts and `onProgress` is not called after the signal. Its
50
+ * first and last steps are never cut short: a signal during the first is
51
+ * acted on once it answers, and once the last is sent the signal stops
52
+ * nothing and that step's answer is the upload's. A signal already aborted
53
+ * sends nothing.
54
+ */
55
+ signal?: AbortSignal;
56
+ };
57
+ export type UpdateOptions = Omit<UploadOptions, "upsert">;
58
+ export type UploadResult = {
59
+ id: string;
60
+ path: string;
61
+ fullPath: string;
62
+ };
63
+ export type ListOptions = {
64
+ /** 1–1,000; 100 by default. */
65
+ limit?: number;
66
+ /** 0–100,000. */
67
+ offset?: number;
68
+ /** Folders come first whatever the sort. Default name ascending. */
69
+ sortBy?: {
70
+ column?: "name" | "created_at" | "updated_at";
71
+ order?: "asc" | "desc";
72
+ };
73
+ };
74
+ /** `true` downloads under the object's own name, a string under that name. */
75
+ export type DownloadOption = {
76
+ download?: boolean | string;
77
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "naro-js",
3
- "version": "0.2.0",
4
- "description": "Auth and data for your Naro project, from the browser or the server.",
3
+ "version": "0.4.0",
4
+ "description": "Auth, data and files for your Naro project, from the browser or the server.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "files": [