@protobase/server 0.51.2 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/dist/aggregate-groups.js +2 -2
  2. package/dist/aggregate-service.js +4 -4
  3. package/dist/aggregate-splits.js +1 -1
  4. package/dist/assistant/batch-approval.js +49 -51
  5. package/dist/assistant/batch-before.js +38 -0
  6. package/dist/assistant/batch-echo.js +36 -0
  7. package/dist/assistant/batch-write-args.js +14 -5
  8. package/dist/assistant/batch-write-tool.js +30 -11
  9. package/dist/assistant/budgeted-page.js +30 -0
  10. package/dist/assistant/comment-tools.d.ts +20 -0
  11. package/dist/assistant/comment-tools.js +120 -0
  12. package/dist/assistant/count-records-tool.d.ts +5 -0
  13. package/dist/assistant/count-records-tool.js +84 -0
  14. package/dist/assistant/query-result-budget.js +32 -0
  15. package/dist/assistant/query-tools.js +7 -7
  16. package/dist/assistant/record-before.js +2 -1
  17. package/dist/assistant/record-field-piece.js +54 -0
  18. package/dist/assistant/record-tool-args.js +6 -3
  19. package/dist/assistant/record-tools.d.ts +4 -1
  20. package/dist/assistant/record-tools.js +17 -6
  21. package/dist/assistant/records.d.ts +19 -0
  22. package/dist/assistant/records.js +20 -13
  23. package/dist/assistant/result-budget.js +72 -0
  24. package/dist/assistant/tools.d.ts +3 -0
  25. package/dist/assistant/write-echo.js +41 -0
  26. package/dist/assistant/write-messages.js +26 -6
  27. package/dist/assistant/write-prompts.js +37 -12
  28. package/dist/assistant/write-record-tools.js +15 -6
  29. package/dist/batch/run-batch.js +1 -1
  30. package/dist/better-auth/auth-settings.d.ts +1 -1
  31. package/dist/changes/capture-schema.js +1 -1
  32. package/dist/changes/resource-change.d.ts +4 -1
  33. package/dist/changes/resource-change.js +27 -3
  34. package/dist/comments/post-comment.js +1 -1
  35. package/dist/comments/read-comments.js +1 -1
  36. package/dist/comments/tool-comment-types.d.ts +28 -0
  37. package/dist/comments/tool-comments.js +30 -0
  38. package/dist/create-admin.d.ts +5 -2
  39. package/dist/create-admin.js +20 -10
  40. package/dist/deps.d.ts +1 -1
  41. package/dist/encryption/scan.js +1 -1
  42. package/dist/error-handler.js +3 -2
  43. package/dist/etag.js +1 -1
  44. package/dist/files/download.js +1 -1
  45. package/dist/files/file-cleanup.d.ts +1 -1
  46. package/dist/functions/records.d.ts +1 -1
  47. package/dist/functions/records.js +2 -2
  48. package/dist/history/read-history.js +2 -2
  49. package/dist/index.d.ts +9 -3
  50. package/dist/index.js +9 -4
  51. package/dist/jobs/run-job.d.ts +1 -1
  52. package/dist/list-photos.js +2 -2
  53. package/dist/list-service.js +6 -6
  54. package/dist/map-service.js +2 -2
  55. package/dist/mcp/mcp-context.d.ts +7 -4
  56. package/dist/mcp/mcp-context.js +6 -4
  57. package/dist/mcp/mcp-server.d.ts +3 -2
  58. package/dist/mcp/mcp-tools.d.ts +6 -1
  59. package/dist/mcp/mcp-tools.js +8 -4
  60. package/dist/package.js +1 -1
  61. package/dist/read-service.js +2 -2
  62. package/dist/realtime/broadcast-api.js +3 -3
  63. package/dist/realtime/broadcast-frame.js +16 -8
  64. package/dist/realtime/broadcast-hub.js +16 -6
  65. package/dist/realtime/broadcast-request.js +1 -1
  66. package/dist/realtime/change-checker.js +1 -1
  67. package/dist/realtime/channel-access.js +1 -1
  68. package/dist/realtime/database-broadcasts.js +83 -0
  69. package/dist/realtime/presence-hub.js +74 -39
  70. package/dist/realtime/realtime-app.js +39 -3
  71. package/dist/realtime/realtime-routes.js +12 -9
  72. package/dist/realtime/realtime-socket.js +10 -7
  73. package/dist/realtime/relay/frame-cursor.js +38 -0
  74. package/dist/realtime/relay/presence-replica.js +57 -0
  75. package/dist/realtime/relay/realtime-options.d.ts +13 -0
  76. package/dist/realtime/relay/realtime-options.js +9 -0
  77. package/dist/realtime/relay/realtime-relay.js +104 -0
  78. package/dist/realtime/relay/relay-frame-sql.js +82 -0
  79. package/dist/realtime/relay/relay-presence-sql.js +71 -0
  80. package/dist/realtime/relay/relay-schema-sql.js +55 -0
  81. package/dist/realtime/relay/relay-schema.d.ts +7 -0
  82. package/dist/realtime/relay/relay-schema.js +18 -0
  83. package/dist/realtime/relay/relayed-hubs.js +61 -0
  84. package/dist/records.js +1 -1
  85. package/dist/related-labels.js +1 -1
  86. package/dist/rest/rest-context.js +1 -1
  87. package/dist/routes/batch.js +1 -1
  88. package/dist/routes/collection.js +2 -2
  89. package/dist/routes/record.js +3 -3
  90. package/dist/row-permissions.js +1 -1
  91. package/dist/sql-parts.js +1 -1
  92. package/dist/web-app/mcp-icons.d.ts +9 -0
  93. package/dist/web-app/mcp-icons.js +26 -0
  94. package/dist/webhooks/subscriber-view.js +27 -2
  95. package/dist/webhooks/webhooks.js +41 -12
  96. package/dist/write-pipeline.js +1 -1
  97. package/dist/write-shared.js +1 -1
  98. package/package.json +4 -4
@@ -1,14 +1,33 @@
1
+ import { broadcastHub } from "./broadcast-hub.js";
1
2
  import { changeChecker } from "./change-checker.js";
2
3
  import { changeHub } from "./change-hub.js";
3
4
  import { channelAccess } from "./channel-access.js";
5
+ import { databaseBroadcasts } from "./database-broadcasts.js";
6
+ import { presenceHub } from "./presence-hub.js";
4
7
  import { realtimeRoutes } from "./realtime-routes.js";
8
+ import { relayed } from "./relay/realtime-options.js";
9
+ import { relayedHubs } from "./relay/relayed-hubs.js";
5
10
  //#region src/realtime/realtime-app.ts
11
+ const hubsOf = (deps, options, report) => {
12
+ if (relayed(options)) return relayedHubs({
13
+ db: deps.db,
14
+ statementTimeoutMs: deps.statementTimeoutMs,
15
+ report
16
+ });
17
+ return {
18
+ broadcasts: broadcastHub(),
19
+ presences: presenceHub(),
20
+ follow: () => () => {}
21
+ };
22
+ };
6
23
  /**
7
24
  * `/api/sb/realtime/v1`: the `postgres_changes` of the live resources, each checked under the subscriber's role, claims and
8
- * the tables' RLS policies before it is sent, and broadcasts and presence between the channels of the process, on a
9
- * private channel as the policies on `realtime.messages` allow.
25
+ * the tables' RLS policies before it is sent, and broadcasts and presence between the channels on a topic, of this
26
+ * process or, with `processes: 'many'`, of every process serving the app, on a
27
+ * private channel as the policies on `realtime.messages` allow, and the broadcasts the database sends with
28
+ * `realtime.send()`.
10
29
  */
11
- const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
30
+ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report, options }) => {
12
31
  const live = deps.registry.entries.filter((entry) => entry.model.live);
13
32
  const check = changeChecker({
14
33
  db: deps.db,
@@ -25,11 +44,28 @@ const realtimeApp = ({ deps, feed, upgradeWebSocket, caller, report }) => {
25
44
  db: deps.db,
26
45
  statementTimeoutMs: deps.statementTimeoutMs
27
46
  });
47
+ const { broadcasts, presences, follow } = hubsOf(deps, options, report);
48
+ const database = databaseBroadcasts({
49
+ db: deps.db,
50
+ statementTimeoutMs: deps.statementTimeoutMs,
51
+ broadcasts,
52
+ report
53
+ });
28
54
  return realtimeRoutes({
29
55
  upgradeWebSocket,
30
56
  caller,
31
57
  live,
32
58
  hub,
59
+ broadcasts,
60
+ presences,
61
+ database: { follow: () => {
62
+ const unfollowRelay = follow();
63
+ const unfollowDatabase = database.follow();
64
+ return () => {
65
+ unfollowDatabase();
66
+ unfollowRelay();
67
+ };
68
+ } },
33
69
  access,
34
70
  report
35
71
  });
@@ -1,9 +1,7 @@
1
1
  import { HttpProblem } from "../problem.js";
2
2
  import { broadcastApi } from "./broadcast-api.js";
3
- import { broadcastHub } from "./broadcast-hub.js";
4
3
  import { bufferedOf } from "./buffered-amount.js";
5
4
  import { vsnOf } from "./phoenix-message.js";
6
- import { presenceHub } from "./presence-hub.js";
7
5
  import { realtimeSocket } from "./realtime-socket.js";
8
6
  import { Hono } from "hono";
9
7
  //#region src/realtime/realtime-routes.ts
@@ -13,15 +11,14 @@ const keyRequest = (apikey, token) => new Request("http://realtime.invalid/", {
13
11
  } });
14
12
  /**
15
13
  * `GET /api/sb/realtime/v1/websocket?apikey=…&vsn=…`: Supabase Realtime's socket, for `postgres_changes` of the live
16
- * resources, and broadcasts and presence between the sockets of this process (see `realtimeSocket`). A missing or wrong
14
+ * resources, and broadcasts and presence between the channels on a topic (see `realtimeSocket`). A missing or wrong
17
15
  * key is refused before the upgrade, with 401. A socket whose raw socket is not one of npm `ws`, such as Bun's own `ws`
18
16
  * shim's, is closed at once with 1011, since its unsent bytes cannot be counted (`realtimeLimits.maxUnsentBytes`).
19
- * `POST /api/sb/realtime/v1/api/broadcast` sends to the same channels without a socket (see `broadcastApi`).
17
+ * `POST /api/sb/realtime/v1/api/broadcast` sends to the same channels without a socket (see `broadcastApi`), and so
18
+ * does the database (see `databaseBroadcasts`).
20
19
  */
21
- const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report }) => {
20
+ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, broadcasts, presences, database, access, report }) => {
22
21
  const app = new Hono();
23
- const broadcasts = broadcastHub();
24
- const presences = presenceHub();
25
22
  let lastId = 0;
26
23
  const nextId = () => ++lastId;
27
24
  app.route("/api/broadcast", broadcastApi({
@@ -37,12 +34,18 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report })
37
34
  }, upgradeWebSocket((c) => {
38
35
  const apikey = c.req.query("apikey");
39
36
  let socket;
37
+ let unfollow = () => {};
38
+ const closed = () => {
39
+ socket?.closed();
40
+ unfollow();
41
+ };
40
42
  return {
41
43
  onOpen: (_event, ws) => {
42
44
  if (bufferedOf(ws.raw) === void 0) {
43
45
  report(/* @__PURE__ */ new Error("Not implemented: realtime needs WebSockets of npm ws, which count unsent bytes; on Bun, load npm ws by its path rather than Bun's ws shim, or run on Node"));
44
46
  return ws.close(1011);
45
47
  }
48
+ unfollow = database.follow();
46
49
  socket = realtimeSocket({
47
50
  vsn: vsnOf(c.req.query("vsn")),
48
51
  send: (frame) => ws.send(frame),
@@ -59,8 +62,8 @@ const realtimeRoutes = ({ upgradeWebSocket, caller, live, hub, access, report })
59
62
  });
60
63
  },
61
64
  onMessage: (event) => socket?.message(event.data),
62
- onError: () => socket?.closed(),
63
- onClose: () => socket?.closed()
65
+ onError: closed,
66
+ onClose: closed
64
67
  };
65
68
  }));
66
69
  return app;
@@ -1,8 +1,8 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { fixedWindow, fixedWindows } from "./fixed-window.js";
3
+ import { realtimeLimits } from "./realtime-limits.js";
2
4
  import { parseBindings } from "./postgres-changes.js";
3
5
  import { decodeBroadcastPush, encodeBroadcast, fitsFrame } from "./broadcast-frame.js";
4
- import { realtimeLimits } from "./realtime-limits.js";
5
- import { fixedWindow, fixedWindows } from "./fixed-window.js";
6
6
  import { decodeMessage, encodeMessage, reply } from "./phoenix-message.js";
7
7
  import { privateGate, publicGate, readRefusalText } from "./channel-gate.js";
8
8
  import { randomUUID } from "node:crypto";
@@ -72,14 +72,15 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
72
72
  event,
73
73
  payload
74
74
  });
75
- const hear = (topic, channel, event, payload) => {
75
+ const hear = (topic, channel, event, payload, meta) => {
76
76
  if (!channel.gate.hears("broadcast")) return;
77
- if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload));
77
+ if (vsn === "2.0.0") return deliver(encodeBroadcast(topic, event, payload, meta));
78
78
  if (payload instanceof ArrayBuffer) return;
79
79
  toChannel(topic, channel, "broadcast", {
80
80
  type: "broadcast",
81
81
  event,
82
- payload
82
+ payload,
83
+ ...meta && { meta }
83
84
  });
84
85
  };
85
86
  const leave = (topic) => {
@@ -122,13 +123,15 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
122
123
  const refused = await gate.authorize(caller);
123
124
  if (!open) return;
124
125
  if (refused) return push(reply(message, "error", { reason: `Unauthorized: ${readRefusalText(message.topic, refused)}` }));
126
+ await presences.ready();
127
+ if (!open) return;
125
128
  const parsed = parseBindings(config.postgres_changes, live, nextId);
126
129
  if (typeof parsed === "string") return push(reply(message, "error", { reason: parsed }));
127
130
  const { topic } = message;
128
131
  const member = {
129
132
  self: broadcast.self === true,
130
133
  private: config.private === true,
131
- receive: (event, payload) => hear(topic, channel, event, payload)
134
+ receive: (event, payload, meta) => hear(topic, channel, event, payload, meta)
132
135
  };
133
136
  const key = typeof presence.key === "string" && presence.key !== "" ? presence.key : randomUUID();
134
137
  const seat = presences.join(topic, {
@@ -188,7 +191,7 @@ const realtimeSocket = ({ vsn, send, buffered, close, resolve, live, hub, broadc
188
191
  const allowed = await channel.gate.mayWrite(channel.caller, "broadcast");
189
192
  if (channels.get(message.topic) !== channel) return;
190
193
  if (!allowed) return push(reply(message, "error", { error: "unauthorized" }));
191
- if (!broadcasts.send(message.topic, channel.member, event, payload)) return push(reply(message, "error", { reason: `${tooFast} on this topic` }));
194
+ if (!await broadcasts.send(message.topic, channel.member, event, payload)) return push(reply(message, "error", { reason: `${tooFast} on this topic` }));
192
195
  if (channel.ack) push(reply(message, "ok", {}));
193
196
  };
194
197
  const updatePresence = async (message, channel) => {
@@ -0,0 +1,38 @@
1
+ //#region src/realtime/relay/frame-cursor.ts
2
+ /** How long an id below the newest one read is asked for again: a frame another process has not committed yet. */
3
+ const gapMs = 2e3;
4
+ /** The most ids asked for again at once. */
5
+ const maxGaps = 1e3;
6
+ /**
7
+ * Where a process is in the frames: every id up to `after` read, except the `gaps`. Processes take ids before they
8
+ * commit, so a frame can commit after one with a higher id: an id skipped is asked for again for `gapMs`, and then given
9
+ * up, as an id a failed insert took and never used.
10
+ */
11
+ const frameCursor = (after, visible = [], now = () => Date.now()) => {
12
+ let cursor = after;
13
+ const gaps = /* @__PURE__ */ new Map();
14
+ const read = (ids) => {
15
+ const at = now();
16
+ const seen = new Set(ids);
17
+ for (const id of ids) gaps.delete(id);
18
+ const newest = ids.reduce((max, id) => id > max ? id : max, cursor);
19
+ for (let id = newest - 1n; id > cursor && gaps.size < maxGaps; id -= 1n) if (!seen.has(id)) gaps.set(id, at);
20
+ cursor = newest;
21
+ for (const [id, since] of gaps) if (at - since > gapMs) gaps.delete(id);
22
+ };
23
+ if (visible.length > 0) {
24
+ cursor = visible.reduce((min, id) => id < min ? id : min) - 1n;
25
+ read(visible);
26
+ }
27
+ return {
28
+ /** What to read next: ids after `after`, and the `gaps`. */
29
+ next: () => ({
30
+ after: cursor,
31
+ gaps: [...gaps.keys()]
32
+ }),
33
+ /** Takes the ids just read. */
34
+ read
35
+ };
36
+ };
37
+ //#endregion
38
+ export { frameCursor };
@@ -0,0 +1,57 @@
1
+ //#region src/realtime/relay/presence-replica.ts
2
+ const topicKey = (topic, isPrivate) => `${isPrivate ? "private" : "public"}:${topic}`;
3
+ /**
4
+ * The other processes' presences by topic and ref, as frames tell them: what `presence_state` adds to this process's
5
+ * own. It holds what they track now, so it is bounded by the presences of the app.
6
+ */
7
+ const presenceReplica = () => {
8
+ let topics = /* @__PURE__ */ new Map();
9
+ const add = (topic, isPrivate, key, meta) => {
10
+ const name = topicKey(topic, isPrivate);
11
+ const refs = topics.get(name) ?? /* @__PURE__ */ new Map();
12
+ if (refs.has(meta.phx_ref)) return false;
13
+ topics.set(name, refs.set(meta.phx_ref, {
14
+ key,
15
+ meta
16
+ }));
17
+ return true;
18
+ };
19
+ const remove = (topic, isPrivate, ref) => {
20
+ const name = topicKey(topic, isPrivate);
21
+ const refs = topics.get(name);
22
+ if (!refs?.delete(ref)) return false;
23
+ if (refs.size === 0) topics.delete(name);
24
+ return true;
25
+ };
26
+ const filtered = (state, keep) => {
27
+ const kept = Object.create(null);
28
+ for (const [key, { metas }] of Object.entries(state)) {
29
+ const left = metas.filter((meta) => keep(key, meta));
30
+ if (left.length > 0) kept[key] = { metas: left };
31
+ }
32
+ return Object.keys(kept).length > 0 ? kept : void 0;
33
+ };
34
+ return {
35
+ /** Starts over from the presences a snapshot read. */
36
+ load: (tracked) => {
37
+ topics = /* @__PURE__ */ new Map();
38
+ for (const presence of tracked) add(presence.topic, presence.private, presence.key, presence.meta);
39
+ },
40
+ /**
41
+ * Takes a frame's diff, and returns what it changed: the leaves of presences that were there and the joins of those
42
+ * that were not, so a frame read twice tells nothing twice; `undefined` when it changed nothing.
43
+ */
44
+ apply: (topic, isPrivate, diff) => {
45
+ const leaves = filtered(diff.leaves, (_key, meta) => remove(topic, isPrivate, meta.phx_ref));
46
+ const joins = filtered(diff.joins, (key, meta) => add(topic, isPrivate, key, meta));
47
+ if (!leaves && !joins) return void 0;
48
+ return {
49
+ joins: joins ?? {},
50
+ leaves: leaves ?? {}
51
+ };
52
+ },
53
+ state: (topic, isPrivate) => [...topics.get(topicKey(topic, isPrivate))?.values() ?? []].map(({ key, meta }) => [key, meta])
54
+ };
55
+ };
56
+ //#endregion
57
+ export { presenceReplica };
@@ -0,0 +1,13 @@
1
+ //#region src/realtime/relay/realtime-options.d.ts
2
+ /** How `/api/sb/realtime/v1` is served (`realtime` in `protobase.config.ts`). */
3
+ export type RealtimeOptions = {
4
+ /**
5
+ * `'one'`, the default, when one process serves the app: broadcasts and presence go between its channels in memory.
6
+ * `'many'` when several processes serve it behind one address: they go between the processes through the
7
+ * `realtime_relay` schema of the app's database, which `protobase migration` writes.
8
+ */
9
+ processes?: 'one' | 'many';
10
+ };
11
+ /** Whether the options share broadcasts and presence between processes. */
12
+ export declare const relayed: (options?: RealtimeOptions) => boolean;
13
+ //#endregion
@@ -0,0 +1,9 @@
1
+ //#region src/realtime/relay/realtime-options.ts
2
+ /** Whether the options share broadcasts and presence between processes. */
3
+ const relayed = (options = {}) => {
4
+ if (options.processes === void 0 || options.processes === "one") return false;
5
+ if (options.processes === "many") return true;
6
+ throw new Error(`Invalid realtime.processes "${String(options.processes)}": use "one" or "many"`);
7
+ };
8
+ //#endregion
9
+ export { relayed };
@@ -0,0 +1,104 @@
1
+ import { waitUntil } from "../../functions/wait-until.js";
2
+ import { frameCursor } from "./frame-cursor.js";
3
+ import { admitBroadcasts, pruneFrames, readFrames } from "./relay-frame-sql.js";
4
+ import { beat, presenceSnapshot, restorePresences, retireSelf, retireStale, writePresence } from "./relay-presence-sql.js";
5
+ import { randomUUID } from "node:crypto";
6
+ //#region src/realtime/relay/realtime-relay.ts
7
+ /**
8
+ * Broadcasts and presence between the processes serving an app, through the `realtime_relay` schema: each process adds
9
+ * what its channels, the broadcast API and the database send as frames, and, while one of its sockets is open, reads
10
+ * the others' every `intervalMs`, from a snapshot of their presences taken when the first socket opens. The broadcasts
11
+ * into a topic are counted in the database, every process's together. A process marks itself every `beatMs`, and one
12
+ * that has not for `staleSeconds`, as when it was killed, has its presences removed, with a leave for each. A process
13
+ * that stops, its last socket closed, removes its own; graceful shutdown waits for these writes (`backgroundSettled`).
14
+ */
15
+ const realtimeRelay = ({ db, statementTimeoutMs, heard, loaded, tracked, report, intervalMs = 100, beatMs = 5e3, staleSeconds = 15, now = () => Date.now() }) => {
16
+ const self = randomUUID();
17
+ let followers = 0;
18
+ let run;
19
+ let queue = Promise.resolve();
20
+ const inTurn = (work) => {
21
+ const next = queue.then(work).catch(report);
22
+ queue = next;
23
+ waitUntil(next);
24
+ return next;
25
+ };
26
+ const load = async (current) => {
27
+ await beat(db, self);
28
+ const snapshot = await presenceSnapshot(db, statementTimeoutMs, self);
29
+ if (current.stopped) return;
30
+ loaded(snapshot.presences);
31
+ current.cursor = frameCursor(snapshot.newest, snapshot.visible, now);
32
+ current.beaten = now();
33
+ };
34
+ const read = async (current, cursor) => {
35
+ for (;;) {
36
+ const { after, gaps } = cursor.next();
37
+ const batch = await readFrames(db, self, after, gaps);
38
+ if (current.stopped) return;
39
+ cursor.read(batch.ids);
40
+ for (const frame of batch.frames) heard(frame);
41
+ if (!batch.full) return;
42
+ }
43
+ };
44
+ const keepAlive = async (current) => {
45
+ if (current.stopped || now() - current.beaten < beatMs) return;
46
+ current.beaten = now();
47
+ if (await beat(db, self)) inTurn(() => restorePresences(db, self, tracked()));
48
+ await retireStale(db, staleSeconds);
49
+ await pruneFrames(db);
50
+ };
51
+ const step = async (current) => {
52
+ if (!current.cursor) await load(current);
53
+ if (!current.cursor || current.stopped) return;
54
+ await read(current, current.cursor);
55
+ await keepAlive(current);
56
+ };
57
+ const tick = async (current) => {
58
+ if (current.stopped) return;
59
+ const done = await step(current).then(() => true, (error) => {
60
+ report(error);
61
+ return false;
62
+ });
63
+ setTimeout(() => void tick(current), done ? intervalMs : beatMs);
64
+ };
65
+ const start = () => {
66
+ const current = {
67
+ stopped: false,
68
+ beaten: 0,
69
+ ready: Promise.resolve()
70
+ };
71
+ current.ready = inTurn(() => load(current)).then(() => void setTimeout(() => void tick(current), intervalMs));
72
+ return current;
73
+ };
74
+ const stop = (current) => {
75
+ current.stopped = true;
76
+ run = void 0;
77
+ loaded([]);
78
+ inTurn(() => retireSelf(db, self));
79
+ };
80
+ return {
81
+ /** The process, as its frames and presences name it. */
82
+ id: self,
83
+ /** Reads the other processes' frames from now on, until the returned function is called; once for each call. */
84
+ follow: () => {
85
+ followers += 1;
86
+ if (!run) run = start();
87
+ let following = true;
88
+ return () => {
89
+ if (!following) return;
90
+ following = false;
91
+ followers -= 1;
92
+ if (followers === 0 && run) stop(run);
93
+ };
94
+ },
95
+ /** Resolves once the other processes' presences are read, or their snapshot failed. */
96
+ ready: () => run?.ready ?? Promise.resolve(),
97
+ /** Counts and adds the broadcasts as frames, all of them or, over a topic's rate, none: `false` then. */
98
+ broadcast: (publications) => admitBroadcasts(db, statementTimeoutMs, self, publications),
99
+ /** Writes a change of this process's presences, after those before it. */
100
+ changed: (change) => void inTurn(() => writePresence(db, self, change))
101
+ };
102
+ };
103
+ //#endregion
104
+ export { realtimeRelay };
@@ -0,0 +1,82 @@
1
+ import { writeTransaction } from "../../transactions.js";
2
+ import { realtimeLimits } from "../realtime-limits.js";
3
+ import { sql } from "kysely";
4
+ import { Buffer } from "node:buffer";
5
+ //#region src/realtime/relay/relay-frame-sql.ts
6
+ /** Frames read in one query at most. */
7
+ const frameBatch = 1e3;
8
+ var Refused = class extends Error {};
9
+ const bytesOf = (payload) => payload instanceof ArrayBuffer ? Buffer.from(payload).toString("base64") : null;
10
+ const jsonOf = (payload) => payload instanceof ArrayBuffer ? null : JSON.stringify(payload) ?? null;
11
+ /**
12
+ * Counts the broadcasts into each topic's second, every process's together, and adds them as frames, all of them or,
13
+ * when a topic would take more than its broadcasts for this second, none: `false` then.
14
+ */
15
+ const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
16
+ if (publications.length === 0) return true;
17
+ const takes = /* @__PURE__ */ new Map();
18
+ for (const { topic } of publications) takes.set(topic, (takes.get(topic) ?? 0) + 1);
19
+ return writeTransaction(db, statementTimeoutMs, async (trx) => {
20
+ const { rows } = await sql`insert into realtime_relay.rates as r (topic, second, count)
21
+ select topic, extract(epoch from now())::bigint, n from unnest(${[...takes.keys()]}::text[], ${[...takes.values()]}::int[]) as t(topic, n)
22
+ where n <= ${realtimeLimits.topicBroadcastsPerSecond}
23
+ on conflict (topic) do update set second = excluded.second, count = case when r.second = excluded.second then r.count + excluded.count else excluded.count end
24
+ where r.second <> excluded.second or r.count + excluded.count <= ${realtimeLimits.topicBroadcastsPerSecond}
25
+ returning topic`.execute(trx);
26
+ if (rows.length < takes.size) throw new Refused();
27
+ await sql`insert into realtime_relay.frames (process, kind, topic, private, event, payload, bytes, meta)
28
+ select ${self}::uuid, 'broadcast', topic, private, event, payload::json, decode(bytes, 'base64'), meta::json
29
+ from unnest(${publications.map((p) => p.topic)}::text[], ${publications.map((p) => p.private)}::boolean[], ${publications.map((p) => p.event)}::text[],
30
+ ${publications.map((p) => jsonOf(p.payload))}::text[], ${publications.map((p) => bytesOf(p.payload))}::text[], ${publications.map((p) => p.meta ? JSON.stringify(p.meta) : null)}::text[])
31
+ with ordinality as f(topic, private, event, payload, bytes, meta, position)
32
+ order by position`.execute(trx);
33
+ return true;
34
+ }).catch((error) => {
35
+ if (error instanceof Refused) return false;
36
+ throw error;
37
+ });
38
+ };
39
+ const frameOf = (row) => {
40
+ const id = BigInt(row.id);
41
+ if (row.kind === "presence") return {
42
+ id,
43
+ kind: "presence",
44
+ topic: row.topic,
45
+ private: row.private,
46
+ diff: row.payload
47
+ };
48
+ const payload = row.bytes ? new Uint8Array(row.bytes).buffer : row.payload;
49
+ return {
50
+ id,
51
+ kind: "broadcast",
52
+ publication: {
53
+ topic: row.topic,
54
+ event: row.event,
55
+ payload,
56
+ private: row.private,
57
+ ...row.meta && { meta: row.meta }
58
+ }
59
+ };
60
+ };
61
+ /**
62
+ * At most `frameBatch` frames after `after` or among `gaps`, in order: the ids of all of them, to move on from, and the
63
+ * other processes' frames, to deliver.
64
+ */
65
+ const readFrames = async (db, self, after, gaps) => {
66
+ const { rows } = await sql`select id::text as id, process = ${self}::uuid as own, kind, topic, private, event,
67
+ case when process <> ${self}::uuid then payload end as payload, case when process <> ${self}::uuid then bytes end as bytes, meta
68
+ from realtime_relay.frames where id > ${String(after)}::bigint or id = any(${gaps.map(String)}::bigint[])
69
+ order by id limit ${frameBatch}`.execute(db);
70
+ return {
71
+ ids: rows.map((row) => BigInt(row.id)),
72
+ frames: rows.filter((row) => !row.own).map(frameOf),
73
+ full: rows.length === frameBatch
74
+ };
75
+ };
76
+ /** Deletes the frames older than a minute and the counts of seconds before it. */
77
+ const pruneFrames = async (db) => {
78
+ await sql`delete from realtime_relay.frames where at < now() - interval '1 minute'`.execute(db);
79
+ await sql`delete from realtime_relay.rates where second < extract(epoch from now())::bigint - 60`.execute(db);
80
+ };
81
+ //#endregion
82
+ export { admitBroadcasts, frameBatch, pruneFrames, readFrames };
@@ -0,0 +1,71 @@
1
+ import { sql } from "kysely";
2
+ //#region src/realtime/relay/relay-presence-sql.ts
3
+ const stateOf = (key, meta) => meta ? { [key]: { metas: [meta] } } : {};
4
+ /** The change as the `presence_diff` the other processes' channels hear. */
5
+ const diffOf = (change) => ({
6
+ joins: stateOf(change.key, change.joined),
7
+ leaves: stateOf(change.key, change.left)
8
+ });
9
+ const leaveFrame = sql.raw(`json_build_object('joins', '{}'::json, 'leaves', json_build_object(key, json_build_object('metas', json_build_array(meta))))`);
10
+ const joinFrame = sql.raw(`json_build_object('joins', json_build_object(key, json_build_object('metas', json_build_array(meta))), 'leaves', '{}'::json)`);
11
+ /**
12
+ * Keeps the change in `presences` and adds its frame, in one statement: a leave alone is sent only when the presence
13
+ * was there, as a process that was taken for stopped had its presences removed already.
14
+ */
15
+ const writePresence = async (db, self, change) => {
16
+ const ref = change.joined?.phx_ref ?? null;
17
+ await sql`with gone as (delete from realtime_relay.presences where process = ${self}::uuid and ref = ${change.left?.phx_ref ?? null}::text returning ref),
18
+ added as (insert into realtime_relay.presences (process, ref, topic, private, key, meta)
19
+ select ${self}::uuid, ${ref}::text, ${change.topic}::text, ${change.private}::boolean, ${change.key}::text, ${JSON.stringify(change.joined ?? null)}::json where ${ref}::text is not null)
20
+ insert into realtime_relay.frames (process, kind, topic, private, payload)
21
+ select ${self}::uuid, 'presence', ${change.topic}::text, ${change.private}::boolean, ${JSON.stringify(diffOf(change))}::json
22
+ where ${ref}::text is not null or exists (select from gone)`.execute(db);
23
+ };
24
+ /** Adds the presences back, with their joins, for a process that was taken for stopped while it was not. */
25
+ const restorePresences = async (db, self, tracked) => {
26
+ if (tracked.length === 0) return;
27
+ await sql`with added as (insert into realtime_relay.presences (process, ref, topic, private, key, meta)
28
+ select ${self}::uuid, ref, topic, private, key, meta::json from unnest(${tracked.map((t) => t.meta.phx_ref)}::text[], ${tracked.map((t) => t.topic)}::text[],
29
+ ${tracked.map((t) => t.private)}::boolean[], ${tracked.map((t) => t.key)}::text[], ${tracked.map((t) => JSON.stringify(t.meta))}::text[]) as t(ref, topic, private, key, meta)
30
+ on conflict do nothing returning process, topic, private, key, meta)
31
+ insert into realtime_relay.frames (process, kind, topic, private, payload) select process, 'presence', topic, private, ${joinFrame} from added`.execute(db);
32
+ };
33
+ /**
34
+ * Marks this process as running, by the database's clock: `true` when its row was not there, as at its start or once
35
+ * another process took it for stopped.
36
+ */
37
+ const beat = async (db, self) => {
38
+ const { rows } = await sql`insert into realtime_relay.processes (id) values (${self}::uuid)
39
+ on conflict (id) do update set seen_at = now() returning (xmax = 0) as inserted`.execute(db);
40
+ return rows[0].inserted;
41
+ };
42
+ const retire = (db, dead) => sql`with dead as (delete from realtime_relay.processes where ${dead} returning id),
43
+ gone as (delete from realtime_relay.presences p where p.process in (select id from dead) or not exists (select from realtime_relay.processes q where q.id = p.process)
44
+ returning process, topic, private, key, meta)
45
+ insert into realtime_relay.frames (process, kind, topic, private, payload) select process, 'presence', topic, private, ${leaveFrame} from gone`.execute(db);
46
+ /** Removes the processes that have not beaten for `staleSeconds`, and their presences. */
47
+ const retireStale = async (db, staleSeconds) => {
48
+ await retire(db, sql`seen_at < now() - make_interval(secs => ${staleSeconds})`);
49
+ };
50
+ /** Removes this process and its presences, as it stops. */
51
+ const retireSelf = async (db, self) => {
52
+ await retire(db, sql`id = ${self}::uuid`);
53
+ };
54
+ /**
55
+ * In one snapshot, the other processes' presences and where their frames stand: the newest id, and every id from the
56
+ * first of the last two seconds on, below which a frame not committed yet is unlikely.
57
+ */
58
+ const presenceSnapshot = (db, statementTimeoutMs, self) => db.transaction().setIsolationLevel("repeatable read").execute(async (trx) => {
59
+ await sql`select set_config('statement_timeout', ${String(statementTimeoutMs)}, true)`.execute(trx);
60
+ const presences = await sql`select topic, private, key, meta from realtime_relay.presences where process <> ${self}::uuid`.execute(trx);
61
+ const newest = await sql`select coalesce(max(id), 0)::text as id from realtime_relay.frames`.execute(trx);
62
+ const recent = await sql`select id::text as id from realtime_relay.frames
63
+ where id >= (select min(id) from realtime_relay.frames where at > now() - interval '2 seconds') order by id`.execute(trx);
64
+ return {
65
+ presences: presences.rows,
66
+ newest: BigInt(newest.rows[0].id),
67
+ visible: recent.rows.map((row) => BigInt(row.id))
68
+ };
69
+ });
70
+ //#endregion
71
+ export { beat, diffOf, presenceSnapshot, restorePresences, retireSelf, retireStale, writePresence };
@@ -0,0 +1,55 @@
1
+ //#region src/realtime/relay/relay-schema-sql.ts
2
+ /** The schema that carries broadcasts and presence between the processes serving an app. */
3
+ const relaySchema = "realtime_relay";
4
+ /** The tables the relay needs; `json`, not `jsonb`, so a payload reaches the other processes as it was sent. */
5
+ const relayTables = [
6
+ "frames",
7
+ "presences",
8
+ "processes",
9
+ "rates"
10
+ ];
11
+ /**
12
+ * - `frames`: what a process sends, a broadcast or a presence diff, read by the others and pruned after a minute.
13
+ * - `presences`: what each process's channels track, which a process that starts reads.
14
+ * - `processes`: each process's last heartbeat; a process silent too long has its presences removed.
15
+ * - `rates`: each topic's broadcasts in the current second, every process's together.
16
+ */
17
+ const relaySchemaSql = `create schema if not exists ${relaySchema};
18
+
19
+ create table if not exists ${relaySchema}.frames (
20
+ id bigint generated always as identity primary key,
21
+ process uuid not null,
22
+ kind text not null check (kind in ('broadcast', 'presence')),
23
+ topic text not null,
24
+ private boolean not null,
25
+ event text,
26
+ payload json,
27
+ bytes bytea,
28
+ meta json,
29
+ at timestamptz not null default now()
30
+ );
31
+
32
+ create index if not exists frames_at on ${relaySchema}.frames (at);
33
+
34
+ create table if not exists ${relaySchema}.processes (
35
+ id uuid primary key,
36
+ seen_at timestamptz not null default now()
37
+ );
38
+
39
+ create table if not exists ${relaySchema}.presences (
40
+ process uuid not null,
41
+ ref text not null,
42
+ topic text not null,
43
+ private boolean not null,
44
+ key text not null,
45
+ meta json not null,
46
+ primary key (process, ref)
47
+ );
48
+
49
+ create table if not exists ${relaySchema}.rates (
50
+ topic text primary key,
51
+ second bigint not null,
52
+ count integer not null
53
+ );`;
54
+ //#endregion
55
+ export { relaySchema, relaySchemaSql, relayTables };
@@ -0,0 +1,7 @@
1
+ import { Db } from "@protobase/query";
2
+ //#region src/realtime/relay/relay-schema.d.ts
3
+ /** The SQL that adds the `realtime_relay` schema `realtime: { processes: 'many' }` needs; `undefined` when it is there. */
4
+ export declare const realtimeRelayMigration: (db: Db) => Promise<"create schema if not exists realtime_relay;\n\ncreate table if not exists realtime_relay.frames (\n id bigint generated always as identity primary key,\n process uuid not null,\n kind text not null check (kind in ('broadcast', 'presence')),\n topic text not null,\n private boolean not null,\n event text,\n payload json,\n bytes bytea,\n meta json,\n at timestamptz not null default now()\n);\n\ncreate index if not exists frames_at on realtime_relay.frames (at);\n\ncreate table if not exists realtime_relay.processes (\n id uuid primary key,\n seen_at timestamptz not null default now()\n);\n\ncreate table if not exists realtime_relay.presences (\n process uuid not null,\n ref text not null,\n topic text not null,\n private boolean not null,\n key text not null,\n meta json not null,\n primary key (process, ref)\n);\n\ncreate table if not exists realtime_relay.rates (\n topic text primary key,\n second bigint not null,\n count integer not null\n);" | undefined>;
5
+ /** Refuses to go on while the `realtime_relay` schema is missing; the project's migrations add it. */
6
+ export declare const checkRealtimeRelay: (db: Db) => Promise<void>;
7
+ //#endregion
@@ -0,0 +1,18 @@
1
+ import { migrationCommand } from "../../changes/capture-schema.js";
2
+ import { relaySchema, relaySchemaSql, relayTables } from "./relay-schema-sql.js";
3
+ import { sql } from "kysely";
4
+ //#region src/realtime/relay/relay-schema.ts
5
+ const relayCurrent = async (db) => {
6
+ const names = relayTables.map((table) => `${relaySchema}.${table}`);
7
+ const { rows } = await sql`select bool_and(to_regclass(name) is not null) as found from unnest(${names}::text[]) as name`.execute(db);
8
+ return rows[0].found;
9
+ };
10
+ /** The SQL that adds the `realtime_relay` schema `realtime: { processes: 'many' }` needs; `undefined` when it is there. */
11
+ const realtimeRelayMigration = async (db) => await relayCurrent(db) ? void 0 : relaySchemaSql;
12
+ /** Refuses to go on while the `realtime_relay` schema is missing; the project's migrations add it. */
13
+ const checkRealtimeRelay = async (db) => {
14
+ if (await relayCurrent(db)) return;
15
+ throw new Error(`The ${relaySchema} schema that realtime.processes "many" 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 { checkRealtimeRelay, realtimeRelayMigration };