From 464a825a7bbf9844ce9d97b7aaa615f70b328618 Mon Sep 17 00:00:00 2001 From: Matthew Raymer Date: Mon, 8 Sep 2025 04:21:00 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20update=20notification=20system=20for=20?= =?UTF-8?q?shared=20SQLite=20+=20clear=20T=E2=80=93lead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add SQLite Ownership & Concurrency section with WAL mode details - Add DB Path & Adapter Configuration with shared DB setup - Clarify T–lead governs prefetch attempts, not arming - Add TTL-at-fire check callout for stale notification prevention - Add DB Sharing acceptance criteria (visibility, WAL overlap, version safety) - Update GLOSSARY.md with shared DB, WAL, and user_version definitions - Remove plugin-owned DB references in favor of single shared database Files modified: - doc/notification-system.md - doc/GLOSSARY.md Compliance: Implements shared SQLite architecture with clear T–lead prefetch semantics and WAL-based concurrency for app/plugin coordination. --- doc/GLOSSARY.md | 16 +++++----- doc/notification-system.md | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/doc/GLOSSARY.md b/doc/GLOSSARY.md index 29242d23..d33989bf 100644 --- a/doc/GLOSSARY.md +++ b/doc/GLOSSARY.md @@ -1,16 +1,18 @@ # Glossary -**T (slot time)** — The local wall-clock time a notification is intended to fire (e.g., 08:00). +**T (slot time)** — The local wall-clock time a notification should fire (e.g., 08:00). -**T–lead** — The moment **{prefetchLeadMinutes} minutes before T** when the system _attempts_ a background prefetch to refresh content. +**T–lead** — The moment **`prefetchLeadMinutes`** before **T** when the system *attempts* a **single** background prefetch. T–lead **controls prefetch attempts, not arming**; locals are pre-armed earlier to guarantee closed-app delivery. -- Example: If T = 12:00 and `prefetchLeadMinutes = 20`, then **T–lead = 11:40**. -- If background prefetch is skipped/denied, delivery still occurs using the most recent cached payload (rolling-window safety). -- T–lead **governs prefetch attempts, not arming**. We still arm one-shot locals early (rolling window) so closed-app delivery is guaranteed. +**Rolling window** — Always keep **today's remaining** (and tomorrow if iOS pending caps allow) locals **armed** so the OS can deliver while the app is closed. -**Rolling window** — Always keep **today’s remaining** one-shot locals armed (and optionally tomorrow, within iOS caps) so the OS can deliver while the app is closed. +**TTL (time-to-live)** — Maximum allowed payload age **at fire time**. If `T − fetchedAt > ttlSeconds`, we **skip** arming for that T. -**TTL (time-to-live)** — Maximum allowed staleness of a payload at **fire time**. If the projected age at T exceeds `ttlSeconds`, we **skip** arming. +**Shared DB (default)** — The app and plugin open the **same SQLite file**; the app owns schema/migrations, the plugin performs short writes with WAL. + +**WAL (Write-Ahead Logging)** — SQLite journaling mode that permits concurrent reads during writes; recommended for foreground-read + background-write. + +**`PRAGMA user_version`** — An integer the app increments on each migration; the plugin **checks** (does not migrate) to ensure compatibility. **Exact alarm (Android)** — Minute-precise alarm via `AlarmManager.setExactAndAllowWhileIdle`, subject to policy and permission. diff --git a/doc/notification-system.md b/doc/notification-system.md index 8f4b6ff6..fa34c543 100644 --- a/doc/notification-system.md +++ b/doc/notification-system.md @@ -46,6 +46,16 @@ App (Vue/TS) → Orchestrator (policy) → Native Adapters: - **BackgroundPrefetchNative** — WorkManager (Android) / BGTaskScheduler (+ silent push) (iOS) - **DataStore** — SQLite +**Storage (single shared DB):** The app and the native plugin will use **the same SQLite database file**. The app owns schema/migrations; the plugin opens the same file with WAL enabled and performs short, serialized writes. This keeps one source of truth for payloads, delivery logs, and config. + +### SQLite Ownership & Concurrency + +* **One DB file:** The plugin opens the **same path** the app uses (no second DB). +* **Migrations owned by app:** The app executes schema migrations and bumps `PRAGMA user_version`. The plugin **never** migrates; it **asserts** the expected version. +* **WAL mode:** Open DB with `journal_mode=WAL`, `synchronous=NORMAL`, `busy_timeout=5000`, `foreign_keys=ON`. WAL allows foreground reads while a background job commits quickly. +* **Single-writer discipline:** Background jobs write in **short transactions** (UPSERT per slot), then return. +* **Encryption (optional):** If using SQLCipher, the **same key** is used by both app and plugin. Do not mix encrypted and unencrypted openings. + ### Scheduling & T–lead - **Arm** a rolling window (today + tomorrow within iOS cap). @@ -70,6 +80,50 @@ App (Vue/TS) → Orchestrator (policy) → Native Adapters: - **DataStore**: SQLite adapters (notif_contents, notif_deliveries, notif_config) - **Public API**: `configure`, `requestPermissions`, `runFullPipelineNow`, `reschedule`, `getState` +### DB Path & Adapter Configuration + +* **Configure option:** `dbPath: string` (absolute path or platform alias) is passed from JS to the plugin during `configure()`. +* **Shared tables:** + + * `notif_contents(slot_id, payload_json, fetched_at, etag, …)` + * `notif_deliveries(slot_id, fire_at, delivered_at, status, error_code, …)` + * `notif_config(k, v)` +* **Open settings:** + + * `journal_mode=WAL` + * `synchronous=NORMAL` + * `busy_timeout=5000` + * `foreign_keys=ON` + +**Type (TS) extension** + +```ts +export type ConfigureOptions = { + // …existing fields… + dbPath: string; // shared DB file the plugin will open + storage: 'shared'; // canonical value; plugin-owned DB is not used +}; +``` + +**Plugin side (pseudo)** + +```kotlin +// Android open +val db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READWRITE) +db.execSQL("PRAGMA journal_mode=WAL") +db.execSQL("PRAGMA synchronous=NORMAL") +db.execSQL("PRAGMA foreign_keys=ON") +db.execSQL("PRAGMA busy_timeout=5000") +// Verify schema version +val uv = rawQuery("PRAGMA user_version").use { it.moveToFirst(); it.getInt(0) } +require(uv >= MIN_EXPECTED_VERSION) { "Schema version too old" } +``` + +```swift +// iOS open (FMDB / SQLite3) +// Set WAL via PRAGMA after open; check user_version the same way. +``` + ### 2) Templating & Arming - Render `title/body` **before** scheduling; pass via **SchedulerNative**. @@ -77,6 +131,8 @@ App (Vue/TS) → Orchestrator (policy) → Native Adapters: ### 3) T–lead (single attempt) +**T–lead governs prefetch, not arming.** We **arm** one-shot locals as part of the rolling window so closed-app delivery is guaranteed. At **T–lead = T − prefetchLeadMinutes**, the **native background job** attempts **one** 12s ETag-aware fetch. If fresh content arrives and will not violate **TTL-at-fire**, we (re)arm the upcoming slot; if the OS skips the wake, the pre-armed local still fires with cached content. + - Compute T–lead = `whenMs - prefetchLeadMinutes*60_000`. - `BackgroundPrefetchNative.schedulePrefetch(slotId, atMs=T–lead)`. - On wake: **ETag** fetch (timeout **12s**), persist, optionally cancel & re-arm if within TTL. @@ -84,6 +140,8 @@ App (Vue/TS) → Orchestrator (policy) → Native Adapters: ### 4) TTL-at-fire +**TTL-at-fire:** Before arming for time **T**, compute `T − fetchedAt`. If that exceeds `ttlSeconds`, **do not arm** (skip). This prevents posting stale notifications when the app has been closed for a long time. + `if (whenMs - fetchedAt) > ttlSeconds*1000 → skip` ### 5) Android specifics @@ -156,6 +214,12 @@ App (Vue/TS) → Orchestrator (policy) → Native Adapters: - Log/telemetry for `scheduled|shown|error`; ACK payload includes slot, times, device TZ, app version. +### DB Sharing + +* **Shared DB visibility:** A background prefetch writes `notif_contents`; the foreground UI **immediately** reads the same row. +* **WAL overlap:** With the app reading while the plugin commits, no user-visible blocking occurs. +* **Version safety:** If `user_version` is behind, the plugin emits an error and does not write (protects against partial installs). + --- ## Web-Push Cleanup