@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,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
|
+
}
|