@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.
- package/LICENSE +235 -0
- package/package.json +51 -3
- package/src/access/session.ts +223 -0
- package/src/access/user-session.ts +61 -0
- package/src/ai/decisions.ts +89 -0
- package/src/calendar/draft.ts +84 -0
- package/src/calendar/feeds.ts +91 -0
- package/src/calendar/groups.ts +198 -0
- package/src/calendar/logic.ts +141 -0
- package/src/calendar/preferences.ts +40 -0
- package/src/calendar/weeks.ts +56 -0
- package/src/commands/index.ts +184 -0
- package/src/commands/local-commit.ts +214 -0
- package/src/commands/standard.ts +8 -0
- package/src/commands/validation.ts +206 -0
- package/src/db/bootstrap.ts +72 -0
- package/src/db/calendar.ts +638 -0
- package/src/db/dynamic-ddl.ts +127 -0
- package/src/db/entities.ts +208 -0
- package/src/db/history.ts +194 -0
- package/src/db/index.ts +1240 -0
- package/src/db/kv.ts +33 -0
- package/src/db/local-statistics.ts +106 -0
- package/src/db/module-projections.ts +46 -0
- package/src/db/raw-browser.ts +361 -0
- package/src/db/read-repositories.ts +29 -0
- package/src/db/read-session.ts +47 -0
- package/src/db/schema-version.ts +76 -0
- package/src/db/session-context.ts +33 -0
- package/src/event/code-export.ts +65 -0
- package/src/event/dynamic-entities.ts +503 -0
- package/src/event/dynamic-materializer.ts +288 -0
- package/src/event/handlers.ts +490 -0
- package/src/event/index.ts +53 -0
- package/src/event/meta-entity-types.ts +165 -0
- package/src/event/projection-builder.ts +384 -0
- package/src/event/queries.ts +126 -0
- package/src/event/rebuild.ts +176 -0
- package/src/event/reference-lookup.ts +93 -0
- package/src/event/registry.ts +158 -0
- package/src/event/schema-effects.ts +45 -0
- package/src/event/types.ts +20 -0
- package/src/history/action-boundary.ts +18 -0
- package/src/history/action-detail.ts +23 -0
- package/src/history/change-comparison.ts +29 -0
- package/src/history/change-display.ts +41 -0
- package/src/history/contracts.ts +63 -0
- package/src/history/history.ts +372 -0
- package/src/history/legacy-replay.ts +69 -0
- package/src/history/legacy-url.ts +9 -0
- package/src/history/lineage.ts +123 -0
- package/src/history/log-format.ts +17 -0
- package/src/history/log-map.ts +110 -0
- package/src/history/navigation.ts +14 -0
- package/src/history/point-snapshot.ts +229 -0
- package/src/history/timeline.ts +215 -0
- package/src/history/url.ts +18 -0
- package/src/ledger/README.md +118 -0
- package/src/ledger/admin-client.ts +616 -0
- package/src/ledger/client.ts +10 -0
- package/src/ledger/config.ts +117 -0
- package/src/ledger/entity-id.ts +115 -0
- package/src/ledger/errors.ts +144 -0
- package/src/ledger/http-client.ts +590 -0
- package/src/ledger/index.ts +67 -0
- package/src/ledger/socket.ts +400 -0
- package/src/ledger/types.ts +202 -0
- package/src/ledger/version-tracker-singleton.ts +12 -0
- package/src/ledger/version-tracker.ts +259 -0
- package/src/log-management.ts +123 -0
- package/src/log-validation.ts +19 -0
- package/src/native/reminders.ts +2 -0
- package/src/native/shell.svelte.ts +136 -0
- package/src/native/shell.ts +102 -0
- package/src/pwa/registration.ts +169 -0
- package/src/recovery/local-recovery.ts +59 -0
- package/src/recovery/reset-sequence.ts +13 -0
- package/src/runtime/clock.ts +43 -0
- package/src/runtime/kept-page-data.svelte.ts +42 -0
- package/src/runtime/page-data.svelte.ts +160 -0
- package/src/runtime/phone-header.svelte.ts +17 -0
- package/src/runtime/read-coordinator.ts +103 -0
- package/src/runtime/refresh.ts +15 -0
- package/src/runtime/start-page.ts +56 -0
- package/src/runtime/view.ts +83 -0
- package/src/runtime/workspace.ts +62 -0
- package/src/storage/indexeddb-statistics.ts +153 -0
- package/src/store/attention.svelte.ts +51 -0
- package/src/store/conflict-merge.ts +169 -0
- package/src/store/entity-sync-state.svelte.ts +36 -0
- package/src/store/event-log.svelte.ts +109 -0
- package/src/store/events.svelte.ts +14 -0
- package/src/store/local-context.ts +93 -0
- package/src/store/local-store.svelte.ts +58 -0
- package/src/store/log-version.svelte.ts +35 -0
- package/src/store/logs.svelte.ts +60 -0
- package/src/store/moved.svelte.ts +43 -0
- package/src/store/offline-readiness.svelte.ts +188 -0
- package/src/store/outbox-request.ts +61 -0
- package/src/store/outbox.ts +426 -0
- package/src/store/pending-work-transfer.ts +49 -0
- package/src/store/pending-work.ts +143 -0
- package/src/store/sync-coordinator.ts +170 -0
- package/src/store/sync-invalidation.ts +10 -0
- package/src/store/sync-issues.ts +406 -0
- package/src/store/sync-progress.ts +54 -0
- package/src/store/sync-state.ts +127 -0
- package/src/store/sync.svelte.ts +1010 -0
- package/src/store/tab-coordination.ts +164 -0
- package/src/testing/e2e-seed.ts +40 -0
- package/src/testing/local-database.ts +120 -0
- package/src/testing/projection-database.ts +105 -0
- package/src/testing/test-entities.ts +28 -0
- package/src/theme/appearance.svelte.ts +111 -0
- package/src/utils/address.ts +5 -0
- package/src/utils/async.ts +30 -0
- package/src/utils/long-press.ts +70 -0
- package/src/utils/perf-dataset.ts +405 -0
- package/src/utils/perf.ts +627 -0
- 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';
|