nixamp 0.3.0 → 0.4.1

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.
@@ -18,19 +18,76 @@ export declare const DEFAULT_DIRECTORY = "https://nixamp.com";
18
18
  export interface Listing {
19
19
  /** Assigned by the directory, so a publisher cannot claim someone else's. */
20
20
  id: string;
21
+ /**
22
+ * A six-digit code for this stream, stable across the whole run.
23
+ *
24
+ * This is what somebody keys into the phone line. It has to be short enough
25
+ * to read out and survive being remembered, which the id is not.
26
+ */
27
+ code: string;
21
28
  name: string;
29
+ /**
30
+ * The account that announced it.
31
+ *
32
+ * Set from the signed-in publisher, never from the announcement body -- a
33
+ * stream that could name its own owner could name somebody else's, and
34
+ * followers would be told about a broadcast that person is not making.
35
+ */
36
+ ownerId: string;
22
37
  /** The listen link, which is what a browser opens. */
23
38
  url: string;
39
+ /**
40
+ * The same stream as bytes, for something that is not a browser.
41
+ *
42
+ * `url` is a share link: it answers 302, sets a cookie and redirects to the
43
+ * player page. That is exactly right for a person and useless to anything
44
+ * that cannot hold a cookie -- the phone line hands this address to Telnyx
45
+ * to play into a call, and Telnyx fetches it once, anonymously, and expects
46
+ * audio back. Handed the share link it gets a 401 in JSON and the caller
47
+ * hears silence after being told the stream is about to start.
48
+ *
49
+ * So a publisher announces both: the link a person opens, and the address
50
+ * that answers with audio/mpeg to a plain GET. Empty when the publisher is
51
+ * an older nixamp that only knows about `url`.
52
+ */
53
+ audio: string;
24
54
  tracks: number;
25
55
  nowPlaying: string;
26
56
  /** Set by the directory from the request, never by the publisher. */
27
57
  updatedAt: number;
58
+ /** When this stream first announced itself: the "started at" a caller hears. */
59
+ startedAt: number;
28
60
  }
61
+ /**
62
+ * A stream that has stopped, kept for a while after it fell out of the list.
63
+ *
64
+ * The directory proper forgets a stream the moment it stops renewing, which is
65
+ * right for a list of what is on -- but it means there is nobody left to say
66
+ * *when* it ended, and "call back later" with no time in it is not worth
67
+ * saying. So an ended stream leaves this behind: enough to answer the phone
68
+ * truthfully, and nothing anybody could listen to.
69
+ */
70
+ export interface Ended {
71
+ id: string;
72
+ code: string;
73
+ name: string;
74
+ ownerId: string;
75
+ /** Kept so a stream returning on the same url is recognised as the same one. */
76
+ url: string;
77
+ nowPlaying: string;
78
+ startedAt: number;
79
+ /** The last heartbeat we saw, which is as close to "ended" as we can know. */
80
+ endedAt: number;
81
+ }
82
+ /** How long an ended stream is still worth telling a caller about. */
83
+ export declare const ENDED_TTL_MS: number;
29
84
  /** What a publisher sends. Everything else about a listing is ours to decide. */
30
85
  export interface Announcement {
31
86
  id?: string;
32
87
  name: string;
33
88
  url: string;
89
+ /** Where the audio actually is. See `Listing.audio`. */
90
+ audio?: string;
34
91
  tracks: number;
35
92
  nowPlaying: string;
36
93
  }
@@ -52,12 +109,78 @@ export declare function parseAnnouncement(input: unknown): Announcement | null;
52
109
  export declare class Directory {
53
110
  private readonly ttl;
54
111
  private readonly now;
112
+ /** Injected so a test can make a code predictable rather than guess it. */
113
+ private readonly randomCode;
114
+ /**
115
+ * Called when a stream starts, and only then.
116
+ *
117
+ * A publisher announces every ninety seconds for as long as it is up, so
118
+ * "announced" is not "went live" -- telling followers on every heartbeat
119
+ * would be telling them forty times an hour. This fires on the transition
120
+ * and not on the renewals that follow it.
121
+ */
122
+ private readonly onLive;
55
123
  private readonly items;
124
+ /** Streams that stopped, so the phone line can say when. */
125
+ private readonly ended;
56
126
  private sequence;
57
- constructor(ttl?: number, now?: () => number);
58
- announce(announcement: Announcement): Listing;
127
+ /**
128
+ * Somewhere to echo the ended list, so it survives a restart.
129
+ *
130
+ * Attached after construction rather than taken as a constructor argument:
131
+ * this is a mirror, not a dependency, and the directory works exactly as it
132
+ * did without one.
133
+ */
134
+ private mirror;
135
+ /** Start echoing ended streams somewhere durable. */
136
+ persistTo(mirror: {
137
+ save: (item: Ended) => void;
138
+ drop: (id: string) => void;
139
+ }): void;
140
+ /**
141
+ * Put back what a previous process knew.
142
+ *
143
+ * Only fills gaps: anything already here was announced since we started and
144
+ * is newer than a row written before the restart.
145
+ */
146
+ seedEnded(items: readonly Ended[]): void;
147
+ constructor(ttl?: number, now?: () => number,
148
+ /** Injected so a test can make a code predictable rather than guess it. */
149
+ randomCode?: () => string,
150
+ /**
151
+ * Called when a stream starts, and only then.
152
+ *
153
+ * A publisher announces every ninety seconds for as long as it is up, so
154
+ * "announced" is not "went live" -- telling followers on every heartbeat
155
+ * would be telling them forty times an hour. This fires on the transition
156
+ * and not on the renewals that follow it.
157
+ */
158
+ onLive?: (listing: Listing) => void);
159
+ announce(announcement: Announcement, ownerId?: string): Listing;
59
160
  withdraw(id: string): void;
60
161
  list(): Listing[];
61
- /** Forget anything that stopped renewing. */
162
+ /** The live stream on this code, if there is one. */
163
+ liveByCode(code: string): Listing | undefined;
164
+ /**
165
+ * Streams that stopped recently, most recent first.
166
+ *
167
+ * Kept for the phone line, which has to say when a stream ended -- but they
168
+ * answer a second question the live list cannot: who is there to follow.
169
+ * Following exists to hear about broadcasts you would otherwise miss, and a
170
+ * directory that only lists what is on can only be used to follow somebody
171
+ * during a broadcast you did not miss.
172
+ */
173
+ recentlyEnded(): Ended[];
174
+ /** The name last used by an account, live or recently ended. */
175
+ nameOf(ownerId: string): string;
176
+ /** Whether this account is streaming right now. */
177
+ isLive(ownerId: string): boolean;
178
+ /** The stream that used to be on this code, if it stopped recently. */
179
+ endedByCode(code: string): Ended | undefined;
180
+ private endedByUrl;
181
+ private remember;
182
+ /** A code no live and no recently-ended stream is using. */
183
+ private freeCode;
184
+ /** Forget anything that stopped renewing, keeping a note of when it did. */
62
185
  private sweep;
63
186
  }
package/dist/directory.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomInt } from "node:crypto";
1
2
  /**
2
3
  * The public directory.
3
4
  *
@@ -15,6 +16,8 @@ export const TTL_MS = 4 * 60 * 1000;
15
16
  /** How often a publisher renews. Comfortably inside the TTL. */
16
17
  export const HEARTBEAT_MS = 90 * 1000;
17
18
  export const DEFAULT_DIRECTORY = "https://nixamp.com";
19
+ /** How long an ended stream is still worth telling a caller about. */
20
+ export const ENDED_TTL_MS = 24 * 60 * 60 * 1000;
18
21
  const MAX_NAME = 60;
19
22
  const MAX_TRACK = 120;
20
23
  /** Trim and flatten, so one publisher cannot draw a box in someone's terminal. */
@@ -53,14 +56,23 @@ export function parseAnnouncement(input) {
53
56
  return null;
54
57
  const record = input;
55
58
  const url = typeof record["url"] === "string" ? record["url"] : "";
56
- if (publishable(url) === null)
59
+ const listen = publishable(url);
60
+ if (listen === null)
57
61
  return null;
62
+ // The audio address has to be the same server as the listen link. This one
63
+ // is played into a telephone call that somebody pays for by the minute, and
64
+ // an announcement that could name any address on the internet could point
65
+ // the phone line at any of them. Same origin, or we do not take it.
66
+ const offered = typeof record["audio"] === "string" ? record["audio"] : "";
67
+ const parsed = offered ? publishable(offered) : null;
68
+ const audio = parsed !== null && parsed.origin === listen.origin ? offered : "";
58
69
  const name = clean(record["name"], MAX_NAME);
59
70
  const tracks = Number(record["tracks"]);
60
71
  return {
61
72
  ...(typeof record["id"] === "string" ? { id: clean(record["id"], 40) } : {}),
62
73
  name: name || "a nixamp",
63
74
  url,
75
+ ...(audio ? { audio } : {}),
64
76
  tracks: Number.isFinite(tracks) && tracks >= 0 ? Math.min(1_000_000, Math.floor(tracks)) : 0,
65
77
  nowPlaying: clean(record["nowPlaying"], MAX_TRACK),
66
78
  };
@@ -73,39 +85,191 @@ export function parseAnnouncement(input) {
73
85
  export class Directory {
74
86
  ttl;
75
87
  now;
88
+ randomCode;
89
+ onLive;
76
90
  items = new Map();
91
+ /** Streams that stopped, so the phone line can say when. */
92
+ ended = new Map();
77
93
  sequence = 0;
78
- constructor(ttl = TTL_MS, now = Date.now) {
94
+ /**
95
+ * Somewhere to echo the ended list, so it survives a restart.
96
+ *
97
+ * Attached after construction rather than taken as a constructor argument:
98
+ * this is a mirror, not a dependency, and the directory works exactly as it
99
+ * did without one.
100
+ */
101
+ mirror = null;
102
+ /** Start echoing ended streams somewhere durable. */
103
+ persistTo(mirror) {
104
+ this.mirror = mirror;
105
+ }
106
+ /**
107
+ * Put back what a previous process knew.
108
+ *
109
+ * Only fills gaps: anything already here was announced since we started and
110
+ * is newer than a row written before the restart.
111
+ */
112
+ seedEnded(items) {
113
+ for (const item of items) {
114
+ if (!this.ended.has(item.id) && !this.items.has(item.id))
115
+ this.ended.set(item.id, item);
116
+ }
117
+ }
118
+ constructor(ttl = TTL_MS, now = Date.now,
119
+ /** Injected so a test can make a code predictable rather than guess it. */
120
+ randomCode = () => String(randomInt(0, 1_000_000)).padStart(6, "0"),
121
+ /**
122
+ * Called when a stream starts, and only then.
123
+ *
124
+ * A publisher announces every ninety seconds for as long as it is up, so
125
+ * "announced" is not "went live" -- telling followers on every heartbeat
126
+ * would be telling them forty times an hour. This fires on the transition
127
+ * and not on the renewals that follow it.
128
+ */
129
+ onLive = () => { }) {
79
130
  this.ttl = ttl;
80
131
  this.now = now;
132
+ this.randomCode = randomCode;
133
+ this.onLive = onLive;
81
134
  }
82
- announce(announcement) {
135
+ announce(announcement, ownerId = "") {
83
136
  this.sweep();
84
137
  const existing = [...this.items.values()].find((item) => item.url === announcement.url);
85
- const id = existing?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
138
+ // A stream coming back after a gap keeps the code it had, so a caller who
139
+ // was told "call back later" can key the same six digits and get through.
140
+ const previously = existing ?? this.endedByUrl(announcement.url);
141
+ const id = previously?.id ?? `s${++this.sequence}${this.now().toString(36)}`;
142
+ const code = previously?.code ?? this.freeCode();
143
+ if (this.ended.has(id)) {
144
+ this.ended.delete(id);
145
+ this.mirror?.drop(id);
146
+ }
86
147
  const listing = {
87
148
  id,
149
+ code,
88
150
  name: announcement.name,
151
+ // A returning stream keeps the owner it had, so a heartbeat that omits
152
+ // it cannot orphan a listing people are following.
153
+ ownerId: ownerId || existing?.ownerId || previously?.ownerId || "",
89
154
  url: announcement.url,
155
+ // A heartbeat that omits it keeps what we had, the same as the owner: an
156
+ // older publisher renewing an entry should not blank the address the
157
+ // phone line is playing from.
158
+ audio: announcement.audio ?? existing?.audio ?? "",
90
159
  tracks: announcement.tracks,
91
160
  nowPlaying: announcement.nowPlaying,
92
161
  updatedAt: this.now(),
162
+ // A stream that never stopped keeps its original start. One that did
163
+ // starts again now, because that is what a caller is being told about.
164
+ startedAt: existing?.startedAt ?? this.now(),
93
165
  };
94
166
  this.items.set(id, listing);
167
+ // The transition, not the heartbeat: existing means it was already live.
168
+ if (existing === undefined)
169
+ this.onLive(listing);
95
170
  return listing;
96
171
  }
97
172
  withdraw(id) {
173
+ const item = this.items.get(id);
174
+ if (item !== undefined)
175
+ this.remember(item);
98
176
  this.items.delete(id);
99
177
  }
100
178
  list() {
101
179
  this.sweep();
102
180
  return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
103
181
  }
104
- /** Forget anything that stopped renewing. */
182
+ /** The live stream on this code, if there is one. */
183
+ liveByCode(code) {
184
+ this.sweep();
185
+ return [...this.items.values()].find((item) => item.code === code);
186
+ }
187
+ /**
188
+ * Streams that stopped recently, most recent first.
189
+ *
190
+ * Kept for the phone line, which has to say when a stream ended -- but they
191
+ * answer a second question the live list cannot: who is there to follow.
192
+ * Following exists to hear about broadcasts you would otherwise miss, and a
193
+ * directory that only lists what is on can only be used to follow somebody
194
+ * during a broadcast you did not miss.
195
+ */
196
+ recentlyEnded() {
197
+ this.sweep();
198
+ const live = new Set([...this.items.values()].map((item) => item.id));
199
+ return [...this.ended.values()]
200
+ .filter((item) => !live.has(item.id))
201
+ .sort((a, b) => b.endedAt - a.endedAt);
202
+ }
203
+ /** The name last used by an account, live or recently ended. */
204
+ nameOf(ownerId) {
205
+ if (!ownerId)
206
+ return "";
207
+ this.sweep();
208
+ const live = [...this.items.values()].find((item) => item.ownerId === ownerId);
209
+ if (live)
210
+ return live.name;
211
+ const ended = [...this.ended.values()]
212
+ .filter((item) => item.ownerId === ownerId)
213
+ .sort((a, b) => b.endedAt - a.endedAt)[0];
214
+ return ended?.name ?? "";
215
+ }
216
+ /** Whether this account is streaming right now. */
217
+ isLive(ownerId) {
218
+ if (!ownerId)
219
+ return false;
220
+ this.sweep();
221
+ return [...this.items.values()].some((item) => item.ownerId === ownerId);
222
+ }
223
+ /** The stream that used to be on this code, if it stopped recently. */
224
+ endedByCode(code) {
225
+ this.sweep();
226
+ return [...this.ended.values()].find((item) => item.code === code);
227
+ }
228
+ endedByUrl(url) {
229
+ return [...this.ended.values()].find((item) => item.url === url);
230
+ }
231
+ remember(item) {
232
+ const record = {
233
+ id: item.id,
234
+ code: item.code,
235
+ name: item.name,
236
+ ownerId: item.ownerId,
237
+ url: item.url,
238
+ nowPlaying: item.nowPlaying,
239
+ startedAt: item.startedAt,
240
+ endedAt: item.updatedAt,
241
+ };
242
+ this.ended.set(item.id, record);
243
+ this.mirror?.save(record);
244
+ }
245
+ /** A code no live and no recently-ended stream is using. */
246
+ freeCode() {
247
+ for (let tries = 0; tries < 40; tries += 1) {
248
+ const code = this.randomCode();
249
+ if (code.length !== 6)
250
+ continue;
251
+ const taken = [...this.items.values()].some((i) => i.code === code) ||
252
+ [...this.ended.values()].some((i) => i.code === code);
253
+ if (!taken)
254
+ return code;
255
+ }
256
+ return "";
257
+ }
258
+ /** Forget anything that stopped renewing, keeping a note of when it did. */
105
259
  sweep() {
106
260
  const cutoff = this.now() - this.ttl;
107
- for (const [id, item] of this.items)
108
- if (item.updatedAt < cutoff)
261
+ for (const [id, item] of this.items) {
262
+ if (item.updatedAt < cutoff) {
263
+ this.remember(item);
109
264
  this.items.delete(id);
265
+ }
266
+ }
267
+ const forget = this.now() - ENDED_TTL_MS;
268
+ for (const [id, item] of this.ended) {
269
+ if (item.endedAt < forget) {
270
+ this.ended.delete(id);
271
+ this.mirror?.drop(id);
272
+ }
273
+ }
110
274
  }
111
275
  }
@@ -0,0 +1,70 @@
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
+ export interface StoredEnded {
29
+ id: string;
30
+ code: string;
31
+ name: string;
32
+ ownerId: string;
33
+ url: string;
34
+ nowPlaying: string;
35
+ startedAt: number;
36
+ endedAt: number;
37
+ }
38
+ export declare class Durable {
39
+ private readonly db;
40
+ private readonly onEvent;
41
+ private ready;
42
+ constructor(db: Queryable, onEvent?: (message: string) => void);
43
+ private ensure;
44
+ /**
45
+ * Nothing here is worth taking a request down for.
46
+ *
47
+ * These are all mirror writes: the in-memory copy is what the request is
48
+ * answered from, so a database that is briefly unreachable should cost the
49
+ * durability and not the feature.
50
+ */
51
+ private quietly;
52
+ saveEnded(stream: StoredEnded): Promise<void>;
53
+ /** A stream that came back, or one old enough to forget. */
54
+ dropEnded(id: string): Promise<void>;
55
+ /** What ended since `since`, oldest first so replaying it rebuilds the order. */
56
+ loadEnded(since: number): Promise<StoredEnded[]>;
57
+ addReminder(code: string, phone: string): Promise<void>;
58
+ /**
59
+ * Take everyone waiting on a code, and stop them waiting, in one statement.
60
+ *
61
+ * `DELETE ... RETURNING` rather than a select and then a delete: the rows
62
+ * come back as they are removed, so two goings-live at once cannot both read
63
+ * the same list and text everybody twice.
64
+ */
65
+ takeReminders(code: string): Promise<string[]>;
66
+ /** Everyone waiting, by code, to seed a process that has just started. */
67
+ loadReminders(): Promise<Map<string, Set<string>>>;
68
+ /** Forget what is too old to be worth telling anybody about. */
69
+ sweep(endedBefore: number, remindersBefore: Date): Promise<void>;
70
+ }
@@ -0,0 +1,156 @@
1
+ const SCHEMA = `
2
+ CREATE TABLE IF NOT EXISTS ended_streams (
3
+ id TEXT PRIMARY KEY,
4
+ code TEXT NOT NULL,
5
+ name TEXT NOT NULL DEFAULT '',
6
+ owner_id TEXT NOT NULL DEFAULT '',
7
+ url TEXT NOT NULL DEFAULT '',
8
+ now_playing TEXT NOT NULL DEFAULT '',
9
+ started_at BIGINT NOT NULL,
10
+ ended_at BIGINT NOT NULL
11
+ );
12
+ CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
13
+
14
+ CREATE TABLE IF NOT EXISTS stream_reminders (
15
+ code TEXT NOT NULL,
16
+ phone TEXT NOT NULL,
17
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
18
+ PRIMARY KEY (code, phone)
19
+ );
20
+ `;
21
+ export class Durable {
22
+ db;
23
+ onEvent;
24
+ ready = null;
25
+ constructor(db, onEvent = () => { }) {
26
+ this.db = db;
27
+ this.onEvent = onEvent;
28
+ }
29
+ async ensure() {
30
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
31
+ await this.ready;
32
+ }
33
+ /**
34
+ * Nothing here is worth taking a request down for.
35
+ *
36
+ * These are all mirror writes: the in-memory copy is what the request is
37
+ * answered from, so a database that is briefly unreachable should cost the
38
+ * durability and not the feature.
39
+ */
40
+ async quietly(what, run) {
41
+ try {
42
+ await this.ensure();
43
+ await run();
44
+ }
45
+ catch (error) {
46
+ this.onEvent(` ${what} did not persist: ${error.message}`);
47
+ }
48
+ }
49
+ async saveEnded(stream) {
50
+ await this.quietly("an ended stream", () => this.db.query(`INSERT INTO ended_streams
51
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at)
52
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
53
+ ON CONFLICT (id) DO UPDATE
54
+ SET code = EXCLUDED.code,
55
+ name = EXCLUDED.name,
56
+ owner_id = EXCLUDED.owner_id,
57
+ url = EXCLUDED.url,
58
+ now_playing = EXCLUDED.now_playing,
59
+ started_at = EXCLUDED.started_at,
60
+ ended_at = EXCLUDED.ended_at`, [
61
+ stream.id,
62
+ stream.code,
63
+ stream.name,
64
+ stream.ownerId,
65
+ stream.url,
66
+ stream.nowPlaying,
67
+ stream.startedAt,
68
+ stream.endedAt,
69
+ ]));
70
+ }
71
+ /** A stream that came back, or one old enough to forget. */
72
+ async dropEnded(id) {
73
+ await this.quietly("dropping an ended stream", () => this.db.query("DELETE FROM ended_streams WHERE id = $1", [id]));
74
+ }
75
+ /** What ended since `since`, oldest first so replaying it rebuilds the order. */
76
+ async loadEnded(since) {
77
+ try {
78
+ await this.ensure();
79
+ const { rows } = await this.db.query("SELECT * FROM ended_streams WHERE ended_at >= $1 ORDER BY ended_at", [since]);
80
+ return rows.map((r) => ({
81
+ id: String(r["id"] ?? ""),
82
+ code: String(r["code"] ?? ""),
83
+ name: String(r["name"] ?? ""),
84
+ ownerId: String(r["owner_id"] ?? ""),
85
+ url: String(r["url"] ?? ""),
86
+ nowPlaying: String(r["now_playing"] ?? ""),
87
+ // BIGINT comes back as a string from pg, which sorts and compares
88
+ // nothing like a number.
89
+ startedAt: Number(r["started_at"] ?? 0),
90
+ endedAt: Number(r["ended_at"] ?? 0),
91
+ }));
92
+ }
93
+ catch (error) {
94
+ this.onEvent(` could not read ended streams: ${error.message}`);
95
+ return [];
96
+ }
97
+ }
98
+ async addReminder(code, phone) {
99
+ if (!code || !phone)
100
+ return;
101
+ await this.quietly("a reminder", () => this.db.query(`INSERT INTO stream_reminders (code, phone) VALUES ($1, $2)
102
+ ON CONFLICT DO NOTHING`, [code, phone]));
103
+ }
104
+ /**
105
+ * Take everyone waiting on a code, and stop them waiting, in one statement.
106
+ *
107
+ * `DELETE ... RETURNING` rather than a select and then a delete: the rows
108
+ * come back as they are removed, so two goings-live at once cannot both read
109
+ * the same list and text everybody twice.
110
+ */
111
+ async takeReminders(code) {
112
+ if (!code)
113
+ return [];
114
+ try {
115
+ await this.ensure();
116
+ const { rows } = await this.db.query("DELETE FROM stream_reminders WHERE code = $1 RETURNING phone", [code]);
117
+ return rows.map((r) => String(r["phone"] ?? "")).filter(Boolean);
118
+ }
119
+ catch (error) {
120
+ this.onEvent(` could not take reminders: ${error.message}`);
121
+ return [];
122
+ }
123
+ }
124
+ /** Everyone waiting, by code, to seed a process that has just started. */
125
+ async loadReminders() {
126
+ const waiting = new Map();
127
+ try {
128
+ await this.ensure();
129
+ const { rows } = await this.db.query("SELECT code, phone FROM stream_reminders", []);
130
+ for (const row of rows) {
131
+ const code = String(row["code"] ?? "");
132
+ const phone = String(row["phone"] ?? "");
133
+ if (!code || !phone)
134
+ continue;
135
+ const set = waiting.get(code) ?? new Set();
136
+ set.add(phone);
137
+ waiting.set(code, set);
138
+ }
139
+ }
140
+ catch (error) {
141
+ this.onEvent(` could not read reminders: ${error.message}`);
142
+ }
143
+ return waiting;
144
+ }
145
+ /** Forget what is too old to be worth telling anybody about. */
146
+ async sweep(endedBefore, remindersBefore) {
147
+ await this.quietly("sweeping", async () => {
148
+ await this.db.query("DELETE FROM ended_streams WHERE ended_at < $1", [endedBefore]);
149
+ // A reminder nobody has collected in a month is somebody who has long
150
+ // since stopped expecting a text.
151
+ await this.db.query("DELETE FROM stream_reminders WHERE created_at < $1", [
152
+ remindersBefore.toISOString(),
153
+ ]);
154
+ });
155
+ }
156
+ }