@protobase/server 0.58.7 → 0.59.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 (45) hide show
  1. package/dist/assistant/query-runner/query-runner.d.ts +7 -2
  2. package/dist/assistant/query-runner/query-runner.js +8 -4
  3. package/dist/assistant/query-runner/runner-plan.js +4 -4
  4. package/dist/better-auth/auth-settings.d.ts +1 -0
  5. package/dist/better-auth/auth-tables.d.ts +13 -6
  6. package/dist/better-auth/auth-tables.js +9 -6
  7. package/dist/better-auth/create-auth.d.ts +1 -0
  8. package/dist/better-auth/store/resources-outside.d.ts +4 -1
  9. package/dist/better-auth/store/resources-outside.js +8 -3
  10. package/dist/better-auth/user-table.d.ts +6 -0
  11. package/dist/better-auth/user-table.js +3 -2
  12. package/dist/channels/channel-bounds.js +17 -0
  13. package/dist/channels/channel-name.d.ts +1 -0
  14. package/dist/channels/channel-name.js +30 -0
  15. package/dist/channels/channel-protocol.js +33 -0
  16. package/dist/channels/channel-refusals.js +26 -0
  17. package/dist/channels/channel-routes.js +98 -0
  18. package/dist/channels/channel-sender.d.ts +20 -0
  19. package/dist/channels/channel-sender.js +39 -0
  20. package/dist/channels/channel-socket.js +253 -0
  21. package/dist/channels/presence-entries.js +24 -0
  22. package/dist/channels/record-readable.js +28 -0
  23. package/dist/channels/sent.js +8 -0
  24. package/dist/create-admin.js +22 -2
  25. package/dist/functions/define-function.d.ts +3 -0
  26. package/dist/functions/routes.js +3 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/package.js +1 -1
  29. package/dist/realtime/broadcast-hub.js +1 -1
  30. package/dist/realtime/broadcast-request.js +1 -1
  31. package/dist/realtime/database-broadcasts.js +1 -1
  32. package/dist/realtime/realtime-app.js +2 -18
  33. package/dist/realtime/realtime-hubs.js +30 -0
  34. package/dist/realtime/realtime-socket.js +2 -2
  35. package/dist/realtime/relay/realtime-options.d.ts +1 -1
  36. package/dist/realtime/relay/realtime-relay.js +2 -2
  37. package/dist/realtime/relay/relay-frame-sql.js +7 -4
  38. package/dist/realtime/relay/relay-presence-sql.js +13 -13
  39. package/dist/realtime/relay/relay-schema-sql.js +11 -2
  40. package/dist/realtime/relay/relay-schema.d.ts +1 -1
  41. package/dist/realtime/relay/relay-schema.js +4 -2
  42. package/dist/realtime/relay/relayed-hubs.js +63 -40
  43. package/dist/webhooks/attempt.js +1 -0
  44. package/dist/webhooks/webhooks.js +2 -1
  45. package/package.json +4 -4
@@ -12,7 +12,7 @@ const jsonOf = (payload) => payload instanceof ArrayBuffer ? null : JSON.stringi
12
12
  * Counts the broadcasts into each topic's second, every process's together, and adds them as frames, all of them or,
13
13
  * when a topic would take more than its broadcasts for this second, none: `false` then.
14
14
  */
15
- const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
15
+ const admitBroadcasts = async (db, statementTimeoutMs, self, space, publications) => {
16
16
  if (publications.length === 0) return true;
17
17
  const takes = /* @__PURE__ */ new Map();
18
18
  for (const { topic } of publications) takes.set(topic, (takes.get(topic) ?? 0) + 1);
@@ -24,8 +24,8 @@ const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
24
24
  where r.second <> excluded.second or r.count + excluded.count <= ${realtimeLimits.topicBroadcastsPerSecond}
25
25
  returning topic`.execute(trx);
26
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
27
+ await sql`insert into realtime_relay.frames (process, kind, space, topic, private, event, payload, bytes, meta)
28
+ select ${self}::uuid, 'broadcast', ${space}::text, topic, private, event, payload::json, decode(bytes, 'base64'), meta::json
29
29
  from unnest(${publications.map((p) => p.topic)}::text[], ${publications.map((p) => p.private)}::boolean[], ${publications.map((p) => p.event)}::text[],
30
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
31
  with ordinality as f(topic, private, event, payload, bytes, meta, position)
@@ -38,8 +38,10 @@ const admitBroadcasts = async (db, statementTimeoutMs, self, publications) => {
38
38
  };
39
39
  const frameOf = (row) => {
40
40
  const id = BigInt(row.id);
41
+ const { space } = row;
41
42
  if (row.kind === "presence") return {
42
43
  id,
44
+ space,
43
45
  kind: "presence",
44
46
  topic: row.topic,
45
47
  private: row.private,
@@ -48,6 +50,7 @@ const frameOf = (row) => {
48
50
  const payload = row.bytes ? new Uint8Array(row.bytes).buffer : row.payload;
49
51
  return {
50
52
  id,
53
+ space,
51
54
  kind: "broadcast",
52
55
  publication: {
53
56
  topic: row.topic,
@@ -63,7 +66,7 @@ const frameOf = (row) => {
63
66
  * other processes' frames, to deliver.
64
67
  */
65
68
  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,
69
+ const { rows } = await sql`select id::text as id, process = ${self}::uuid as own, kind, space, topic, private, event,
67
70
  case when process <> ${self}::uuid then payload end as payload, case when process <> ${self}::uuid then bytes end as bytes, meta
68
71
  from realtime_relay.frames where id > ${String(after)}::bigint or id = any(${gaps.map(String)}::bigint[])
69
72
  order by id limit ${frameBatch}`.execute(db);
@@ -12,23 +12,23 @@ const joinFrame = sql.raw(`json_build_object('joins', json_build_object(key, jso
12
12
  * Keeps the change in `presences` and adds its frame, in one statement: a leave alone is sent only when the presence
13
13
  * was there, as a process that was taken for stopped had its presences removed already.
14
14
  */
15
- const writePresence = async (db, self, change) => {
15
+ const writePresence = async (db, self, space, change) => {
16
16
  const ref = change.joined?.phx_ref ?? null;
17
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
18
+ added as (insert into realtime_relay.presences (process, ref, space, topic, private, key, meta)
19
+ select ${self}::uuid, ${ref}::text, ${space}::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, space, topic, private, payload)
21
+ select ${self}::uuid, 'presence', ${space}::text, ${change.topic}::text, ${change.private}::boolean, ${JSON.stringify(diffOf(change))}::json
22
22
  where ${ref}::text is not null or exists (select from gone)`.execute(db);
23
23
  };
24
24
  /** Adds the presences back, with their joins, for a process that was taken for stopped while it was not. */
25
25
  const restorePresences = async (db, self, tracked) => {
26
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);
27
+ await sql`with added as (insert into realtime_relay.presences (process, ref, space, topic, private, key, meta)
28
+ select ${self}::uuid, ref, space, topic, private, key, meta::json from unnest(${tracked.map((t) => t.meta.phx_ref)}::text[], ${tracked.map((t) => t.space)}::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, space, topic, private, key, meta)
30
+ on conflict do nothing returning process, space, topic, private, key, meta)
31
+ insert into realtime_relay.frames (process, kind, space, topic, private, payload) select process, 'presence', space, topic, private, ${joinFrame} from added`.execute(db);
32
32
  };
33
33
  /**
34
34
  * Marks this process as running, by the database's clock: `true` when its row was not there, as at its start or once
@@ -41,8 +41,8 @@ const beat = async (db, self) => {
41
41
  };
42
42
  const retire = (db, dead) => sql`with dead as (delete from realtime_relay.processes where ${dead} returning id),
43
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);
44
+ returning process, space, topic, private, key, meta)
45
+ insert into realtime_relay.frames (process, kind, space, topic, private, payload) select process, 'presence', space, topic, private, ${leaveFrame} from gone`.execute(db);
46
46
  /** Removes the processes that have not beaten for `staleSeconds`, and their presences. */
47
47
  const retireStale = async (db, staleSeconds) => {
48
48
  await retire(db, sql`seen_at < now() - make_interval(secs => ${staleSeconds})`);
@@ -57,7 +57,7 @@ const retireSelf = async (db, self) => {
57
57
  */
58
58
  const presenceSnapshot = (db, statementTimeoutMs, self) => db.transaction().setIsolationLevel("repeatable read").execute(async (trx) => {
59
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);
60
+ const presences = await sql`select space, topic, private, key, meta from realtime_relay.presences where process <> ${self}::uuid`.execute(trx);
61
61
  const newest = await sql`select coalesce(max(id), 0)::text as id from realtime_relay.frames`.execute(trx);
62
62
  const recent = await sql`select id::text as id from realtime_relay.frames
63
63
  where id >= (select min(id) from realtime_relay.frames where at > now() - interval '2 seconds') order by id`.execute(trx);
@@ -8,8 +8,12 @@ const relayTables = [
8
8
  "processes",
9
9
  "rates"
10
10
  ];
11
+ /** The tables with a `space` column, which a relay schema written before it lacks. */
12
+ const spacedTables = ["frames", "presences"];
11
13
  /**
12
14
  * - `frames`: what a process sends, a broadcast or a presence diff, read by the others and pruned after a minute.
15
+ * - `space` in `frames` and `presences`: the channels they are of (`RelaySpace`), `topics` for a row written without
16
+ * one, as by a process of a version before the record channels.
13
17
  * - `presences`: what each process's channels track, which a process that starts reads.
14
18
  * - `processes`: each process's last heartbeat; a process silent too long has its presences removed.
15
19
  * - `rates`: each topic's broadcasts in the current second, every process's together.
@@ -22,6 +26,7 @@ create table if not exists ${relaySchema}.frames (
22
26
  kind text not null check (kind in ('broadcast', 'presence')),
23
27
  topic text not null,
24
28
  private boolean not null,
29
+ space text not null default 'topics' check (space in ('topics', 'records')),
25
30
  event text,
26
31
  payload json,
27
32
  bytes bytea,
@@ -41,6 +46,7 @@ create table if not exists ${relaySchema}.presences (
41
46
  ref text not null,
42
47
  topic text not null,
43
48
  private boolean not null,
49
+ space text not null default 'topics' check (space in ('topics', 'records')),
44
50
  key text not null,
45
51
  meta json not null,
46
52
  primary key (process, ref)
@@ -50,6 +56,9 @@ create table if not exists ${relaySchema}.rates (
50
56
  topic text primary key,
51
57
  second bigint not null,
52
58
  count integer not null
53
- );`;
59
+ );
60
+
61
+ -- A relay schema written before the record channels gets the column too.
62
+ ${spacedTables.map((table) => `alter table ${relaySchema}.${table} add column if not exists space text not null default 'topics' check (space in ('topics', 'records'));`).join("\n")}`;
54
63
  //#endregion
55
- export { relaySchema, relaySchemaSql, relayTables };
64
+ export { relaySchema, relaySchemaSql, relayTables, spacedTables };
@@ -1,7 +1,7 @@
1
1
  import { Db } from "@protobase/query";
2
2
  //#region src/realtime/relay/relay-schema.d.ts
3
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>;
4
+ export declare const realtimeRelayMigration: (db: Db) => Promise<string | undefined>;
5
5
  /** Refuses to go on while the `realtime_relay` schema is missing; the project's migrations add it. */
6
6
  export declare const checkRealtimeRelay: (db: Db) => Promise<void>;
7
7
  //#endregion
@@ -1,11 +1,13 @@
1
1
  import { migrationCommand } from "../../changes/capture-schema.js";
2
- import { relaySchema, relaySchemaSql, relayTables } from "./relay-schema-sql.js";
2
+ import { relaySchema, relaySchemaSql, relayTables, spacedTables } from "./relay-schema-sql.js";
3
3
  import { sql } from "kysely";
4
4
  //#region src/realtime/relay/relay-schema.ts
5
5
  const relayCurrent = async (db) => {
6
6
  const names = relayTables.map((table) => `${relaySchema}.${table}`);
7
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;
8
+ if (!rows[0].found) return false;
9
+ return (await sql`select count(*)::int as count from information_schema.columns
10
+ where table_schema = ${relaySchema} and table_name = any(${[...spacedTables]}::text[]) and column_name = 'space'`.execute(db)).rows[0].count === spacedTables.length;
9
11
  };
10
12
  /** The SQL that adds the `realtime_relay` schema `realtime: { processes: 'many' }` needs; `undefined` when it is there. */
11
13
  const realtimeRelayMigration = async (db) => await relayCurrent(db) ? void 0 : relaySchemaSql;
@@ -4,56 +4,79 @@ import { presenceReplica } from "./presence-replica.js";
4
4
  import { realtimeRelay } from "./realtime-relay.js";
5
5
  //#region src/realtime/relay/relayed-hubs.ts
6
6
  /**
7
- * The broadcast and presence hubs of `realtime: { processes: 'many' }`: this process's channels in memory, as with one
8
- * process, and every broadcast and presence change written to the relay for the other processes, whose own reach the
9
- * channels here as the relay reads them. A topic's private and public channels stay apart across processes too. A
10
- * broadcast reaches this process's channels once the relay admitted it under the topic's rate. `follow` reads the
11
- * relay while a socket is open.
7
+ * The broadcast and presence hubs of `realtime: { processes: 'many' }`, one pair for each space (`RelaySpace`): this
8
+ * process's channels in memory, as with one process, and every broadcast and presence change written to the relay for
9
+ * the other processes, whose own reach the channels here as the relay reads them, in the space they were sent in. A
10
+ * topic's private and public channels stay apart across processes too. A broadcast reaches this process's channels
11
+ * once the relay admitted it under the topic's rate. `follow` reads the relay while a socket is open.
12
12
  */
13
13
  const relayedHubs = ({ db, statementTimeoutMs, report }) => {
14
- const local = broadcastHub();
15
- const replica = presenceReplica();
14
+ const spaces = /* @__PURE__ */ new Map();
16
15
  const relay = realtimeRelay({
17
16
  db,
18
17
  statementTimeoutMs,
19
18
  report,
20
19
  heard: (frame) => {
21
- if (frame.kind === "broadcast") return local.deliver(frame.publication);
22
- const diff = replica.apply(frame.topic, frame.private, frame.diff);
23
- if (diff) presences.hear(frame.topic, frame.private, diff);
20
+ const space = spaces.get(frame.space);
21
+ if (!space) return;
22
+ if (frame.kind === "broadcast") return space.local.deliver(frame.publication);
23
+ const diff = space.replica.apply(frame.topic, frame.private, frame.diff);
24
+ if (diff) space.presences.hear(frame.topic, frame.private, diff);
24
25
  },
25
- loaded: replica.load,
26
- tracked: () => presences.tracked()
27
- });
28
- const presences = presenceHub({
29
- refPrefix: `${relay.id}:`,
30
- remote: {
31
- state: replica.state,
32
- changed: relay.changed,
33
- ready: relay.ready
34
- }
26
+ loaded: (tracked) => {
27
+ for (const [name, space] of spaces) space.replica.load(tracked.filter((presence) => presence.space === name));
28
+ },
29
+ tracked: () => [...spaces].flatMap(([name, space]) => space.presences.tracked().map((presence) => ({
30
+ space: name,
31
+ ...presence
32
+ })))
35
33
  });
36
- return {
37
- broadcasts: {
38
- join: local.join,
39
- send: async (topic, from, event, payload) => {
40
- const publication = {
41
- topic,
42
- event,
43
- payload,
44
- private: from.private
45
- };
46
- if (!await relay.broadcast([publication])) return false;
47
- local.deliver(publication, from);
48
- return true;
49
- },
50
- publish: async (publications) => {
51
- if (!await relay.broadcast(publications)) return false;
52
- for (const publication of publications) local.deliver(publication);
53
- return true;
34
+ const spaceOf = (name) => {
35
+ const local = broadcastHub();
36
+ const replica = presenceReplica();
37
+ return {
38
+ local,
39
+ replica,
40
+ presences: presenceHub({
41
+ refPrefix: `${relay.id}:`,
42
+ remote: {
43
+ state: replica.state,
44
+ changed: (change) => relay.changed(name, change),
45
+ ready: relay.ready
46
+ }
47
+ }),
48
+ broadcasts: {
49
+ join: local.join,
50
+ send: async (topic, from, event, payload) => {
51
+ const publication = {
52
+ topic,
53
+ event,
54
+ payload,
55
+ private: from.private
56
+ };
57
+ if (!await relay.broadcast(name, [publication])) return false;
58
+ local.deliver(publication, from);
59
+ return true;
60
+ },
61
+ publish: async (publications) => {
62
+ if (!await relay.broadcast(name, publications)) return false;
63
+ for (const publication of publications) local.deliver(publication);
64
+ return true;
65
+ }
54
66
  }
55
- },
56
- presences,
67
+ };
68
+ };
69
+ for (const name of ["topics", "records"]) spaces.set(name, spaceOf(name));
70
+ const hubsOf = (name) => {
71
+ const { broadcasts, presences } = spaces.get(name);
72
+ return {
73
+ broadcasts,
74
+ presences
75
+ };
76
+ };
77
+ return {
78
+ topics: hubsOf("topics"),
79
+ records: hubsOf("records"),
57
80
  follow: relay.follow
58
81
  };
59
82
  };
@@ -53,6 +53,7 @@ const callFunction = async (name, subscription, event, options, context, signal)
53
53
  const functionContext = {
54
54
  name,
55
55
  db: context.db,
56
+ channels: context.channels,
56
57
  caller: async () => caller,
57
58
  acceptWebSocket,
58
59
  calls: { join: async () => acceptWebSocket() }
@@ -60,7 +60,7 @@ const deliveryGroup = ({ subscription, change }) => `${subscription.name}:${chan
60
60
  *
61
61
  * No jobs without a resource with events or history; the subscriptions must name declared events.
62
62
  */
63
- const createWebhooks = ({ deps, subscriptions, functions, schema, log, table }) => {
63
+ const createWebhooks = ({ deps, subscriptions, functions, channels, schema, log, table }) => {
64
64
  const entries = deps.registry.entries;
65
65
  const models = entries.map((entry) => entry.model);
66
66
  const problems = subscriptionProblems(subscriptions, models, Object.keys(functions));
@@ -82,6 +82,7 @@ const createWebhooks = ({ deps, subscriptions, functions, schema, log, table })
82
82
  db,
83
83
  functions,
84
84
  records,
85
+ channels,
85
86
  attempt: tried,
86
87
  signal,
87
88
  ...checkUrl && { checkUrl }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.58.7",
3
+ "version": "0.59.0",
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.58.7",
46
- "@protobase/query": "0.58.7",
47
- "@protobase/schema": "0.58.7",
45
+ "@protobase/layout": "0.59.0",
46
+ "@protobase/query": "0.59.0",
47
+ "@protobase/schema": "0.59.0",
48
48
  "@react-email/render": "^2.1.0",
49
49
  "@scalar/hono-api-reference": "^0.12.10",
50
50
  "@sentry/core": "^11.7.0",