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
for the offer's project, a WebAssembly explanation on the start-up error
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)
Notification service (`notification-wakeup-service`):
- [x] Service changes 1–6, `c` in the SMS link and FCM data, tests, README,
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)
endorser-ch:
@@ -298,11 +298,11 @@ endorser-ch:
sends) gets `toEntity`, so it reaches the owner's
`personalPlanContributions` and `trackedPlanClaims`; migration V23 fills
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`,
`endorser.db.service.js`, `test/controller-endorser-6-plans-totals.js` —
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)
- [ ] Leave a person's own gives and offers out of their
`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
## 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
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
to `StartView` through `sessionStorage`.
- Three places catch it and redirect to `/start`: the router `errorHandler`
and `beforeEach` guard (`src/router/index.ts:344`, `:363-454`), and the Vue
global error handler (`src/main.common.ts`).
and `beforeEach` guard (`src/router/index.ts`), and the Vue global error
handler (`src/main.common.ts`).
- The guard calls `retrieveAccountDids()` for every route except the
identity-setup routes, so with the database down every route except
`/account`, `/test` and `/logs` lands on `/start`.
- `StartView` shows the recovery screen: Try Again, Email/Copy Details,
"Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
(`resetDatabase()`, which copies the file aside before deleting it).
- `StartView` shows the recovery screen: Try Again, Email Details, Copy
Details, "Save a Copy of My Data" (`shareDatabaseFile()`), and "Start Over"
(`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()`
(`src/libs/endorserServer.ts:430`) omits the Authorization header when there
(`src/libs/endorserServer.ts`) omits the Authorization header when there
is no DID.
- Every path that creates, saves, or signs with an identity reads the `secret`
and `accounts` tables (`src/libs/util.ts`: `saveNewIdentity`,
`retrieveFullyDecryptedAccount`), so no action that needs a DID can work while
the database is down.
## Part A: One-tap diagnostics report
## Part A: One-tap diagnostics report (not built)
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.
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.
## Part B: Degraded mode
## Part B: Degraded mode (not built)
- 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
@@ -77,13 +91,14 @@ Android and iOS.
confirm, invite, contacts) with that same reason, instead of the
"someone must register you" text an unregistered user sees.
- Default `apiServer` in every settings fallback. `$accountSettings()`
(`src/utils/PlatformServiceMixin.ts:1071-1136`) returns `defaultSettings` or
the caller's `defaults` (often `{}`) on its early-return and error paths, so
`apiServer` can be undefined; `HomeView` works around this on its own.
(`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. (Moved to PLAN.md)
- Order the recovery screen as a ladder: Try Again → Send Diagnostics → Save a
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`).
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
These stay open for whoever picks up Parts A–C.
- Which server and endpoint receive diagnostics, and how long reports are kept.
- Whether to quarantine a corrupt database automatically ("file is not 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
keys together with the secret that decrypts them. The backup rules
(`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
@@ -119,6 +137,8 @@ encryption setting (`androidIsEncryption: false`) in effect.
## Checklist
Parts A–C are deferred, not scheduled.
- [ ] A1. Decide the diagnostics endpoint and payload retention.
- [ ] A2. Capture "database file existed before first connection" during startup.
- [ ] 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.
- [ ] B4. Reorder the recovery screen.
- [ ] 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
installed through adb or Android Studio. Full symptom report:
`startup-db-error-symptoms.md`.
installed through adb or Android Studio.
## Causes
@@ -107,7 +106,8 @@ the app on both platforms.
## Follow-up
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
@@ -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 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">
<button :class="sqlLinkClasses" @click="setAllTablesQuery">
All Tables
@@ -95,6 +95,61 @@
</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">
<h2 class="text-xl font-bold mb-4">Image Sharing</h2>
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 { Account } from "../db/tables/accounts";
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 PlatformServiceMixinTest from "../test/PlatformServiceMixinTest.vue";
@@ -421,6 +482,13 @@ export default class Help extends Vue {
sqlResult: unknown = null;
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;
// for component tests
@@ -547,6 +615,11 @@ export default class Help extends Vue {
this.executeSql();
}
setNotificationPluginQuery(query: string) {
this.notificationPluginSqlQuery = query;
this.executeNotificationPluginSql();
}
/**
* Configuration for notification test buttons
* 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.
* This simulates the flow where a user's DID is set, and then the