12 KiB
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)
- Exactly one active
<model>
pointer (orNULL
during first-run). - Block deletion when it would leave zero
<models>
. - If deleting the active item, atomically re-point to a deterministic next item before delete.
- Enforce with app logic + FK
RESTRICT
(andON UPDATE CASCADE
ifref
can change).
1) Schema / Migration (SQLite)
-- <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''
—useNULL
for "no selection yet". - Ensure
PRAGMA foreign_keys = ON
for every connection.
2) Data Access API (TypeScript)
// 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)
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
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
withUPDATE <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 shadowrefs
ledger to prevent reuse.
- SQLite workaround: make
-
Adjust
getAllRefs()
to filterWHERE deleted_at IS NULL
.
7) Bulk Ops & Imports
-
For batch deletes:
- Compute survivors.
- If a batch would remove all survivors → refuse.
- 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:
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)
- Non-active delete (≥2): deleted; active unchanged.
- Active delete (≥2): active switches deterministically, then delete succeeds.
- Last item delete (==1): blocked with message.
- First-run: 0 items →
activeRef
staysNULL
; add first →ensureActiveSelected()
selects it. - Ref update (if allowed):
activeRef
follows viaON UPDATE CASCADE
. - Soft delete mode: filters respected; invariants preserved.
- Bulk delete that includes active but not all: pre-switch then delete set.
- 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):
-- 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:
// 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:
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:
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:
// 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:
- Revert current foreign key implementation
- Apply clean migration sequence above
- Convert existing
activeDid = ''
toNULL
- Implement smart deletion logic
- Test all scenarios from test matrix
This clean implementation follows the directive exactly and provides complete pattern compliance with production-grade safeguards.