refactor(projections): wrap streak-tracker writes in system actor

Derived-state writes should be attributed to the projection subsystem,
not to whoever triggered the upstream event. `_streakState` is local-
only today so no cross-device user-visible effect, but once any derived
table joins sync this is the only correct model.

- `markActive` and `ensureSeeded` now run under
  `runAsAsync({ kind: 'system', source: 'projection' }, …)`
- Sets the pattern for future projections (DaySnapshot, correlations, …)
  to follow verbatim when they start writing persistently

Closes one of the Step-1 follow-ups tracked in
COMPANION_BRAIN_ARCHITECTURE §20. Remaining:
- mana-sync Go + Postgres migration for the `actor` field
- rule-engine to wrap its future writes the same way (no writes today)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 23:23:16 +02:00
parent 2fe9522953
commit 90e6d4dcc6

View file

@ -17,9 +17,12 @@
import { db } from '../database';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { eventBus } from '../events/event-bus';
import { runAsAsync } from '../events/actor';
import type { DomainEvent } from '../events/types';
import type { StreakInfo } from './types';
const PROJECTION_ACTOR = { kind: 'system', source: 'projection' } as const;
// ── Persistent State ────────────────────────────────
interface StreakState {
@ -152,7 +155,10 @@ export function startStreakTracker(): void {
for (const def of STREAK_DEFS) {
if (!def.triggerEvents.includes(event.type)) continue;
if (def.filter && !def.filter(event.payload as Record<string, unknown>)) continue;
markActive(def.id);
// Derived write — attribute to the projection subsystem, not to
// whoever triggered the upstream event. Matters the moment
// `_streakState` (or any future derived table) joins sync.
void runAsAsync(PROJECTION_ACTOR, () => markActive(def.id));
}
});
}
@ -167,17 +173,20 @@ export function stopStreakTracker(): void {
async function ensureSeeded(): Promise<void> {
const count = await db.table(TABLE).count();
if (count > 0) return;
// Seed empty states so useStreaks() returns all definitions
for (const def of STREAK_DEFS) {
await db.table(TABLE).add({
id: def.id,
label: def.label,
moduleId: def.moduleId,
currentStreak: 0,
longestStreak: 0,
lastActiveDate: '',
});
}
// Seed empty states so useStreaks() returns all definitions. Same
// attribution reasoning as markActive — this is a subsystem write.
await runAsAsync(PROJECTION_ACTOR, async () => {
for (const def of STREAK_DEFS) {
await db.table(TABLE).add({
id: def.id,
label: def.label,
moduleId: def.moduleId,
currentStreak: 0,
longestStreak: 0,
lastActiveDate: '',
});
}
});
}
// ── Read API ────────────────────────────────────────