cursedbelt-server 4.27.0 → 4.29.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.
- package/dist/server/activity/d1.d.ts +70 -0
- package/dist/server/activity/d1.js +68 -0
- package/dist/server/activity/migrations.d.ts +1 -3
- package/dist/server/activity/migrations.js +7 -32
- package/dist/server/activity/query.d.ts +123 -0
- package/dist/server/activity/query.js +171 -0
- package/dist/server/activity/schema.d.ts +13 -0
- package/dist/server/activity/schema.js +43 -0
- package/dist/server/activity/shareSource.d.ts +1 -1
- package/dist/server/activity/shareSource.js +1 -1
- package/dist/server/activity/store.d.ts +4 -62
- package/dist/server/activity/store.js +14 -145
- package/dist/server/metrics/metricsBuffer.d.ts +0 -2
- package/dist/server/metrics/metricsBuffer.js +5 -2
- package/dist/server/metrics/requestPlace.d.ts +8 -0
- package/dist/server/metrics/requestPlace.js +8 -0
- package/dist/server/notifications/d1.d.ts +80 -0
- package/dist/server/notifications/d1.js +226 -0
- package/dist/server/notifications/service.d.ts +2 -20
- package/dist/server/notifications/types.d.ts +27 -0
- package/dist/server/notifications/types.js +1 -0
- package/dist/server/requestLog/requestMetrics.js +1 -1
- package/dist/server/sharing/contract.d.ts +94 -0
- package/dist/server/sharing/contract.js +3 -0
- package/dist/server/sharing/d1.d.ts +77 -0
- package/dist/server/sharing/d1.js +309 -0
- package/dist/server/sharing/index.d.ts +1 -0
- package/dist/server/sharing/migrations.d.ts +1 -4
- package/dist/server/sharing/migrations.js +7 -72
- package/dist/server/sharing/router.d.ts +8 -4
- package/dist/server/sharing/router.js +7 -5
- package/dist/server/sharing/schema.d.ts +19 -0
- package/dist/server/sharing/schema.js +88 -0
- package/dist/server/sharing/sql.js +1 -1
- package/dist/server/sharing/statements.d.ts +116 -0
- package/dist/server/sharing/statements.js +139 -0
- package/dist/server/sharing/store.d.ts +3 -28
- package/dist/server/sharing/store.js +32 -105
- package/dist/server/telemetry.d.ts +1 -1
- package/dist/server/telemetry.js +1 -1
- package/docs/activity.md +7 -0
- package/docs/notifications.md +9 -0
- package/package.json +19 -1
- package/src/server/activity/d1.spec.ts +169 -0
- package/src/server/activity/d1.ts +172 -0
- package/src/server/activity/migrations.ts +6 -40
- package/src/server/activity/query.ts +283 -0
- package/src/server/activity/schema.ts +45 -0
- package/src/server/activity/shareSource.ts +2 -2
- package/src/server/activity/store.ts +45 -257
- package/src/server/metrics/metricsBuffer.ts +5 -2
- package/src/server/metrics/requestPlace.ts +9 -0
- package/src/server/notifications/d1.spec.ts +179 -0
- package/src/server/notifications/d1.ts +334 -0
- package/src/server/notifications/service.ts +7 -23
- package/src/server/notifications/types.ts +29 -0
- package/src/server/requestLog/requestMetrics.ts +1 -1
- package/src/server/sharing/contract.ts +109 -0
- package/src/server/sharing/d1.spec.ts +242 -0
- package/src/server/sharing/d1.ts +430 -0
- package/src/server/sharing/index.ts +3 -0
- package/src/server/sharing/migrations.ts +12 -85
- package/src/server/sharing/router.ts +21 -11
- package/src/server/sharing/schema.ts +90 -0
- package/src/server/sharing/sql.ts +1 -1
- package/src/server/sharing/statements.ts +213 -0
- package/src/server/sharing/store.ts +46 -221
- package/src/server/telemetry.ts +1 -0
- package/src/server/telemetryIsWorkerSafe.spec.ts +54 -0
|
@@ -34,80 +34,36 @@
|
|
|
34
34
|
* cannot be filtered, and filtering is the whole point of the column.
|
|
35
35
|
*/
|
|
36
36
|
import type { Database, SQLQueryBindings } from "bun:sqlite";
|
|
37
|
-
import {
|
|
38
|
-
type ActivityEvent,
|
|
39
|
-
type ActivityEventInput,
|
|
40
|
-
type ActivityOutcome,
|
|
41
|
-
isActivityAction,
|
|
42
|
-
isActivityOutcome,
|
|
43
|
-
} from "cursedbelt-core/activity/model";
|
|
37
|
+
import type { ActivityEvent, ActivityEventInput } from "cursedbelt-core/activity/model";
|
|
44
38
|
import { runMigrations } from "../migration/runner.js";
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
/** ISO-8601, inclusive. */
|
|
74
|
-
since?: string;
|
|
75
|
-
/** ISO-8601, inclusive. */
|
|
76
|
-
until?: string;
|
|
77
|
-
/** Case-insensitive exact match on the account. */
|
|
78
|
-
actor?: string;
|
|
79
|
-
/** Exact, or a PREFIX when it ends in a dot: `"share."` matches `share.grant`. */
|
|
80
|
-
action?: string;
|
|
81
|
-
kind?: string;
|
|
82
|
-
resourceId?: string;
|
|
83
|
-
outcome?: ActivityOutcome;
|
|
84
|
-
/** Restrict to one contributor (`"log"`, `"share"`). */
|
|
85
|
-
source?: string;
|
|
86
|
-
/** Substring, over actor + action + note + resource id. */
|
|
87
|
-
q?: string;
|
|
88
|
-
/** Default 100, hard cap 1000. */
|
|
89
|
-
limit?: number;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export interface ActivityPage {
|
|
93
|
-
events: ActivityEvent[];
|
|
94
|
-
/** The limit actually applied, after defaulting and capping. */
|
|
95
|
-
limit: number;
|
|
96
|
-
/** There is at least one more row older than the last one returned. */
|
|
97
|
-
truncated: boolean;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** One dimension of "what is in this result", for the filter UI. */
|
|
101
|
-
export interface ActivityFacet {
|
|
102
|
-
value: string;
|
|
103
|
-
count: number;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface ActivityFacets {
|
|
107
|
-
actions: ActivityFacet[];
|
|
108
|
-
actors: ActivityFacet[];
|
|
109
|
-
kinds: ActivityFacet[];
|
|
110
|
-
}
|
|
39
|
+
import { ACTIVITY_MIGRATIONS } from "./migrations.js";
|
|
40
|
+
import {
|
|
41
|
+
ACTIVITY_SQL,
|
|
42
|
+
type ActivityFacet,
|
|
43
|
+
type ActivityFacets,
|
|
44
|
+
type ActivityPage,
|
|
45
|
+
type ActivityQuery,
|
|
46
|
+
type ActivitySource,
|
|
47
|
+
buildStream,
|
|
48
|
+
facetLimit,
|
|
49
|
+
MAX_FACET,
|
|
50
|
+
pageLimit,
|
|
51
|
+
prepareRecord,
|
|
52
|
+
type StreamRow,
|
|
53
|
+
toStreamEvent,
|
|
54
|
+
} from "./query.js";
|
|
55
|
+
|
|
56
|
+
// The contract and the SQL have no driver in them, so `./d1.ts` (which runs on `workerd`)
|
|
57
|
+
// shares them without reaching this file. Re-exported so `cursedbelt-server/activity` is
|
|
58
|
+
// unchanged.
|
|
59
|
+
export type {
|
|
60
|
+
ActivityBindable,
|
|
61
|
+
ActivityFacet,
|
|
62
|
+
ActivityFacets,
|
|
63
|
+
ActivityPage,
|
|
64
|
+
ActivityQuery,
|
|
65
|
+
ActivitySource,
|
|
66
|
+
} from "./query.js";
|
|
111
67
|
|
|
112
68
|
export interface ActivityLogOptions {
|
|
113
69
|
db: Database;
|
|
@@ -136,59 +92,6 @@ export interface ActivityLog {
|
|
|
136
92
|
sources(): string[];
|
|
137
93
|
}
|
|
138
94
|
|
|
139
|
-
interface StreamRow {
|
|
140
|
-
source: string;
|
|
141
|
-
seq: number;
|
|
142
|
-
at: string;
|
|
143
|
-
app: string;
|
|
144
|
-
actor: string;
|
|
145
|
-
action: string;
|
|
146
|
-
kind: string | null;
|
|
147
|
-
resource_id: string | null;
|
|
148
|
-
outcome: ActivityOutcome;
|
|
149
|
-
note: string;
|
|
150
|
-
data: string | null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const DEFAULT_LIMIT = 100;
|
|
154
|
-
const MAX_LIMIT = 1000;
|
|
155
|
-
const MAX_FACET = 25;
|
|
156
|
-
|
|
157
|
-
/** `%` and `_` are wildcards and `\` is the escape — a note containing any of
|
|
158
|
-
* them must still search literally. */
|
|
159
|
-
const likeContains = (value: string): string =>
|
|
160
|
-
`%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
161
|
-
|
|
162
|
-
const toEvent = (row: StreamRow): ActivityEvent => ({
|
|
163
|
-
id: `${row.source}:${row.seq}`,
|
|
164
|
-
source: row.source,
|
|
165
|
-
at: row.at,
|
|
166
|
-
app: row.app,
|
|
167
|
-
actor: row.actor,
|
|
168
|
-
action: row.action,
|
|
169
|
-
resource:
|
|
170
|
-
row.kind === null || row.resource_id === null
|
|
171
|
-
? null
|
|
172
|
-
: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
173
|
-
outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
|
|
174
|
-
note: row.note ?? "",
|
|
175
|
-
data: parseData(row.data),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
/** A row whose `data` is unreadable must not take the page down with it — the
|
|
179
|
-
* event still says who did what, which is the part that matters. */
|
|
180
|
-
function parseData(raw: string | null): Record<string, unknown> | null {
|
|
181
|
-
if (!raw) return null;
|
|
182
|
-
try {
|
|
183
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
184
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
185
|
-
? (parsed as Record<string, unknown>)
|
|
186
|
-
: { value: parsed };
|
|
187
|
-
} catch {
|
|
188
|
-
return { unparsed: raw };
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
95
|
export function createActivityLog(options: ActivityLogOptions): ActivityLog {
|
|
193
96
|
const { db, app } = options;
|
|
194
97
|
const now = options.now ?? (() => new Date().toISOString());
|
|
@@ -196,146 +99,37 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
|
|
|
196
99
|
|
|
197
100
|
runMigrations(db, ACTIVITY_MIGRATIONS);
|
|
198
101
|
|
|
199
|
-
const insert = db.query(
|
|
200
|
-
`INSERT INTO ${ACTIVITY_EVENTS_TABLE}
|
|
201
|
-
(at, app, actor, action, kind, resource_id, outcome, note, data)
|
|
202
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
203
|
-
);
|
|
102
|
+
const insert = db.query(ACTIVITY_SQL.insert);
|
|
204
103
|
|
|
205
|
-
const tableExists = (name: string): boolean =>
|
|
206
|
-
!!db
|
|
207
|
-
.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
208
|
-
.get(name);
|
|
104
|
+
const tableExists = (name: string): boolean => !!db.query(ACTIVITY_SQL.tableExists).get(name);
|
|
209
105
|
|
|
210
106
|
/** The contributors that can actually answer right now. */
|
|
211
107
|
const liveSources = (): ActivitySource[] =>
|
|
212
108
|
registered.filter((source) => !source.requiresTable || tableExists(source.requiresTable));
|
|
213
109
|
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
FROM ${ACTIVITY_EVENTS_TABLE}`;
|
|
217
|
-
|
|
218
|
-
/** The union, plus the WHERE the caller asked for, plus their parameters in
|
|
219
|
-
* bind order (sources first — they are inside the FROM). */
|
|
220
|
-
const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } => {
|
|
221
|
-
const params: SQLQueryBindings[] = [];
|
|
222
|
-
const selects = [OWN_SELECT];
|
|
223
|
-
for (const source of liveSources()) {
|
|
224
|
-
if (query.source && query.source !== source.name) continue;
|
|
225
|
-
selects.push(source.sql);
|
|
226
|
-
params.push(...(source.params ?? []));
|
|
227
|
-
}
|
|
228
|
-
const where: string[] = [];
|
|
229
|
-
if (query.source) {
|
|
230
|
-
where.push("source = ?");
|
|
231
|
-
params.push(query.source);
|
|
232
|
-
}
|
|
233
|
-
if (query.since) {
|
|
234
|
-
where.push("at >= ?");
|
|
235
|
-
params.push(query.since);
|
|
236
|
-
}
|
|
237
|
-
if (query.until) {
|
|
238
|
-
where.push("at <= ?");
|
|
239
|
-
params.push(query.until);
|
|
240
|
-
}
|
|
241
|
-
if (query.actor) {
|
|
242
|
-
where.push("LOWER(actor) = LOWER(?)");
|
|
243
|
-
params.push(query.actor);
|
|
244
|
-
}
|
|
245
|
-
if (query.action) {
|
|
246
|
-
if (query.action.endsWith(".")) {
|
|
247
|
-
where.push("action LIKE ? ESCAPE '\\'");
|
|
248
|
-
params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
|
|
249
|
-
} else {
|
|
250
|
-
where.push("action = ?");
|
|
251
|
-
params.push(query.action);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
if (query.kind) {
|
|
255
|
-
where.push("kind = ?");
|
|
256
|
-
params.push(query.kind);
|
|
257
|
-
}
|
|
258
|
-
if (query.resourceId) {
|
|
259
|
-
where.push("resource_id = ?");
|
|
260
|
-
params.push(query.resourceId);
|
|
261
|
-
}
|
|
262
|
-
if (query.outcome) {
|
|
263
|
-
where.push("outcome = ?");
|
|
264
|
-
params.push(query.outcome);
|
|
265
|
-
}
|
|
266
|
-
if (query.q) {
|
|
267
|
-
where.push(
|
|
268
|
-
"(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')",
|
|
269
|
-
);
|
|
270
|
-
const like = likeContains(query.q);
|
|
271
|
-
params.push(like, like, like, like);
|
|
272
|
-
}
|
|
273
|
-
// `query.source` naming a contributor that is not live leaves only the own
|
|
274
|
-
// select, filtered to a source it cannot be — an empty result, which is the
|
|
275
|
-
// honest answer rather than every row.
|
|
276
|
-
const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${
|
|
277
|
-
where.length ? ` WHERE ${where.join(" AND ")}` : ""
|
|
278
|
-
}`;
|
|
279
|
-
return { sql, params };
|
|
280
|
-
};
|
|
110
|
+
const stream = (query: ActivityQuery): { sql: string; params: SQLQueryBindings[] } =>
|
|
111
|
+
buildStream(query, liveSources());
|
|
281
112
|
|
|
282
113
|
return {
|
|
283
114
|
app,
|
|
284
115
|
|
|
285
116
|
record(input: ActivityEventInput): ActivityEvent {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
);
|
|
290
|
-
}
|
|
291
|
-
const actor = input.actor?.trim();
|
|
292
|
-
if (!actor) {
|
|
293
|
-
// An event that cannot say who is a log line, not an audit trail.
|
|
294
|
-
throw new Error(`activity: ${input.action} has no actor`);
|
|
295
|
-
}
|
|
296
|
-
const at = input.at ?? now();
|
|
297
|
-
const outcome = input.outcome ?? "ok";
|
|
298
|
-
const data = input.data ? JSON.stringify(input.data) : null;
|
|
299
|
-
const info = insert.run(
|
|
300
|
-
at,
|
|
301
|
-
app,
|
|
302
|
-
actor,
|
|
303
|
-
input.action,
|
|
304
|
-
input.resource?.kind ?? null,
|
|
305
|
-
input.resource?.id ?? null,
|
|
306
|
-
outcome,
|
|
307
|
-
input.note ?? "",
|
|
308
|
-
data,
|
|
309
|
-
);
|
|
310
|
-
return {
|
|
311
|
-
id: `log:${Number(info.lastInsertRowid)}`,
|
|
312
|
-
source: "log",
|
|
313
|
-
at,
|
|
314
|
-
app,
|
|
315
|
-
actor,
|
|
316
|
-
action: input.action,
|
|
317
|
-
resource: input.resource ? { app, ...input.resource } : null,
|
|
318
|
-
outcome,
|
|
319
|
-
note: input.note ?? "",
|
|
320
|
-
data: input.data ?? null,
|
|
321
|
-
};
|
|
117
|
+
const { binds, event } = prepareRecord(app, input, now);
|
|
118
|
+
const info = insert.run(...binds);
|
|
119
|
+
return { id: `log:${Number(info.lastInsertRowid)}`, ...event };
|
|
322
120
|
},
|
|
323
121
|
|
|
324
122
|
query(query: ActivityQuery = {}): ActivityPage {
|
|
325
|
-
const limit =
|
|
123
|
+
const limit = pageLimit(query.limit);
|
|
326
124
|
const { sql, params } = stream(query);
|
|
327
|
-
|
|
328
|
-
// guess made from `rows.length === limit`.
|
|
329
|
-
const rows = db
|
|
330
|
-
.query(`${sql} ORDER BY at DESC, seq DESC LIMIT ?`)
|
|
331
|
-
.all(...params, limit + 1) as StreamRow[];
|
|
125
|
+
const rows = db.query(ACTIVITY_SQL.page(sql)).all(...params, limit + 1) as StreamRow[];
|
|
332
126
|
const truncated = rows.length > limit;
|
|
333
|
-
return { events: rows.slice(0, limit).map(
|
|
127
|
+
return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
|
|
334
128
|
},
|
|
335
129
|
|
|
336
130
|
count(query: ActivityQuery = {}): number {
|
|
337
131
|
const { sql, params } = stream(query);
|
|
338
|
-
const row = db.query(
|
|
132
|
+
const row = db.query(ACTIVITY_SQL.count(sql)).get(...params) as {
|
|
339
133
|
n: number;
|
|
340
134
|
} | null;
|
|
341
135
|
return row?.n ?? 0;
|
|
@@ -343,17 +137,11 @@ export function createActivityLog(options: ActivityLogOptions): ActivityLog {
|
|
|
343
137
|
|
|
344
138
|
facets(query: ActivityQuery = {}, limit = MAX_FACET): ActivityFacets {
|
|
345
139
|
const { sql, params } = stream(query);
|
|
346
|
-
const capped =
|
|
347
|
-
const dimension = (column:
|
|
348
|
-
(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
`SELECT ${column} AS value, COUNT(*) AS count FROM (${sql})
|
|
352
|
-
WHERE ${column} IS NOT NULL AND ${column} <> ''
|
|
353
|
-
GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`,
|
|
354
|
-
)
|
|
355
|
-
.all(...params, capped) as { value: string; count: number }[]
|
|
356
|
-
).map((row) => ({ value: row.value, count: row.count }));
|
|
140
|
+
const capped = facetLimit(limit);
|
|
141
|
+
const dimension = (column: "action" | "actor" | "kind"): ActivityFacet[] =>
|
|
142
|
+
(db.query(ACTIVITY_SQL.facet(sql, column)).all(...params, capped) as { value: string; count: number }[]).map(
|
|
143
|
+
(row) => ({ value: row.value, count: row.count }),
|
|
144
|
+
);
|
|
357
145
|
return {
|
|
358
146
|
actions: dimension("action"),
|
|
359
147
|
actors: dimension("actor"),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite';
|
|
2
|
+
import { PLACE_COLUMNS } from './requestPlace.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* In-memory, batched writer for `request_metrics`. To keep request latency
|
|
@@ -30,8 +31,10 @@ export interface MetricRow {
|
|
|
30
31
|
city?: string | null;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// Every place column is a `MetricRow` field — a column declared in `requestPlace.ts` that this
|
|
35
|
+
// row cannot carry fails to compile here rather than inserting `undefined` for ever.
|
|
36
|
+
const _placeColumnsAreRowFields: readonly (keyof MetricRow)[] = PLACE_COLUMNS;
|
|
37
|
+
void _placeColumnsAreRowFields;
|
|
35
38
|
|
|
36
39
|
export interface MetricsBufferOptions {
|
|
37
40
|
db: Database;
|
|
@@ -33,6 +33,15 @@ export interface RequestPlace {
|
|
|
33
33
|
/** Place one request, or `null` when it cannot. Must not throw — but a throw is caught and read as `null`. */
|
|
34
34
|
export type RequestLocator = (request: Request) => RequestPlace | null;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The place columns, in insert order — the ONE declaration every reader and migration uses
|
|
38
|
+
* (`metricsBuffer` writes them, an app's reader allows them, an app's migration adds them).
|
|
39
|
+
* Exported through `cursedbelt-server/telemetry` since 4.29.0 (task 2141): until then it was
|
|
40
|
+
* private to `metricsBuffer.ts`, so flix, station and desk each declared their own copy, and a
|
|
41
|
+
* fourth column added here would have been written by the buffer and dropped by all three.
|
|
42
|
+
*/
|
|
43
|
+
export const PLACE_COLUMNS = ['country', 'region', 'city'] as const;
|
|
44
|
+
|
|
36
45
|
/** Each place field is capped so three of them can never crowd the 5120-byte Analytics Engine blob budget. */
|
|
37
46
|
export const PLACE_FIELD_MAX_CHARS = 96;
|
|
38
47
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The D1 notification service is the sync one over D1 — the same table, the same rows for the
|
|
3
|
+
* same notifications, the same routes minus the live stream (task 393, `apps/family`).
|
|
4
|
+
*
|
|
5
|
+
* Parity is proven at the STORE seam, which is the only thing that changed: cwip's own center
|
|
6
|
+
* drives cwip's sqlite store and the D1 store through one scenario, with one clock and one id
|
|
7
|
+
* sequence, and every result and every row is compared. It runs over the local driver and over
|
|
8
|
+
* the fake D1 binding, which refuses `undefined` binds exactly as D1 does.
|
|
9
|
+
*/
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import {
|
|
13
|
+
createNotificationCenter,
|
|
14
|
+
createSqliteNotificationStore,
|
|
15
|
+
type NotificationCenter,
|
|
16
|
+
type NotificationDb,
|
|
17
|
+
} from "cwip/notifications";
|
|
18
|
+
import { Hono } from "hono";
|
|
19
|
+
import { createFakeD1Binding } from "../d1/fakeD1.js";
|
|
20
|
+
import { createLocalD1 } from "../d1/local.js";
|
|
21
|
+
import { createRemoteD1 } from "../d1/remote.js";
|
|
22
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
23
|
+
import {
|
|
24
|
+
createD1NotificationService,
|
|
25
|
+
createD1NotificationStore,
|
|
26
|
+
NOTIFICATIONS_DDL,
|
|
27
|
+
notificationsDdl,
|
|
28
|
+
} from "./d1.js";
|
|
29
|
+
import { NOTIFICATION_MIGRATIONS } from "./migrations.js";
|
|
30
|
+
|
|
31
|
+
const DRIVERS: Record<string, (sqlite: Database) => D1LikeDatabase> = {
|
|
32
|
+
local: (sqlite) => createLocalD1(sqlite),
|
|
33
|
+
"remote (fake D1 binding)": (sqlite) => createRemoteD1(createFakeD1Binding(sqlite)),
|
|
34
|
+
};
|
|
35
|
+
const local = DRIVERS.local as (sqlite: Database) => D1LikeDatabase;
|
|
36
|
+
|
|
37
|
+
const withDdl = (ddl: readonly string[] = NOTIFICATIONS_DDL): Database => {
|
|
38
|
+
const sqlite = new Database(":memory:");
|
|
39
|
+
for (const statement of ddl) sqlite.run(statement);
|
|
40
|
+
return sqlite;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A clock that ticks 1 ms per call and a sequential id — identical for both centers. */
|
|
44
|
+
const seams = () => {
|
|
45
|
+
let t = 1_000;
|
|
46
|
+
let n = 0;
|
|
47
|
+
return { now: () => t++, newId: () => `n${++n}`, onError: (error: unknown) => { throw error; } };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const SCENARIO: ReadonlyArray<(c: NotificationCenter) => Promise<unknown>> = [
|
|
51
|
+
(c) => c.notify("u1", { type: "share.grant", title: "Shared with you", body: "Grandma", link: "/people/p1" }),
|
|
52
|
+
(c) => c.notify("u1", { type: "share.grant", title: "Two things", groupKey: "g1", severity: "warning" }),
|
|
53
|
+
(c) => c.notify("u1", { type: "share.grant", title: "Three things", groupKey: "g1" }),
|
|
54
|
+
(c) => c.notify("u2", { type: "auth.login", title: "New sign-in", source: "auth", actorId: "a1", actorName: "Ann", resourceType: "session", resourceId: "s1" }),
|
|
55
|
+
(c) => c.notify("u1", { type: "plain", title: "A later row" }),
|
|
56
|
+
(c) => c.list({ userId: "u1" }),
|
|
57
|
+
(c) => c.list({ userId: "u1", limit: 1 }),
|
|
58
|
+
(c) => c.list({ userId: "u1", limit: 1, offset: 1 }),
|
|
59
|
+
(c) => c.unreadCount("u1"),
|
|
60
|
+
(c) => c.get("u1", "n1"),
|
|
61
|
+
(c) => c.get("u2", "n1"),
|
|
62
|
+
(c) => c.markRead("u1", "n1", true),
|
|
63
|
+
(c) => c.markRead("u1", "n1", true),
|
|
64
|
+
(c) => c.list({ userId: "u1", filter: "unread" }),
|
|
65
|
+
(c) => c.markAllRead("u1"),
|
|
66
|
+
(c) => c.markRead("u1", "n1", false),
|
|
67
|
+
(c) => c.setDone("u1", "n2", true),
|
|
68
|
+
(c) => c.list({ userId: "u1", filter: "done" }),
|
|
69
|
+
(c) => c.notify("u1", { type: "share.grant", title: "g1 again, after done", groupKey: "g1" }),
|
|
70
|
+
(c) => c.doneAll("u2"),
|
|
71
|
+
(c) => c.remove("u1", "n4"),
|
|
72
|
+
(c) => c.remove("u1", "nothing"),
|
|
73
|
+
(c) => c.pruneDone(0),
|
|
74
|
+
(c) => c.list({ userId: "u2", filter: "all" }),
|
|
75
|
+
(c) => c.unreadCount("nobody"),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
describe("cursedbelt-server/notifications/d1", () => {
|
|
79
|
+
for (const [name, driver] of Object.entries(DRIVERS)) {
|
|
80
|
+
test(`🔴 parity over the ${name} driver: every result and every row match cwip's sqlite store`, async () => {
|
|
81
|
+
const syncDb = new Database(":memory:");
|
|
82
|
+
const sync = createNotificationCenter({
|
|
83
|
+
store: createSqliteNotificationStore(syncDb as unknown as NotificationDb),
|
|
84
|
+
...seams(),
|
|
85
|
+
});
|
|
86
|
+
const d1Sqlite = withDdl();
|
|
87
|
+
const d1 = createNotificationCenter({ store: createD1NotificationStore(driver(d1Sqlite)), ...seams() });
|
|
88
|
+
const results: unknown[] = [];
|
|
89
|
+
for (const [index, step] of SCENARIO.entries()) {
|
|
90
|
+
const expected = await step(sync);
|
|
91
|
+
const actual = await step(d1);
|
|
92
|
+
expect({ step: index, result: actual }).toEqual({ step: index, result: expected });
|
|
93
|
+
results.push(actual);
|
|
94
|
+
}
|
|
95
|
+
const table = (db: Database) => db.query("SELECT * FROM notifications ORDER BY rowid").all();
|
|
96
|
+
expect(table(d1Sqlite)).toEqual(table(syncDb));
|
|
97
|
+
// Not vacuous: a group coalesced to a count of 2, the prune removed a done row, and
|
|
98
|
+
// rows survived it.
|
|
99
|
+
expect(JSON.stringify(results)).toContain('"count":2');
|
|
100
|
+
expect(results[SCENARIO.length - 3]).toBeGreaterThan(0);
|
|
101
|
+
expect(table(d1Sqlite).length).toBeGreaterThan(1);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("the DDL builds exactly the table cwip's store and NOTIFICATION_MIGRATIONS build", () => {
|
|
106
|
+
const shape = (db: Database) =>
|
|
107
|
+
(
|
|
108
|
+
db
|
|
109
|
+
.query("SELECT type, name, sql FROM sqlite_master WHERE tbl_name = 'notifications' AND sql IS NOT NULL ORDER BY name")
|
|
110
|
+
.all() as { type: string; name: string; sql: string }[]
|
|
111
|
+
).map((r) => ({ ...r, sql: r.sql.replace(/\s+/g, " ").replace("IF NOT EXISTS ", "").trim() }));
|
|
112
|
+
const viaStore = new Database(":memory:");
|
|
113
|
+
createSqliteNotificationStore(viaStore as unknown as NotificationDb);
|
|
114
|
+
const viaMigration = new Database(":memory:");
|
|
115
|
+
for (const migration of NOTIFICATION_MIGRATIONS) migration.up(viaMigration);
|
|
116
|
+
expect(shape(withDdl())).toEqual(shape(viaStore));
|
|
117
|
+
expect(shape(withDdl())).toEqual(shape(viaMigration));
|
|
118
|
+
expect(shape(viaStore).length).toBe(3);
|
|
119
|
+
// A renamed table carries its name into both indexes.
|
|
120
|
+
expect(notificationsDdl("inbox").join("\n")).toContain("idx_inbox_group ON inbox(");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("🔴 a table name is an identifier or it is refused — it is interpolated into SQL", () => {
|
|
124
|
+
expect(() => notificationsDdl("notifications; DROP TABLE x")).toThrow(/invalid table identifier/);
|
|
125
|
+
expect(() => createD1NotificationStore(local(withDdl()), { table: "a b" })).toThrow(/invalid table identifier/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("🔴 coalescing is enforced by the index on D1 too — a second open row in a group is refused", async () => {
|
|
129
|
+
const store = createD1NotificationStore(local(withDdl()));
|
|
130
|
+
const row = (id: string) => ({
|
|
131
|
+
id,
|
|
132
|
+
userId: "u1",
|
|
133
|
+
createdAt: 0,
|
|
134
|
+
updatedAt: 0,
|
|
135
|
+
type: "t",
|
|
136
|
+
severity: "info" as const,
|
|
137
|
+
title: "x",
|
|
138
|
+
groupKey: "g",
|
|
139
|
+
count: 1,
|
|
140
|
+
read: false,
|
|
141
|
+
done: false,
|
|
142
|
+
});
|
|
143
|
+
await store.insert(row("a"));
|
|
144
|
+
await expect(store.insert(row("b"))).rejects.toThrow(/UNIQUE constraint/);
|
|
145
|
+
expect(await store.unreadCount("u1")).toBe(1);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("🔴 runs no DDL: on a database without the table it creates nothing and fails loudly", async () => {
|
|
149
|
+
const sqlite = new Database(":memory:");
|
|
150
|
+
const service = createD1NotificationService({ db: local(sqlite) });
|
|
151
|
+
expect(sqlite.query("SELECT COUNT(*) AS n FROM sqlite_master").get()).toEqual({ n: 0 });
|
|
152
|
+
await expect(service.store.unreadCount("u1")).rejects.toThrow(/no such table/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("the service mounts the inbox routes, user-scoped, with no hub and no stream route", async () => {
|
|
156
|
+
let who: string | null = "u1";
|
|
157
|
+
const service = createD1NotificationService({ db: local(withDdl()), onError: () => {} });
|
|
158
|
+
expect(service.hub).toBeNull();
|
|
159
|
+
const app = new Hono();
|
|
160
|
+
service.mount(app, { authorize: () => who });
|
|
161
|
+
await service.center.notify("u1", { type: "share.grant", title: "Shared with you", link: "/people/p1" });
|
|
162
|
+
await service.center.notify("u2", { type: "share.grant", title: "Not yours" });
|
|
163
|
+
|
|
164
|
+
const list = (await (await app.request("/api/notifications")).json()) as { items: { id: string; title: string }[] };
|
|
165
|
+
expect(list.items.map((n) => n.title)).toEqual(["Shared with you"]);
|
|
166
|
+
expect(await (await app.request("/api/notifications/unread-count")).json()).toEqual({ count: 1 });
|
|
167
|
+
const id = list.items[0]?.id as string;
|
|
168
|
+
expect(await (await app.request(`/api/notifications/${id}/read`, { method: "POST" })).json()).toEqual({ changed: true });
|
|
169
|
+
expect(await (await app.request("/api/notifications/unread-count")).json()).toEqual({ count: 0 });
|
|
170
|
+
// u2's row cannot be reached by u1's session, even by id.
|
|
171
|
+
const u2 = (await service.center.list({ userId: "u2" })).items[0]?.id as string;
|
|
172
|
+
expect((await app.request(`/api/notifications/${u2}`)).status).toBe(404);
|
|
173
|
+
// No hub, so no stream: the client's poll transport falls back from the 404.
|
|
174
|
+
expect((await app.request("/api/notifications/stream")).status).not.toBe(200);
|
|
175
|
+
who = null;
|
|
176
|
+
expect((await app.request("/api/notifications")).status).toBe(401);
|
|
177
|
+
service.close();
|
|
178
|
+
});
|
|
179
|
+
});
|