cursedbelt-server 4.26.1 → 4.27.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.
@@ -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
- const doc = JSON.parse(readFileSync(path, "utf8"));
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 [];
@@ -44,4 +44,10 @@ manifest parses (`apps/roms/routes.manifest.test.ts` is the pattern).
44
44
  excluded app, `SATELLITE_ENGAGEMENT=0` or an unresolvable data directory each turn recording OFF
45
45
  and say why, and the app still boots.
46
46
 
47
+ **On a Worker**, the same three tables live in the app's own D1 instead:
48
+ `cursedbelt-server/engagement/d1` (since 4.27.0, task 2096) exports `ENGAGEMENT_DDL` for the
49
+ app's `db/schema.sql`, `createD1EngagementRecorder` to mount inside the gate, and
50
+ `placesFromManifest` for the bundled `routes.manifest.json`. It is Worker-safe and uses the same
51
+ `policy.ts`, so there is one privacy rule whichever host records. station reads either shape.
52
+
47
53
  Cite this page from an app as `cursedbelt-server/docs/engagement.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.26.1",
3
+ "version": "4.27.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -125,6 +125,12 @@
125
125
  "source": "./src/server/engagement/index.ts",
126
126
  "import": "./dist/server/_bunOnly.js"
127
127
  },
128
+ "./engagement/d1": {
129
+ "types": "./dist/server/engagement/d1.d.ts",
130
+ "bun": "./src/server/engagement/d1.ts",
131
+ "source": "./src/server/engagement/d1.ts",
132
+ "import": "./dist/server/engagement/d1.js"
133
+ },
128
134
  "./errors": {
129
135
  "types": "./dist/server/errors.d.ts",
130
136
  "bun": "./src/server/errors.ts",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The Worker's engagement recorder is the kit's, over D1 — the SAME tables and the SAME rows for
3
+ * the same views, because station reads both with one query set (tasks 2081, 2096).
4
+ */
5
+ import { Database } from 'bun:sqlite';
6
+ import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { mkdtempSync, rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { Hono } from 'hono';
11
+ import { createLocalD1 } from '../d1/local.js';
12
+ import { createD1EngagementRecorder, ENGAGEMENT_DDL, placesFromManifest, recordViewD1 } from './d1.js';
13
+ import { engagementPlacesFor } from './places.js';
14
+ import { EngagementStore } from './store.js';
15
+
16
+ const dirs: string[] = [];
17
+ afterEach(() => {
18
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true });
19
+ });
20
+ const tempDir = (): string => {
21
+ const dir = mkdtempSync(join(tmpdir(), 'engagement-d1-'));
22
+ dirs.push(dir);
23
+ return dir;
24
+ };
25
+ const kitStore = (): EngagementStore => new EngagementStore({ dbPath: join(tempDir(), 'engagement.sqlite') });
26
+ const d1 = () => {
27
+ const sqlite = new Database(':memory:');
28
+ for (const ddl of ENGAGEMENT_DDL) sqlite.run(ddl);
29
+ return { sqlite, db: createLocalD1(sqlite) };
30
+ };
31
+ const shape = (db: Database) =>
32
+ (
33
+ db
34
+ .query("SELECT type, name, sql FROM sqlite_master WHERE name LIKE '%engagement%' AND sql IS NOT NULL ORDER BY name")
35
+ .all() as { type: string; name: string; sql: string }[]
36
+ ).map((r) => ({ type: r.type, name: r.name, sql: r.sql.replace(/\s+/g, ' ').replace('IF NOT EXISTS ', '').trim() }));
37
+ const dump = (db: Database) => ({
38
+ days: db.query('SELECT * FROM engagement_days ORDER BY user_key, place, day').all(),
39
+ places: db.query('SELECT * FROM engagement_places ORDER BY user_key, place').all(),
40
+ users: db.query('SELECT * FROM engagement_users ORDER BY user_key').all(),
41
+ });
42
+
43
+ describe('cursedbelt-server/engagement/d1', () => {
44
+ test("🔴 creates exactly the kit store's tables and indexes — station reads both with one query set", () => {
45
+ const kit = kitStore();
46
+ const { sqlite } = d1();
47
+ expect(shape(sqlite)).toEqual(shape(kit.handle));
48
+ });
49
+
50
+ test("🔴 the same views produce the same rows as the kit's store — sessions, dwell, days", async () => {
51
+ const kit = kitStore();
52
+ const { sqlite, db } = d1();
53
+ const t0 = Date.UTC(2026, 8, 23, 10, 0, 0);
54
+ const views = [
55
+ { userKey: 'u1', place: 'library', ts: t0 },
56
+ { userKey: 'u1', place: 'downloads', ts: t0 + 40_000 },
57
+ { userKey: 'u2', place: 'library', ts: t0 + 50_000 },
58
+ { userKey: 'u1', place: 'library', ts: t0 + 90_000 },
59
+ // A new session (a long gap) on the next day.
60
+ { userKey: 'u1', place: 'library', ts: t0 + 26 * 3_600_000 },
61
+ ];
62
+ for (const v of views) expect(await recordViewD1(db, v)).toEqual(kit.recordView(v));
63
+ expect(dump(sqlite)).toEqual(dump(kit.handle));
64
+ });
65
+
66
+ test('records only a signed-in account, and buckets an off-manifest place as `other`', async () => {
67
+ const { sqlite, db } = d1();
68
+ const places = placesFromManifest({ routes: [{ id: 'library', path: '/library' }, { id: 'library' }, { path: '/x' }] });
69
+ expect(places).toEqual([{ id: 'library', path: '/library' }]);
70
+ let user: string | null = null;
71
+ const app = new Hono().route('/api/engagement', createD1EngagementRecorder({ db, places, resolveUser: () => user }));
72
+ const post = (place: unknown) =>
73
+ app.request('http://x/api/engagement/view', {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ place }),
77
+ });
78
+ expect((await post('library')).status).toBe(401);
79
+ user = 'u1';
80
+ expect(await (await post('library')).json()).toMatchObject({ ok: true, place: 'library', bucketed: false });
81
+ // An id, an album name, a search string — never a row of its own.
82
+ expect(await (await post('album:Some Private Name')).json()).toMatchObject({ place: 'other', bucketed: true });
83
+ expect((await post(42)).status).toBe(400);
84
+ expect(sqlite.query('SELECT place FROM engagement_places ORDER BY place').all()).toEqual([
85
+ { place: 'library' },
86
+ { place: 'other' },
87
+ ]);
88
+ });
89
+
90
+ test('🔴 ONE manifest rule: a Worker handed the JSON and a Bun server reading the file agree', async () => {
91
+ const doc = { routes: [{ id: 'queue', path: '/#/queue' }, { id: 'home', path: '/' }, { id: 'queue' }, { id: '' }, null] };
92
+ const dir = tempDir();
93
+ await Bun.write(join(dir, 'routes.manifest.json'), JSON.stringify(doc));
94
+ expect(placesFromManifest(doc)).toEqual([
95
+ { id: 'home', path: '/' },
96
+ { id: 'queue', path: '/#/queue' },
97
+ ]);
98
+ expect(engagementPlacesFor(dir)).toEqual(placesFromManifest(doc));
99
+ expect(placesFromManifest('not a manifest')).toEqual([]);
100
+ expect(placesFromManifest(null)).toEqual([]);
101
+ });
102
+ });
@@ -0,0 +1,141 @@
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 { dayKey, foldView, OTHER_PLACE, type PlaceRef, resolvePlace } from "./policy.js";
41
+
42
+ /** The kit store's own tables and indexes, as one-line DDL — see the header. */
43
+ export const ENGAGEMENT_DDL: readonly string[] = [
44
+ "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) )",
45
+ "CREATE INDEX IF NOT EXISTS idx_engagement_days_day ON engagement_days (day)",
46
+ "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) )",
47
+ "CREATE INDEX IF NOT EXISTS idx_engagement_places_last ON engagement_places (last_ts)",
48
+ "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 '' )",
49
+ ];
50
+
51
+ /**
52
+ * Record one view — the kit's `EngagementStore.recordView`, asynchronously: one read (the
53
+ * account's previous view) and ONE batch of up to five statements, so a view is atomic and
54
+ * costs two D1 round trips however much it credits.
55
+ */
56
+ export async function recordViewD1(
57
+ db: D1LikeDatabase,
58
+ input: { userKey: string; place: string; ts: number },
59
+ ): Promise<{ newSession: boolean; activeMs: number }> {
60
+ const { userKey, place, ts } = input;
61
+ const prior = await db
62
+ .prepare("SELECT last_ts, last_place FROM engagement_users WHERE user_key = ?")
63
+ .bind(userKey)
64
+ .first<{ last_ts: number; last_place: string }>();
65
+ const fold = foldView(prior ? prior.last_ts : null, ts);
66
+ const sessionDelta = fold.newSession ? 1 : 0;
67
+ const statements = [];
68
+ if (fold.activeMs > 0 && prior?.last_place) {
69
+ // The time since the last view is credited to the place the account was LOOKING AT.
70
+ statements.push(
71
+ db
72
+ .prepare("UPDATE engagement_days SET active_ms = active_ms + ? WHERE user_key = ? AND place = ? AND day = ?")
73
+ .bind(fold.activeMs, userKey, prior.last_place, dayKey(prior.last_ts)),
74
+ db
75
+ .prepare("UPDATE engagement_places SET active_ms = active_ms + ? WHERE user_key = ? AND place = ?")
76
+ .bind(fold.activeMs, userKey, prior.last_place),
77
+ );
78
+ }
79
+ statements.push(
80
+ db
81
+ .prepare(
82
+ `INSERT INTO engagement_days (user_key, place, day, views, active_ms, sessions, first_ts, last_ts)
83
+ VALUES (?, ?, ?, 1, 0, ?, ?, ?)
84
+ ON CONFLICT(user_key, place, day) DO UPDATE SET
85
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`,
86
+ )
87
+ .bind(userKey, place, dayKey(ts), sessionDelta, ts, ts),
88
+ db
89
+ .prepare(
90
+ `INSERT INTO engagement_places (user_key, place, views, sessions, active_ms, first_ts, last_ts)
91
+ VALUES (?, ?, 1, ?, 0, ?, ?)
92
+ ON CONFLICT(user_key, place) DO UPDATE SET
93
+ views = views + 1, sessions = sessions + excluded.sessions, last_ts = excluded.last_ts`,
94
+ )
95
+ .bind(userKey, place, sessionDelta, ts, ts),
96
+ db
97
+ .prepare(
98
+ `INSERT INTO engagement_users (user_key, views, sessions, active_ms, first_ts, last_ts, last_place)
99
+ VALUES (?, 1, ?, 0, ?, ?, ?)
100
+ ON CONFLICT(user_key) DO UPDATE SET
101
+ views = views + 1, sessions = sessions + excluded.sessions, active_ms = active_ms + ?,
102
+ last_ts = excluded.last_ts, last_place = excluded.last_place`,
103
+ )
104
+ .bind(userKey, sessionDelta, ts, ts, place, fold.activeMs),
105
+ );
106
+ await db.batch(statements);
107
+ return fold;
108
+ }
109
+
110
+ /**
111
+ * The recorder as the kit's `createEngagementRecorder` mounts it — `POST /view` under
112
+ * `ENGAGEMENT_BASE_PATH` — over D1. Recording only: there is no read surface here, as there is
113
+ * none on the Mac; station reads the tables directly.
114
+ */
115
+ export function createD1EngagementRecorder(options: {
116
+ db: D1LikeDatabase;
117
+ places: readonly PlaceRef[];
118
+ resolveUser: (c: Context) => string | null;
119
+ now?: () => number;
120
+ }): Hono {
121
+ const app = new Hono();
122
+ const now = options.now ?? (() => Date.now());
123
+ app.post("/view", async (c) => {
124
+ const userKey = options.resolveUser(c);
125
+ if (!userKey) return c.json({ error: "unauthenticated" }, 401);
126
+ let body: unknown;
127
+ try {
128
+ body = await c.req.json();
129
+ } catch {
130
+ return c.json({ error: "expected a json body" }, 400);
131
+ }
132
+ const claimed = (body as { place?: unknown } | null)?.place;
133
+ if (typeof claimed !== "string") return c.json({ error: "place must be a string" }, 400);
134
+ const place = resolvePlace(claimed, options.places);
135
+ const fold = await recordViewD1(options.db, { userKey, place, ts: now() });
136
+ return c.json({ ok: true, place, bucketed: place === OTHER_PLACE, ...fold });
137
+ });
138
+ return app;
139
+ }
140
+
141
+ export { placesFromManifest } from "./manifestPlaces.js";
@@ -0,0 +1,24 @@
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
+
10
+ export function placesFromManifest(doc: unknown): PlaceRef[] {
11
+ const routes = Array.isArray(doc) ? doc : ((doc as { routes?: unknown } | null)?.routes as unknown[] | undefined);
12
+ if (!Array.isArray(routes)) return [];
13
+ const seen = new Set<string>();
14
+ const places: PlaceRef[] = [];
15
+ for (const raw of routes) {
16
+ const r = raw as { id?: unknown; path?: unknown } | null;
17
+ if (!r || typeof r.id !== "string" || !r.id || seen.has(r.id)) continue;
18
+ seen.add(r.id);
19
+ places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
20
+ }
21
+ // Sorted so the allowlist a board renders is stable between deployments.
22
+ places.sort((a, b) => a.id.localeCompare(b.id));
23
+ return places;
24
+ }
@@ -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
 
28
29
  /** Where the manifest sits, in a source tree and in a built artifact alike. */
29
30
  const MANIFEST = "routes.manifest.json";
@@ -54,22 +55,7 @@ export function engagementPlacesFor(dir: string): EngagementPlace[] {
54
55
  const path = join(dir, MANIFEST);
55
56
  if (!existsSync(path)) return [];
56
57
  try {
57
- const doc = JSON.parse(readFileSync(path, "utf8")) as unknown;
58
- const routes = Array.isArray(doc)
59
- ? doc
60
- : ((doc as { routes?: unknown }).routes as unknown[] | undefined);
61
- if (!Array.isArray(routes)) return [];
62
- const seen = new Set<string>();
63
- const places: EngagementPlace[] = [];
64
- for (const raw of routes) {
65
- const r = raw as { id?: unknown; path?: unknown };
66
- if (typeof r.id !== "string" || !r.id || seen.has(r.id)) continue;
67
- seen.add(r.id);
68
- places.push({ id: r.id, path: typeof r.path === "string" ? r.path : "" });
69
- }
70
- // Sorted so the allowlist a board renders is stable between deployments.
71
- places.sort((a, b) => a.id.localeCompare(b.id));
72
- return places;
58
+ return placesFromManifest(JSON.parse(readFileSync(path, "utf8")) as unknown);
73
59
  } catch {
74
60
  return [];
75
61
  }
@@ -89,3 +89,21 @@ describe('cursedbelt-server/telemetry', () => {
89
89
  expect(found).toEqual(['bun:sqlite']);
90
90
  });
91
91
  });
92
+
93
+ describe('🔴 cursedbelt-server/engagement/d1 — a Worker mounts it (task 2096)', () => {
94
+ test('reaches no `bun:` module at runtime — not the store, not the Bun-only barrel', () => {
95
+ const { files, bunImports } = runtimeGraph(join(import.meta.dir, 'engagement', 'd1.ts'));
96
+ expect(bunImports).toEqual([]);
97
+ // Not vacuous: the policy and the manifest rule are both in the graph, and the store is not.
98
+ expect(files.some((f) => f.endsWith('engagement/policy.ts'))).toBe(true);
99
+ expect(files.some((f) => f.endsWith('engagement/manifestPlaces.ts'))).toBe(true);
100
+ expect(files.some((f) => f.endsWith('engagement/store.ts'))).toBe(false);
101
+ });
102
+
103
+ test("its export map's `import` is the real module, not the Bun-only refusal", () => {
104
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as {
105
+ exports: Record<string, { import?: string }>;
106
+ };
107
+ expect(pkg.exports['./engagement/d1']?.import).toBe('./dist/server/engagement/d1.js');
108
+ });
109
+ });