naro-js 0.5.0 → 0.6.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
@@ -79,6 +79,7 @@ await naro.from("posts").delete().eq("id", 10);
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
81
  - Files — see [Storage](#storage).
82
+ - Live changes, broadcast and presence — see [Realtime](#realtime).
82
83
 
83
84
  ## Policies
84
85
 
@@ -271,12 +272,14 @@ await naro.auth.updateUser({ password: newPassword });
271
272
  redirects off them. `getSessionFromUrl()` never shows text from the URL:
272
273
  its errors carry the SDK's own messages.
273
274
  - A provider email the provider has not verified is refused
274
- (`OAUTH_PROVIDER_EMAIL_NOT_VERIFIED`): verify it with the provider first.
275
+ (`OAUTH_PROVIDER_EMAIL_NOT_VERIFIED`, or `OAUTH_ACCOUNT_NOT_LINKED` when an
276
+ account with that address already exists): verify it with the provider first.
275
277
  An account with the same email is linked only when both sides have it
276
278
  confirmed (`OAUTH_ACCOUNT_NOT_LINKED` otherwise).
277
279
  - A confirmation link opens no session. If someone else signed up with the
278
280
  address before its owner confirmed it — or the owner's Google or GitHub
279
- sign-in was refused for that unconfirmed account
281
+ sign-in, with an address the provider verified, was refused for that
282
+ unconfirmed account
280
283
  (`OAUTH_ACCOUNT_NOT_LINKED`) — confirming clears that password
281
284
  (`PASSWORD_RESET_REQUIRED`): set one with `resetPasswordForEmail`.
282
285
  - `updateUser({ password })` sets a new password in the recovery flow only
@@ -489,16 +492,25 @@ const { data: links } = await naro.storage
489
492
  const { data: plans } = await naro.storage
490
493
  .from("docs")
491
494
  .list("team-1", { search: "plan" });
495
+ const { data: copied } = await naro.storage
496
+ .from("docs")
497
+ .copy("team-1/plan.pdf", "team-2/plan.pdf", { upsert: true });
498
+ await naro.storage
499
+ .from("docs")
500
+ .move("team-1/plan.pdf", "team-1/archive/plan.pdf");
492
501
  ```
493
502
 
494
- - `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`,
495
- `createSignedUrl`, `createSignedUrls` and `getPublicUrl` take
503
+ - `upload`, `update`, `download`, `info`, `exists`, `list`, `remove`, `move`,
504
+ `copy`, `createSignedUrl`, `createSignedUrls` and `getPublicUrl` take
496
505
  supabase-js's arguments and answer its shapes (`FileObject` in
497
- snake_case), as `{ data, error }`.
498
- One exception: supabase-js's `info` answers a camelCase object
499
- (`bucketId`, `createdAt`, `size`, `contentType`, …); naro-js's `info`
500
- answers the same snake_case `FileObject` as `list`, with the size and type
501
- in `metadata.size` and `metadata.mimetype`.
506
+ snake_case), as `{ data, error }`, with two exceptions. supabase-js's
507
+ `info` answers a camelCase object (`bucketId`, `createdAt`, `size`,
508
+ `contentType`, …); naro-js's `info` answers the same snake_case
509
+ `FileObject` as `list`, with the size and type in `metadata.size` and
510
+ `metadata.mimetype`. supabase-js's `copy` answers `{ path }` with the
511
+ bucket in front of the name; naro-js's `copy` answers
512
+ `{ id, path, fullPath }`, as `upload` does, with `path` the name inside
513
+ the bucket.
502
514
  - `createSignedUrls(paths, expiresIn, { download })` signs 1 to 100 paths in
503
515
  one request (naro-js 0.5.0) and answers one `{ path, signedUrl, error }`
504
516
  per path, in the order given — a path given twice is answered twice. A
@@ -515,17 +527,37 @@ const { data: plans } = await naro.storage
515
527
  wildcards. Unlike Supabase, only ASCII letters ignore case (Postgres folds
516
528
  case by the database's locale) and `search` takes up to 256 characters. A
517
529
  folder shows when a name under it starts with `search`.
518
- - A backend whose Storage was turned on before these two came answers
519
- `createSignedUrls` with `404 NOT_FOUND` and a `search` with
520
- `400 INVALID_REQUEST` (`details.path` `search`, `details.reason`
530
+ - `move(from, to)` and `copy(from, to, { upsert })` work inside one bucket
531
+ (naro-js 0.6.0), on files up to 5 GiB − 5 MiB (`413 OBJECT_TOO_LARGE`
532
+ `Objects over 5,363,466,240 bytes (5 GiB − 5 MiB) cannot be copied or moved.`).
533
+ A move keeps the file — its `id` and owner — under the new name and never
534
+ replaces a file that is there (`409 OBJECT_EXISTS`); the file's signed
535
+ links stop working at its old name (a link is bound to the name, the
536
+ file's `id` and its bytes, so moving it back can open them again). A copy
537
+ to a new name is a new file of the caller's, held to the bucket's limits
538
+ as they are now; `upsert: true` replaces one that is there, which keeps
539
+ its owner. Both are
540
+ judged by the policies as in Supabase: a move needs SELECT and UPDATE on the
541
+ file, and SELECT and the UPDATE check on its new name; a copy needs SELECT
542
+ on the source and INSERT on the copy (with `upsert: true`, replacing a file
543
+ takes what an upload's `upsert` takes). While a file is moved, or copied to
544
+ a new name, the project needs room for one more copy of it (a copy that
545
+ replaces a file with `upsert: true` needs only the growth), so in a project
546
+ that is almost full they can answer `507 STORAGE_QUOTA_EXCEEDED`. `copy`
547
+ answers `{ id, path, fullPath }`, not supabase-js's `{ path }` (see
548
+ above). Unlike supabase-js, a `destinationBucket` other than the bucket
549
+ answers `400 INVALID_REQUEST`.
550
+ - A backend whose Storage was turned on before these came answers
551
+ `createSignedUrls`, `move` and `copy` with `404 NOT_FOUND` and a `search`
552
+ with `400 INVALID_REQUEST` (`details.path` `search`, `details.reason`
521
553
  `search is not supported yet`): update it once with `naro backend enable`.
522
554
  - A public file is kept at Naro's edge for up to 5 minutes (or for its own
523
- `cacheControl`, when that is shorter): after it is deleted, its bucket is
524
- made private or it is replaced, `getPublicUrl`'s URL can still serve the
525
- old bytes that long. Put a version in names that change
526
- (`avatar-<time>.png`) or keep `cacheControl` short. Signed URLs are not
527
- kept at the edge: replacing or deleting a file ends its signed links at
528
- once.
555
+ `cacheControl`, when that is shorter): after it is deleted, moved or
556
+ replaced, or its bucket is made private, `getPublicUrl`'s URL (for a moved
557
+ file, the old name's) can still serve the old bytes that long. Put a
558
+ version in names that change (`avatar-<time>.png`) or keep `cacheControl`
559
+ short. Signed URLs are not kept at the edge: replacing or deleting a file
560
+ ends its signed links at once, and moving it ends them at its old name.
529
561
  - An upload sends the file's own bytes, typed as the file is (`Blob.type`,
530
562
  or `application/octet-stream`; a string is `text/plain;charset=utf-8`).
531
563
  The body is a `Blob`, `File`, `ArrayBuffer`, typed array or string — not
@@ -630,5 +662,201 @@ await naro.storage
630
662
  downloads that way.
631
663
  - `OBJECT_RATE_LIMITED` and `STORAGE_OPS_QUOTA_EXCEEDED` carry
632
664
  `details.retryAfter`, in seconds.
633
- - Not yet: `move`, `copy`, resuming an upload in another session, and bucket
634
- methods (buckets are made with `naro storage bucket`).
665
+ - Not yet: resuming an upload in another session, and bucket methods
666
+ (buckets are made with `naro storage bucket`).
667
+
668
+ ## Realtime
669
+
670
+ Row changes, broadcast and presence over one WebSocket per page, called the
671
+ way supabase-js's `channel()` is (naro-js 0.6.0). Turn it on for the project
672
+ (the backend must be on) and publish the tables whose changes subscribers
673
+ get:
674
+
675
+ ```sh
676
+ naro realtime enable
677
+ naro realtime tables add messages
678
+ ```
679
+
680
+ ```ts
681
+ const room = naro.channel("room:1", {
682
+ config: { private: true, presence: { key: user.id } },
683
+ });
684
+
685
+ room
686
+ .on(
687
+ "postgres_changes",
688
+ { event: "INSERT", table: "messages", filter: "room_id=eq.1" },
689
+ (change) => append(change.new),
690
+ )
691
+ .on("broadcast", { event: "cursor" }, ({ payload }) => moveCursor(payload))
692
+ .on("presence", { event: "sync" }, () => render(room.presenceState()))
693
+ .on("system", {}, (notice) => {
694
+ // BULK_CHANGE, EVENTS_DROPPED: some changes did not come as rows.
695
+ if (notice.extension === "postgres_changes") refetchMessages();
696
+ })
697
+ .subscribe(async (status, error) => {
698
+ if (status === "SUBSCRIBED") {
699
+ refetchMessages(); // what changed while it was away does not come again
700
+ await room.track({ online_at: new Date().toISOString() });
701
+ }
702
+ if (status === "CHANNEL_ERROR") console.warn(error?.code, error?.message);
703
+ });
704
+
705
+ await room.send({
706
+ type: "broadcast",
707
+ event: "cursor",
708
+ payload: { x: 10, y: 20 },
709
+ });
710
+ await naro.removeChannel(room);
711
+ ```
712
+
713
+ - What becomes an event: Realtime publishes changes made through the Data
714
+ API (naro-js, any key). Writes your app makes with env.DB, naro db query
715
+ and the table editor are not published; send them with the realtime
716
+ broadcast API if clients need to know. Nor are the changes a trigger or a
717
+ cascade makes to other rows. A table publishes within about 10 seconds of
718
+ `naro realtime tables add`; a channel joined under a table's old name
719
+ receives nothing after a rename — join again with the new one.
720
+ - A subscriber receives a row change only if its select policies on the
721
+ table let it read the row (for a `DELETE`, the row as it was); without a
722
+ select policy only the service key does. Broadcast and presence need a
723
+ private channel (`{ private: true }`) and policies on `realtime.messages`:
724
+ `select` to join and receive, `insert` to send and track, with
725
+ `realtime.topic()` as the channel's name. A policy that reads another
726
+ table — a members table — reads it through that table's own select
727
+ policies, so that table needs one too, or every private join is refused.
728
+ Declare a type (`TEXT`, `INTEGER`, `REAL`) on every column a select policy
729
+ reads.
730
+ - Nothing that changed while the connection was away comes again: read again
731
+ when the channel says `SUBSCRIBED`. `BULK_CHANGE` (one request changed over
732
+ 1,000 rows or 8 MiB) and `EVENTS_DROPPED` reach `.on("system", {}, …)` with
733
+ `extension: "postgres_changes"`, and both mean: read the table again.
734
+ Changes can arrive out of order, even two writes of one client: order by
735
+ `commit_timestamp`.
736
+ - Every call returns `{ data, error }` and none throws. `subscribe(callback)`
737
+ hears `SUBSCRIBED`, `CHANNEL_ERROR` (with the error), `TIMED_OUT` and
738
+ `CLOSED`. The connection opens at the first `subscribe()` and closes with
739
+ the last channel; a join goes once the server has taken who the connection
740
+ is — on a page a sign-in link or provider sent back to, after that sign-in
741
+ has landed — and a sign-in, a sign-out or a renewed session reaches the
742
+ open connection at once.
743
+ - Make one client per page and share it: a sign-in or sign-out through one
744
+ `createClient()` reaches only its own connection, and another client of the
745
+ same project keeps who it was until its next connection.
746
+ - `send()` on a joined channel waits, as `track()` does, while a sign-in or
747
+ sign-out is on its way, and goes over HTTP if the join went meanwhile. A
748
+ payload over the plan's limit answers `PAYLOAD_TOO_LARGE`; a message whose
749
+ frame is over that limit plus 16 KiB answers `INVALID_MESSAGE`
750
+ (`is larger than N bytes`), and later ones over N on that connection get
751
+ the same answer without being sent.
752
+ - Close codes: 1000 (a plain close, from the server or a proxy), 4000, 4001,
753
+ 4003, 4004 and 4410 stop it — call `subscribe()` again; it connects after
754
+ the backoff, counted from the stop, so a `subscribe()` in the
755
+ `CHANNEL_ERROR` callback does not spin. 4029 comes back after 5 seconds at
756
+ least (60 to 70 for `IP_RATE_LIMITED`); 4503 after 0 to 10 seconds, every
757
+ time; anything else after 1, 2, 5 and then 10 seconds, each times 0.5 to
758
+ 1.5 — after a 4008 the steps start over only once the connection outlived
759
+ a heartbeat. Removing the last channel and subscribing again keeps the wait
760
+ the server asked for. A 4401 signs out
761
+ only when the server refused the session it sent with `INVALID_SESSION`
762
+ and that session is still the one stored; one without that answer — a tab
763
+ open for days — keeps the session and connects again with it.
764
+ - `unsubscribe()` and `removeChannel()` return the server's first answer to
765
+ the leave. A leave refused for now (`TOO_MANY_JOINS`, `INTERNAL_ERROR`,
766
+ `CONNECTION_STATE_TOO_LARGE`, `TIMED_OUT`) is sent again until the server
767
+ takes it, and a `subscribe()` of the same name and privacy joins after
768
+ that. While such a leave holds its place under the server's caps (4
769
+ channels of a connection on one table's changes, 100 a connection), a join
770
+ of another name refused by one (`INVALID_CHANNEL_CONFIG`,
771
+ `TOO_MANY_CHANNELS`) waits for it and joins, instead of stopping. That can
772
+ happen right after a `removeChannel()`: while your sends wait their turn
773
+ (below), your leave waits behind them, but another channel's join goes
774
+ first, so that join may hear the cap once (`CHANNEL_ERROR`) before it
775
+ joins. Removing the last channel closes the connection: `removeChannel()`
776
+ then answers `{ data: null, error: null }`, and a `send()` or `track()`
777
+ still waiting gets `ABORTED`
778
+ (`The realtime connection was closed by the app (it removed its last channel) before the Naro realtime server answered.`).
779
+ Do not use a removed channel again: `channel()` makes a new one.
780
+ - Presence: `track()` works on a joined channel (`NOT_JOINED` before), with a
781
+ JSON object of up to 10 keys and 1 KiB and no key starting with `naro_` or
782
+ `phx_`: the server answers a bigger one, or one with such a key,
783
+ `PRESENCE_TOO_LARGE`
784
+ (`Presence state must be a JSON object with up to 10 keys and 1 KiB, and no key starting with naro_ or phx_.`),
785
+ and naro-js refuses what is not a JSON object before it is sent
786
+ (`INVALID_MESSAGE`, `needs a presence payload object`). Each entry carries
787
+ `naro_user_id`, the one value the server sets. After a reconnect the last
788
+ state is tracked again for the same session only: after a sign-in, a
789
+ sign-out or a renewed session, call `track()` again in the `SUBSCRIBED`
790
+ callback. The last state is the last `track()` called, even one the
791
+ connection dropped before its answer or that had none in time; a
792
+ `track()` whose join went before it was sent (`NOT_JOINED`) leaves none. While a sign-in or sign-out is on its way, `track()` waits for
793
+ the server to take it, and an `untrack()` behind such a call waits its
794
+ turn, so they go in the order called (if the join goes meanwhile,
795
+ `track()` answers `NOT_JOINED`). The `join` and
796
+ `leave` callbacks get `event`, `key`, `newPresences` or `leftPresences`
797
+ and `currentPresences`, as in supabase-js; a join's `currentPresences` are
798
+ the key's presences before it. A channel that stops lets go of its presences
799
+ with a `leave` for each key and a `sync` before `CHANNEL_ERROR`; only
800
+ `unsubscribe()` clears them without a word. A channel stopped by a close
801
+ that does not reconnect or a join refused for good keeps what it tracked
802
+ for a `subscribe()` again in the same session; one the server closed lets
803
+ go of it — track again in `SUBSCRIBED`.
804
+ - One connection sends 100 frames at once and then 10 a second (the server
805
+ closes one past 120 at once or 10 a second with 4008): more wait their
806
+ turn, so 70 presence channels coming back — a join and a track each —
807
+ take about 4 seconds. The server also closes a connection it refused 10
808
+ times in a minute (`TOO_MANY_JOINS`, `TOO_MANY_MESSAGES`,
809
+ `TOO_MANY_PRESENCE_MESSAGES`): the joins, the tracks sent again and the
810
+ retries naro-js makes of its own go only while those refusals in the last
811
+ minute and the requests sent and still waiting for an answer are fewer
812
+ than 6 — or, while none of naro-js's own waits for an answer, one at a
813
+ time while those refusals alone are fewer than 6, so your requests in
814
+ flight on a slow link do not keep another channel from joining. Your own
815
+ `send()`, `track()`, `untrack()` and `unsubscribe()` are not held for
816
+ refusals; they go after naro-js's own frames, which do not wait behind
817
+ them. A sign-in or sign-out goes before them too, but not before your
818
+ `track()` and `untrack()` still waiting their turn (the server would hold
819
+ that presence for the new user), so behind those it can wait up to
820
+ `timeoutMs`. One that would not go within `timeoutMs` — an app sending
821
+ faster than 10 a second, a cursor on every mousemove — is refused at once
822
+ and not sent: `CLIENT_FLOODING`
823
+ (`This connection is sending messages faster than a connection may (10 a second), so this one was not sent. Send less often.`)
824
+ with `lost: false`; send it again later. A leave is never refused.
825
+ `send()`, `track()` and `untrack()` also answer `lost`: `true` when the
826
+ connection closed before the server answered, so the message may have
827
+ been delivered, or not. When the server closes a connection that sent too
828
+ fast (4008), the calls still waiting answer `CLIENT_FLOODING`
829
+ (`This connection sent messages faster than a connection may.`) with
830
+ `lost: true`: sent again, such a message may arrive twice. Keep such sends
831
+ to 10 a second for all of a client's channels together.
832
+ - Unlike supabase-js: broadcast and presence listeners match one event name
833
+ exactly, case included — `{ event: "*" }` hears nothing; `DELETE` events
834
+ pass the select policies too; a subscription takes one filter (`eq`, `neq`,
835
+ `lt`, `lte`, `gt`, `gte`, `in`); settings naro does not take
836
+ (`broadcast.replay`) are refused with `INVALID_CHANNEL_CONFIG`; `send()` and
837
+ the rest answer `{ data, error }` (`send()`, `track()` and `untrack()` with
838
+ `lost` too), not `'ok' | 'timed out' | 'error'`.
839
+ - The service key receives every change of a published table, whatever the
840
+ policies say: keep it on your server (`createClient` refuses it in a
841
+ browser). A server that writes with `env.DB` broadcasts with
842
+ `POST /_naro/v1/realtime/broadcast` and the API key: up to 100 messages and
843
+ 1 MiB, all sent or none (202).
844
+ - `createClient({ realtime: { WebSocket, heartbeatIntervalMs, timeoutMs } })`
845
+ — a heartbeat every 25,000 ms and answers within 10,000 ms by default, each
846
+ in whole milliseconds from 1 to 2,147,483,647 (the longest a timer waits).
847
+ `timeoutMs` counts from when a frame goes; your own frame waits its turn
848
+ for about as long at most, or is refused at once (`CLIENT_FLOODING`).
849
+ Browsers, Deno, Bun, Cloudflare Workers and Node 22 or later have a
850
+ WebSocket; a runtime without one (Node 20) reports `WEBSOCKET_UNAVAILABLE`
851
+ until you pass `ws`'s. naro-js listens for the `error` `ws` emits when a
852
+ connection is closed before it opens, so that close does not end the
853
+ process.
854
+
855
+ ```ts
856
+ import WebSocket from "ws";
857
+
858
+ const naro = createClient({ url, anonKey, realtime: { WebSocket } });
859
+ ```
860
+
861
+ The whole guide, with the policies, the close codes and the limits:
862
+ https://naro.sh/docs/realtime
@@ -45,13 +45,14 @@ export declare class TokenStore {
45
45
  set(token: string): Promise<void>;
46
46
  clear(): Promise<void>;
47
47
  /**
48
- * Clears only if the stored token is still the one that was rejected. A
49
- * request that left with an old token can come back after the user signed
50
- * in again; its INVALID_SESSION must not sign the new session out. The
51
- * read and the removal are one operation: with an async storage, a sign-in
52
- * that lands between them would otherwise be the token removed.
48
+ * Clears only if the stored token is still the one that was rejected, and
49
+ * says whether it did. A request that left with an old token can come back
50
+ * after the user signed in again; its INVALID_SESSION must not sign the
51
+ * new session out. The read and the removal are one operation: with an
52
+ * async storage, a sign-in that lands between them would otherwise be the
53
+ * token removed.
53
54
  */
54
- clearIf(token: string): Promise<void>;
55
+ clearIf(token: string): Promise<boolean>;
55
56
  /**
56
57
  * Reads the value, hands it to `transform` and stores what that returns
57
58
  * (null clears it), as one operation — the per-flow verifiers
@@ -60,4 +61,13 @@ export declare class TokenStore {
60
61
  update(transform: (current: string | null) => string | null): Promise<void>;
61
62
  /** Stores `next` only if `expected` is still stored, as one operation. */
62
63
  replaceIf(expected: string, next: string): Promise<void>;
64
+ /**
65
+ * Calls `listener` after every write of the token — a sign-in, a
66
+ * sign-out, a renewed session, a cleared INVALID_SESSION — from inside the
67
+ * write's turn, so a `get()` it starts reads the value just written — or
68
+ * that of a write already queued behind it, which calls the listener
69
+ * again. The Realtime connection (Realtime 3-2d) uses it to tell the hub.
70
+ * What a listener throws is its own: the write has happened and stands.
71
+ */
72
+ subscribe(listener: () => void): () => void;
63
73
  }
package/dist/client.d.ts CHANGED
@@ -2,6 +2,8 @@ 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 type { RealtimeChannel } from "./realtime/channel.js";
6
+ import type { RealtimeChannelOptions, RealtimeOptions, RealtimeResult } from "./realtime/types.js";
5
7
  import { StorageClient } from "./storage/client.js";
6
8
  import type { DatabaseShape, InsertOf, RowOf, TableName, TablesOf, UntypedDatabase, UpdateOf } from "./types.js";
7
9
  type CommonOptions = {
@@ -23,6 +25,12 @@ type CommonOptions = {
23
25
  };
24
26
  /** Replaces the global fetch (tests, custom runtimes). */
25
27
  fetch?: FetchLike;
28
+ /**
29
+ * Realtime (first-release design 2026-10-16 §9.1, original spec §15):
30
+ * the WebSocket class where the runtime has none, and the heartbeat and
31
+ * answer timings. Optional everywhere a WebSocket is global.
32
+ */
33
+ realtime?: RealtimeOptions;
26
34
  };
27
35
  export type ClientOptions = CommonOptions & ({
28
36
  anonKey: string;
@@ -44,6 +52,7 @@ export declare const CONFIG_MESSAGES: {
44
52
  readonly bothKeys: "Pass createClient an anonKey or a serviceKey, not both.";
45
53
  readonly serviceKeyOffServer: "A Naro service key (naro_sk_…) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_…) here and keep the service key on your server.";
46
54
  readonly fetch: "This runtime has no fetch. Pass one to createClient({ fetch }).";
55
+ readonly realtime: "createClient's realtime options take a WebSocket class, and heartbeatIntervalMs and timeoutMs in whole milliseconds from 1 to 2147483647.";
47
56
  };
48
57
  export declare class NaroClient<Database extends DatabaseShape<Database>> {
49
58
  #private;
@@ -61,6 +70,21 @@ export declare class NaroClient<Database extends DatabaseShape<Database>> {
61
70
  constructor(options: ClientOptions);
62
71
  /** Start a query on `table`: `.select()`, `.insert()`, `.update()` or `.delete()`. */
63
72
  from<Table extends TableName<Database>>(table: Table): TableBuilder<RowOf<TablesOf<Database>[Table]>, InsertOf<TablesOf<Database>[Table]>, UpdateOf<TablesOf<Database>[Table]>>;
73
+ /**
74
+ * A Realtime channel (original spec §15): `.on()` listeners, then
75
+ * `.subscribe()`. The same name and privacy give the same channel until
76
+ * `removeChannel`. Nothing connects until the first `subscribe()`.
77
+ */
78
+ channel(name: string, options?: RealtimeChannelOptions): RealtimeChannel<Database>;
79
+ /** The channels made with `channel()` and not yet removed. */
80
+ getChannels(): RealtimeChannel<Database>[];
81
+ /**
82
+ * Leaves the channel and forgets it, so `channel()` of its name makes a new
83
+ * one; the connection closes with the last channel.
84
+ */
85
+ removeChannel(channel: RealtimeChannel<Database>): Promise<RealtimeResult>;
86
+ /** `removeChannel()` for every channel; the connection closes. */
87
+ removeAllChannels(): Promise<RealtimeResult>;
64
88
  }
65
89
  /**
66
90
  * The one entry point (spec §15). Throws only for a configuration mistake —
package/dist/errors.d.ts CHANGED
@@ -54,7 +54,77 @@ export declare function invalidProvider(): NaroError;
54
54
  export declare function serviceKeyRequired(): NaroError;
55
55
  /** No `crypto.subtle`: an http page that is not localhost. */
56
56
  export declare function cryptoUnavailable(): NaroError;
57
+ /** The service key off a server (spec §7); `CONFIG_MESSAGES.serviceKeyOffServer` is this. */
58
+ export declare const SERVICE_KEY_OFF_SERVER_MESSAGE = "A Naro service key (naro_sk_\u2026) skips every policy, so createClient accepts it only on a server (Node, Deno, Bun, Cloudflare Workers, an edge runtime), never in a browser, a web worker or a React Native app. Use the anon key (naro_pk_\u2026) here and keep the service key on your server.";
59
+ /** No WebSocket in this runtime, and none passed to createClient. */
60
+ export declare function websocketUnavailable(): NaroError;
61
+ /**
62
+ * The server did not answer a join, a request or the opening in `ms`. Whole
63
+ * seconds, never fewer than one: a `timeoutMs` under 500 must not read
64
+ * "within 0 seconds".
65
+ */
66
+ export declare function realtimeTimedOut(ms: number): NaroError;
67
+ /** A drop the connection answers by connecting again. */
68
+ export declare function realtimeClosed(code: number): NaroError;
69
+ /**
70
+ * The app's own close: naro-js closes the connection when its last channel
71
+ * is removed, and what still waited for an answer hears this. Called off,
72
+ * as an upload's `signal` calls it off — not a drop, and nothing reconnects.
73
+ */
74
+ export declare function realtimeDisconnected(): NaroError;
75
+ /**
76
+ * A close the connection does not answer by connecting again, when the
77
+ * server's `system` frame did not say why first: the reason is the close's
78
+ * own or, with none, NETWORK_ERROR.
79
+ */
80
+ export declare function realtimeStopped(code: number, reason: string): NaroError;
81
+ /** The WebSocket constructor threw: its message quotes the URL, so only its name is kept. */
82
+ export declare function socketRefused(error: unknown): NaroError;
83
+ /**
84
+ * The hub's INVALID_CHANNEL, raised before sending (`reason` is
85
+ * channelNameProblem's): its words, copied from data-core's catalog.
86
+ */
87
+ export declare function invalidChannel(reason: string): NaroError;
88
+ /**
89
+ * The hub's INVALID_CHANNEL_CONFIG, raised before sending: for a join
90
+ * config JSON cannot carry (a BigInt, a cycle), which no frame could hold.
91
+ */
92
+ export declare function invalidChannelConfig(path: string, reason: string): NaroError;
93
+ /** `createClient`'s refusal of a service key off a server, for the call that reaches the socket late. */
94
+ export declare function serviceKeyOffServer(): NaroError;
95
+ /**
96
+ * The hub's NOT_JOINED, raised before sending. A name that passed
97
+ * channelNameProblem has at most 128 code points, which the hub quotes
98
+ * whole (its `realtimeName` cuts at 128), so the two read the same.
99
+ */
100
+ export declare function notJoined(channel: string): NaroError;
101
+ /** The hub's INVALID_MESSAGE for a `send` that is not a broadcast. */
102
+ export declare function invalidBroadcast(): NaroError;
103
+ /**
104
+ * The hub's INVALID_MESSAGE for a track whose state is not a JSON object,
105
+ * raised before sending: one JSON cannot carry (a BigInt, a cycle) would
106
+ * otherwise read as a connection that could not send.
107
+ */
108
+ export declare function invalidPresence(): NaroError;
109
+ /**
110
+ * The hub's INVALID_MESSAGE for a frame over its size limit — the plan's
111
+ * broadcast payload plus 16 KiB, in UTF-8 bytes — which it refuses before
112
+ * reading the frame's `ref` (data-core `tooLargeReason`).
113
+ */
114
+ export declare function frameTooLarge(limit: number): NaroError;
115
+ /**
116
+ * A frame of the app's that would not go within `timeoutMs`, at the rate
117
+ * the hub takes frames from one connection (`perSecond`), behind those
118
+ * still to go: refused here, and not sent (PR #328 re-review). The hub's own
119
+ * word for a connection over that rate is its 4008 close, CLIENT_FLOODING;
120
+ * an app that sends faster than that for long hears this instead.
121
+ */
122
+ export declare function clientFlooding(perSecond: number): NaroError;
123
+ /** The server closed a channel (`phx_close`) without saying why first. */
124
+ export declare function channelClosed(): NaroError;
57
125
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
126
+ /** `{ code, message, details? }` with nothing else; null if it is not one. */
127
+ export declare function asError(value: unknown): NaroError | null;
58
128
  /**
59
129
  * The error a response carries, or null when it carries none. Every error
60
130
  * under `/_naro/v1` comes as `{ data: null, error: { code, message,
package/dist/http.d.ts CHANGED
@@ -15,7 +15,8 @@ export type HttpConfig = {
15
15
  tokens: TokenStore;
16
16
  /**
17
17
  * The short-lived access JWT the sign-in answers carry. Data and Storage
18
- * requests send it; the auth routes keep the session token in `tokens`.
18
+ * requests send it; the auth and Realtime routes send the session token
19
+ * in `tokens` (`storeFor`).
19
20
  * Without it (not signed in) those requests go as anon.
20
21
  */
21
22
  accessJwt?: TokenStore;
package/dist/index.d.ts CHANGED
@@ -6,7 +6,9 @@ export type { NaroError } from "./errors.js";
6
6
  export type { FetchLike } from "./http.js";
7
7
  export type { StorageClient, StorageFileApi } from "./storage/client.js";
8
8
  export { objectRoute, type Encoded as ObjectRouteResult, } from "./storage/path.js";
9
- export type { CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
9
+ export type { RealtimeChannel } from "./realtime/channel.js";
10
+ export type { PostgresChangesEvent, PostgresChangesFilter, RealtimeBroadcastMessage, RealtimeChannelOptions, RealtimeChannelState, RealtimeOptions, RealtimePostgresChangesPayload, RealtimePresenceEntry, RealtimePresenceJoinPayload, RealtimePresenceLeavePayload, RealtimePresenceState, RealtimeResult, RealtimeSendResult, RealtimeStatus, RealtimeStatusCallback, RealtimeSystemMessage, WebSocketConstructor, } from "./realtime/types.js";
11
+ export type { CopyOptions, CreateSignedUploadUrlOptions, DownloadOption, FileObject, FolderEntry, ListOptions, MoveOptions, MoveResult, SignedUploadResult, SignedUploadUrl, SignedUrlEntry, UpdateOptions, UploadBody, UploadOptions, UploadResult, } from "./storage/types.js";
10
12
  export type { ArrayValues, FilterGroup, FilterGroupCallback, InsertBuilder, MatchQuery, MutationBuilder, RawFilterValue, SelectBuilder, TableBuilder, } from "./query/builder.js";
11
13
  export { quoteFilterValue, type FilterOperatorName } from "./query/filters.js";
12
14
  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";