@hearthlog/runtime 0.0.0-stage → 0.1.1

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,76 @@
1
+ /**
2
+ * Databases older than this are not altered in place: their module projection tables are
3
+ * dropped and recreated, and the next rebuild refills them from raw events. A module that raises
4
+ * `projectionVersion` gets the same treatment, so no column repair is ever written again.
5
+ */
6
+ export const MIN_IN_PLACE_SCHEMA_VERSION = 42;
7
+
8
+ /**
9
+ * v42: `module_migrations` records each module's projection version (baseline 1).
10
+ * v41: Books, Vacations, and Web Archive are deleted; their projection tables
11
+ * are dropped.
12
+ * v40: activity entries record the service integration that wrote them (`source`).
13
+ *
14
+ * v39: transactions record where their category came from (`category_source`)
15
+ * and received invoices carry an expense type (`expense_type`).
16
+ *
17
+ * v38: raw events keep the author Ledger recorded for each accepted event.
18
+ *
19
+ * v37: an explicit null in an update payload clears the field instead of
20
+ * being treated as omitted, so every log re-projects; the unused legacy
21
+ * outbox_events table is dropped.
22
+ *
23
+ * v35: add Gallery albums and photo membership projections.
24
+ *
25
+ * v34: Gallery photos keep camera, exposure, and location metadata from EXIF.
26
+ *
27
+ * v33: add the tenant-scoped Gallery photo projection and timeline indexes.
28
+ *
29
+ * v32: calendar gains declarative date sources — entities declare one or more
30
+ * calendar dates, dynamic entity definitions persist a calendar config,
31
+ * and the unified month query uses new scoped date indexes.
32
+ *
33
+ * v31: budget plans become universal planning scenarios — the per-month
34
+ * column and its index are gone, so every plan is available in every
35
+ * month.
36
+ *
37
+ * v30: Finance adds monthly budget scenarios and their planned line items.
38
+ *
39
+ * v29: the party directory drops the supplier/customer split — one company
40
+ * record serves both invoice slots — and duplicates merged away point at
41
+ * their survivor through `merged_into`.
42
+ *
43
+ * v28: meal slots become a user-managed entity so the diary can add, rename,
44
+ * reorder, and hide parts of the day beyond the four built-in meals.
45
+ *
46
+ * v27: food items retain the source catalog's common servings so imported
47
+ * liquids and labeled portions can be logged in their source units.
48
+ * v26: issued invoices retain imported originals (PDF + ISDOC) and their
49
+ * extraction provenance, matching received-invoice storage.
50
+ * v25: received invoices keep the full document breakdown, supplier identity,
51
+ * payment details, the retained ISDOC XML, and extraction provenance.
52
+ * v24: add received invoices with separately stored original PDF documents.
53
+ * v23: preserve invoice document identity, fiscal fields and party/payment snapshots.
54
+ *
55
+ * v22: invoices keep the tax point date (DUZP) used on generated documents.
56
+ *
57
+ * v21: retain accepted action identity for history across devices.
58
+ *
59
+ * v20: opt-in Books reference entity (disabled for all family tenants).
60
+ *
61
+ * v19: repair overstated synchronization cursors and rebuild projections.
62
+ *
63
+ * v18: activity entries and behavior goals track steps and exercise separately
64
+ * from Nutrition's fixed calorie target.
65
+ *
66
+ * v17: nutrition goals retain the inputs, selected result, and method version
67
+ * used by the offline target-calorie calculator.
68
+ *
69
+ * v16: food items retain external catalog provenance and missing-nutrient
70
+ * metadata while remaining compatible with manual foods.
71
+ *
72
+ * v15: entity tables are scoped to the tenant's approved entities — the
73
+ * rebuild drops projection tables of non-approved entities (raw_events keeps
74
+ * the full history, so re-enabling a feature re-projects cleanly).
75
+ */
76
+ export const SCHEMA_VERSION = 42;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tab-local Ledger context.
3
+ *
4
+ * OPFS is shared across tabs, but each tab's JS heap is not. This module is the
5
+ * source of truth for "which log is this tab working on?" so concurrent tabs
6
+ * cannot stomp each other via a single ambient DB row.
7
+ *
8
+ * The active_context table remains a last-used default for cold start only.
9
+ */
10
+
11
+ export interface ActiveContext {
12
+ tenantId: string;
13
+ principalId: string;
14
+ logId: string;
15
+ ledgerOrigin: string;
16
+ }
17
+
18
+ let sessionContext: ActiveContext | null = null;
19
+
20
+ export function getSessionContext(): ActiveContext | null {
21
+ return sessionContext;
22
+ }
23
+
24
+ export function setSessionContext(context: ActiveContext | null): void {
25
+ sessionContext = context;
26
+ }
27
+
28
+ export function requireSessionContext(): ActiveContext {
29
+ if (!sessionContext) {
30
+ throw new Error('Local Ledger context has not been initialized in this tab');
31
+ }
32
+ return sessionContext;
33
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Code Export
3
+ *
4
+ * Generates TypeScript code for promoting dynamic entities to static.
5
+ * This allows users to copy-paste into entity-definitions.ts.
6
+ */
7
+
8
+ import type { EntityTypeRecord } from '../db/dynamic-ddl.js';
9
+ import type { DynamicFieldDefinition } from './meta-entity-types.js';
10
+
11
+ /**
12
+ * Generate the static entity definition code for a dynamic entity type.
13
+ * This code can be copy-pasted into entity-definitions.ts
14
+ */
15
+ export function generateStaticDefinition(record: EntityTypeRecord): string {
16
+ const fields = JSON.parse(record.fields_json) as DynamicFieldDefinition[];
17
+
18
+ const fieldLines = fields.map(f => {
19
+ const parts: string[] = [];
20
+ parts.push(`name: '${f.name}'`);
21
+ parts.push(`type: '${f.type}'`);
22
+
23
+ // Only add payloadKey if different from name
24
+ if (f.payloadKey && f.payloadKey !== f.name) {
25
+ parts.push(`payloadKey: '${f.payloadKey}'`);
26
+ }
27
+
28
+ if (f.required) {
29
+ parts.push('required: true');
30
+ }
31
+
32
+ if (f.default !== undefined) {
33
+ // Format default value appropriately
34
+ if (typeof f.default === 'string') {
35
+ parts.push(`default: '${f.default}'`);
36
+ } else {
37
+ parts.push(`default: ${f.default}`);
38
+ }
39
+ }
40
+
41
+ return ` { ${parts.join(', ')} },`;
42
+ });
43
+
44
+ return ` {
45
+ name: '${record.name}',
46
+ table: '${record.table_name}',
47
+ fields: [
48
+ ${fieldLines.join('\n')}
49
+ ],
50
+ },`;
51
+ }
52
+
53
+ /**
54
+ * Generate the full entity-definitions.ts snippet with instructions
55
+ */
56
+ export function generateExportSnippet(record: EntityTypeRecord): string {
57
+ const definition = generateStaticDefinition(record);
58
+
59
+ return `// Add this to ENTITY_DEFINITIONS array in packages/contracts/src/event/entity-definitions.ts
60
+ // Then run: pnpm generate:entities
61
+
62
+ ${definition}
63
+
64
+ // After adding, create your custom UI at: src/routes/${record.name}s/+page.svelte`;
65
+ }
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Dynamic Entities
3
+ *
4
+ * Runtime management of dynamic entity types.
5
+ * Handles loading, registering, and creating dynamic entities.
6
+ *
7
+ * Definitions are projected per (tenant_id, log_id) — the same scope as the
8
+ * events that create them — so multi-log / multi-tab work cannot clobber catalogs.
9
+ */
10
+
11
+ import { db, requireActiveContext, type ActiveContext } from '../db/index.js';
12
+ import {
13
+ registerDynamicEntity,
14
+ clearDynamicRegistry,
15
+ isStaticEntity,
16
+ type EntityRegistryEntry
17
+ } from './registry.js';
18
+ import {
19
+ type EntityProjectionConfig,
20
+ type FieldConfig,
21
+ } from './projection-builder.js';
22
+ import { generateDynamicTableDDL, generateAddColumnDDL, type EntityTypeRecord } from '../db/dynamic-ddl.js';
23
+ import type { DynamicFieldDefinition } from './meta-entity-types.js';
24
+ import {
25
+ parseCalendarConfig,
26
+ sanitizeCalendarSource,
27
+ serializeCalendarConfig,
28
+ validateCalendarConfig,
29
+ type EntityCalendarSource,
30
+ } from '@hearthlog/contracts/calendar/config';
31
+ import { sql } from 'kysely';
32
+ import { tenant } from '@hearthlog/contracts/tenant/config';
33
+
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+
38
+ export type { EntityTypeRecord } from '../db/dynamic-ddl.js';
39
+
40
+ // ============================================================================
41
+ // Loading & Registration
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Load dynamic entity types for the active tab log and register them.
46
+ * Called on app initialization and after log context is ready.
47
+ */
48
+ export async function loadDynamicEntityTypes(): Promise<void> {
49
+ console.log('[DynamicEntities] Loading dynamic entity types...');
50
+
51
+ // Clear existing dynamic registrations (tab-local registry)
52
+ clearDynamicRegistry();
53
+
54
+ if (!tenant.dynamicEntities) {
55
+ console.log('[DynamicEntities] Dynamic entities are disabled for this tenant');
56
+ return;
57
+ }
58
+
59
+ let context: ActiveContext;
60
+ try {
61
+ context = await requireActiveContext();
62
+ } catch {
63
+ console.log('[DynamicEntities] No active log context yet — skipping definition load');
64
+ return;
65
+ }
66
+
67
+ // Load only this log's definitions
68
+ const records = await db
69
+ .selectFrom('entity_type_definitions')
70
+ .selectAll()
71
+ .where('tenant_id', '=', context.tenantId)
72
+ .where('log_id', '=', context.logId)
73
+ .where('is_archived', '=', 0)
74
+ .execute();
75
+
76
+ console.log(`[DynamicEntities] Found ${records.length} dynamic entity types for log ${context.logId}`);
77
+
78
+ for (const record of records) {
79
+ // Skip if a static entity with the same name exists
80
+ if (isStaticEntity(record.name)) {
81
+ console.log(`[DynamicEntities] Skipping "${record.name}" - shadowed by static entity`);
82
+ continue;
83
+ }
84
+
85
+ try {
86
+ await registerDynamicEntityFromRecord(record);
87
+ } catch (error) {
88
+ console.error(`[DynamicEntities] Failed to register "${record.name}":`, error);
89
+ }
90
+ }
91
+
92
+ console.log('[DynamicEntities] Dynamic entity types loaded');
93
+ }
94
+
95
+ /**
96
+ * Register a dynamic entity from a database record.
97
+ */
98
+ async function registerDynamicEntityFromRecord(record: EntityTypeRecord): Promise<void> {
99
+ const fields = parseEntityFields(record);
100
+
101
+ // Ensure the table exists
102
+ await ensureDynamicTable(record);
103
+
104
+ // Create projection config
105
+ const projection = createProjectionConfigFromDynamic(record.name, record.table_name, fields);
106
+
107
+ // Create registry entry
108
+ const entry: EntityRegistryEntry = {
109
+ projection,
110
+ eventPrefix: record.name.toUpperCase() + '_',
111
+ table: record.table_name,
112
+ isDynamic: true,
113
+ calendar: parseCalendarConfig(record.calendar_json),
114
+ };
115
+
116
+ registerDynamicEntity(record.name, entry);
117
+ }
118
+
119
+ /**
120
+ * Create a projection config from dynamic field definitions
121
+ */
122
+ export function createProjectionConfigFromDynamic(
123
+ entityName: string,
124
+ tableName: string,
125
+ fields: DynamicFieldDefinition[]
126
+ ): EntityProjectionConfig {
127
+ const eventPrefix = entityName.toUpperCase() + '_';
128
+
129
+ return {
130
+ entityName,
131
+ tableName,
132
+ entityIdAlias: `${entityName}_id`,
133
+ eventTypes: [
134
+ `${eventPrefix}ADDED`,
135
+ `${eventPrefix}UPDATED`,
136
+ `${eventPrefix}REMOVED`,
137
+ ],
138
+ removedEventType: `${eventPrefix}REMOVED`,
139
+ fields: fields.map((f): FieldConfig => ({
140
+ name: f.name,
141
+ payloadKey: f.payloadKey,
142
+ cast: f.type === 'number' ? 'REAL' :
143
+ f.type === 'boolean' ? 'INTEGER' :
144
+ undefined,
145
+ default: f.default as string | number | null | undefined,
146
+ })),
147
+ };
148
+ }
149
+
150
+ // ============================================================================
151
+ // Table Management
152
+ // ============================================================================
153
+
154
+ /**
155
+ * Ensure a dynamic entity table exists, creating it if necessary.
156
+ * Migrates legacy unscoped tables to (tenant_id, log_id, id) primary keys.
157
+ */
158
+ async function ensureDynamicTable(record: EntityTypeRecord): Promise<void> {
159
+ const ddl = generateDynamicTableDDL(record);
160
+ console.log(`[DynamicEntities] Ensuring table "${record.table_name}" exists`);
161
+
162
+ await sql.raw(ddl).execute(db);
163
+ await migrateDynamicTableToScoped(record.table_name, {
164
+ tenantId: record.tenant_id,
165
+ logId: record.log_id,
166
+ });
167
+ }
168
+
169
+ async function migrateDynamicTableToScoped(
170
+ tableName: string,
171
+ scope?: { tenantId: string; logId: string },
172
+ ): Promise<void> {
173
+ const typedCols = await sql<{ name: string; type: string; notnull: number; dflt_value: string | null; pk: number }>`
174
+ PRAGMA table_info(${sql.raw(`"${tableName.replace(/"/g, '""')}"`)})
175
+ `.execute(db);
176
+
177
+ if (typedCols.rows.length === 0) return;
178
+
179
+ const hasTenant = typedCols.rows.some((column) => column.name === 'tenant_id');
180
+ const primaryKey = typedCols.rows
181
+ .filter((column) => column.pk > 0)
182
+ .sort((left, right) => left.pk - right.pk)
183
+ .map((column) => column.name);
184
+ if (hasTenant && primaryKey.join(',') === 'tenant_id,log_id,id') return;
185
+
186
+ let tenantId = scope?.tenantId ?? 'legacy-unassigned';
187
+ let logId = scope?.logId ?? 'legacy-unassigned';
188
+ if (!scope) {
189
+ try {
190
+ const context = await requireActiveContext();
191
+ tenantId = context.tenantId;
192
+ logId = context.logId;
193
+ } catch {
194
+ // Cold start before context — attribute legacy rows as unassigned
195
+ }
196
+ }
197
+
198
+ const dataColumns = typedCols.rows
199
+ .map((column) => column.name)
200
+ .filter((name) => name !== 'tenant_id' && name !== 'log_id');
201
+ const scopedName = `${tableName}__scoped`;
202
+ const bodyCols = typedCols.rows
203
+ .filter((c) => c.name !== 'tenant_id' && c.name !== 'log_id')
204
+ .map((c) => {
205
+ if (c.name === 'id') return '"id" TEXT NOT NULL';
206
+ const notNull = c.notnull ? ' NOT NULL' : '';
207
+ const dflt = c.dflt_value != null ? ` DEFAULT ${c.dflt_value}` : '';
208
+ return `"${c.name}" ${c.type || 'TEXT'}${notNull}${dflt}`;
209
+ });
210
+
211
+ await db.transaction().execute(async (tx) => {
212
+ await sql`DROP TABLE IF EXISTS ${sql.raw(`"${scopedName}"`)}`.execute(tx);
213
+ await sql`
214
+ CREATE TABLE ${sql.raw(`"${scopedName}"`)} (
215
+ "tenant_id" TEXT NOT NULL,
216
+ "log_id" TEXT NOT NULL,
217
+ ${sql.raw(bodyCols.join(', '))},
218
+ PRIMARY KEY ("tenant_id", "log_id", "id")
219
+ )
220
+ `.execute(tx);
221
+
222
+ const insertList = ['tenant_id', 'log_id', ...dataColumns]
223
+ .map((name) => `"${name}"`)
224
+ .join(', ');
225
+ const selectList = [
226
+ sql`${tenantId}`,
227
+ sql`${logId}`,
228
+ ...dataColumns.map((name) => sql.raw(`"${name}"`)),
229
+ ];
230
+
231
+ await sql`
232
+ INSERT INTO ${sql.raw(`"${scopedName}"`)} (${sql.raw(insertList)})
233
+ SELECT ${sql.join(selectList, sql`, `)}
234
+ FROM ${sql.raw(`"${tableName}"`)}
235
+ `.execute(tx);
236
+
237
+ await sql`DROP TABLE ${sql.raw(`"${tableName}"`)}`.execute(tx);
238
+ await sql`ALTER TABLE ${sql.raw(`"${scopedName}"`)} RENAME TO ${sql.raw(`"${tableName}"`)}`.execute(tx);
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Add a new column to a dynamic entity table.
244
+ */
245
+ export async function addColumnToDynamicTable(
246
+ tableName: string,
247
+ field: DynamicFieldDefinition
248
+ ): Promise<void> {
249
+ const ddl = generateAddColumnDDL(tableName, field);
250
+ console.log(`[DynamicEntities] Adding column "${field.name}" to "${tableName}"`);
251
+
252
+ await sql.raw(ddl).execute(db);
253
+ }
254
+
255
+ // ============================================================================
256
+ // CRUD Helpers
257
+ // ============================================================================
258
+
259
+ /**
260
+ * Get a dynamic entity type record by name for the active log.
261
+ */
262
+ export async function getDynamicEntityDefinition(name: string): Promise<EntityTypeRecord | null> {
263
+ const context = await requireActiveContext();
264
+ const record = await db
265
+ .selectFrom('entity_type_definitions')
266
+ .selectAll()
267
+ .where('tenant_id', '=', context.tenantId)
268
+ .where('log_id', '=', context.logId)
269
+ .where('name', '=', name)
270
+ .where('is_archived', '=', 0)
271
+ .executeTakeFirst();
272
+
273
+ return record || null;
274
+ }
275
+
276
+ /**
277
+ * Get all dynamic entity type records for the active log (non-archived).
278
+ */
279
+ export async function getAllDynamicEntityDefinitions(): Promise<EntityTypeRecord[]> {
280
+ const context = await requireActiveContext();
281
+ return db
282
+ .selectFrom('entity_type_definitions')
283
+ .selectAll()
284
+ .where('tenant_id', '=', context.tenantId)
285
+ .where('log_id', '=', context.logId)
286
+ .where('is_archived', '=', 0)
287
+ .execute();
288
+ }
289
+
290
+ /**
291
+ * Get parsed fields for a dynamic entity type
292
+ */
293
+ export function parseEntityFields(
294
+ record: Pick<EntityTypeRecord, 'name' | 'fields_json'>,
295
+ ): DynamicFieldDefinition[] {
296
+ try {
297
+ const parsed: unknown = JSON.parse(record.fields_json);
298
+ if (Array.isArray(parsed)) return parsed as DynamicFieldDefinition[];
299
+ } catch {
300
+ // Fall through to the diagnostic below.
301
+ }
302
+ console.error(`[DynamicEntities] Malformed field definitions for "${record.name}"`);
303
+ return [];
304
+ }
305
+
306
+ /**
307
+ * Validate a calendar config from a meta event against the entity's fields.
308
+ * Invalid configs are dropped with a warning (the meta-event validator rejects
309
+ * structurally invalid payloads before they reach here). Dynamic entities may
310
+ * not declare parent joins in the first release.
311
+ */
312
+ function validatedCalendarJson(
313
+ entityName: string,
314
+ calendar: EntityCalendarSource[] | null | undefined,
315
+ fields: readonly DynamicFieldDefinition[],
316
+ ): string | null {
317
+ if (calendar === undefined || calendar === null || calendar.length === 0) {
318
+ return null;
319
+ }
320
+ const sources: EntityCalendarSource[] = [];
321
+ for (const entry of calendar) {
322
+ const source = sanitizeCalendarSource(entry);
323
+ if (!source) {
324
+ console.warn(
325
+ `[DynamicEntities] Ignoring malformed calendar source for "${entityName}"`,
326
+ );
327
+ return null;
328
+ }
329
+ sources.push(source);
330
+ }
331
+ const errors = validateCalendarConfig(entityName, sources, fields, {
332
+ parentTables: new Set<string>(),
333
+ });
334
+ if (errors.length > 0) {
335
+ console.warn(
336
+ `[DynamicEntities] Ignoring invalid calendar config for "${entityName}":`,
337
+ errors.join('; '),
338
+ );
339
+ return null;
340
+ }
341
+ return serializeCalendarConfig(sources);
342
+ }
343
+
344
+ // ============================================================================
345
+ // Event Handling
346
+ // ============================================================================
347
+
348
+ /**
349
+ * Handle ENTITY_TYPE_DEFINED event.
350
+ * Creates the database record and table for the active log scope.
351
+ */
352
+ export async function handleEntityTypeDefined(
353
+ payload: {
354
+ id: string;
355
+ name: string;
356
+ displayName: string;
357
+ tableName: string;
358
+ fields: DynamicFieldDefinition[];
359
+ calendar?: EntityCalendarSource[];
360
+ }
361
+ ): Promise<void> {
362
+ console.log(`[DynamicEntities] Defining entity type: ${payload.name}`);
363
+ const context = await requireActiveContext();
364
+
365
+ // Check if static entity exists with same name
366
+ if (isStaticEntity(payload.name)) {
367
+ console.warn(`[DynamicEntities] Cannot create "${payload.name}" - conflicts with static entity`);
368
+ return;
369
+ }
370
+
371
+ const calendarJson = validatedCalendarJson(payload.name, payload.calendar, payload.fields);
372
+
373
+ // Insert into database scoped to this log
374
+ await db
375
+ .insertInto('entity_type_definitions')
376
+ .values({
377
+ tenant_id: context.tenantId,
378
+ log_id: context.logId,
379
+ id: payload.id,
380
+ name: payload.name,
381
+ display_name: payload.displayName,
382
+ table_name: payload.tableName,
383
+ fields_json: JSON.stringify(payload.fields),
384
+ calendar_json: calendarJson,
385
+ is_archived: 0,
386
+ })
387
+ .onConflict((c) => c.columns(['tenant_id', 'log_id', 'id']).doNothing())
388
+ .execute();
389
+
390
+ // Load the record and register
391
+ const record = await getDynamicEntityDefinition(payload.name);
392
+ if (record) {
393
+ await registerDynamicEntityFromRecord(record);
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Handle ENTITY_TYPE_UPDATED event.
399
+ * Updates the definition and potentially adds columns.
400
+ */
401
+ export async function handleEntityTypeUpdated(
402
+ payload: {
403
+ id: string;
404
+ displayName?: string;
405
+ addFields?: DynamicFieldDefinition[];
406
+ updateFields?: Array<{
407
+ name: string;
408
+ displayName?: string;
409
+ default?: string | number | boolean | null;
410
+ }>;
411
+ /** Replace the calendar dates; null clears them, undefined leaves them. */
412
+ calendar?: EntityCalendarSource[] | null;
413
+ }
414
+ ): Promise<void> {
415
+ console.log(`[DynamicEntities] Updating entity type: ${payload.id}`);
416
+ const context = await requireActiveContext();
417
+
418
+ // Get existing record in this log
419
+ const existing = await db
420
+ .selectFrom('entity_type_definitions')
421
+ .selectAll()
422
+ .where('tenant_id', '=', context.tenantId)
423
+ .where('log_id', '=', context.logId)
424
+ .where('id', '=', payload.id)
425
+ .executeTakeFirst();
426
+
427
+ if (!existing) {
428
+ console.warn(`[DynamicEntities] Entity type not found: ${payload.id}`);
429
+ return;
430
+ }
431
+
432
+ let fields = parseEntityFields(existing);
433
+
434
+ // Add new fields
435
+ if (payload.addFields && payload.addFields.length > 0) {
436
+ for (const newField of payload.addFields) {
437
+ // Add column to table
438
+ await addColumnToDynamicTable(existing.table_name, newField);
439
+ // Add to fields array
440
+ fields.push(newField);
441
+ }
442
+ }
443
+
444
+ // Update existing fields (only non-breaking changes)
445
+ if (payload.updateFields) {
446
+ for (const update of payload.updateFields) {
447
+ const fieldIndex = fields.findIndex(f => f.name === update.name);
448
+ if (fieldIndex >= 0) {
449
+ fields[fieldIndex] = { ...fields[fieldIndex], ...update };
450
+ }
451
+ }
452
+ }
453
+
454
+ // Update database record
455
+ await db
456
+ .updateTable('entity_type_definitions')
457
+ .set({
458
+ display_name: payload.displayName ?? existing.display_name,
459
+ fields_json: JSON.stringify(fields),
460
+ ...(payload.calendar !== undefined
461
+ ? {
462
+ calendar_json: validatedCalendarJson(
463
+ existing.name,
464
+ payload.calendar,
465
+ fields,
466
+ ),
467
+ }
468
+ : {}),
469
+ updated_at: new Date().toISOString(),
470
+ })
471
+ .where('tenant_id', '=', context.tenantId)
472
+ .where('log_id', '=', context.logId)
473
+ .where('id', '=', payload.id)
474
+ .execute();
475
+
476
+ // Re-register the entity
477
+ const record = await getDynamicEntityDefinition(existing.name);
478
+ if (record) {
479
+ await registerDynamicEntityFromRecord(record);
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Handle ENTITY_TYPE_ARCHIVED event.
485
+ * Marks the entity type as archived (soft delete) within the active log.
486
+ */
487
+ export async function handleEntityTypeArchived(payload: { id: string }): Promise<void> { console.log(`[DynamicEntities] Archiving entity type: ${payload.id}`);
488
+ const context = await requireActiveContext();
489
+
490
+ await db
491
+ .updateTable('entity_type_definitions')
492
+ .set({
493
+ is_archived: 1,
494
+ updated_at: new Date().toISOString(),
495
+ })
496
+ .where('tenant_id', '=', context.tenantId)
497
+ .where('log_id', '=', context.logId)
498
+ .where('id', '=', payload.id)
499
+ .execute();
500
+
501
+ // Note: We don't unregister here because the entity data still exists
502
+ // and may need to be accessed. The entity just won't appear in the UI.
503
+ }