Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
248 changes: 248 additions & 0 deletions app/api/biometrics/analysis-export/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// @vitest-environment node
import { createHash, hkdfSync } from 'node:crypto'
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, truncateSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { parseTarHeader } from '@/src/biometrics/backtest/bundle-tar'

const state = vi.hoisted(() => ({ archive: '', source: '', seed: { source: 'mmc-cid', value: 'cid-0123' } as { source: string, value: string } }))
vi.mock('@/src/lib/podStorage', () => ({
get ARCHIVE_DIR() {
return state.archive
},
}))
vi.mock('@/src/db', () => ({ biometricsDb: { $client: { get name() {
return state.source
} } } }))
vi.mock('@/src/homekit/storage', () => ({ readSeed: () => state.seed }))
vi.mock('@/src/hardware/dacMonitor.instance', () => ({
getDacMonitorIfRunning: () => ({ getLastStatus: () => ({ podVersion: 'J00', sensorLabel: '20600-0003-J55-B0708DE3' }) }),
}))

const START = Date.UTC(2026, 9, 10, 6)
const END = Date.UTC(2026, 9, 10, 14)
const MINUTE = 60_000
const journal = JSON.parse(readFileSync(join(process.cwd(), 'src/db/biometrics-migrations/meta/_journal.json'), 'utf-8')) as { entries: Array<{ when: number, tag: string }> }
const latest = journal.entries[journal.entries.length - 1]

let root: string
let snapshots: string

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'analysis-export-test-'))
state.archive = join(root, 'biometrics-archive')
snapshots = join(root, 'tmpfs')
mkdirSync(state.archive)
mkdirSync(snapshots)
vi.stubEnv('TMPDIR', snapshots)
state.source = join(root, 'biometrics.db')
const database = new Database(state.source)
database.pragma('journal_mode = WAL')
database.exec('CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash TEXT NOT NULL, created_at NUMERIC); CREATE TABLE vitals (id INTEGER PRIMARY KEY, heart_rate REAL)')
database.prepare('INSERT INTO __drizzle_migrations (hash, created_at) VALUES (?, ?), (?, ?)').run('old-hash', 1, 'latest-hash', latest.when)
// Left in the WAL (no checkpoint) so the snapshot must include uncheckpointed pages.
database.prepare('INSERT INTO vitals (heart_rate) VALUES (?), (?)').run(58, 61)
sources.push(database)
})
const sources: Database.Database[] = []
afterEach(() => {
for (const database of sources.splice(0)) database.close()
vi.unstubAllEnvs()
rmSync(root, { recursive: true, force: true })
})

function archive(name: string, mtimeMs: number, body: string | Buffer = `frame ${name}`) {
const path = join(state.archive, name)
writeFileSync(path, body)
utimesSync(path, mtimeMs / 1000, mtimeMs / 1000)
return path
}

async function loadRoute() {
vi.resetModules()
return await import('./route')
}

const get = (GET: (request: Request) => Promise<Response>, query: string, init?: RequestInit) =>
GET(new Request(`http://pod/api/biometrics/analysis-export?${query}`, init))

function untar(buffer: Buffer) {
const entries: Array<{ name: string, body: Buffer }> = []
let position = 0
for (;;) {
const header = parseTarHeader(buffer.subarray(position, position + 512))
position += 512
if (!header) break
entries.push({ name: header.name, body: buffer.subarray(position, position + header.bytes) })
position += Math.ceil(header.bytes / 512) * 512
}
return entries
}
const sha256 = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')

describe('GET /api/biometrics/analysis-export', () => {
it('selects archives by the widened mtime window and streams a hashed snapshot', async () => {
archive('A0000001.RAW.gz', START - 21 * MINUTE)
archive('A0000002.RAW.gz', START - 19 * MINUTE)
archive('A0000003.RAW.gz', START + 4 * 60 * MINUTE, Buffer.alloc(1300, 7))
archive('A0000004.RAW.gz', END + 44 * MINUTE)
archive('A0000005.RAW.gz', END + 46 * MINUTE)
archive('A0000006.RAW.gz.tmp.4242', START + MINUTE)
archive('notes.txt', START + MINUTE)
symlinkSync(archive('linked-target', START), join(state.archive, 'A0000007.RAW.gz'))
const before = readdirSync(state.archive).sort()

const { GET } = await loadRoute()
const response = await get(GET, `start=${new Date(START).toISOString()}&end=${END}`)
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toBe('application/x-tar')
expect(response.headers.get('Cache-Control')).toBe('no-store, no-transform')
const buffer = Buffer.from(await response.arrayBuffer())
expect(buffer.length).toBe(Number(response.headers.get('Content-Length')))

const entries = untar(buffer)
expect(entries.map(entry => entry.name)).toEqual(['biometrics.db', 'raw/A0000002.RAW.gz', 'raw/A0000003.RAW.gz', 'raw/A0000004.RAW.gz', 'manifest.json'])
const manifest = JSON.parse(entries[4].body.toString())
expect(manifest.formatVersion).toBe('analysis-export-v1')
expect(manifest.files).toEqual(entries.slice(0, 4).map(entry => expect.objectContaining({ path: entry.name, bytes: entry.body.length, sha256: sha256(entry.body) })))
expect(manifest.files[2]).toEqual({ role: 'raw', path: 'raw/A0000003.RAW.gz', name: 'A0000003.RAW.gz', mtimeMs: START + 4 * 60 * MINUTE, bytes: 1300, sha256: sha256(Buffer.alloc(1300, 7)) })
expect(manifest.window).toEqual({
requested: { startMs: START, endMs: END, start: new Date(START).toISOString(), end: new Date(END).toISOString() },
rawSelection: { includeRaw: true, mtimeFromMs: START - 20 * MINUTE, mtimeToMs: END + 45 * MINUTE },
served: { archiveCount: 3, rawMtimeMinMs: START - 19 * MINUTE, rawMtimeMaxMs: END + 44 * MINUTE },
})
expect(manifest.pod).toEqual({ id: Buffer.from(hkdfSync('sha256', 'cid-0123', 'sleepypod-pod-id-v1', 'pod-id', 16)).toString('hex'),
idSource: 'mmc-cid', hostname: hostname(), model: { podVersion: 'J00', name: 'Pod 5', hardwareRevision: 'J55' } })
expect(manifest.schema.biometrics).toEqual({ migrationHash: 'latest-hash', migrationCreatedAt: latest.when, migrationTag: latest.tag, appliedMigrations: 2 })
expect(manifest.build).toEqual(expect.objectContaining({ branch: expect.any(String), commitHash: expect.any(String) }))

const extracted = join(root, 'extracted.db')
writeFileSync(extracted, entries[0].body)
const copy = new Database(extracted, { readonly: true })
expect(copy.prepare('SELECT heart_rate FROM vitals ORDER BY id').pluck().all()).toEqual([58, 61])
copy.close()

expect(readdirSync(snapshots)).toEqual([])
expect(readdirSync(state.archive).sort()).toEqual(before)
})

it('omits RAW archives with raw=0', async () => {
archive('A0000001.RAW.gz', START)
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}&raw=0`)
const entries = untar(Buffer.from(await response.arrayBuffer()))
expect(entries.map(entry => entry.name)).toEqual(['biometrics.db', 'manifest.json'])
const manifest = JSON.parse(entries[1].body.toString())
expect(manifest.window.served).toEqual({ archiveCount: 0, rawMtimeMinMs: null, rawMtimeMaxMs: null })
expect(manifest.window.rawSelection.includeRaw).toBe(false)
})

it('reports a null pod id when only a random dev seed exists', async () => {
state.seed = { source: 'random-dev', value: 'abc' }
try {
const { GET } = await loadRoute()
const entries = untar(Buffer.from(await (await get(GET, `start=${START}&end=${END}`)).arrayBuffer()))
expect(JSON.parse(String(entries.at(-1)?.body)).pod).toMatchObject({ id: null, idSource: 'random-dev' })
}
finally {
state.seed = { source: 'mmc-cid', value: 'cid-0123' }
}
})

it.each([
['', 'missing start and end'],
[`start=${START}`, 'missing end'],
[`start=${END}&end=${START}`, 'start after end'],
[`start=${START}&end=${START}`, 'empty window'],
[`start=${START}&end=${START + 14 * 24 * 60 * MINUTE + 1}`, 'over 14 days'],
[`start=yesterday&end=${END}`, 'unparseable start'],
[`start=${START}&end=${END}&raw=yes`, 'bad raw flag'],
])('rejects %s (%s) with 400', async (query) => {
const { GET } = await loadRoute()
const response = await get(GET, query)
expect(response.status).toBe(400)
expect((await response.json()).error).toMatch(/start and end/)
expect(readdirSync(snapshots)).toEqual([])
})

it('accepts exactly 14 days', async () => {
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${START + 14 * 24 * 60 * MINUTE}&raw=0`)
expect(response.status).toBe(200)
await response.arrayBuffer()
})

it('rejects windows over 2 GiB of RAW with 413 and no snapshot', async () => {
const sparse = archive('A0000001.RAW.gz', START, '')
truncateSync(sparse, 2 * 1024 ** 3 - 10)
utimesSync(sparse, START / 1000, START / 1000)
archive('A0000002.RAW.gz', START + MINUTE, Buffer.alloc(11))
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}`)
expect(response.status).toBe(413)
expect((await response.json()).error).toMatch(/2 RAW archives totalling 2147483649 bytes.*raw=0/)
expect(readdirSync(snapshots)).toEqual([])
const without = await get(GET, `start=${START}&end=${END}&raw=0`)
expect(without.status).toBe(200)
await without.arrayBuffer()
})

it('allows one export at a time and removes the snapshot when the client cancels', async () => {
archive('A0000001.RAW.gz', START, Buffer.alloc(600_000, 1))
const { GET } = await loadRoute()
const first = await get(GET, `start=${START}&end=${END}`)
expect(readdirSync(snapshots)).toHaveLength(1)
expect((await get(GET, `start=${START}&end=${END}`)).status).toBe(429)
const reader = (first.body as ReadableStream<Uint8Array>).getReader()
await reader.read()
await reader.cancel()
await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([]))
const next = await get(GET, `start=${START}&end=${END}&raw=0`)
expect(next.status).toBe(200)
await next.arrayBuffer()
})

it('removes the snapshot when the request is aborted mid-stream', async () => {
archive('A0000001.RAW.gz', START, Buffer.alloc(600_000, 1))
const { GET } = await loadRoute()
const controller = new AbortController()
const response = await get(GET, `start=${START}&end=${END}`, { signal: controller.signal })
await (response.body as ReadableStream<Uint8Array>).getReader().read()
controller.abort()
await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([]))
expect((await get(GET, `start=${START}&end=${END}&raw=0`)).status).toBe(200)
})

it('cleans up and streams nothing when the client left while the snapshot was taken', async () => {
archive('A0000001.RAW.gz', START)
const { GET } = await loadRoute()
const controller = new AbortController()
controller.abort()
const response = await get(GET, `start=${START}&end=${END}`, { signal: controller.signal })
expect(response.status).toBe(499)
expect(readdirSync(snapshots)).toEqual([])
expect((await get(GET, `start=${START}&end=${END}&raw=0`)).status).toBe(200)
})

it('aborts the stream and removes the snapshot when an archive vanishes', async () => {
const path = archive('A0000001.RAW.gz', START)
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}`)
unlinkSync(path)
await expect(response.arrayBuffer()).rejects.toThrow()
await vi.waitFor(() => expect(readdirSync(snapshots)).toEqual([]))
})

it('returns 500 and cleans up when the database cannot be read', async () => {
state.source = join(root, 'missing.db')
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const { GET } = await loadRoute()
const response = await get(GET, `start=${START}&end=${END}`)
expect(response.status).toBe(500)
expect(error).toHaveBeenCalled()
expect(readdirSync(snapshots)).toEqual([])
error.mockRestore()
})
})
128 changes: 128 additions & 0 deletions app/api/biometrics/analysis-export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { mkdtemp, rm, statfs } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { biometricsDb } from '@/src/db'
import { AnalysisExportTooLargeError, analysisWindowSchema, liveDatabaseBytes, planAnalysisExport } from '@/src/biometrics/analysis-export'
import { analysisBundleTarBytes, analysisExportChunks, podIdentity, readBuildInfo, snapshotBiometricsDatabase, type AnalysisBundle } from '@/src/biometrics/analysis-export-bundle'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

// One export at a time: each holds a database snapshot on tmpfs (RAM) until it finishes.
let inflight = false
const EXPORT_WATCHDOG_MS = (() => {
const raw = Number(process.env.ANALYSIS_EXPORT_WATCHDOG_MS ?? '')
return Number.isFinite(raw) && raw > 0 ? raw : 60 * 60 * 1000
})()

/**
* Stream an analysis snapshot as an uncompressed tar: a consistent backup of
* biometrics.db, the RAW archives for the window (raw=1, the default) and
* manifest.json with every file's SHA-256. Read-only: the backup goes through
* a read-only connection into os.tmpdir() (tmpfs on the pod) and is deleted
* when the stream ends, fails or the client disconnects.
*/
export async function GET(request: Request) {
const query = new URL(request.url).searchParams
const raw = query.get('raw') ?? '1'
const parsed = raw === '0' || raw === '1'
? analysisWindowSchema.safeParse({ start: query.get('start') ?? '', end: query.get('end') ?? '', includeRaw: raw === '1' })
: null
if (!parsed?.success) {
const detail = parsed ? parsed.error.issues.map(issue => issue.message).join('; ') : 'raw must be 0 or 1'
return Response.json({ error: `Specify start and end as ISO 8601 or epoch ms, at most 14 days apart, and raw=0|1: ${detail}` }, { status: 400 })
}
if (inflight) return Response.json({ error: 'Another analysis export is in progress, retry later' }, { status: 429, headers: { 'Retry-After': '60' } })
inflight = true

let directory: string | undefined
const release = async () => {
const target = directory
directory = undefined
try {
if (target) await rm(target, { recursive: true, force: true })
}
finally { inflight = false }
}

let bundle: AnalysisBundle
try {
const plan = await planAnalysisExport(parsed.data)
const source = biometricsDb.$client.name
const neededBytes = await liveDatabaseBytes(source)
const space = await statfs(tmpdir())
if (neededBytes > space.bavail * space.bsize) {
await release()
return Response.json({ error: `Not enough free space in ${tmpdir()} for a ${neededBytes}-byte database snapshot` }, { status: 507 })
}
directory = await mkdtemp(path.join(tmpdir(), 'sp-analysis-export-'))
const [snapshot, build] = await Promise.all([snapshotBiometricsDatabase(source, directory), readBuildInfo()])
bundle = { generatedAtMs: Date.now(), window: plan.window, database: { path: snapshot.path, bytes: snapshot.bytes },
archives: plan.archives, build, pod: podIdentity(), schema: snapshot.schema }
}
catch (error) {
await release()
if (error instanceof AnalysisExportTooLargeError) return Response.json({ error: error.message }, { status: 413 })
console.error('analysis export:', error)
return Response.json({ error: 'Analysis export failed' }, { status: 500 })
}

const chunks = analysisExportChunks(bundle)
let watchdog: ReturnType<typeof setTimeout> | undefined
let finished = false
// Runs once on completion, failure, cancel, client abort or the watchdog.
// chunks.return() closes any open file handle before the snapshot is removed.
const finish = async () => {
if (finished) return
finished = true
if (watchdog) clearTimeout(watchdog)
request.signal.removeEventListener('abort', onAbort)
try {
await chunks.return(undefined)
}
finally { await release() }
}
const onAbort = () => void finish()
request.signal.addEventListener('abort', onAbort, { once: true })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The client may have gone while the snapshot was being taken; the abort
// event has then already fired, so clean up now instead of streaming.
if (request.signal.aborted) {
await finish()
return new Response(null, { status: 499 })
}
const body = new ReadableStream<Uint8Array>({
start(controller) {
watchdog = setTimeout(() => {
controller.error(new Error('Analysis export exceeded its time limit'))
void finish()
}, EXPORT_WATCHDOG_MS)
},
async pull(controller) {
try {
const next = await chunks.next()
if (next.done) {
await finish()
controller.close()
}
else controller.enqueue(next.value)
}
catch (error) {
await finish()
controller.error(error)
}
},
async cancel() {
await finish()
},
})
const { startMs, endMs } = bundle.window
return new Response(body, {
headers: {
'Content-Type': 'application/x-tar',
'Content-Disposition': `attachment; filename="sleepypod-analysis-${startMs}-${endMs}.tar"`,
'Content-Length': String(analysisBundleTarBytes(bundle)),
// no-transform: a proxy that recompresses the tar would break Content-Length and the manifest hashes.
'Cache-Control': 'no-store, no-transform',
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}
Loading
Loading