add a DB browser for the notification SQL, and update docs

This commit is contained in:
2026-10-24 20:29:29 -06:00
parent a5b44aeb66
commit 9ff865818e
6 changed files with 254 additions and 22 deletions
@@ -281,14 +281,14 @@ App (`crowd-funder-for-time-pwa`):
where New Activity starts in parallel with the feed, Affirm Delivery waits where New Activity starts in parallel with the feed, Affirm Delivery waits
for the offer's project, a WebAssembly explanation on the start-up error for the offer's project, a WebAssembly explanation on the start-up error
screen, test warm-up, retry and reporter). screen, test warm-up, retry and reporter).
- [ ] Release the app after endorser-ch is deployed, since detail rows read - [x] Release the app after endorser-ch is deployed, since detail rows read
`claim` from `alertSearch` — next: release (Trent) `claim` from `alertSearch` — next: release (Trent)
Notification service (`notification-wakeup-service`): Notification service (`notification-wakeup-service`):
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README, - [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
CHANGELOG. CHANGELOG.
- [ ] Deploy to test and production — next: deploy, or tick this if done - [x] Deploy to test and production — next: deploy, or tick this if done
(Trent) (Trent)
endorser-ch: endorser-ch:
@@ -298,11 +298,11 @@ endorser-ch:
sends) gets `toEntity`, so it reaches the owner's sends) gets `toEntity`, so it reaches the owner's
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills `personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
existing offers without moving `changeSeq`; test, CHANGELOG. existing offers without moving `changeSeq`; test, CHANGELOG.
- [ ] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`, - [x] Commit the `isPartOf` change: `CHANGELOG.md`, `sql/README.md`,
`sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`, `sql/V23__offer_is_part_of_to_entity.sqlite3`, `claim.service.js`,
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` — `endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
next: commit (Trent) next: commit (Trent)
- [ ] Deploy to test, then production, running Flyway on the main DB so V23 - [x] Deploy to test, then production, running Flyway on the main DB so V23
applies — next: deploy (Trent) applies — next: deploy (Trent)
- [ ] Leave a person's own gives and offers out of their - [ ] Leave a person's own gives and offers out of their
`personalPlanContributions` (the query includes them when a person `personalPlanContributions` (the query includes them when a person
+8
View File
@@ -0,0 +1,8 @@
## Tasks
- [ ] Default `apiServer` in every settings fallback. `$accountSettings()`
(`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
`{}`) on its early-return and error paths, so `apiServer` can be undefined;
`HomeView` works around this on its own. This gap exists independently of
degraded mode.
@@ -1,5 +1,17 @@
# Plan: Startup Database Failure — Diagnostics and Degraded Mode # Plan: Startup Database Failure — Diagnostics and Degraded Mode
## Status
Closed without building Parts A–C. The startup failure this plan prepared for
had a root cause, and the fix in `fix-startup-db-crash.md` removed it: the
SQLite plugin's configuration key, migration recording, and Android backup
rules. A user who saw the error on every launch started normally after
installing a build with that fix.
The recovery screen from commit d6ccf0e4 remains the answer for any other
database failure. Parts A–C below are kept as a design to pick up if startup
database failures are reported again; nothing in them has been built.
## Goal ## Goal
When the local database cannot be opened, a user should (1) get a useful When the local database cannot be opened, a user should (1) get a useful
@@ -27,23 +39,25 @@ Requirements:
- `src/utils/startupError.ts` recognizes that error by its message and hands it - `src/utils/startupError.ts` recognizes that error by its message and hands it
to `StartView` through `sessionStorage`. to `StartView` through `sessionStorage`.
- Three places catch it and redirect to `/start`: the router `errorHandler` - Three places catch it and redirect to `/start`: the router `errorHandler`
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue and `beforeEach` guard (`src/router/index.ts`), and the Vue global error
global error handler (`src/main.common.ts`). handler (`src/main.common.ts`).
- The guard calls `retrieveAccountDids()` for every route except the - The guard calls `retrieveAccountDids()` for every route except the
identity-setup routes, so with the database down every route except identity-setup routes, so with the database down every route except
`/account`, `/test` and `/logs` lands on `/start`. `/account`, `/test` and `/logs` lands on `/start`.
- `StartView` shows the recovery screen: Try Again, Email/Copy Details, - `StartView` shows the recovery screen: Try Again, Email Details, Copy
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over" Details, "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
(`resetDatabase()`, which copies the file aside before deleting it). (`resetDatabase()`, which copies the file aside before deleting it). When the
browser cannot run WebAssembly, the screen says so and points to the
settings that block it.
- API reads work without an identity: `getHeaders()` - API reads work without an identity: `getHeaders()`
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there (`src/libs/endorserServer.ts`) omits the Authorization header when there
is no DID. is no DID.
- Every path that creates, saves, or signs with an identity reads the `secret` - Every path that creates, saves, or signs with an identity reads the `secret`
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`, and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while `retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
the database is down. the database is down.
## Part A: One-tap diagnostics report ## Part A: One-tap diagnostics report (not built)
Contents of the report: Contents of the report:
@@ -61,10 +75,10 @@ screen shows the exact payload and a Send button; the email option stays for
users who decline. users who decline.
The database-existed flag needs a native-side or `Filesystem.stat` check before The database-existed flag needs a native-side or `Filesystem.stat` check before
`createConnection`; `databaseDirectoryUri()` already resolves the directory on `createConnection`; `databaseDirectoryUri()` resolves the directory on
Android and iOS. Android and iOS.
## Part B: Degraded mode ## Part B: Degraded mode (not built)
- Change the router guard so that a database startup error records the error - Change the router guard so that a database startup error records the error
and lets read-only routes through: home feed, projects, project and claim and lets read-only routes through: home feed, projects, project and claim
@@ -77,13 +91,14 @@ Android and iOS.
confirm, invite, contacts) with that same reason, instead of the confirm, invite, contacts) with that same reason, instead of the
"someone must register you" text an unregistered user sees. "someone must register you" text an unregistered user sees.
- Default `apiServer` in every settings fallback. `$accountSettings()` - Default `apiServer` in every settings fallback. `$accountSettings()`
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or (`src/utils/PlatformServiceMixin.ts`) returns the caller's `defaults` (often
the caller's `defaults` (often `{}`) on its early-return and error paths, so `{}`) on its early-return and error paths, so `apiServer` can be undefined;
`apiServer` can be undefined; `HomeView` works around this on its own. `HomeView` works around this on its own. This gap exists independently of
degraded mode. (Moved to PLAN.md)
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a - Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
Copy → Start Over. Copy → Start Over.
## Part C (optional): An identity that does not need SQLite ## Part C (optional): An identity that does not need SQLite (not built)
Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`). Key generation is pure (`generateSeed` → `deriveAddress` → `newIdentifier`).
In degraded mode the app could generate an identity, require the user to write In degraded mode the app could generate an identity, require the user to write
@@ -99,6 +114,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
## Open questions ## Open questions
These stay open for whoever picks up Parts A–C.
- Which server and endpoint receive diagnostics, and how long reports are kept. - Which server and endpoint receive diagnostics, and how long reports are kept.
- Whether to quarantine a corrupt database automatically ("file is not a - Whether to quarantine a corrupt database automatically ("file is not a
database", "malformed") and start fresh. Automatic quarantine trades a database", "malformed") and start fresh. Automatic quarantine trades a
@@ -109,7 +126,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
user's identity on a new phone; it also gives the Google backup a copy of the user's identity on a new phone; it also gives the Google backup a copy of the
keys together with the secret that decrypts them. The backup rules keys together with the secret that decrypts them. The backup rules
(`android/app/src/main/res/xml/backup_rules.xml`, (`android/app/src/main/res/xml/backup_rules.xml`,
`data_extraction_rules.xml`) include it. `data_extraction_rules.xml`) include it and exclude only the plugin's
encrypted preference files.
## Rejected ## Rejected
@@ -119,6 +137,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
## Checklist ## Checklist
Parts A–C are deferred, not scheduled.
- [ ] A1. Decide the diagnostics endpoint and payload retention. - [ ] A1. Decide the diagnostics endpoint and payload retention.
- [ ] A2. Capture "database file existed before first connection" during startup. - [ ] A2. Capture "database file existed before first connection" during startup.
- [ ] A3. Build the report payload and the Send Diagnostics button with preview. - [ ] A3. Build the report payload and the Send Diagnostics button with preview.
@@ -127,3 +147,17 @@ encryption setting (`androidIsEncryption: false`) in effect.
- [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks. - [ ] B3. Default `apiServer` in all `$accountSettings()` fallbacks.
- [ ] B4. Reorder the recovery screen. - [ ] B4. Reorder the recovery screen.
- [ ] C1. (Optional) Preferences-backed identity with import on recovery. - [ ] C1. (Optional) Preferences-backed identity with import on recovery.
## History
- 2026-10-10: commit d6ccf0e4 added the failed step and cause to the startup
error, the `startupError.ts` hand-off to `StartView`, and the recovery screen
(Try Again, Email/Copy Details, Save a Copy, Start Over).
- 2026-10-10: commit f41a807a fixed the root cause (see
`fix-startup-db-crash.md`) and wrote this plan. Tagged 1.4.8; the CHANGELOG
records it under 1.4.9 as "Startup DB error for new installations".
- 2026-10-17: commit d2cdd578 added the WebAssembly explanation to the recovery
screen (CHANGELOG 1.4.10).
- After 1.4.9: a user who had the startup error on every launch reported
normal startup on a build with the fix. The plan was closed with Parts A–C
unbuilt.
+3 -3
View File
@@ -10,8 +10,7 @@ Error" on every launch:
``` ```
Reinstalling did not help. The failure never appeared on developer devices Reinstalling did not help. The failure never appeared on developer devices
installed through adb or Android Studio. Full symptom report: installed through adb or Android Studio.
`startup-db-error-symptoms.md`.
## Causes ## Causes
@@ -107,7 +106,8 @@ the app on both platforms.
## Follow-up ## Follow-up
The one-tap diagnostics report and a degraded mode for users without a working The one-tap diagnostics report and a degraded mode for users without a working
database are planned in `doc/CHANGES/PLAN-startup-db-failure-recovery.md`. database are designed, but not built, in
`PLAN-startup-db-failure-recovery.md` in this directory.
## History ## History
@@ -0,0 +1,89 @@
/**
* Read-only SQL access to the daily-notification-plugin's own SQLite database.
*
* The plugin keeps its state in a database separate from the app's, created
* natively (Room on Android, raw sqlite3 on iOS). It exposes no SQL method, so
* this opens the file through @capacitor-community/sqlite as a "non-conformed"
* database, which that library always opens read-only. Writing is deliberately
* unsupported: the plugin owns the schema and holds its own connection.
*
* Locations (both resolve from the sqlite plugin's "default" folder):
* - Android: <app data>/databases/daily_notification_plugin.db
* - iOS: <app>/Documents/daily_notifications.db
*
* @see https://gitea.anomalistdesign.com/trent_larson/daily-notification-plugin
*/
import { Capacitor } from "@capacitor/core";
import { CapacitorSQLite, SQLiteConnection } from "@capacitor-community/sqlite";
const DATABASE_FILE_BY_PLATFORM: Record<string, string> = {
android: "daily_notification_plugin.db",
ios: "daily_notifications.db",
};
/** Tables the plugin creates on each platform, for query presets. */
export const NOTIFICATION_PLUGIN_TABLES_BY_PLATFORM: Record<string, string[]> =
{
android: [
"schedules",
"content_cache",
"callbacks",
"history",
"notification_content",
"notification_delivery",
"notification_config",
],
ios: ["notif_contents", "notif_deliveries", "notif_config"],
};
let sqlite: SQLiteConnection | undefined;
/**
* @returns the plugin database file name for this platform, or undefined
* where the plugin keeps no SQLite database (web, Electron)
*/
export function notificationPluginDatabaseFile(): string | undefined {
return DATABASE_FILE_BY_PLATFORM[Capacitor.getPlatform()];
}
/**
* Runs one statement against the plugin database and returns its rows.
* The connection is opened and closed per call so it never holds a lock
* the plugin's background work could wait on.
*
* @throws if the platform has no plugin database, the file does not exist
* yet (the plugin creates it on first use), or the statement writes
*/
export async function queryNotificationPluginDatabase(
statement: string,
): Promise<{ path: string; values: unknown[] }> {
const database = notificationPluginDatabaseFile();
if (!database) {
throw new Error(
`The notification plugin has no SQLite database on platform "${Capacitor.getPlatform()}".`,
);
}
sqlite = sqlite ?? new SQLiteConnection(CapacitorSQLite);
const { path } = await CapacitorSQLite.getNCDatabasePath({
path: "default",
database,
});
if (!path) {
throw new Error(`Could not resolve a path for ${database}.`);
}
const exists = await sqlite.isNCDatabase(path);
if (!exists.result) {
throw new Error(
`No plugin database at ${path}. The plugin creates it on first use.`,
);
}
const db = await sqlite.createNCConnection(path, 1);
try {
await db.open();
const result = await db.query(statement);
return { path, values: result.values ?? [] };
} finally {
await sqlite.closeNCConnection(path);
}
}
+102 -1
View File
@@ -49,7 +49,7 @@
</div> </div>
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-bold mb-4">SQL Operations</h2> <h2 class="text-xl font-bold mb-4">App SQL</h2>
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
<button :class="sqlLinkClasses" @click="setAllTablesQuery"> <button :class="sqlLinkClasses" @click="setAllTablesQuery">
All Tables All Tables
@@ -95,6 +95,61 @@
</div> </div>
</div> </div>
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">
Notification Plugin SQL (read-only)
</h2>
<div v-if="!notificationPluginDatabaseFile">
The daily-notification-plugin keeps a database only on Android and iOS.
</div>
<div v-else>
<div class="text-sm mb-2">
Separate from the app database: {{ notificationPluginDatabaseFile }}
</div>
<div class="flex flex-wrap gap-2 mt-2">
<button
:class="sqlLinkClasses"
@click="
setNotificationPluginQuery(
`SELECT * FROM sqlite_master WHERE type='table';`,
)
"
>
All Tables
</button>
<button
v-for="table in notificationPluginTables"
:key="table"
:class="sqlLinkClasses"
@click="setNotificationPluginQuery(`SELECT * FROM ${table};`)"
>
{{ table }}
</button>
</div>
<div>
<textarea
v-model="notificationPluginSqlQuery"
class="w-full h-32 p-2 border border-gray-300 rounded-md font-mono"
placeholder="Enter a SELECT or PRAGMA query here..."
></textarea>
</div>
<div class="mt-4">
<button
:class="primaryButtonClasses"
@click="executeNotificationPluginSql"
>
Execute
</button>
</div>
<div v-if="notificationPluginSqlResult" class="mt-4">
<h3 class="text-lg font-semibold mb-2">Result:</h3>
<pre class="bg-gray-100 p-4 rounded-md overflow-x-auto">{{
JSON.stringify(notificationPluginSqlResult, null, 2)
}}</pre>
</div>
</div>
</div>
<div class="mt-8"> <div class="mt-8">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2> <h2 class="text-xl font-bold mb-4">Image Sharing</h2>
Populates the "shared-photo" view as if they used "share_target". Populates the "shared-photo" view as if they used "share_target".
@@ -349,6 +404,12 @@ import { testBecomeUser0, testServerRegisterUser } from "@/test";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { Account } from "../db/tables/accounts"; import { Account } from "../db/tables/accounts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import {
NOTIFICATION_PLUGIN_TABLES_BY_PLATFORM,
notificationPluginDatabaseFile,
queryNotificationPluginDatabase,
} from "@/services/notifications/notificationPluginDatabase";
import { Capacitor } from "@capacitor/core";
import EntityGridFunctionPropTest from "../test/EntityGridFunctionPropTest.vue"; import EntityGridFunctionPropTest from "../test/EntityGridFunctionPropTest.vue";
import PlatformServiceMixinTest from "../test/PlatformServiceMixinTest.vue"; import PlatformServiceMixinTest from "../test/PlatformServiceMixinTest.vue";
@@ -421,6 +482,13 @@ export default class Help extends Vue {
sqlResult: unknown = null; sqlResult: unknown = null;
returnRawResults = false; returnRawResults = false;
// for SQL operations on the daily-notification-plugin database
notificationPluginDatabaseFile = notificationPluginDatabaseFile();
notificationPluginTables =
NOTIFICATION_PLUGIN_TABLES_BY_PLATFORM[Capacitor.getPlatform()] ?? [];
notificationPluginSqlQuery = "";
notificationPluginSqlResult: unknown = null;
cryptoLib = cryptoLib; cryptoLib = cryptoLib;
// for component tests // for component tests
@@ -547,6 +615,11 @@ export default class Help extends Vue {
this.executeSql(); this.executeSql();
} }
setNotificationPluginQuery(query: string) {
this.notificationPluginSqlQuery = query;
this.executeNotificationPluginSql();
}
/** /**
* Configuration for notification test buttons * Configuration for notification test buttons
* Eliminates repetitive notification button definitions in template * Eliminates repetitive notification button definitions in template
@@ -1022,6 +1095,34 @@ export default class Help extends Vue {
} }
} }
/**
* Executes a read-only query against the daily-notification-plugin's
* separate database (see notificationPluginDatabase.ts for why writes
* are not supported)
*/
async executeNotificationPluginSql() {
try {
this.notificationPluginSqlResult = await queryNotificationPluginDatabase(
this.notificationPluginSqlQuery,
);
logger.log(
"Test Notification Plugin SQL Result:",
this.notificationPluginSqlResult,
);
} catch (error) {
logger.error("Test Notification Plugin SQL Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: NOTIFY_SQL_ERROR.title,
text: createSqlErrorMessage(error),
},
5000,
);
}
}
/** /**
* Tests the URL flow from initialization to change propagation. * Tests the URL flow from initialization to change propagation.
* This simulates the flow where a user's DID is set, and then the * This simulates the flow where a user's DID is set, and then the