Skip to content

Commit 48906fb

Browse files
committed
chore: complete schema.sql bootstrap + remove dead code
schema.sql: add minute_day + read_cache tables and analytics_cursor wake columns (sleep_phase/phase_since/last_close_date) so a fresh DB bootstraps fully from schema.sql alone; index minute_day(ymd) for the seal/prune scans; mark legacy minute table deprecated. Remove dead code: index.ts cron-sweep helpers (sendChunked/enqueueJobs/ enqueueJobDays/lastNDates) and ingest_signals encodeRr/decodeRr.
1 parent 19900dd commit 48906fb

3 files changed

Lines changed: 38 additions & 52 deletions

File tree

src/db/schema.sql

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,37 @@ CREATE TABLE IF NOT EXISTS refresh_tokens(
3030
);
3131
CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
3232

33-
-- ── TIMESERIES ROLLUP (≤1440 rows/day/user; pruned to R2 after 90 days) ───────
34-
-- hr_sum / act_sum / act_n are running aggregates kept so the ingest upsert can
35-
-- merge new samples into the stored minute EXACTLY (deterministic idempotency).
36-
-- hr_avg / activity are the derived display values (kept in sync on every write).
33+
-- ── TIMESERIES (day-packed) ───────────────────────────────────────────────────
34+
-- [feat/wake-trigger] minute_day is the HOT minute store: ONE row per (user, ymd),
35+
-- value = gzipped JSON MinuteRec[] (one entry per touched minute; RR rides the blob
36+
-- as number[]). Ingest read-merge-writes ~1 row/day instead of ~1,440. Days older
37+
-- than the hot window are sealed to gzipped R2 objects and the D1 row dropped; the
38+
-- 10-day prune drops ~1 row/day. See minute_store.ts. (migrate_v14)
39+
CREATE TABLE IF NOT EXISTS minute_day(
40+
user_id TEXT NOT NULL,
41+
ymd TEXT NOT NULL, -- 'YYYY-MM-DD' (UTC day of the minute)
42+
blob BLOB NOT NULL, -- gzipped JSON MinuteRec[] (bound param; << 2MB cap)
43+
updated_at INTEGER NOT NULL,
44+
PRIMARY KEY(user_id, ymd)
45+
) WITHOUT ROWID;
46+
-- seal/prune scan by day across all users (WHERE ymd < cutoff) — index the bare ymd.
47+
CREATE INDEX IF NOT EXISTS idx_minute_day_ymd ON minute_day(ymd);
48+
49+
-- TTL read-cache for Tier 1/2 on-read metrics (no watermark; time-based only).
50+
-- key e.g. 'today:strain:2026-06-19'; today→60s, past days immutable-until-prune.
51+
-- See cache.ts. (migrate_v12)
52+
CREATE TABLE IF NOT EXISTS read_cache(
53+
user_id TEXT NOT NULL,
54+
key TEXT NOT NULL,
55+
payload TEXT NOT NULL, -- JSON
56+
computed_at INTEGER NOT NULL,
57+
PRIMARY KEY(user_id, key)
58+
) WITHOUT ROWID;
59+
60+
-- ── LEGACY ROLLUP (deprecated; empty on fresh DBs) ────────────────────────────
61+
-- The pre-v14 row-per-minute store. Superseded by minute_day above; left in place so
62+
-- old deployments keep working, but ingest no longer writes here. Safe to drop once
63+
-- no instance predates v14.
3764
CREATE TABLE IF NOT EXISTS minute(
3865
user_id TEXT NOT NULL,
3966
ts_min INTEGER NOT NULL, -- unix sec floored to minute
@@ -177,7 +204,13 @@ CREATE TABLE IF NOT EXISTS analytics_cursor(
177204
last_run INTEGER DEFAULT 0,
178205
steps_cursor_ts INTEGER, -- incremental steps: last settled minute counted (unix s)
179206
steps_cursor_day TEXT, -- UTC day the steps accumulator is for
180-
bio_last_date TEXT -- event-driven biometrics: last sleep-date already triggered
207+
bio_last_date TEXT, -- event-driven biometrics: last sleep-date already triggered
208+
-- [feat/wake-trigger] incremental sleep/wake state machine (migrate_v13). The */N
209+
-- cron reads these: skip awake-and-closed users, peek the asleep ones, fire close_day
210+
-- once per physiological day.
211+
sleep_phase TEXT, -- 'awake' | 'asleep' | NULL
212+
phase_since INTEGER, -- unix s of last transition
213+
last_close_date TEXT -- YYYY-MM-DD of last day-close
181214
);
182215

183216
-- Per-user ingest rate-limit token bucket (RESILIENCE §7).

src/index.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -420,34 +420,6 @@ app.post('/admin/prune', async (c) => {
420420
return c.json({ ok: true, events_deleted: ev.meta?.changes ?? 0 })
421421
})
422422

423-
// Send messages in sendBatch chunks of 100 (the Queues per-call max), so the cron
424-
// itself never blows its own subrequest budget no matter how many users.
425-
async function sendChunked(q: Queue<AnalyticsMessage>, msgs: { body: AnalyticsMessage }[]): Promise<void> {
426-
for (let i = 0; i < msgs.length; i += 100) {
427-
await q.sendBatch(msgs.slice(i, i + 100))
428-
}
429-
}
430-
431-
// One (user, job) message per user (day-less — for 'sweep').
432-
async function enqueueJobs(q: Queue<AnalyticsMessage>, userIds: string[], job: AnalyticsJob): Promise<void> {
433-
await sendChunked(q, userIds.map((user_id) => ({ body: { user_id, job } })))
434-
}
435-
436-
// One (user, job, day) message per user × day — heavy R2 jobs fanned out so each
437-
// consumer invocation processes exactly ONE bounded day (works for any user, no
438-
// matter how much they wore the band).
439-
async function enqueueJobDays(q: Queue<AnalyticsMessage>, userIds: string[], job: AnalyticsJob, dates: string[]): Promise<void> {
440-
const msgs: { body: AnalyticsMessage }[] = []
441-
for (const user_id of userIds) for (const day of dates) msgs.push({ body: { user_id, job, day } })
442-
await sendChunked(q, msgs)
443-
}
444-
445-
// The last `n` UTC dates (today first) as YYYY-MM-DD.
446-
function lastNDates(n: number): string[] {
447-
const now = Math.floor(Date.now() / 1000)
448-
return Array.from({ length: n }, (_, d) => new Date((Math.floor((now - d * DAY) / DAY) * DAY) * 1000).toISOString().slice(0, 10))
449-
}
450-
451423
export default {
452424
fetch: app.fetch,
453425

src/ingest_signals.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -103,22 +103,3 @@ export function perMinuteSignals(records: string[]): Map<number, MinuteSignal> {
103103
}
104104
return out
105105
}
106-
107-
/** Encode an RR list (ms) to a compact little-endian int16 blob. */
108-
export function encodeRr(rr: number[]): Uint8Array | null {
109-
if (!rr.length) return null
110-
const buf = new Uint8Array(rr.length * 2)
111-
const view = new DataView(buf.buffer)
112-
for (let i = 0; i < rr.length; i++) view.setInt16(i * 2, Math.max(0, Math.min(32767, Math.round(rr[i]))), true)
113-
return buf
114-
}
115-
116-
/** Decode a minute.rr blob back to an RR list (ms). */
117-
export function decodeRr(blob: ArrayBuffer | Uint8Array | null | undefined): number[] {
118-
if (!blob) return []
119-
const u8 = blob instanceof Uint8Array ? blob : new Uint8Array(blob)
120-
const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength)
121-
const out: number[] = []
122-
for (let i = 0; i + 2 <= u8.byteLength; i += 2) out.push(view.getInt16(i, true))
123-
return out
124-
}

0 commit comments

Comments
 (0)