13 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 (orNULLduring 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 CASCADEifrefcan 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
activeRefto''—useNULLfor "no selection yet". - Ensure
PRAGMA foreign_keys = ONfor 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
DELETEwithUPDATE <models> SET deleted_at = datetime('now') WHERE id=?. -
Add a partial uniqueness strategy for
ref:- SQLite workaround: make
refunique globally and never reuse; or maintain a shadowrefsledger 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
scopeparameter; 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
pickNextRefstrategy for predictability.
10) Observability
-
Log categories:
blocked:last-itemfk:restrictrepair:auto-selected-activeactive: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 →
activeRefstaysNULL; add first →ensureActiveSelected()selects it. - Ref update (if allowed):
activeReffollows 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
Current State Analysis (2025-01-27)
Status: ✅ FULLY COMPLIANT - Active Pointer + Smart Deletion Pattern implementation complete.
Compliance Score: 100% (6/6 components compliant)
✅ What's Working
- Smart Deletion Logic:
IdentitySwitcherView.vueimplements atomic transaction-safe deletion - Data Access API: All required DAL methods exist in
PlatformServiceMixin.ts - Schema Structure:
active_identitytable follows singleton pattern correctly - Bootstrapping:
$ensureActiveSelected()method implemented - Foreign Key Constraint: ✅ FIXED - Now uses
ON DELETE RESTRICT(Migration 005) - Settings Cleanup: ✅ COMPLETED - Orphaned records removed (Migration 006)
✅ All Issues Resolved
- ✅ Foreign key constraint fixed to
ON DELETE RESTRICT - ✅ Settings table cleaned up (orphaned records removed)
Updated Implementation Plan
Note: Smart deletion logic is already implemented correctly. Focus on fixing security issues and cleanup.
1) Critical Security Fix (Migration 005)
Fix Foreign Key Constraint:
-- Migration 005: Fix foreign key constraint to ON DELETE RESTRICT
{
name: "005_active_identity_constraint_fix",
sql: `
PRAGMA foreign_keys = ON;
-- Recreate table with ON DELETE RESTRICT constraint (SECURITY FIX)
CREATE TABLE active_identity_new (
id INTEGER PRIMARY KEY CHECK (id = 1),
activeDid TEXT REFERENCES accounts(did) ON DELETE RESTRICT,
lastUpdated TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Copy existing data
INSERT INTO active_identity_new (id, activeDid, lastUpdated)
SELECT id, activeDid, lastUpdated FROM active_identity;
-- Replace old table
DROP TABLE active_identity;
ALTER TABLE active_identity_new RENAME TO active_identity;
-- Recreate indexes
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id);
`
}
Updated Implementation Plan
Note: Smart deletion logic is already implemented correctly. Migration 005 (security fix) completed successfully.
✅ Phase 1: Critical Security Fix (COMPLETED)
- Migration 005: ✅ COMPLETED - Fixed foreign key constraint to
ON DELETE RESTRICT - Impact: Prevents accidental account deletion
- Status: ✅ Successfully applied and tested
Phase 2: Settings Cleanup (CURRENT)
- Migration 006: Remove orphaned settings records
- Impact: Cleaner architecture, reduced confusion
- Risk: LOW - Only removes obsolete data
3) Optional Future Enhancement (Migration 007)
Remove Legacy activeDid Column:
-- Migration 007: Remove activeDid column entirely (future task)
{
name: "007_remove_activeDid_column",
sql: `
-- Remove the legacy activeDid column from settings table
ALTER TABLE settings DROP COLUMN activeDid;
`
}
Current Implementation Status
✅ Already Implemented Correctly
- Smart Deletion Logic:
IdentitySwitcherView.vuelines 285-315 - Data Access API: All methods exist in
PlatformServiceMixin.ts - Transaction Safety: Uses
$withTransaction()for atomicity - Last Account Protection: Blocks deletion when
total <= 1 - Deterministic Selection:
$pickNextAccountDid()method - Bootstrapping:
$ensureActiveSelected()method
❌ Requires Immediate Fix
- Foreign Key Constraint: Change from
ON DELETE SET NULLtoON DELETE RESTRICT - Settings Cleanup: Remove orphaned records with
accountDid=null
Implementation Priority
Phase 1: Critical Security Fix (IMMEDIATE)
- Migration 005: Fix foreign key constraint to
ON DELETE RESTRICT - Impact: Prevents accidental account deletion
- Risk: HIGH - Current implementation allows data loss
Phase 2: Settings Cleanup (HIGH PRIORITY)
- Migration 006: Remove orphaned settings records
- Impact: Cleaner architecture, reduced confusion
- Risk: LOW - Only removes obsolete data
Phase 3: Future Enhancement (OPTIONAL)
- Migration 007: Remove
activeDidcolumn from settings - Impact: Complete separation of concerns
- Risk: LOW - Architectural cleanup
Phase 2: Settings Cleanup Implementation (Migration 006)
Remove Orphaned Records:
-- Migration 006: Settings cleanup
{
name: "006_settings_cleanup",
sql: `
-- Remove orphaned settings records (accountDid is null)
DELETE FROM settings WHERE accountDid IS NULL;
-- Clear any remaining activeDid values in settings
UPDATE settings SET activeDid = NULL;
`
}
Updated Compliance Assessment
Current Status: ✅ FULLY COMPLIANT (100%)
| Component | Status | Compliance |
|---|---|---|
| Smart Deletion Logic | ✅ Complete | 100% |
| Data Access API | ✅ Complete | 100% |
| Schema Structure | ✅ Complete | 100% |
| Foreign Key Constraint | ✅ Fixed (RESTRICT) |
100% |
| Settings Cleanup | ✅ Completed | 100% |
| Overall | ✅ Complete | 100% |
Implementation Benefits
Current implementation already provides:
- ✅ Atomic Operations: Transaction-safe account deletion
- ✅ Last Account Protection: Prevents deletion of final account
- ✅ Smart Switching: Auto-switches active account before deletion
- ✅ Deterministic Behavior: Predictable "next account" selection
- ✅ NULL Handling: Proper empty state management
After fixes will add:
- ✅ Data Integrity: Foreign key constraints prevent orphaned references
- ✅ Clean Architecture: Complete separation of identity vs. settings
- ✅ Production Safety: No accidental account deletion possible
Implementation Complete
✅ All Required Steps Completed:
- ✅ Migration 005: Foreign key constraint fixed to
ON DELETE RESTRICT - ✅ Migration 006: Settings cleanup completed (orphaned records removed)
- ✅ Testing: All migrations executed successfully with no performance delays
Optional Future Enhancement:
- Migration 007: Remove
activeDidcolumn from settings table (architectural cleanup)
The Active Pointer + Smart Deletion Pattern is now fully implemented with 100% compliance.