nixamp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +171 -0
  2. package/dist/accounts.d.ts +54 -0
  3. package/dist/accounts.js +160 -0
  4. package/dist/broadcast.d.ts +96 -0
  5. package/dist/broadcast.js +193 -0
  6. package/dist/channels.d.ts +94 -0
  7. package/dist/channels.js +235 -0
  8. package/dist/connections.d.ts +6 -0
  9. package/dist/connections.js +13 -0
  10. package/dist/directory.d.ts +186 -0
  11. package/dist/directory.js +275 -0
  12. package/dist/durable.d.ts +70 -0
  13. package/dist/durable.js +156 -0
  14. package/dist/follows.d.ts +92 -0
  15. package/dist/follows.js +248 -0
  16. package/dist/ingest.d.ts +80 -0
  17. package/dist/ingest.js +252 -0
  18. package/dist/main.js +21 -0
  19. package/dist/manage.js +2 -1
  20. package/dist/notify.d.ts +83 -0
  21. package/dist/notify.js +126 -0
  22. package/dist/optin.d.ts +37 -0
  23. package/dist/optin.js +122 -0
  24. package/dist/owner.d.ts +53 -0
  25. package/dist/owner.js +96 -0
  26. package/dist/partyline.d.ts +259 -0
  27. package/dist/partyline.js +616 -0
  28. package/dist/paywall.d.ts +60 -0
  29. package/dist/paywall.js +162 -0
  30. package/dist/playlist.js +5 -0
  31. package/dist/publish.d.ts +57 -0
  32. package/dist/publish.js +106 -0
  33. package/dist/rtmp-in.d.ts +22 -0
  34. package/dist/rtmp-in.js +79 -0
  35. package/dist/server.d.ts +94 -0
  36. package/dist/server.js +1158 -12
  37. package/dist/session.d.ts +29 -0
  38. package/dist/session.js +184 -0
  39. package/dist/share.d.ts +26 -0
  40. package/dist/share.js +31 -0
  41. package/package.json +8 -2
  42. package/src/accounts.ts +193 -0
  43. package/src/broadcast.ts +264 -0
  44. package/src/channels.ts +281 -0
  45. package/src/connections.ts +13 -0
  46. package/src/directory.ts +362 -0
  47. package/src/durable.ts +215 -0
  48. package/src/follows.ts +307 -0
  49. package/src/ingest.ts +297 -0
  50. package/src/main.ts +21 -0
  51. package/src/manage.ts +2 -1
  52. package/src/notify.ts +217 -0
  53. package/src/optin.ts +128 -0
  54. package/src/owner.ts +113 -0
  55. package/src/partyline.ts +742 -0
  56. package/src/paywall.ts +198 -0
  57. package/src/playlist.ts +5 -0
  58. package/src/publish.ts +137 -0
  59. package/src/rtmp-in.ts +90 -0
  60. package/src/server.ts +1304 -12
  61. package/src/session.ts +209 -0
  62. package/src/share.ts +40 -0
  63. package/src/types/auth-system.d.ts +77 -0
  64. package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
  65. package/web/dist/assets/index-qRguFskX.js +1 -0
  66. package/web/dist/index.html +62 -6
  67. package/web/dist/install.sh +82 -0
  68. package/web/dist/sw.js +45 -3
  69. package/web/dist/assets/index-Dhja5wxB.js +0 -1
@@ -0,0 +1,362 @@
1
+ import { randomInt } from "node:crypto";
2
+
3
+ /**
4
+ * The public directory.
5
+ *
6
+ * A nixamp that agrees to be listed announces itself to nixamp.com every so
7
+ * often and is forgotten when it stops. There is no database behind it: an
8
+ * entry lives for a few minutes and a heartbeat renews it, so a restart of the
9
+ * directory costs one heartbeat rather than a migration, and a stream that
10
+ * dies falls out of the list without anyone having to notice.
11
+ *
12
+ * What is published is the *listen* link. The control key never leaves the
13
+ * machine it was minted on.
14
+ */
15
+
16
+ /** How long an entry survives without a heartbeat. */
17
+ export const TTL_MS = 4 * 60 * 1000;
18
+ /** How often a publisher renews. Comfortably inside the TTL. */
19
+ export const HEARTBEAT_MS = 90 * 1000;
20
+ export const DEFAULT_DIRECTORY = "https://nixamp.com";
21
+
22
+ export interface Listing {
23
+ /** Assigned by the directory, so a publisher cannot claim someone else's. */
24
+ id: string;
25
+ /**
26
+ * A six-digit code for this stream, stable across the whole run.
27
+ *
28
+ * This is what somebody keys into the phone line. It has to be short enough
29
+ * to read out and survive being remembered, which the id is not.
30
+ */
31
+ code: string;
32
+ name: string;
33
+ /**
34
+ * The account that announced it.
35
+ *
36
+ * Set from the signed-in publisher, never from the announcement body -- a
37
+ * stream that could name its own owner could name somebody else's, and
38
+ * followers would be told about a broadcast that person is not making.
39
+ */
40
+ ownerId: string;
41
+ /** The listen link, which is what a browser opens. */
42
+ url: string;
43
+ /**
44
+ * The same stream as bytes, for something that is not a browser.
45
+ *
46
+ * `url` is a share link: it answers 302, sets a cookie and redirects to the
47
+ * player page. That is exactly right for a person and useless to anything
48
+ * that cannot hold a cookie -- the phone line hands this address to Telnyx
49
+ * to play into a call, and Telnyx fetches it once, anonymously, and expects
50
+ * audio back. Handed the share link it gets a 401 in JSON and the caller
51
+ * hears silence after being told the stream is about to start.
52
+ *
53
+ * So a publisher announces both: the link a person opens, and the address
54
+ * that answers with audio/mpeg to a plain GET. Empty when the publisher is
55
+ * an older nixamp that only knows about `url`.
56
+ */
57
+ audio: string;
58
+ tracks: number;
59
+ nowPlaying: string;
60
+ /** Set by the directory from the request, never by the publisher. */
61
+ updatedAt: number;
62
+ /** When this stream first announced itself: the "started at" a caller hears. */
63
+ startedAt: number;
64
+ }
65
+
66
+ /**
67
+ * A stream that has stopped, kept for a while after it fell out of the list.
68
+ *
69
+ * The directory proper forgets a stream the moment it stops renewing, which is
70
+ * right for a list of what is on -- but it means there is nobody left to say
71
+ * *when* it ended, and "call back later" with no time in it is not worth
72
+ * saying. So an ended stream leaves this behind: enough to answer the phone
73
+ * truthfully, and nothing anybody could listen to.
74
+ */
75
+ export interface Ended {
76
+ id: string;
77
+ code: string;
78
+ name: string;
79
+ ownerId: string;
80
+ /** Kept so a stream returning on the same url is recognised as the same one. */
81
+ url: string;
82
+ nowPlaying: string;
83
+ startedAt: number;
84
+ /** The last heartbeat we saw, which is as close to "ended" as we can know. */
85
+ endedAt: number;
86
+ }
87
+
88
+ /** How long an ended stream is still worth telling a caller about. */
89
+ export const ENDED_TTL_MS = 24 * 60 * 60 * 1000;
90
+
91
+ /** What a publisher sends. Everything else about a listing is ours to decide. */
92
+ export interface Announcement {
93
+ id?: string;
94
+ name: string;
95
+ url: string;
96
+ /** Where the audio actually is. See `Listing.audio`. */
97
+ audio?: string;
98
+ tracks: number;
99
+ nowPlaying: string;
100
+ }
101
+
102
+ const MAX_NAME = 60;
103
+ const MAX_TRACK = 120;
104
+
105
+ /** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
106
+ export function clean(value: unknown, max: number): string {
107
+ if (typeof value !== "string") return "";
108
+ // Control characters include the escape that starts an ANSI sequence, and
109
+ // this text is rendered in a terminal as well as a browser.
110
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
111
+ }
112
+
113
+ /**
114
+ * A URL we are willing to list. It has to be somewhere a browser can go, and
115
+ * it must not be a loopback or link-local address: those are only reachable
116
+ * from the machine that published them, so listing one is an entry nobody but
117
+ * the publisher can ever open.
118
+ */
119
+ export function publishable(raw: string): URL | null {
120
+ let url: URL;
121
+ try {
122
+ url = new URL(raw);
123
+ } catch {
124
+ return null;
125
+ }
126
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
127
+
128
+ const host = url.hostname.replace(/^\[|\]$/g, "");
129
+ if (host === "localhost" || host === "::1" || host.endsWith(".localhost")) return null;
130
+ if (/^127\./.test(host) || /^169\.254\./.test(host)) return null;
131
+ return url;
132
+ }
133
+
134
+ export function parseAnnouncement(input: unknown): Announcement | null {
135
+ if (typeof input !== "object" || input === null) return null;
136
+ const record = input as Record<string, unknown>;
137
+
138
+ const url = typeof record["url"] === "string" ? record["url"] : "";
139
+ const listen = publishable(url);
140
+ if (listen === null) return null;
141
+
142
+ // The audio address has to be the same server as the listen link. This one
143
+ // is played into a telephone call that somebody pays for by the minute, and
144
+ // an announcement that could name any address on the internet could point
145
+ // the phone line at any of them. Same origin, or we do not take it.
146
+ const offered = typeof record["audio"] === "string" ? record["audio"] : "";
147
+ const parsed = offered ? publishable(offered) : null;
148
+ const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
149
+
150
+ const name = clean(record["name"], MAX_NAME);
151
+ const tracks = Number(record["tracks"]);
152
+ return {
153
+ ...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
154
+ name: name || "a nixamp",
155
+ url,
156
+ ...(audio ? { audio } : {}),
157
+ tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
158
+ nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
159
+ };
160
+ }
161
+
162
+ /**
163
+ * The registry. In memory on purpose: see the note at the top of the file.
164
+ * One entry per URL, so a publisher restarting does not leave a ghost of
165
+ * itself behind next to the entry that replaced it.
166
+ */
167
+ export class Directory {
168
+ private readonly items = new Map<string, Listing>();
169
+ /** Streams that stopped, so the phone line can say when. */
170
+ private readonly ended = new Map<string, Ended>();
171
+ private sequence = 0;
172
+ /**
173
+ * Somewhere to echo the ended list, so it survives a restart.
174
+ *
175
+ * Attached after construction rather than taken as a constructor argument:
176
+ * this is a mirror, not a dependency, and the directory works exactly as it
177
+ * did without one.
178
+ */
179
+ private mirror: { save: (item: Ended) => void; drop: (id: string) => void } | null = null;
180
+
181
+ /** Start echoing ended streams somewhere durable. */
182
+ persistTo(mirror: { save: (item: Ended) => void; drop: (id: string) => void }): void {
183
+ this.mirror = mirror;
184
+ }
185
+
186
+ /**
187
+ * Put back what a previous process knew.
188
+ *
189
+ * Only fills gaps: anything already here was announced since we started and
190
+ * is newer than a row written before the restart.
191
+ */
192
+ seedEnded(items: readonly Ended[]): void {
193
+ for (const item of items) {
194
+ if (!this.ended.has(item.id) && !this.items.has(item.id)) this.ended.set(item.id, item);
195
+ }
196
+ }
197
+
198
+ constructor(
199
+ private readonly ttl = TTL_MS,
200
+ private readonly now: () => number = Date.now,
201
+ /** Injected so a test can make a code predictable rather than guess it. */
202
+ private readonly randomCode: () => string = () =>
203
+ String(randomInt(0, 1_000_000)).padStart(6, "0"),
204
+ /**
205
+ * Called when a stream starts, and only then.
206
+ *
207
+ * A publisher announces every ninety seconds for as long as it is up, so
208
+ * "announced" is not "went live" -- telling followers on every heartbeat
209
+ * would be telling them forty times an hour. This fires on the transition
210
+ * and not on the renewals that follow it.
211
+ */
212
+ private readonly onLive: (listing: Listing) => void = () => {},
213
+ ) {}
214
+
215
+ announce(announcement: Announcement, ownerId = ""): Listing {
216
+ this.sweep();
217
+ const existing = [...this.items.values()].find((item) => item.url === announcement.url);
218
+
219
+ // A stream coming back after a gap keeps the code it had, so a caller who
220
+ // was told "call back later" can key the same six digits and get through.
221
+ const previously = existing ?? this.endedByUrl(announcement.url);
222
+ const id = previously?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
223
+ const code = previously?.code ?? this.freeCode();
224
+ if (this.ended.has(id)) {
225
+ this.ended.delete(id);
226
+ this.mirror?.drop(id);
227
+ }
228
+
229
+ const listing: Listing = {
230
+ id,
231
+ code,
232
+ name: announcement.name,
233
+ // A returning stream keeps the owner it had, so a heartbeat that omits
234
+ // it cannot orphan a listing people are following.
235
+ ownerId: ownerId || existing?.ownerId || previously?.ownerId || "",
236
+ url: announcement.url,
237
+ // A heartbeat that omits it keeps what we had, the same as the owner: an
238
+ // older publisher renewing an entry should not blank the address the
239
+ // phone line is playing from.
240
+ audio: announcement.audio ?? existing?.audio ?? "",
241
+ tracks: announcement.tracks,
242
+ nowPlaying: announcement.nowPlaying,
243
+ updatedAt: this.now(),
244
+ // A stream that never stopped keeps its original start. One that did
245
+ // starts again now, because that is what a caller is being told about.
246
+ startedAt: existing?.startedAt ?? this.now(),
247
+ };
248
+ this.items.set(id, listing);
249
+ // The transition, not the heartbeat: existing means it was already live.
250
+ if (existing === undefined) this.onLive(listing);
251
+ return listing;
252
+ }
253
+
254
+ withdraw(id: string): void {
255
+ const item = this.items.get(id);
256
+ if (item !== undefined) this.remember(item);
257
+ this.items.delete(id);
258
+ }
259
+
260
+ list(): Listing[] {
261
+ this.sweep();
262
+ return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
263
+ }
264
+
265
+ /** The live stream on this code, if there is one. */
266
+ liveByCode(code: string): Listing | undefined {
267
+ this.sweep();
268
+ return [...this.items.values()].find((item) => item.code === code);
269
+ }
270
+
271
+ /**
272
+ * Streams that stopped recently, most recent first.
273
+ *
274
+ * Kept for the phone line, which has to say when a stream ended -- but they
275
+ * answer a second question the live list cannot: who is there to follow.
276
+ * Following exists to hear about broadcasts you would otherwise miss, and a
277
+ * directory that only lists what is on can only be used to follow somebody
278
+ * during a broadcast you did not miss.
279
+ */
280
+ recentlyEnded(): Ended[] {
281
+ this.sweep();
282
+ const live = new Set([...this.items.values()].map((item) => item.id));
283
+ return [...this.ended.values()]
284
+ .filter((item) => !live.has(item.id))
285
+ .sort((a, b) => b.endedAt - a.endedAt);
286
+ }
287
+
288
+ /** The name last used by an account, live or recently ended. */
289
+ nameOf(ownerId: string): string {
290
+ if (!ownerId) return "";
291
+ this.sweep();
292
+ const live = [...this.items.values()].find((item) => item.ownerId === ownerId);
293
+ if (live) return live.name;
294
+ const ended = [...this.ended.values()]
295
+ .filter((item) => item.ownerId === ownerId)
296
+ .sort((a, b) => b.endedAt - a.endedAt)[0];
297
+ return ended?.name ?? "";
298
+ }
299
+
300
+ /** Whether this account is streaming right now. */
301
+ isLive(ownerId: string): boolean {
302
+ if (!ownerId) return false;
303
+ this.sweep();
304
+ return [...this.items.values()].some((item) => item.ownerId === ownerId);
305
+ }
306
+
307
+ /** The stream that used to be on this code, if it stopped recently. */
308
+ endedByCode(code: string): Ended | undefined {
309
+ this.sweep();
310
+ return [...this.ended.values()].find((item) => item.code === code);
311
+ }
312
+
313
+ private endedByUrl(url: string): Ended | undefined {
314
+ return [...this.ended.values()].find((item) => item.url === url);
315
+ }
316
+
317
+ private remember(item: Listing): void {
318
+ const record: Ended = {
319
+ id: item.id,
320
+ code: item.code,
321
+ name: item.name,
322
+ ownerId: item.ownerId,
323
+ url: item.url,
324
+ nowPlaying: item.nowPlaying,
325
+ startedAt: item.startedAt,
326
+ endedAt: item.updatedAt,
327
+ };
328
+ this.ended.set(item.id, record);
329
+ this.mirror?.save(record);
330
+ }
331
+
332
+ /** A code no live and no recently-ended stream is using. */
333
+ private freeCode(): string {
334
+ for (let tries = 0; tries < 40; tries += 1) {
335
+ const code = this.randomCode();
336
+ if (code.length !== 6) continue;
337
+ const taken =
338
+ [...this.items.values()].some((i) => i.code === code) ||
339
+ [...this.ended.values()].some((i) => i.code === code);
340
+ if (!taken) return code;
341
+ }
342
+ return "";
343
+ }
344
+
345
+ /** Forget anything that stopped renewing, keeping a note of when it did. */
346
+ private sweep(): void {
347
+ const cutoff = this.now() - this.ttl;
348
+ for (const [id, item] of this.items) {
349
+ if (item.updatedAt < cutoff) {
350
+ this.remember(item);
351
+ this.items.delete(id);
352
+ }
353
+ }
354
+ const forget = this.now() - ENDED_TTL_MS;
355
+ for (const [id, item] of this.ended) {
356
+ if (item.endedAt < forget) {
357
+ this.ended.delete(id);
358
+ this.mirror?.drop(id);
359
+ }
360
+ }
361
+ }
362
+ }
package/src/durable.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The two pieces of state that were promises, and were only in memory.
3
+ *
4
+ * Most of nixamp is deliberately ephemeral. The directory is a four-minute TTL
5
+ * and a heartbeat, because a stream that stops is a stream nobody is hearing,
6
+ * and a restart costs one heartbeat rather than a migration. That reasoning is
7
+ * right for what is on. It is wrong for two things that outlived their stream
8
+ * on purpose:
9
+ *
10
+ * A caller who pressed 1 was told "we will text you when they are live
11
+ * again". That subscription lived in a Map, so a deploy dropped it and the
12
+ * text never came -- and nothing anywhere said so. A promise made on a phone
13
+ * call and quietly forgotten is worse than never offering it.
14
+ *
15
+ * A stream that ended is what the phone line reads back ("ended at 9:27 PM
16
+ * Pacific") and what the directory offers to follow when nobody is on. After
17
+ * a deploy the code a caller had been told to key would find nothing and
18
+ * open an empty room instead.
19
+ *
20
+ * This is a mirror rather than a replacement. The in-memory maps stay exactly
21
+ * as they were -- so every caller stays synchronous and every existing test
22
+ * still describes the same object -- and each write is echoed here, with the
23
+ * contents read back once at boot. The cost of that choice is that two
24
+ * instances would each hold their own copy; nixamp.com runs one, and a second
25
+ * would need this to become the source of truth rather than the mirror.
26
+ */
27
+ import type { Queryable } from "./follows.ts";
28
+
29
+ export interface StoredEnded {
30
+ id: string;
31
+ code: string;
32
+ name: string;
33
+ ownerId: string;
34
+ url: string;
35
+ nowPlaying: string;
36
+ startedAt: number;
37
+ endedAt: number;
38
+ }
39
+
40
+ const SCHEMA = `
41
+ CREATE TABLE IF NOT EXISTS ended_streams (
42
+ id TEXT PRIMARY KEY,
43
+ code TEXT NOT NULL,
44
+ name TEXT NOT NULL DEFAULT '',
45
+ owner_id TEXT NOT NULL DEFAULT '',
46
+ url TEXT NOT NULL DEFAULT '',
47
+ now_playing TEXT NOT NULL DEFAULT '',
48
+ started_at BIGINT NOT NULL,
49
+ ended_at BIGINT NOT NULL
50
+ );
51
+ CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
52
+
53
+ CREATE TABLE IF NOT EXISTS stream_reminders (
54
+ code TEXT NOT NULL,
55
+ phone TEXT NOT NULL,
56
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
57
+ PRIMARY KEY (code, phone)
58
+ );
59
+ `;
60
+
61
+ export class Durable {
62
+ private ready: Promise<void> | null = null;
63
+
64
+ constructor(
65
+ private readonly db: Queryable,
66
+ private readonly onEvent: (message: string) => void = () => {},
67
+ ) {}
68
+
69
+ private async ensure(): Promise<void> {
70
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
71
+ await this.ready;
72
+ }
73
+
74
+ /**
75
+ * Nothing here is worth taking a request down for.
76
+ *
77
+ * These are all mirror writes: the in-memory copy is what the request is
78
+ * answered from, so a database that is briefly unreachable should cost the
79
+ * durability and not the feature.
80
+ */
81
+ private async quietly(what: string, run: () => Promise<unknown>): Promise<void> {
82
+ try {
83
+ await this.ensure();
84
+ await run();
85
+ } catch (error) {
86
+ this.onEvent(` ${what} did not persist: ${(error as Error).message}`);
87
+ }
88
+ }
89
+
90
+ async saveEnded(stream: StoredEnded): Promise<void> {
91
+ await this.quietly("an ended stream", () =>
92
+ this.db.query(
93
+ `INSERT INTO ended_streams
94
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at)
95
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
96
+ ON CONFLICT (id) DO UPDATE
97
+ SET code = EXCLUDED.code,
98
+ name = EXCLUDED.name,
99
+ owner_id = EXCLUDED.owner_id,
100
+ url = EXCLUDED.url,
101
+ now_playing = EXCLUDED.now_playing,
102
+ started_at = EXCLUDED.started_at,
103
+ ended_at = EXCLUDED.ended_at`,
104
+ [
105
+ stream.id,
106
+ stream.code,
107
+ stream.name,
108
+ stream.ownerId,
109
+ stream.url,
110
+ stream.nowPlaying,
111
+ stream.startedAt,
112
+ stream.endedAt,
113
+ ],
114
+ ),
115
+ );
116
+ }
117
+
118
+ /** A stream that came back, or one old enough to forget. */
119
+ async dropEnded(id: string): Promise<void> {
120
+ await this.quietly("dropping an ended stream", () =>
121
+ this.db.query("DELETE FROM ended_streams WHERE id = $1", [id]),
122
+ );
123
+ }
124
+
125
+ /** What ended since `since`, oldest first so replaying it rebuilds the order. */
126
+ async loadEnded(since: number): Promise<StoredEnded[]> {
127
+ try {
128
+ await this.ensure();
129
+ const { rows } = await this.db.query(
130
+ "SELECT * FROM ended_streams WHERE ended_at >= $1 ORDER BY ended_at",
131
+ [since],
132
+ );
133
+ return rows.map((r) => ({
134
+ id: String(r["id"] ?? ""),
135
+ code: String(r["code"] ?? ""),
136
+ name: String(r["name"] ?? ""),
137
+ ownerId: String(r["owner_id"] ?? ""),
138
+ url: String(r["url"] ?? ""),
139
+ nowPlaying: String(r["now_playing"] ?? ""),
140
+ // BIGINT comes back as a string from pg, which sorts and compares
141
+ // nothing like a number.
142
+ startedAt: Number(r["started_at"] ?? 0),
143
+ endedAt: Number(r["ended_at"] ?? 0),
144
+ }));
145
+ } catch (error) {
146
+ this.onEvent(` could not read ended streams: ${(error as Error).message}`);
147
+ return [];
148
+ }
149
+ }
150
+
151
+ async addReminder(code: string, phone: string): Promise<void> {
152
+ if (!code || !phone) return;
153
+ await this.quietly("a reminder", () =>
154
+ this.db.query(
155
+ `INSERT INTO stream_reminders (code, phone) VALUES ($1, $2)
156
+ ON CONFLICT DO NOTHING`,
157
+ [code, phone],
158
+ ),
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Take everyone waiting on a code, and stop them waiting, in one statement.
164
+ *
165
+ * `DELETE ... RETURNING` rather than a select and then a delete: the rows
166
+ * come back as they are removed, so two goings-live at once cannot both read
167
+ * the same list and text everybody twice.
168
+ */
169
+ async takeReminders(code: string): Promise<string[]> {
170
+ if (!code) return [];
171
+ try {
172
+ await this.ensure();
173
+ const { rows } = await this.db.query(
174
+ "DELETE FROM stream_reminders WHERE code = $1 RETURNING phone",
175
+ [code],
176
+ );
177
+ return rows.map((r) => String(r["phone"] ?? "")).filter(Boolean);
178
+ } catch (error) {
179
+ this.onEvent(` could not take reminders: ${(error as Error).message}`);
180
+ return [];
181
+ }
182
+ }
183
+
184
+ /** Everyone waiting, by code, to seed a process that has just started. */
185
+ async loadReminders(): Promise<Map<string, Set<string>>> {
186
+ const waiting = new Map<string, Set<string>>();
187
+ try {
188
+ await this.ensure();
189
+ const { rows } = await this.db.query("SELECT code, phone FROM stream_reminders", []);
190
+ for (const row of rows) {
191
+ const code = String(row["code"] ?? "");
192
+ const phone = String(row["phone"] ?? "");
193
+ if (!code || !phone) continue;
194
+ const set = waiting.get(code) ?? new Set<string>();
195
+ set.add(phone);
196
+ waiting.set(code, set);
197
+ }
198
+ } catch (error) {
199
+ this.onEvent(` could not read reminders: ${(error as Error).message}`);
200
+ }
201
+ return waiting;
202
+ }
203
+
204
+ /** Forget what is too old to be worth telling anybody about. */
205
+ async sweep(endedBefore: number, remindersBefore: Date): Promise<void> {
206
+ await this.quietly("sweeping", async () => {
207
+ await this.db.query("DELETE FROM ended_streams WHERE ended_at < $1", [endedBefore]);
208
+ // A reminder nobody has collected in a month is somebody who has long
209
+ // since stopped expecting a text.
210
+ await this.db.query("DELETE FROM stream_reminders WHERE created_at < $1", [
211
+ remindersBefore.toISOString(),
212
+ ]);
213
+ });
214
+ }
215
+ }