cursedbelt-server 4.26.1 → 4.28.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/engagement/d1.d.ts +67 -0
- package/dist/server/engagement/d1.js +81 -0
- package/dist/server/engagement/manifestPlaces.d.ts +9 -0
- package/dist/server/engagement/manifestPlaces.js +17 -0
- package/dist/server/engagement/places.js +2 -18
- 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/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/docs/activity.md +7 -0
- package/docs/engagement.md +6 -0
- package/docs/notifications.md +9 -0
- package/package.json +25 -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/engagement/d1.spec.ts +102 -0
- package/src/server/engagement/d1.ts +141 -0
- package/src/server/engagement/manifestPlaces.ts +24 -0
- package/src/server/engagement/places.ts +2 -16
- 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/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/telemetryIsWorkerSafe.spec.ts +64 -0
|
@@ -1,170 +1,39 @@
|
|
|
1
|
-
import { isActivityAction, isActivityOutcome, } from "cursedbelt-core/activity/model";
|
|
2
1
|
import { runMigrations } from "../migration/runner.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const MAX_LIMIT = 1000;
|
|
6
|
-
const MAX_FACET = 25;
|
|
7
|
-
/** `%` and `_` are wildcards and `\` is the escape — a note containing any of
|
|
8
|
-
* them must still search literally. */
|
|
9
|
-
const likeContains = (value) => `%${value.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
|
|
10
|
-
const toEvent = (row) => ({
|
|
11
|
-
id: `${row.source}:${row.seq}`,
|
|
12
|
-
source: row.source,
|
|
13
|
-
at: row.at,
|
|
14
|
-
app: row.app,
|
|
15
|
-
actor: row.actor,
|
|
16
|
-
action: row.action,
|
|
17
|
-
resource: row.kind === null || row.resource_id === null
|
|
18
|
-
? null
|
|
19
|
-
: { app: row.app, kind: row.kind, id: row.resource_id },
|
|
20
|
-
outcome: isActivityOutcome(row.outcome) ? row.outcome : "ok",
|
|
21
|
-
note: row.note ?? "",
|
|
22
|
-
data: parseData(row.data),
|
|
23
|
-
});
|
|
24
|
-
/** A row whose `data` is unreadable must not take the page down with it — the
|
|
25
|
-
* event still says who did what, which is the part that matters. */
|
|
26
|
-
function parseData(raw) {
|
|
27
|
-
if (!raw)
|
|
28
|
-
return null;
|
|
29
|
-
try {
|
|
30
|
-
const parsed = JSON.parse(raw);
|
|
31
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
32
|
-
? parsed
|
|
33
|
-
: { value: parsed };
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
return { unparsed: raw };
|
|
37
|
-
}
|
|
38
|
-
}
|
|
2
|
+
import { ACTIVITY_MIGRATIONS } from "./migrations.js";
|
|
3
|
+
import { ACTIVITY_SQL, buildStream, facetLimit, MAX_FACET, pageLimit, prepareRecord, toStreamEvent, } from "./query.js";
|
|
39
4
|
export function createActivityLog(options) {
|
|
40
5
|
const { db, app } = options;
|
|
41
6
|
const now = options.now ?? (() => new Date().toISOString());
|
|
42
7
|
const registered = options.sources ?? [];
|
|
43
8
|
runMigrations(db, ACTIVITY_MIGRATIONS);
|
|
44
|
-
const insert = db.query(
|
|
45
|
-
|
|
46
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
47
|
-
const tableExists = (name) => !!db
|
|
48
|
-
.query("SELECT 1 AS ok FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
49
|
-
.get(name);
|
|
9
|
+
const insert = db.query(ACTIVITY_SQL.insert);
|
|
10
|
+
const tableExists = (name) => !!db.query(ACTIVITY_SQL.tableExists).get(name);
|
|
50
11
|
/** The contributors that can actually answer right now. */
|
|
51
12
|
const liveSources = () => registered.filter((source) => !source.requiresTable || tableExists(source.requiresTable));
|
|
52
|
-
const
|
|
53
|
-
kind, resource_id, outcome, note, data
|
|
54
|
-
FROM ${ACTIVITY_EVENTS_TABLE}`;
|
|
55
|
-
/** The union, plus the WHERE the caller asked for, plus their parameters in
|
|
56
|
-
* bind order (sources first — they are inside the FROM). */
|
|
57
|
-
const stream = (query) => {
|
|
58
|
-
const params = [];
|
|
59
|
-
const selects = [OWN_SELECT];
|
|
60
|
-
for (const source of liveSources()) {
|
|
61
|
-
if (query.source && query.source !== source.name)
|
|
62
|
-
continue;
|
|
63
|
-
selects.push(source.sql);
|
|
64
|
-
params.push(...(source.params ?? []));
|
|
65
|
-
}
|
|
66
|
-
const where = [];
|
|
67
|
-
if (query.source) {
|
|
68
|
-
where.push("source = ?");
|
|
69
|
-
params.push(query.source);
|
|
70
|
-
}
|
|
71
|
-
if (query.since) {
|
|
72
|
-
where.push("at >= ?");
|
|
73
|
-
params.push(query.since);
|
|
74
|
-
}
|
|
75
|
-
if (query.until) {
|
|
76
|
-
where.push("at <= ?");
|
|
77
|
-
params.push(query.until);
|
|
78
|
-
}
|
|
79
|
-
if (query.actor) {
|
|
80
|
-
where.push("LOWER(actor) = LOWER(?)");
|
|
81
|
-
params.push(query.actor);
|
|
82
|
-
}
|
|
83
|
-
if (query.action) {
|
|
84
|
-
if (query.action.endsWith(".")) {
|
|
85
|
-
where.push("action LIKE ? ESCAPE '\\'");
|
|
86
|
-
params.push(`${query.action.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
where.push("action = ?");
|
|
90
|
-
params.push(query.action);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (query.kind) {
|
|
94
|
-
where.push("kind = ?");
|
|
95
|
-
params.push(query.kind);
|
|
96
|
-
}
|
|
97
|
-
if (query.resourceId) {
|
|
98
|
-
where.push("resource_id = ?");
|
|
99
|
-
params.push(query.resourceId);
|
|
100
|
-
}
|
|
101
|
-
if (query.outcome) {
|
|
102
|
-
where.push("outcome = ?");
|
|
103
|
-
params.push(query.outcome);
|
|
104
|
-
}
|
|
105
|
-
if (query.q) {
|
|
106
|
-
where.push("(actor LIKE ? ESCAPE '\\' OR action LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\' OR IFNULL(resource_id, '') LIKE ? ESCAPE '\\')");
|
|
107
|
-
const like = likeContains(query.q);
|
|
108
|
-
params.push(like, like, like, like);
|
|
109
|
-
}
|
|
110
|
-
// `query.source` naming a contributor that is not live leaves only the own
|
|
111
|
-
// select, filtered to a source it cannot be — an empty result, which is the
|
|
112
|
-
// honest answer rather than every row.
|
|
113
|
-
const sql = `SELECT * FROM (${selects.join("\n UNION ALL\n")}) AS stream${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`;
|
|
114
|
-
return { sql, params };
|
|
115
|
-
};
|
|
13
|
+
const stream = (query) => buildStream(query, liveSources());
|
|
116
14
|
return {
|
|
117
15
|
app,
|
|
118
16
|
record(input) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
const actor = input.actor?.trim();
|
|
123
|
-
if (!actor) {
|
|
124
|
-
// An event that cannot say who is a log line, not an audit trail.
|
|
125
|
-
throw new Error(`activity: ${input.action} has no actor`);
|
|
126
|
-
}
|
|
127
|
-
const at = input.at ?? now();
|
|
128
|
-
const outcome = input.outcome ?? "ok";
|
|
129
|
-
const data = input.data ? JSON.stringify(input.data) : null;
|
|
130
|
-
const info = insert.run(at, app, actor, input.action, input.resource?.kind ?? null, input.resource?.id ?? null, outcome, input.note ?? "", data);
|
|
131
|
-
return {
|
|
132
|
-
id: `log:${Number(info.lastInsertRowid)}`,
|
|
133
|
-
source: "log",
|
|
134
|
-
at,
|
|
135
|
-
app,
|
|
136
|
-
actor,
|
|
137
|
-
action: input.action,
|
|
138
|
-
resource: input.resource ? { app, ...input.resource } : null,
|
|
139
|
-
outcome,
|
|
140
|
-
note: input.note ?? "",
|
|
141
|
-
data: input.data ?? null,
|
|
142
|
-
};
|
|
17
|
+
const { binds, event } = prepareRecord(app, input, now);
|
|
18
|
+
const info = insert.run(...binds);
|
|
19
|
+
return { id: `log:${Number(info.lastInsertRowid)}`, ...event };
|
|
143
20
|
},
|
|
144
21
|
query(query = {}) {
|
|
145
|
-
const limit =
|
|
22
|
+
const limit = pageLimit(query.limit);
|
|
146
23
|
const { sql, params } = stream(query);
|
|
147
|
-
|
|
148
|
-
// guess made from `rows.length === limit`.
|
|
149
|
-
const rows = db
|
|
150
|
-
.query(`${sql} ORDER BY at DESC, seq DESC LIMIT ?`)
|
|
151
|
-
.all(...params, limit + 1);
|
|
24
|
+
const rows = db.query(ACTIVITY_SQL.page(sql)).all(...params, limit + 1);
|
|
152
25
|
const truncated = rows.length > limit;
|
|
153
|
-
return { events: rows.slice(0, limit).map(
|
|
26
|
+
return { events: rows.slice(0, limit).map(toStreamEvent), limit, truncated };
|
|
154
27
|
},
|
|
155
28
|
count(query = {}) {
|
|
156
29
|
const { sql, params } = stream(query);
|
|
157
|
-
const row = db.query(
|
|
30
|
+
const row = db.query(ACTIVITY_SQL.count(sql)).get(...params);
|
|
158
31
|
return row?.n ?? 0;
|
|
159
32
|
},
|
|
160
33
|
facets(query = {}, limit = MAX_FACET) {
|
|
161
34
|
const { sql, params } = stream(query);
|
|
162
|
-
const capped =
|
|
163
|
-
const dimension = (column) => db
|
|
164
|
-
.query(`SELECT ${column} AS value, COUNT(*) AS count FROM (${sql})
|
|
165
|
-
WHERE ${column} IS NOT NULL AND ${column} <> ''
|
|
166
|
-
GROUP BY ${column} ORDER BY count DESC, value ASC LIMIT ?`)
|
|
167
|
-
.all(...params, capped).map((row) => ({ value: row.value, count: row.count }));
|
|
35
|
+
const capped = facetLimit(limit);
|
|
36
|
+
const dimension = (column) => db.query(ACTIVITY_SQL.facet(sql, column)).all(...params, capped).map((row) => ({ value: row.value, count: row.count }));
|
|
168
37
|
return {
|
|
169
38
|
actions: dimension("action"),
|
|
170
39
|
actors: dimension("actor"),
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/engagement/d1` — the engagement recorder over D1, for an app a Worker serves.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it exists ───────────────────────────────────────────────────────────
|
|
5
|
+
* `./engagement` is Bun-only: its store is a synchronous `bun:sqlite` file (`engagement.sqlite`)
|
|
6
|
+
* that station's Metrics page reads off disk. A Worker has neither, so an app that moved to one
|
|
7
|
+
* stopped being heard with no error anywhere — `collections` from its 2026-09-22 cutover until
|
|
8
|
+
* this subpath (task 2096). The SAME three tables live in the app's own D1 instead (its
|
|
9
|
+
* `db/schema.sql`, from {@link ENGAGEMENT_DDL}), written with the SAME fold (`./policy.ts` →
|
|
10
|
+
* `foldView`, `dayKey`, `resolvePlace`), and station reads them from D1 for any Worker-served app.
|
|
11
|
+
*
|
|
12
|
+
* It was `apps/music/src/server/engagementD1.ts` plus a copied `kit/engagement/policy.ts` held
|
|
13
|
+
* byte-identical by a parity test (task 2081). collections was the second consumer, so it was
|
|
14
|
+
* lifted here rather than copied again — and the policy copy went with it: this module imports
|
|
15
|
+
* the library's own `policy.ts`, so there is one privacy rule, not one per Worker.
|
|
16
|
+
*
|
|
17
|
+
* 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
|
|
18
|
+
* (`telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./store.js` or `./api.js` here.
|
|
19
|
+
*
|
|
20
|
+
* 🔴 The kit's privacy story holds unchanged: the place is resolved against the app's own route
|
|
21
|
+
* manifest AT THE DOOR, so an id, an album name or a search string never reaches a row; the
|
|
22
|
+
* account is the session's subject, never anything in the body. Mount it INSIDE the session gate.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 {@link ENGAGEMENT_DDL} must stay byte-identical in effect to `EngagementStore`'s `migrate()` —
|
|
25
|
+
* station reads both shapes with one query set. `d1.spec.ts` builds both and compares their
|
|
26
|
+
* `sqlite_master` rows, so a column added to one and not the other is a red.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { createD1EngagementRecorder, placesFromManifest } from "cursedbelt-server/engagement/d1";
|
|
30
|
+
* import routesManifest from "../routes.manifest.json";
|
|
31
|
+
*
|
|
32
|
+
* gated.route("/api/engagement", createD1EngagementRecorder({
|
|
33
|
+
* db, places: placesFromManifest(routesManifest), resolveUser: (c) => sso.userOf(c)?.id ?? null,
|
|
34
|
+
* }));
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
38
|
+
import type { Context } from "hono";
|
|
39
|
+
import { Hono } from "hono";
|
|
40
|
+
import { type PlaceRef } from "./policy.js";
|
|
41
|
+
/** The kit store's own tables and indexes, as one-line DDL — see the header. */
|
|
42
|
+
export declare const ENGAGEMENT_DDL: readonly string[];
|
|
43
|
+
/**
|
|
44
|
+
* Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
|
|
45
|
+
* account's previous view) and ONE batch of up to five statements, so a view is atomic and
|
|
46
|
+
* costs two D1 round trips however much it credits.
|
|
47
|
+
*/
|
|
48
|
+
export declare function recordViewD1(db: D1LikeDatabase, input: {
|
|
49
|
+
userKey: string;
|
|
50
|
+
place: string;
|
|
51
|
+
ts: number;
|
|
52
|
+
}): Promise<{
|
|
53
|
+
newSession: boolean;
|
|
54
|
+
activeMs: number;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
|
|
58
|
+
* `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
|
|
59
|
+
* none on the Mac; station reads the tables directly.
|
|
60
|
+
*/
|
|
61
|
+
export declare function createD1EngagementRecorder(options: {
|
|
62
|
+
db: D1LikeDatabase;
|
|
63
|
+
places: readonly PlaceRef[];
|
|
64
|
+
resolveUser: (c: Context) => string | null;
|
|
65
|
+
now?: () => number;
|
|
66
|
+
}): Hono;
|
|
67
|
+
export { placesFromManifest } from "./manifestPlaces.js";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { dayKey, foldView, OTHER_PLACE, resolvePlace } from "./policy.js";
|
|
3
|
+
/** The kit store's own tables and indexes, as one-line DDL — see the header. */
|
|
4
|
+
export const ENGAGEMENT_DDL = [
|
|
5
|
+
"CREATE TABLE IF NOT EXISTS engagement_days ( user_key TEXT NOT NULL, place TEXT NOT NULL, day TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place, day) )",
|
|
6
|
+
"CREATE INDEX IF NOT EXISTS idx_engagement_days_day ON engagement_days (day)",
|
|
7
|
+
"CREATE TABLE IF NOT EXISTS engagement_places ( user_key TEXT NOT NULL, place TEXT NOT NULL, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, PRIMARY KEY (user_key, place) )",
|
|
8
|
+
"CREATE INDEX IF NOT EXISTS idx_engagement_places_last ON engagement_places (last_ts)",
|
|
9
|
+
"CREATE TABLE IF NOT EXISTS engagement_users ( user_key TEXT PRIMARY KEY, views INTEGER NOT NULL DEFAULT 0, sessions INTEGER NOT NULL DEFAULT 0, active_ms INTEGER NOT NULL DEFAULT 0, first_ts INTEGER NOT NULL, last_ts INTEGER NOT NULL, last_place TEXT NOT NULL DEFAULT '' )",
|
|
10
|
+
];
|
|
11
|
+
/**
|
|
12
|
+
* Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
|
|
13
|
+
* account's previous view) and ONE batch of up to five statements, so a view is atomic and
|
|
14
|
+
* costs two D1 round trips however much it credits.
|
|
15
|
+
*/
|
|
16
|
+
export async function recordViewD1(db, input) {
|
|
17
|
+
const { userKey, place, ts } = input;
|
|
18
|
+
const prior = await db
|
|
19
|
+
.prepare("SELECT last_ts, last_place FROM engagement_users WHERE user_key = ?")
|
|
20
|
+
.bind(userKey)
|
|
21
|
+
.first();
|
|
22
|
+
const fold = foldView(prior ? prior.last_ts : null, ts);
|
|
23
|
+
const sessionDelta = fold.newSession ? 1 : 0;
|
|
24
|
+
const statements = [];
|
|
25
|
+
if (fold.activeMs > 0 && prior?.last_place) {
|
|
26
|
+
// The time since the last view is credited to the place the account was LOOKING AT.
|
|
27
|
+
statements.push(db
|
|
28
|
+
.prepare("UPDATE engagement_days SET active_ms = active_ms + ? WHERE user_key = ? AND place = ? AND day = ?")
|
|
29
|
+
.bind(fold.activeMs, userKey, prior.last_place, dayKey(prior.last_ts)), db
|
|
30
|
+
.prepare("UPDATE engagement_places SET active_ms = active_ms + ? WHERE user_key = ? AND place = ?")
|
|
31
|
+
.bind(fold.activeMs, userKey, prior.last_place));
|
|
32
|
+
}
|
|
33
|
+
statements.push(db
|
|
34
|
+
.prepare(`INSERT INTO engagement_days (user_key, place, day, views, active_ms, sessions, first_ts, last_ts)
|
|
35
|
+
VALUES (?, ?, ?, 1, 0, ?, ?, ?)
|
|
36
|
+
ON CONFLICT(user_key, place, day) DO UPDATE SET
|
|
37
|
+
views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`)
|
|
38
|
+
.bind(userKey, place, dayKey(ts), sessionDelta, ts, ts), db
|
|
39
|
+
.prepare(`INSERT INTO engagement_places (user_key, place, views, sessions, active_ms, first_ts, last_ts)
|
|
40
|
+
VALUES (?, ?, 1, ?, 0, ?, ?)
|
|
41
|
+
ON CONFLICT(user_key, place) DO UPDATE SET
|
|
42
|
+
views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`)
|
|
43
|
+
.bind(userKey, place, sessionDelta, ts, ts), db
|
|
44
|
+
.prepare(`INSERT INTO engagement_users (user_key, views, sessions, active_ms, first_ts, last_ts, last_place)
|
|
45
|
+
VALUES (?, 1, ?, 0, ?, ?, ?)
|
|
46
|
+
ON CONFLICT(user_key) DO UPDATE SET
|
|
47
|
+
views = views + 1, sessions = sessions + excluded.sessions, active_ms = active_ms + ?,
|
|
48
|
+
last_ts = excluded.last_ts, last_place = excluded.last_place`)
|
|
49
|
+
.bind(userKey, sessionDelta, ts, ts, place, fold.activeMs));
|
|
50
|
+
await db.batch(statements);
|
|
51
|
+
return fold;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
|
|
55
|
+
* `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
|
|
56
|
+
* none on the Mac; station reads the tables directly.
|
|
57
|
+
*/
|
|
58
|
+
export function createD1EngagementRecorder(options) {
|
|
59
|
+
const app = new Hono();
|
|
60
|
+
const now = options.now ?? (() => Date.now());
|
|
61
|
+
app.post("/view", async (c) => {
|
|
62
|
+
const userKey = options.resolveUser(c);
|
|
63
|
+
if (!userKey)
|
|
64
|
+
return c.json({ error: "unauthenticated" }, 401);
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = await c.req.json();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return c.json({ error: "expected a json body" }, 400);
|
|
71
|
+
}
|
|
72
|
+
const claimed = body?.place;
|
|
73
|
+
if (typeof claimed !== "string")
|
|
74
|
+
return c.json({ error: "place must be a string" }, 400);
|
|
75
|
+
const place = resolvePlace(claimed, options.places);
|
|
76
|
+
const fold = await recordViewD1(options.db, { userKey, place, ts: now() });
|
|
77
|
+
return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
|
|
78
|
+
});
|
|
79
|
+
return app;
|
|
80
|
+
}
|
|
81
|
+
export { placesFromManifest } from "./manifestPlaces.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A parsed `routes.manifest.json` → the place allowlist. Pure, and Worker-safe: `places.ts`
|
|
3
|
+
* reads the file off disk for a Bun server, and a Worker hands over the JSON it bundles
|
|
4
|
+
* (`./d1.ts`). ONE rule for both — until task 2096 music's Worker carried its own copy of it.
|
|
5
|
+
*
|
|
6
|
+
* Anything it cannot read is `[]`, never a throw: see `places.ts`, "Missing is EMPTY".
|
|
7
|
+
*/
|
|
8
|
+
import type { PlaceRef } from "./policy.js";
|
|
9
|
+
export declare function placesFromManifest(doc: unknown): PlaceRef[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function placesFromManifest(doc) {
|
|
2
|
+
const routes = Array.isArray(doc) ? doc : doc?.routes;
|
|
3
|
+
if (!Array.isArray(routes))
|
|
4
|
+
return [];
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
const places = [];
|
|
7
|
+
for (const raw of routes) {
|
|
8
|
+
const r = raw;
|
|
9
|
+
if (!r || typeof r.id !== "string" || !r.id || seen.has(r.id))
|
|
10
|
+
continue;
|
|
11
|
+
seen.add(r.id);
|
|
12
|
+
places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
|
|
13
|
+
}
|
|
14
|
+
// Sorted so the allowlist a board renders is stable between deployments.
|
|
15
|
+
places.sort((a, b) => a.id.localeCompare(b.id));
|
|
16
|
+
return places;
|
|
17
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
26
26
|
import { join } from "node:path";
|
|
27
|
+
import { placesFromManifest } from "./manifestPlaces.js";
|
|
27
28
|
/** Where the manifest sits, in a source tree and in a built artifact alike. */
|
|
28
29
|
const MANIFEST = "routes.manifest.json";
|
|
29
30
|
/**
|
|
@@ -38,24 +39,7 @@ export function engagementPlacesFor(dir) {
|
|
|
38
39
|
if (!existsSync(path))
|
|
39
40
|
return [];
|
|
40
41
|
try {
|
|
41
|
-
|
|
42
|
-
const routes = Array.isArray(doc)
|
|
43
|
-
? doc
|
|
44
|
-
: doc.routes;
|
|
45
|
-
if (!Array.isArray(routes))
|
|
46
|
-
return [];
|
|
47
|
-
const seen = new Set();
|
|
48
|
-
const places = [];
|
|
49
|
-
for (const raw of routes) {
|
|
50
|
-
const r = raw;
|
|
51
|
-
if (typeof r.id !== "string" || !r.id || seen.has(r.id))
|
|
52
|
-
continue;
|
|
53
|
-
seen.add(r.id);
|
|
54
|
-
places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
|
|
55
|
-
}
|
|
56
|
-
// Sorted so the allowlist a board renders is stable between deployments.
|
|
57
|
-
places.sort((a, b) => a.id.localeCompare(b.id));
|
|
58
|
-
return places;
|
|
42
|
+
return placesFromManifest(JSON.parse(readFileSync(path, "utf8")));
|
|
59
43
|
}
|
|
60
44
|
catch {
|
|
61
45
|
return [];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/notifications/d1` — the notification service over D1, for an app a Worker
|
|
3
|
+
* serves.
|
|
4
|
+
*
|
|
5
|
+
* ── Why it exists ───────────────────────────────────────────────────────────
|
|
6
|
+
* `createNotificationService` builds cwip's `createSqliteNotificationStore` over a synchronous
|
|
7
|
+
* `bun:sqlite` handle; a Worker has neither. `apps/family`'s port to a Worker + D1 (task 393)
|
|
8
|
+
* needs the SAME inbox — the same table, the same coalescing, the same routes — so this is
|
|
9
|
+
* cwip's `NotificationStore` contract implemented over D1, handed to cwip's own
|
|
10
|
+
* `createNotificationCenter`, and mounted by the same `mountNotificationRoutes`. The engine
|
|
11
|
+
* stays cwip's; only the storage changes.
|
|
12
|
+
*
|
|
13
|
+
* ── What is the same, and what is not ─────────────────────────────────────
|
|
14
|
+
* · The table is `createSqliteNotificationStore`'s, column for column and index for index
|
|
15
|
+
* ({@link notificationsDdl}; `./d1.spec.ts` compares the two schemas), so coalescing is
|
|
16
|
+
* still the partial unique index and never only a read.
|
|
17
|
+
* · Every store method runs cwip's statement, bound identically; `./d1.spec.ts` drives both
|
|
18
|
+
* stores through one scenario and compares every result and every row.
|
|
19
|
+
* · 🔴 `hub` is always `null` and `{base}/stream` is never mounted. Live delivery is an
|
|
20
|
+
* in-memory set of open streams, and a Worker isolate cannot share one with the isolate
|
|
21
|
+
* that produced the notification — a stream route that answered 200 and never emitted
|
|
22
|
+
* would be worse than the 404 the client already falls back from. The poll transport is
|
|
23
|
+
* the delivery story here, exactly as it is for `live: false` on the Mac.
|
|
24
|
+
* · 🔴 No DDL runs here, ever. The sync store migrates on construction; on D1 the table is
|
|
25
|
+
* the app's `db/schema.sql`, built from {@link notificationsDdl} (or from an in-memory run
|
|
26
|
+
* of `NOTIFICATION_MIGRATIONS` — the two converge, and the spec proves it).
|
|
27
|
+
*
|
|
28
|
+
* 🔴 Worker-safe: nothing reachable from here at runtime imports `bun:*`
|
|
29
|
+
* (`../telemetryIsWorkerSafe.spec.ts` walks it). Do not import `./service.js`,
|
|
30
|
+
* `./migrations.js` or `./index.js` here.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* import { createD1NotificationService } from "cursedbelt-server/notifications/d1";
|
|
34
|
+
*
|
|
35
|
+
* const notifications = createD1NotificationService({ db: createRemoteD1(env.DB) });
|
|
36
|
+
* notifications.mount(app, { authorize: (c) => sso.userOf(c)?.id ?? null });
|
|
37
|
+
* await notifications.center.notify(userId, { type: "share.grant", title: "Shared with you" });
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
import { type NotificationStore } from "cwip/notifications";
|
|
41
|
+
import type { D1LikeDatabase } from "../d1/types.js";
|
|
42
|
+
import type { NotificationService } from "./types.js";
|
|
43
|
+
export type { NotificationService, NotificationServiceMountOptions } from "./types.js";
|
|
44
|
+
/** The default table — the name `createSqliteNotificationStore` and `NOTIFICATION_MIGRATIONS` use. */
|
|
45
|
+
export declare const D1_NOTIFICATIONS_TABLE = "notifications";
|
|
46
|
+
/**
|
|
47
|
+
* The DDL a D1 schema needs for the notification table — the FINAL shape
|
|
48
|
+
* `createSqliteNotificationStore` migrates any database to (its later `source`/`severity`
|
|
49
|
+
* columns are in the CREATE, as they are in cwip's), with both indexes. Idempotent.
|
|
50
|
+
*/
|
|
51
|
+
export declare function notificationsDdl(table?: string): string[];
|
|
52
|
+
/** {@link notificationsDdl} for the default table. */
|
|
53
|
+
export declare const NOTIFICATIONS_DDL: readonly string[];
|
|
54
|
+
export interface D1NotificationStoreOptions {
|
|
55
|
+
/** Table name override (identifier-validated). Default `notifications`. */
|
|
56
|
+
table?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* cwip's `NotificationStore` over D1 — the same statements as `createSqliteNotificationStore`,
|
|
60
|
+
* awaited. Every method is user-scoped, as the contract requires. Runs no DDL.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createD1NotificationStore(db: D1LikeDatabase, options?: D1NotificationStoreOptions): NotificationStore;
|
|
63
|
+
export interface CreateD1NotificationServiceOptions {
|
|
64
|
+
/** The app's D1 handle — `createRemoteD1(env.DB)` on a Worker, `createLocalD1(sqlite)` in a test. */
|
|
65
|
+
db: D1LikeDatabase;
|
|
66
|
+
/** Table name override (identifier-validated). */
|
|
67
|
+
table?: string;
|
|
68
|
+
/** Resolve a role audience (`notifyAudience({ role })`) to user ids. */
|
|
69
|
+
resolveRole?: (role: string) => Promise<string[]>;
|
|
70
|
+
/** Producer-path failures land here. Defaults to stderr. */
|
|
71
|
+
onError?: (error: unknown, context: string) => void;
|
|
72
|
+
/** Test seams, forwarded to the center. */
|
|
73
|
+
now?: () => number;
|
|
74
|
+
newId?: () => string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The whole server half over D1 — `createNotificationService`'s shape, with `hub: null`.
|
|
78
|
+
* `mount` hangs the same routes on the app, minus `{base}/stream` (see the header).
|
|
79
|
+
*/
|
|
80
|
+
export declare function createD1NotificationService(options: CreateD1NotificationServiceOptions): NotificationService;
|