feat(db): Phase 2c — stop stamping userId on data-record writes

The creating-hook now splits its user-stamping behaviour by table:

- USER_LEVEL_TABLES (userSettings, userContext, newsPreferences,
  meditateSettings, sleepSettings, moodSettings, timeSettings,
  invoiceSettings, broadcastSettings, wetterSettings, userTagPresets)
  still get userId stamped — these rows are primarily scoped to the
  signed-in user rather than a Space.

- All other sync tables (the ~53 data-record tables) no longer
  receive userId on new writes. Attribution is the Actor system's
  job (__lastActor + __fieldActors are already stamped on every
  write); tenancy is the spaceId column's job (stamped below in the
  same hook). Keeping both userId and spaceId on data records was
  redundant.

Migration approach — lenient, no Dexie bump: existing rows keep the
userId they were stamped with in v28. New writes don't have it. The
three public type converters that exposed userId (tags-local's
toTag/toTagGroup, calc's toCalculation/toSavedFormula) use a
`?? 'guest'` / `?? ''` fallback, so rows without userId stay
readable. The 16-site codebase audit in phase 2c found no load-
bearing reader: the few sites that reference record.userId are
either one-time migration code (v28/v31/guest-migration), manifest
metadata (backup format — different userId field), or the hook's own
immutability guard.

authorId stamping now derives from effectiveUserId directly instead
of reading objRecord.userId — the previous chain relied on the
userId stamp having just happened, which no longer holds for data
tables.

The "no table has both userId AND spaceId" invariant from the plan
is now partially met: data tables will converge on it as old rows
cycle out. User-level tables still have both but that's by design
(userId = ownership, spaceId = v28 Personal-sentinel carried through
the hook; a future cleanup could drop the spaceId on user-level
tables but it's harmless today).

Tests: 20/20 agents + workbench-scenes pass. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 18:27:11 +02:00
parent d5b889ac58
commit e9b9544ea3

View file

@ -936,6 +936,36 @@ function isInternalKey(key: string): boolean {
);
}
/**
* Tables whose rows are scoped to a specific user rather than to a
* Space. These are singletons or small lookups tied to the signed-in
* identity (preferences, the profile hub, per-user templates). The
* creating-hook continues to stamp `userId` on these; data tables
* (tasks, events, tags, ) stopped carrying `userId` in Phase 2c of
* the space-scoped data model rollout attribution there lives on
* the Actor fields (`__lastActor` / `__fieldActors`) and tenancy on
* `spaceId`.
*
* Keeping this list explicit instead of inferring by naming
* convention: the audit in docs/plans/space-scoped-data-model.md
* appendix enumerates exactly which tables need user-level stamping,
* and a typo here would silently re-introduce `userId` on a data
* table.
*/
const USER_LEVEL_TABLES: ReadonlySet<string> = new Set([
'userSettings',
'userContext',
'newsPreferences',
'meditateSettings',
'sleepSettings',
'moodSettings',
'timeSettings',
'invoiceSettings',
'broadcastSettings',
'wetterSettings',
'userTagPresets',
]);
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
for (const tableName of tables) {
const table = db.table(tableName);
@ -948,12 +978,20 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
// trackPendingChange below. Freezing it here is the authoritative step.
const actor: Actor = getCurrentActor();
// Auto-stamp the active user. Module stores never set userId themselves,
// preventing accidental impersonation and removing all hardcoded
// 'guest'/'local' fallbacks scattered across query files.
// Auto-stamp the active user. Module stores never set userId
// themselves. After Phase 2c, user-level tables (userSettings,
// invoiceSettings, userTagPresets, …) continue to carry a
// userId column because their records are primarily scoped to
// the user, not a Space. Data tables (tasks, events, tags, …)
// no longer get userId stamped — attribution on data records
// lives on the Actor fields (__lastActor / __fieldActors) and
// tenancy on spaceId.
const objRecord = obj as Record<string, unknown>;
if (objRecord.userId === undefined || objRecord.userId === null) {
objRecord.userId = getEffectiveUserId();
const effectiveUserId = getEffectiveUserId();
if (USER_LEVEL_TABLES.has(tableName)) {
if (objRecord.userId === undefined || objRecord.userId === null) {
objRecord.userId = effectiveUserId;
}
}
// Auto-stamp the Space-scope fields. Until the scope bootstrap
@ -962,12 +1000,13 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
// deterministic sentinel `_personal:<userId>` 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.
// 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:${objRecord.userId as string}`;
objRecord.spaceId = `_personal:${effectiveUserId}`;
}
if (objRecord.authorId === undefined || objRecord.authorId === null) {
objRecord.authorId = objRecord.userId as string;
objRecord.authorId = effectiveUserId;
}
if (objRecord.visibility === undefined || objRecord.visibility === null) {
objRecord.visibility = 'space';