@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.
- package/dist/calls/call-hub.js +1 -0
- package/dist/calls/call-member.d.ts +3 -0
- package/dist/calls/call-ring.d.ts +12 -0
- package/dist/calls/call-ring.js +8 -0
- package/dist/calls/call-store.d.ts +2 -1
- package/dist/calls/call-store.js +21 -6
- package/dist/calls/call-sweep.js +11 -4
- package/dist/calls/calls-access.js +7 -1
- package/dist/calls/calls-runtime.d.ts +18 -2
- package/dist/calls/calls-runtime.js +38 -8
- package/dist/calls/calls-service.js +39 -11
- package/dist/calls/calls-sql.d.ts +7 -3
- package/dist/calls/calls-sql.js +32 -9
- package/dist/calls/calls-store.d.ts +4 -1
- package/dist/calls/calls-store.js +2 -1
- package/dist/calls/member-relay.js +4 -2
- package/dist/calls/ring-routes.js +28 -0
- package/dist/calls/ring-store.d.ts +14 -0
- package/dist/calls/ring-store.js +12 -0
- package/dist/calls/ringer.d.ts +40 -0
- package/dist/calls/ringer.js +53 -0
- package/dist/calls/rings-sql.d.ts +9 -0
- package/dist/calls/rings-sql.js +50 -0
- package/dist/create-admin.js +11 -3
- package/dist/index.js +4 -4
- package/dist/jobs/runtime.js +1 -1
- package/dist/live/visible-notices.js +9 -3
- package/dist/meta.d.ts +2 -0
- package/dist/meta.js +1 -0
- package/dist/notifications/deliver.js +1 -1
- package/dist/notifications/history-routes.js +1 -1
- package/dist/notifications/postgres-history-store.js +1 -1
- package/dist/notifications/push-job.js +1 -1
- package/dist/notifications/send-notification.js +1 -1
- package/dist/notifications/web-push/web-push-sender.js +1 -1
- package/dist/package.js +1 -1
- package/package.json +4 -4
package/dist/calls/call-hub.js
CHANGED
|
@@ -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
|
|
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
|
};
|
package/dist/calls/call-store.js
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|
package/dist/calls/call-sweep.js
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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 ??=
|
|
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
|
-
/**
|
|
4
|
-
|
|
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
|
package/dist/calls/calls-sql.js
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|
package/dist/create-admin.js
CHANGED
|
@@ -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";
|
package/dist/jobs/runtime.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.62.
|
|
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.
|
|
46
|
-
"@protobase/query": "0.62.
|
|
47
|
-
"@protobase/schema": "0.62.
|
|
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",
|