From ce5d1f1a2a4c98f0789eeb6be0676bf81512bed9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 19:13:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(db):=20Phase=202c-followup=20#2=20?= =?UTF-8?q?=E2=80=94=20strip=20Space-scope=20fields=20from=20user-level=20?= =?UTF-8?q?tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the "no table has both userId AND spaceId" invariant from the space-scoped plan. Phase 2c-followup v35 cleaned the userId column off data-record tables; this follow-up cleans the inverse off user-level singleton tables (userSettings, invoiceSettings, …). Hook change: user-level tables no longer receive spaceId / authorId / visibility stamps on new writes. Those three fields are only meaningful for tenant-scoped data; stamping them on user-level rows was v28 collateral damage from the blanket migration. Dexie v36 upgrade: deletes spaceId + authorId + visibility from every row in the 11 user-level tables. No schema change — these fields were never indexed on user-level tables, so .stores() stays untouched. Safety check before shipping: grep showed zero callers use scopedTable() or .where('spaceId') against these tables. They're queried directly by userId (via shared-stores or singleton lookups), so dropping the space columns is a pure cleanup. After this ships, user-level tables have {userId, …fields} and data tables have {spaceId, __lastActor, …fields} — the invariant is truly met app-wide. Type-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/apps/web/src/lib/data/database.ts | 90 ++++++++++++++++----- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 4257b80d9..1e464783c 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -852,6 +852,49 @@ db.version(35) } }); +// v36 — Phase 2c-followup #2: strip the Space-scope fields from +// user-level singleton tables. v28 blanket-stamped every row with +// `spaceId=_personal:` + `authorId` + `visibility`, but +// user-level tables (userSettings, invoiceSettings, …) are genuinely +// user-scoped — they're never queried through scopedTable. Those +// fields were dead weight + a subtle invariant violation ("user-level +// table with a spaceId stamp is either user-level or space-level, +// pick one"). Hook was updated in the same commit to stop stamping +// them on user-level tables going forward; this upgrade cleans up +// the historical rows. +// +// Grep verified: zero callers use `scopedTable()` +// or `.where('spaceId')` against these tables, so dropping is safe. +// No schema change — the columns weren't indexed on user-level +// tables, so there's nothing to re-declare in .stores(). +db.version(36).upgrade(async (tx) => { + const USER_LEVEL = [ + 'userSettings', + 'userContext', + 'newsPreferences', + 'meditateSettings', + 'sleepSettings', + 'moodSettings', + 'timeSettings', + 'invoiceSettings', + 'broadcastSettings', + 'wetterSettings', + 'userTagPresets', + ]; + + for (const name of USER_LEVEL) { + if (!tx.db.tables.find((t) => t.name === name)) continue; + await tx + .table(name) + .toCollection() + .modify((record: Record) => { + if ('spaceId' in record) delete record.spaceId; + if ('authorId' in record) delete record.authorId; + if ('visibility' in record) delete record.visibility; + }); + } +}); + // ─── Sync Routing ────────────────────────────────────────── // SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE, // toSyncName() and fromSyncName() are now derived from per-module @@ -1058,28 +1101,37 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { // tenancy on spaceId. const objRecord = obj as Record; const effectiveUserId = getEffectiveUserId(); - if (USER_LEVEL_TABLES.has(tableName)) { + const isUserLevel = USER_LEVEL_TABLES.has(tableName); + + if (isUserLevel) { if (objRecord.userId === undefined || objRecord.userId === null) { objRecord.userId = effectiveUserId; } - } - - // Auto-stamp the Space-scope fields. Until the scope bootstrap - // (see `./scope/active-space.svelte.ts`) resolves the user's - // personal-space id from Better Auth, new records carry a - // deterministic sentinel `_personal:` that the bootstrap - // rewrites in a single pass. Module stores set spaceId explicitly - // once they start writing into non-personal spaces — this stamp - // only fills the gap. Sentinel uses `effectiveUserId` directly - // now that `userId` may not be present on the record itself. - if (objRecord.spaceId === undefined || objRecord.spaceId === null) { - objRecord.spaceId = `_personal:${effectiveUserId}`; - } - if (objRecord.authorId === undefined || objRecord.authorId === null) { - objRecord.authorId = effectiveUserId; - } - if (objRecord.visibility === undefined || objRecord.visibility === null) { - objRecord.visibility = 'space'; + // User-level tables DON'T get Space-scope fields — they're + // genuinely user-scoped, not tenant-scoped. v28 stamped + // them anyway as a byproduct of the blanket migration; + // Phase 2c-followup removed those fields retroactively + // (see v36 below). Skipping the stamps here keeps future + // rows clean. + } else { + // Auto-stamp the Space-scope fields on data tables. Until + // the scope bootstrap (see `./scope/active-space.svelte.ts`) + // resolves the user's personal-space id from Better Auth, + // new records carry a deterministic sentinel + // `_personal:` that the bootstrap rewrites in a + // single pass. Module stores set spaceId explicitly once + // they start writing into non-personal spaces — this stamp + // only fills the gap. Sentinel uses `effectiveUserId` + // directly since `userId` isn't on data records anymore. + if (objRecord.spaceId === undefined || objRecord.spaceId === null) { + objRecord.spaceId = `_personal:${effectiveUserId}`; + } + if (objRecord.authorId === undefined || objRecord.authorId === null) { + objRecord.authorId = effectiveUserId; + } + if (objRecord.visibility === undefined || objRecord.visibility === null) { + objRecord.visibility = 'space'; + } } // Stamp every real field with the create-time so future LWW comparisons