pi-roundtable-web 0.22.5 → 0.23.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.
- package/CHANGELOG.md +53 -1
- package/README.md +444 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-660d2t6p.js +1 -0
- package/dist/chunk-connectors-d19v8cjc.js +1 -0
- package/dist/chunk-conversations-4cmexmqq.js +1 -0
- package/dist/chunk-entry-8xpx35ef.js +1 -0
- package/dist/chunk-index-19g8jmr8.js +2 -0
- package/dist/chunk-index-362dfedj.js +1 -0
- package/dist/chunk-index-6g69tqt0.js +47 -0
- package/dist/chunk-index-89q2qdkj.css +1 -0
- package/dist/chunk-index-9tvf6es7.js +41 -0
- package/dist/chunk-index-p8h0e861.js +2 -0
- package/dist/chunk-index-xw5hhmfq.js +2 -0
- package/dist/chunk-notes-fefwds14.js +1 -0
- package/dist/chunk-overview-bxpwpmn6.js +1 -0
- package/dist/chunk-skills-pce3zgxy.js +1 -0
- package/dist/chunk-table-zmpbrfvz.js +1 -0
- package/dist/chunk-table2d-ac2gez71.js +1 -0
- package/dist/chunk-table3d-g258jemr.js +4229 -0
- package/dist/chunk-transcript-yhtjp3pd.js +1 -0
- package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/features.ts +6 -1
- package/src/http.ts +8 -0
- package/src/i18n/index.ts +13 -0
- package/src/i18n/locale.ts +11 -0
- package/src/i18n/zh-tw.ts +696 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- package/dist/chunk-e8z9q554.css +0 -1
package/src/app/app.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import type {
|
|
4
|
+
AgentServer,
|
|
5
|
+
ChannelKey,
|
|
6
|
+
ConversationModes,
|
|
7
|
+
ConversationPort,
|
|
8
|
+
DirectChannelProvider,
|
|
9
|
+
IdentityService,
|
|
10
|
+
Logger,
|
|
11
|
+
Migration,
|
|
12
|
+
ScheduleStore,
|
|
13
|
+
WebSocketRoute,
|
|
14
|
+
} from "pi-roundtable";
|
|
15
|
+
import type { Authenticated, Authenticator } from "../auth/authenticator.ts";
|
|
16
|
+
import { PushApi } from "../push/api.ts";
|
|
17
|
+
import { webPushChannel } from "../push/channel.ts";
|
|
18
|
+
import { EndpointPolicy } from "../push/endpoint.ts";
|
|
19
|
+
import { PushSender } from "../push/sender.ts";
|
|
20
|
+
import { PushSubscriptions } from "../push/subscriptions.ts";
|
|
21
|
+
import { PushService } from "../push/triggers.ts";
|
|
22
|
+
import type { Vapid } from "../push/vapid.ts";
|
|
23
|
+
import { CardLog } from "./card-log.ts";
|
|
24
|
+
import { OwnerDesk } from "./desk.ts";
|
|
25
|
+
import { FileDir } from "./files.ts";
|
|
26
|
+
import { Presence } from "./presence.ts";
|
|
27
|
+
import { RoomLog } from "./room-log.ts";
|
|
28
|
+
import { Rooms } from "./rooms.ts";
|
|
29
|
+
import { AppApi, partsOf } from "./routes.ts";
|
|
30
|
+
import { SocketRoute } from "./socket.ts";
|
|
31
|
+
import { type AppSocket, Sockets } from "./sockets.ts";
|
|
32
|
+
import { AppSurface } from "./surface.ts";
|
|
33
|
+
import { WebThreads } from "./threads.ts";
|
|
34
|
+
import { Uploads } from "./uploads.ts";
|
|
35
|
+
|
|
36
|
+
/** Changes closer together than this reach the sockets as one `changed` frame. */
|
|
37
|
+
const CHANGED_COALESCE_MS = 250;
|
|
38
|
+
/** How often old files are swept: hourly. */
|
|
39
|
+
const SWEEP_EVERY_MS = 3_600_000;
|
|
40
|
+
|
|
41
|
+
/** The tables the app keeps; the plugin lists them as its own migrations. */
|
|
42
|
+
export const APP_MIGRATIONS: readonly Migration[] = [
|
|
43
|
+
RoomLog.migration,
|
|
44
|
+
CardLog.migration,
|
|
45
|
+
WebThreads.migration,
|
|
46
|
+
PushSubscriptions.migration,
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export interface OwnerAppOptions {
|
|
50
|
+
/** The key prefix of the rooms, such as `app`. */
|
|
51
|
+
surface: string;
|
|
52
|
+
/** The path the console is served under, without a trailing slash. */
|
|
53
|
+
mount: string;
|
|
54
|
+
/** The console's own origin. */
|
|
55
|
+
origin: string;
|
|
56
|
+
/** The host's data directory, whose `web/` holds the app's files. */
|
|
57
|
+
dataDir: string;
|
|
58
|
+
/** How long a turn waits on its card before it goes on without the answer, in seconds. */
|
|
59
|
+
cardGraceSeconds: number;
|
|
60
|
+
/** The most bytes of one uploaded file. */
|
|
61
|
+
uploadMaxBytes: number;
|
|
62
|
+
authenticator: Pick<Authenticator, "authenticate">;
|
|
63
|
+
/** How the console signs the person in. */
|
|
64
|
+
authMode: "none" | "password" | "verifier";
|
|
65
|
+
/** The longest an open socket lives; for a credential that logout or a new password does not reach. */
|
|
66
|
+
socketLifeMs?: number;
|
|
67
|
+
/** Web Push: the server's key, and how notifications read. */
|
|
68
|
+
push: {
|
|
69
|
+
vapid: Vapid;
|
|
70
|
+
/** The `sub` of the VAPID token; absent = push is not set up and nothing is sent. */
|
|
71
|
+
subject: string | undefined;
|
|
72
|
+
preview: boolean;
|
|
73
|
+
endpointHosts: readonly string[];
|
|
74
|
+
/** The HTTP client the posts go through. */
|
|
75
|
+
fetch?: typeof fetch;
|
|
76
|
+
/** The path a tap on a room's notification opens, under the origin. */
|
|
77
|
+
urlOf(room: string): string;
|
|
78
|
+
};
|
|
79
|
+
database: SQL;
|
|
80
|
+
agents: AgentServer;
|
|
81
|
+
modes: ConversationModes;
|
|
82
|
+
identity: IdentityService;
|
|
83
|
+
/** Present when the schedules pane is served. */
|
|
84
|
+
schedules?: ScheduleStore;
|
|
85
|
+
conversations: ConversationPort;
|
|
86
|
+
/** Called after a change the other pages should refetch. */
|
|
87
|
+
changed(): void;
|
|
88
|
+
logger: Logger;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The owner app's server side, wired: the rooms as a chat surface, the cards, the WebSocket, the
|
|
93
|
+
* stored messages, uploads and files, and the REST under the mount. The plugin contributes its
|
|
94
|
+
* `surface` and `migrations`, adds `websocket` to its route, and hands `handle` the requests the
|
|
95
|
+
* console's authenticator admitted.
|
|
96
|
+
*/
|
|
97
|
+
export class OwnerApp {
|
|
98
|
+
readonly surface: AppSurface;
|
|
99
|
+
readonly websocket: WebSocketRoute<AppSocket>;
|
|
100
|
+
/** Reaches an owner through the app: a line in the home room, and a push to their devices. */
|
|
101
|
+
readonly directChannel: DirectChannelProvider;
|
|
102
|
+
readonly #api: AppApi;
|
|
103
|
+
readonly #push: PushApi;
|
|
104
|
+
readonly #sockets: Sockets;
|
|
105
|
+
readonly #desk: OwnerDesk;
|
|
106
|
+
readonly #presence: Presence;
|
|
107
|
+
readonly #uploads: Uploads;
|
|
108
|
+
readonly #options: OwnerAppOptions;
|
|
109
|
+
readonly #unsubscribe: (() => void)[] = [];
|
|
110
|
+
#sweeper: ReturnType<typeof setInterval> | undefined;
|
|
111
|
+
#pending: ReturnType<typeof setTimeout> | undefined;
|
|
112
|
+
|
|
113
|
+
constructor(options: OwnerAppOptions) {
|
|
114
|
+
this.#options = options;
|
|
115
|
+
const { agents, modes, identity, logger } = options;
|
|
116
|
+
const sockets = new Sockets(logger);
|
|
117
|
+
const log = new RoomLog(options.database);
|
|
118
|
+
const cards = new CardLog(options.database);
|
|
119
|
+
const stored = new FileDir(join(options.dataDir, "web", "files"));
|
|
120
|
+
const waiting = new FileDir(join(options.dataDir, "web", "uploads"));
|
|
121
|
+
const uploads = new Uploads(waiting, stored, {
|
|
122
|
+
maxBytes: options.uploadMaxBytes,
|
|
123
|
+
});
|
|
124
|
+
const threads = new WebThreads(options.database);
|
|
125
|
+
const subscriptions = new PushSubscriptions(options.database);
|
|
126
|
+
const policy = new EndpointPolicy(options.push.endpointHosts);
|
|
127
|
+
const push = new PushService({
|
|
128
|
+
subscriptions,
|
|
129
|
+
sender: new PushSender({
|
|
130
|
+
vapid: options.push.vapid,
|
|
131
|
+
subject: options.push.subject,
|
|
132
|
+
policy,
|
|
133
|
+
...(options.push.fetch ? { fetch: options.push.fetch } : {}),
|
|
134
|
+
logger,
|
|
135
|
+
}),
|
|
136
|
+
identity,
|
|
137
|
+
visible: (principalId) => sockets.anyVisible(principalId),
|
|
138
|
+
preview: options.push.preview,
|
|
139
|
+
urlOf: options.push.urlOf,
|
|
140
|
+
logger,
|
|
141
|
+
});
|
|
142
|
+
this.#push = new PushApi({
|
|
143
|
+
vapid: options.push.vapid,
|
|
144
|
+
policy,
|
|
145
|
+
subscriptions,
|
|
146
|
+
identity,
|
|
147
|
+
enabled: options.push.subject !== undefined,
|
|
148
|
+
});
|
|
149
|
+
// The desk starts a late answer's turn through the socket route, which is made after it.
|
|
150
|
+
let route: SocketRoute | undefined;
|
|
151
|
+
const desk = new OwnerDesk({
|
|
152
|
+
broadcast: (frame) => sockets.broadcast(frame),
|
|
153
|
+
graceMs: options.cardGraceSeconds * 1000,
|
|
154
|
+
closed: (card) => cards.record(card),
|
|
155
|
+
opened: (room, frame) => {
|
|
156
|
+
const cap =
|
|
157
|
+
frame.kind === "approval" &&
|
|
158
|
+
frame.purpose === "turn-timeout" &&
|
|
159
|
+
modes.peek(room).longRunning;
|
|
160
|
+
push.card(room, frame.title, cap).catch(() => undefined);
|
|
161
|
+
},
|
|
162
|
+
resume: async (room, by, text) => {
|
|
163
|
+
if (!route) throw new Error("the app socket route is not ready");
|
|
164
|
+
await route.say(room, by, text, []);
|
|
165
|
+
},
|
|
166
|
+
namesOf: async (ids) =>
|
|
167
|
+
Promise.all(
|
|
168
|
+
ids.map(
|
|
169
|
+
async (id) => (await identity.principal(id))?.displayName ?? id,
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
logger,
|
|
173
|
+
});
|
|
174
|
+
let presence: Presence | undefined;
|
|
175
|
+
const surface = new AppSurface({
|
|
176
|
+
surface: options.surface,
|
|
177
|
+
sockets,
|
|
178
|
+
desk,
|
|
179
|
+
log,
|
|
180
|
+
files: stored,
|
|
181
|
+
uploads,
|
|
182
|
+
threads,
|
|
183
|
+
canHost: async (parent) => {
|
|
184
|
+
const room = await rooms.resolve(parent);
|
|
185
|
+
return room !== undefined && room.kind !== "thread";
|
|
186
|
+
},
|
|
187
|
+
changed: () => this.changed(),
|
|
188
|
+
unprompted: (room, author, text) => {
|
|
189
|
+
push.reply(room, author, text).catch(() => undefined);
|
|
190
|
+
},
|
|
191
|
+
activity: () => presence?.schedule(),
|
|
192
|
+
logger,
|
|
193
|
+
});
|
|
194
|
+
presence = new Presence({
|
|
195
|
+
team: agents.team,
|
|
196
|
+
sockets,
|
|
197
|
+
working: () => surface.workingAgents(),
|
|
198
|
+
logger,
|
|
199
|
+
});
|
|
200
|
+
const rooms = new Rooms({
|
|
201
|
+
surface: options.surface,
|
|
202
|
+
mount: options.mount,
|
|
203
|
+
directory: agents.directory,
|
|
204
|
+
team: agents.team,
|
|
205
|
+
avatars: agents.avatars,
|
|
206
|
+
threads,
|
|
207
|
+
log,
|
|
208
|
+
modes,
|
|
209
|
+
activity: (room) => surface.activityOf(room),
|
|
210
|
+
});
|
|
211
|
+
route = new SocketRoute({
|
|
212
|
+
path: `${options.mount}/api/socket`,
|
|
213
|
+
origin: options.origin,
|
|
214
|
+
authenticator: options.authenticator,
|
|
215
|
+
...(options.socketLifeMs === undefined
|
|
216
|
+
? {}
|
|
217
|
+
: { maxLifeMs: options.socketLifeMs }),
|
|
218
|
+
sockets,
|
|
219
|
+
surface,
|
|
220
|
+
desk,
|
|
221
|
+
rooms,
|
|
222
|
+
uploads,
|
|
223
|
+
presence,
|
|
224
|
+
identity,
|
|
225
|
+
conversations: options.conversations,
|
|
226
|
+
logger,
|
|
227
|
+
});
|
|
228
|
+
this.websocket = route.route();
|
|
229
|
+
this.directChannel = webPushChannel({
|
|
230
|
+
surface: options.surface,
|
|
231
|
+
identity,
|
|
232
|
+
home: () => rooms.homeKey(),
|
|
233
|
+
post: async (room, text) => {
|
|
234
|
+
await surface.publish({
|
|
235
|
+
room,
|
|
236
|
+
kind: "system",
|
|
237
|
+
author: "Notice",
|
|
238
|
+
text,
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
push,
|
|
242
|
+
});
|
|
243
|
+
this.#api = new AppApi({
|
|
244
|
+
surface,
|
|
245
|
+
rooms,
|
|
246
|
+
log,
|
|
247
|
+
cards,
|
|
248
|
+
uploads,
|
|
249
|
+
files: stored,
|
|
250
|
+
modes,
|
|
251
|
+
conversations: options.conversations,
|
|
252
|
+
identity,
|
|
253
|
+
...(options.schedules ? { schedules: options.schedules } : {}),
|
|
254
|
+
directory: agents.directory,
|
|
255
|
+
team: agents.team,
|
|
256
|
+
avatars: agents.avatars,
|
|
257
|
+
mount: options.mount,
|
|
258
|
+
authMode: options.authMode,
|
|
259
|
+
changed: options.changed,
|
|
260
|
+
logger,
|
|
261
|
+
});
|
|
262
|
+
this.surface = surface;
|
|
263
|
+
this.#sockets = sockets;
|
|
264
|
+
this.#desk = desk;
|
|
265
|
+
this.#presence = presence;
|
|
266
|
+
this.#uploads = uploads;
|
|
267
|
+
this.#unsubscribe.push(
|
|
268
|
+
modes.onChange((channel, mode) => {
|
|
269
|
+
if (!rooms.ownsKey(channel)) return;
|
|
270
|
+
sockets.broadcast({
|
|
271
|
+
type: "mode",
|
|
272
|
+
room: channel,
|
|
273
|
+
yolo: mode.yolo,
|
|
274
|
+
longRunning: mode.longRunning,
|
|
275
|
+
});
|
|
276
|
+
}),
|
|
277
|
+
modes.onAutoApproved((entry) => {
|
|
278
|
+
if (!rooms.ownsKey(entry.channel)) return;
|
|
279
|
+
this.#autoApproved(entry.channel, entry.tool, entry.action).catch(
|
|
280
|
+
(error: unknown) =>
|
|
281
|
+
logger.warn({ err: error }, "a yolo approval was not recorded"),
|
|
282
|
+
);
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Answers a path under the mount that is the app's; undefined for any other. */
|
|
288
|
+
async handle(
|
|
289
|
+
request: Request,
|
|
290
|
+
rest: string,
|
|
291
|
+
admission: Authenticated,
|
|
292
|
+
): Promise<Response | undefined> {
|
|
293
|
+
const parts = partsOf(rest);
|
|
294
|
+
const pushed = parts
|
|
295
|
+
? await this.#push.handle(request, parts, admission)
|
|
296
|
+
: undefined;
|
|
297
|
+
return pushed ?? this.#api.handle(request, rest, admission);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Tells every open socket the team or the room list may differ, once changes settle. */
|
|
301
|
+
changed(): void {
|
|
302
|
+
if (this.#pending) return;
|
|
303
|
+
this.#pending = setTimeout(() => {
|
|
304
|
+
this.#pending = undefined;
|
|
305
|
+
this.#sockets.broadcast({ type: "changed" });
|
|
306
|
+
}, CHANGED_COALESCE_MS);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
start(): void {
|
|
310
|
+
this.#uploads.adopt();
|
|
311
|
+
this.#sweep();
|
|
312
|
+
this.#sweeper = setInterval(() => this.#sweep(), SWEEP_EVERY_MS);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Closes the sockets and the cards, and stops the sweeping; the host has stopped its turns by now. */
|
|
316
|
+
stop(): void {
|
|
317
|
+
clearInterval(this.#sweeper);
|
|
318
|
+
this.#sweeper = undefined;
|
|
319
|
+
clearTimeout(this.#pending);
|
|
320
|
+
this.#pending = undefined;
|
|
321
|
+
for (const unsubscribe of this.#unsubscribe.splice(0)) unsubscribe();
|
|
322
|
+
this.#desk.stop();
|
|
323
|
+
this.#presence.stop();
|
|
324
|
+
this.#sockets.closeAll(1001, "stopping");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#sweep(): void {
|
|
328
|
+
try {
|
|
329
|
+
const dropped = this.#uploads.sweep();
|
|
330
|
+
if (dropped > 0)
|
|
331
|
+
this.#options.logger.info({ dropped }, "old app files deleted");
|
|
332
|
+
} catch (error) {
|
|
333
|
+
this.#options.logger.warn({ err: error }, "old app files not deleted");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Records and shows that yolo mode approved a held call in a room. */
|
|
338
|
+
async #autoApproved(
|
|
339
|
+
room: ChannelKey,
|
|
340
|
+
tool: string,
|
|
341
|
+
action: string,
|
|
342
|
+
): Promise<void> {
|
|
343
|
+
await this.surface.publish({
|
|
344
|
+
room,
|
|
345
|
+
kind: "system",
|
|
346
|
+
author: "Yolo mode",
|
|
347
|
+
text: `Yolo mode approved: ${action}`,
|
|
348
|
+
});
|
|
349
|
+
this.#sockets.broadcast({ type: "auto_approved", room, tool, action });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import type {
|
|
4
|
+
CardAnswer,
|
|
5
|
+
ClosedCard,
|
|
6
|
+
PromptFrame,
|
|
7
|
+
PromptOutcome,
|
|
8
|
+
} from "../api-types.ts";
|
|
9
|
+
|
|
10
|
+
interface Row {
|
|
11
|
+
room: string;
|
|
12
|
+
frame: PromptFrame | string;
|
|
13
|
+
outcome: PromptOutcome;
|
|
14
|
+
note: string | null;
|
|
15
|
+
answered_by: string | null;
|
|
16
|
+
answer: CardAnswer | string | null;
|
|
17
|
+
closed_at: Date;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A jsonb column as the driver hands it back: parsed, or as text. */
|
|
21
|
+
function parsed<T>(value: T | string): T {
|
|
22
|
+
if (typeof value !== "string") return value;
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(value) as T;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
throw new Error("the card log holds JSON that cannot be read", {
|
|
27
|
+
cause: error,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cardOf(row: Row): ClosedCard {
|
|
33
|
+
return {
|
|
34
|
+
room: row.room,
|
|
35
|
+
frame: parsed(row.frame),
|
|
36
|
+
outcome: row.outcome,
|
|
37
|
+
...(row.note === null ? {} : { note: row.note }),
|
|
38
|
+
...(row.answered_by === null ? {} : { by: row.answered_by }),
|
|
39
|
+
...(row.answer === null ? {} : { answer: parsed(row.answer) }),
|
|
40
|
+
closedAt: row.closed_at.toISOString(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The cards of the app's rooms that ended, kept in `web_room_cards` so a room's history shows
|
|
46
|
+
* them after a reload as the Discord channel keeps a card with its answer. A row holds the card
|
|
47
|
+
* as it was shown, so it carries nothing the card did not, and is written once, when the card
|
|
48
|
+
* ends; the cards still open live in the desk.
|
|
49
|
+
*/
|
|
50
|
+
export class CardLog {
|
|
51
|
+
readonly #sql: SQL;
|
|
52
|
+
/** The writes under way, which a read waits for so a card that just ended is in it. */
|
|
53
|
+
readonly #writing = new Set<Promise<void>>();
|
|
54
|
+
|
|
55
|
+
constructor(sql: SQL) {
|
|
56
|
+
this.#sql = sql;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The log's table. */
|
|
60
|
+
static readonly migration: Migration = {
|
|
61
|
+
name: "web-room-cards",
|
|
62
|
+
up: async (sql) => {
|
|
63
|
+
await sql`
|
|
64
|
+
CREATE TABLE IF NOT EXISTS web_room_cards (
|
|
65
|
+
prompt_id text PRIMARY KEY,
|
|
66
|
+
room text NOT NULL,
|
|
67
|
+
opened_at timestamptz NOT NULL,
|
|
68
|
+
closed_at timestamptz NOT NULL,
|
|
69
|
+
outcome text NOT NULL,
|
|
70
|
+
note text,
|
|
71
|
+
answered_by text,
|
|
72
|
+
frame jsonb NOT NULL,
|
|
73
|
+
answer jsonb
|
|
74
|
+
)`;
|
|
75
|
+
await sql`CREATE INDEX IF NOT EXISTS web_room_cards_room_opened ON web_room_cards (room, opened_at)`;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Stores an ended card; a card stored already stays as it was. */
|
|
80
|
+
record(card: ClosedCard): Promise<void> {
|
|
81
|
+
const write = this.#write(card).finally(() => this.#writing.delete(write));
|
|
82
|
+
this.#writing.add(write);
|
|
83
|
+
return write;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async #write(card: ClosedCard): Promise<void> {
|
|
87
|
+
const opened = card.frame.at ?? card.closedAt;
|
|
88
|
+
await this.#sql`
|
|
89
|
+
INSERT INTO web_room_cards
|
|
90
|
+
(prompt_id, room, opened_at, closed_at, outcome, note, answered_by, frame, answer)
|
|
91
|
+
VALUES (${card.frame.id}, ${card.room}, ${opened}::timestamptz, ${card.closedAt}::timestamptz,
|
|
92
|
+
${card.outcome}, ${card.note ?? null}, ${card.by ?? null},
|
|
93
|
+
${JSON.stringify(card.frame)}::jsonb,
|
|
94
|
+
${card.answer ? JSON.stringify(card.answer) : null}::jsonb)
|
|
95
|
+
ON CONFLICT (prompt_id) DO NOTHING`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The room's cards that opened at or after `from` (all of them when absent) and before the
|
|
100
|
+
* stored message `before` of the same room was written (all of them when absent), oldest
|
|
101
|
+
* first. Times compare at the millisecond, which is how the page orders them.
|
|
102
|
+
*/
|
|
103
|
+
async during(
|
|
104
|
+
room: string,
|
|
105
|
+
span: { from?: string; before?: number },
|
|
106
|
+
): Promise<ClosedCard[]> {
|
|
107
|
+
const { from, before } = span;
|
|
108
|
+
await Promise.allSettled([...this.#writing]);
|
|
109
|
+
const rows: Row[] = await this.#sql`
|
|
110
|
+
SELECT room, frame, outcome, note, answered_by, answer, closed_at FROM web_room_cards
|
|
111
|
+
WHERE room = ${room}
|
|
112
|
+
AND (${from ?? null}::timestamptz IS NULL OR opened_at >= ${from ?? null}::timestamptz)
|
|
113
|
+
AND (${before ?? null}::bigint IS NULL OR opened_at < (
|
|
114
|
+
SELECT date_trunc('milliseconds', created_at) FROM web_room_messages
|
|
115
|
+
WHERE id = ${before ?? null}::bigint AND room = ${room}))
|
|
116
|
+
ORDER BY opened_at, closed_at`;
|
|
117
|
+
return rows.map(cardOf);
|
|
118
|
+
}
|
|
119
|
+
}
|