From 998491468c7bcf5432f1d4c67ed7ee35fc1f2033 Mon Sep 17 00:00:00 2001
From: Jonathan Ng
Date: Sat, 17 Oct 2026 15:21:12 -0700
Subject: [PATCH 1/3] feat(storage): show eMMC wear and writes/day, keep the
journal in RAM to cut flash wear
---
scripts/bin/sp-update | 5 +
scripts/install | 18 +-
scripts/lib/journald-helpers | 26 +
src/components/System/StorageTab.tsx | 63 +-
.../System/tests/StorageTab.test.tsx | 46 +
.../0031_wide_black_knight.sql | 8 +
.../meta/0031_snapshot.json | 3006 +++++++++++++++++
.../biometrics-migrations/meta/_journal.json | 7 +
src/db/biometrics-schema.ts | 14 +
src/db/retention.ts | 4 +-
.../tests/__snapshots__/schema.test.ts.snap | 17 +
src/db/tests/retention.test.ts | 9 +-
src/demo/handlers/system.ts | 10 +
src/lib/flashHealth.ts | 160 +
src/lib/flashSampler.ts | 44 +
src/lib/tests/flashHealth.test.ts | 170 +
src/server/instrumentation-node.ts | 4 +
src/server/routers/system.ts | 21 +
18 files changed, 3616 insertions(+), 16 deletions(-)
create mode 100644 scripts/lib/journald-helpers
create mode 100644 src/db/biometrics-migrations/0031_wide_black_knight.sql
create mode 100644 src/db/biometrics-migrations/meta/0031_snapshot.json
create mode 100644 src/lib/flashHealth.ts
create mode 100644 src/lib/flashSampler.ts
create mode 100644 src/lib/tests/flashHealth.test.ts
diff --git a/scripts/bin/sp-update b/scripts/bin/sp-update
index d80052f96..a9b31b20d 100644
--- a/scripts/bin/sp-update
+++ b/scripts/bin/sp-update
@@ -665,6 +665,11 @@ if [ -f "$INSTALL_DIR/scripts/lib/storage-helpers" ]; then
install_storage_cleanup_sudoers
fi
+if [ -f "$INSTALL_DIR/scripts/lib/journald-helpers" ]; then
+ source "$INSTALL_DIR/scripts/lib/journald-helpers"
+ install_journald_config
+fi
+
# Re-block WAN before starting service
if [ "$WAN_WAS_BLOCKED" = true ]; then
reload_iptables_helpers
diff --git a/scripts/install b/scripts/install
index 08856aa0f..dace21dc4 100755
--- a/scripts/install
+++ b/scripts/install
@@ -279,9 +279,9 @@ reclaim_rootfs_caches() {
rm -rf /var/lib/dnf
fi
- # Journal vacuum. The journald config below caps SystemMaxUse=50M, but
- # only enforces it on rotation. A one-shot vacuum brings the on-disk
- # size in line immediately; the cap keeps growth bounded after that.
+ # Journal vacuum. The journald config below keeps new logs in RAM, so
+ # the on-disk journal stops growing; a one-shot vacuum trims what the
+ # firmware's persistent journal already holds.
if command -v journalctl &>/dev/null; then
echo " Vacuuming systemd journal to 20M..."
journalctl --vacuum-size=20M >/dev/null 2>&1 || true
@@ -1489,15 +1489,9 @@ RuntimeDirectory=dac
WantedBy=multi-user.target
EOF
-# Cap journal size to prevent unbounded log growth on limited disk
-mkdir -p /etc/systemd/journald.conf.d
-cat > /etc/systemd/journald.conf.d/sleepypod.conf << 'JOURNALD'
-[Journal]
-SystemMaxUse=50M
-SystemKeepFree=200M
-MaxFileSec=1week
-JOURNALD
-systemctl restart systemd-journald 2>/dev/null || true
+# Keep the journal in RAM so firmware log chatter doesn't wear the eMMC
+source "$INSTALL_DIR/scripts/lib/journald-helpers"
+install_journald_config
# Install CLI tools from scripts/bin/ BEFORE the service starts. The systemd
# unit's ExecStartPre references /usr/local/bin/sp-maintenance — if these
diff --git a/scripts/lib/journald-helpers b/scripts/lib/journald-helpers
new file mode 100644
index 000000000..e541b2f7d
--- /dev/null
+++ b/scripts/lib/journald-helpers
@@ -0,0 +1,26 @@
+# shellcheck shell=bash
+# Keep the systemd journal in RAM (/run/log/journal). Eight's firmware sets
+# Storage=persistent and links /var/log/journal to /persistent/journal, so
+# frank's steady log stream otherwise lands on the eMMC around the clock. A
+# drop-in overrides journald.conf; logs from earlier boots are the cost.
+# Shared by install and sp-update so existing pods pick it up on update.
+install_journald_config() {
+ local file="/etc/systemd/journald.conf.d/sleepypod.conf" tmp
+ mkdir -p /etc/systemd/journald.conf.d
+ tmp=$(mktemp)
+ cat > "$tmp" <<'JOURNALD'
+[Journal]
+Storage=volatile
+RuntimeMaxUse=48M
+RuntimeKeepFree=100M
+MaxFileSec=1week
+JOURNALD
+ if cmp -s "$tmp" "$file"; then
+ rm -f "$tmp"
+ return 0
+ fi
+ install -m 0644 "$tmp" "$file"
+ rm -f "$tmp"
+ # journald keeps service stdout streams in its fd store across a restart.
+ systemctl restart systemd-journald 2>/dev/null || true
+}
diff --git a/src/components/System/StorageTab.tsx b/src/components/System/StorageTab.tsx
index 441a74400..d12ae5d95 100644
--- a/src/components/System/StorageTab.tsx
+++ b/src/components/System/StorageTab.tsx
@@ -1,15 +1,17 @@
'use client'
import { useState } from 'react'
-import { HardDrive, Trash2 } from 'lucide-react'
+import { Gauge, HardDrive, Trash2 } from 'lucide-react'
import type { inferRouterOutputs } from '@trpc/server'
import type { AppRouter } from '@/src/server/routers/app'
import { trpc } from '@/src/utils/trpc'
-import { Button, Card, CardHeader, InlineError, Skeleton, Toggle } from '@/src/components/ds'
+import { Button, Card, CardHeader, InlineError, KeyValue, Skeleton, StatusDot, Toggle } from '@/src/components/ds'
import { ConfirmDialog } from '@/src/components/Schedule/ConfirmDialog'
import { formatBytes } from '@/src/components/status/SystemInfoCard'
type Storage = inferRouterOutputs['system']['getStorage']
+type Flash = NonNullable['system']['getFlashHealth']>
+type Wear = Flash['lifeTime'][number]
type SegmentKey = Storage['segments'][number]['key']
const SEGMENTS: Record = {
@@ -26,6 +28,60 @@ function shortPath(p: string): string {
return p.replace(/^\/persistent\//, '')
}
+const PRE_EOL = {
+ normal: { label: 'Normal', tone: 'ok' },
+ warning: { label: 'Warning', tone: 'warn' },
+ urgent: { label: 'Urgent', tone: 'danger' },
+} as const
+
+/** JEDEC reports wear in 10% steps; "exceeded" has no upper bound. */
+function wearLabel(w: NonNullable): string {
+ return w.maxPercent == null ? 'Past rated life' : `${w.minPercent}–${w.maxPercent}%`
+}
+
+/** Card tone from the chip's own estimates: 80%+ worn or reserve blocks low is a warning. */
+function flashTone(f: Flash): 'warn' | 'danger' | undefined {
+ const worst = Math.max(...f.lifeTime.map(w => w?.minPercent ?? 0))
+ if (f.preEol === 'urgent' || worst >= 100) return 'danger'
+ if (f.preEol === 'warning' || worst >= 80) return 'warn'
+ return undefined
+}
+
+/**
+ * eMMC endurance: how worn the chip says it is, and how fast the pod is
+ * writing to it. Hidden off-pod, where there's no eMMC to read.
+ */
+function FlashCard({ flash }: { flash: Flash }) {
+ const [slc, main] = flash.lifeTime
+ const eol = flash.preEol && PRE_EOL[flash.preEol]
+ return (
+
+ }
+ />
+
+
+ {main && }
+ {slc && }
+
+
+
+
+ {flash.perDay
+ ? `Averaged over the last ${flash.perDay.coveredHours < 48 ? `${flash.perDay.coveredHours} h` : `${Math.round(flash.perDay.coveredHours / 24)} days`}. `
+ : 'Writes per day appear after an hour of samples. '}
+ {'Covers everything on the chip — Eight\'s firmware, sleepypod and the OS. Wear is the chip\'s own estimate, in 10% steps.'}
+
+
+ )
+}
+
/**
* System → Storage: what fills /persistent, how much raw history that leaves
* once the pruner trims to its target, and a one-button cleanup of
@@ -34,6 +90,7 @@ function shortPath(p: string): string {
export function StorageTab() {
const utils = trpc.useUtils()
const storage = trpc.system.getStorage.useQuery({}, { refetchInterval: 60_000 })
+ const flash = trpc.system.getFlashHealth.useQuery({}, { refetchInterval: 60_000 })
const [includeBackups, setIncludeBackups] = useState(false)
const [confirming, setConfirming] = useState(false)
const free = trpc.system.freeStorage.useMutation({
@@ -127,6 +184,8 @@ export function StorageTab() {
+ {flash.data && }
+
({
mutate: vi.fn(),
result: undefined as unknown,
pending: false,
+ flash: null as unknown,
}))
vi.mock('@/src/utils/trpc', () => ({
@@ -15,6 +16,7 @@ vi.mock('@/src/utils/trpc', () => ({
useUtils: () => ({ system: { getStorage: { invalidate: vi.fn() } } }),
system: {
getStorage: { useQuery: () => ({ data: mocks.pending ? undefined : mocks.data, isPending: mocks.pending, isLoading: false, error: null }) },
+ getFlashHealth: { useQuery: () => ({ data: mocks.flash }) },
freeStorage: { useMutation: () => ({ mutate: mocks.mutate, isPending: false, error: null, data: mocks.result }) },
},
},
@@ -26,6 +28,7 @@ beforeEach(() => {
mocks.mutate.mockReset()
mocks.result = undefined
mocks.pending = false
+ mocks.flash = null
mocks.data = {
persistent: { totalBytes: 15 * GB, usedBytes: 12 * GB, availableBytes: 3 * GB, usedPercent: 80 },
prunerTargetPercent: 80,
@@ -96,4 +99,47 @@ describe('StorageTab', () => {
expect(screen.getByText(/Cleanup tool not installed/)).toBeTruthy()
expect(screen.queryByRole('button', { name: /Free up/ })).toBeNull()
})
+
+ describe('flash endurance', () => {
+ const flash = (over: Record = {}) => ({
+ device: 'mmcblk0',
+ name: 'C9A551',
+ capacityBytes: 29 * GB,
+ lifeTime: [{ minPercent: 30, maxPercent: 40 }, null],
+ preEol: 'normal',
+ sinceBoot: { bytes: 1.5 * GB, uptimeSeconds: 2600 },
+ perDay: { bytes: 3.1 * GB, coveredHours: 168 },
+ ...over,
+ })
+
+ it('is hidden off-pod', () => {
+ render()
+ expect(screen.queryByTestId('flash-card')).toBeNull()
+ })
+
+ it('shows wear, reserve blocks and writes per day', () => {
+ mocks.flash = flash()
+ render()
+ expect(screen.getByText('C9A551 · 29.0 GB eMMC')).toBeTruthy()
+ expect(screen.getByText('30–40%')).toBeTruthy()
+ expect(screen.queryByText('Wear (main area)')).toBeNull()
+ expect(screen.getByText('3.1 GB')).toBeTruthy()
+ expect(screen.getByText(/Averaged over the last 7 days/)).toBeTruthy()
+ expect(screen.getAllByText('Normal').length).toBeGreaterThan(0)
+ })
+
+ it('says it is still measuring before an hour of samples', () => {
+ mocks.flash = flash({ perDay: null })
+ render()
+ expect(screen.getByText('Measuring…')).toBeTruthy()
+ expect(screen.getByText(/appear after an hour of samples/)).toBeTruthy()
+ })
+
+ it('flags a chip past rated life', () => {
+ mocks.flash = flash({ lifeTime: [null, { minPercent: 100, maxPercent: null }], preEol: 'urgent' })
+ render()
+ expect(screen.getByText('Past rated life')).toBeTruthy()
+ expect(screen.getByTestId('flash-card').className).toContain('border-danger-line')
+ })
+ })
})
diff --git a/src/db/biometrics-migrations/0031_wide_black_knight.sql b/src/db/biometrics-migrations/0031_wide_black_knight.sql
new file mode 100644
index 000000000..670381943
--- /dev/null
+++ b/src/db/biometrics-migrations/0031_wide_black_knight.sql
@@ -0,0 +1,8 @@
+CREATE TABLE `flash_write_samples` (
+ `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ `timestamp` integer NOT NULL,
+ `boot_id` text NOT NULL,
+ `sectors_written` integer NOT NULL
+);
+--> statement-breakpoint
+CREATE INDEX `idx_flash_write_samples_timestamp` ON `flash_write_samples` (`timestamp`);
\ No newline at end of file
diff --git a/src/db/biometrics-migrations/meta/0031_snapshot.json b/src/db/biometrics-migrations/meta/0031_snapshot.json
new file mode 100644
index 000000000..b97b4ff95
--- /dev/null
+++ b/src/db/biometrics-migrations/meta/0031_snapshot.json
@@ -0,0 +1,3006 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "53709beb-0cba-4645-9770-0ef733082ef6",
+ "prevId": "8642f917-fd30-4e9e-8b99-de14b5ea5dca",
+ "tables": {
+ "ambient_light": {
+ "name": "ambient_light",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lux": {
+ "name": "lux",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_ambient_light_timestamp": {
+ "name": "idx_ambient_light_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bed_temp": {
+ "name": "bed_temp",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ambient_temp": {
+ "name": "ambient_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mcu_temp": {
+ "name": "mcu_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "humidity": {
+ "name": "humidity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "left_outer_temp": {
+ "name": "left_outer_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "left_center_temp": {
+ "name": "left_center_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "left_inner_temp": {
+ "name": "left_inner_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "right_outer_temp": {
+ "name": "right_outer_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "right_center_temp": {
+ "name": "right_center_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "right_inner_temp": {
+ "name": "right_inner_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_bed_temp_timestamp": {
+ "name": "idx_bed_temp_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_backtest_jobs": {
+ "name": "biometric_backtest_jobs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "experiment_id": {
+ "name": "experiment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_hash": {
+ "name": "request_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "input": {
+ "name": "input",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lease_owner": {
+ "name": "lease_owner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lease_expires_at_ms": {
+ "name": "lease_expires_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fencing_token": {
+ "name": "fencing_token",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "heartbeat_at_ms": {
+ "name": "heartbeat_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "progress": {
+ "name": "progress",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cancellation_requested_at_ms": {
+ "name": "cancellation_requested_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at_ms": {
+ "name": "updated_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "biometric_backtest_jobs_idempotency_key_unique": {
+ "name": "biometric_backtest_jobs_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_job_state_created": {
+ "name": "idx_biometric_job_state_created",
+ "columns": [
+ "state",
+ "created_at_ms"
+ ],
+ "isUnique": false
+ },
+ "idx_biometric_job_experiment": {
+ "name": "idx_biometric_job_experiment",
+ "columns": [
+ "experiment_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "biometric_backtest_jobs_experiment_id_biometric_experiments_id_fk": {
+ "name": "biometric_backtest_jobs_experiment_id_biometric_experiments_id_fk",
+ "tableFrom": "biometric_backtest_jobs",
+ "tableTo": "biometric_experiments",
+ "columnsFrom": [
+ "experiment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_backtest_results": {
+ "name": "biometric_backtest_results",
+ "columns": {
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "fencing_token": {
+ "name": "fencing_token",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "report": {
+ "name": "report",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "completed_at_ms": {
+ "name": "completed_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "released_at_ms": {
+ "name": "released_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "biometric_backtest_results_job_id_biometric_backtest_jobs_id_fk": {
+ "name": "biometric_backtest_results_job_id_biometric_backtest_jobs_id_fk",
+ "tableFrom": "biometric_backtest_results",
+ "tableTo": "biometric_backtest_jobs",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_dataset_raw_artifacts": {
+ "name": "biometric_dataset_raw_artifacts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dataset_id": {
+ "name": "dataset_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "artifact_hash": {
+ "name": "artifact_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "released_at_ms": {
+ "name": "released_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_biometric_dataset_raw_identity": {
+ "name": "idx_biometric_dataset_raw_identity",
+ "columns": [
+ "dataset_id",
+ "artifact_hash"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_dataset_raw_position": {
+ "name": "idx_biometric_dataset_raw_position",
+ "columns": [
+ "dataset_id",
+ "position"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_raw_artifact_references": {
+ "name": "idx_biometric_raw_artifact_references",
+ "columns": [
+ "artifact_hash",
+ "released_at_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "biometric_dataset_raw_artifacts_dataset_id_biometric_datasets_id_fk": {
+ "name": "biometric_dataset_raw_artifacts_dataset_id_biometric_datasets_id_fk",
+ "tableFrom": "biometric_dataset_raw_artifacts",
+ "tableTo": "biometric_datasets",
+ "columnsFrom": [
+ "dataset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "biometric_dataset_raw_artifacts_artifact_hash_biometric_raw_artifacts_content_hash_fk": {
+ "name": "biometric_dataset_raw_artifacts_artifact_hash_biometric_raw_artifacts_content_hash_fk",
+ "tableFrom": "biometric_dataset_raw_artifacts",
+ "tableTo": "biometric_raw_artifacts",
+ "columnsFrom": [
+ "artifact_hash"
+ ],
+ "columnsTo": [
+ "content_hash"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_datasets": {
+ "name": "biometric_datasets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "request_hash": {
+ "name": "request_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "manifest": {
+ "name": "manifest",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "byte_length": {
+ "name": "byte_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "released_at_ms": {
+ "name": "released_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "biometric_datasets_idempotency_key_unique": {
+ "name": "biometric_datasets_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_dataset_side_created": {
+ "name": "idx_biometric_dataset_side_created",
+ "columns": [
+ "side",
+ "created_at_ms",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_experiments": {
+ "name": "biometric_experiments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "request_hash": {
+ "name": "request_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dataset_id": {
+ "name": "dataset_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol": {
+ "name": "protocol",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "protocol_hash": {
+ "name": "protocol_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "holdout_accesses": {
+ "name": "holdout_accesses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "biometric_experiments_idempotency_key_unique": {
+ "name": "biometric_experiments_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_experiment_dataset": {
+ "name": "idx_biometric_experiment_dataset",
+ "columns": [
+ "dataset_id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "biometric_experiments_dataset_id_biometric_datasets_id_fk": {
+ "name": "biometric_experiments_dataset_id_biometric_datasets_id_fk",
+ "tableFrom": "biometric_experiments",
+ "tableTo": "biometric_datasets",
+ "columnsFrom": [
+ "dataset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_holdout_accesses": {
+ "name": "biometric_holdout_accesses",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_ms": {
+ "name": "start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_ms": {
+ "name": "end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "accessed_at_ms": {
+ "name": "accessed_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_biometric_holdout_side_start": {
+ "name": "idx_biometric_holdout_side_start",
+ "columns": [
+ "side",
+ "start_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "biometric_holdout_accesses_job_id_biometric_backtest_jobs_id_fk": {
+ "name": "biometric_holdout_accesses_job_id_biometric_backtest_jobs_id_fk",
+ "tableFrom": "biometric_holdout_accesses",
+ "tableTo": "biometric_backtest_jobs",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_processing_runs": {
+ "name": "biometric_processing_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bundle": {
+ "name": "bundle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "config_hash": {
+ "name": "config_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "calibration_hash": {
+ "name": "calibration_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "input_identity": {
+ "name": "input_identity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at_ms": {
+ "name": "started_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ended_at_ms": {
+ "name": "ended_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_biometric_processing_side_start": {
+ "name": "idx_biometric_processing_side_start",
+ "columns": [
+ "side",
+ "started_at_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_raw_artifacts": {
+ "name": "biometric_raw_artifacts",
+ "columns": {
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "compressed_bytes": {
+ "name": "compressed_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expanded_bytes": {
+ "name": "expanded_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "released_at_ms": {
+ "name": "released_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "biometric_raw_artifacts_name_unique": {
+ "name": "biometric_raw_artifacts_name_unique",
+ "columns": [
+ "name"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_raw_sources": {
+ "name": "biometric_raw_sources",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source_name": {
+ "name": "source_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "artifact_hash": {
+ "name": "artifact_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "descriptor": {
+ "name": "descriptor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inventory_version": {
+ "name": "inventory_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "inventory": {
+ "name": "inventory",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "first_event_ms": {
+ "name": "first_event_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "last_event_ms": {
+ "name": "last_event_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "inspected_at_ms": {
+ "name": "inspected_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "unavailable_at_ms": {
+ "name": "unavailable_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_biometric_raw_source_identity": {
+ "name": "idx_biometric_raw_source_identity",
+ "columns": [
+ "source_name",
+ "artifact_hash",
+ "inventory_version"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_raw_source_window": {
+ "name": "idx_biometric_raw_source_window",
+ "columns": [
+ "first_event_ms",
+ "last_event_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_runtime_configs": {
+ "name": "biometric_runtime_configs",
+ "columns": {
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "resolved_hash": {
+ "name": "resolved_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "boundary": {
+ "name": "boundary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_biometric_runtime_config": {
+ "name": "uq_biometric_runtime_config",
+ "columns": [
+ "side",
+ "generation"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_runtime_receipts": {
+ "name": "biometric_runtime_receipts",
+ "columns": {
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "component": {
+ "name": "component",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "applied_hash": {
+ "name": "applied_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "effective_sample_time_ms": {
+ "name": "effective_sample_time_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "heartbeat_at_ms": {
+ "name": "heartbeat_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_biometric_runtime_receipt": {
+ "name": "uq_biometric_runtime_receipt",
+ "columns": [
+ "side",
+ "component",
+ "generation"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_shadow_runs": {
+ "name": "biometric_shadow_runs",
+ "columns": {
+ "worker_receipt": {
+ "name": "worker_receipt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "worker_receipt_hash": {
+ "name": "worker_receipt_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plan_hash": {
+ "name": "plan_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attempt_id": {
+ "name": "attempt_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lease_until_ms": {
+ "name": "lease_until_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "heartbeat_at_ms": {
+ "name": "heartbeat_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ended_at_ms": {
+ "name": "ended_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "point_count": {
+ "name": "point_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "byte_count": {
+ "name": "byte_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "biometric_shadow_runs_idempotency_key_unique": {
+ "name": "biometric_shadow_runs_idempotency_key_unique",
+ "columns": [
+ "idempotency_key"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_shadow_side_state": {
+ "name": "idx_biometric_shadow_side_state",
+ "columns": [
+ "side",
+ "state"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "biometric_shadow_samples": {
+ "name": "biometric_shadow_samples",
+ "columns": {
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sequence": {
+ "name": "sequence",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp_ms": {
+ "name": "timestamp_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "byte_length": {
+ "name": "byte_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_biometric_shadow_sample": {
+ "name": "uq_biometric_shadow_sample",
+ "columns": [
+ "run_id",
+ "sequence"
+ ],
+ "isUnique": true
+ },
+ "idx_biometric_shadow_sample_time": {
+ "name": "idx_biometric_shadow_sample_time",
+ "columns": [
+ "run_id",
+ "timestamp_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "biometric_shadow_samples_run_id_biometric_shadow_runs_id_fk": {
+ "name": "biometric_shadow_samples_run_id_biometric_shadow_runs_id_fk",
+ "tableFrom": "biometric_shadow_samples",
+ "tableTo": "biometric_shadow_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calibration_profiles": {
+ "name": "calibration_profiles",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sensor_type": {
+ "name": "sensor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "parameters": {
+ "name": "parameters",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quality_score": {
+ "name": "quality_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_window_start": {
+ "name": "source_window_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_window_end": {
+ "name": "source_window_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "samples_used": {
+ "name": "samples_used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cal_type_status": {
+ "name": "idx_cal_type_status",
+ "columns": [
+ "sensor_type",
+ "status"
+ ],
+ "isUnique": false
+ },
+ "uq_cal_side_type_active": {
+ "name": "uq_cal_side_type_active",
+ "columns": [
+ "side",
+ "sensor_type"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "calibration_runs": {
+ "name": "calibration_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sensor_type": {
+ "name": "sensor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parameters": {
+ "name": "parameters",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quality_score": {
+ "name": "quality_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_window_start": {
+ "name": "source_window_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "source_window_end": {
+ "name": "source_window_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "samples_used": {
+ "name": "samples_used",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "triggered_by": {
+ "name": "triggered_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_cal_runs_side_type": {
+ "name": "idx_cal_runs_side_type",
+ "columns": [
+ "side",
+ "sensor_type",
+ "created_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cap_sense_frames": {
+ "name": "cap_sense_frames",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "zones": {
+ "name": "zones",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "max": {
+ "name": "max",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "mean": {
+ "name": "mean",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "spread": {
+ "name": "spread",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "peak_zone": {
+ "name": "peak_zone",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "frame_count": {
+ "name": "frame_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status_counts": {
+ "name": "status_counts",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_cap_sense_frames_side_ts": {
+ "name": "uq_cap_sense_frames_side_ts",
+ "columns": [
+ "side",
+ "timestamp"
+ ],
+ "isUnique": true
+ },
+ "idx_cap_sense_frames_timestamp": {
+ "name": "idx_cap_sense_frames_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "flash_write_samples": {
+ "name": "flash_write_samples",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "boot_id": {
+ "name": "boot_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sectors_written": {
+ "name": "sectors_written",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_flash_write_samples_timestamp": {
+ "name": "idx_flash_write_samples_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "flow_readings": {
+ "name": "flow_readings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_flowrate_cd": {
+ "name": "left_flowrate_cd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "right_flowrate_cd": {
+ "name": "right_flowrate_cd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "left_pump_rpm": {
+ "name": "left_pump_rpm",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "right_pump_rpm": {
+ "name": "right_pump_rpm",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_flow_readings_timestamp": {
+ "name": "idx_flow_readings_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "freezer_temp": {
+ "name": "freezer_temp",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ambient_temp": {
+ "name": "ambient_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "heatsink_temp": {
+ "name": "heatsink_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "left_water_temp": {
+ "name": "left_water_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "right_water_temp": {
+ "name": "right_water_temp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_freezer_temp_timestamp": {
+ "name": "idx_freezer_temp_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "health_runs": {
+ "name": "health_runs",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "check_id": {
+ "name": "check_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_health_runs_check_started": {
+ "name": "idx_health_runs_check_started",
+ "columns": [
+ "check_id",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "idx_health_runs_last_seen": {
+ "name": "idx_health_runs_last_seen",
+ "columns": [
+ "last_seen_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "movement": {
+ "name": "movement",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "total_movement": {
+ "name": "total_movement",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_movement_side_timestamp": {
+ "name": "idx_movement_side_timestamp",
+ "columns": [
+ "side",
+ "timestamp"
+ ],
+ "isUnique": false
+ },
+ "idx_movement_timestamp": {
+ "name": "idx_movement_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "prime_events": {
+ "name": "prime_events",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_prime_events_timestamp": {
+ "name": "idx_prime_events_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "pump_alerts": {
+ "name": "pump_alerts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rpm": {
+ "name": "rpm",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "flowrate_cd": {
+ "name": "flowrate_cd",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_seconds": {
+ "name": "duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "restore_target_temperature": {
+ "name": "restore_target_temperature",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "restore_duration_seconds": {
+ "name": "restore_duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "acknowledged_at": {
+ "name": "acknowledged_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_pump_alerts_timestamp": {
+ "name": "idx_pump_alerts_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ },
+ "idx_pump_alerts_acknowledged": {
+ "name": "idx_pump_alerts_acknowledged",
+ "columns": [
+ "acknowledged_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reference_imports": {
+ "name": "reference_imports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "family_id": {
+ "name": "family_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "supersedes_import_id": {
+ "name": "supersedes_import_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "adapter_version": {
+ "name": "adapter_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "time_policy": {
+ "name": "time_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "overlap_policy": {
+ "name": "overlap_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'exclude_conflicts'"
+ },
+ "accepted_count": {
+ "name": "accepted_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rejected_count": {
+ "name": "rejected_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at_ms": {
+ "name": "created_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "deleted_at_ms": {
+ "name": "deleted_at_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_reference_import_active_root": {
+ "name": "uq_reference_import_active_root",
+ "columns": [
+ "content_hash"
+ ],
+ "isUnique": true,
+ "where": "\"reference_imports\".\"deleted_at_ms\" IS NULL AND \"reference_imports\".\"supersedes_import_id\" IS NULL"
+ },
+ "uq_reference_import_active_correction": {
+ "name": "uq_reference_import_active_correction",
+ "columns": [
+ "content_hash",
+ "supersedes_import_id"
+ ],
+ "isUnique": true,
+ "where": "\"reference_imports\".\"deleted_at_ms\" IS NULL"
+ },
+ "uq_reference_import_revision": {
+ "name": "uq_reference_import_revision",
+ "columns": [
+ "family_id",
+ "revision"
+ ],
+ "isUnique": true
+ },
+ "idx_reference_import_side_created": {
+ "name": "idx_reference_import_side_created",
+ "columns": [
+ "side",
+ "created_at_ms",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reference_measurements": {
+ "name": "reference_measurements",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "import_id": {
+ "name": "import_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "record_key": {
+ "name": "record_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "stage": {
+ "name": "stage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_ms": {
+ "name": "start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_ms": {
+ "name": "end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "temporal_kind": {
+ "name": "temporal_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "aggregation": {
+ "name": "aggregation",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "window_ms": {
+ "name": "window_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "quality": {
+ "name": "quality",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scorable": {
+ "name": "scorable",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "exclusions": {
+ "name": "exclusions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "original": {
+ "name": "original",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_reference_measurement_key": {
+ "name": "uq_reference_measurement_key",
+ "columns": [
+ "import_id",
+ "record_key"
+ ],
+ "isUnique": true
+ },
+ "idx_reference_measurement_side_metric_start": {
+ "name": "idx_reference_measurement_side_metric_start",
+ "columns": [
+ "side",
+ "metric",
+ "start_ms"
+ ],
+ "isUnique": false
+ },
+ "idx_reference_measurement_import_id": {
+ "name": "idx_reference_measurement_import_id",
+ "columns": [
+ "import_id",
+ "id"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "reference_measurements_import_id_reference_imports_id_fk": {
+ "name": "reference_measurements_import_id_reference_imports_id_fk",
+ "tableFrom": "reference_measurements",
+ "tableTo": "reference_imports",
+ "columnsFrom": [
+ "import_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reference_stages": {
+ "name": "reference_stages",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start": {
+ "name": "start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end": {
+ "name": "end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "stage": {
+ "name": "stage",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "import_id": {
+ "name": "import_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_reference_stages_side_start_source": {
+ "name": "uq_reference_stages_side_start_source",
+ "columns": [
+ "side",
+ "start",
+ "source"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "reference_vitals": {
+ "name": "reference_vitals",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "heart_rate": {
+ "name": "heart_rate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "import_id": {
+ "name": "import_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_reference_vitals_side_timestamp_source": {
+ "name": "uq_reference_vitals_side_timestamp_source",
+ "columns": [
+ "side",
+ "timestamp",
+ "source"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "sleep_records": {
+ "name": "sleep_records",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entered_bed_at": {
+ "name": "entered_bed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_bed_at": {
+ "name": "left_bed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "sleep_duration_seconds": {
+ "name": "sleep_duration_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "times_exited_bed": {
+ "name": "times_exited_bed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "present_intervals": {
+ "name": "present_intervals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "not_present_intervals": {
+ "name": "not_present_intervals",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_sleep_records_side_entered": {
+ "name": "idx_sleep_records_side_entered",
+ "columns": [
+ "side",
+ "entered_bed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "thermal_state": {
+ "name": "thermal_state",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_powered": {
+ "name": "is_powered",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "target_temp_f": {
+ "name": "target_temp_f",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "current_temp_f": {
+ "name": "current_temp_f",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_thermal_state_timestamp": {
+ "name": "idx_thermal_state_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "vitals": {
+ "name": "vitals",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "heart_rate": {
+ "name": "heart_rate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hrv": {
+ "name": "hrv",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "breathing_rate": {
+ "name": "breathing_rate",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "processing_run_id": {
+ "name": "processing_run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "uq_vitals_side_timestamp": {
+ "name": "uq_vitals_side_timestamp",
+ "columns": [
+ "side",
+ "timestamp"
+ ],
+ "isUnique": true
+ },
+ "idx_vitals_timestamp": {
+ "name": "idx_vitals_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "vitals_quality": {
+ "name": "vitals_quality",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "vitals_id": {
+ "name": "vitals_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "side": {
+ "name": "side",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "quality_score": {
+ "name": "quality_score",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "flags": {
+ "name": "flags",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "hr_raw": {
+ "name": "hr_raw",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_vq_vitals_id": {
+ "name": "idx_vq_vitals_id",
+ "columns": [
+ "vitals_id"
+ ],
+ "isUnique": false
+ },
+ "idx_vq_side_ts": {
+ "name": "idx_vq_side_ts",
+ "columns": [
+ "side",
+ "timestamp"
+ ],
+ "isUnique": false
+ },
+ "idx_vq_timestamp": {
+ "name": "idx_vq_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "water_level_alerts": {
+ "name": "water_level_alerts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_water_level_alerts_dismissed": {
+ "name": "idx_water_level_alerts_dismissed",
+ "columns": [
+ "dismissed_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "water_level_readings": {
+ "name": "water_level_readings",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "level": {
+ "name": "level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "raw": {
+ "name": "raw",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "calibrated_empty": {
+ "name": "calibrated_empty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "calibrated_full": {
+ "name": "calibrated_full",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_water_level_timestamp": {
+ "name": "idx_water_level_timestamp",
+ "columns": [
+ "timestamp"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/db/biometrics-migrations/meta/_journal.json b/src/db/biometrics-migrations/meta/_journal.json
index abb988136..1d0dba727 100644
--- a/src/db/biometrics-migrations/meta/_journal.json
+++ b/src/db/biometrics-migrations/meta/_journal.json
@@ -218,6 +218,13 @@
"when": 1791878980742,
"tag": "0030_icy_maginty",
"breakpoints": true
+ },
+ {
+ "idx": 31,
+ "version": "6",
+ "when": 1792274814771,
+ "tag": "0031_wide_black_knight",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/db/biometrics-schema.ts b/src/db/biometrics-schema.ts
index fbacef079..89878ba7e 100644
--- a/src/db/biometrics-schema.ts
+++ b/src/db/biometrics-schema.ts
@@ -231,6 +231,20 @@ export const healthRuns = sqliteTable('health_runs', {
index('idx_health_runs_last_seen').on(t.lastSeenAt),
])
+/**
+ * Hourly readings of the eMMC's cumulative sectors-written counter
+ * (/sys/block//stat) for System → Storage's writes-per-day. The kernel
+ * resets the counter at boot, so each row carries the boot it was read in.
+ */
+export const flashWriteSamples = sqliteTable('flash_write_samples', {
+ id: integer('id').primaryKey({ autoIncrement: true }),
+ timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
+ bootId: text('boot_id').notNull(),
+ sectorsWritten: integer('sectors_written').notNull(),
+}, t => [
+ index('idx_flash_write_samples_timestamp').on(t.timestamp),
+])
+
/**
* Externally scored sleep stages (Apple Watch SleepAnalysis posted by the iOS
* app) used as a named comparison source for the pod's stage classifier. One
diff --git a/src/db/retention.ts b/src/db/retention.ts
index 921b7ebc0..6b9364d6e 100644
--- a/src/db/retention.ts
+++ b/src/db/retention.ts
@@ -4,6 +4,7 @@ import { biometricsDb } from './biometrics'
import {
ambientLight,
bedTemp,
+ flashWriteSamples,
flowReadings,
freezerTemp,
healthRuns,
@@ -26,7 +27,7 @@ import {
* Tables covered (all write at ≥1/minute and have no referential joins):
* vitals, movement, bed_temp, freezer_temp, flow_readings,
* ambient_light, water_level_readings, pump_alerts, vitals_quality,
- * thermal_state, health_runs
+ * thermal_state, health_runs, flash_write_samples
*
* vitals_quality shares vitals' timestamp cutoff so each quality row dies
* with its paired vitals row (vitals_id is a logical, unenforced reference —
@@ -52,6 +53,7 @@ const RETENTION_TABLES = [
{ table: pumpAlerts, column: pumpAlerts.timestamp, name: 'pump_alerts' },
{ table: thermalState, column: thermalState.timestamp, name: 'thermal_state' },
{ table: healthRuns, column: healthRuns.lastSeenAt, name: 'health_runs' },
+ { table: flashWriteSamples, column: flashWriteSamples.timestamp, name: 'flash_write_samples' },
] as const
/** Tables the daily retention pass prunes, for System → Databases. */
diff --git a/src/db/tests/__snapshots__/schema.test.ts.snap b/src/db/tests/__snapshots__/schema.test.ts.snap
index f1737ba6b..5ea1312bb 100644
--- a/src/db/tests/__snapshots__/schema.test.ts.snap
+++ b/src/db/tests/__snapshots__/schema.test.ts.snap
@@ -415,6 +415,23 @@ exports[`complete Drizzle schema contracts > pins every biometrics table, column
"primaryKeys": [],
"uniqueConstraints": [],
},
+ {
+ "checks": 0,
+ "columns": [
+ "id | sql:integer | logical:number/SQLiteInteger | not-null | default:auto | primary-key | not-unique | autoincrement | enum:none",
+ "timestamp | sql:integer | logical:date/SQLiteTimestamp | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none",
+ "boot_id | sql:text | logical:string/SQLiteText | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none",
+ "sectors_written | sql:integer | logical:number/SQLiteInteger | not-null | default:none | not-primary-key | not-unique | not-autoincrement | enum:none",
+ ],
+ "exportName": "flashWriteSamples",
+ "foreignKeys": [],
+ "indexes": [
+ "idx_flash_write_samples_timestamp | non-unique | columns:timestamp | where:none",
+ ],
+ "name": "flash_write_samples",
+ "primaryKeys": [],
+ "uniqueConstraints": [],
+ },
{
"checks": 0,
"columns": [
diff --git a/src/db/tests/retention.test.ts b/src/db/tests/retention.test.ts
index 7eff879e0..c81be533b 100644
--- a/src/db/tests/retention.test.ts
+++ b/src/db/tests/retention.test.ts
@@ -142,9 +142,14 @@ describe('pruneOldBiometrics', () => {
{ checkId: 'dac', status: 'ok', startedAt: old, lastSeenAt: fresh },
]).run()
+ db.insert(schema.flashWriteSamples).values([
+ { timestamp: old, bootId: 'a', sectorsWritten: 1 },
+ { timestamp: fresh, bootId: 'a', sectorsWritten: 2 },
+ ]).run()
+
const result = pruneOldBiometrics(cutoff, db)
- expect(result.rowsDeleted).toBe(11)
+ expect(result.rowsDeleted).toBe(12)
expect(result.perTable).toEqual({
vitals: 1,
vitals_quality: 1,
@@ -157,6 +162,7 @@ describe('pruneOldBiometrics', () => {
pump_alerts: 1,
thermal_state: 1,
health_runs: 1,
+ flash_write_samples: 1,
})
// Verify fresh rows remain
@@ -265,6 +271,7 @@ describe('pruneOldBiometrics', () => {
pump_alerts: 0,
thermal_state: 0,
health_runs: 0,
+ flash_write_samples: 0,
})
})
diff --git a/src/demo/handlers/system.ts b/src/demo/handlers/system.ts
index 2a24b66ce..b1c1bd7c4 100644
--- a/src/demo/handlers/system.ts
+++ b/src/demo/handlers/system.ts
@@ -154,6 +154,16 @@ export const system: DemoHandlers<'system'> = {
getStorage: () => storage(),
+ getFlashHealth: () => ({
+ device: 'mmcblk0',
+ name: 'C9A551',
+ capacityBytes: Math.round(29.1 * GB),
+ lifeTime: [{ minPercent: 0, maxPercent: 10 }, { minPercent: 10, maxPercent: 20 }],
+ preEol: 'normal',
+ sinceBoot: { bytes: Math.round(1.4 * GB), uptimeSeconds: Math.round((Date.now() - loadedAt) / 1000) + 9 * 3600 },
+ perDay: { bytes: Math.round(3.1 * GB), coveredHours: 168 },
+ }),
+
freeStorage: (input) => {
const removed = reclaimable.filter(i => input.includeDbBackups || i.kind !== 'backup')
reclaimable = reclaimable.filter(i => !removed.includes(i))
diff --git a/src/lib/flashHealth.ts b/src/lib/flashHealth.ts
new file mode 100644
index 000000000..b9503f49c
--- /dev/null
+++ b/src/lib/flashHealth.ts
@@ -0,0 +1,160 @@
+import { readFile } from 'node:fs/promises'
+import { asc, gte } from 'drizzle-orm'
+import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
+import { flashWriteSamples } from '@/src/db/biometrics-schema'
+import type * as biometricsSchema from '@/src/db/biometrics-schema'
+
+/**
+ * eMMC endurance for System → Storage: the chip's own wear estimates
+ * (JEDEC life_time / pre_eol_info) and how much the pod writes per day.
+ *
+ * The kernel has no per-process I/O accounting on the pods, so writes are
+ * the whole device — firmware, sleepypod and the OS together.
+ */
+
+type Db = BetterSQLite3Database
+
+export const FLASH_DEVICE = process.env.SP_FLASH_DEVICE || 'mmcblk0'
+/** /sys/block//stat counts in 512-byte sectors regardless of the device's block size. */
+const SECTOR_BYTES = 512
+const DAY_MS = 86_400_000
+const RATE_WINDOW_MS = 7 * DAY_MS
+/** Shortest stretch of samples worth turning into a per-day rate. */
+const MIN_COVERAGE_MS = 3_600_000
+
+/** One JEDEC life_time estimate: 0x01 = 0–10% of rated endurance used … 0x0A = 90–100%, 0x0B = exceeded. */
+export interface WearRange { minPercent: number, maxPercent: number | null }
+
+export type PreEol = 'normal' | 'warning' | 'urgent'
+
+export interface FlashReading {
+ name: string | null
+ capacityBytes: number | null
+ /** [type A (SLC area), type B (MLC area)]; null where the chip doesn't report one. */
+ lifeTime: [WearRange | null, WearRange | null]
+ preEol: PreEol | null
+ sectorsWritten: number
+ bootId: string
+ uptimeSeconds: number
+}
+
+export interface FlashReport {
+ device: string
+ name: string | null
+ capacityBytes: number | null
+ lifeTime: [WearRange | null, WearRange | null]
+ preEol: PreEol | null
+ sinceBoot: { bytes: number, uptimeSeconds: number }
+ /** Average over the last week of samples, null until they cover an hour. */
+ perDay: { bytes: number, coveredHours: number } | null
+}
+
+export function parseWear(code: string | undefined): WearRange | null {
+ const n = Number.parseInt(code ?? '', 16)
+ if (!Number.isFinite(n) || n < 1 || n > 11) return null
+ if (n === 11) return { minPercent: 100, maxPercent: null }
+ return { minPercent: (n - 1) * 10, maxPercent: n * 10 }
+}
+
+export function parseLifeTime(text: string): [WearRange | null, WearRange | null] {
+ const [a, b] = text.trim().split(/\s+/)
+ return [parseWear(a), parseWear(b)]
+}
+
+export function parsePreEol(text: string): PreEol | null {
+ switch (Number.parseInt(text.trim(), 16)) {
+ case 1: return 'normal'
+ case 2: return 'warning'
+ case 3: return 'urgent'
+ default: return null
+ }
+}
+
+/** Field 7 of /sys/block//stat: sectors written since boot. */
+export function parseSectorsWritten(stat: string): number | null {
+ const n = Number(stat.trim().split(/\s+/)[6])
+ return Number.isFinite(n) ? n : null
+}
+
+async function readText(path: string): Promise {
+ try {
+ return await readFile(path, 'utf-8')
+ }
+ catch {
+ return null
+ }
+}
+
+/** Read the device's sysfs entries, or null when it isn't there (dev machines, non-eMMC storage). */
+export async function readFlash(device = FLASH_DEVICE): Promise {
+ const base = `/sys/block/${device}`
+ const [stat, name, size, lifeTime, preEol, bootId, uptime] = await Promise.all([
+ readText(`${base}/stat`),
+ readText(`${base}/device/name`),
+ readText(`${base}/size`),
+ readText(`${base}/device/life_time`),
+ readText(`${base}/device/pre_eol_info`),
+ readText('/proc/sys/kernel/random/boot_id'),
+ readText('/proc/uptime'),
+ ])
+ const sectorsWritten = stat == null ? null : parseSectorsWritten(stat)
+ if (sectorsWritten == null) return null
+ const sizeSectors = Number(size?.trim())
+ return {
+ name: name?.trim() || null,
+ capacityBytes: sizeSectors > 0 ? sizeSectors * SECTOR_BYTES : null,
+ lifeTime: lifeTime == null ? [null, null] : parseLifeTime(lifeTime),
+ preEol: preEol == null ? null : parsePreEol(preEol),
+ sectorsWritten,
+ bootId: bootId?.trim() || 'unknown',
+ uptimeSeconds: Number(uptime?.trim().split(/\s+/)[0]) || 0,
+ }
+}
+
+export interface CounterSample { at: number, bootId: string, sectorsWritten: number }
+
+/**
+ * Bytes per day across consecutive samples. Within a boot the delta is the
+ * counter's growth; across a reboot the counter restarted from zero, so the
+ * new reading is all writes since that boot. Writes between the last sample
+ * of a boot and its shutdown are never seen.
+ */
+export function writeRate(samples: CounterSample[]): { bytesPerDay: number, coveredMs: number } | null {
+ if (samples.length < 2) return null
+ let sectors = 0
+ for (let i = 1; i < samples.length; i++) {
+ const prev = samples[i - 1]
+ const cur = samples[i]
+ sectors += cur.bootId === prev.bootId ? Math.max(0, cur.sectorsWritten - prev.sectorsWritten) : cur.sectorsWritten
+ }
+ const coveredMs = samples[samples.length - 1].at - samples[0].at
+ if (coveredMs < MIN_COVERAGE_MS) return null
+ return { bytesPerDay: (sectors * SECTOR_BYTES * DAY_MS) / coveredMs, coveredMs }
+}
+
+export function recordFlashSample(db: Db, reading: FlashReading, now: number): void {
+ db.insert(flashWriteSamples)
+ .values({ timestamp: new Date(now), bootId: reading.bootId, sectorsWritten: reading.sectorsWritten })
+ .run()
+}
+
+/** Average over the last week of samples; the live reading closes the window at now. */
+export function buildFlashReport(db: Db, reading: FlashReading, now: number, device = FLASH_DEVICE): FlashReport {
+ const rows = db.select().from(flashWriteSamples)
+ .where(gte(flashWriteSamples.timestamp, new Date(now - RATE_WINDOW_MS)))
+ .orderBy(asc(flashWriteSamples.timestamp))
+ .all()
+ const rate = writeRate([
+ ...rows.map(r => ({ at: r.timestamp.getTime(), bootId: r.bootId, sectorsWritten: r.sectorsWritten })),
+ { at: now, bootId: reading.bootId, sectorsWritten: reading.sectorsWritten },
+ ])
+ return {
+ device,
+ name: reading.name,
+ capacityBytes: reading.capacityBytes,
+ lifeTime: reading.lifeTime,
+ preEol: reading.preEol,
+ sinceBoot: { bytes: reading.sectorsWritten * SECTOR_BYTES, uptimeSeconds: Math.round(reading.uptimeSeconds) },
+ perDay: rate && { bytes: Math.round(rate.bytesPerDay), coveredHours: Math.round((rate.coveredMs / 3_600_000) * 10) / 10 },
+ }
+}
diff --git a/src/lib/flashSampler.ts b/src/lib/flashSampler.ts
new file mode 100644
index 000000000..144bf41c5
--- /dev/null
+++ b/src/lib/flashSampler.ts
@@ -0,0 +1,44 @@
+import { biometricsDb } from '@/src/db'
+import { buildFlashReport, readFlash, recordFlashSample, type FlashReport } from '@/src/lib/flashHealth'
+
+const SAMPLE_INTERVAL_MS = 3_600_000
+
+let timer: NodeJS.Timeout | null = null
+
+async function sampleOnce(): Promise {
+ const reading = await readFlash()
+ if (reading) recordFlashSample(biometricsDb as never, reading, Date.now())
+}
+
+/** System → Storage's flash report, or null when the pod's eMMC isn't visible. */
+export async function getFlashReport(): Promise {
+ const reading = await readFlash()
+ return reading && buildFlashReport(biometricsDb as never, reading, Date.now())
+}
+
+/**
+ * Record the eMMC's sectors-written counter hourly so System → Storage can
+ * average writes per day across reboots. A row an hour is the whole cost.
+ */
+export function startFlashSampler(initialDelayMs = 60_000): void {
+ if (timer) return
+ const tick = () => {
+ sampleOnce().catch((err) => {
+ console.warn('[flash] sample failed:', err instanceof Error ? err.message : err)
+ })
+ }
+ timer = setTimeout(() => {
+ tick()
+ timer = setInterval(tick, SAMPLE_INTERVAL_MS)
+ timer.unref()
+ }, initialDelayMs)
+ timer.unref()
+}
+
+export function stopFlashSampler(): void {
+ if (timer) {
+ clearTimeout(timer)
+ clearInterval(timer)
+ timer = null
+ }
+}
diff --git a/src/lib/tests/flashHealth.test.ts b/src/lib/tests/flashHealth.test.ts
new file mode 100644
index 000000000..f1764b648
--- /dev/null
+++ b/src/lib/tests/flashHealth.test.ts
@@ -0,0 +1,170 @@
+import path from 'node:path'
+import Database from 'better-sqlite3'
+import { drizzle } from 'drizzle-orm/better-sqlite3'
+import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import * as schema from '@/src/db/biometrics-schema'
+import { flashWriteSamples } from '@/src/db/biometrics-schema'
+import type { FlashReading } from '../flashHealth'
+
+const files = vi.hoisted(() => new Map())
+vi.mock('node:fs/promises', () => {
+ const readFile = vi.fn(async (p: string) => {
+ const v = files.get(p)
+ if (v == null) throw new Error('ENOENT')
+ return v
+ })
+ return { readFile, default: { readFile } }
+})
+
+const {
+ buildFlashReport, parseLifeTime, parsePreEol, parseSectorsWritten, readFlash, recordFlashSample, writeRate,
+} = await import('../flashHealth')
+
+const HOUR = 3_600_000
+const DAY = 24 * HOUR
+const T0 = Date.UTC(2026, 9, 17, 0, 0, 0)
+/** sectors for n MiB */
+const mib = (n: number) => (n * 1024 * 1024) / 512
+
+// Captured from a Pod 5 (FORESEE eMMC) — field 7 is sectors written.
+const STAT = ' 43154 9400 2711852 39177 21804 18522 3248408 1139238 0 80564 1205189 5197 166 2954288 26227 665 545\n'
+
+function pod(overrides: Record = {}) {
+ files.clear()
+ const base = '/sys/block/mmcblk0'
+ const entries: Record = {
+ [`${base}/stat`]: STAT,
+ [`${base}/size`]: '61071360\n',
+ [`${base}/device/name`]: 'C9A551\n',
+ [`${base}/device/life_time`]: '0x04 0x00\n',
+ [`${base}/device/pre_eol_info`]: '0x01\n',
+ '/proc/sys/kernel/random/boot_id': 'boot-a\n',
+ '/proc/uptime': '2604.89 6984.84\n',
+ ...overrides,
+ }
+ for (const [k, v] of Object.entries(entries)) files.set(k, v)
+}
+
+describe('parsers', () => {
+ it('maps JEDEC life_time codes to 10% bands, exceeded and undefined', () => {
+ expect(parseLifeTime('0x04 0x00\n')).toEqual([{ minPercent: 30, maxPercent: 40 }, null])
+ expect(parseLifeTime('0x01 0x0A')).toEqual([{ minPercent: 0, maxPercent: 10 }, { minPercent: 90, maxPercent: 100 }])
+ expect(parseLifeTime('0x0B 0x0c')).toEqual([{ minPercent: 100, maxPercent: null }, null])
+ expect(parseLifeTime('')).toEqual([null, null])
+ })
+
+ it('maps pre_eol_info', () => {
+ expect(parsePreEol('0x01\n')).toBe('normal')
+ expect(parsePreEol('0x02')).toBe('warning')
+ expect(parsePreEol('0x03')).toBe('urgent')
+ expect(parsePreEol('0x00')).toBeNull()
+ })
+
+ it('reads sectors written from field 7 of the block stat', () => {
+ expect(parseSectorsWritten(STAT)).toBe(3248408)
+ expect(parseSectorsWritten('1 2 3')).toBeNull()
+ })
+})
+
+describe('readFlash', () => {
+ it('reads the chip and counters', async () => {
+ pod()
+ expect(await readFlash()).toEqual({
+ name: 'C9A551',
+ capacityBytes: 61071360 * 512,
+ lifeTime: [{ minPercent: 30, maxPercent: 40 }, null],
+ preEol: 'normal',
+ sectorsWritten: 3248408,
+ bootId: 'boot-a',
+ uptimeSeconds: 2604.89,
+ })
+ })
+
+ it('is null without the block device (off-pod)', async () => {
+ files.clear()
+ expect(await readFlash()).toBeNull()
+ })
+
+ it('still reports writes when the chip hides its health registers', async () => {
+ pod()
+ files.delete('/sys/block/mmcblk0/device/life_time')
+ files.delete('/sys/block/mmcblk0/device/pre_eol_info')
+ const r = await readFlash()
+ expect(r?.lifeTime).toEqual([null, null])
+ expect(r?.preEol).toBeNull()
+ expect(r?.sectorsWritten).toBe(3248408)
+ })
+})
+
+describe('writeRate', () => {
+ it('needs an hour of samples', () => {
+ expect(writeRate([{ at: T0, bootId: 'a', sectorsWritten: 0 }])).toBeNull()
+ expect(writeRate([
+ { at: T0, bootId: 'a', sectorsWritten: 0 },
+ { at: T0 + HOUR - 1, bootId: 'a', sectorsWritten: mib(100) },
+ ])).toBeNull()
+ })
+
+ it('scales counter growth within a boot to a day', () => {
+ const r = writeRate([
+ { at: T0, bootId: 'a', sectorsWritten: mib(1000) },
+ { at: T0 + 2 * HOUR, bootId: 'a', sectorsWritten: mib(1100) },
+ ])
+ expect(r).toEqual({ bytesPerDay: 1200 * 1024 * 1024, coveredMs: 2 * HOUR })
+ })
+
+ it('counts a new boot from zero instead of going negative', () => {
+ const r = writeRate([
+ { at: T0, bootId: 'a', sectorsWritten: mib(5000) },
+ { at: T0 + 12 * HOUR, bootId: 'b', sectorsWritten: mib(300) },
+ { at: T0 + DAY, bootId: 'b', sectorsWritten: mib(700) },
+ ])
+ expect(r?.bytesPerDay).toBe(700 * 1024 * 1024)
+ })
+})
+
+describe('buildFlashReport', () => {
+ let raw: Database.Database
+ let db: ReturnType>
+
+ beforeEach(() => {
+ raw = new Database(':memory:')
+ db = drizzle(raw, { schema })
+ migrate(db, { migrationsFolder: path.resolve(process.cwd(), 'src/db/biometrics-migrations') })
+ })
+
+ afterEach(() => raw.close())
+
+ const reading = (sectorsWritten: number, bootId = 'boot-a'): FlashReading => ({
+ name: 'C9A551',
+ capacityBytes: 29 * 1024 ** 3,
+ lifeTime: [{ minPercent: 30, maxPercent: 40 }, null],
+ preEol: 'normal',
+ sectorsWritten,
+ bootId,
+ uptimeSeconds: 7200,
+ })
+
+ it('has no per-day rate before samples cover an hour', () => {
+ const report = buildFlashReport(db, reading(mib(50)), T0)
+ expect(report.perDay).toBeNull()
+ expect(report.sinceBoot).toEqual({ bytes: 50 * 1024 * 1024, uptimeSeconds: 7200 })
+ })
+
+ it('averages stored samples up to the live reading', () => {
+ recordFlashSample(db, reading(mib(0)), T0)
+ recordFlashSample(db, reading(mib(100)), T0 + HOUR)
+ const report = buildFlashReport(db, reading(mib(400)), T0 + 4 * HOUR)
+ expect(report.perDay).toEqual({ bytes: 2400 * 1024 * 1024, coveredHours: 4 })
+ expect(report.device).toBe('mmcblk0')
+ })
+
+ it('ignores samples older than a week', () => {
+ recordFlashSample(db, reading(0, 'old'), T0 - 8 * DAY)
+ recordFlashSample(db, reading(mib(100)), T0)
+ const report = buildFlashReport(db, reading(mib(200)), T0 + 2 * HOUR)
+ expect(report.perDay).toEqual({ bytes: 1200 * 1024 * 1024, coveredHours: 2 })
+ expect(db.select().from(flashWriteSamples).all()).toHaveLength(2)
+ })
+})
diff --git a/src/server/instrumentation-node.ts b/src/server/instrumentation-node.ts
index 9bf4e1bf3..de098df71 100644
--- a/src/server/instrumentation-node.ts
+++ b/src/server/instrumentation-node.ts
@@ -23,6 +23,7 @@ import { closeDatabase, closeBiometricsDatabase } from '@/src/db'
import { startBiometricsRetention, stopBiometricsRetention } from '@/src/db/retention'
import { startAutomationRunsRetention, stopAutomationRunsRetention } from '@/src/db/automationRunsRetention'
import { startHealthSampler, stopHealthSampler } from '@/src/lib/healthSampler'
+import { startFlashSampler, stopFlashSampler } from '@/src/lib/flashSampler'
import { stopBacktestHost } from '@/src/biometrics/backtest/host'
import { stopBiometricCoordinator } from '@/src/biometrics/backtest/coordinator-service'
import { stopBiometricRuntime } from '@/src/biometrics/profiles/runtime-host'
@@ -149,6 +150,7 @@ async function gracefulShutdown(signal: string): Promise {
stopBiometricsRetention()
stopAutomationRunsRetention()
stopHealthSampler()
+ stopFlashSampler()
stopBiometricRuntime()
}
catch (error) {
@@ -358,6 +360,8 @@ async function initializeBackgroundServices(): Promise {
// Started before the scheduler so a scheduler that fails to load still
// leaves a history of the failure.
startHealthSampler()
+ // Hourly eMMC write counter for System → Storage's writes-per-day.
+ startFlashSampler()
const schedulerStartedAt = performance.now()
console.log('Initializing job scheduler...')
diff --git a/src/server/routers/system.ts b/src/server/routers/system.ts
index 9e5b0f5c6..0202b2f5a 100644
--- a/src/server/routers/system.ts
+++ b/src/server/routers/system.ts
@@ -7,6 +7,7 @@ import { accessSync, constants } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { promisify } from 'node:util'
import { getStorageReport, runStorageCleanup } from '@/src/lib/podStorage'
+import { getFlashReport } from '@/src/lib/flashSampler'
import {
classifyFirmware,
expectedTransport,
@@ -259,6 +260,8 @@ async function blockWan(): Promise {
* - `setInternetAccess` — toggle WAN block on/off
* - `triggerUpdate` — kick off sp-update (or a deploy-driven install)
*/
+const wearRange = z.object({ minPercent: z.number(), maxPercent: z.number().nullable() }).nullable()
+
export const systemRouter = router({
/**
* Returns whether WAN access is currently blocked by iptables.
@@ -651,6 +654,24 @@ export const systemRouter = router({
}))
.query(() => getStorageReport()),
+ /**
+ * eMMC endurance (System → Storage): the chip's own wear estimates and
+ * average writes per day for the whole device. Null off-pod.
+ */
+ getFlashHealth: publicProcedure
+ .meta({ openapi: { method: 'GET', path: '/system/flash', protect: false, tags: ['System'] } })
+ .input(z.object({}))
+ .output(z.object({
+ device: z.string(),
+ name: z.string().nullable(),
+ capacityBytes: z.number().nullable(),
+ lifeTime: z.tuple([wearRange, wearRange]),
+ preEol: z.enum(['normal', 'warning', 'urgent']).nullable(),
+ sinceBoot: z.object({ bytes: z.number(), uptimeSeconds: z.number() }),
+ perDay: z.object({ bytes: z.number(), coveredHours: z.number() }).nullable(),
+ }).nullable())
+ .query(() => getFlashReport()),
+
/**
* Delete sleepypod's own leftovers on /persistent (never the live install,
* databases, raw archive, swap or HomeKit data). Old database backups only
From 4208183fec74b8729f2282f355e88bdf1a27176e Mon Sep 17 00:00:00 2001
From: Jonathan Ng
Date: Sat, 17 Oct 2026 16:51:02 -0700
Subject: [PATCH 2/3] fix(storage): retry journald activation after a failed
restart and count a counter reset as a reboot
---
scripts/lib/journald-helpers | 7 ++++++-
src/lib/flashHealth.ts | 4 +++-
src/lib/tests/flashHealth.test.ts | 8 ++++++++
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/scripts/lib/journald-helpers b/scripts/lib/journald-helpers
index e541b2f7d..3600f574e 100644
--- a/scripts/lib/journald-helpers
+++ b/scripts/lib/journald-helpers
@@ -22,5 +22,10 @@ JOURNALD
install -m 0644 "$tmp" "$file"
rm -f "$tmp"
# journald keeps service stdout streams in its fd store across a restart.
- systemctl restart systemd-journald 2>/dev/null || true
+ # If it won't restart the new storage never took effect, so drop the file
+ # and let the next install or update try again. Non-fatal either way.
+ if ! systemctl restart systemd-journald 2>/dev/null; then
+ rm -f "$file"
+ echo "Warning: systemd-journald restart failed — journal storage unchanged, will retry on next update" >&2
+ fi
}
diff --git a/src/lib/flashHealth.ts b/src/lib/flashHealth.ts
index b9503f49c..010f4ac37 100644
--- a/src/lib/flashHealth.ts
+++ b/src/lib/flashHealth.ts
@@ -125,7 +125,9 @@ export function writeRate(samples: CounterSample[]): { bytesPerDay: number, cove
for (let i = 1; i < samples.length; i++) {
const prev = samples[i - 1]
const cur = samples[i]
- sectors += cur.bootId === prev.bootId ? Math.max(0, cur.sectorsWritten - prev.sectorsWritten) : cur.sectorsWritten
+ // A counter that went backwards also restarted, even if boot_id was unreadable.
+ const sameBoot = cur.bootId === prev.bootId && cur.sectorsWritten >= prev.sectorsWritten
+ sectors += sameBoot ? cur.sectorsWritten - prev.sectorsWritten : cur.sectorsWritten
}
const coveredMs = samples[samples.length - 1].at - samples[0].at
if (coveredMs < MIN_COVERAGE_MS) return null
diff --git a/src/lib/tests/flashHealth.test.ts b/src/lib/tests/flashHealth.test.ts
index f1764b648..ff3ebbe16 100644
--- a/src/lib/tests/flashHealth.test.ts
+++ b/src/lib/tests/flashHealth.test.ts
@@ -122,6 +122,14 @@ describe('writeRate', () => {
])
expect(r?.bytesPerDay).toBe(700 * 1024 * 1024)
})
+
+ it('treats a counter that went backwards as a reboot when boot_id is unreadable', () => {
+ const r = writeRate([
+ { at: T0, bootId: 'unknown', sectorsWritten: mib(5000) },
+ { at: T0 + DAY, bootId: 'unknown', sectorsWritten: mib(700) },
+ ])
+ expect(r?.bytesPerDay).toBe(700 * 1024 * 1024)
+ })
})
describe('buildFlashReport', () => {
From 65a12ee3cdf066cf3fa9890a69a7fd17d1dac252 Mon Sep 17 00:00:00 2001
From: Jonathan Ng
Date: Sat, 17 Oct 2026 18:28:29 -0700
Subject: [PATCH 3/3] fix(storage): restore the prior journald config on a
failed restart and skip rate samples without a boot id
---
scripts/lib/journald-helpers | 24 +++++++++++++++++++-----
src/lib/flashHealth.ts | 11 +++++++----
src/lib/tests/flashHealth.test.ts | 21 ++++++++++++++++++---
3 files changed, 44 insertions(+), 12 deletions(-)
diff --git a/scripts/lib/journald-helpers b/scripts/lib/journald-helpers
index 3600f574e..34e90bf9c 100644
--- a/scripts/lib/journald-helpers
+++ b/scripts/lib/journald-helpers
@@ -5,7 +5,7 @@
# drop-in overrides journald.conf; logs from earlier boots are the cost.
# Shared by install and sp-update so existing pods pick it up on update.
install_journald_config() {
- local file="/etc/systemd/journald.conf.d/sleepypod.conf" tmp
+ local file="/etc/systemd/journald.conf.d/sleepypod.conf" tmp backup=""
mkdir -p /etc/systemd/journald.conf.d
tmp=$(mktemp)
cat > "$tmp" <<'JOURNALD'
@@ -19,13 +19,27 @@ JOURNALD
rm -f "$tmp"
return 0
fi
+ if [ -f "$file" ]; then
+ backup=$(mktemp)
+ cp -fp "$file" "$backup"
+ fi
install -m 0644 "$tmp" "$file"
rm -f "$tmp"
# journald keeps service stdout streams in its fd store across a restart.
- # If it won't restart the new storage never took effect, so drop the file
- # and let the next install or update try again. Non-fatal either way.
+ # If it won't restart, put the previous config back and bring journald up
+ # on it, so the next install or update retries. Non-fatal either way.
if ! systemctl restart systemd-journald 2>/dev/null; then
- rm -f "$file"
- echo "Warning: systemd-journald restart failed — journal storage unchanged, will retry on next update" >&2
+ if [ -n "$backup" ]; then
+ install -m 0644 "$backup" "$file"
+ else
+ rm -f "$file"
+ fi
+ systemctl restart systemd-journald 2>/dev/null || true
+ if systemctl is-active --quiet systemd-journald; then
+ echo "Warning: systemd-journald rejected the RAM-only journal config — kept the previous one, will retry on next update" >&2
+ else
+ echo "Warning: systemd-journald is not running after restoring its previous config — check 'systemctl status systemd-journald'" >&2
+ fi
fi
+ rm -f "$backup"
}
diff --git a/src/lib/flashHealth.ts b/src/lib/flashHealth.ts
index 010f4ac37..92d9e4315 100644
--- a/src/lib/flashHealth.ts
+++ b/src/lib/flashHealth.ts
@@ -34,7 +34,8 @@ export interface FlashReading {
lifeTime: [WearRange | null, WearRange | null]
preEol: PreEol | null
sectorsWritten: number
- bootId: string
+ /** null when /proc/sys/kernel/random/boot_id is unreadable; such readings never feed the rate. */
+ bootId: string | null
uptimeSeconds: number
}
@@ -106,7 +107,7 @@ export async function readFlash(device = FLASH_DEVICE): Promise= prev.sectorsWritten
sectors += sameBoot ? cur.sectorsWritten - prev.sectorsWritten : cur.sectorsWritten
}
@@ -135,6 +136,8 @@ export function writeRate(samples: CounterSample[]): { bytesPerDay: number, cove
}
export function recordFlashSample(db: Db, reading: FlashReading, now: number): void {
+ // Without a boot id a reboot can't be told apart from growth.
+ if (reading.bootId == null) return
db.insert(flashWriteSamples)
.values({ timestamp: new Date(now), bootId: reading.bootId, sectorsWritten: reading.sectorsWritten })
.run()
@@ -148,7 +151,7 @@ export function buildFlashReport(db: Db, reading: FlashReading, now: number, dev
.all()
const rate = writeRate([
...rows.map(r => ({ at: r.timestamp.getTime(), bootId: r.bootId, sectorsWritten: r.sectorsWritten })),
- { at: now, bootId: reading.bootId, sectorsWritten: reading.sectorsWritten },
+ ...(reading.bootId == null ? [] : [{ at: now, bootId: reading.bootId, sectorsWritten: reading.sectorsWritten }]),
])
return {
device,
diff --git a/src/lib/tests/flashHealth.test.ts b/src/lib/tests/flashHealth.test.ts
index ff3ebbe16..3167dcf12 100644
--- a/src/lib/tests/flashHealth.test.ts
+++ b/src/lib/tests/flashHealth.test.ts
@@ -86,6 +86,12 @@ describe('readFlash', () => {
expect(await readFlash()).toBeNull()
})
+ it('reports a null boot id when it is unreadable', async () => {
+ pod()
+ files.delete('/proc/sys/kernel/random/boot_id')
+ expect((await readFlash())?.bootId).toBeNull()
+ })
+
it('still reports writes when the chip hides its health registers', async () => {
pod()
files.delete('/sys/block/mmcblk0/device/life_time')
@@ -123,10 +129,10 @@ describe('writeRate', () => {
expect(r?.bytesPerDay).toBe(700 * 1024 * 1024)
})
- it('treats a counter that went backwards as a reboot when boot_id is unreadable', () => {
+ it('treats a counter that went backwards as a reboot', () => {
const r = writeRate([
- { at: T0, bootId: 'unknown', sectorsWritten: mib(5000) },
- { at: T0 + DAY, bootId: 'unknown', sectorsWritten: mib(700) },
+ { at: T0, bootId: 'a', sectorsWritten: mib(5000) },
+ { at: T0 + DAY, bootId: 'a', sectorsWritten: mib(700) },
])
expect(r?.bytesPerDay).toBe(700 * 1024 * 1024)
})
@@ -160,6 +166,15 @@ describe('buildFlashReport', () => {
expect(report.sinceBoot).toEqual({ bytes: 50 * 1024 * 1024, uptimeSeconds: 7200 })
})
+ it('records nothing and leaves the live reading out of the rate without a boot id', () => {
+ recordFlashSample(db, reading(mib(0)), T0)
+ recordFlashSample(db, { ...reading(mib(100)), bootId: null }, T0 + HOUR)
+ expect(db.select().from(flashWriteSamples).all()).toHaveLength(1)
+ const report = buildFlashReport(db, { ...reading(mib(400)), bootId: null }, T0 + 4 * HOUR)
+ expect(report.perDay).toBeNull()
+ expect(report.sinceBoot.bytes).toBe(400 * 1024 * 1024)
+ })
+
it('averages stored samples up to the live reading', () => {
recordFlashSample(db, reading(mib(0)), T0)
recordFlashSample(db, reading(mib(100)), T0 + HOUR)