@hearthlog/runtime 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/access/session.ts +223 -0
  4. package/src/access/user-session.ts +61 -0
  5. package/src/ai/decisions.ts +89 -0
  6. package/src/calendar/draft.ts +84 -0
  7. package/src/calendar/feeds.ts +91 -0
  8. package/src/calendar/groups.ts +198 -0
  9. package/src/calendar/logic.ts +141 -0
  10. package/src/calendar/preferences.ts +40 -0
  11. package/src/calendar/weeks.ts +56 -0
  12. package/src/commands/index.ts +184 -0
  13. package/src/commands/local-commit.ts +214 -0
  14. package/src/commands/standard.ts +8 -0
  15. package/src/commands/validation.ts +206 -0
  16. package/src/db/bootstrap.ts +72 -0
  17. package/src/db/calendar.ts +638 -0
  18. package/src/db/dynamic-ddl.ts +127 -0
  19. package/src/db/entities.ts +208 -0
  20. package/src/db/history.ts +194 -0
  21. package/src/db/index.ts +1240 -0
  22. package/src/db/kv.ts +33 -0
  23. package/src/db/local-statistics.ts +106 -0
  24. package/src/db/module-projections.ts +46 -0
  25. package/src/db/raw-browser.ts +361 -0
  26. package/src/db/read-repositories.ts +29 -0
  27. package/src/db/read-session.ts +47 -0
  28. package/src/db/schema-version.ts +76 -0
  29. package/src/db/session-context.ts +33 -0
  30. package/src/event/code-export.ts +65 -0
  31. package/src/event/dynamic-entities.ts +503 -0
  32. package/src/event/dynamic-materializer.ts +288 -0
  33. package/src/event/handlers.ts +490 -0
  34. package/src/event/index.ts +53 -0
  35. package/src/event/meta-entity-types.ts +165 -0
  36. package/src/event/projection-builder.ts +384 -0
  37. package/src/event/queries.ts +126 -0
  38. package/src/event/rebuild.ts +176 -0
  39. package/src/event/reference-lookup.ts +93 -0
  40. package/src/event/registry.ts +158 -0
  41. package/src/event/schema-effects.ts +45 -0
  42. package/src/event/types.ts +20 -0
  43. package/src/history/action-boundary.ts +18 -0
  44. package/src/history/action-detail.ts +23 -0
  45. package/src/history/change-comparison.ts +29 -0
  46. package/src/history/change-display.ts +41 -0
  47. package/src/history/contracts.ts +63 -0
  48. package/src/history/history.ts +372 -0
  49. package/src/history/legacy-replay.ts +69 -0
  50. package/src/history/legacy-url.ts +9 -0
  51. package/src/history/lineage.ts +123 -0
  52. package/src/history/log-format.ts +17 -0
  53. package/src/history/log-map.ts +110 -0
  54. package/src/history/navigation.ts +14 -0
  55. package/src/history/point-snapshot.ts +229 -0
  56. package/src/history/timeline.ts +215 -0
  57. package/src/history/url.ts +18 -0
  58. package/src/ledger/README.md +118 -0
  59. package/src/ledger/admin-client.ts +616 -0
  60. package/src/ledger/client.ts +10 -0
  61. package/src/ledger/config.ts +117 -0
  62. package/src/ledger/entity-id.ts +115 -0
  63. package/src/ledger/errors.ts +144 -0
  64. package/src/ledger/http-client.ts +590 -0
  65. package/src/ledger/index.ts +67 -0
  66. package/src/ledger/socket.ts +400 -0
  67. package/src/ledger/types.ts +202 -0
  68. package/src/ledger/version-tracker-singleton.ts +12 -0
  69. package/src/ledger/version-tracker.ts +259 -0
  70. package/src/log-management.ts +123 -0
  71. package/src/log-validation.ts +19 -0
  72. package/src/native/reminders.ts +2 -0
  73. package/src/native/shell.svelte.ts +136 -0
  74. package/src/native/shell.ts +102 -0
  75. package/src/pwa/registration.ts +169 -0
  76. package/src/recovery/local-recovery.ts +59 -0
  77. package/src/recovery/reset-sequence.ts +13 -0
  78. package/src/runtime/clock.ts +43 -0
  79. package/src/runtime/kept-page-data.svelte.ts +42 -0
  80. package/src/runtime/page-data.svelte.ts +160 -0
  81. package/src/runtime/phone-header.svelte.ts +17 -0
  82. package/src/runtime/read-coordinator.ts +103 -0
  83. package/src/runtime/refresh.ts +15 -0
  84. package/src/runtime/start-page.ts +56 -0
  85. package/src/runtime/view.ts +83 -0
  86. package/src/runtime/workspace.ts +62 -0
  87. package/src/storage/indexeddb-statistics.ts +153 -0
  88. package/src/store/attention.svelte.ts +51 -0
  89. package/src/store/conflict-merge.ts +169 -0
  90. package/src/store/entity-sync-state.svelte.ts +36 -0
  91. package/src/store/event-log.svelte.ts +109 -0
  92. package/src/store/events.svelte.ts +14 -0
  93. package/src/store/local-context.ts +93 -0
  94. package/src/store/local-store.svelte.ts +58 -0
  95. package/src/store/log-version.svelte.ts +35 -0
  96. package/src/store/logs.svelte.ts +60 -0
  97. package/src/store/moved.svelte.ts +43 -0
  98. package/src/store/offline-readiness.svelte.ts +188 -0
  99. package/src/store/outbox-request.ts +61 -0
  100. package/src/store/outbox.ts +426 -0
  101. package/src/store/pending-work-transfer.ts +49 -0
  102. package/src/store/pending-work.ts +143 -0
  103. package/src/store/sync-coordinator.ts +170 -0
  104. package/src/store/sync-invalidation.ts +10 -0
  105. package/src/store/sync-issues.ts +406 -0
  106. package/src/store/sync-progress.ts +54 -0
  107. package/src/store/sync-state.ts +127 -0
  108. package/src/store/sync.svelte.ts +1010 -0
  109. package/src/store/tab-coordination.ts +164 -0
  110. package/src/testing/e2e-seed.ts +40 -0
  111. package/src/testing/local-database.ts +120 -0
  112. package/src/testing/projection-database.ts +105 -0
  113. package/src/testing/test-entities.ts +28 -0
  114. package/src/theme/appearance.svelte.ts +111 -0
  115. package/src/utils/address.ts +5 -0
  116. package/src/utils/async.ts +30 -0
  117. package/src/utils/long-press.ts +70 -0
  118. package/src/utils/perf-dataset.ts +405 -0
  119. package/src/utils/perf.ts +627 -0
  120. package/README.md +0 -3
@@ -0,0 +1,490 @@
1
+ import { hasReplayDirectives } from '../history/legacy-replay.js';
2
+ import { EVENTS_EXCISED, WORKSPACE_RESTORED } from '@hearthlog/contracts/history/replay-selection';
3
+ import { recordIngestedProgress } from '../store/sync-progress.js';
4
+ /**
5
+ * Event Handlers
6
+ *
7
+ * Core event processing and handling functions.
8
+ * Uses dynamic dispatch based on the entity registry.
9
+ */
10
+
11
+ import { db as readModelDB, requireActiveContext } from '../db/index.js';
12
+ import type { ActiveContext, Database } from '../db/index.js';
13
+ import { sql, type Transaction } from 'kysely';
14
+ import type { StoredEvent } from '../ledger/types.js';
15
+ import { versionTracker, rebuildReadModel } from './rebuild.js';
16
+ import { schemaTombstoneIds } from './schema-effects.js';
17
+ import { entityRegistry, getAllEntityTables, clearDynamicRegistry } from './registry.js';
18
+ import { createEntityProjector, projectEntitySet } from './projection-builder.js';
19
+ import type {
20
+ AppEvent,
21
+ TombstoneEvent,
22
+ } from './types.js';
23
+ import {
24
+ isEntityTypeDefinedEvent,
25
+ isEntityTypeUpdatedEvent,
26
+ isEntityTypeArchivedEvent,
27
+ } from './meta-entity-types.js';
28
+ import {
29
+ handleEntityTypeDefined,
30
+ handleEntityTypeUpdated,
31
+ handleEntityTypeArchived,
32
+ } from './dynamic-entities.js';
33
+ import { withTiming, createTimer } from '../utils/perf.js';
34
+ import { postTabChange } from '../store/tab-coordination.js';
35
+
36
+ // ============================================================================
37
+ // In-memory event cache for realtime duplicate suppression
38
+ // ============================================================================
39
+
40
+ /**
41
+ * Cache of recently processed event IDs to skip redundant INSERT operations.
42
+ * When tabs share SQLite, one tab inserts and another receives via WebSocket.
43
+ * The receiving tab can skip INSERT if the event was already processed.
44
+ */
45
+ const RECENT_EVENT_CACHE_SIZE = 500;
46
+ const recentEventIds = new Set<string>();
47
+ const recentEventQueue: string[] = []; // FIFO queue to maintain order for eviction
48
+
49
+ /** Add an event ID to the recent cache */
50
+ export function cacheEventId(eventId: string): void {
51
+ if (recentEventIds.has(eventId)) return;
52
+
53
+ recentEventIds.add(eventId);
54
+ recentEventQueue.push(eventId);
55
+
56
+ // Evict oldest if over capacity
57
+ while (recentEventQueue.length > RECENT_EVENT_CACHE_SIZE) {
58
+ const oldest = recentEventQueue.shift();
59
+ if (oldest) recentEventIds.delete(oldest);
60
+ }
61
+ }
62
+
63
+ /** Check if event was recently processed by this tab */
64
+ export function isEventCached(eventId: string): boolean {
65
+ return recentEventIds.has(eventId);
66
+ }
67
+
68
+ export function clearEventCache(): void {
69
+ recentEventIds.clear();
70
+ recentEventQueue.length = 0;
71
+ }
72
+
73
+ // ============================================================================
74
+ // Type Guards
75
+ // ============================================================================
76
+
77
+ export function isTombstoneEvent(event: AppEvent): event is TombstoneEvent {
78
+ return event.type === 'EVENT_TOMBSTONED';
79
+ }
80
+
81
+ // ============================================================================
82
+ // Event Projection
83
+ // ============================================================================
84
+
85
+ export async function projectEntityById(
86
+ entityId: string,
87
+ tx: Transaction<Database>,
88
+ context: ActiveContext,
89
+ ): Promise<void> {
90
+ const timer = createTimer('projection', `projectEntity:${entityId.split(':')[0]}`);
91
+ const [entityType, uuid] = entityId.split(':');
92
+ if (!uuid || !entityType) {
93
+ console.warn('[Event] Invalid entity ID format for projection:', entityId);
94
+ return;
95
+ }
96
+
97
+ const entry = entityRegistry[entityType];
98
+ if (!entry) {
99
+ // This is expected for events that don't map to entities (if any)
100
+ return;
101
+ }
102
+
103
+ // The projector handles deletion of the old record and re-creation
104
+ const projector = createEntityProjector(entry.projection);
105
+ await projector({ payload: { id: uuid } }, tx, entityId, context);
106
+
107
+ timer.stop({ entityId });
108
+ }
109
+
110
+ function compareStoredEvents(a: StoredEvent, b: StoredEvent): number {
111
+ return a.version - b.version || a.timestamp - b.timestamp || a.id.localeCompare(b.id);
112
+ }
113
+
114
+ // Twenty raw-event columns keep 40 rows below SQLite's conservative 999-bind
115
+ // limit. Projection sets need far fewer bindings and can contain 100 entities.
116
+ const EVENT_WRITE_BATCH_SIZE = 40;
117
+ const PROJECTION_BATCH_SIZE = 100;
118
+
119
+ async function storeIncomingBatch(
120
+ events: readonly StoredEvent[],
121
+ tx: Transaction<Database>,
122
+ context: ActiveContext,
123
+ ): Promise<void> {
124
+ await tx.insertInto('raw_events').values(events.map(event => ({
125
+ id: event.id,
126
+ tenant_id: context.tenantId,
127
+ log_id: context.logId,
128
+ local_batch_id: `server:${context.tenantId}:${context.logId}:${event.id}`,
129
+ local_sequence: event.version,
130
+ action_id: event.action?.id ?? null,
131
+ action_index: event.action?.index ?? null,
132
+ action_size: event.action?.size ?? null,
133
+ actor: event.actor ?? null,
134
+ type: event.type,
135
+ timestamp: event.timestamp,
136
+ payload: JSON.stringify(event.payload),
137
+ entity_id: event.entityId,
138
+ entity_version: event.entityVersion,
139
+ log_version: event.version,
140
+ sync_state: 'synced',
141
+ projection_state: 'effective',
142
+ rejection_code: null,
143
+ rejection_detail: null,
144
+ is_tombstoned: 0,
145
+ }))).onConflict(oc => oc.columns(['tenant_id', 'log_id', 'id']).doUpdateSet(eb => ({
146
+ log_version: eb.ref('excluded.log_version'),
147
+ action_id: sql`COALESCE(excluded.action_id, raw_events.action_id)`,
148
+ action_index: sql`CASE WHEN excluded.action_id IS NOT NULL THEN excluded.action_index ELSE raw_events.action_index END`,
149
+ action_size: sql`CASE WHEN excluded.action_id IS NOT NULL THEN excluded.action_size ELSE raw_events.action_size END`,
150
+ actor: sql`CASE WHEN excluded.actor IS NOT NULL AND excluded.actor <> '' THEN excluded.actor ELSE raw_events.actor END`,
151
+ entity_version: eb.ref('excluded.entity_version'),
152
+ entity_id: eb.ref('excluded.entity_id'),
153
+ sync_state: 'synced',
154
+ // Never rewrite payloads/local batch identities or revive hidden history.
155
+ projection_state: sql`CASE WHEN raw_events.projection_state = 'restored_out' THEN 'restored_out' ELSE 'effective' END`,
156
+ rejection_code: null,
157
+ rejection_detail: null,
158
+ }))).execute();
159
+
160
+ // Derive status from all events in each original local batch, not just this
161
+ // download page. Keep its creation/replacement metadata on acknowledgement.
162
+ await sql`
163
+ INSERT INTO local_batches (id, tenant_id, log_id, status, replacement_for, updated_at)
164
+ SELECT DISTINCT accepted.local_batch_id, ${context.tenantId}, ${context.logId},
165
+ CASE WHEN EXISTS (
166
+ SELECT 1 FROM raw_events pending
167
+ WHERE pending.tenant_id = accepted.tenant_id
168
+ AND pending.log_id = accepted.log_id
169
+ AND pending.local_batch_id = accepted.local_batch_id
170
+ AND pending.log_version IS NULL
171
+ ) THEN 'pending' ELSE 'synced' END,
172
+ NULL, ${new Date().toISOString()}
173
+ FROM raw_events accepted
174
+ WHERE accepted.tenant_id = ${context.tenantId}
175
+ AND accepted.log_id = ${context.logId}
176
+ AND accepted.id IN (${sql.join(events.map(event => event.id))})
177
+ ON CONFLICT (id) DO UPDATE SET
178
+ status = excluded.status, updated_at = excluded.updated_at
179
+ WHERE local_batches.tenant_id = excluded.tenant_id
180
+ AND local_batches.log_id = excluded.log_id
181
+ `.execute(tx);
182
+ }
183
+
184
+ async function projectAffectedEntities(
185
+ entityIds: ReadonlySet<string>,
186
+ tx: Transaction<Database>,
187
+ context: ActiveContext,
188
+ ): Promise<void> {
189
+ const byType = new Map<string, string[]>();
190
+ for (const entityId of entityIds) {
191
+ const [entityType, uuid] = entityId.split(':');
192
+ if (!entityType || !uuid) {
193
+ console.warn('[Event] Invalid entity ID format for projection:', entityId);
194
+ continue;
195
+ }
196
+ if (!entityRegistry[entityType]) continue;
197
+ const ids = byType.get(entityType) ?? [];
198
+ ids.push(entityId);
199
+ byType.set(entityType, ids);
200
+ }
201
+ for (const [type, ids] of byType) {
202
+ for (let offset = 0; offset < ids.length; offset += PROJECTION_BATCH_SIZE) {
203
+ const batch = ids.slice(offset, offset + PROJECTION_BATCH_SIZE);
204
+ await withTiming('projection', `projectEntities:${type}`, () =>
205
+ projectEntitySet(entityRegistry[type].projection, batch, tx, context),
206
+ );
207
+ }
208
+ }
209
+ }
210
+
211
+ export async function applyMetaEvents(events: Array<{ type: string; payload: unknown }>): Promise<void> {
212
+ let changedSchema = false;
213
+ for (const event of events) {
214
+ if (isEntityTypeDefinedEvent(event)) {
215
+ await handleEntityTypeDefined(event.payload as Parameters<typeof handleEntityTypeDefined>[0]);
216
+ changedSchema = true;
217
+ } else if (isEntityTypeUpdatedEvent(event)) {
218
+ await handleEntityTypeUpdated(event.payload as Parameters<typeof handleEntityTypeUpdated>[0]);
219
+ changedSchema = true;
220
+ } else if (isEntityTypeArchivedEvent(event)) {
221
+ await handleEntityTypeArchived(event.payload as Parameters<typeof handleEntityTypeArchived>[0]);
222
+ changedSchema = true;
223
+ }
224
+ }
225
+ if (changedSchema) {
226
+ // Each tab keeps its own registry of runtime-defined entities.
227
+ const context = await requireActiveContext();
228
+ postTabChange({ kind: 'schema', tenantId: context.tenantId, logId: context.logId });
229
+ }
230
+ }
231
+
232
+
233
+ // ============================================================================
234
+ // Tombstone Handling
235
+ // ============================================================================
236
+
237
+ /**
238
+ * Handles a tombstone event by re-projecting only the affected entity.
239
+ * This avoids the costly full read model rebuild.
240
+ */
241
+ /**
242
+ * Applies a tombstone by marking the target event as tombstoned.
243
+ * Returns the entity ID of the target event so it can be re-projected.
244
+ */
245
+ async function applyTombstoneAndGetEntityId(
246
+ event: TombstoneEvent,
247
+ tx: Transaction<Database>,
248
+ context: ActiveContext,
249
+ ): Promise<string | null> {
250
+ const targetEventId = event.payload.eventId;
251
+ console.log('[Event] Applying tombstone for target event:', targetEventId);
252
+
253
+ // 1. Look up the target event to get entity info
254
+ const targetEvent = await tx
255
+ .selectFrom('raw_events')
256
+ .select(['entity_id', 'type'])
257
+ .where('id', '=', targetEventId)
258
+ .where('tenant_id', '=', context.tenantId)
259
+ .where('log_id', '=', context.logId)
260
+ .executeTakeFirst();
261
+
262
+ if (!targetEvent) {
263
+ console.warn('[Event] Tombstone target event not found:', targetEventId);
264
+ return null;
265
+ }
266
+
267
+ // 2. Mark as tombstoned
268
+ await tx
269
+ .updateTable('raw_events')
270
+ .set({ is_tombstoned: 1 })
271
+ .where('id', '=', targetEventId)
272
+ .where('tenant_id', '=', context.tenantId)
273
+ .where('log_id', '=', context.logId)
274
+ .execute();
275
+
276
+ return targetEvent.entity_id || null;
277
+ }
278
+
279
+ // ============================================================================
280
+ // Public API
281
+ // ============================================================================
282
+
283
+ export async function clearActiveProjections(): Promise<void> {
284
+ console.log('[Event] Clearing active projections');
285
+ try {
286
+ const context = await requireActiveContext();
287
+ const tablesToClear = [...new Set(getAllEntityTables())];
288
+ await readModelDB.transaction().execute(async (tx) => {
289
+ // Clear only this tab's log scope — other logs' projections stay intact
290
+ for (const table of tablesToClear) {
291
+ await tx.deleteFrom(table as keyof Database)
292
+ .where('tenant_id', '=', context.tenantId)
293
+ .where('log_id', '=', context.logId)
294
+ .execute();
295
+ }
296
+ // Clear entity version cache table as well
297
+ await tx.deleteFrom('entity_versions')
298
+ .where('tenant_id', '=', context.tenantId)
299
+ .where('log_id', '=', context.logId)
300
+ .execute();
301
+ // Dynamic type definitions follow the same log scope as their source events
302
+ await tx.deleteFrom('entity_type_definitions')
303
+ .where('tenant_id', '=', context.tenantId)
304
+ .where('log_id', '=', context.logId)
305
+ .execute();
306
+ });
307
+ clearDynamicRegistry();
308
+ clearEventCache();
309
+ console.log('[Event] Active projections cleared');
310
+ } catch (error) {
311
+ console.error('[Event] Failed to clear read model:', error);
312
+ throw error;
313
+ }
314
+ }
315
+
316
+ async function deleteActiveLogData(): Promise<void> {
317
+ const context = await requireActiveContext();
318
+ await readModelDB.transaction().execute(async (tx) => {
319
+ const operations = await tx.selectFrom('outbox_operations')
320
+ .select('id')
321
+ .where('tenant_id', '=', context.tenantId)
322
+ .where('log_id', '=', context.logId)
323
+ .execute();
324
+ if (operations.length > 0) {
325
+ await tx.deleteFrom('outbox_operation_items')
326
+ .where('operation_id', 'in', operations.map((operation) => operation.id))
327
+ .execute();
328
+ }
329
+ await tx.deleteFrom('outbox_operations')
330
+ .where('tenant_id', '=', context.tenantId)
331
+ .where('log_id', '=', context.logId)
332
+ .execute();
333
+ await tx.deleteFrom('local_batches')
334
+ .where('tenant_id', '=', context.tenantId)
335
+ .where('log_id', '=', context.logId)
336
+ .execute();
337
+ await tx.deleteFrom('raw_events')
338
+ .where('tenant_id', '=', context.tenantId)
339
+ .where('log_id', '=', context.logId)
340
+ .execute();
341
+ await tx.deleteFrom('log_sync_state')
342
+ .where('tenant_id', '=', context.tenantId)
343
+ .where('log_id', '=', context.logId)
344
+ .execute();
345
+ });
346
+ await clearActiveProjections();
347
+ }
348
+
349
+ export async function replaceAllStoredEvents(storedEvents: StoredEvent[]): Promise<void> {
350
+ console.log('[Event] Replacing all stored events with', storedEvents.length, 'events');
351
+
352
+ try {
353
+ await deleteActiveLogData();
354
+ await handleIncomingStoredEvents(storedEvents);
355
+ console.log('[Event] Replace all stored events completed successfully');
356
+ } catch (error) {
357
+ console.error('[Event] Replace all stored events failed:', error);
358
+ throw error;
359
+ }
360
+ }
361
+
362
+ /** An incoming tombstone that undoes an accepted restore or removal. */
363
+ async function undoesReplayDirective(
364
+ context: ActiveContext,
365
+ storedEvents: readonly StoredEvent[],
366
+ ): Promise<boolean> {
367
+ const targetIds = storedEvents
368
+ .filter((event) => event.type === 'EVENT_TOMBSTONED')
369
+ .map((event) => (event.payload as { eventId?: unknown }).eventId)
370
+ .filter((eventId): eventId is string => typeof eventId === 'string' && eventId.length > 0);
371
+ if (targetIds.length === 0) return false;
372
+ const directive = await readModelDB.selectFrom('raw_events').select('id')
373
+ .where('tenant_id', '=', context.tenantId)
374
+ .where('log_id', '=', context.logId)
375
+ .where('id', 'in', targetIds)
376
+ .where('type', 'in', [WORKSPACE_RESTORED, EVENTS_EXCISED])
377
+ .executeTakeFirst();
378
+ return Boolean(directive);
379
+ }
380
+
381
+ /**
382
+ * Reconciliation holds back events past a download gap, so in a log with
383
+ * directives any accepted row beyond the contiguous head means this batch may
384
+ * release a deferred restore or removal.
385
+ */
386
+ async function fillsDeferredReplay(context: ActiveContext): Promise<boolean> {
387
+ if (!await hasReplayDirectives(readModelDB, context)) return false;
388
+ const progress = await readModelDB.selectFrom('log_sync_state')
389
+ .select('downloaded_head_version')
390
+ .where('tenant_id', '=', context.tenantId)
391
+ .where('log_id', '=', context.logId)
392
+ .executeTakeFirst();
393
+ const beyondHead = await readModelDB.selectFrom('raw_events').select('id')
394
+ .where('tenant_id', '=', context.tenantId)
395
+ .where('log_id', '=', context.logId)
396
+ .where('log_version', '>', progress?.downloaded_head_version ?? 0)
397
+ .executeTakeFirst();
398
+ return Boolean(beyondHead);
399
+ }
400
+
401
+ /**
402
+ * Handles incoming StoredEvents from the Ledger Service (via HTTP or WebSocket).
403
+ * Preserves server-assigned entity versions.
404
+ * @returns Object indicating whether new events were processed (vs just metadata updates)
405
+ */
406
+ export async function handleIncomingStoredEvents(
407
+ storedEvents: StoredEvent[]
408
+ ): Promise<{ hasNewEvents: boolean; newCount: number }> {
409
+ console.log(
410
+ '[Event] handleIncomingStoredEvents called with',
411
+ storedEvents.length,
412
+ 'events'
413
+ );
414
+
415
+ if (storedEvents.length === 0) return { hasNewEvents: false, newCount: 0 };
416
+
417
+ return withTiming('projection', 'handleIncomingStoredEvents', async () => {
418
+ storedEvents.sort(compareStoredEvents);
419
+ const context = await requireActiveContext();
420
+
421
+ const existingRows = await readModelDB.selectFrom('raw_events')
422
+ .select('id')
423
+ .where('tenant_id', '=', context.tenantId)
424
+ .where('log_id', '=', context.logId)
425
+ .where('id', 'in', storedEvents.map((event) => event.id))
426
+ .execute();
427
+ const existingIds = new Set(existingRows.map((row) => row.id));
428
+ const uncachedEvents = storedEvents.filter((event) => !existingIds.has(event.id));
429
+ const cachedEvents = storedEvents.filter((event) => existingIds.has(event.id));
430
+
431
+ console.log(`[Event] Processing: ${uncachedEvents.length} uncached, ${cachedEvents.length} cached (skipping INSERT)`);
432
+
433
+ const affectedEntityIds = new Set<string>();
434
+ // Ordinary events after a restore or removal keep membership as is:
435
+ // only directives, their undo, or filling a gap that holds a directive
436
+ // back change which events replay.
437
+ const hasDdl = storedEvents.some(event => event.type.startsWith('ENTITY_TYPE_') || event.type === WORKSPACE_RESTORED || event.type === EVENTS_EXCISED)
438
+ || (await schemaTombstoneIds(readModelDB, context, storedEvents)).size > 0
439
+ || await undoesReplayDirective(context, storedEvents)
440
+ || await fillsDeferredReplay(context);
441
+
442
+ if (storedEvents.length > 0) {
443
+ await withTiming('db', 'eventTransaction', async () => {
444
+ await readModelDB.transaction().execute(async (tx) => {
445
+ for (let offset = 0; offset < storedEvents.length;) {
446
+ const candidate = storedEvents.slice(offset, offset + EVENT_WRITE_BATCH_SIZE);
447
+ // Flush through each tombstone before inserting later events:
448
+ // its target lookup must observe the same ordered prefix.
449
+ const tombstoneIndex = candidate.findIndex(event => event.type === 'EVENT_TOMBSTONED');
450
+ const batch = tombstoneIndex < 0 ? candidate : candidate.slice(0, tombstoneIndex + 1);
451
+ await storeIncomingBatch(batch, tx, context);
452
+ offset += batch.length;
453
+ for (const event of batch) {
454
+ // Cache the event ID
455
+ cacheEventId(event.id);
456
+
457
+ if (event.entityId) affectedEntityIds.add(event.entityId);
458
+
459
+ // Handle tombstone events
460
+ if (event.type === 'EVENT_TOMBSTONED') {
461
+ const tombstonePayload = event.payload as { eventId: string };
462
+ const tombstoneEvent = {
463
+ ...event,
464
+ payload: tombstonePayload
465
+ } as unknown as TombstoneEvent;
466
+ const targetEntityId = await applyTombstoneAndGetEntityId(tombstoneEvent, tx, context);
467
+ if (targetEntityId) affectedEntityIds.add(targetEntityId);
468
+ }
469
+ }
470
+ }
471
+ if (!hasDdl) {
472
+ await projectAffectedEntities(affectedEntityIds, tx, context);
473
+ }
474
+ });
475
+ });
476
+ }
477
+
478
+ if (hasDdl) await rebuildReadModel();
479
+
480
+ // Update version tracker for ALL events (cached and uncached need version updates)
481
+ await withTiming('db', 'updateVersionTracker', async () => {
482
+ await versionTracker.updateVersionsFromEvents(storedEvents);
483
+ });
484
+
485
+ await recordIngestedProgress(readModelDB, context, storedEvents.map(event => event.version));
486
+
487
+ console.log(`[Event] handleIncomingStoredEvents completed: ${uncachedEvents.length} inserted, ${cachedEvents.length} skipped`);
488
+ return { hasNewEvents: uncachedEvents.length > 0, newCount: uncachedEvents.length };
489
+ });
490
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Event Module
3
+ *
4
+ * Main entry point for the event sourcing system.
5
+ * Re-exports all public APIs from focused modules.
6
+ */
7
+
8
+ // Types
9
+ export * from './types.js';
10
+
11
+ // Type Guards
12
+ export {
13
+ isTombstoneEvent,
14
+ } from './handlers.js';
15
+
16
+ // Event Handlers
17
+ export {
18
+ handleIncomingStoredEvents,
19
+ replaceAllStoredEvents,
20
+ clearActiveProjections,
21
+ } from './handlers.js';
22
+
23
+ // Queries
24
+ export {
25
+ getAllEvents,
26
+ getEvents,
27
+ exportEventsAsJson,
28
+ } from './queries.js';
29
+
30
+ // Rebuild
31
+ export { rebuildIfSchemaChanged, rebuildReadModel } from './rebuild.js';
32
+
33
+ // Entity Definitions (single source of truth for adding new entities)
34
+ export { ENTITY_DEFINITIONS, getEntityDefinition, getAllEntityNames } from '@hearthlog/contracts/event/entity-definitions';
35
+ export type {
36
+ EntityDefinition,
37
+ EntityFieldDefinition,
38
+ EntityIndexDefinition,
39
+ FieldType,
40
+ } from '@hearthlog/contracts/event/entity-definitions';
41
+
42
+ // Entity Registry (auto-generated from definitions)
43
+ export { entityRegistry, getAllEntityConfigs, getAllEntityTables } from './registry.js';
44
+
45
+ // Projection Builder (for advanced use cases)
46
+ export type { EntityProjectionConfig, FieldConfig } from './projection-builder.js';
47
+ export {
48
+ buildSingleEntityProjectionSQL,
49
+ buildBulkProjectionSQL,
50
+ createEntityProjector,
51
+ PROJECTION_CONFIGS,
52
+ createProjectionConfigFromDefinition,
53
+ } from './projection-builder.js';