@protobase/server 0.62.1 → 0.62.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.
Files changed (37) hide show
  1. package/dist/calls/call-hub.js +1 -0
  2. package/dist/calls/call-member.d.ts +3 -0
  3. package/dist/calls/call-ring.d.ts +12 -0
  4. package/dist/calls/call-ring.js +8 -0
  5. package/dist/calls/call-store.d.ts +2 -1
  6. package/dist/calls/call-store.js +21 -6
  7. package/dist/calls/call-sweep.js +11 -4
  8. package/dist/calls/calls-access.js +7 -1
  9. package/dist/calls/calls-runtime.d.ts +18 -2
  10. package/dist/calls/calls-runtime.js +38 -8
  11. package/dist/calls/calls-service.js +39 -11
  12. package/dist/calls/calls-sql.d.ts +7 -3
  13. package/dist/calls/calls-sql.js +32 -9
  14. package/dist/calls/calls-store.d.ts +4 -1
  15. package/dist/calls/calls-store.js +2 -1
  16. package/dist/calls/member-relay.js +4 -2
  17. package/dist/calls/ring-routes.js +28 -0
  18. package/dist/calls/ring-store.d.ts +14 -0
  19. package/dist/calls/ring-store.js +12 -0
  20. package/dist/calls/ringer.d.ts +40 -0
  21. package/dist/calls/ringer.js +53 -0
  22. package/dist/calls/rings-sql.d.ts +9 -0
  23. package/dist/calls/rings-sql.js +50 -0
  24. package/dist/create-admin.js +11 -3
  25. package/dist/index.js +4 -4
  26. package/dist/jobs/runtime.js +1 -1
  27. package/dist/live/visible-notices.js +9 -3
  28. package/dist/meta.d.ts +2 -0
  29. package/dist/meta.js +1 -0
  30. package/dist/notifications/deliver.js +1 -1
  31. package/dist/notifications/history-routes.js +1 -1
  32. package/dist/notifications/postgres-history-store.js +1 -1
  33. package/dist/notifications/push-job.js +1 -1
  34. package/dist/notifications/send-notification.js +1 -1
  35. package/dist/notifications/web-push/web-push-sender.js +1 -1
  36. package/dist/package.js +1 -1
  37. package/package.json +4 -4
@@ -142,6 +142,7 @@ const callHub = ({ store, manyProcesses, report }) => {
142
142
  };
143
143
  return {
144
144
  call: admitted.call,
145
+ started: admitted.started,
145
146
  room,
146
147
  listen: async (listener) => {
147
148
  local.listener = listener;
@@ -1,3 +1,4 @@
1
+ import { CallRingInput } from "./call-ring.js";
1
2
  import { CallRemoteTrack, CallTrackSource } from "@protobase/schema/internal";
2
3
  //#region src/calls/call-member.d.ts
3
4
  /** What a member may send and hear in a call. */
@@ -23,5 +24,7 @@ export type CallMember = CallRights & {
23
24
  member: string;
24
25
  /** The function's own checks, repeated every minute after the record's, such as whether the caller is still invited. */
25
26
  recheck?: () => unknown;
27
+ /** Whom the call rings, on any page of the app they have open, when this join starts it. */
28
+ ring?: CallRingInput;
26
29
  };
27
30
  //#endregion
@@ -0,0 +1,12 @@
1
+ //#region src/calls/call-ring.d.ts
2
+ /** Whom a call rings when a join starts it, and what the ring says. */
3
+ export type CallRingInput = {
4
+ /** The users rung, by their ids, such as the record's other members; the member who starts the call is not. */
5
+ users: readonly string[];
6
+ /** What the ring and the bell say, such as "Ann is calling". */
7
+ title: string;
8
+ body?: string;
9
+ /** Where the bell's entry leads, an absolute URL; the record's page by default. */
10
+ url?: string;
11
+ };
12
+ //#endregion
@@ -0,0 +1,8 @@
1
+ /** The ring as given, or an error naming what is wrong with it. */
2
+ const checkRing = (ring) => {
3
+ if (!Array.isArray(ring.users) || ring.users.length > 50 || !ring.users.every((user) => typeof user === "string" && user.length > 0)) throw new TypeError(`calls.join's ring.users must be a list of at most 50 user ids, each a non-empty string`);
4
+ if (typeof ring.title !== "string" || ring.title.length === 0) throw new TypeError("calls.join's ring.title must be a non-empty string");
5
+ return ring;
6
+ };
7
+ //#endregion
8
+ export { checkRing };
@@ -20,10 +20,11 @@ export type AdmitInput = {
20
20
  maxMembers: number;
21
21
  maxMinutes: number;
22
22
  };
23
- /** The member in the call `call`, as the members row `row`; or why not. */
23
+ /** The member in the call `call`, as the members row `row`, with whether their join started it; or why not. */
24
24
  export type Admission = {
25
25
  call: string;
26
26
  row: string;
27
+ started: boolean;
27
28
  } | {
28
29
  refused: Refusal;
29
30
  };
@@ -18,13 +18,16 @@ const endIfEmpty = async (trx, id) => {
18
18
  };
19
19
  const liveCall = async (trx, record, member, maxMinutes) => {
20
20
  for (let attempt = 0; attempt < 2; attempt++) {
21
- await sql`insert into "calls"."calls" ("id", "resource", "record_key", "started_by", "started_at", "ends_at")
21
+ const inserted = await sql`insert into "calls"."calls" ("id", "resource", "record_key", "started_by", "started_at", "ends_at")
22
22
  values (gen_random_uuid(), ${record.resource}, ${record.key}, ${member}, now(), now() + make_interval(mins => ${maxMinutes}))
23
- on conflict ("resource", "record_key") where "ended_at" is null do nothing`.execute(trx);
23
+ on conflict ("resource", "record_key") where "ended_at" is null do nothing returning "id"::text as id`.execute(trx);
24
24
  const { rows } = await sql`select "id"::text as id from "calls"."calls"
25
25
  where "resource" = ${record.resource} and "record_key" = ${record.key} and "ended_at" is null`.execute(trx);
26
26
  const call = rows[0] && await lockCall(trx, rows[0].id);
27
- if (call && !call.ended && !call.expired) return call.id;
27
+ if (call && !call.ended && !call.expired) return {
28
+ id: call.id,
29
+ started: inserted.rows[0]?.id === call.id
30
+ };
28
31
  if (call && !call.ended) await endCall(trx, call.id);
29
32
  }
30
33
  throw new Error(`The call on ${record.resource} ${record.key} could not be started`);
@@ -45,6 +48,7 @@ const enter = async (trx, call, member, process, tracks) => {
45
48
  const admit = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (trx) => {
46
49
  const { record, member, resume } = input;
47
50
  let call;
51
+ let started = false;
48
52
  if (resume) {
49
53
  const resumed = isCallId(resume.call) ? await lockCall(trx, resume.call) : void 0;
50
54
  if (!resumed || resumed.ended || resumed.resource !== record.resource || resumed.record_key !== record.key) return { refused: "ended" };
@@ -53,7 +57,11 @@ const admit = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (t
53
57
  return { refused: "ended" };
54
58
  }
55
59
  call = resumed.id;
56
- } else call = await liveCall(trx, record, member, input.maxMinutes);
60
+ } else {
61
+ const live = await liveCall(trx, record, member, input.maxMinutes);
62
+ call = live.id;
63
+ started = live.started;
64
+ }
57
65
  await sql`update "calls"."members" set "left_at" = now()
58
66
  where "call_id" = ${call}::uuid and "left_at" is null and "seen_at" < now() - make_interval(secs => ${30})`.execute(trx);
59
67
  const { rows } = await sql`select "member", "left_at" is null as present from "calls"."members"
@@ -67,7 +75,8 @@ const admit = (db, timeoutMs, input) => writeTransaction(db, timeoutMs, async (t
67
75
  if (refused) return { refused };
68
76
  return {
69
77
  call,
70
- row: await enter(trx, call, member, input.process, resume?.tracks ?? [])
78
+ row: await enter(trx, call, member, input.process, resume?.tracks ?? []),
79
+ started
71
80
  };
72
81
  });
73
82
  /** The member of row `row` left, unless another process took them over; the call ends with its last member. */
@@ -108,5 +117,11 @@ const heartbeat = async (db, process, rows) => {
108
117
  await sql`update "calls"."members" set "seen_at" = now()
109
118
  where "id" = any(${`{${rows.join(",")}}`}::bigint[]) and "process" = ${process}::uuid and "left_at" is null`.execute(db);
110
119
  };
120
+ /** The record the call is on; `undefined` for an unknown call. */
121
+ const callRecord = async (db, call) => {
122
+ if (!isCallId(call)) return;
123
+ const { rows } = await sql`select "resource", "record_key" as key from "calls"."calls" where "id" = ${call}::uuid`.execute(db);
124
+ return rows[0];
125
+ };
111
126
  //#endregion
112
- export { admit, endCall, heartbeat, isCallId, leave, remove, setTracks };
127
+ export { admit, callRecord, endCall, heartbeat, isCallId, leave, remove, setTracks };
@@ -7,20 +7,27 @@ const signalSeconds = 60;
7
7
  /**
8
8
  * Ends what should have ended, over every call, whichever process served it: the members no process heard of within
9
9
  * `graceSeconds`, as after their socket was lost or their process stopped; the calls past their last minute; the
10
- * calls nobody is in any more. A call an admission holds is left to the next sweep. Old signals go too.
10
+ * calls nobody is in any more. A call an admission holds is left to the next sweep. Old signals go too. The records
11
+ * whose calls it changed.
11
12
  */
12
13
  const sweepCalls = (db, timeoutMs) => writeTransaction(db, timeoutMs, async (trx) => {
13
- await sql`update "calls"."members" set "left_at" = now()
14
- where "left_at" is null and "seen_at" < now() - make_interval(secs => ${30})`.execute(trx);
14
+ const left = await sql`update "calls"."members" set "left_at" = now()
15
+ where "left_at" is null and "seen_at" < now() - make_interval(secs => ${30}) returning "call_id"::text as id`.execute(trx);
16
+ const changed = new Set(left.rows.map((row) => row.id));
15
17
  const { rows } = await sql`select "id"::text as id from "calls"."calls" c where "ended_at" is null
16
18
  and ("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null))
17
19
  for update skip locked`.execute(trx);
18
20
  if (rows.length > 0) {
19
21
  const ended = await sql`select "id"::text as id from "calls"."calls" c where "id" = any(${`{${rows.map((row) => row.id).join(",")}}`}::uuid[])
20
22
  and ("ends_at" <= now() or not exists (select from "calls"."members" m where m."call_id" = c."id" and m."left_at" is null))`.execute(trx);
21
- for (const { id } of ended.rows) await endCall(trx, id);
23
+ for (const { id } of ended.rows) {
24
+ await endCall(trx, id);
25
+ changed.add(id);
26
+ }
22
27
  }
23
28
  await sql`delete from "calls"."signals" where "at" < now() - make_interval(secs => ${signalSeconds})`.execute(trx);
29
+ if (changed.size === 0) return [];
30
+ return (await sql`select distinct "resource", "record_key" as key from "calls"."calls" where "id" = any(${`{${[...changed].join(",")}}`}::uuid[])`.execute(trx)).rows;
24
31
  });
25
32
  //#endregion
26
33
  export { sweepCalls };
@@ -1,4 +1,5 @@
1
1
  import { forbidden } from "../problem.js";
2
+ import { checkRing } from "./call-ring.js";
2
3
  //#region src/calls/calls-access.ts
3
4
  /** The largest message of a member's socket: a description, with room to spare. */
4
5
  const maxMessageBytes = 307200;
@@ -26,11 +27,16 @@ const callsAccess = (service, caller) => {
26
27
  const record = service.recordOf(resource, key);
27
28
  const mayRead = () => records.get(resource, key);
28
29
  await mayRead();
30
+ const ring = member.ring && checkRing(member.ring);
31
+ const by = {
32
+ user: String(session.user.id),
33
+ ...ring && { ring }
34
+ };
29
35
  return {
30
36
  ...await (await service.runtime()).relay(record, member.member, {
31
37
  publish: member.publish,
32
38
  ...member.hears && { hears: member.hears }
33
- }),
39
+ }, by),
34
40
  maxMessageBytes,
35
41
  recheck: async () => {
36
42
  await mayRead();
@@ -1,9 +1,11 @@
1
1
  import { CallsOptions } from "./calls-options.js";
2
2
  import { SocketEvents } from "../functions/function-socket.js";
3
+ import { CallRingInput } from "./call-ring.js";
3
4
  import { CallRights } from "./call-member.js";
4
5
  import { CallRecord } from "./call-store.js";
5
6
  import { CallItem } from "./call-reads.js";
6
7
  import { CallStore } from "./calls-store.js";
8
+ import { Ringer } from "./ringer.js";
7
9
  import { Db } from "@protobase/query";
8
10
  //#region src/calls/calls-runtime.d.ts
9
11
  export type CallsRuntimeInput = {
@@ -13,17 +15,31 @@ export type CallsRuntimeInput = {
13
15
  report: (error: unknown) => void;
14
16
  /** Memory in tests; the `calls` schema otherwise. */
15
17
  store?: CallStore;
18
+ /** The rings of calls, which joins with a `ring` start. */
19
+ rings?: Ringer;
20
+ /** Tells the record's readers its calls changed, such as on the record's channel. */
21
+ announce?: (record: CallRecord, items: CallItem[]) => Promise<void>;
16
22
  };
17
23
  /**
18
24
  * An app's calls on this process: the members it serves, through the configured plugin, and the records whose calls
19
25
  * its pages follow. While it serves a member or a page, it reads them every second and, every five, marks its members
20
26
  * as served and sweeps every call, whichever process served it; with neither, it runs no timer.
21
27
  */
22
- export declare const callsRuntime: ({ db, timeoutMs, options, report, store }: CallsRuntimeInput) => {
28
+ export declare const callsRuntime: ({ db, timeoutMs, options, report, store, rings, announce }: CallsRuntimeInput) => {
23
29
  /** The events of the member's socket, relayed to the plugin: they come into the record's call at their `join`. */
24
- relay: (record: CallRecord, member: string, rights: CallRights) => Promise<SocketEvents>;
30
+ relay: (record: CallRecord, member: string, rights: CallRights, by?: {
31
+ user?: string;
32
+ ring?: CallRingInput;
33
+ }) => Promise<SocketEvents>;
25
34
  /** Removes the member from the call, and only from it: whether they were in it. */
26
35
  remove: (call: string, member: string) => Promise<boolean>;
36
+ /** The calls that ring the user now, and declining one. */
37
+ rings: () => {
38
+ start: (call: string, record: CallRecord, by: string, ring: CallRingInput) => Promise<void>;
39
+ stop: (call: string, userId: string) => Promise<void>;
40
+ settle: () => Promise<void>;
41
+ of: (userId: string) => Promise<import("@protobase/schema/internal").CallRing[]>;
42
+ };
27
43
  /** The record's latest calls, newest first, once every call that should have ended has. */
28
44
  of: (record: CallRecord) => Promise<CallItem[]>;
29
45
  /** Tells `listener` the record's calls now and whenever they change, until the returned function is called. */
@@ -18,7 +18,7 @@ const loadBackend = async (settings) => {
18
18
  * its pages follow. While it serves a member or a page, it reads them every second and, every five, marks its members
19
19
  * as served and sweeps every call, whichever process served it; with neither, it runs no timer.
20
20
  */
21
- const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db, timeoutMs) }) => {
21
+ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db, timeoutMs), rings, announce }) => {
22
22
  const hub = callHub({
23
23
  store,
24
24
  manyProcesses: options.processes === "many",
@@ -30,12 +30,20 @@ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db,
30
30
  let backend;
31
31
  let timer;
32
32
  let reads = 0;
33
+ const changed = (records) => {
34
+ if (!announce) return;
35
+ for (const record of records) store.recordCalls([record]).then((found) => announce(record, found.get(recordId(record)) ?? [])).catch(report);
36
+ };
37
+ const sweep = async () => {
38
+ changed(await store.sweep());
39
+ await rings?.settle();
40
+ };
33
41
  const busy = () => hub.size > 0 || watch.size > 0;
34
42
  const tick = async () => {
35
43
  reads++;
36
44
  if (reads % beatEvery === 0) {
37
45
  await hub.beat();
38
- await store.sweep();
46
+ await sweep();
39
47
  }
40
48
  await hub.refreshAll();
41
49
  await watch.read();
@@ -50,14 +58,27 @@ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db,
50
58
  }, readEveryMs);
51
59
  timer.unref?.();
52
60
  };
53
- const door = { admit: async (input, link) => {
61
+ const door = { admit: async ({ user, ring, ...input }, link) => {
54
62
  const admitted = await hub.admit(input, link);
55
63
  schedule();
56
- return admitted;
64
+ if ("refused" in admitted) return admitted;
65
+ changed([input.record]);
66
+ if (rings && user !== void 0) {
67
+ if (ring && admitted.started) await rings.start(admitted.call, input.record, user, ring).catch(report);
68
+ await rings.stop(admitted.call, user).catch(report);
69
+ }
70
+ return {
71
+ ...admitted,
72
+ leave: async () => {
73
+ await admitted.leave();
74
+ changed([input.record]);
75
+ await rings?.settle();
76
+ }
77
+ };
57
78
  } };
58
79
  return {
59
80
  /** The events of the member's socket, relayed to the plugin: they come into the record's call at their `join`. */
60
- relay: async (record, member, rights) => {
81
+ relay: async (record, member, rights, by = {}) => {
61
82
  backend ??= loadBackend(options.backend);
62
83
  return memberRelay({
63
84
  door,
@@ -67,23 +88,32 @@ const callsRuntime = ({ db, timeoutMs, options, report, store = sqlCallStore(db,
67
88
  rights,
68
89
  maxMembers,
69
90
  maxMinutes,
70
- report
91
+ report,
92
+ ...by
71
93
  });
72
94
  },
73
95
  /** Removes the member from the call, and only from it: whether they were in it. */
74
96
  remove: async (call, member) => {
97
+ const record = await store.recordOf(call);
75
98
  const removed = await store.remove(call, member);
76
99
  await hub.refresh([call]);
100
+ if (record) changed([record]);
101
+ await rings?.settle();
77
102
  return removed;
78
103
  },
104
+ /** The calls that ring the user now, and declining one. */
105
+ rings: () => {
106
+ if (!rings) throw new Error("Not implemented: rings without a ring store");
107
+ return rings;
108
+ },
79
109
  /** The record's latest calls, newest first, once every call that should have ended has. */
80
110
  of: async (record) => {
81
- await store.sweep();
111
+ await sweep();
82
112
  return (await store.recordCalls([record])).get(recordId(record)) ?? [];
83
113
  },
84
114
  /** Tells `listener` the record's calls now and whenever they change, until the returned function is called. */
85
115
  watch: async (record, listener) => {
86
- await store.sweep();
116
+ await sweep();
87
117
  const stop = await watch.add(record, listener);
88
118
  schedule();
89
119
  return stop;
@@ -1,19 +1,47 @@
1
1
  import { toolKey } from "../assistant/records.js";
2
- import { encodeKey } from "@protobase/schema/internal";
2
+ import { sendNotification } from "../notifications/send-notification.js";
3
+ import { callsChannelEvent, decodeKey, encodeKey } from "@protobase/schema/internal";
4
+ import { appPath } from "@protobase/schema";
3
5
  //#region src/calls/calls-service.ts
4
6
  /** An app's calls over its resources: the runtime loaded at the first use, so an app that never calls loads none of it. */
5
- const callsService = (deps, options) => {
7
+ const callsService = (deps, options, wiring) => {
6
8
  let runtime;
9
+ const load = async () => {
10
+ const [{ callsRuntime }, { ringer, ringMessage }, { sqlRingStore }] = await Promise.all([
11
+ import("./calls-runtime.js"),
12
+ import("./ringer.js"),
13
+ import("./ring-store.js")
14
+ ]);
15
+ const { db, statementTimeoutMs, report, registry } = deps();
16
+ const recordUrl = ({ resource, key }) => wiring.origin === void 0 ? void 0 : new URL(appPath({
17
+ kind: "record",
18
+ resource,
19
+ key: decodeKey(key, registry.find(resource).keyTypes)
20
+ }), wiring.origin).href;
21
+ return callsRuntime({
22
+ db,
23
+ timeoutMs: statementTimeoutMs,
24
+ options,
25
+ report,
26
+ rings: ringer({
27
+ store: sqlRingStore(db, wiring.notices),
28
+ recordUrl,
29
+ ...wiring.doNotDisturb && { doNotDisturb: (userIds) => wiring.doNotDisturb(db, userIds) },
30
+ ...wiring.notifications && { notify: ({ userIds, ...notification }) => sendNotification(db, {
31
+ userIds,
32
+ inbox: true
33
+ }, ringMessage(notification)) }
34
+ }),
35
+ announce: (record, items) => wiring.channels().broadcast({
36
+ resource: record.resource,
37
+ key: record.key,
38
+ event: callsChannelEvent,
39
+ payload: { items }
40
+ })
41
+ });
42
+ };
7
43
  return {
8
- runtime: () => runtime ??= import("./calls-runtime.js").then(({ callsRuntime }) => {
9
- const { db, statementTimeoutMs, report } = deps();
10
- return callsRuntime({
11
- db,
12
- timeoutMs: statementTimeoutMs,
13
- options,
14
- report
15
- });
16
- }),
44
+ runtime: () => runtime ??= load(),
17
45
  recordOf: (resource, key) => {
18
46
  const found = toolKey(deps(), resource, key);
19
47
  return {
@@ -1,7 +1,11 @@
1
+ import { EventsOptions } from "../changes/events-options.js";
1
2
  import { Db } from "@protobase/query";
2
3
  //#region src/calls/calls-sql.d.ts
3
- /** The SQL that adds the `calls` schema and its tables, `undefined` when they are there. They are never dropped. */
4
- export declare const callsMigration: (db: Db) => Promise<"create schema if not exists \"calls\";\n\ncreate table if not exists \"calls\".\"calls\" (\n \"id\" uuid primary key,\n \"resource\" text not null,\n \"record_key\" text not null,\n \"started_by\" text not null,\n \"started_at\" timestamptz not null,\n \"ends_at\" timestamptz not null,\n \"ended_at\" timestamptz\n);\ncreate unique index if not exists \"calls_live\" on \"calls\".\"calls\" (\"resource\", \"record_key\") where \"ended_at\" is null;\ncreate index if not exists \"calls_record\" on \"calls\".\"calls\" (\"resource\", \"record_key\", \"started_at\");\n\ncreate table if not exists \"calls\".\"members\" (\n \"id\" bigint generated always as identity primary key,\n \"call_id\" uuid not null references \"calls\".\"calls\" (\"id\") on delete cascade,\n \"member\" text not null,\n \"process\" uuid not null,\n \"tracks\" jsonb not null,\n \"joined_at\" timestamptz not null,\n \"seen_at\" timestamptz not null,\n \"left_at\" timestamptz,\n \"removed_at\" timestamptz\n);\ncreate unique index if not exists \"members_present\" on \"calls\".\"members\" (\"call_id\", \"member\") where \"left_at\" is null;\ncreate index if not exists \"members_call\" on \"calls\".\"members\" (\"call_id\", \"member\");\ncreate index if not exists \"members_seen\" on \"calls\".\"members\" (\"seen_at\") where \"left_at\" is null;\n\ncreate table if not exists \"calls\".\"signals\" (\n \"id\" bigint generated always as identity primary key,\n \"process\" uuid not null,\n \"call_id\" uuid not null,\n \"to_member\" text not null,\n \"from_member\" text not null,\n \"body\" json not null,\n \"at\" timestamptz not null\n);\ncreate index if not exists \"signals_process\" on \"calls\".\"signals\" (\"process\", \"id\");\ncreate index if not exists \"signals_at\" on \"calls\".\"signals\" (\"at\");;" | undefined>;
4
+ /**
5
+ * The SQL that adds the `calls` schema and its tables, and the changes schema's notices table rings reach open pages
6
+ * through, `undefined` when they are there. They are never dropped.
7
+ */
8
+ export declare const callsMigration: (db: Db, events?: EventsOptions) => Promise<string | undefined>;
5
9
  /** Refuses to go on while the app has `calls` and the database lacks their tables; the project's migrations add them. */
6
- export declare const checkCalls: (db: Db, calls: unknown) => Promise<void>;
10
+ export declare const checkCalls: (db: Db, calls: unknown, events?: EventsOptions) => Promise<void>;
7
11
  //#endregion
@@ -1,3 +1,6 @@
1
+ import { quoteIdent } from "../changes/capture-sql.js";
2
+ import { noticesTableSql } from "../live/notices-sql.js";
3
+ import { changesSchema } from "../changes/events-options.js";
1
4
  import { migrationCommand } from "../changes/capture-schema.js";
2
5
  import { sql } from "kysely";
3
6
  //#region src/calls/calls-sql.ts
@@ -9,6 +12,8 @@ import { sql } from "kysely";
9
12
  * them and when it last heard of them. A removal of someone not in the call is a row that starts and ends at once.
10
13
  * - `signals`: with several processes, a member's description or candidate for a member another process serves,
11
14
  * taken out by that process as it reads it, and deleted after a minute when no process did.
15
+ * - `rings`: a user a call rang, by their id, with the ring's text, until when it rings, and when it stopped: they
16
+ * joined the call, declined, or the call ended.
12
17
  */
13
18
  const callsSchemaSql = `create schema if not exists "calls";
14
19
 
@@ -49,18 +54,36 @@ create table if not exists "calls"."signals" (
49
54
  "at" timestamptz not null
50
55
  );
51
56
  create index if not exists "signals_process" on "calls"."signals" ("process", "id");
52
- create index if not exists "signals_at" on "calls"."signals" ("at");`;
53
- const hasCallsTables = async (db) => (await sql`select to_regclass('calls.calls') is not null and to_regclass('calls.members') is not null and to_regclass('calls.signals') is not null as found`.execute(db)).rows[0].found;
54
- /** The SQL that adds the `calls` schema and its tables, `undefined` when they are there. They are never dropped. */
55
- const callsMigration = async (db) => {
56
- if (await hasCallsTables(db)) return void 0;
57
- return `${callsSchemaSql};`;
57
+ create index if not exists "signals_at" on "calls"."signals" ("at");
58
+
59
+ create table if not exists "calls"."rings" (
60
+ "call_id" uuid not null references "calls"."calls" ("id") on delete cascade,
61
+ "user_id" text not null,
62
+ "title" text not null,
63
+ "body" text,
64
+ "rung_at" timestamptz not null,
65
+ "until" timestamptz not null,
66
+ "stopped_at" timestamptz,
67
+ primary key ("call_id", "user_id")
68
+ );
69
+ create index if not exists "rings_user" on "calls"."rings" ("user_id", "until") where "stopped_at" is null;
70
+ create index if not exists "rings_until" on "calls"."rings" ("until") where "stopped_at" is null;`;
71
+ const hasCallsTables = async (db) => (await sql`select to_regclass('calls.calls') is not null and to_regclass('calls.members') is not null and to_regclass('calls.signals') is not null and to_regclass('calls.rings') is not null as found`.execute(db)).rows[0].found;
72
+ const noticesTable = (events) => `${quoteIdent(changesSchema(events))}."notices"`;
73
+ const hasNotices = async (db, events) => (await sql`select to_regclass(${noticesTable(events)}) is not null as found`.execute(db)).rows[0].found;
74
+ /**
75
+ * The SQL that adds the `calls` schema and its tables, and the changes schema's notices table rings reach open pages
76
+ * through, `undefined` when they are there. They are never dropped.
77
+ */
78
+ const callsMigration = async (db, events) => {
79
+ const statements = [...await hasCallsTables(db) ? [] : [callsSchemaSql], ...await hasNotices(db, events) ? [] : [`create schema if not exists ${quoteIdent(changesSchema(events))}`, noticesTableSql(changesSchema(events))]];
80
+ return statements.length === 0 ? void 0 : `${statements.join(";\n\n")};`;
58
81
  };
59
82
  /** Refuses to go on while the app has `calls` and the database lacks their tables; the project's migrations add them. */
60
- const checkCalls = async (db, calls) => {
83
+ const checkCalls = async (db, calls, events) => {
61
84
  if (!calls) return;
62
- if (await hasCallsTables(db)) return;
63
- throw new Error(`The calls schema is missing, and calls keep their records in it. Write it as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
85
+ if (await hasCallsTables(db) && await hasNotices(db, events)) return;
86
+ throw new Error(`The calls schema or the notices table that rings go through is missing. Write them as a migration with \`${migrationCommand}\`, apply it with the project's migrations, then start again.`);
64
87
  };
65
88
  //#endregion
66
89
  export { callsMigration, callsSchemaSql, checkCalls };
@@ -11,7 +11,10 @@ export type CallStore = {
11
11
  remove: (call: string, member: string) => Promise<boolean>;
12
12
  setTracks: (row: string, process: string, tracks: CallRemoteTrack[]) => Promise<void>;
13
13
  heartbeat: (process: string, rows: readonly string[]) => Promise<void>;
14
- sweep: () => Promise<void>;
14
+ /** Ends what should have ended: the records whose calls it changed. */
15
+ sweep: () => Promise<CallRecord[]>;
16
+ /** The record the call is on; `undefined` for an unknown call. */
17
+ recordOf: (call: string) => Promise<CallRecord | undefined>;
15
18
  rosters: (calls: readonly string[]) => Promise<Map<string, {
16
19
  ended: boolean;
17
20
  members: RosterMember[];
@@ -1,6 +1,6 @@
1
+ import { admit, callRecord, heartbeat, leave, remove, setTracks } from "./call-store.js";
1
2
  import { recordCalls, rosters } from "./call-reads.js";
2
3
  import { takeSignals, writeSignal } from "./call-signals-sql.js";
3
- import { admit, heartbeat, leave, remove, setTracks } from "./call-store.js";
4
4
  import { sweepCalls } from "./call-sweep.js";
5
5
  //#region src/calls/calls-store.ts
6
6
  /** The `calls` schema of the app's database; writes under the API's statement timeout. */
@@ -11,6 +11,7 @@ const sqlCallStore = (db, timeoutMs) => ({
11
11
  setTracks: (row, process, tracks) => setTracks(db, row, process, tracks),
12
12
  heartbeat: (process, rows) => heartbeat(db, process, rows),
13
13
  sweep: () => sweepCalls(db, timeoutMs),
14
+ recordOf: (call) => callRecord(db, call),
14
15
  rosters: (calls) => rosters(db, calls),
15
16
  recordCalls: (records) => recordCalls(db, records),
16
17
  writeSignal: (signal) => writeSignal(db, signal),
@@ -15,7 +15,7 @@ const refusals = {
15
15
  * may not make, and tells the call and the plugin how the socket ended. The plugin only ever sees well-formed messages
16
16
  * of a member Protobase let in.
17
17
  */
18
- const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMinutes, report }) => {
18
+ const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMinutes, report, user, ring }) => {
19
19
  let browser;
20
20
  let session;
21
21
  let inCall;
@@ -54,7 +54,9 @@ const memberRelay = ({ door, backend, record, member, rights, maxMembers, maxMin
54
54
  member,
55
55
  maxMembers,
56
56
  maxMinutes,
57
- ...resume && { resume }
57
+ ...resume && { resume },
58
+ ...user !== void 0 && { user },
59
+ ...ring && !resume && { ring }
58
60
  }, {
59
61
  send,
60
62
  close
@@ -0,0 +1,28 @@
1
+ import { isCallId } from "./call-store.js";
2
+ import { Hono } from "hono";
3
+ import { declineWord } from "@protobase/schema/internal";
4
+ //#region src/calls/ring-routes.ts
5
+ const noStore = { "Cache-Control": "no-store" };
6
+ const userOf = (session) => session.anonymous || session.client ? void 0 : String(session.user.id);
7
+ /**
8
+ * The rings of the signed-in user, mounted at `ringsPath` behind the API's authenticator: `GET /` the calls that ring
9
+ * them now, newest first, and `POST /{call}/decline` stops a call ringing them, on every page they have open. The
10
+ * user comes from the session alone.
11
+ */
12
+ const ringRoutes = (calls) => {
13
+ const app = new Hono();
14
+ app.get("/", async (c) => {
15
+ const user = userOf(c.get("session"));
16
+ const items = user === void 0 ? [] : await (await calls.runtime()).rings().of(user);
17
+ return c.json({ items }, 200, noStore);
18
+ });
19
+ app.post(`/:call/${declineWord}`, async (c) => {
20
+ const user = userOf(c.get("session"));
21
+ const call = c.req.param("call");
22
+ if (user !== void 0 && isCallId(call)) await (await calls.runtime()).rings().stop(call, user);
23
+ return c.body(null, 204);
24
+ });
25
+ return app;
26
+ };
27
+ //#endregion
28
+ export { ringRoutes };
@@ -0,0 +1,14 @@
1
+ import { RingText } from "./rings-sql.js";
2
+ import { CallRing } from "@protobase/schema/internal";
3
+ import { Db } from "@protobase/query";
4
+ //#region src/calls/ring-store.d.ts
5
+ /** Where the rings of calls are kept and their users' pages told of them: the `calls` schema, or memory in tests. */
6
+ export type RingStore = {
7
+ start: (call: string, userIds: readonly string[], text: RingText, seconds: number) => Promise<string[]>;
8
+ stop: (call: string, userId: string) => Promise<boolean>;
9
+ stopEnded: () => Promise<string[]>;
10
+ of: (userId: string) => Promise<CallRing[]>;
11
+ /** Tells these users' open pages that the calls ringing them changed. */
12
+ notice: (userIds: readonly string[]) => Promise<void>;
13
+ };
14
+ //#endregion
@@ -0,0 +1,12 @@
1
+ import { ringsOf, startRings, stopEndedRings, stopRing, writeRingNotices } from "./rings-sql.js";
2
+ //#region src/calls/ring-store.ts
3
+ /** The `calls` schema's rings, and the notices table of the changes schema `notices`. */
4
+ const sqlRingStore = (db, notices) => ({
5
+ start: (call, userIds, text, seconds) => startRings(db, call, userIds, text, seconds),
6
+ stop: (call, userId) => stopRing(db, call, userId),
7
+ stopEnded: () => stopEndedRings(db),
8
+ of: (userId) => ringsOf(db, userId),
9
+ notice: (userIds) => writeRingNotices(db, notices, userIds)
10
+ });
11
+ //#endregion
12
+ export { sqlRingStore };
@@ -0,0 +1,40 @@
1
+ import { CallRingInput } from "./call-ring.js";
2
+ import { CallRecord } from "./call-store.js";
3
+ import { RingStore } from "./ring-store.js";
4
+ import { CallRing } from "@protobase/schema/internal";
5
+ //#region src/calls/ringer.d.ts
6
+ /** The bell's entry of a ring, sent to every user it names, also to those on do not disturb. */
7
+ export type RingNotification = {
8
+ userIds: string[];
9
+ title: string;
10
+ body?: string;
11
+ url?: string;
12
+ data: {
13
+ call: string;
14
+ resource: string;
15
+ key: string;
16
+ };
17
+ };
18
+ export type RingerInput = {
19
+ store: RingStore;
20
+ /** The users on do not disturb of these, with `status`: they are not rung, and keep the ring in their bell. */
21
+ doNotDisturb?: (userIds: readonly string[]) => Promise<Set<string>>;
22
+ /** Sends the bell's entry, with notifications: their push job holds the push of someone on do not disturb. */
23
+ notify?: (notification: RingNotification) => Promise<void>;
24
+ /** The record's page in the app, as an absolute URL, where a ring's entry leads by default; none without the app's address. */
25
+ recordUrl: (record: CallRecord) => string | undefined;
26
+ };
27
+ /**
28
+ * The rings of calls: started when a join starts a call with a `ring`, stopped for a user once they join the call or
29
+ * decline it, and for everyone once the call ends. Each change reaches the user's open pages as a live notice.
30
+ */
31
+ export declare const ringer: ({ store, doNotDisturb, notify, recordUrl }: RingerInput) => {
32
+ start: (call: string, record: CallRecord, by: string, ring: CallRingInput) => Promise<void>;
33
+ /** The user joined the call, or declined it: it rings them no more, on any page. */
34
+ stop: (call: string, userId: string) => Promise<void>;
35
+ /** Stops the rings of the calls that ended. */
36
+ settle: () => Promise<void>;
37
+ of: (userId: string) => Promise<CallRing[]>;
38
+ };
39
+ export type Ringer = ReturnType<typeof ringer>;
40
+ //#endregion
@@ -0,0 +1,53 @@
1
+ //#region src/calls/ringer.ts
2
+ /** How long a call rings the people it names, unless they answer or decline first, or it ends. */
3
+ const ringSeconds = 30;
4
+ /**
5
+ * The bell's entry and push of a ring: urgent, kept by push services no longer than a minute, and tagged by its call,
6
+ * its id without hyphens as a push tag takes it, so the call's later pushes replace it.
7
+ */
8
+ const ringMessage = ({ title, body, url, data }) => ({
9
+ title,
10
+ ...body !== void 0 && { body },
11
+ ...url !== void 0 && { url },
12
+ data,
13
+ urgency: "high",
14
+ ttl: 60,
15
+ tag: data.call.replaceAll("-", "")
16
+ });
17
+ /**
18
+ * The rings of calls: started when a join starts a call with a `ring`, stopped for a user once they join the call or
19
+ * decline it, and for everyone once the call ends. Each change reaches the user's open pages as a live notice.
20
+ */
21
+ const ringer = ({ store, doNotDisturb, notify, recordUrl }) => ({
22
+ start: async (call, record, by, ring) => {
23
+ const named = [...new Set(ring.users)].filter((user) => user !== by);
24
+ if (named.length === 0) return;
25
+ const quiet = doNotDisturb ? await doNotDisturb(named) : /* @__PURE__ */ new Set();
26
+ const rung = await store.start(call, named.filter((user) => !quiet.has(user)), {
27
+ title: ring.title,
28
+ body: ring.body ?? null
29
+ }, 30);
30
+ await store.notice(rung);
31
+ const url = ring.url ?? recordUrl(record);
32
+ await notify?.({
33
+ userIds: named,
34
+ title: ring.title,
35
+ ...ring.body !== void 0 && { body: ring.body },
36
+ ...url !== void 0 && { url },
37
+ data: {
38
+ call,
39
+ resource: record.resource,
40
+ key: record.key
41
+ }
42
+ });
43
+ },
44
+ /** The user joined the call, or declined it: it rings them no more, on any page. */
45
+ stop: async (call, userId) => {
46
+ if (await store.stop(call, userId)) await store.notice([userId]);
47
+ },
48
+ /** Stops the rings of the calls that ended. */
49
+ settle: async () => store.notice(await store.stopEnded()),
50
+ of: (userId) => store.of(userId)
51
+ });
52
+ //#endregion
53
+ export { ringMessage, ringSeconds, ringer };
@@ -0,0 +1,9 @@
1
+ import { CallRing } from "@protobase/schema/internal";
2
+ import { Db } from "@protobase/query";
3
+ //#region src/calls/rings-sql.d.ts
4
+ /** What a ring says, as the app gives it: the bell and the ringing page show it. */
5
+ export type RingText = {
6
+ title: string;
7
+ body: string | null;
8
+ };
9
+ //#endregion
@@ -0,0 +1,50 @@
1
+ import { quoteIdent } from "../changes/capture-sql.js";
2
+ import { ringNotice } from "@protobase/schema/internal";
3
+ import { sql } from "kysely";
4
+ //#region src/calls/rings-sql.ts
5
+ const users = (values) => `{${values.map((value) => JSON.stringify(value)).join(",")}}`;
6
+ /** Rings these users for the call until `seconds` from now; a user it rang already is not rung again. The users it rang. */
7
+ const startRings = async (db, call, userIds, text, seconds) => {
8
+ if (userIds.length === 0) return [];
9
+ const { rows } = await sql`insert into "calls"."rings" ("call_id", "user_id", "title", "body", "rung_at", "until")
10
+ select ${call}::uuid, u, ${text.title}, ${text.body}, now(), now() + make_interval(secs => ${seconds}) from unnest(${users(userIds)}::text[]) as u
11
+ on conflict ("call_id", "user_id") do nothing returning "user_id"`.execute(db);
12
+ return rows.map((row) => row.user_id);
13
+ };
14
+ /** Stops the call ringing the user, as when they joined or declined it: whether it rang them still. */
15
+ const stopRing = async (db, call, userId) => {
16
+ const { rows } = await sql`update "calls"."rings" set "stopped_at" = now()
17
+ where "call_id" = ${call}::uuid and "user_id" = ${userId} and "stopped_at" is null and "until" > now() returning "user_id"`.execute(db);
18
+ return rows.length > 0;
19
+ };
20
+ /** Stops the rings of calls that ended: the users they rang still. */
21
+ const stopEndedRings = async (db) => {
22
+ const { rows } = await sql`update "calls"."rings" r set "stopped_at" = now() from "calls"."calls" c
23
+ where c."id" = r."call_id" and r."stopped_at" is null and r."until" > now() and c."ended_at" is not null returning r."user_id"`.execute(db);
24
+ return [...new Set(rows.map((row) => row.user_id))];
25
+ };
26
+ /** How many rings a page shows at once at most: the newest. */
27
+ const shownRings = 5;
28
+ /** The calls that ring the user now, newest first. */
29
+ const ringsOf = async (db, userId) => {
30
+ const { rows } = await sql`select r."call_id"::text as call, c."resource", c."record_key" as key, c."started_by", r."title", r."body",
31
+ greatest(0, ceil(extract(epoch from r."until" - now()) * 1000))::int as remaining_ms
32
+ from "calls"."rings" r join "calls"."calls" c on c."id" = r."call_id"
33
+ where r."user_id" = ${userId} and r."stopped_at" is null and r."until" > now() and c."ended_at" is null
34
+ order by r."rung_at" desc limit ${shownRings}`.execute(db);
35
+ return rows;
36
+ };
37
+ /**
38
+ * Tells these users' open pages, through the live stream's notices, that the calls ringing them changed. The table is
39
+ * locked against other writers first, so the notice commits in the order of its id and no reader skips it.
40
+ */
41
+ const writeRingNotices = async (db, schema, userIds) => {
42
+ if (userIds.length === 0) return;
43
+ const table = sql.raw(`${quoteIdent(schema)}."notices"`);
44
+ await db.transaction().execute(async (trx) => {
45
+ await sql`lock table ${table} in share row exclusive mode`.execute(trx);
46
+ await sql`insert into ${table} ("resource", "record_key", "operation") select ${ringNotice}, u, 'update' from unnest(${users([...new Set(userIds)])}::text[]) as u`.execute(trx);
47
+ });
48
+ };
49
+ //#endregion
50
+ export { ringsOf, startRings, stopEndedRings, stopRing, writeRingNotices };
@@ -60,6 +60,7 @@ import { createWebhooks } from "./webhooks/webhooks.js";
60
60
  import { webhookTable } from "./webhooks/table/webhook-table.js";
61
61
  import { subscriptionUrlCheck, subscriptionWriteCheck } from "./webhooks/table/validate-hook.js";
62
62
  import { callsService } from "./calls/calls-service.js";
63
+ import { ringRoutes } from "./calls/ring-routes.js";
63
64
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
64
65
  import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
65
66
  import { devicesPath, historyPath } from "./notifications/paths.js";
@@ -111,7 +112,7 @@ import { createScanGuard } from "./scan-guard.js";
111
112
  import { failedJobs, failedJobsView } from "./jobs/failed-jobs.js";
112
113
  import { Hono } from "hono";
113
114
  import { accountPage, accountPageName, checkPages, settingsResources, withSettingsFields } from "@protobase/layout";
114
- import { statusPath } from "@protobase/schema/internal";
115
+ import { ringsPath, statusPath } from "@protobase/schema/internal";
115
116
  //#region src/create-admin.ts
116
117
  const declaredRoles = (auth, roles) => {
117
118
  const names = [...auth?.roles ?? [], ...Object.keys(roles?.definitions ?? {})];
@@ -265,7 +266,13 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
265
266
  ...model && { model },
266
267
  ...options.onUnhandledError && { report: options.onUnhandledError }
267
268
  } },
268
- ...calls && { calls: callsService(() => deps, calls) }
269
+ ...calls && { calls: callsService(() => deps, calls, {
270
+ notices: changes,
271
+ channels: () => channels,
272
+ notifications: Boolean(notifications),
273
+ ...auth?.origins[0] && { origin: auth.origins[0] },
274
+ ...statusTables && { doNotDisturb: (ringDb, userIds) => onDoNotDisturb(ringDb, statusTables.statuses, userIds) }
275
+ }) }
269
276
  };
270
277
  const appName = shownAppName(auth, globalThis.process?.env ?? {});
271
278
  const meta = createMeta(deps, appName, icons?.favicon ? faviconPath : void 0);
@@ -461,6 +468,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
461
468
  app.route(historyPath(systemPath), guarded((routes) => void routes.route("/", historyRoutes(history, auth?.origins ?? []))));
462
469
  }
463
470
  if (statusTables) app.route(statusPath(systemPath), guarded((routes) => void routes.route("/", statusRoutes(db, statusTables))));
471
+ if (deps.calls) app.route(ringsPath(systemPath), guarded((routes) => void routes.route("/", ringRoutes(deps.calls))));
464
472
  if (forms) app.route(`${systemPath}/forms`, formRoutes(deps, forms));
465
473
  const feed = tableFeed({
466
474
  db,
@@ -502,7 +510,7 @@ const createAdmin = ({ resources: appResources, views: appViews = [], pages = []
502
510
  }));
503
511
  }
504
512
  }
505
- if (appModels.some((model) => model.live)) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
513
+ if (appModels.some((model) => model.live) || deps.calls) app.route(systemPath || "/", guarded((live) => void live.route("/", liveRoutes(feed, (session) => visibleNotices(deps, session))), withAnonymous(authenticate, options.roles)));
506
514
  app.route(basePath, api);
507
515
  app.route(`${deps.systemPath}/meta`, metaApp);
508
516
  app.route(systemPath || "/", webManifestRoute(appName, identity, icons));
package/dist/index.js CHANGED
@@ -63,21 +63,22 @@ import { recordOf, resourceChange } from "./changes/resource-change.js";
63
63
  import { defineFunction } from "./functions/define-function.js";
64
64
  import { newWebhookSecret } from "./webhooks/standard-webhooks.js";
65
65
  import { subscription } from "./webhooks/subscription.js";
66
+ import { historyIdPattern, historyLimits } from "./notifications/history.js";
67
+ import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
68
+ import { NotificationSendError, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
69
+ import { sendNotification } from "./notifications/send-notification.js";
66
70
  import { maxHistoryPage, maxUnreadCount } from "./notifications/history-store.js";
67
71
  import { generateVapidKeys } from "./notifications/web-push/keys.js";
68
72
  import { DeviceStoreError, postgresDeviceStore } from "./notifications/postgres-store.js";
69
73
  import { deviceStore, removeDevicesWithUsers } from "./notifications/device-store-of.js";
70
- import { historyIdPattern, historyLimits } from "./notifications/history.js";
71
74
  import { HistoryStoreError, postgresHistoryStore } from "./notifications/postgres-history-store.js";
72
75
  import { historyStore, removeHistoryWithUsers } from "./notifications/history-store-of.js";
73
76
  import { deviceIdParam, devicesPath, historyIdParam, historyPath } from "./notifications/paths.js";
74
77
  import { defaultHistoryPage } from "./notifications/history-routes.js";
75
78
  import { maxDevicesPerUser } from "./notifications/routes.js";
76
- import { PushMessageError, maxTtlSeconds, pushMessage, urgencies } from "./notifications/message.js";
77
79
  import { readVapidSettings, vapidPrivateKeyVariable, vapidPublicKeyVariable, vapidSubjectVariable } from "./notifications/web-push/vapid-settings.js";
78
80
  import { webPushSender } from "./notifications/web-push/web-push-sender.js";
79
81
  import { notificationSenders } from "./notifications/senders-of.js";
80
- import { NotificationSendError, maxMessageBytes, maxRecipients } from "./notifications/send-input.js";
81
82
  import { onDoNotDisturb } from "./status/do-not-disturb.js";
82
83
  import { statusNames } from "./status/options.js";
83
84
  import { errorReportingSettings, errorReportingVariable } from "./error-reporting/error-reporting-settings.js";
@@ -115,7 +116,6 @@ import { channelSendsMigration, checkChannelSends } from "./channels/sends-schem
115
116
  import { callsMigration, checkCalls } from "./calls/calls-sql.js";
116
117
  import { checkAi } from "./ai/check-ai.js";
117
118
  import { checkNotificationsSchema, notificationsMigration } from "./notifications/schema.js";
118
- import { sendNotification } from "./notifications/send-notification.js";
119
119
  import { checkStatusSchema, statusMigration } from "./status/schema.js";
120
120
  import { maxWidgetPropsBytes, widget } from "./assistant/widget.js";
121
121
  import { checkQueryRunner, queryRunnerMigration } from "./assistant/query-runner/query-runner.js";
@@ -4,8 +4,8 @@ import "./schedule.js";
4
4
  import { runJob, validatePayload } from "./run-job.js";
5
5
  import { platformMailer } from "../mail/platform-mailer.js";
6
6
  import "../notifications/push-job-name.js";
7
- import { runInBackground } from "../background-work.js";
8
7
  import { sendNotification } from "../notifications/send-notification.js";
8
+ import { runInBackground } from "../background-work.js";
9
9
  import { busyDatabase } from "./busy-database.js";
10
10
  import { jobsDatabase } from "./database.js";
11
11
  import { queueOptions, queueSettingsDiffer, sendOptions, workOptions } from "./options.js";
@@ -1,8 +1,10 @@
1
1
  import { requestAccess } from "../request-access.js";
2
+ import { ringNotice } from "@protobase/schema/internal";
2
3
  //#region src/live/visible-notices.ts
3
4
  /**
4
- * Which notices a caller hears of: those of the live resources they may list or read, in their tenant. A notice names
5
- * a record, never its values, and the page reads the record again as the caller, so no row filter applies here.
5
+ * Which notices a caller hears of: those of the live resources they may list or read, in their tenant, and, with
6
+ * calls, those of the calls that ring them (`ringNotice`, keyed by the user's id). A notice names a record, never its
7
+ * values, and the page reads the record again as the caller, so no row filter applies here.
6
8
  */
7
9
  const visibleNotices = async (deps, session) => {
8
10
  const readable = /* @__PURE__ */ new Map();
@@ -12,7 +14,11 @@ const visibleNotices = async (deps, session) => {
12
14
  if (entry.model.tenant && session.tenant === void 0) continue;
13
15
  readable.set(entry.name, entry.model.tenant ? String(session.tenant) : void 0);
14
16
  }
15
- return (notice) => readable.has(notice.resource) && (readable.get(notice.resource) === void 0 || readable.get(notice.resource) === notice.tenant);
17
+ const rung = deps.calls && !session.anonymous && !session.client ? String(session.user.id) : void 0;
18
+ return (notice) => {
19
+ if (notice.resource === ringNotice) return rung !== void 0 && notice.key === rung;
20
+ return readable.has(notice.resource) && (readable.get(notice.resource) === void 0 || readable.get(notice.resource) === notice.tenant);
21
+ };
16
22
  };
17
23
  //#endregion
18
24
  export { visibleNotices };
package/dist/meta.d.ts CHANGED
@@ -61,6 +61,8 @@ export type CallerMeta = {
61
61
  };
62
62
  /** With `status: {}`: people set their own status, and see the statuses of those they share an organization with. */
63
63
  status?: true;
64
+ /** With `calls`: the app's calls ring people, on any page they have open, through the live stream. */
65
+ calls?: true;
64
66
  /** The language the admin app's screens are in, when the app sets one for everyone. */
65
67
  locale?: Locale;
66
68
  /** The hosts besides the app's own whose images the admin app draws with the page's referrer, when there are any. */
package/dist/meta.js CHANGED
@@ -98,6 +98,7 @@ const createMeta = (deps, appName, appIcon) => {
98
98
  ...deps.webPushKey && { webPushKey: deps.webPushKey }
99
99
  } },
100
100
  ...deps.status && { status: true },
101
+ ...deps.calls && { calls: true },
101
102
  ...deps.locale && { locale: deps.locale },
102
103
  ...deps.files && deps.files.trustedHosts.length > 0 && { files: { trustedHosts: deps.files.trustedHosts } },
103
104
  ...plugins.length > 0 && { plugins },
@@ -1,7 +1,7 @@
1
1
  import { FatalJobError } from "../jobs/run-job.js";
2
2
  import { PushMessageError } from "./message.js";
3
- import { PushSenderError } from "./guarded-sender.js";
4
3
  import { historyEntry } from "./send-input.js";
4
+ import { PushSenderError } from "./guarded-sender.js";
5
5
  import { NotificationStoreError, storeCall } from "./store-call.js";
6
6
  //#region src/notifications/deliver.ts
7
7
  const tally = () => ({
@@ -1,6 +1,6 @@
1
1
  import { badRequest } from "../problem.js";
2
- import "./history-store.js";
3
2
  import { historyIdPattern } from "./history.js";
3
+ import "./history-store.js";
4
4
  import { ownSignInUser } from "./caller.js";
5
5
  import { historyCursor, historyPosition } from "./history-cursor.js";
6
6
  import { jsonBody, jsonOnly, limitedBody, queryOf } from "./history-request.js";
@@ -1,7 +1,7 @@
1
1
  import { isDatabaseError } from "../error-handler.js";
2
+ import { checkedNotification } from "./history.js";
2
3
  import { requiredPage, requiredText, requiredTime } from "./history-store.js";
3
4
  import { historyColumns, historyNames } from "./options.js";
4
- import { checkedNotification } from "./history.js";
5
5
  import { sql } from "kysely";
6
6
  //#region src/notifications/postgres-history-store.ts
7
7
  /**
@@ -1,11 +1,11 @@
1
1
  import { defineJob } from "../jobs/define-job.js";
2
+ import { pushJobName } from "./push-job-name.js";
2
3
  import { deviceStore } from "./device-store-of.js";
3
4
  import { HistoryStoreError } from "./postgres-history-store.js";
4
5
  import { historyStore } from "./history-store-of.js";
5
6
  import { holdJob, replaceJobData, saveJobProgress } from "../jobs/job-progress.js";
6
7
  import { deliver } from "./deliver.js";
7
8
  import { noProgress, pushPayload } from "./push-payload.js";
8
- import { pushJobName } from "./push-job-name.js";
9
9
  //#region src/notifications/push-job.ts
10
10
  const userGone = "23503";
11
11
  /**
@@ -1,6 +1,6 @@
1
1
  import { currentJobs } from "../jobs/current.js";
2
- import { checkedSend } from "./send-input.js";
3
2
  import { pushJobName } from "./push-job-name.js";
3
+ import { checkedSend } from "./send-input.js";
4
4
  //#region src/notifications/send-notification.ts
5
5
  /**
6
6
  * Sends `message` to users or to devices as a background job, enqueued in `db`'s transaction when it is one, so it
@@ -1,6 +1,6 @@
1
+ import { PushMessageError, pushMessage } from "../message.js";
1
2
  import { fromBase64url, isPublicPoint } from "./keys.js";
2
3
  import { isPushEndpoint } from "../push-hosts.js";
3
- import { PushMessageError, pushMessage } from "../message.js";
4
4
  import { encryptPayload, maxPlaintextBytes } from "./encrypt.js";
5
5
  import { webPushPayload } from "./payload.js";
6
6
  import { postPush } from "./post.js";
package/dist/package.js CHANGED
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.62.1";
2
+ var version = "0.62.2";
3
3
  //#endregion
4
4
  export { version };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/server",
3
- "version": "0.62.1",
3
+ "version": "0.62.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.62.1",
46
- "@protobase/query": "0.62.1",
47
- "@protobase/schema": "0.62.1",
45
+ "@protobase/layout": "0.62.2",
46
+ "@protobase/query": "0.62.2",
47
+ "@protobase/schema": "0.62.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",