cursedbelt-server 4.18.1 → 4.19.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/index.d.ts +2 -1
- package/dist/server/activity/index.js +2 -1
- package/dist/server/auth/passwordCost.d.ts +21 -0
- package/dist/server/auth/passwordCost.js +80 -0
- package/dist/server/bench/index.d.ts +1 -0
- package/dist/server/bench/index.js +1 -0
- package/dist/server/bench/tail.d.ts +110 -0
- package/dist/server/bench/tail.js +182 -0
- package/dist/server/d1/index.d.ts +1 -2
- package/dist/server/d1/index.js +9 -9
- package/dist/server/engagement/api.d.ts +71 -0
- package/dist/server/engagement/api.js +84 -0
- package/dist/server/engagement/env.d.ts +18 -0
- package/dist/server/engagement/env.js +52 -0
- package/dist/server/engagement/index.d.ts +55 -0
- package/dist/server/engagement/index.js +55 -0
- package/dist/server/engagement/places.d.ts +22 -0
- package/dist/server/engagement/places.js +63 -0
- package/dist/server/engagement/policy.d.ts +168 -0
- package/dist/server/engagement/policy.js +202 -0
- package/dist/server/engagement/store.d.ts +92 -0
- package/dist/server/engagement/store.js +223 -0
- package/dist/server/engagement/summary.d.ts +102 -0
- package/dist/server/engagement/summary.js +127 -0
- package/dist/server/engagement/types.d.ts +42 -0
- package/dist/server/engagement/types.js +12 -0
- package/dist/server/maps-budget/mapsBudget.d.ts +194 -0
- package/dist/server/maps-budget/mapsBudget.js +193 -0
- package/dist/server/satellite/config.d.ts +173 -0
- package/dist/server/satellite/config.js +259 -0
- package/dist/server/satellite/door.d.ts +112 -0
- package/dist/server/satellite/door.js +149 -0
- package/dist/server/storage/binaryStore.d.ts +18 -0
- package/dist/server/storage/binaryStore.js +32 -1
- package/dist/server/storage/derivatives.d.ts +253 -0
- package/dist/server/storage/derivatives.js +266 -0
- package/dist/server/storage/uploadSession.d.ts +75 -0
- package/dist/server/storage/uploadSession.js +74 -0
- package/docs/THE-DEV-DEPENDENCY-CYCLE.md +55 -0
- package/docs/activity.md +43 -0
- package/docs/engagement.md +47 -0
- package/docs/notifications.md +43 -0
- package/docs/retention.md +81 -0
- package/docs/skipped-tests.md +19 -0
- package/package.json +46 -9
- package/src/barrelsReachNoOptionalPeer.spec.ts +5 -3
- package/src/leafSubpathsImportNothing.spec.ts +49 -0
- package/src/server/activity/index.ts +2 -1
- package/src/server/auth/passwordCost.spec.ts +42 -0
- package/src/server/auth/passwordCost.ts +87 -0
- package/src/server/bench/index.ts +13 -0
- package/src/server/bench/tail.spec.ts +126 -0
- package/src/server/bench/tail.ts +237 -0
- package/src/server/d1/index.ts +9 -9
- package/src/server/engagement/api.ts +119 -0
- package/src/server/engagement/engagement.spec.ts +462 -0
- package/src/server/engagement/env.ts +73 -0
- package/src/server/engagement/index.ts +92 -0
- package/src/server/engagement/places.ts +76 -0
- package/src/server/engagement/policy.ts +250 -0
- package/src/server/engagement/store.ts +272 -0
- package/src/server/engagement/summary.ts +216 -0
- package/src/server/engagement/types.ts +61 -0
- package/src/server/maps-budget/mapsBudget.spec.ts +366 -0
- package/src/server/maps-budget/mapsBudget.ts +304 -0
- package/src/server/satellite/config.ts +389 -0
- package/src/server/satellite/door.ts +169 -0
- package/src/server/satellite/satellite.spec.ts +161 -0
- package/src/server/storage/binaryStore.ts +31 -1
- package/src/server/storage/derivatives.spec.ts +125 -0
- package/src/server/storage/derivatives.ts +329 -0
- package/src/server/storage/uploadSession.spec.ts +132 -0
- package/src/server/storage/uploadSession.ts +114 -0
- package/dist/server/d1/kysely.d.ts +0 -56
- package/dist/server/d1/kysely.js +0 -138
- package/src/server/d1/kysely.spec.ts +0 -145
- package/src/server/d1/kysely.ts +0 -169
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-app ENGAGEMENT database — who opens which places, how often, and when
|
|
3
|
+
* they stopped.
|
|
4
|
+
*
|
|
5
|
+
* ── Its own file, beside `metrics.sqlite`, on purpose ───────────────────────
|
|
6
|
+
* `metrics/store.ts` opens with a promise: no user id, no query string, no
|
|
7
|
+
* path, in twelve apps including the zero-knowledge vault. That promise is the
|
|
8
|
+
* reason its middleware is safe to run everywhere, and a user-keyed table added
|
|
9
|
+
* to that file would retire it silently. So this is `engagement.sqlite`: a
|
|
10
|
+
* different file, a different guarantee, and an app that has never mounted the
|
|
11
|
+
* recorder simply has no such file at all.
|
|
12
|
+
*
|
|
13
|
+
* ── AGGREGATED, never an event log ──────────────────────────────────────────
|
|
14
|
+
* One row per (user, place, UTC day). The alternative — a row per view — buys
|
|
15
|
+
* nothing at this fleet's size and costs the one thing worth protecting: an
|
|
16
|
+
* event log is a timeline, and a timeline of a household's private apps is a
|
|
17
|
+
* far more sensitive object than a count. Sequence is exactly the information
|
|
18
|
+
* we do not want to be holding, so the schema cannot express it.
|
|
19
|
+
*
|
|
20
|
+
* The one place ordering DOES matter is sessionization, and it is resolved at
|
|
21
|
+
* WRITE time against `engagement_users.last_ts` — a single stored clock per
|
|
22
|
+
* user, which survives a restart and reveals nothing on its own. See
|
|
23
|
+
* `policy.ts:foldView`.
|
|
24
|
+
*
|
|
25
|
+
* ── Bounded like the rest of the telemetry ──────────────────────────────────
|
|
26
|
+
* Daily rows are trimmed by AGE, because the question this table answers ("is
|
|
27
|
+
* anyone still using it?") is a question about recent history, and a return
|
|
28
|
+
* curve longer than a quarter is not something a three-person fleet can read.
|
|
29
|
+
* The per-user roll-up is NOT trimmed by age: it is one row per (user, place)
|
|
30
|
+
* pair carrying first/last seen, and it is precisely the row that must survive
|
|
31
|
+
* for "dropped off" to be answerable. Dropping it after 90 days would make
|
|
32
|
+
* every lapsed user look like a new one.
|
|
33
|
+
*/
|
|
34
|
+
import { Database } from "bun:sqlite";
|
|
35
|
+
import type { EngagementDay, EngagementUser, EngagementUserPlace } from "./types.js";
|
|
36
|
+
export type { EngagementDay, EngagementUser, EngagementUserPlace } from "./types.js";
|
|
37
|
+
/** How much history the daily grain keeps. */
|
|
38
|
+
export declare const ENGAGEMENT_DAY_RETENTION_DAYS = 120;
|
|
39
|
+
export interface EngagementStoreOptions {
|
|
40
|
+
dbPath: string;
|
|
41
|
+
retentionDays?: number;
|
|
42
|
+
}
|
|
43
|
+
export declare class EngagementStore {
|
|
44
|
+
readonly handle: Database;
|
|
45
|
+
readonly retentionDays: number;
|
|
46
|
+
private writes;
|
|
47
|
+
constructor(options: EngagementStoreOptions);
|
|
48
|
+
private migrate;
|
|
49
|
+
/**
|
|
50
|
+
* Record one resolved place-view.
|
|
51
|
+
*
|
|
52
|
+
* `place` must ALREADY have been through `resolvePlace` — this method does not
|
|
53
|
+
* validate it, because the allowlist lives with the app that owns the route
|
|
54
|
+
* manifest and a second half-check here would be the one somebody trusts. The
|
|
55
|
+
* HTTP door (`api.ts`) is where resolution happens, and `engagement.test.ts`
|
|
56
|
+
* pins that it does.
|
|
57
|
+
*
|
|
58
|
+
* Returns the fold that was applied, so a caller (or a test) can assert on
|
|
59
|
+
* the sessionization rather than re-deriving it.
|
|
60
|
+
*/
|
|
61
|
+
recordView(input: {
|
|
62
|
+
userKey: string;
|
|
63
|
+
place: string;
|
|
64
|
+
ts: number;
|
|
65
|
+
}): {
|
|
66
|
+
newSession: boolean;
|
|
67
|
+
activeMs: number;
|
|
68
|
+
};
|
|
69
|
+
/** Every account's roll-up for this app, most recently seen first. */
|
|
70
|
+
users(): EngagementUser[];
|
|
71
|
+
/** Every (user, place) roll-up — the per-FEATURE grain, whole-fleet readable. */
|
|
72
|
+
places(): EngagementUserPlace[];
|
|
73
|
+
/** Daily buckets inside a window — the sessions-over-time and return curve. */
|
|
74
|
+
days(sinceTs: number): EngagementDay[];
|
|
75
|
+
/**
|
|
76
|
+
* Drop daily rows older than the retention window.
|
|
77
|
+
*
|
|
78
|
+
* 🔴 Only `engagement_days`. The two roll-ups are deliberately untouched —
|
|
79
|
+
* see the file header. An agent "completing" this by trimming them too would
|
|
80
|
+
* delete the only record that somebody used to be here, which is the entire
|
|
81
|
+
* drop-off signal.
|
|
82
|
+
*/
|
|
83
|
+
prune(at?: number): number;
|
|
84
|
+
/** Row counts + age bounds, for the retention card. */
|
|
85
|
+
stats(): {
|
|
86
|
+
days: number;
|
|
87
|
+
places: number;
|
|
88
|
+
users: number;
|
|
89
|
+
oldestDay: string | null;
|
|
90
|
+
};
|
|
91
|
+
close(): void;
|
|
92
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-app ENGAGEMENT database — who opens which places, how often, and when
|
|
3
|
+
* they stopped.
|
|
4
|
+
*
|
|
5
|
+
* ── Its own file, beside `metrics.sqlite`, on purpose ───────────────────────
|
|
6
|
+
* `metrics/store.ts` opens with a promise: no user id, no query string, no
|
|
7
|
+
* path, in twelve apps including the zero-knowledge vault. That promise is the
|
|
8
|
+
* reason its middleware is safe to run everywhere, and a user-keyed table added
|
|
9
|
+
* to that file would retire it silently. So this is `engagement.sqlite`: a
|
|
10
|
+
* different file, a different guarantee, and an app that has never mounted the
|
|
11
|
+
* recorder simply has no such file at all.
|
|
12
|
+
*
|
|
13
|
+
* ── AGGREGATED, never an event log ──────────────────────────────────────────
|
|
14
|
+
* One row per (user, place, UTC day). The alternative — a row per view — buys
|
|
15
|
+
* nothing at this fleet's size and costs the one thing worth protecting: an
|
|
16
|
+
* event log is a timeline, and a timeline of a household's private apps is a
|
|
17
|
+
* far more sensitive object than a count. Sequence is exactly the information
|
|
18
|
+
* we do not want to be holding, so the schema cannot express it.
|
|
19
|
+
*
|
|
20
|
+
* The one place ordering DOES matter is sessionization, and it is resolved at
|
|
21
|
+
* WRITE time against `engagement_users.last_ts` — a single stored clock per
|
|
22
|
+
* user, which survives a restart and reveals nothing on its own. See
|
|
23
|
+
* `policy.ts:foldView`.
|
|
24
|
+
*
|
|
25
|
+
* ── Bounded like the rest of the telemetry ──────────────────────────────────
|
|
26
|
+
* Daily rows are trimmed by AGE, because the question this table answers ("is
|
|
27
|
+
* anyone still using it?") is a question about recent history, and a return
|
|
28
|
+
* curve longer than a quarter is not something a three-person fleet can read.
|
|
29
|
+
* The per-user roll-up is NOT trimmed by age: it is one row per (user, place)
|
|
30
|
+
* pair carrying first/last seen, and it is precisely the row that must survive
|
|
31
|
+
* for "dropped off" to be answerable. Dropping it after 90 days would make
|
|
32
|
+
* every lapsed user look like a new one.
|
|
33
|
+
*/
|
|
34
|
+
import { Database } from "bun:sqlite";
|
|
35
|
+
import { mkdirSync } from "node:fs";
|
|
36
|
+
import { dirname, resolve } from "node:path";
|
|
37
|
+
import { dayKey, foldView } from "./policy.js";
|
|
38
|
+
/**
|
|
39
|
+
* `bun:sqlite`'s `create: true` creates the FILE, never the directory above it, so a first boot
|
|
40
|
+
* into an empty data dir would throw here. Swallowed on purpose: the open that follows names the
|
|
41
|
+
* real problem, and telemetry must never be what takes an app's boot down.
|
|
42
|
+
*/
|
|
43
|
+
function ensureDbDirectory(dbPath) {
|
|
44
|
+
if (!dbPath || dbPath === ":memory:" || dbPath.startsWith("file::memory:"))
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(dirname(resolve(dbPath)), { recursive: true });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// see above
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** How much history the daily grain keeps. */
|
|
54
|
+
export const ENGAGEMENT_DAY_RETENTION_DAYS = 120;
|
|
55
|
+
export class EngagementStore {
|
|
56
|
+
handle;
|
|
57
|
+
retentionDays;
|
|
58
|
+
writes = 0;
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.retentionDays = options.retentionDays ?? ENGAGEMENT_DAY_RETENTION_DAYS;
|
|
61
|
+
// `create: true` creates the file, never the directory above it — see
|
|
62
|
+
// `ensureDbDirectory`, which is the whole of notes' "NOT YET DIAGNOSED" waiver.
|
|
63
|
+
ensureDbDirectory(options.dbPath);
|
|
64
|
+
this.handle = new Database(options.dbPath, { create: true });
|
|
65
|
+
// busy_timeout FIRST — journal_mode takes a lock. scripts/sqliteBusyTimeout.test.ts
|
|
66
|
+
this.handle.run("PRAGMA busy_timeout = 5000");
|
|
67
|
+
this.handle.run("PRAGMA journal_mode = WAL");
|
|
68
|
+
// A beacon must never block a page on an fsync, and losing the last few
|
|
69
|
+
// views to a hard kill costs nothing a human would notice.
|
|
70
|
+
this.handle.run("PRAGMA synchronous = NORMAL");
|
|
71
|
+
this.migrate();
|
|
72
|
+
}
|
|
73
|
+
migrate() {
|
|
74
|
+
this.handle.run(`CREATE TABLE IF NOT EXISTS engagement_days (
|
|
75
|
+
user_key TEXT NOT NULL,
|
|
76
|
+
place TEXT NOT NULL,
|
|
77
|
+
day TEXT NOT NULL,
|
|
78
|
+
views INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
active_ms INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
sessions INTEGER NOT NULL DEFAULT 0,
|
|
81
|
+
first_ts INTEGER NOT NULL,
|
|
82
|
+
last_ts INTEGER NOT NULL,
|
|
83
|
+
PRIMARY KEY (user_key, place, day)
|
|
84
|
+
)`);
|
|
85
|
+
this.handle.run("CREATE INDEX IF NOT EXISTS idx_engagement_days_day ON engagement_days (day)");
|
|
86
|
+
// The (user, place) lifetime row. Separate from the daily grain because it
|
|
87
|
+
// must OUTLIVE it — see the header: this is the row that knows somebody
|
|
88
|
+
// stopped, and age-trimming it would turn every lapsed account into a new
|
|
89
|
+
// one on day 121.
|
|
90
|
+
this.handle.run(`CREATE TABLE IF NOT EXISTS engagement_places (
|
|
91
|
+
user_key TEXT NOT NULL,
|
|
92
|
+
place TEXT NOT NULL,
|
|
93
|
+
views INTEGER NOT NULL DEFAULT 0,
|
|
94
|
+
sessions INTEGER NOT NULL DEFAULT 0,
|
|
95
|
+
active_ms INTEGER NOT NULL DEFAULT 0,
|
|
96
|
+
first_ts INTEGER NOT NULL,
|
|
97
|
+
last_ts INTEGER NOT NULL,
|
|
98
|
+
PRIMARY KEY (user_key, place)
|
|
99
|
+
)`);
|
|
100
|
+
this.handle.run("CREATE INDEX IF NOT EXISTS idx_engagement_places_last ON engagement_places (last_ts)");
|
|
101
|
+
// One row per ACCOUNT. `last_ts` here is the sessionization clock — the only
|
|
102
|
+
// piece of ordering the schema keeps, and it keeps exactly one timestamp
|
|
103
|
+
// rather than a sequence (header, "AGGREGATED, never an event log").
|
|
104
|
+
this.handle.run(`CREATE TABLE IF NOT EXISTS engagement_users (
|
|
105
|
+
user_key TEXT PRIMARY KEY,
|
|
106
|
+
views INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
sessions INTEGER NOT NULL DEFAULT 0,
|
|
108
|
+
active_ms INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
first_ts INTEGER NOT NULL,
|
|
110
|
+
last_ts INTEGER NOT NULL,
|
|
111
|
+
last_place TEXT NOT NULL DEFAULT ''
|
|
112
|
+
)`);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Record one resolved place-view.
|
|
116
|
+
*
|
|
117
|
+
* `place` must ALREADY have been through `resolvePlace` — this method does not
|
|
118
|
+
* validate it, because the allowlist lives with the app that owns the route
|
|
119
|
+
* manifest and a second half-check here would be the one somebody trusts. The
|
|
120
|
+
* HTTP door (`api.ts`) is where resolution happens, and `engagement.test.ts`
|
|
121
|
+
* pins that it does.
|
|
122
|
+
*
|
|
123
|
+
* Returns the fold that was applied, so a caller (or a test) can assert on
|
|
124
|
+
* the sessionization rather than re-deriving it.
|
|
125
|
+
*/
|
|
126
|
+
recordView(input) {
|
|
127
|
+
const { userKey, place, ts } = input;
|
|
128
|
+
const prior = this.handle
|
|
129
|
+
.query("SELECT last_ts, last_place FROM engagement_users WHERE user_key = ?")
|
|
130
|
+
.get(userKey);
|
|
131
|
+
const fold = foldView(prior ? prior.last_ts : null, ts);
|
|
132
|
+
// Dwell is credited BACKWARDS, to the page they were already on — which is
|
|
133
|
+
// why `last_place` is stored at all. Crediting it to the page just opened
|
|
134
|
+
// would mean the most-abandoned page in the app scored highest.
|
|
135
|
+
if (fold.activeMs > 0 && prior?.last_place) {
|
|
136
|
+
const creditDay = dayKey(prior.last_ts);
|
|
137
|
+
this.handle.run("UPDATE engagement_days SET active_ms = active_ms + ? WHERE user_key = ? AND place = ? AND day = ?", [fold.activeMs, userKey, prior.last_place, creditDay]);
|
|
138
|
+
this.handle.run("UPDATE engagement_places SET active_ms = active_ms + ? WHERE user_key = ? AND place = ?", [fold.activeMs, userKey, prior.last_place]);
|
|
139
|
+
}
|
|
140
|
+
const sessionDelta = fold.newSession ? 1 : 0;
|
|
141
|
+
this.handle.run(`INSERT INTO engagement_days (user_key, place, day, views, active_ms, sessions, first_ts, last_ts)
|
|
142
|
+
VALUES (?, ?, ?, 1, 0, ?, ?, ?)
|
|
143
|
+
ON CONFLICT(user_key, place, day) DO UPDATE SET
|
|
144
|
+
views = views + 1,
|
|
145
|
+
sessions = sessions + excluded.sessions,
|
|
146
|
+
last_ts = excluded.last_ts`, [userKey, place, dayKey(ts), sessionDelta, ts, ts]);
|
|
147
|
+
this.handle.run(`INSERT INTO engagement_places (user_key, place, views, sessions, active_ms, first_ts, last_ts)
|
|
148
|
+
VALUES (?, ?, 1, ?, 0, ?, ?)
|
|
149
|
+
ON CONFLICT(user_key, place) DO UPDATE SET
|
|
150
|
+
views = views + 1,
|
|
151
|
+
sessions = sessions + excluded.sessions,
|
|
152
|
+
last_ts = excluded.last_ts`, [userKey, place, sessionDelta, ts, ts]);
|
|
153
|
+
this.handle.run(`INSERT INTO engagement_users (user_key, views, sessions, active_ms, first_ts, last_ts, last_place)
|
|
154
|
+
VALUES (?, 1, ?, 0, ?, ?, ?)
|
|
155
|
+
ON CONFLICT(user_key) DO UPDATE SET
|
|
156
|
+
views = views + 1,
|
|
157
|
+
sessions = sessions + excluded.sessions,
|
|
158
|
+
active_ms = active_ms + ?,
|
|
159
|
+
last_ts = excluded.last_ts,
|
|
160
|
+
last_place = excluded.last_place`, [userKey, sessionDelta, ts, ts, place, fold.activeMs]);
|
|
161
|
+
// Amortised, exactly as the metrics store trims: a DELETE per beacon would
|
|
162
|
+
// cost more than the beacon, and never trimming lets a long-lived daemon
|
|
163
|
+
// keep every day it has ever served.
|
|
164
|
+
if (++this.writes % 256 === 0)
|
|
165
|
+
this.prune(ts);
|
|
166
|
+
return fold;
|
|
167
|
+
}
|
|
168
|
+
/** Every account's roll-up for this app, most recently seen first. */
|
|
169
|
+
users() {
|
|
170
|
+
return this.handle
|
|
171
|
+
.query(`SELECT u.user_key AS userKey, u.views AS views, u.sessions AS sessions,
|
|
172
|
+
u.active_ms AS activeMs, u.first_ts AS firstTs, u.last_ts AS lastTs,
|
|
173
|
+
(SELECT COUNT(*) FROM engagement_places p WHERE p.user_key = u.user_key) AS places
|
|
174
|
+
FROM engagement_users u
|
|
175
|
+
ORDER BY u.last_ts DESC`)
|
|
176
|
+
.all();
|
|
177
|
+
}
|
|
178
|
+
/** Every (user, place) roll-up — the per-FEATURE grain, whole-fleet readable. */
|
|
179
|
+
places() {
|
|
180
|
+
return this.handle
|
|
181
|
+
.query(`SELECT user_key AS userKey, place, views, sessions, active_ms AS activeMs,
|
|
182
|
+
first_ts AS firstTs, last_ts AS lastTs
|
|
183
|
+
FROM engagement_places
|
|
184
|
+
ORDER BY views DESC`)
|
|
185
|
+
.all();
|
|
186
|
+
}
|
|
187
|
+
/** Daily buckets inside a window — the sessions-over-time and return curve. */
|
|
188
|
+
days(sinceTs) {
|
|
189
|
+
return this.handle
|
|
190
|
+
.query(`SELECT user_key AS userKey, place, day, views, active_ms AS activeMs, sessions
|
|
191
|
+
FROM engagement_days WHERE day >= ?
|
|
192
|
+
ORDER BY day ASC`)
|
|
193
|
+
.all(dayKey(sinceTs));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Drop daily rows older than the retention window.
|
|
197
|
+
*
|
|
198
|
+
* 🔴 Only `engagement_days`. The two roll-ups are deliberately untouched —
|
|
199
|
+
* see the file header. An agent "completing" this by trimming them too would
|
|
200
|
+
* delete the only record that somebody used to be here, which is the entire
|
|
201
|
+
* drop-off signal.
|
|
202
|
+
*/
|
|
203
|
+
prune(at = Date.now()) {
|
|
204
|
+
const cutoff = dayKey(at - this.retentionDays * 86_400_000);
|
|
205
|
+
return this.handle.run("DELETE FROM engagement_days WHERE day < ?", [cutoff]).changes;
|
|
206
|
+
}
|
|
207
|
+
/** Row counts + age bounds, for the retention card. */
|
|
208
|
+
stats() {
|
|
209
|
+
const n = (sql) => (this.handle.query(sql).get()?.n ?? 0);
|
|
210
|
+
const oldest = this.handle
|
|
211
|
+
.query("SELECT MIN(day) AS d FROM engagement_days")
|
|
212
|
+
.get();
|
|
213
|
+
return {
|
|
214
|
+
days: n("SELECT COUNT(*) AS n FROM engagement_days"),
|
|
215
|
+
places: n("SELECT COUNT(*) AS n FROM engagement_places"),
|
|
216
|
+
users: n("SELECT COUNT(*) AS n FROM engagement_users"),
|
|
217
|
+
oldestDay: oldest?.d ?? null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
close() {
|
|
221
|
+
this.handle.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One app's engagement, folded into the shape the console draws.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the APP does the folding ────────────────────────────────────────────
|
|
5
|
+
* The same argument `metrics/inventory.ts` makes: the console runs on this Mac
|
|
6
|
+
* and most of the fleet runs on a checkout-less box reachable only over HTTPS,
|
|
7
|
+
* so the app that owns the data is the only thing that can read it. Shipping
|
|
8
|
+
* raw daily rows would also mean shipping the per-day timeline of a household's
|
|
9
|
+
* private apps across the wire on every poll, when the console draws totals.
|
|
10
|
+
*
|
|
11
|
+
* ── Everything here is pure over rows ───────────────────────────────────────
|
|
12
|
+
* `summarize` takes the three roll-ups and a clock, so `engagement.test.ts`
|
|
13
|
+
* drives it with hand-written rows and checks arithmetic a human can verify —
|
|
14
|
+
* which is the whole design constraint at n = 3. No I/O, no `Date.now()`.
|
|
15
|
+
*/
|
|
16
|
+
import { type EngagementDay, type EngagementUser, type EngagementUserPlace, type InterestVerdict } from "./types.js";
|
|
17
|
+
/** One account's relationship with one app. */
|
|
18
|
+
export interface UserEngagement {
|
|
19
|
+
userKey: string;
|
|
20
|
+
views: number;
|
|
21
|
+
sessions: number;
|
|
22
|
+
activeMs: number;
|
|
23
|
+
firstTs: number;
|
|
24
|
+
lastTs: number;
|
|
25
|
+
/** Distinct places this account has opened here. */
|
|
26
|
+
places: number;
|
|
27
|
+
interest: InterestVerdict;
|
|
28
|
+
/** Whole days since this account was last seen in this app. */
|
|
29
|
+
daysSinceSeen: number;
|
|
30
|
+
}
|
|
31
|
+
/** One PLACE inside an app, across every account — the per-feature grain. */
|
|
32
|
+
export interface PlaceEngagement {
|
|
33
|
+
place: string;
|
|
34
|
+
/** Distinct accounts that have ever opened it. */
|
|
35
|
+
users: number;
|
|
36
|
+
views: number;
|
|
37
|
+
sessions: number;
|
|
38
|
+
activeMs: number;
|
|
39
|
+
lastTs: number;
|
|
40
|
+
/** Mean ms of attention per view. The "does it hold interest" number. */
|
|
41
|
+
msPerView: number;
|
|
42
|
+
}
|
|
43
|
+
/** One day of the app's whole activity. */
|
|
44
|
+
export interface DailyEngagement {
|
|
45
|
+
day: string;
|
|
46
|
+
views: number;
|
|
47
|
+
sessions: number;
|
|
48
|
+
/** Distinct accounts active that day. */
|
|
49
|
+
users: number;
|
|
50
|
+
}
|
|
51
|
+
export interface AppEngagement {
|
|
52
|
+
app: string;
|
|
53
|
+
generatedAt: number;
|
|
54
|
+
users: UserEngagement[];
|
|
55
|
+
places: PlaceEngagement[];
|
|
56
|
+
/** The (user, place) cross grain — what lets the console answer "which
|
|
57
|
+
* features does THIS person like", rather than only the two margins. */
|
|
58
|
+
userPlaces: Array<{
|
|
59
|
+
userKey: string;
|
|
60
|
+
place: string;
|
|
61
|
+
views: number;
|
|
62
|
+
activeMs: number;
|
|
63
|
+
lastTs: number;
|
|
64
|
+
}>;
|
|
65
|
+
daily: DailyEngagement[];
|
|
66
|
+
totals: {
|
|
67
|
+
users: number;
|
|
68
|
+
views: number;
|
|
69
|
+
sessions: number;
|
|
70
|
+
activeMs: number;
|
|
71
|
+
/** Accounts with more than one session — they came back. */
|
|
72
|
+
returning: number;
|
|
73
|
+
/** Accounts with exactly one session, ever. */
|
|
74
|
+
oneTime: number;
|
|
75
|
+
/** Accounts seen in the last week. */
|
|
76
|
+
activeUsers: number;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export interface SummarizeInput {
|
|
80
|
+
app: string;
|
|
81
|
+
now: number;
|
|
82
|
+
users: EngagementUser[];
|
|
83
|
+
places: EngagementUserPlace[];
|
|
84
|
+
days: EngagementDay[];
|
|
85
|
+
}
|
|
86
|
+
export declare function summarize(input: SummarizeInput): AppEngagement;
|
|
87
|
+
/**
|
|
88
|
+
* The return curve: of the accounts first seen on a given day, how many came
|
|
89
|
+
* back at least once afterwards.
|
|
90
|
+
*
|
|
91
|
+
* Stated as WHOLE ACCOUNTS on a named cohort day rather than as a percentage,
|
|
92
|
+
* because a percentage of one person is a number that lies confidently. The
|
|
93
|
+
* console renders "2 of 3 came back" for the same reason.
|
|
94
|
+
*/
|
|
95
|
+
export declare function returnCurve(users: Array<{
|
|
96
|
+
firstTs: number;
|
|
97
|
+
sessions: number;
|
|
98
|
+
}>, now: number, windowDays?: number): Array<{
|
|
99
|
+
day: string;
|
|
100
|
+
joined: number;
|
|
101
|
+
returned: number;
|
|
102
|
+
}>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One app's engagement, folded into the shape the console draws.
|
|
3
|
+
*
|
|
4
|
+
* ── Why the APP does the folding ────────────────────────────────────────────
|
|
5
|
+
* The same argument `metrics/inventory.ts` makes: the console runs on this Mac
|
|
6
|
+
* and most of the fleet runs on a checkout-less box reachable only over HTTPS,
|
|
7
|
+
* so the app that owns the data is the only thing that can read it. Shipping
|
|
8
|
+
* raw daily rows would also mean shipping the per-day timeline of a household's
|
|
9
|
+
* private apps across the wire on every poll, when the console draws totals.
|
|
10
|
+
*
|
|
11
|
+
* ── Everything here is pure over rows ───────────────────────────────────────
|
|
12
|
+
* `summarize` takes the three roll-ups and a clock, so `engagement.test.ts`
|
|
13
|
+
* drives it with hand-written rows and checks arithmetic a human can verify —
|
|
14
|
+
* which is the whole design constraint at n = 3. No I/O, no `Date.now()`.
|
|
15
|
+
*/
|
|
16
|
+
import { classifyInterest, dayKey, } from "./types.js";
|
|
17
|
+
export function summarize(input) {
|
|
18
|
+
const { app, now } = input;
|
|
19
|
+
const users = input.users.map((u) => ({
|
|
20
|
+
userKey: u.userKey,
|
|
21
|
+
views: u.views,
|
|
22
|
+
sessions: u.sessions,
|
|
23
|
+
activeMs: u.activeMs,
|
|
24
|
+
firstTs: u.firstTs,
|
|
25
|
+
lastTs: u.lastTs,
|
|
26
|
+
places: u.places,
|
|
27
|
+
interest: classifyInterest({ lastSeenTs: u.lastTs, sessions: u.sessions }, now),
|
|
28
|
+
daysSinceSeen: Math.max(0, Math.floor((now - u.lastTs) / 86_400_000)),
|
|
29
|
+
}));
|
|
30
|
+
// Fold the (user, place) rows down the USER axis to get the per-feature view.
|
|
31
|
+
const byPlace = new Map();
|
|
32
|
+
for (const row of input.places) {
|
|
33
|
+
const existing = byPlace.get(row.place);
|
|
34
|
+
if (existing) {
|
|
35
|
+
existing.users += 1;
|
|
36
|
+
existing.views += row.views;
|
|
37
|
+
existing.sessions += row.sessions;
|
|
38
|
+
existing.activeMs += row.activeMs;
|
|
39
|
+
existing.lastTs = Math.max(existing.lastTs, row.lastTs);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
byPlace.set(row.place, {
|
|
43
|
+
place: row.place,
|
|
44
|
+
users: 1,
|
|
45
|
+
views: row.views,
|
|
46
|
+
sessions: row.sessions,
|
|
47
|
+
activeMs: row.activeMs,
|
|
48
|
+
lastTs: row.lastTs,
|
|
49
|
+
msPerView: 0,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const places = [...byPlace.values()].map((p) => ({
|
|
54
|
+
...p,
|
|
55
|
+
// Integer ms: a mean over three people does not deserve decimals, and a
|
|
56
|
+
// whole number is one the owner can sanity-check against a stopwatch.
|
|
57
|
+
msPerView: p.views > 0 ? Math.round(p.activeMs / p.views) : 0,
|
|
58
|
+
}));
|
|
59
|
+
places.sort((a, b) => b.views - a.views || a.place.localeCompare(b.place));
|
|
60
|
+
// Distinct users per day needs the (user, place, day) grain, which is exactly
|
|
61
|
+
// why `days()` returns it rather than pre-summed days.
|
|
62
|
+
const dayAcc = new Map();
|
|
63
|
+
for (const row of input.days) {
|
|
64
|
+
let bucket = dayAcc.get(row.day);
|
|
65
|
+
if (!bucket) {
|
|
66
|
+
bucket = { views: 0, sessions: 0, users: new Set() };
|
|
67
|
+
dayAcc.set(row.day, bucket);
|
|
68
|
+
}
|
|
69
|
+
bucket.views += row.views;
|
|
70
|
+
bucket.sessions += row.sessions;
|
|
71
|
+
bucket.users.add(row.userKey);
|
|
72
|
+
}
|
|
73
|
+
const daily = [...dayAcc.entries()]
|
|
74
|
+
.map(([day, b]) => ({ day, views: b.views, sessions: b.sessions, users: b.users.size }))
|
|
75
|
+
.sort((a, b) => a.day.localeCompare(b.day));
|
|
76
|
+
const userPlaces = input.places
|
|
77
|
+
.map((p) => ({
|
|
78
|
+
userKey: p.userKey,
|
|
79
|
+
place: p.place,
|
|
80
|
+
views: p.views,
|
|
81
|
+
activeMs: p.activeMs,
|
|
82
|
+
lastTs: p.lastTs,
|
|
83
|
+
}))
|
|
84
|
+
.sort((a, b) => b.views - a.views);
|
|
85
|
+
return {
|
|
86
|
+
app,
|
|
87
|
+
generatedAt: now,
|
|
88
|
+
users,
|
|
89
|
+
places,
|
|
90
|
+
userPlaces,
|
|
91
|
+
daily,
|
|
92
|
+
totals: {
|
|
93
|
+
users: users.length,
|
|
94
|
+
views: users.reduce((n, u) => n + u.views, 0),
|
|
95
|
+
sessions: users.reduce((n, u) => n + u.sessions, 0),
|
|
96
|
+
activeMs: users.reduce((n, u) => n + u.activeMs, 0),
|
|
97
|
+
returning: users.filter((u) => u.sessions > 1).length,
|
|
98
|
+
oneTime: users.filter((u) => u.sessions <= 1).length,
|
|
99
|
+
activeUsers: users.filter((u) => u.interest === "active").length,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The return curve: of the accounts first seen on a given day, how many came
|
|
105
|
+
* back at least once afterwards.
|
|
106
|
+
*
|
|
107
|
+
* Stated as WHOLE ACCOUNTS on a named cohort day rather than as a percentage,
|
|
108
|
+
* because a percentage of one person is a number that lies confidently. The
|
|
109
|
+
* console renders "2 of 3 came back" for the same reason.
|
|
110
|
+
*/
|
|
111
|
+
export function returnCurve(users, now, windowDays = 30) {
|
|
112
|
+
const cutoff = now - windowDays * 86_400_000;
|
|
113
|
+
const acc = new Map();
|
|
114
|
+
for (const u of users) {
|
|
115
|
+
if (u.firstTs < cutoff)
|
|
116
|
+
continue;
|
|
117
|
+
const day = dayKey(u.firstTs);
|
|
118
|
+
const bucket = acc.get(day) ?? { joined: 0, returned: 0 };
|
|
119
|
+
bucket.joined += 1;
|
|
120
|
+
if (u.sessions > 1)
|
|
121
|
+
bucket.returned += 1;
|
|
122
|
+
acc.set(day, bucket);
|
|
123
|
+
}
|
|
124
|
+
return [...acc.entries()]
|
|
125
|
+
.map(([day, b]) => ({ day, ...b }))
|
|
126
|
+
.sort((a, b) => a.day.localeCompare(b.day));
|
|
127
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row shapes the engagement store reads and writes, plus the pure policy
|
|
3
|
+
* re-exported through one door.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `store.ts` so `summary.ts` — and the console's BROWSER code,
|
|
6
|
+
* which types the fetched payload — can import them without pulling in
|
|
7
|
+
* `bun:sqlite`. A type-only import would be erased, but the console also wants
|
|
8
|
+
* `classifyInterest` and `dayKey` at runtime to label a row it is rendering,
|
|
9
|
+
* and importing those from the store module would drag a native sqlite binding
|
|
10
|
+
* into a Vite build.
|
|
11
|
+
*/
|
|
12
|
+
export { classifyInterest, dayKey, daysSince, ENGAGEMENT_EXCLUDED_APPS, engagementAllowedForApp, foldView, INTEREST_ACTIVE_DAYS, INTEREST_FADING_DAYS, type InterestVerdict, MAX_DWELL_SLICE_MS, normalizePlaceId, OTHER_PLACE, resolvePlace, SESSION_GAP_MS, type ViewFold, } from "./policy.js";
|
|
13
|
+
/** One (user, place, UTC day) bucket. */
|
|
14
|
+
export interface EngagementDay {
|
|
15
|
+
userKey: string;
|
|
16
|
+
place: string;
|
|
17
|
+
day: string;
|
|
18
|
+
views: number;
|
|
19
|
+
activeMs: number;
|
|
20
|
+
sessions: number;
|
|
21
|
+
}
|
|
22
|
+
/** One (user, place) lifetime roll-up — what the retention answers read. */
|
|
23
|
+
export interface EngagementUserPlace {
|
|
24
|
+
userKey: string;
|
|
25
|
+
place: string;
|
|
26
|
+
views: number;
|
|
27
|
+
sessions: number;
|
|
28
|
+
activeMs: number;
|
|
29
|
+
firstTs: number;
|
|
30
|
+
lastTs: number;
|
|
31
|
+
}
|
|
32
|
+
/** One account's relationship with the whole app. */
|
|
33
|
+
export interface EngagementUser {
|
|
34
|
+
userKey: string;
|
|
35
|
+
views: number;
|
|
36
|
+
sessions: number;
|
|
37
|
+
activeMs: number;
|
|
38
|
+
firstTs: number;
|
|
39
|
+
lastTs: number;
|
|
40
|
+
/** Distinct places this account has ever opened in this app. */
|
|
41
|
+
places: number;
|
|
42
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The row shapes the engagement store reads and writes, plus the pure policy
|
|
3
|
+
* re-exported through one door.
|
|
4
|
+
*
|
|
5
|
+
* Split out of `store.ts` so `summary.ts` — and the console's BROWSER code,
|
|
6
|
+
* which types the fetched payload — can import them without pulling in
|
|
7
|
+
* `bun:sqlite`. A type-only import would be erased, but the console also wants
|
|
8
|
+
* `classifyInterest` and `dayKey` at runtime to label a row it is rendering,
|
|
9
|
+
* and importing those from the store module would drag a native sqlite binding
|
|
10
|
+
* into a Vite build.
|
|
11
|
+
*/
|
|
12
|
+
export { classifyInterest, dayKey, daysSince, ENGAGEMENT_EXCLUDED_APPS, engagementAllowedForApp, foldView, INTEREST_ACTIVE_DAYS, INTEREST_FADING_DAYS, MAX_DWELL_SLICE_MS, normalizePlaceId, OTHER_PLACE, resolvePlace, SESSION_GAP_MS, } from "./policy.js";
|