@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,165 @@
1
+ /**
2
+ * Meta-Entity Event Types
3
+ *
4
+ * Event types for managing entity type definitions at runtime.
5
+ * These events are stored in the Ledger and enable dynamic entity creation.
6
+ */
7
+
8
+ import type { EntityCalendarSource } from '@hearthlog/contracts/calendar/config';
9
+
10
+ // ============================================================================
11
+ // Field Definition Types
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Field type for dynamic entity fields
16
+ */
17
+ export type DynamicFieldType = 'string' | 'number' | 'boolean' | 'reference' | 'datetime';
18
+
19
+ /**
20
+ * Definition for a field in a dynamic entity
21
+ */
22
+ export interface DynamicFieldDefinition {
23
+ /** Database column name (snake_case) */
24
+ name: string;
25
+ /** Field data type */
26
+ type: DynamicFieldType;
27
+ /** Event payload key (camelCase). Defaults to camelCase version of name */
28
+ payloadKey: string;
29
+ /** Whether the field is required in ADD events */
30
+ required?: boolean;
31
+ /** Default value for the field */
32
+ default?: string | number | boolean | null;
33
+
34
+ // Validation constraints
35
+ /** Regex pattern for string validation */
36
+ pattern?: string;
37
+ /** Minimum value for numbers */
38
+ min?: number;
39
+ /** Maximum value for numbers */
40
+ max?: number;
41
+ /** Minimum length for strings */
42
+ minLength?: number;
43
+ /** Maximum length for strings */
44
+ maxLength?: number;
45
+ /** Allowed values (picklist/enum) */
46
+ enum?: string[];
47
+
48
+ // Reference configuration
49
+ /** Target entity name for reference fields */
50
+ referenceTo?: string;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Event Payload Types
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Payload for ENTITY_TYPE_DEFINED event
59
+ */
60
+ export interface DefineEntityTypePayload {
61
+ /** Entity type ID (UUID) */
62
+ id: string;
63
+ /** Entity name (lowercase, e.g., 'project') */
64
+ name: string;
65
+ /** Human-readable display name (e.g., 'Project') */
66
+ displayName: string;
67
+ /** Database table name (e.g., 'projects') */
68
+ tableName: string;
69
+ /** Field definitions */
70
+ fields: DynamicFieldDefinition[];
71
+ /** Optional calendar dates exposed by this entity. */
72
+ calendar?: EntityCalendarSource[];
73
+ }
74
+
75
+ /**
76
+ * Payload for ENTITY_TYPE_UPDATED event
77
+ */
78
+ export interface UpdateEntityTypePayload {
79
+ /** Entity type ID */
80
+ id: string;
81
+ /** Updated display name */
82
+ displayName?: string;
83
+ /** Fields to add (additive only) */
84
+ addFields?: DynamicFieldDefinition[];
85
+ /** Replace the calendar dates; null clears them. */
86
+ calendar?: EntityCalendarSource[] | null;
87
+ /** Field updates (non-breaking changes only) */
88
+ updateFields?: Array<{
89
+ name: string;
90
+ displayName?: string;
91
+ default?: string | number | boolean | null;
92
+ // Validation can be loosened but not tightened
93
+ pattern?: string;
94
+ min?: number;
95
+ max?: number;
96
+ minLength?: number;
97
+ maxLength?: number;
98
+ enum?: string[];
99
+ }>;
100
+ }
101
+
102
+ /**
103
+ * Payload for ENTITY_TYPE_ARCHIVED event
104
+ */
105
+ export interface ArchiveEntityTypePayload {
106
+ /** Entity type ID */
107
+ id: string;
108
+ }
109
+
110
+ // ============================================================================
111
+ // Event Types
112
+ // ============================================================================
113
+
114
+ export interface EntityTypeDefinedEvent {
115
+ id: string;
116
+ type: 'ENTITY_TYPE_DEFINED';
117
+ timestamp: number;
118
+ payload: DefineEntityTypePayload;
119
+ }
120
+
121
+ export interface EntityTypeUpdatedEvent {
122
+ id: string;
123
+ type: 'ENTITY_TYPE_UPDATED';
124
+ timestamp: number;
125
+ payload: UpdateEntityTypePayload;
126
+ }
127
+
128
+ export interface EntityTypeArchivedEvent {
129
+ id: string;
130
+ type: 'ENTITY_TYPE_ARCHIVED';
131
+ timestamp: number;
132
+ payload: ArchiveEntityTypePayload;
133
+ }
134
+
135
+ /**
136
+ * Union type for all meta-entity events
137
+ */
138
+ export type MetaEntityEvent =
139
+ | EntityTypeDefinedEvent
140
+ | EntityTypeUpdatedEvent
141
+ | EntityTypeArchivedEvent;
142
+
143
+ // ============================================================================
144
+ // Type Guards
145
+ // ============================================================================
146
+
147
+ export function isMetaEntityEvent(event: { type: string }): event is MetaEntityEvent {
148
+ return (
149
+ event.type === 'ENTITY_TYPE_DEFINED' ||
150
+ event.type === 'ENTITY_TYPE_UPDATED' ||
151
+ event.type === 'ENTITY_TYPE_ARCHIVED'
152
+ );
153
+ }
154
+
155
+ export function isEntityTypeDefinedEvent(event: { type: string }): event is EntityTypeDefinedEvent {
156
+ return event.type === 'ENTITY_TYPE_DEFINED';
157
+ }
158
+
159
+ export function isEntityTypeUpdatedEvent(event: { type: string }): event is EntityTypeUpdatedEvent {
160
+ return event.type === 'ENTITY_TYPE_UPDATED';
161
+ }
162
+
163
+ export function isEntityTypeArchivedEvent(event: { type: string }): event is EntityTypeArchivedEvent {
164
+ return event.type === 'ENTITY_TYPE_ARCHIVED';
165
+ }
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Projection Builder - SQL factory utilities for event sourcing projections
3
+ *
4
+ * Generates consistent CTE-based SQL for materializing entity state from raw_events.
5
+ * Eliminates duplication across entity projectors and rebuild functions.
6
+ *
7
+ * Projection is always scoped by explicit tenant/log — never via ambient active_context.
8
+ */
9
+
10
+ import { sql, type RawBuilder, type Transaction } from 'kysely';
11
+ import type { ActiveContext, Database } from '../db/index.js';
12
+ import { requireActiveContext } from '../db/index.js';
13
+
14
+ /**
15
+ * Configuration for an entity field projection
16
+ */
17
+ export interface FieldConfig {
18
+ /** Column name in the target table */
19
+ name: string;
20
+ /** Key in the event payload JSON */
21
+ payloadKey: string;
22
+ /** SQL type cast (e.g., 'REAL', 'INTEGER') */
23
+ cast?: string;
24
+ /** Default value if null */
25
+ default?: string | number | null;
26
+ }
27
+
28
+ /**
29
+ * Configuration for projecting an entity type from events
30
+ */
31
+ export interface EntityProjectionConfig {
32
+ /** Entity name (e.g., 'calendar_event') */
33
+ entityName: string;
34
+ /** Target table name (e.g., 'calendar_events') */
35
+ tableName: string;
36
+ /** Entity ID field alias in CTEs (e.g., 'asset_id', 'tx_id') */
37
+ entityIdAlias: string;
38
+ /** Event types to include in projection */
39
+ eventTypes: string[];
40
+ /** The 'removed' event type that marks entity deletion */
41
+ removedEventType: string;
42
+ /** Fields to project from event payload */
43
+ fields: FieldConfig[];
44
+ }
45
+
46
+ export type ProjectionBoundary =
47
+ | { kind: 'current' }
48
+ | { kind: 'log-version'; version: number };
49
+
50
+ export interface ProjectionTarget {
51
+ schema?: string;
52
+ }
53
+
54
+ export interface ProjectionSource {
55
+ schema?: string;
56
+ table: string;
57
+ /** A staged snapshot source has already applied scope, boundary, and tombstones. */
58
+ staged?: boolean;
59
+ }
60
+
61
+ export interface ProjectionBuildOptions {
62
+ boundary?: ProjectionBoundary;
63
+ target?: ProjectionTarget;
64
+ source?: ProjectionSource;
65
+ }
66
+
67
+ /** Quote a SQLite identifier. Values are never accepted as identifiers. */
68
+ export function quoteIdentifier(identifier: string): string {
69
+ return `"${identifier.replace(/"/g, '""')}"`;
70
+ }
71
+
72
+ export function qualifiedIdentifier(schema: string | undefined, name: string): string {
73
+ return schema
74
+ ? `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`
75
+ : quoteIdentifier(name);
76
+ }
77
+
78
+ /**
79
+ * Builds the field extraction expressions for the entity_events CTE. Each
80
+ * field also records whether the payload names it: an explicit null clears a
81
+ * value, while an omitted key leaves it unchanged.
82
+ */
83
+ function buildFieldExtractions(fields: FieldConfig[]): string {
84
+ return fields
85
+ .map(f => `json_extract(payload, '$.${f.payloadKey}') AS ${f.name},
86
+ json_type(payload, '$.${f.payloadKey}') IS NOT NULL AS ${presenceColumn(f)}`)
87
+ .join(',\n ');
88
+ }
89
+
90
+ function presenceColumn(field: FieldConfig): string {
91
+ return `__present_${field.name}`;
92
+ }
93
+
94
+ /**
95
+ * Builds the FIRST_VALUE window function expressions for the 'w' CTE
96
+ */
97
+ function buildWindowFunctions(fields: FieldConfig[], entityIdAlias: string): string {
98
+ return fields
99
+ .map(f => `
100
+ FIRST_VALUE(${f.name}) OVER (
101
+ PARTITION BY ${entityIdAlias}
102
+ ORDER BY ${presenceColumn(f)} DESC, (entity_version IS NULL) DESC, entity_version DESC, local_sequence DESC, timestamp DESC, id DESC
103
+ ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
104
+ ) AS final_${f.name}`)
105
+ .join(',');
106
+ }
107
+
108
+ /**
109
+ * Builds the final SELECT field expressions with type casting and defaults
110
+ */
111
+ function buildFinalFields(fields: FieldConfig[]): string {
112
+ return fields
113
+ .map(f => {
114
+ let expr = `final_${f.name}`;
115
+ if (f.cast) {
116
+ expr = `CAST(${expr} AS ${f.cast})`;
117
+ }
118
+ if (f.default !== undefined && f.default !== null) {
119
+ // SQL expressions (containing parentheses) should not be quoted
120
+ // Plain string literals should be quoted
121
+ const isSqlExpression = typeof f.default === 'string' && f.default.includes('(');
122
+ const deterministicTimeDefault = f.default === "date('now')"
123
+ ? "date(created_ts/1000, 'unixepoch')"
124
+ : f.default === "datetime('now')"
125
+ ? "datetime(created_ts/1000, 'unixepoch')"
126
+ : null;
127
+ const defaultVal = deterministicTimeDefault ?? (
128
+ typeof f.default === 'string' && !isSqlExpression
129
+ ? `'${f.default.replace(/'/g, "''")}'`
130
+ : f.default
131
+ );
132
+ expr = `COALESCE(${expr}, ${defaultVal})`;
133
+ }
134
+ return `${expr} AS ${f.name}`;
135
+ })
136
+ .join(',\n ');
137
+ }
138
+
139
+ /**
140
+ * Builds the INSERT column list and final SELECT column list (scoped projection rows)
141
+ */
142
+ function buildColumnLists(fields: FieldConfig[]): { insertCols: string; selectCols: string } {
143
+ const fieldNames = fields.map(f => f.name);
144
+ return {
145
+ insertCols: ['tenant_id', 'log_id', 'id', ...fieldNames, 'created_at', 'updated_at'].join(', '),
146
+ selectCols: [
147
+ 'tenant_id',
148
+ 'log_id',
149
+ 'id',
150
+ ...fieldNames,
151
+ "datetime(created_ts/1000, 'unixepoch') AS created_at",
152
+ "datetime(updated_ts/1000, 'unixepoch') AS updated_at",
153
+ ].join(', '),
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Generates the common CTE prefix filtered by explicit tenant/log.
159
+ */
160
+ function buildCommonCTEs(
161
+ tenantId: string,
162
+ logId: string,
163
+ options: ProjectionBuildOptions,
164
+ ): RawBuilder<unknown> {
165
+ const source = options.source ?? { schema: 'main', table: 'raw_events' };
166
+ const sourceTable = sql.raw(qualifiedIdentifier(source.schema, source.table));
167
+
168
+ if (source.staged) {
169
+ return sql`WITH effective AS (SELECT events.* FROM ${sourceTable} events)`;
170
+ }
171
+
172
+ const boundary = options.boundary ?? { kind: 'current' as const };
173
+ if (boundary.kind === 'current') {
174
+ return sql`
175
+ WITH effective AS (
176
+ SELECT events.*
177
+ FROM ${sourceTable} events
178
+ WHERE events.is_tombstoned = 0
179
+ AND events.projection_state = 'effective'
180
+ AND events.tenant_id = ${tenantId}
181
+ AND events.log_id = ${logId}
182
+ )`;
183
+ }
184
+
185
+ return sql`
186
+ WITH bounded AS (
187
+ SELECT events.*
188
+ FROM ${sourceTable} events
189
+ WHERE events.projection_state = 'effective'
190
+ AND events.tenant_id = ${tenantId}
191
+ AND events.log_id = ${logId}
192
+ AND events.log_version IS NOT NULL
193
+ AND events.log_version <= ${boundary.version}
194
+ ),
195
+ temporal_tombstones AS (
196
+ SELECT json_extract(payload, '$.eventId') AS target_event_id
197
+ FROM bounded
198
+ WHERE type = 'EVENT_TOMBSTONED'
199
+ ),
200
+ effective AS (
201
+ SELECT events.*
202
+ FROM bounded events
203
+ WHERE NOT EXISTS (
204
+ SELECT 1
205
+ FROM temporal_tombstones tombstones
206
+ WHERE tombstones.target_event_id = events.id
207
+ )
208
+ )`;
209
+ }
210
+
211
+ function buildProjectionSQL(
212
+ config: EntityProjectionConfig,
213
+ context: Pick<ActiveContext, 'tenantId' | 'logId'>,
214
+ options: ProjectionBuildOptions,
215
+ entityId?: string | readonly string[],
216
+ ): RawBuilder<unknown> {
217
+ const { tableName, entityIdAlias, eventTypes, removedEventType, fields } = config;
218
+ const { insertCols, selectCols } = buildColumnLists(fields);
219
+ const fieldExtractions = buildFieldExtractions(fields);
220
+ const windowFunctions = buildWindowFunctions(fields, entityIdAlias);
221
+ const finalFields = buildFinalFields(fields);
222
+ const eventTypeValues = eventTypes.map((eventType) => sql`${eventType}`);
223
+ const entityFilter = entityId === undefined
224
+ ? sql``
225
+ : typeof entityId === 'string'
226
+ ? sql`AND entity_id = ${entityId}`
227
+ : sql`AND entity_id IN (${sql.join(entityId)})`;
228
+ const targetTable = sql.raw(qualifiedIdentifier(options.target?.schema, tableName));
229
+
230
+ return sql`
231
+ INSERT INTO ${targetTable} (${sql.raw(insertCols)})
232
+ ${buildCommonCTEs(context.tenantId, context.logId, options)},
233
+ entity_events AS (
234
+ SELECT
235
+ id,
236
+ type,
237
+ timestamp,
238
+ entity_version,
239
+ local_sequence,
240
+ json_extract(payload, '$.id') AS ${sql.raw(entityIdAlias)},
241
+ ${sql.raw(fieldExtractions)}
242
+ FROM effective
243
+ WHERE type IN (${sql.join(eventTypeValues, sql`, `)})
244
+ ${entityFilter}
245
+ ),
246
+ w AS (
247
+ SELECT
248
+ *,
249
+ MIN(timestamp) OVER (PARTITION BY ${sql.raw(entityIdAlias)}) AS created_ts,${sql.raw(windowFunctions)},
250
+ ROW_NUMBER() OVER (
251
+ PARTITION BY ${sql.raw(entityIdAlias)}
252
+ ORDER BY (entity_version IS NULL) DESC, entity_version DESC, local_sequence DESC, timestamp DESC, id DESC
253
+ ) AS rn
254
+ FROM entity_events
255
+ ),
256
+ final AS (
257
+ SELECT
258
+ ${context.tenantId} AS tenant_id,
259
+ ${context.logId} AS log_id,
260
+ ${sql.raw(entityIdAlias)} AS id,
261
+ ${sql.raw(finalFields)},
262
+ type,
263
+ created_ts,
264
+ timestamp AS updated_ts
265
+ FROM w
266
+ WHERE rn = 1
267
+ )
268
+ SELECT ${sql.raw(selectCols)}
269
+ FROM final
270
+ WHERE type <> ${removedEventType}
271
+ `;
272
+ }
273
+
274
+ /**
275
+ * Generates a projection SQL query for a single entity
276
+ * Used when projecting after a single event
277
+ */
278
+ export function buildSingleEntityProjectionSQL(
279
+ config: EntityProjectionConfig,
280
+ entityId: string,
281
+ context: Pick<ActiveContext, 'tenantId' | 'logId'>,
282
+ options: ProjectionBuildOptions = {},
283
+ ): RawBuilder<unknown> {
284
+ return buildProjectionSQL(config, context, options, entityId);
285
+ }
286
+
287
+ /**
288
+ * Generates a projection SQL query for all entities of a type
289
+ * Used during full rebuild
290
+ */
291
+ export function buildBulkProjectionSQL(
292
+ config: EntityProjectionConfig,
293
+ context: Pick<ActiveContext, 'tenantId' | 'logId'>,
294
+ options: ProjectionBuildOptions = {},
295
+ ): RawBuilder<unknown> {
296
+ return buildProjectionSQL(config, context, options);
297
+ }
298
+
299
+ /** Reproject a bounded affected set without touching other records of the type. */
300
+ export async function projectEntitySet(
301
+ config: EntityProjectionConfig,
302
+ entityIds: readonly string[],
303
+ tx: Transaction<Database>,
304
+ context: Pick<ActiveContext, 'tenantId' | 'logId'>,
305
+ ): Promise<void> {
306
+ if (entityIds.length === 0) return;
307
+ await tx.deleteFrom(config.tableName as keyof Database)
308
+ .where('tenant_id', '=', context.tenantId)
309
+ .where('log_id', '=', context.logId)
310
+ .where('id', 'in', entityIds.map(entityId => entityId.split(':')[1]))
311
+ .execute();
312
+ await buildProjectionSQL(config, context, {}, entityIds).execute(tx);
313
+ }
314
+
315
+ /**
316
+ * Creates an entity projector function from configuration
317
+ * Returns a function that can be used to project a single event to the read model
318
+ */
319
+ export function createEntityProjector(config: EntityProjectionConfig) {
320
+ return async function projectEvent(
321
+ event: { payload: { id: string } },
322
+ tx: Transaction<Database>,
323
+ entityIdOverride?: string,
324
+ context?: Pick<ActiveContext, 'tenantId' | 'logId'>,
325
+ ): Promise<void> {
326
+ const id = event.payload.id;
327
+ // If an explicit entity ID is provided (e.g. from the event envelope), use it.
328
+ // Otherwise, construct it from the config and payload ID.
329
+ // This ensures consistency between what's in raw_events and what we query for.
330
+ const fullEntityId = entityIdOverride || `${config.entityName}:${id}`;
331
+ const scope = context ?? await requireActiveContext();
332
+
333
+ // Delete existing entry for this scope + entity id
334
+ await tx.deleteFrom(config.tableName as keyof Database)
335
+ .where('tenant_id', '=', scope.tenantId)
336
+ .where('log_id', '=', scope.logId)
337
+ .where('id', '=', id)
338
+ .execute();
339
+
340
+ // Rebuild from events using the full entity ID to find matching events
341
+ const query = buildSingleEntityProjectionSQL(config, fullEntityId, scope);
342
+ await query.execute(tx);
343
+ };
344
+ }
345
+
346
+ // ============================================================================
347
+ // Entity Definition to Projection Config Conversion
348
+ // ============================================================================
349
+
350
+ import {
351
+ ENTITY_DEFINITIONS,
352
+ getEventTypes,
353
+ getPayloadKey,
354
+ type EntityDefinition,
355
+ type EntityFieldDefinition,
356
+ } from '@hearthlog/contracts/event/entity-definitions';
357
+
358
+ /**
359
+ * Converts an EntityDefinition to an EntityProjectionConfig
360
+ */
361
+ export function createProjectionConfigFromDefinition(def: EntityDefinition): EntityProjectionConfig {
362
+ const eventTypes = getEventTypes(def);
363
+
364
+ return {
365
+ entityName: def.name,
366
+ tableName: def.table,
367
+ entityIdAlias: `${def.name}_id`,
368
+ eventTypes: eventTypes,
369
+ removedEventType: eventTypes[2], // REMOVED event
370
+ fields: def.fields.map((field: EntityFieldDefinition) => ({
371
+ name: field.name,
372
+ payloadKey: getPayloadKey(field),
373
+ cast: field.type === 'number' ? 'REAL' : undefined,
374
+ default: field.default,
375
+ })),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Auto-generated projection configs from ENTITY_DEFINITIONS
381
+ */
382
+ export const PROJECTION_CONFIGS: Record<string, EntityProjectionConfig> = Object.fromEntries(
383
+ ENTITY_DEFINITIONS.map(def => [def.name, createProjectionConfigFromDefinition(def)])
384
+ );
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Event Queries
3
+ *
4
+ * Functions for reading events from the local database.
5
+ */
6
+
7
+ import { db as readModelDB, requireActiveContext } from '../db/index.js';
8
+ import type { AppEvent } from './types.js';
9
+ import { withTimeout } from '../utils/async.js';
10
+ import { tenant } from '@hearthlog/contracts/tenant/config';
11
+ import { sql, type RawBuilder } from 'kysely';
12
+
13
+ /**
14
+ * Tenant feature visibility as a SQL predicate, so raw event reads never
15
+ * fetch rows they must discard. Mirrors `isVisibleEventType` exactly.
16
+ */
17
+ export function visibleEventCondition(): RawBuilder<boolean> {
18
+ const prefixes = [...tenant.approvedEntities.eventPrefixes];
19
+ const prefixCondition = prefixes.length
20
+ ? sql.join(
21
+ prefixes.map((prefix) => sql`type LIKE ${`${prefix}%`}`),
22
+ sql` OR `,
23
+ )
24
+ : sql`0`;
25
+ const dynamicSuffix = tenant.dynamicEntities
26
+ ? sql`(type LIKE '%_ADDED' OR type LIKE '%_UPDATED' OR type LIKE '%_REMOVED')`
27
+ : sql`0`;
28
+ return sql<boolean>`(
29
+ CASE
30
+ WHEN type IN ('EVENT_TOMBSTONED', 'WORKSPACE_RESTORED', 'EVENTS_EXCISED') THEN 1
31
+ WHEN type LIKE 'ENTITY_TYPE_%' THEN ${tenant.dynamicEntities ? 1 : 0}
32
+ WHEN ${prefixCondition} THEN 1
33
+ WHEN ${dynamicSuffix} THEN 1
34
+ ELSE 0
35
+ END = 1
36
+ )`;
37
+ }
38
+
39
+ function parseEventPayload(payload: string): AppEvent['payload'] {
40
+ try {
41
+ return JSON.parse(payload) as AppEvent['payload'];
42
+ } catch (error) {
43
+ throw new Error(
44
+ `Stored event payload is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
45
+ );
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Gets all events from the local database (for display purposes)
51
+ */
52
+ export async function getAllEvents(order: 'asc' | 'desc' = 'desc'): Promise<AppEvent[]> {
53
+ const context = await requireActiveContext();
54
+ const rows = await readModelDB
55
+ .selectFrom('raw_events')
56
+ .select(['id', 'type', 'timestamp', 'payload'])
57
+ .where('tenant_id', '=', context.tenantId)
58
+ .where('log_id', '=', context.logId)
59
+ .where(visibleEventCondition())
60
+ .orderBy('timestamp', order)
61
+ .execute();
62
+
63
+ return rows.map((r) => ({
64
+ id: r.id,
65
+ type: r.type as AppEvent['type'],
66
+ timestamp: r.timestamp,
67
+ payload: parseEventPayload(r.payload),
68
+ }) as AppEvent);
69
+ }
70
+
71
+ /**
72
+ * Gets only local events that haven't been uploaded to the server yet
73
+ */
74
+ export async function getEvents(): Promise<AppEvent[]> {
75
+ const context = await requireActiveContext();
76
+ const rows = await withTimeout(
77
+ readModelDB
78
+ .selectFrom('raw_events')
79
+ .select(['id', 'type', 'timestamp', 'payload'])
80
+ .where('tenant_id', '=', context.tenantId)
81
+ .where('log_id', '=', context.logId)
82
+ .where('sync_state', '=', 'pending')
83
+ .where('projection_state', '=', 'effective')
84
+ .where(visibleEventCondition())
85
+ .orderBy('local_sequence', 'asc')
86
+ .execute(),
87
+ 5000,
88
+ 'getEvents query timed out'
89
+ );
90
+
91
+ const localEvents: AppEvent[] = rows.map((r) => ({
92
+ id: r.id,
93
+ type: r.type as AppEvent['type'],
94
+ timestamp: r.timestamp,
95
+ payload: parseEventPayload(r.payload),
96
+ }) as AppEvent);
97
+
98
+ console.log(
99
+ `[Event] getEvents returning ${localEvents.length} local-only events`
100
+ );
101
+ return localEvents;
102
+ }
103
+
104
+ /**
105
+ * Keep operational event views within the tenant's feature contract. Raw
106
+ * events remain locally stored for sync/recovery, but disabled feature events
107
+ * must not become a backdoor into another tenant artifact's data model.
108
+ */
109
+ export function isVisibleEventType(type: string): boolean {
110
+ if (type === 'EVENT_TOMBSTONED' || type === 'WORKSPACE_RESTORED' || type === 'EVENTS_EXCISED') return true;
111
+ if (type.startsWith('ENTITY_TYPE_')) return tenant.dynamicEntities;
112
+ if ([...tenant.approvedEntities.eventPrefixes].some((prefix) => type.startsWith(prefix))) {
113
+ return true;
114
+ }
115
+ // Dynamic entity events are runtime-defined and are only valid for sites
116
+ // that expose the dynamic entity capability.
117
+ return tenant.dynamicEntities && /_(ADDED|UPDATED|REMOVED)$/.test(type);
118
+ }
119
+
120
+ /**
121
+ * Exports all events as JSON string
122
+ */
123
+ export async function exportEventsAsJson(): Promise<string> {
124
+ const allEvents = await getAllEvents('asc');
125
+ return JSON.stringify(allEvents, null, 2);
126
+ }