@protobase/server 0.59.0 → 0.59.2

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.
@@ -0,0 +1,44 @@
1
+ //#region src/channels/binary-frame.ts
2
+ const encoder = new TextEncoder();
3
+ const decoder = new TextDecoder();
4
+ const lengthBytes = 4;
5
+ /**
6
+ * A binary frame of `/api/channels`: the byte length of a JSON header, the header, then the bytes it carries. A binary
7
+ * broadcast travels as one, its header the message without its payload.
8
+ */
9
+ const encodeBinaryFrame = (header, bytes) => {
10
+ const head = encoder.encode(JSON.stringify(header));
11
+ const frame = new Uint8Array(lengthBytes + head.length + bytes.byteLength);
12
+ new DataView(frame.buffer).setUint32(0, head.length);
13
+ frame.set(head, lengthBytes);
14
+ frame.set(new Uint8Array(bytes), lengthBytes + head.length);
15
+ return frame.buffer;
16
+ };
17
+ /** The header and bytes of a binary frame, or `undefined` for one cut short or whose header is not a JSON object. */
18
+ const decodeBinaryFrame = (frame) => {
19
+ if (frame.byteLength < lengthBytes) return;
20
+ const end = lengthBytes + new DataView(frame).getUint32(0);
21
+ if (end > frame.byteLength) return;
22
+ const header = parsed(decoder.decode(new Uint8Array(frame, lengthBytes, end - lengthBytes)));
23
+ if (typeof header !== "object" || header === null || Array.isArray(header)) return;
24
+ return {
25
+ header,
26
+ bytes: frame.slice(end)
27
+ };
28
+ };
29
+ const parsed = (text) => {
30
+ try {
31
+ return JSON.parse(text);
32
+ } catch (error) {
33
+ if (error instanceof SyntaxError) return;
34
+ throw error;
35
+ }
36
+ };
37
+ /** The bytes of an `ArrayBuffer` or a typed array, as an `ArrayBuffer` of their own; `undefined` for anything else. */
38
+ const bytesOf = (value) => {
39
+ if (value instanceof ArrayBuffer) return value;
40
+ if (!ArrayBuffer.isView(value)) return;
41
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice().buffer;
42
+ };
43
+ //#endregion
44
+ export { bytesOf, decodeBinaryFrame, encodeBinaryFrame };
@@ -1,9 +1,22 @@
1
+ import { decodeBinaryFrame } from "./binary-frame.js";
1
2
  //#region src/channels/channel-protocol.ts
2
3
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
4
  const text = (value) => typeof value === "string";
4
5
  /** The message a text frame carries, or `undefined` for one that is not JSON or not a message. */
5
- const decodeClientMessage = (frame) => {
6
- const message = parsed(frame);
6
+ const decodeClientMessage = (frame) => messageOf(parsed(frame));
7
+ /**
8
+ * The send of bytes a binary frame carries (`decodeBinaryFrame`): its header a `send` without a payload, its bytes the
9
+ * payload. `undefined` for any other frame.
10
+ */
11
+ const decodeBinaryMessage = (frame) => {
12
+ const decoded = decodeBinaryFrame(frame);
13
+ if (!decoded || decoded.header.type !== "send" || "payload" in decoded.header) return;
14
+ return messageOf({
15
+ ...decoded.header,
16
+ payload: decoded.bytes
17
+ });
18
+ };
19
+ const messageOf = (message) => {
7
20
  if (!isRecord(message)) return;
8
21
  if (message.ref !== void 0 && !text(message.ref)) return;
9
22
  const { type } = message;
@@ -30,4 +43,4 @@ const parsed = (frame) => {
30
43
  }
31
44
  };
32
45
  //#endregion
33
- export { decodeClientMessage };
46
+ export { decodeBinaryMessage, decodeClientMessage };
@@ -10,7 +10,7 @@ const refusal = (code, message) => ({
10
10
  */
11
11
  const channelRefusals = {
12
12
  notFound: refusal("not-found", "There is no such record"),
13
- invalidMessage: refusal("invalid-message", "Send a JSON text frame of a ping, token, join, leave, track, untrack or send"),
13
+ invalidMessage: refusal("invalid-message", "Send a JSON text frame of a ping, token, join, leave, track, untrack or send, or a binary frame of a send"),
14
14
  notJoined: refusal("not-joined", "Join the channel first"),
15
15
  alreadyJoined: refusal("already-joined", "This connection has joined the channel already"),
16
16
  nameTooLong: refusal("name-too-long", "A channel name, its resource, a slash and its key, is at most 255 bytes"),
@@ -16,11 +16,30 @@ const withBearer = (request, token) => {
16
16
  headers.set("authorization", `Bearer ${token}`);
17
17
  return new Request(request, { headers });
18
18
  };
19
- const invalidBody = () => new HttpProblem(422, "invalid-broadcast", "Unprocessable Content", "Send { \"event\": \"…\", \"payload\": … } as application/json");
19
+ const invalidBody = () => new HttpProblem(422, "invalid-broadcast", "Unprocessable Content", "Send { \"event\": \"…\", \"payload\": … } as application/json, or bytes as application/octet-stream with ?event=…");
20
+ const broadcastOf = async (request) => {
21
+ if (request.header("content-type")?.split(";")[0].trim().toLowerCase() === "application/octet-stream") {
22
+ const event = request.query("event");
23
+ if (event === void 0) throw invalidBody();
24
+ return {
25
+ event,
26
+ payload: await request.arrayBuffer()
27
+ };
28
+ }
29
+ const body = await request.json().catch((error) => {
30
+ if (error instanceof SyntaxError) throw invalidBody();
31
+ throw error;
32
+ });
33
+ if (!isRecord(body) || typeof body.event !== "string" || !("payload" in body)) throw invalidBody();
34
+ return {
35
+ event: body.event,
36
+ payload: body.payload
37
+ };
38
+ };
20
39
  /**
21
40
  * `GET /channels`, a WebSocket of channels named by records (see `channelSocket`), refused before the upgrade like
22
41
  * any request its caller cannot make. `POST /channels/:resource/:key/broadcast` sends one broadcast without a socket,
23
- * as the caller, when they may read the record.
42
+ * as the caller, when they may read the record: JSON, or bytes (`broadcastOf`).
24
43
  */
25
44
  const channelRoutes = ({ deps, upgradeWebSocket, authenticate, authenticateUpgrade, onError, hubs, follow, report }) => {
26
45
  const app = new Hono();
@@ -72,16 +91,10 @@ const channelRoutes = ({ deps, upgradeWebSocket, authenticate, authenticateUpgra
72
91
  throw new HttpProblem(413, "too-large", "Payload Too Large", `A broadcast request is at most ${realtimeLimits.maxFrameBytes} bytes`);
73
92
  }
74
93
  }), async (c) => {
75
- const body = await c.req.json().catch((error) => {
76
- if (error instanceof SyntaxError) throw invalidBody();
77
- throw error;
78
- });
79
- if (!isRecord(body) || typeof body.event !== "string" || !("payload" in body)) throw invalidBody();
80
94
  const message = {
81
95
  resource: c.req.param("resource"),
82
96
  key: c.req.param("key"),
83
- event: body.event,
84
- payload: body.payload
97
+ ...await broadcastOf(c.req)
85
98
  };
86
99
  const record = broadcastRecord(deps.registry, message);
87
100
  const session = c.get("session");
@@ -1,7 +1,7 @@
1
1
  import "../registry.js";
2
2
  import "./channel-name.js";
3
3
  //#region src/channels/channel-sender.d.ts
4
- /** A broadcast to the channel of a record, from outside a connection. */
4
+ /** A broadcast to the channel of a record, from outside a connection: a JSON payload, or bytes in an `ArrayBuffer` or typed array. */
5
5
  export type ChannelBroadcast = {
6
6
  resource: string;
7
7
  key: string;
@@ -2,31 +2,43 @@ import { HttpProblem, notFound } from "../problem.js";
2
2
  import { fitsFrame } from "../realtime/broadcast-frame.js";
3
3
  import { realtimeLimits } from "../realtime/realtime-limits.js";
4
4
  import { channelRecord } from "./channel-name.js";
5
+ import { bytesOf } from "./binary-frame.js";
5
6
  import { channelRefusals } from "./channel-refusals.js";
7
+ import { packSent } from "./sent.js";
6
8
  import { Buffer } from "node:buffer";
7
9
  //#region src/channels/channel-sender.ts
8
10
  const invalid = (refusal) => new HttpProblem(422, refusal.code, "Unprocessable Content", refusal.message);
11
+ const invalidPayload = {
12
+ code: "invalid-payload",
13
+ message: `A payload is JSON or bytes of at most ${realtimeLimits.maxFrameBytes} bytes`
14
+ };
15
+ const payloadBytes = (payload) => {
16
+ if (payload instanceof ArrayBuffer || ArrayBuffer.isView(payload)) return payload.byteLength;
17
+ const json = JSON.stringify(payload);
18
+ return json === void 0 ? NaN : Buffer.byteLength(json);
19
+ };
9
20
  /** The record a broadcast names, its event and payload checked, or a problem. */
10
21
  const broadcastRecord = (registry, { resource, key, event, payload }) => {
11
22
  const found = channelRecord(registry, resource, key);
12
23
  if (found === "too-long") throw invalid(channelRefusals.nameTooLong);
13
24
  if (!found) throw notFound(channelRefusals.notFound.message);
14
25
  if (!fitsFrame(event)) throw invalid(channelRefusals.eventTooLong);
15
- const json = JSON.stringify(payload);
16
- if (json === void 0 || Buffer.byteLength(json) > realtimeLimits.maxFrameBytes) throw invalid({
17
- code: "invalid-payload",
18
- message: `A payload is JSON of at most ${realtimeLimits.maxFrameBytes} bytes`
19
- });
26
+ if (!(payloadBytes(payload) <= realtimeLimits.maxFrameBytes)) throw invalid(invalidPayload);
20
27
  return found;
21
28
  };
29
+ /** A checked broadcast as the hub publishes it; a typed array's bytes go as an `ArrayBuffer` of their own. */
30
+ const publicationTo = (record, event, sent) => ({
31
+ topic: record.name,
32
+ event,
33
+ payload: packSent({
34
+ ...sent,
35
+ payload: bytesOf(sent.payload) ?? sent.payload
36
+ }),
37
+ private: false
38
+ });
22
39
  /** Publishes a checked broadcast, refused with 429 when the channel took its broadcasts for this second. */
23
40
  const publishTo = async (broadcasts, record, event, sent) => {
24
- if (await broadcasts.publish([{
25
- topic: record.name,
26
- event,
27
- payload: sent,
28
- private: false
29
- }])) return;
41
+ if (await broadcasts.publish([publicationTo(record, event, sent)])) return;
30
42
  throw new HttpProblem(429, channelRefusals.channelTooBusy.code, "Too Many Requests", channelRefusals.channelTooBusy.message);
31
43
  };
32
44
  /** The server's own sender over the record channels' hub. */
@@ -36,4 +48,4 @@ const channelSender = (registry, broadcasts) => ({ broadcast: async (message) =>
36
48
  payload: message.payload
37
49
  }) });
38
50
  //#endregion
39
- export { broadcastRecord, channelSender, publishTo };
51
+ export { broadcastRecord, channelSender, publicationTo, publishTo };
@@ -1,11 +1,12 @@
1
1
  import { fitsFrame } from "../realtime/broadcast-frame.js";
2
2
  import { realtimeLimits } from "../realtime/realtime-limits.js";
3
3
  import { fixedWindow, fixedWindows } from "../realtime/fixed-window.js";
4
+ import { encodeBinaryFrame } from "./binary-frame.js";
4
5
  import { channelRefusals } from "./channel-refusals.js";
6
+ import { packSent, senderOf, unpackSent } from "./sent.js";
5
7
  import { channelBounds } from "./channel-bounds.js";
6
- import { decodeClientMessage } from "./channel-protocol.js";
8
+ import { decodeBinaryMessage, decodeClientMessage } from "./channel-protocol.js";
7
9
  import { entriesOf, entryDiff } from "./presence-entries.js";
8
- import { senderOf } from "./sent.js";
9
10
  import { randomUUID } from "node:crypto";
10
11
  import { Buffer } from "node:buffer";
11
12
  //#region src/channels/channel-socket.ts
@@ -56,6 +57,10 @@ const channelSocket = ({ send, buffered, close, session: first, reauthenticate,
56
57
  const push = (message) => {
57
58
  if (!open) return;
58
59
  if (!(buffered() <= realtimeLimits.maxUnsentBytes)) return shut(policyViolation);
60
+ if (message.type === "broadcast" && message.payload instanceof ArrayBuffer) {
61
+ const { payload, ...header } = message;
62
+ return send(encodeBinaryFrame(header, payload));
63
+ }
59
64
  send(JSON.stringify(message));
60
65
  };
61
66
  const ok = (ref, channel) => {
@@ -105,7 +110,7 @@ const channelSocket = ({ send, buffered, close, session: first, reauthenticate,
105
110
  const member = {
106
111
  self: false,
107
112
  private: false,
108
- receive: (event, payload) => hear(name, event, payload)
113
+ receive: (event, payload) => hear(name, event, unpackSent(payload))
109
114
  };
110
115
  const seat = presences.join(name, {
111
116
  key: randomUUID(),
@@ -161,7 +166,7 @@ const channelSocket = ({ send, buffered, close, session: first, reauthenticate,
161
166
  user: userOf(session),
162
167
  payload: message.payload
163
168
  };
164
- if (!await broadcasts.send(message.channel, channel.member, message.event, sent)) return refuse(message.ref, channelRefusals.channelTooBusy);
169
+ if (!await broadcasts.send(message.channel, channel.member, message.event, packSent(sent))) return refuse(message.ref, channelRefusals.channelTooBusy);
165
170
  if (message.self === true) hear(message.channel, message.event, sent);
166
171
  if (message.ack === true) ok(message.ref);
167
172
  };
@@ -224,15 +229,15 @@ const channelSocket = ({ send, buffered, close, session: first, reauthenticate,
224
229
  };
225
230
  return {
226
231
  /**
227
- * Handles a text frame after those before it; a binary one, or one that is not a message, gets an error. A frame
228
- * over the size cap, or of a kind whose size is unknown, closes the socket.
232
+ * Handles a frame after those before it: a text one, or a binary `ArrayBuffer` of a send of bytes; one that is not
233
+ * a message gets an error. A frame over the size cap, or of a kind whose size is unknown, closes the socket.
229
234
  */
230
235
  message: (frame) => {
231
236
  if (!open) return;
232
237
  const size = frameBytes(frame);
233
238
  if (!(size <= realtimeLimits.maxFrameBytes)) return shut(messageTooBig);
234
239
  stayAwake();
235
- const message = typeof frame === "string" ? decodeClientMessage(frame) : void 0;
240
+ const message = typeof frame === "string" ? decodeClientMessage(frame) : frame instanceof ArrayBuffer ? decodeBinaryMessage(frame) : void 0;
236
241
  if (!message) return push({
237
242
  type: "error",
238
243
  error: channelRefusals.invalidMessage
@@ -0,0 +1,57 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { writeTransaction } from "../transactions.js";
3
+ import { followedPoll } from "../realtime/followed-poll.js";
4
+ import { broadcastRecord, publicationTo } from "./channel-sender.js";
5
+ import { sendsSchema } from "./sends-schema-sql.js";
6
+ import { sql } from "kysely";
7
+ //#region src/channels/database-sends.ts
8
+ /** Sends taken in one transaction at most. */
9
+ const batchSize = 1e3;
10
+ /**
11
+ * The broadcast a row sends, as the server, under the checks of `channels.broadcast` in API functions; `undefined` for
12
+ * one they refuse, such as for a record the app does not have or a payload over a frame, which is dropped.
13
+ */
14
+ const publicationOfSend = (registry, row) => {
15
+ try {
16
+ const record = broadcastRecord(registry, row);
17
+ return publicationTo(record, row.event, {
18
+ user: null,
19
+ server: true,
20
+ payload: row.payload
21
+ });
22
+ } catch (error) {
23
+ if (error instanceof HttpProblem) return;
24
+ throw error;
25
+ }
26
+ };
27
+ const takeSends = (db, statementTimeoutMs) => writeTransaction(db, statementTimeoutMs, async (trx) => {
28
+ const { rows } = await sql`with taken as (
29
+ delete from ${sql.id(sendsSchema, "sends")} where id in (
30
+ select id from ${sql.id(sendsSchema, "sends")} order by id limit ${batchSize} for update skip locked
31
+ ) returning id, resource, key, event, payload
32
+ )
33
+ select id::text as id, resource, key, event, payload from taken order by id`.execute(trx);
34
+ return rows;
35
+ });
36
+ /**
37
+ * SQL's broadcasts to the record channels: `channels.send()` inserts a row in the caller's transaction, so only a
38
+ * committed one is seen, and while a record channel's socket of this process is open the table is read every
39
+ * `intervalMs`, its rows taken out of it and sent to the channels of their records, as the server, under the channel's
40
+ * broadcast rate. A send nobody hears is dropped, as is one the checks refuse. With several processes, one takes a row
41
+ * and the relay carries it to the others.
42
+ */
43
+ const databaseSends = ({ db, statementTimeoutMs, registry, broadcasts, intervalMs = 1e3, report }) => {
44
+ const read = async () => {
45
+ for (;;) {
46
+ const rows = await takeSends(db, statementTimeoutMs);
47
+ for (const row of rows) {
48
+ const publication = publicationOfSend(registry, row);
49
+ if (publication) await broadcasts.publish([publication]);
50
+ }
51
+ if (rows.length < batchSize) return;
52
+ }
53
+ };
54
+ return followedPoll(read, intervalMs, report);
55
+ };
56
+ //#endregion
57
+ export { databaseSends, publicationOfSend };
@@ -0,0 +1,15 @@
1
+ import { writeTransaction } from "../transactions.js";
2
+ import { sendsSchema } from "./sends-schema-sql.js";
3
+ import { inBatches, pruneBatchSize } from "../realtime/broadcast-prune.js";
4
+ import { sql } from "kysely";
5
+ //#region src/channels/sends-prune.ts
6
+ const deleteOldSends = (db, statementTimeoutMs, limit) => writeTransaction(db, statementTimeoutMs, async (trx) => {
7
+ const { numAffectedRows } = await sql`delete from ${sql.id(sendsSchema, "sends")} where id in (
8
+ select id from ${sql.id(sendsSchema, "sends")} where sent_at < now() - interval '48 hours' limit ${limit} for update skip locked
9
+ )`.execute(trx);
10
+ return Number(numAffectedRows ?? 0n);
11
+ });
12
+ /** Deletes the sends of `channels.sends` more than 48 hours old, which no reader took because no socket was open, in batches. */
13
+ const pruneSends = (db, statementTimeoutMs) => inBatches(pruneBatchSize, (limit) => deleteOldSends(db, statementTimeoutMs, limit));
14
+ //#endregion
15
+ export { pruneSends };
@@ -0,0 +1,30 @@
1
+ //#region src/channels/sends-schema-sql.ts
2
+ /** The schema of SQL's broadcasts to the record channels. */
3
+ const sendsSchema = "channels";
4
+ /**
5
+ * - `sends`: each broadcast SQL sent, inserted in the sender's transaction, taken out by a serving process once
6
+ * committed, or pruned after 48 hours when none read it.
7
+ * - `send(resource, key, event, payload)`: inserts one. It runs as its owner, the app's role, which alone may call it,
8
+ * so that no other role, such as one of `/api/sb/rest/v1`'s, sends as the server; a trigger function that sends for
9
+ * them is `security definer`.
10
+ */
11
+ const sendsSchemaSql = `create schema if not exists ${sendsSchema};
12
+
13
+ create table if not exists ${sendsSchema}.sends (
14
+ id bigint generated always as identity primary key,
15
+ resource text not null,
16
+ key text not null,
17
+ event text not null,
18
+ payload jsonb not null,
19
+ sent_at timestamptz not null default now()
20
+ );
21
+
22
+ create index if not exists sends_sent_at on ${sendsSchema}.sends (sent_at);
23
+
24
+ create or replace function ${sendsSchema}.send(resource text, key text, event text, payload jsonb) returns void
25
+ language sql security definer set search_path = ''
26
+ as $$ insert into ${sendsSchema}.sends (resource, key, event, payload) values (send.resource, send.key, send.event, send.payload) $$;
27
+
28
+ revoke execute on function ${sendsSchema}.send(text, text, text, jsonb) from public;`;
29
+ //#endregion
30
+ export { sendsSchema, sendsSchemaSql };
@@ -0,0 +1,7 @@
1
+ import { Db } from "@protobase/query";
2
+ //#region src/channels/sends-schema.d.ts
3
+ /** The SQL that adds the `channels` schema `realtime: { sqlSends: true }` needs; `undefined` when it is there. */
4
+ export declare const channelSendsMigration: (db: Db) => Promise<"create schema if not exists channels;\n\ncreate table if not exists channels.sends (\n id bigint generated always as identity primary key,\n resource text not null,\n key text not null,\n event text not null,\n payload jsonb not null,\n sent_at timestamptz not null default now()\n);\n\ncreate index if not exists sends_sent_at on channels.sends (sent_at);\n\ncreate or replace function channels.send(resource text, key text, event text, payload jsonb) returns void\n language sql security definer set search_path = ''\n as $$ insert into channels.sends (resource, key, event, payload) values (send.resource, send.key, send.event, send.payload) $$;\n\nrevoke execute on function channels.send(text, text, text, jsonb) from public;" | undefined>;
5
+ /** Refuses to go on while the `channels` schema is missing; the project's migrations add it. */
6
+ export declare const checkChannelSends: (db: Db) => Promise<void>;
7
+ //#endregion
@@ -0,0 +1,18 @@
1
+ import { sendsSchema, sendsSchemaSql } from "./sends-schema-sql.js";
2
+ import { migrationCommand } from "../changes/capture-schema.js";
3
+ import { sql } from "kysely";
4
+ //#region src/channels/sends-schema.ts
5
+ const sendsCurrent = async (db) => {
6
+ const { rows } = await sql`select to_regclass(${`${sendsSchema}.sends`}) is not null
7
+ and to_regprocedure(${`${sendsSchema}.send(text, text, text, jsonb)`}) is not null as found`.execute(db);
8
+ return rows[0].found;
9
+ };
10
+ /** The SQL that adds the `channels` schema `realtime: { sqlSends: true }` needs; `undefined` when it is there. */
11
+ const channelSendsMigration = async (db) => await sendsCurrent(db) ? void 0 : sendsSchemaSql;
12
+ /** Refuses to go on while the `channels` schema is missing; the project's migrations add it. */
13
+ const checkChannelSends = async (db) => {
14
+ if (await sendsCurrent(db)) return;
15
+ throw new Error(`The ${sendsSchema} schema that realtime.sqlSends needs is missing. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
16
+ };
17
+ //#endregion
18
+ export { channelSendsMigration, checkChannelSends };
@@ -1,8 +1,24 @@
1
+ import { decodeBinaryFrame, encodeBinaryFrame } from "./binary-frame.js";
1
2
  //#region src/channels/sent.ts
2
3
  /** The sender as a broadcast message names it. */
3
4
  const senderOf = (sent) => ({
4
5
  user: sent.user,
5
6
  ...sent.server && { server: true }
6
7
  });
8
+ /**
9
+ * A broadcast as the hub's payload. One of bytes goes as one binary frame, its sender the header, since the relay
10
+ * carries a payload of bytes as they are and any other as JSON, which has no bytes.
11
+ */
12
+ const packSent = (sent) => sent.payload instanceof ArrayBuffer ? encodeBinaryFrame(senderOf(sent), sent.payload) : sent;
13
+ /** The broadcast a hub's payload carries (`packSent`). */
14
+ const unpackSent = (packed) => {
15
+ if (!(packed instanceof ArrayBuffer)) return packed;
16
+ const { header, bytes } = decodeBinaryFrame(packed);
17
+ return {
18
+ user: header.user,
19
+ ...header.server === true && { server: true },
20
+ payload: bytes
21
+ };
22
+ };
7
23
  //#endregion
8
- export { senderOf };
24
+ export { packSent, senderOf, unpackSent };
@@ -51,7 +51,7 @@ export type CreateAdminInput = {
51
51
  subscriptions?: SubscriptionDefinition[];
52
52
  /** Webhook subscriptions made in the admin app, from `webhooks: {}` (see `WebhooksOptions`). */
53
53
  webhooks?: WebhooksOptions;
54
- /** How `/api/sb/realtime/v1` is served: by one process or several (see `RealtimeOptions`). */
54
+ /** How `/api/sb/realtime/v1` and `/api/channels` are served: by one process or several, and whether SQL sends to channels (see `RealtimeOptions`). */
55
55
  realtime?: RealtimeOptions;
56
56
  /** Each user's devices and the endpoints that register them, from `notifications: {}` (see `NotificationsOptions`). */
57
57
  notifications?: NotificationsOptions;
@@ -17,10 +17,11 @@ import { apiKeySettings } from "./api-keys/api-key-settings.js";
17
17
  import { apiKeyCaller } from "./api-keys/resolve-caller.js";
18
18
  import { jwtVerifier } from "./auth/jwt.js";
19
19
  import { realtimeApp } from "./realtime/realtime-app.js";
20
- import { relayed } from "./realtime/relay/realtime-options.js";
20
+ import { relayed, sqlSends } from "./realtime/relay/realtime-options.js";
21
21
  import { realtimeHubs } from "./realtime/realtime-hubs.js";
22
22
  import { channelSender } from "./channels/channel-sender.js";
23
23
  import { channelRoutes } from "./channels/channel-routes.js";
24
+ import { databaseSends } from "./channels/database-sends.js";
24
25
  import { startRealtimePrune } from "./realtime/realtime-prune.js";
25
26
  import { restRoutes } from "./rest/rest-routes.js";
26
27
  import { storageRoutes } from "./storage/storage-routes.js";
@@ -399,6 +400,18 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
399
400
  }));
400
401
  if (upgradeWebSocket) {
401
402
  const visitors = (caller) => withAnonymous(caller, options.roles);
403
+ const sends = sqlSends(realtime) ? databaseSends({
404
+ db,
405
+ statementTimeoutMs: deps.statementTimeoutMs,
406
+ registry,
407
+ broadcasts: hubs.records.broadcasts,
408
+ report
409
+ }) : void 0;
410
+ const follow = () => {
411
+ const unrelay = hubs.follow();
412
+ const unsend = sends?.follow();
413
+ return () => (unsend?.(), unrelay());
414
+ };
402
415
  app.route(systemPath || "/", channelRoutes({
403
416
  deps,
404
417
  upgradeWebSocket,
@@ -406,7 +419,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
406
419
  authenticateUpgrade: visitors(upgradeAuthenticate),
407
420
  onError,
408
421
  hubs: hubs.records,
409
- follow: hubs.follow,
422
+ follow,
410
423
  report
411
424
  }));
412
425
  }
@@ -490,6 +503,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
490
503
  db,
491
504
  statementTimeoutMs: deps.statementTimeoutMs,
492
505
  relayed: relayed(realtime),
506
+ sqlSends: sqlSends(realtime),
493
507
  report
494
508
  });
495
509
  return Object.assign(app, {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { MobileConfig } from "./mobile-config.js";
2
2
  import { AppIdentity } from "./app-identity.js";
3
3
  import { AppIcons, PngIcon, pngIcons } from "./web-app/app-icons.js";
4
- import { RealtimeOptions, relayed } from "./realtime/relay/realtime-options.js";
4
+ import { RealtimeOptions, relayed, sqlSends } from "./realtime/relay/realtime-options.js";
5
5
  import { EventsOptions } from "./changes/events-options.js";
6
6
  import { DecryptField, FieldDecrypter, FieldReader, RecordKey } from "./encryption/decrypt-field-types.js";
7
7
  import { StandardSchema, readJson } from "./functions/read-json.js";
@@ -101,6 +101,7 @@ import { parseColumns, readOutbox } from "./changes/outbox.js";
101
101
  import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
102
102
  import { checkWebhooksSchema, webhooksMigration } from "./webhooks/table/schema.js";
103
103
  import { checkRealtimeRelay, realtimeRelayMigration } from "./realtime/relay/relay-schema.js";
104
+ import { channelSendsMigration, checkChannelSends } from "./channels/sends-schema.js";
104
105
  import { checkComments, commentsMigration } from "./comments/comments-sql.js";
105
106
  import { CheckAiInput, checkAi } from "./ai/check-ai.js";
106
107
  import { CommentItem } from "./comments/comment-item.js";
@@ -175,4 +176,4 @@ import { DrainResult, JobsRuntime, StartJobsOptions, startJobs } from "./jobs/ru
175
176
  import { jobsDrainApp } from "./jobs/drain-route.js";
176
177
  import { checkJobsSchema, jobsSchema, jobsSchemaMigration } from "./jobs/schema.js";
177
178
  import { AdminAuth, CreateAuthOptions, UserCreatedHook, normalizeRoles } from "./better-auth/create-auth.js";
178
- export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BaseToolContext, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type CallBackend, type CallMember, type Caller, type CallerOptions, type CallsAccess, type CallsOptions, type CapturedRow, type CascadingKey, type ChannelBroadcast, type ChannelSender, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LiveKitBackend, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type SendNotification, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
179
+ export { type Admin, type AdminAuth, type AdminOptions, type AggregateParams, type ApiFunction, type ApiKeys, type ApiKeysOptions, type ApnsEnvironment, type AppIcons, type AppIdentity, type AppPoolConfig, type ApplyOptions, type ApplyResult, type ApprovalAnswer, type ApprovalRequest, type ArgumentsPayload, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type AuthHooks, type AuthSettings, type AuthenticateSettings, type Authenticator, type BaseToolContext, type BatchOperation, type BatchResult, type BetterAuthAuthenticator, type BetterAuthAuthenticatorOptions, type CallBackend, type CallMember, type Caller, type CallerOptions, type CallsAccess, type CallsOptions, type CapturedRow, type CascadingKey, type ChannelBroadcast, type ChannelSender, type ChatMessage, type ChatTextPart, type CheckAiInput, type CleanupEntry, type CleanupReport, type CleanupSchedule, type CoalescedChange, type CommentItem, type CommentToolsInput, type CommentUsers, type Completion, type CompletionOptions, ConnectionsBusy, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CorsOptions, type CorsOrigins, type CreateAdminInput, type CreateAuthOptions, type DecryptField, type Device, type DeviceColumn, type DeviceKind, type DeviceStore, DeviceStoreError, type DrainResult, type EmailComponent, type EmailOptions, type EmailTemplate, type EncryptionStatus, type EncryptionTools, type EncryptionToolsInput, type EnqueueJob, type EnqueueOptions, type ErrorReporter, type ErrorReportingOptions, type ErrorReportingSettings, type EventData, type EventsOptions, FatalJobError, type FieldApplied, type FieldDecrypter, type FieldReader, type FieldStatus, type FileCleanupInput, type FileLimits, type FileObject, type FileProvider, type FileProviderSettings, type FileRef, type FileStore, type FileVariantObject, type FilesOptions, type FunctionApp, type FunctionCaller, type FunctionContext, type FunctionEnv, type FunctionHandler, type FunctionOptions, type FunctionRecords, type FunctionSocket, type FunctionSource, type HistoryColumn, type HistoryOptions, type HistoryPosition, type HistoryStore, HistoryStoreError, type HookContext, HttpProblem, type IssueTokenOptions, type JobContext, type JobDeclaration, type JobDefinition, type JobName, type JobOptions, type JobPayload, type JobRun, type JobsRuntime, type JsonObject, type JsonValue, type JwtOptions, type JwtVerifyOptions, type KeyFunctionContext, type LinkedAccount, type ListParams, type LiveKitBackend, type LocalFilesOptions, type MailMessage, type Mailer, type MapLayer, type MapOptions, type MapSettings, type McpApprovals, type McpGateInput, type McpHandlerInput, type McpRoutesInput, type McpServerInput, type McpToolsInput, type McpWrites, type MemberInput, type Membership, MissingCredentials, type MobileConfig, ModelError, type ModelSettings, type NewDevice, type NewNotification, type NewOrganization, type NewUser, type NotificationRecipients, NotificationSendError, type NotificationsOptions, type OperatorProvider, type OrganizationRole, type OrganizationsOptions, type PgModule, type PipelineHook, type PlatformSignIn, type PluginOption, type PluginRef, type PngIcon, type ProtectedFunctionOptions, type PublicFunctionContext, type PublicFunctionEnv, type PublicFunctionHandler, type PublicFunctionOptions, type PushAnswer, type PushMessage, PushMessageError, type PushSender, type QueryOptions, type QueryResult, type ReadableResources, type RealtimeOptions, type RealtimePrune, type ReasoningDetail, type ReasoningEffort, type RebindOption, type RecordGroups, type RecordKey, type RecordKeyInput, type RecordPage, type RenderedEmail, type ResourceChange, type ResourceSource, type RoleDefinition, type RolesInput, type RunContext, type RuntimeOptions, type S3Bucket, type S3FilesOptions, type S3ListPage, type S3Object, type ScanGuardMode, type ScheduledDelete, type SendNotification, type Session, type SessionOrganization, type Sharp, type SignInPolicy, type SignInRule, type SignatureOptions, type SocketEvents, type SourceOption, type SourceRef, type StaffAccessRule, type StaffSignIn, type StandardSchema, type StandardWebhookOptions, type StartJobsOptions, type StoreFileInput, type StoredMember, type StoredNotification, type StoredObject, type StoredOrganization, type StoredUser, type StrayValues, type SubscriptionDefinition, type SubscriptionEvent, type SubscriptionOptions, type TenantValue, type ToolAnnotations, type ToolCall, type ToolComment, type ToolCommentPage, type ToolComments, type ToolContext, type ToolRecord, type ToolRecords, type ToolSpec, type TrustedApp, type TurnOptions, type UnreadableEntry, type Urgency, type User, type UserCreatedHook, type VapidSettings, type ViewSource, type WebPushFields, type WebPushOptions, type WebhooksOptions, type WidgetOptions, type WriteEvent, type WriteOperation, type WriteRecordToolsInput, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, channelSendsMigration, checkAi, checkAuthSchema, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, sqlSends, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ import { apiKeySettings, apiKeyVariables } from "./api-keys/api-key-settings.js"
25
25
  import { apiKeyCaller } from "./api-keys/resolve-caller.js";
26
26
  import { jwtAuthenticator, jwtVerifier } from "./auth/jwt.js";
27
27
  import { realtimeLimits } from "./realtime/realtime-limits.js";
28
- import { relayed } from "./realtime/relay/realtime-options.js";
28
+ import { relayed, sqlSends } from "./realtime/relay/realtime-options.js";
29
29
  import { backgroundSettled, waitUntil } from "./functions/wait-until.js";
30
30
  import { objectKey } from "./storage/object-key.js";
31
31
  import { appNameOver, defaultAppName, shownAppName } from "./app-name.js";
@@ -109,6 +109,7 @@ import { checkResourcesOutsideAuthStore } from "./better-auth/store/resources-ou
109
109
  import { checkComputed, computedMigration } from "./computed/computed-schema.js";
110
110
  import { checkWebhooksSchema, webhooksMigration } from "./webhooks/table/schema.js";
111
111
  import { checkRealtimeRelay, realtimeRelayMigration } from "./realtime/relay/relay-schema.js";
112
+ import { channelSendsMigration, checkChannelSends } from "./channels/sends-schema.js";
112
113
  import { checkAi } from "./ai/check-ai.js";
113
114
  import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
114
115
  import { sendNotification } from "./notifications/send-notification.js";
@@ -127,4 +128,4 @@ import { enqueueJob } from "./jobs/declared-job.js";
127
128
  import { defineEmail } from "./mail/define-email.js";
128
129
  import { startJobs } from "./jobs/runtime.js";
129
130
  import { jobsDrainApp } from "./jobs/drain-route.js";
130
- export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, HttpProblem, MissingCredentials, ModelError, NotificationSendError, PushMessageError, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, checkAi, checkAuthSchema, checkChanges, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
131
+ export { ConnectionsBusy, DeviceStoreError, FatalJobError, HistoryStoreError, HttpProblem, MissingCredentials, ModelError, NotificationSendError, PushMessageError, addAppRoleToOwners, addCommentTool, addMember, apiKeyCaller, apiKeySettings, apiKeyVariables, appNameOver, aspectRatio, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, authSchemaDrift, authSchemaMigration, authStoreTables, backgroundSettled, badRequest, batchWriteTool, beforeSignInPluginsVariable, betterAuthTokens, capSocketMessages, changesMigration, channelSendsMigration, checkAi, checkAuthSchema, checkChanges, checkChannelSends, checkComments, checkComputed, checkJobsSchema, checkNotificationsSchema, checkQueryRunner, checkRealtimeRelay, checkResourcesOutsideAuthStore, checkSourceNames, checkViewQueries, checkWebhooksSchema, coalesce, commentTools, commentsMigration, computedMigration, configExports, consoleAuditQueue, conversationKey, countRecordsTool, createAdmin, createConversations, createEncryptionTools, createFileCleanup, createOrganization, createRecordTool, createUser, defaultAppName, defaultHistoryPage, defaultSignInPolicy, defineEmail, defineFunction, defineJob, defineTool, deleteRecordTool, deleteUser, describeResourceTool, deviceIdParam, deviceStore, devicesPath, enqueueJob, errorReportingSettings, errorReportingVariable, eventMatcher, fileConversationStore, fileSize, fileType, forbidden, formatUri, functionApp, generateVapidKeys, getRecordTool, goTruePath, handleDroppedConnections, hasUsers, historyIdParam, historyIdPattern, historyLimits, historyPath, historyStore, honorSocketMessageCaps, imageSize, isBackgroundWork, isCaptured, isJobDefinition, isOAuthWellKnownPath, isSchedule, issueToken, jobDefinitions, jobsDrainApp, jobsSchema, jobsSchemaMigration, jwtAuthenticator, jwtVerifier, limitedFileStore, listCommentsTool, listOrganizations, listRecordsTool, listResourcesTool, listUsers, localFileStore, longQueryTimeoutMs, mapSettings, mapStyleUrl, mapStyleVariable, maxDevicesPerUser, maxHistoryPage, maxMessageBytes, maxRecipients, maxTokenTtlSeconds, maxTtlSeconds, maxUnreadCount, maxWidgetPropsBytes, mcpApprovals, mcpGate, mcpHandler, mcpIcons, mcpInstructions, mcpRoutes, mcpServer, mcpToolContext, mcpTools, migrationCommand, newWebhookSecret, normalizeRoles, notFound, notificationSenders, notificationsMigration, oauthWellKnownPaths, objectKey, parseColumns, parseRoles, passwordLength, pluginOptions, pluginsBeforeSignIn, pluginsFor, pluginsVariable, pngIcons, poolWaitMs, postgresDeviceStore, postgresHistoryStore, processor, pushMessage, queryRunnerMigration, readDbConnections, readJson, readOnlyQueryTool, readOperatorSettings, readOutbox, readPlatformSignIn, readRecordTools, readVapidSettings, readWriteQueryTool, readableResources, realtimeLimits, realtimeRelayMigration, recordOf, refusalOf, refused, relayed, removeDevicesWithUsers, removeHistoryWithUsers, removeMember, renderVariant, requestApproval, requireEnv, resourceChange, roleChoices, runInBackground, runReadOnlyQuery, runReadWriteQuery, runTurn, runtimeUrl, runtimeVariable, s3FileStore, seesAssistant, seesRuntime, sendNotification, sessionOf, setMemberRoles, setUserBanned, setUserRole, shownAppName, signInPolicyMethods, socketMessageCapOf, sourceOptions, sourcesFor, sourcesVariable, sqlSends, startErrorReporting, startJobs, storageCleanupSchedule, storageProvider, storeFile, storeUsage, streamChatCompletion, subscription, supabasePath, thumbHash, unauthorized, updateRecordTool, urgencies, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable, variantPath, verifySignature, verifyStandardWebhook, waitUntil, wantsLongQueries, webManifestPath, webPushSender, webauthnPath, webhooksMigration, widget, withCacheBreakpoints, withTrustedApps, writeRecordTools };
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.59.0";
2
+ var version = "0.59.2";
3
3
  //#endregion
4
4
  export { version };
@@ -1,5 +1,6 @@
1
1
  import { writeTransaction } from "../transactions.js";
2
2
  import { fitsFrame } from "./broadcast-frame.js";
3
+ import { followedPoll } from "./followed-poll.js";
3
4
  import { realtimeLimits } from "./realtime-limits.js";
4
5
  import { sql } from "kysely";
5
6
  import { Buffer } from "node:buffer";
@@ -43,8 +44,6 @@ const messagesTableExists = async (db) => (await sql`select to_regclass('realtim
43
44
  * `realtime.messages` sends nothing, and is checked again at each read.
44
45
  */
45
46
  const databaseBroadcasts = ({ db, statementTimeoutMs, broadcasts, intervalMs = 1e3, report }) => {
46
- let followers = 0;
47
- let running = false;
48
47
  const read = async () => {
49
48
  if (!await messagesTableExists(db)) return;
50
49
  for (;;) {
@@ -56,29 +55,7 @@ const databaseBroadcasts = ({ db, statementTimeoutMs, broadcasts, intervalMs = 1
56
55
  if (rows.length < batchSize) return;
57
56
  }
58
57
  };
59
- const tick = async () => {
60
- if (followers === 0) {
61
- running = false;
62
- return;
63
- }
64
- await read().catch(report);
65
- setTimeout(() => void tick(), intervalMs);
66
- };
67
- return {
68
- /** Reads the table from now on, until the returned function is called; once for each call, however often. */
69
- follow: () => {
70
- followers += 1;
71
- if (!running) {
72
- running = true;
73
- tick();
74
- }
75
- let followed = true;
76
- return () => {
77
- if (!followed) return;
78
- followed = false;
79
- followers -= 1;
80
- };
81
- } };
58
+ return followedPoll(read, intervalMs, report);
82
59
  };
83
60
  //#endregion
84
61
  export { databaseBroadcasts, messagesTableExists, publicationOfRow };
@@ -0,0 +1,34 @@
1
+ //#region src/realtime/followed-poll.ts
2
+ /**
3
+ * Runs `read` every `intervalMs` while anyone follows, starting at once with the first follower; a failed read goes to
4
+ * `report`, and the next one tries again.
5
+ */
6
+ const followedPoll = (read, intervalMs, report) => {
7
+ let followers = 0;
8
+ let running = false;
9
+ const tick = async () => {
10
+ if (followers === 0) {
11
+ running = false;
12
+ return;
13
+ }
14
+ await read().catch(report);
15
+ setTimeout(() => void tick(), intervalMs);
16
+ };
17
+ return {
18
+ /** Reads from now on, until the returned function is called; once for each call, however often. */
19
+ follow: () => {
20
+ followers += 1;
21
+ if (!running) {
22
+ running = true;
23
+ tick();
24
+ }
25
+ let followed = true;
26
+ return () => {
27
+ if (!followed) return;
28
+ followed = false;
29
+ followers -= 1;
30
+ };
31
+ } };
32
+ };
33
+ //#endregion
34
+ export { followedPoll };
@@ -5,15 +5,17 @@ export type RealtimePruneOptions = {
5
5
  statementTimeoutMs: number;
6
6
  /** Whether the app's processes relay through `realtime_relay`, whose frames and counts are pruned too. */
7
7
  relayed: boolean;
8
+ /** Whether SQL sends to the record channels through `channels.sends`, whose old rows are pruned too. */
9
+ sqlSends: boolean;
8
10
  /** Where a failed prune goes; the next one tries again. */
9
11
  report: (error: unknown) => void;
10
12
  };
11
13
  /**
12
14
  * Prunes what the realtime tables keep whether or not a socket is open, as on an app that never opens one: the old
13
- * broadcast rows of `realtime.messages` and, relayed, the relay's old frames and counts, on `pruneSchedule`. It runs
14
- * only while the process does, so it never wakes a sleeping app.
15
+ * broadcast rows of `realtime.messages`, relayed, the relay's old frames and counts and, with `sqlSends`, the old
16
+ * rows of `channels.sends`, on `pruneSchedule`. It runs only while the process does, so it never wakes a sleeping app.
15
17
  */
16
- export declare const startRealtimePrune: ({ db, statementTimeoutMs, relayed, report }: RealtimePruneOptions) => {
18
+ export declare const startRealtimePrune: ({ db, statementTimeoutMs, relayed, sqlSends, report }: RealtimePruneOptions) => {
17
19
  stop: () => Promise<void>;
18
20
  };
19
21
  export type RealtimePrune = ReturnType<typeof startRealtimePrune>;
@@ -1,12 +1,17 @@
1
1
  import { pruneFrames } from "./relay/relay-frame-sql.js";
2
2
  import { pruneBroadcasts } from "./broadcast-prune.js";
3
+ import { pruneSends } from "../channels/sends-prune.js";
3
4
  import { pruneSchedule } from "./prune-schedule.js";
4
5
  //#region src/realtime/realtime-prune.ts
5
6
  /**
6
7
  * Prunes what the realtime tables keep whether or not a socket is open, as on an app that never opens one: the old
7
- * broadcast rows of `realtime.messages` and, relayed, the relay's old frames and counts, on `pruneSchedule`. It runs
8
- * only while the process does, so it never wakes a sleeping app.
8
+ * broadcast rows of `realtime.messages`, relayed, the relay's old frames and counts and, with `sqlSends`, the old
9
+ * rows of `channels.sends`, on `pruneSchedule`. It runs only while the process does, so it never wakes a sleeping app.
9
10
  */
10
- const startRealtimePrune = ({ db, statementTimeoutMs, relayed, report }) => pruneSchedule([() => pruneBroadcasts(db, statementTimeoutMs), ...relayed ? [() => pruneFrames(db)] : []], report);
11
+ const startRealtimePrune = ({ db, statementTimeoutMs, relayed, sqlSends, report }) => pruneSchedule([
12
+ () => pruneBroadcasts(db, statementTimeoutMs),
13
+ ...relayed ? [() => pruneFrames(db)] : [],
14
+ ...sqlSends ? [() => pruneSends(db, statementTimeoutMs)] : []
15
+ ], report);
11
16
  //#endregion
12
17
  export { startRealtimePrune };
@@ -7,7 +7,14 @@ export type RealtimeOptions = {
7
7
  * `realtime_relay` schema of the app's database, which `protobase migration` writes.
8
8
  */
9
9
  processes?: 'one' | 'many';
10
+ /**
11
+ * `true` lets SQL in the app's database broadcast to the record channels with `channels.send()`, from the `channels`
12
+ * schema `protobase migration` writes.
13
+ */
14
+ sqlSends?: boolean;
10
15
  };
11
16
  /** Whether the options share broadcasts and presence between processes. */
12
17
  export declare const relayed: (options?: RealtimeOptions) => boolean;
18
+ /** Whether the options let SQL broadcast to the record channels. */
19
+ export declare const sqlSends: (options?: RealtimeOptions) => boolean;
13
20
  //#endregion
@@ -5,5 +5,11 @@ const relayed = (options = {}) => {
5
5
  if (options.processes === "many") return true;
6
6
  throw new Error(`Invalid realtime.processes "${String(options.processes)}": use "one" or "many"`);
7
7
  };
8
+ /** Whether the options let SQL broadcast to the record channels. */
9
+ const sqlSends = (options = {}) => {
10
+ if (options.sqlSends === void 0) return false;
11
+ if (typeof options.sqlSends === "boolean") return options.sqlSends;
12
+ throw new Error(`Invalid realtime.sqlSends "${String(options.sqlSends)}": use true or false`);
13
+ };
8
14
  //#endregion
9
- export { relayed };
15
+ export { relayed, sqlSends };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.59.0",
3
+ "version": "0.59.2",
4
4
  "description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -42,9 +42,9 @@
42
42
  "@better-auth/sso": "^1.7.12",
43
43
  "@livekit/protocol": "1.53.1",
44
44
  "@modelcontextprotocol/server": "^2.3.1",
45
- "@protobase/layout": "0.59.0",
46
- "@protobase/query": "0.59.0",
47
- "@protobase/schema": "0.59.0",
45
+ "@protobase/layout": "0.59.2",
46
+ "@protobase/query": "0.59.2",
47
+ "@protobase/schema": "0.59.2",
48
48
  "@react-email/render": "^2.1.0",
49
49
  "@scalar/hono-api-reference": "^0.12.10",
50
50
  "@sentry/core": "^11.7.0",