mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
feat(brain): add Domain Event Bus and emit events from 5 pilot modules
Phase 1 of the Companion Brain architecture. Introduces a typed, synchronous event bus with microtask-scheduled handlers, an append-only event store persisted to IndexedDB (_events table, v10 schema), and semantic domain events emitted from module stores. Pilot modules with emit() calls: - Todo: TaskCreated, TaskCompleted, TaskUncompleted, TaskDeleted, SubtasksUpdated - Calendar: CalendarEventCreated, CalendarEventUpdated, CalendarEventDeleted - Drink: DrinkLogged, DrinkEntryDeleted, DrinkEntryUndone - Nutriphi: MealLogged, MealFromPhotoLogged, MealDeleted - Places: PlaceCreated, PlaceDeleted, PlaceVisited, LocationLogged, TrackingStarted, TrackingStopped Also includes the full architecture plan at docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f5b9d0a31f
commit
e927c1f10f
14 changed files with 2116 additions and 3 deletions
|
|
@ -398,6 +398,36 @@ db.version(8).stores({
|
|||
recipes: 'id, difficulty, isFavorite, *tags',
|
||||
});
|
||||
|
||||
// Schema version 9 — adds the Stretch module (guided stretching routines
|
||||
// with mobility assessments, session tracking, and reminders).
|
||||
// Additive only; no prior tables touched.
|
||||
//
|
||||
// Index strategy:
|
||||
// - stretchExercises indexes bodyRegion + difficulty for the exercise picker
|
||||
// filter strip, isPreset to separate seeds from custom.
|
||||
// - stretchRoutines indexes routineType for the type-based filter tabs,
|
||||
// order for the user's custom sort.
|
||||
// - stretchSessions indexes startedAt for the history timeline view
|
||||
// (range scan descending).
|
||||
// - stretchAssessments indexes assessedAt for the trend chart.
|
||||
// - stretchReminders indexes isActive so the reminder engine can quickly
|
||||
// find enabled reminders without scanning the full table.
|
||||
db.version(9).stores({
|
||||
stretchExercises: 'id, bodyRegion, difficulty, isPreset, isArchived, order',
|
||||
stretchRoutines: 'id, routineType, order, isPinned, isPreset',
|
||||
stretchSessions: 'id, routineId, startedAt, [startedAt]',
|
||||
stretchAssessments: 'id, assessedAt',
|
||||
stretchReminders: 'id, isActive',
|
||||
});
|
||||
|
||||
// v10 — Domain Event Store for the Companion Brain.
|
||||
// Append-only log of semantic events emitted by module stores.
|
||||
// NOT synced (local intelligence only). Replaces _activity long-term.
|
||||
db.version(10).stores({
|
||||
_events:
|
||||
'++seq, type, meta.appId, meta.timestamp, meta.recordId, [meta.appId+meta.timestamp], [type+meta.timestamp]',
|
||||
});
|
||||
|
||||
// ─── 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
|
||||
|
|
|
|||
296
apps/mana/apps/web/src/lib/data/events/catalog.ts
Normal file
296
apps/mana/apps/web/src/lib/data/events/catalog.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* Domain Event Catalog — Typed event definitions for all modules.
|
||||
*
|
||||
* Each module section defines payload interfaces and a string-literal
|
||||
* union of event types. The top-level ManaEvent union covers every
|
||||
* possible event so the EventStore and Projection Engine can work
|
||||
* with full type safety.
|
||||
*
|
||||
* Pilot modules: Todo, Calendar, Drink, Nutriphi, Places.
|
||||
*/
|
||||
|
||||
import type { DomainEvent } from './types';
|
||||
|
||||
// ── Todo ────────────────────────────────────────────
|
||||
|
||||
export interface TaskCreatedPayload {
|
||||
taskId: string;
|
||||
title: string;
|
||||
dueDate?: string;
|
||||
priority?: number;
|
||||
projectId?: string;
|
||||
labelIds?: string[];
|
||||
}
|
||||
|
||||
export interface TaskCompletedPayload {
|
||||
taskId: string;
|
||||
title: string;
|
||||
projectId?: string;
|
||||
wasOverdue: boolean;
|
||||
}
|
||||
|
||||
export interface TaskUncompletedPayload {
|
||||
taskId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TaskUpdatedPayload {
|
||||
taskId: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface TaskDeletedPayload {
|
||||
taskId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TaskRescheduledPayload {
|
||||
taskId: string;
|
||||
title: string;
|
||||
oldDate?: string;
|
||||
newDate: string;
|
||||
}
|
||||
|
||||
export interface SubtasksUpdatedPayload {
|
||||
taskId: string;
|
||||
total: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
export interface ReminderSetPayload {
|
||||
taskId: string;
|
||||
reminderId: string;
|
||||
minutesBefore: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface ReminderDeletedPayload {
|
||||
taskId: string;
|
||||
reminderId: string;
|
||||
}
|
||||
|
||||
export type TodoEventType =
|
||||
| 'TaskCreated'
|
||||
| 'TaskCompleted'
|
||||
| 'TaskUncompleted'
|
||||
| 'TaskUpdated'
|
||||
| 'TaskDeleted'
|
||||
| 'TaskRescheduled'
|
||||
| 'SubtasksUpdated'
|
||||
| 'ReminderSet'
|
||||
| 'ReminderDeleted';
|
||||
|
||||
// ── Calendar ────────────────────────────────────────
|
||||
|
||||
export interface CalendarEventCreatedPayload {
|
||||
eventId: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay: boolean;
|
||||
isRecurring: boolean;
|
||||
calendarId: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventUpdatedPayload {
|
||||
eventId: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface CalendarEventDeletedPayload {
|
||||
eventId: string;
|
||||
title: string;
|
||||
wasRecurring: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarEventMovedPayload {
|
||||
eventId: string;
|
||||
title: string;
|
||||
oldStart: string;
|
||||
newStart: string;
|
||||
}
|
||||
|
||||
export type CalendarEventType =
|
||||
| 'CalendarEventCreated'
|
||||
| 'CalendarEventUpdated'
|
||||
| 'CalendarEventDeleted'
|
||||
| 'CalendarEventMoved';
|
||||
|
||||
// ── Drink ───────────────────────────────────────────
|
||||
|
||||
export interface DrinkLoggedPayload {
|
||||
entryId: string;
|
||||
drinkType: string;
|
||||
quantityMl: number;
|
||||
name: string;
|
||||
date: string;
|
||||
time: string;
|
||||
fromPreset: boolean;
|
||||
}
|
||||
|
||||
export interface DrinkEntryDeletedPayload {
|
||||
entryId: string;
|
||||
drinkType: string;
|
||||
quantityMl: number;
|
||||
}
|
||||
|
||||
export interface DrinkEntryUndonePayload {
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
export type DrinkEventType = 'DrinkLogged' | 'DrinkEntryDeleted' | 'DrinkEntryUndone';
|
||||
|
||||
// ── Nutriphi ────────────────────────────────────────
|
||||
|
||||
export interface MealLoggedPayload {
|
||||
mealId: string;
|
||||
mealType: string;
|
||||
inputType: string;
|
||||
description: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface MealFromPhotoLoggedPayload {
|
||||
mealId: string;
|
||||
mealType: string;
|
||||
photoMediaId: string;
|
||||
confidence: number;
|
||||
calories?: number;
|
||||
}
|
||||
|
||||
export interface MealDeletedPayload {
|
||||
mealId: string;
|
||||
mealType: string;
|
||||
}
|
||||
|
||||
export interface NutritionGoalSetPayload {
|
||||
goalId: string;
|
||||
dailyCalories: number;
|
||||
dailyProtein?: number;
|
||||
dailyCarbs?: number;
|
||||
dailyFat?: number;
|
||||
}
|
||||
|
||||
export type NutriphiEventType =
|
||||
| 'MealLogged'
|
||||
| 'MealFromPhotoLogged'
|
||||
| 'MealDeleted'
|
||||
| 'NutritionGoalSet';
|
||||
|
||||
// ── Places ──────────────────────────────────────────
|
||||
|
||||
export interface PlaceCreatedPayload {
|
||||
placeId: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface PlaceDeletedPayload {
|
||||
placeId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PlaceVisitedPayload {
|
||||
placeId: string;
|
||||
name: string;
|
||||
visitCount: number;
|
||||
}
|
||||
|
||||
export interface LocationLoggedPayload {
|
||||
logId: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
placeId?: string;
|
||||
accuracy?: number;
|
||||
}
|
||||
|
||||
export interface TrackingStartedPayload {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TrackingStoppedPayload {
|
||||
durationMs: number;
|
||||
logCount: number;
|
||||
}
|
||||
|
||||
export type PlacesEventType =
|
||||
| 'PlaceCreated'
|
||||
| 'PlaceDeleted'
|
||||
| 'PlaceVisited'
|
||||
| 'LocationLogged'
|
||||
| 'TrackingStarted'
|
||||
| 'TrackingStopped';
|
||||
|
||||
// ── System Events (Goals, Companion) ────────────────
|
||||
|
||||
export interface GoalReachedPayload {
|
||||
goalId: string;
|
||||
title: string;
|
||||
value: number;
|
||||
target: number;
|
||||
period: string;
|
||||
}
|
||||
|
||||
export interface GoalProgressPayload {
|
||||
goalId: string;
|
||||
title: string;
|
||||
value: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
export type SystemEventType = 'GoalReached' | 'GoalProgress';
|
||||
|
||||
// ── Union of all event types ────────────────────────
|
||||
|
||||
export type ManaEventType =
|
||||
| TodoEventType
|
||||
| CalendarEventType
|
||||
| DrinkEventType
|
||||
| NutriphiEventType
|
||||
| PlacesEventType
|
||||
| SystemEventType;
|
||||
|
||||
/**
|
||||
* Discriminated union of all domain events.
|
||||
* Use this for the EventStore subscriber and Projection handlers.
|
||||
*/
|
||||
export type ManaEvent =
|
||||
// Todo
|
||||
| DomainEvent<'TaskCreated', TaskCreatedPayload>
|
||||
| DomainEvent<'TaskCompleted', TaskCompletedPayload>
|
||||
| DomainEvent<'TaskUncompleted', TaskUncompletedPayload>
|
||||
| DomainEvent<'TaskUpdated', TaskUpdatedPayload>
|
||||
| DomainEvent<'TaskDeleted', TaskDeletedPayload>
|
||||
| DomainEvent<'TaskRescheduled', TaskRescheduledPayload>
|
||||
| DomainEvent<'SubtasksUpdated', SubtasksUpdatedPayload>
|
||||
| DomainEvent<'ReminderSet', ReminderSetPayload>
|
||||
| DomainEvent<'ReminderDeleted', ReminderDeletedPayload>
|
||||
// Calendar
|
||||
| DomainEvent<'CalendarEventCreated', CalendarEventCreatedPayload>
|
||||
| DomainEvent<'CalendarEventUpdated', CalendarEventUpdatedPayload>
|
||||
| DomainEvent<'CalendarEventDeleted', CalendarEventDeletedPayload>
|
||||
| DomainEvent<'CalendarEventMoved', CalendarEventMovedPayload>
|
||||
// Drink
|
||||
| DomainEvent<'DrinkLogged', DrinkLoggedPayload>
|
||||
| DomainEvent<'DrinkEntryDeleted', DrinkEntryDeletedPayload>
|
||||
| DomainEvent<'DrinkEntryUndone', DrinkEntryUndonePayload>
|
||||
// Nutriphi
|
||||
| DomainEvent<'MealLogged', MealLoggedPayload>
|
||||
| DomainEvent<'MealFromPhotoLogged', MealFromPhotoLoggedPayload>
|
||||
| DomainEvent<'MealDeleted', MealDeletedPayload>
|
||||
| DomainEvent<'NutritionGoalSet', NutritionGoalSetPayload>
|
||||
// Places
|
||||
| DomainEvent<'PlaceCreated', PlaceCreatedPayload>
|
||||
| DomainEvent<'PlaceDeleted', PlaceDeletedPayload>
|
||||
| DomainEvent<'PlaceVisited', PlaceVisitedPayload>
|
||||
| DomainEvent<'LocationLogged', LocationLoggedPayload>
|
||||
| DomainEvent<'TrackingStarted', TrackingStartedPayload>
|
||||
| DomainEvent<'TrackingStopped', TrackingStoppedPayload>
|
||||
// System
|
||||
| DomainEvent<'GoalReached', GoalReachedPayload>
|
||||
| DomainEvent<'GoalProgress', GoalProgressPayload>;
|
||||
44
apps/mana/apps/web/src/lib/data/events/emit.ts
Normal file
44
apps/mana/apps/web/src/lib/data/events/emit.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Convenience helper for emitting domain events from module stores.
|
||||
*
|
||||
* Builds the EventMeta automatically so stores only need to specify
|
||||
* the event type, routing info, and payload.
|
||||
*/
|
||||
|
||||
import { eventBus } from './event-bus';
|
||||
import { getEffectiveUserId } from '../current-user';
|
||||
import type { DomainEvent } from './types';
|
||||
|
||||
/**
|
||||
* Emit a domain event on the shared bus.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
|
||||
* taskId: id, title: task.title, wasOverdue: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function emitDomainEvent<P>(
|
||||
type: string,
|
||||
appId: string,
|
||||
collection: string,
|
||||
recordId: string,
|
||||
payload: P,
|
||||
causedBy?: string
|
||||
): void {
|
||||
const event: DomainEvent<string, P> = {
|
||||
type,
|
||||
payload,
|
||||
meta: {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
appId,
|
||||
collection,
|
||||
recordId,
|
||||
userId: getEffectiveUserId(),
|
||||
causedBy,
|
||||
},
|
||||
};
|
||||
eventBus.emit(event);
|
||||
}
|
||||
69
apps/mana/apps/web/src/lib/data/events/event-bus.ts
Normal file
69
apps/mana/apps/web/src/lib/data/events/event-bus.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* EventBus — Synchronous in-process event dispatcher.
|
||||
*
|
||||
* Handlers are invoked via queueMicrotask so they run after the current
|
||||
* Dexie transaction commits but before the next frame — no setTimeout
|
||||
* delay, no transaction interference.
|
||||
*
|
||||
* A re-entrancy guard prevents infinite loops when a handler emits
|
||||
* another event of the same type.
|
||||
*/
|
||||
|
||||
import type { DomainEvent, EventBus, EventHandler } from './types';
|
||||
|
||||
export function createEventBus(): EventBus {
|
||||
const handlers = new Map<string, Set<EventHandler>>();
|
||||
const anyHandlers = new Set<EventHandler>();
|
||||
const emitting = new Set<string>();
|
||||
|
||||
return {
|
||||
emit(event: DomainEvent) {
|
||||
queueMicrotask(() => {
|
||||
if (emitting.has(event.type)) {
|
||||
console.warn(`[event-bus] re-entrant emit blocked: ${event.type}`);
|
||||
return;
|
||||
}
|
||||
emitting.add(event.type);
|
||||
try {
|
||||
const typeHandlers = handlers.get(event.type);
|
||||
if (typeHandlers) {
|
||||
for (const h of typeHandlers) {
|
||||
try {
|
||||
h(event);
|
||||
} catch (err) {
|
||||
console.error(`[event-bus] handler error for ${event.type}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const h of anyHandlers) {
|
||||
try {
|
||||
h(event);
|
||||
} catch (err) {
|
||||
console.error(`[event-bus] anyHandler error for ${event.type}:`, err);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
emitting.delete(event.type);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
on(type, handler) {
|
||||
if (!handlers.has(type)) handlers.set(type, new Set());
|
||||
handlers.get(type)!.add(handler);
|
||||
return () => handlers.get(type)?.delete(handler);
|
||||
},
|
||||
|
||||
onAny(handler) {
|
||||
anyHandlers.add(handler);
|
||||
return () => anyHandlers.delete(handler);
|
||||
},
|
||||
|
||||
off(type, handler) {
|
||||
handlers.get(type)?.delete(handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton bus shared across the entire app. */
|
||||
export const eventBus: EventBus = createEventBus();
|
||||
156
apps/mana/apps/web/src/lib/data/events/event-store.ts
Normal file
156
apps/mana/apps/web/src/lib/data/events/event-store.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Event Store — Persists all domain events to the _events Dexie table.
|
||||
*
|
||||
* Subscribes to eventBus.onAny() and writes each event as an append-only
|
||||
* row. Provides query helpers for Projections and the Correlation Engine.
|
||||
*
|
||||
* Retention: 90 days / 50,000 events max (whichever is reached first).
|
||||
*/
|
||||
|
||||
import { db } from '../database';
|
||||
import { eventBus } from './event-bus';
|
||||
import type { DomainEvent } from './types';
|
||||
|
||||
const EVENTS_TABLE = '_events';
|
||||
const MAX_EVENTS = 50_000;
|
||||
const TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
|
||||
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
/** Start persisting all domain events. Call once at app init. */
|
||||
export function startEventStore(): void {
|
||||
if (unsubscribe) return;
|
||||
unsubscribe = eventBus.onAny((event: DomainEvent) => {
|
||||
db.table(EVENTS_TABLE)
|
||||
.add({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
meta: event.meta,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('[event-store] failed to persist event:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop persisting events (for cleanup / tests). */
|
||||
export function stopEventStore(): void {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
// ── Queries ─────────────────────────────────────────
|
||||
|
||||
export interface EventQuery {
|
||||
appId?: string;
|
||||
type?: string;
|
||||
since?: string; // ISO timestamp
|
||||
until?: string; // ISO timestamp
|
||||
limit?: number; // default 100
|
||||
}
|
||||
|
||||
interface StoredEvent {
|
||||
seq?: number;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
meta: {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
appId: string;
|
||||
collection: string;
|
||||
recordId: string;
|
||||
userId: string;
|
||||
causedBy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Query persisted events. Most recent first. */
|
||||
export async function queryEvents(query: EventQuery = {}): Promise<DomainEvent[]> {
|
||||
const limit = Math.min(query.limit ?? 100, 1000);
|
||||
let collection;
|
||||
|
||||
if (query.appId && query.type) {
|
||||
// Use compound index for appId, filter type in JS
|
||||
collection = db
|
||||
.table(EVENTS_TABLE)
|
||||
.where('[meta.appId+meta.timestamp]')
|
||||
.between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']);
|
||||
} else if (query.appId) {
|
||||
collection = db
|
||||
.table(EVENTS_TABLE)
|
||||
.where('[meta.appId+meta.timestamp]')
|
||||
.between([query.appId, query.since ?? ''], [query.appId, query.until ?? '\uffff']);
|
||||
} else if (query.type) {
|
||||
collection = db
|
||||
.table(EVENTS_TABLE)
|
||||
.where('[type+meta.timestamp]')
|
||||
.between([query.type, query.since ?? ''], [query.type, query.until ?? '\uffff']);
|
||||
} else {
|
||||
collection = db.table(EVENTS_TABLE).orderBy('seq');
|
||||
}
|
||||
|
||||
let results: StoredEvent[] = await collection.reverse().limit(limit).toArray();
|
||||
|
||||
// Apply additional filters not covered by the index
|
||||
if (query.type && query.appId) {
|
||||
results = results.filter((e) => e.type === query.type);
|
||||
}
|
||||
if (query.since && !query.appId && !query.type) {
|
||||
results = results.filter((e) => e.meta.timestamp >= query.since!);
|
||||
}
|
||||
if (query.until && !query.appId && !query.type) {
|
||||
results = results.filter((e) => e.meta.timestamp <= query.until!);
|
||||
}
|
||||
|
||||
return results.map((row) => ({
|
||||
type: row.type,
|
||||
payload: row.payload,
|
||||
meta: row.meta,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Count events by type within a date range. */
|
||||
export async function countEventsByType(
|
||||
since: string,
|
||||
until: string
|
||||
): Promise<Record<string, number>> {
|
||||
const rows: StoredEvent[] = await db
|
||||
.table(EVENTS_TABLE)
|
||||
.where('meta.timestamp')
|
||||
.between(since, until)
|
||||
.toArray();
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
counts[row.type] = (counts[row.type] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
// ── Pruning ─────────────────────────────────────────
|
||||
|
||||
/** Remove events older than TTL or exceeding max count. */
|
||||
export async function pruneEventStore(): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - TTL_MS).toISOString();
|
||||
let deleted = 0;
|
||||
|
||||
// Delete by age
|
||||
const old = await db.table(EVENTS_TABLE).where('meta.timestamp').below(cutoff).primaryKeys();
|
||||
if (old.length > 0) {
|
||||
await db.table(EVENTS_TABLE).bulkDelete(old);
|
||||
deleted += old.length;
|
||||
}
|
||||
|
||||
// Delete overflow (keep newest MAX_EVENTS)
|
||||
const total = await db.table(EVENTS_TABLE).count();
|
||||
if (total > MAX_EVENTS) {
|
||||
const overflow = total - MAX_EVENTS;
|
||||
const oldest = await db.table(EVENTS_TABLE).orderBy('seq').limit(overflow).primaryKeys();
|
||||
if (oldest.length > 0) {
|
||||
await db.table(EVENTS_TABLE).bulkDelete(oldest);
|
||||
deleted += oldest.length;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
4
apps/mana/apps/web/src/lib/data/events/index.ts
Normal file
4
apps/mana/apps/web/src/lib/data/events/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { eventBus, createEventBus } from './event-bus';
|
||||
export { emitDomainEvent } from './emit';
|
||||
export type { DomainEvent, EventMeta, EventBus, EventHandler } from './types';
|
||||
export type * from './catalog';
|
||||
48
apps/mana/apps/web/src/lib/data/events/types.ts
Normal file
48
apps/mana/apps/web/src/lib/data/events/types.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Domain Event types for the Mana Companion Brain.
|
||||
*
|
||||
* Every module mutation emits a typed DomainEvent via the EventBus.
|
||||
* Events carry semantic meaning ("TaskCompleted") rather than raw CRUD
|
||||
* operations ("tasks table updated"), enabling Projections, Rules, and
|
||||
* the LLM Context Builder to work without reverse-engineering field diffs.
|
||||
*/
|
||||
|
||||
// ── Core Event Shape ────────────────────────────────
|
||||
|
||||
export interface DomainEvent<T extends string = string, P = unknown> {
|
||||
readonly type: T;
|
||||
readonly payload: P;
|
||||
readonly meta: EventMeta;
|
||||
}
|
||||
|
||||
export interface EventMeta {
|
||||
/** Unique event ID */
|
||||
readonly id: string;
|
||||
/** ISO timestamp */
|
||||
readonly timestamp: string;
|
||||
/** Source module (e.g. 'todo', 'drink') */
|
||||
readonly appId: string;
|
||||
/** Source Dexie table */
|
||||
readonly collection: string;
|
||||
/** Affected record ID */
|
||||
readonly recordId: string;
|
||||
/** User who triggered this */
|
||||
readonly userId: string;
|
||||
/** Parent event ID (for trigger chains / cascades) */
|
||||
readonly causedBy?: string;
|
||||
}
|
||||
|
||||
// ── Bus Interface ───────────────────────────────────
|
||||
|
||||
export type EventHandler<E extends DomainEvent = DomainEvent> = (event: E) => void;
|
||||
|
||||
export interface EventBus {
|
||||
/** Emit a domain event. Handlers run asynchronously via queueMicrotask. */
|
||||
emit(event: DomainEvent): void;
|
||||
/** Subscribe to a specific event type. Returns unsubscribe function. */
|
||||
on<T extends string>(type: T, handler: EventHandler): () => void;
|
||||
/** Subscribe to all events. Returns unsubscribe function. */
|
||||
onAny(handler: EventHandler): () => void;
|
||||
/** Unsubscribe a handler from a specific event type. */
|
||||
off(type: string, handler: EventHandler): void;
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
||||
import {
|
||||
|
|
@ -85,6 +86,16 @@ export const eventsStore = {
|
|||
// reads go through queries.ts which decrypts on the way out.
|
||||
await encryptRecord('events', newLocal);
|
||||
await db.table<LocalEvent>('events').add(newLocal);
|
||||
emitDomainEvent('CalendarEventCreated', 'calendar', 'events', eventId, {
|
||||
eventId,
|
||||
title: input.title,
|
||||
startTime: input.startTime,
|
||||
endTime: input.endTime,
|
||||
isAllDay: input.isAllDay ?? false,
|
||||
isRecurring: !!input.recurrenceRule,
|
||||
calendarId: input.calendarId,
|
||||
location: input.location,
|
||||
});
|
||||
CalendarEvents.eventCreated(!!input.recurrenceRule);
|
||||
return { success: true, data: { id: eventId, timeBlockId } };
|
||||
} catch (e) {
|
||||
|
|
@ -142,6 +153,10 @@ export const eventsStore = {
|
|||
|
||||
await encryptRecord('events', localData);
|
||||
await db.table('events').update(id, localData);
|
||||
emitDomainEvent('CalendarEventUpdated', 'calendar', 'events', id, {
|
||||
eventId: id,
|
||||
fields: Object.keys(input).filter((k) => input[k as keyof typeof input] !== undefined),
|
||||
});
|
||||
CalendarEvents.eventUpdated();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
|
|
@ -342,6 +357,11 @@ export const eventsStore = {
|
|||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('CalendarEventDeleted', 'calendar', 'events', id, {
|
||||
eventId: id,
|
||||
title: event?.title ?? '',
|
||||
wasRecurring: false,
|
||||
});
|
||||
CalendarEvents.eventDeleted();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { drinkEntryTable, drinkPresetTable } from '../collections';
|
||||
import { toDrinkEntry, toDrinkPreset, todayStr, nowTime } from '../queries';
|
||||
import type { LocalDrinkEntry, LocalDrinkPreset, DrinkType } from '../types';
|
||||
|
|
@ -34,6 +35,15 @@ export const drinkStore = {
|
|||
const snapshot = toDrinkEntry({ ...newLocal });
|
||||
await encryptRecord('drinkEntries', newLocal);
|
||||
await drinkEntryTable.add(newLocal);
|
||||
emitDomainEvent('DrinkLogged', 'drink', 'drinkEntries', newLocal.id, {
|
||||
entryId: newLocal.id,
|
||||
drinkType: input.drinkType,
|
||||
quantityMl: input.quantityMl,
|
||||
name: input.name,
|
||||
date: newLocal.date,
|
||||
time: newLocal.time,
|
||||
fromPreset: !!input.presetId,
|
||||
});
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
|
|
@ -64,10 +74,18 @@ export const drinkStore = {
|
|||
},
|
||||
|
||||
async deleteEntry(id: string) {
|
||||
const entry = await drinkEntryTable.get(id);
|
||||
await drinkEntryTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
if (entry) {
|
||||
emitDomainEvent('DrinkEntryDeleted', 'drink', 'drinkEntries', id, {
|
||||
entryId: id,
|
||||
drinkType: entry.drinkType,
|
||||
quantityMl: entry.quantityMl,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async undoLastEntry() {
|
||||
|
|
@ -76,10 +94,14 @@ export const drinkStore = {
|
|||
.filter((e) => !e.deletedAt)
|
||||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
|
||||
if (active.length > 0) {
|
||||
await drinkEntryTable.update(active[0].id, {
|
||||
const entry = active[0];
|
||||
await drinkEntryTable.update(entry.id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('DrinkEntryUndone', 'drink', 'drinkEntries', entry.id, {
|
||||
entryId: entry.id,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
import { db } from '$lib/data/database';
|
||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import {
|
||||
uploadMealPhoto,
|
||||
analyzeMealPhoto,
|
||||
|
|
@ -74,6 +75,15 @@ export const mealMutations = {
|
|||
const encrypted: Record<string, unknown> = { ...row };
|
||||
await encryptRecord('meals', encrypted);
|
||||
await db.table('meals').add(encrypted);
|
||||
emitDomainEvent('MealLogged', 'nutriphi', 'meals', row.id, {
|
||||
mealId: row.id,
|
||||
mealType: dto.mealType,
|
||||
inputType: 'text',
|
||||
description: dto.description,
|
||||
calories: dto.nutrition?.calories,
|
||||
protein: dto.nutrition?.protein,
|
||||
date: row.date,
|
||||
});
|
||||
return row;
|
||||
},
|
||||
|
||||
|
|
@ -99,6 +109,13 @@ export const mealMutations = {
|
|||
const encrypted: Record<string, unknown> = { ...row };
|
||||
await encryptRecord('meals', encrypted);
|
||||
await db.table('meals').add(encrypted);
|
||||
emitDomainEvent('MealFromPhotoLogged', 'nutriphi', 'meals', row.id, {
|
||||
mealId: row.id,
|
||||
mealType: dto.mealType,
|
||||
photoMediaId: dto.photoMediaId,
|
||||
confidence: dto.confidence,
|
||||
calories: dto.nutrition?.calories,
|
||||
});
|
||||
return row;
|
||||
},
|
||||
|
||||
|
|
@ -131,8 +148,13 @@ export const mealMutations = {
|
|||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const existing = await db.table<LocalMeal>('meals').get(id);
|
||||
const now = new Date().toISOString();
|
||||
await db.table('meals').update(id, { deletedAt: now, updatedAt: now });
|
||||
emitDomainEvent('MealDeleted', 'nutriphi', 'meals', id, {
|
||||
mealId: id,
|
||||
mealType: existing?.mealType ?? '',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { createBlock } from '$lib/data/time-blocks/service';
|
||||
import { placeTable } from '../collections';
|
||||
import { toPlace } from '../queries';
|
||||
|
|
@ -41,6 +42,13 @@ export const placesStore = {
|
|||
const plaintextSnapshot = toPlace({ ...newLocal });
|
||||
await encryptRecord('places', newLocal);
|
||||
await placeTable.add(newLocal);
|
||||
emitDomainEvent('PlaceCreated', 'places', 'places', newLocal.id, {
|
||||
placeId: newLocal.id,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
lat: data.latitude,
|
||||
lng: data.longitude,
|
||||
});
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
|
|
@ -67,10 +75,16 @@ export const placesStore = {
|
|||
},
|
||||
|
||||
async deletePlace(id: string) {
|
||||
const local = await placeTable.get(id);
|
||||
const decrypted = local ? await decryptRecord('places', { ...local }) : null;
|
||||
await placeTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('PlaceDeleted', 'places', 'places', id, {
|
||||
placeId: id,
|
||||
name: (decrypted?.name as string) ?? '',
|
||||
});
|
||||
},
|
||||
|
||||
async toggleFavorite(id: string) {
|
||||
|
|
@ -114,5 +128,10 @@ export const placesStore = {
|
|||
title: placeName,
|
||||
color: '#a855f7',
|
||||
});
|
||||
emitDomainEvent('PlaceVisited', 'places', 'places', id, {
|
||||
placeId: id,
|
||||
name: placeName,
|
||||
visitCount: (local.visitCount ?? 0) + 1,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import { decryptRecords, encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { createBlock } from '$lib/data/time-blocks/service';
|
||||
import { locationLogTable, placeTable } from '../collections';
|
||||
import { getDistanceKm, findNearestPlace, toPlace } from '../queries';
|
||||
|
|
@ -48,6 +49,9 @@ function startTracking() {
|
|||
|
||||
error = null;
|
||||
isTracking = true;
|
||||
emitDomainEvent('TrackingStarted', 'places', 'locationLogs', '', {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
_watchId = navigator.geolocation.watchPosition(
|
||||
async (pos) => {
|
||||
|
|
@ -79,6 +83,10 @@ function stopTracking() {
|
|||
_watchId = null;
|
||||
}
|
||||
isTracking = false;
|
||||
emitDomainEvent('TrackingStopped', 'places', 'locationLogs', '', {
|
||||
durationMs: 0,
|
||||
logCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function getCurrentPosition(): Promise<GeolocationPosition | null> {
|
||||
|
|
@ -135,6 +143,13 @@ async function logPosition(pos: GeolocationPosition) {
|
|||
|
||||
await encryptRecord('locationLogs', log);
|
||||
await locationLogTable.add(log);
|
||||
emitDomainEvent('LocationLogged', 'places', 'locationLogs', log.id, {
|
||||
logId: log.id,
|
||||
lat,
|
||||
lng,
|
||||
placeId: nearest?.id,
|
||||
accuracy: pos.coords.accuracy,
|
||||
});
|
||||
|
||||
// Update visit count on the matched place + create TimeBlock
|
||||
if (nearest) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { toTask } from '../queries';
|
|||
import type { LocalTask, TaskPriority, Subtask } from '../types';
|
||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { transcribeAudio } from '$lib/voice/transcribe';
|
||||
import { TodoEvents } from '@mana/shared-utils/analytics';
|
||||
import { tagCollection, type LocalTag } from '@mana/shared-stores';
|
||||
|
|
@ -153,6 +154,14 @@ export const tasksStore = {
|
|||
const plaintextSnapshot = toTask({ ...newLocal });
|
||||
await encryptRecord('tasks', newLocal);
|
||||
await taskTable.add(newLocal);
|
||||
emitDomainEvent('TaskCreated', 'todo', 'tasks', taskId, {
|
||||
taskId,
|
||||
title: plaintextSnapshot.title,
|
||||
dueDate: data.dueDate,
|
||||
priority: data.priority,
|
||||
projectId: data.projectId,
|
||||
labelIds: data.labelIds,
|
||||
});
|
||||
TodoEvents.taskCreated(!!data.dueDate);
|
||||
return plaintextSnapshot;
|
||||
},
|
||||
|
|
@ -362,6 +371,7 @@ export const tasksStore = {
|
|||
|
||||
async deleteTask(id: string) {
|
||||
const task = await taskTable.get(id);
|
||||
const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
|
||||
if (task?.scheduledBlockId) {
|
||||
await deleteBlock(task.scheduledBlockId);
|
||||
}
|
||||
|
|
@ -370,24 +380,44 @@ export const tasksStore = {
|
|||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('TaskDeleted', 'todo', 'tasks', id, {
|
||||
taskId: id,
|
||||
title: (decrypted?.title as string) ?? '',
|
||||
});
|
||||
TodoEvents.taskDeleted();
|
||||
},
|
||||
|
||||
async completeTask(id: string) {
|
||||
const task = await taskTable.get(id);
|
||||
const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
|
||||
const now = new Date().toISOString();
|
||||
const wasOverdue = task?.dueDate != null && (task.dueDate as string) < now.slice(0, 10);
|
||||
await taskTable.update(id, {
|
||||
isCompleted: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
completedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
emitDomainEvent('TaskCompleted', 'todo', 'tasks', id, {
|
||||
taskId: id,
|
||||
title: (decrypted?.title as string) ?? '',
|
||||
projectId: task?.projectId,
|
||||
wasOverdue,
|
||||
});
|
||||
TodoEvents.taskCompleted();
|
||||
},
|
||||
|
||||
async uncompleteTask(id: string) {
|
||||
const task = await taskTable.get(id);
|
||||
const decrypted = task ? await decryptRecord('tasks', { ...task }) : null;
|
||||
await taskTable.update(id, {
|
||||
isCompleted: false,
|
||||
completedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
emitDomainEvent('TaskUncompleted', 'todo', 'tasks', id, {
|
||||
taskId: id,
|
||||
title: (decrypted?.title as string) ?? '',
|
||||
});
|
||||
},
|
||||
|
||||
async toggleComplete(id: string) {
|
||||
|
|
@ -408,6 +438,11 @@ export const tasksStore = {
|
|||
};
|
||||
await encryptRecord('tasks', diff);
|
||||
await taskTable.update(id, diff);
|
||||
emitDomainEvent('SubtasksUpdated', 'todo', 'tasks', id, {
|
||||
taskId: id,
|
||||
total: subtasks.length,
|
||||
completed: subtasks.filter((s) => s.isCompleted).length,
|
||||
});
|
||||
},
|
||||
|
||||
async updateLabels(id: string, labelIds: string[]) {
|
||||
|
|
|
|||
1333
docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
Normal file
1333
docs/architecture/COMPANION_BRAIN_ARCHITECTURE.md
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue