Browse Source
- Consolidate migrations: merge 002/003 into 001_initial with UNIQUE did constraint - Add foreign key: active_identity.activeDid REFERENCES accounts.did ON DELETE RESTRICT - Replace empty string defaults with NULL for proper empty state handling - Implement atomic smart deletion with auto-switch logic in IdentitySwitcherView - Add DAL methods: $getAllAccountDids, $getActiveDid, $setActiveDid, $pickNextAccountDid - Add migration bootstrapping to auto-select first account if none selected - Block deletion of last remaining account with user notification Refs: doc/active-pointer-smart-deletion-pattern.mdpull/188/head
4 changed files with 540 additions and 38 deletions
@ -0,0 +1,370 @@ |
|||||
|
# Engineering Directive v2 — Active Pointer + Smart Deletion Pattern (hardened) |
||||
|
|
||||
|
**Author**: Matthew Raymer |
||||
|
**Date**: 2025-01-27 |
||||
|
**Status**: 🎯 **ACTIVE** - Production-grade engineering directive for implementing smart deletion patterns |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This supersedes the previous draft and is **copy-pasteable** for any `<model>`. It keeps UX smooth, guarantees data integrity, and adds production-grade safeguards (bootstrapping, races, soft deletes, bulk ops, and testability). Built on your prior pattern. |
||||
|
|
||||
|
## 0) Objectives (non-negotiable) |
||||
|
|
||||
|
1. Exactly **one active `<model>`** pointer (or `NULL` during first-run). |
||||
|
2. **Block deletion** when it would leave **zero** `<models>`. |
||||
|
3. If deleting the **active** item, **atomically re-point** to a deterministic **next** item **before** delete. |
||||
|
4. Enforce with **app logic** + **FK `RESTRICT`** (and `ON UPDATE CASCADE` if `ref` can change). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1) Schema / Migration (SQLite) |
||||
|
|
||||
|
```sql |
||||
|
-- <timestamp>__active_<model>.sql |
||||
|
PRAGMA foreign_keys = ON; |
||||
|
|
||||
|
-- Stable external key on <models> (e.g., did/slug/uuid) |
||||
|
-- ALTER TABLE <models> ADD COLUMN ref TEXT UNIQUE NOT NULL; -- if missing |
||||
|
|
||||
|
CREATE TABLE IF NOT EXISTS active_<model> ( |
||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), |
||||
|
activeRef TEXT UNIQUE, -- allow NULL on first run |
||||
|
lastUpdated TEXT NOT NULL DEFAULT (datetime('now')), |
||||
|
FOREIGN KEY (activeRef) REFERENCES <models>(ref) |
||||
|
ON UPDATE CASCADE |
||||
|
ON DELETE RESTRICT |
||||
|
); |
||||
|
|
||||
|
-- Seed singleton row (idempotent) |
||||
|
INSERT INTO active_<model> (id, activeRef) |
||||
|
SELECT 1, NULL |
||||
|
WHERE NOT EXISTS (SELECT 1 FROM active_<model> WHERE id = 1); |
||||
|
``` |
||||
|
|
||||
|
**Rules** |
||||
|
|
||||
|
* **Never** default `activeRef` to `''`—use `NULL` for "no selection yet". |
||||
|
* Ensure `PRAGMA foreign_keys = ON` for **every connection**. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2) Data Access API (TypeScript) |
||||
|
|
||||
|
```ts |
||||
|
// Required DAL |
||||
|
async function getAllRefs(): Promise<string[]> { /* SELECT ref FROM <models> ORDER BY created_at, ref */ } |
||||
|
async function getRefById(id: number): Promise<string> { /* SELECT ref FROM <models> WHERE id=? */ } |
||||
|
async function getActiveRef(): Promise<string|null> { /* SELECT activeRef FROM active_<model> WHERE id=1 */ } |
||||
|
async function setActiveRef(ref: string|null): Promise<void> { /* UPDATE active_<model> SET activeRef=?, lastUpdated=datetime('now') WHERE id=1 */ } |
||||
|
async function deleteById(id: number): Promise<void> { /* DELETE FROM <models> WHERE id=? */ } |
||||
|
async function countModels(): Promise<number> { /* SELECT COUNT(*) FROM <models> */ } |
||||
|
|
||||
|
// Deterministic "next" |
||||
|
function pickNextRef(all: string[], current?: string): string { |
||||
|
const sorted = [...all].sort(); |
||||
|
if (!current) return sorted[0]; |
||||
|
const i = sorted.indexOf(current); |
||||
|
return sorted[(i + 1) % sorted.length]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3) Smart Delete (Atomic, Race-safe) |
||||
|
|
||||
|
```ts |
||||
|
async function smartDeleteModelById(id: number, notify: (m: string) => void) { |
||||
|
await db.transaction(async trx => { |
||||
|
const total = await countModels(); |
||||
|
if (total <= 1) { |
||||
|
notify("Cannot delete the last item. Keep at least one."); |
||||
|
throw new Error("blocked:last-item"); |
||||
|
} |
||||
|
|
||||
|
const refToDelete = await getRefById(id); |
||||
|
const activeRef = await getActiveRef(); |
||||
|
|
||||
|
if (activeRef === refToDelete) { |
||||
|
const all = (await getAllRefs()).filter(r => r !== refToDelete); |
||||
|
const next = pickNextRef(all, refToDelete); |
||||
|
await setActiveRef(next); |
||||
|
notify(`Switched active to ${next} before deletion.`); |
||||
|
} |
||||
|
|
||||
|
await deleteById(id); // RESTRICT prevents orphaning if we forgot to switch |
||||
|
}); |
||||
|
|
||||
|
// Post-tx: emit events / refresh UI |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4) Bootstrapping & Repair |
||||
|
|
||||
|
```ts |
||||
|
async function ensureActiveSelected() { |
||||
|
const active = await getActiveRef(); |
||||
|
const all = await getAllRefs(); |
||||
|
if (active === null && all.length > 0) { |
||||
|
await setActiveRef(pickNextRef(all)); // first stable choice |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
Invoke after migrations and after bulk imports. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5) Concurrency & Crash Safety |
||||
|
|
||||
|
* **Always** wrap "switch → delete" inside a **single transaction**. |
||||
|
* Treat any FK violation as a **logic regression**; surface telemetry (`fk:restrict`). |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6) Soft Deletes (if applicable) |
||||
|
|
||||
|
If `<models>` uses `deleted_at`: |
||||
|
|
||||
|
* Replace `DELETE` with `UPDATE <models> SET deleted_at = datetime('now') WHERE id=?`. |
||||
|
* Add a **partial uniqueness** strategy for `ref`: |
||||
|
|
||||
|
* SQLite workaround: make `ref` unique globally and never reuse; or maintain a shadow `refs` ledger to prevent reuse. |
||||
|
* Adjust `getAllRefs()` to filter `WHERE deleted_at IS NULL`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7) Bulk Ops & Imports |
||||
|
|
||||
|
* For batch deletes: |
||||
|
|
||||
|
1. Compute survivors. |
||||
|
2. If a batch would remove **all** survivors → **refuse**. |
||||
|
3. If the **active** is included, precompute a deterministic **new active** and set it **once** before deleting. |
||||
|
* After imports, run `ensureActiveSelected()`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8) Multi-Scope Actives (optional) |
||||
|
|
||||
|
To support **one active per workspace/tenant**: |
||||
|
|
||||
|
* Replace singleton with scoped pointer: |
||||
|
|
||||
|
```sql |
||||
|
CREATE TABLE active_<model> ( |
||||
|
scope TEXT NOT NULL, -- e.g., workspace_id |
||||
|
activeRef TEXT, |
||||
|
lastUpdated TEXT NOT NULL DEFAULT (datetime('now')), |
||||
|
PRIMARY KEY (scope), |
||||
|
FOREIGN KEY (activeRef) REFERENCES <models>(ref) ON UPDATE CASCADE ON DELETE RESTRICT |
||||
|
); |
||||
|
``` |
||||
|
* All APIs gain `scope` parameter; transactions remain unchanged in spirit. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9) UX Contract |
||||
|
|
||||
|
* Delete confirmation must state: |
||||
|
|
||||
|
* Deleting the **active** item will **auto-switch**. |
||||
|
* Deleting the **last** item is **not allowed**. |
||||
|
* Keep list ordering aligned with `pickNextRef` strategy for predictability. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10) Observability |
||||
|
|
||||
|
* Log categories: |
||||
|
|
||||
|
* `blocked:last-item` |
||||
|
* `fk:restrict` |
||||
|
* `repair:auto-selected-active` |
||||
|
* `active:switch:pre-delete` |
||||
|
* Emit metrics counters; attach `<model>` and (if used) `scope`. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11) Test Matrix (must pass) |
||||
|
|
||||
|
1. **Non-active delete** (≥2): deleted; active unchanged. |
||||
|
2. **Active delete** (≥2): active switches deterministically, then delete succeeds. |
||||
|
3. **Last item delete** (==1): blocked with message. |
||||
|
4. **First-run**: 0 items → `activeRef` stays `NULL`; add first → `ensureActiveSelected()` selects it. |
||||
|
5. **Ref update** (if allowed): `activeRef` follows via `ON UPDATE CASCADE`. |
||||
|
6. **Soft delete** mode: filters respected; invariants preserved. |
||||
|
7. **Bulk delete** that includes active but not all: pre-switch then delete set. |
||||
|
8. **Foreign keys disabled** (fault injection): tests must fail to surface missing PRAGMA. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12) Rollout & Rollback |
||||
|
|
||||
|
* **Feature-flag** the new deletion path. |
||||
|
* Migrations are **idempotent**; ship `ensureActiveSelected()` with them. |
||||
|
* Keep a pre-migration backup for `<models>` on first rollout. |
||||
|
* Rollback leaves `active_<model>` table harmlessly present. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13) Replace-Me Cheatsheet |
||||
|
|
||||
|
* `<model>` → singular (e.g., `project`) |
||||
|
* `<models>` → plural table (e.g., `projects`) |
||||
|
* `ref` → stable external key (`did` | `slug` | `uuid`) |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
**Outcome:** You get **predictable UX**, **atomic state changes**, and **hard integrity guarantees** across single- or multi-scope actives, with clear tests and telemetry to keep it honest. |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## TimeSafari Implementation Guide |
||||
|
|
||||
|
### Clean Implementation Path (Following Directive) |
||||
|
|
||||
|
If implementing this pattern from scratch or reverting to reapply the directive, follow this clean implementation: |
||||
|
|
||||
|
#### 1) Schema Implementation |
||||
|
|
||||
|
**Initial Migration (001_initial):** |
||||
|
```sql |
||||
|
-- Enable foreign key constraints for data integrity |
||||
|
PRAGMA foreign_keys = ON; |
||||
|
|
||||
|
-- Create accounts table with UNIQUE constraint on did |
||||
|
CREATE TABLE IF NOT EXISTS accounts ( |
||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
dateCreated TEXT NOT NULL, |
||||
|
derivationPath TEXT, |
||||
|
did TEXT NOT NULL UNIQUE, -- UNIQUE constraint for foreign key support |
||||
|
identityEncrBase64 TEXT, |
||||
|
mnemonicEncrBase64 TEXT, |
||||
|
passkeyCredIdHex TEXT, |
||||
|
publicKeyHex TEXT NOT NULL |
||||
|
); |
||||
|
|
||||
|
-- Create active_identity table with foreign key constraint |
||||
|
CREATE TABLE IF NOT EXISTS active_identity ( |
||||
|
id INTEGER PRIMARY KEY CHECK (id = 1), |
||||
|
activeDid TEXT DEFAULT NULL, -- NULL instead of empty string |
||||
|
lastUpdated TEXT NOT NULL DEFAULT (datetime('now')), |
||||
|
FOREIGN KEY (activeDid) REFERENCES accounts(did) ON DELETE RESTRICT |
||||
|
); |
||||
|
|
||||
|
-- Seed singleton row |
||||
|
INSERT INTO active_identity (id, activeDid, lastUpdated) VALUES (1, NULL, datetime('now')); |
||||
|
``` |
||||
|
|
||||
|
#### 2) Data Access API Implementation |
||||
|
|
||||
|
**Add to PlatformServiceMixin.ts:** |
||||
|
```typescript |
||||
|
// Required DAL methods following the pattern |
||||
|
async $getAllAccountDids(): Promise<string[]> { |
||||
|
const result = await this.$dbQuery("SELECT did FROM accounts ORDER BY dateCreated, did"); |
||||
|
return result?.values?.map(row => row[0] as string) || []; |
||||
|
} |
||||
|
|
||||
|
async $getAccountDidById(id: number): Promise<string> { |
||||
|
const result = await this.$dbQuery("SELECT did FROM accounts WHERE id = ?", [id]); |
||||
|
return result?.values?.[0]?.[0] as string; |
||||
|
} |
||||
|
|
||||
|
async $getActiveDid(): Promise<string | null> { |
||||
|
const result = await this.$dbQuery("SELECT activeDid FROM active_identity WHERE id = 1"); |
||||
|
return result?.values?.[0]?.[0] as string || null; |
||||
|
} |
||||
|
|
||||
|
async $setActiveDid(did: string | null): Promise<void> { |
||||
|
await this.$dbExec( |
||||
|
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", |
||||
|
[did] |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
async $countAccounts(): Promise<number> { |
||||
|
const result = await this.$dbQuery("SELECT COUNT(*) FROM accounts"); |
||||
|
return result?.values?.[0]?.[0] as number || 0; |
||||
|
} |
||||
|
|
||||
|
// Deterministic "next" picker |
||||
|
$pickNextAccountDid(all: string[], current?: string): string { |
||||
|
const sorted = [...all].sort(); |
||||
|
if (!current) return sorted[0]; |
||||
|
const i = sorted.indexOf(current); |
||||
|
return sorted[(i + 1) % sorted.length]; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 3) Smart Deletion Implementation |
||||
|
|
||||
|
**Replace deleteAccount in IdentitySwitcherView.vue:** |
||||
|
```typescript |
||||
|
async smartDeleteAccount(id: string) { |
||||
|
await this.$withTransaction(async () => { |
||||
|
const total = await this.$countAccounts(); |
||||
|
if (total <= 1) { |
||||
|
this.notify.warning("Cannot delete the last account. Keep at least one."); |
||||
|
throw new Error("blocked:last-item"); |
||||
|
} |
||||
|
|
||||
|
const accountDid = await this.$getAccountDidById(parseInt(id)); |
||||
|
const activeDid = await this.$getActiveDid(); |
||||
|
|
||||
|
if (activeDid === accountDid) { |
||||
|
const allDids = await this.$getAllAccountDids(); |
||||
|
const nextDid = this.$pickNextAccountDid(allDids.filter(d => d !== accountDid), accountDid); |
||||
|
await this.$setActiveDid(nextDid); |
||||
|
this.notify.success(`Switched active to ${nextDid} before deletion.`); |
||||
|
} |
||||
|
|
||||
|
await this.$exec("DELETE FROM accounts WHERE id = ?", [id]); |
||||
|
}); |
||||
|
|
||||
|
// Update UI |
||||
|
this.otherIdentities = this.otherIdentities.filter(ident => ident.id !== id); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### 4) Bootstrapping Implementation |
||||
|
|
||||
|
**Add to PlatformServiceMixin.ts:** |
||||
|
```typescript |
||||
|
async $ensureActiveSelected() { |
||||
|
const active = await this.$getActiveDid(); |
||||
|
const all = await this.$getAllAccountDids(); |
||||
|
if (active === null && all.length > 0) { |
||||
|
await this.$setActiveDid(this.$pickNextAccountDid(all)); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
**Call after migrations:** |
||||
|
```typescript |
||||
|
// In migration completion or app startup |
||||
|
await this.$ensureActiveSelected(); |
||||
|
``` |
||||
|
|
||||
|
### Implementation Benefits |
||||
|
|
||||
|
**Following this clean path provides:** |
||||
|
- ✅ **Atomic Operations**: Transaction-safe account deletion |
||||
|
- ✅ **Last Account Protection**: Prevents deletion of final account |
||||
|
- ✅ **Smart Switching**: Auto-switches active account before deletion |
||||
|
- ✅ **Data Integrity**: Foreign key constraints prevent orphaned references |
||||
|
- ✅ **Deterministic Behavior**: Predictable "next account" selection |
||||
|
- ✅ **NULL Handling**: Proper empty state management |
||||
|
|
||||
|
### Migration Strategy |
||||
|
|
||||
|
**For Existing Databases:** |
||||
|
1. **Revert** current foreign key implementation |
||||
|
2. **Apply** clean migration sequence above |
||||
|
3. **Convert** existing `activeDid = ''` to `NULL` |
||||
|
4. **Implement** smart deletion logic |
||||
|
5. **Test** all scenarios from test matrix |
||||
|
|
||||
|
This clean implementation follows the directive exactly and provides **complete pattern compliance** with **production-grade safeguards**. |
Loading…
Reference in new issue