@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,176 @@
|
|
|
1
|
+
import { reconcileRestoredEvents } from '../history/legacy-replay.js';
|
|
2
|
+
/**
|
|
3
|
+
* Read Model Rebuild
|
|
4
|
+
*
|
|
5
|
+
* Functions for rebuilding the entire read model from the event store.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db as readModelDB, ensureCoreSchema, recreateModuleProjectionTables, requireActiveContext } from '../db/index.js';
|
|
9
|
+
import type { Database, ActiveContext } from '../db/index.js';
|
|
10
|
+
import { sql, type Transaction } from 'kysely';
|
|
11
|
+
import { buildBulkProjectionSQL, type EntityProjectionConfig } from './projection-builder.js';
|
|
12
|
+
import { entityRegistry, getAllEntityTables } from './registry.js';
|
|
13
|
+
import { versionTracker } from '../ledger/version-tracker-singleton.js';
|
|
14
|
+
import { get as getKv, set as setKv } from '../db/kv.js';
|
|
15
|
+
import { SCHEMA_VERSION } from '../db/schema-version.js';
|
|
16
|
+
import { currentProjections, moduleTables, staleModules } from '../db/module-projections.js';
|
|
17
|
+
import { loadDynamicEntityTypes } from './dynamic-entities.js';
|
|
18
|
+
import { materializeDynamicEntities } from './dynamic-materializer.js';
|
|
19
|
+
import { withTiming } from '../utils/perf.js';
|
|
20
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
21
|
+
import { postTabChange, SCHEMA_LOCK_NAME, withTabLock } from '../store/tab-coordination.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Rebuilds the entire read model from raw_events
|
|
25
|
+
* Uses the entity registry to iterate over all entities
|
|
26
|
+
*/
|
|
27
|
+
const SCHEMA_VERSION_KEY = 'schema-version';
|
|
28
|
+
/** Projection tables the stored read model was built for. */
|
|
29
|
+
const PROJECTED_TABLES_KEY = 'projected-tables';
|
|
30
|
+
|
|
31
|
+
export async function rebuildIfSchemaChanged(): Promise<boolean> {
|
|
32
|
+
return withTiming('rebuild', 'rebuildIfSchemaChanged', async () => {
|
|
33
|
+
// Ensure core tables (raw_events) have the latest columns
|
|
34
|
+
const context = await requireActiveContext();
|
|
35
|
+
await ensureCoreSchema({ tenantId: context.tenantId, logId: context.logId });
|
|
36
|
+
|
|
37
|
+
// Always load dynamic entity types first - they need to be registered
|
|
38
|
+
// before any event processing can happen
|
|
39
|
+
await loadDynamicEntityTypes();
|
|
40
|
+
|
|
41
|
+
// Tabs opening together decide inside one lock, so exactly one rebuilds
|
|
42
|
+
// and the rest find the stored version current.
|
|
43
|
+
return withTabLock(SCHEMA_LOCK_NAME, async () => {
|
|
44
|
+
const storedVersion = await getKv<number>(SCHEMA_VERSION_KEY);
|
|
45
|
+
// A re-enabled feature gets its projection table back empty; a build
|
|
46
|
+
// with a different feature set re-projects like a schema change.
|
|
47
|
+
const projectedTables = [...tenant.approvedEntities.tables].sort();
|
|
48
|
+
const storedTables = await getKv<string[]>(PROJECTED_TABLES_KEY);
|
|
49
|
+
const tablesUnchanged = JSON.stringify(storedTables) === JSON.stringify(projectedTables);
|
|
50
|
+
|
|
51
|
+
// A module that raised its projection version re-projects like a schema change.
|
|
52
|
+
const projections = currentProjections(tenant.features);
|
|
53
|
+
const storedProjections = await readModelDB.selectFrom('module_migrations')
|
|
54
|
+
.select(['module_id', 'projection_version']).execute();
|
|
55
|
+
const stale = staleModules(storedProjections, projections);
|
|
56
|
+
|
|
57
|
+
if (storedVersion === SCHEMA_VERSION && tablesUnchanged && stale.length === 0) {
|
|
58
|
+
console.log('[Event] Schema version unchanged, skipping rebuild');
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(`[Event] Schema version (${storedVersion} → ${SCHEMA_VERSION}), projected tables, or module projections (${stale.join(', ') || 'none'}) changed, rebuilding...`);
|
|
63
|
+
// A module whose tables changed shape gets them recreated; no column repair is written.
|
|
64
|
+
if (stale.length > 0) await recreateModuleProjectionTables(readModelDB, moduleTables(stale));
|
|
65
|
+
await rebuildReadModel();
|
|
66
|
+
await setKv(SCHEMA_VERSION_KEY, SCHEMA_VERSION);
|
|
67
|
+
await setKv(PROJECTED_TABLES_KEY, projectedTables);
|
|
68
|
+
for (const { module, version } of projections) {
|
|
69
|
+
await readModelDB.insertInto('module_migrations')
|
|
70
|
+
.values({ module_id: module, projection_version: version, applied_at: new Date().toISOString() })
|
|
71
|
+
.onConflict((conflict) => conflict.column('module_id').doUpdateSet({ projection_version: version, applied_at: new Date().toISOString() }))
|
|
72
|
+
.execute();
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
}, { currentVersion: SCHEMA_VERSION });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Rebuilds the entire read model from raw events.
|
|
81
|
+
* Uses the entity registry to iterate over all entities.
|
|
82
|
+
* Only call this explicitly when needed - not on every app load!
|
|
83
|
+
*/
|
|
84
|
+
export async function rebuildReadModel(options: { includePendingDdl?: boolean } = {}): Promise<void> {
|
|
85
|
+
return withTiming('rebuild', 'rebuildReadModel', async () => {
|
|
86
|
+
console.log('[Event] Starting read model rebuild');
|
|
87
|
+
const context = await requireActiveContext();
|
|
88
|
+
|
|
89
|
+
const staticConfigs = Object.values(entityRegistry).filter(entry => !entry.isDynamic).map(entry => entry.projection);
|
|
90
|
+
const previousTables = getAllEntityTables();
|
|
91
|
+
// Definitions, DDL, restore membership, and all entity rows publish together.
|
|
92
|
+
// Keep the tab-local registry unchanged until the database commit succeeds.
|
|
93
|
+
await readModelDB.transaction().execute(async (tx) => {
|
|
94
|
+
await reconcileRestoredEvents(tx, context, options.includePendingDdl);
|
|
95
|
+
const dynamicConfigs = await rebuildDynamicDefinitions(tx, context, options.includePendingDdl);
|
|
96
|
+
const configs = [...staticConfigs, ...dynamicConfigs];
|
|
97
|
+
console.log('[Event] Clearing existing read model tables for active log');
|
|
98
|
+
|
|
99
|
+
// Clear only this log's projection rows — leave other logs intact
|
|
100
|
+
for (const table of new Set([...previousTables, ...configs.map(config => config.tableName)])) {
|
|
101
|
+
await tx.deleteFrom(table as keyof Database)
|
|
102
|
+
.where('tenant_id', '=', context.tenantId)
|
|
103
|
+
.where('log_id', '=', context.logId)
|
|
104
|
+
.execute();
|
|
105
|
+
}
|
|
106
|
+
await tx.deleteFrom('entity_versions')
|
|
107
|
+
.where('tenant_id', '=', context.tenantId)
|
|
108
|
+
.where('log_id', '=', context.logId)
|
|
109
|
+
.execute();
|
|
110
|
+
console.log('[Event] Read model tables cleared for scope');
|
|
111
|
+
|
|
112
|
+
// Rebuild each entity from events using the registry
|
|
113
|
+
for (const config of configs) {
|
|
114
|
+
await buildBulkProjectionSQL(config, context).execute(tx);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
await loadDynamicEntityTypes();
|
|
118
|
+
|
|
119
|
+
// Rebuild entity version cache
|
|
120
|
+
console.log('[Event] Rebuilding entity version cache');
|
|
121
|
+
await versionTracker.rebuildVersionCache();
|
|
122
|
+
|
|
123
|
+
// Other tabs hold stale registries and pages until told.
|
|
124
|
+
postTabChange({ kind: 'rebuild', tenantId: context.tenantId, logId: context.logId });
|
|
125
|
+
console.log('[Event] Read model rebuild completed successfully');
|
|
126
|
+
}, { entityCount: Object.keys(entityRegistry).length });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Rebuilds the entity_type_definitions table and dynamic registry
|
|
131
|
+
* by replaying all definition events.
|
|
132
|
+
*/
|
|
133
|
+
async function rebuildDynamicDefinitions(tx: Transaction<Database>, context: ActiveContext, includePendingDdl = false): Promise<EntityProjectionConfig[]> {
|
|
134
|
+
return withTiming('rebuild', 'rebuildDynamicDefinitions', async () => {
|
|
135
|
+
console.log('[Event] Rebuilding dynamic entity definitions...');
|
|
136
|
+
|
|
137
|
+
if (!tenant.dynamicEntities) {
|
|
138
|
+
await tx.deleteFrom('entity_type_definitions')
|
|
139
|
+
.where('tenant_id', '=', context.tenantId)
|
|
140
|
+
.where('log_id', '=', context.logId)
|
|
141
|
+
.execute();
|
|
142
|
+
console.log('[Event] Dynamic entities disabled; definitions remain hidden');
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const stagedTable = '__stor_live_effective_events';
|
|
147
|
+
try {
|
|
148
|
+
await tx.deleteFrom('entity_type_definitions')
|
|
149
|
+
.where('tenant_id', '=', context.tenantId)
|
|
150
|
+
.where('log_id', '=', context.logId)
|
|
151
|
+
.execute();
|
|
152
|
+
await sql.raw(`DROP TABLE IF EXISTS temp."${stagedTable}"`).execute(tx);
|
|
153
|
+
await sql`
|
|
154
|
+
CREATE TEMP TABLE ${sql.raw(`"${stagedTable}"`)} AS
|
|
155
|
+
SELECT * FROM main.raw_events
|
|
156
|
+
WHERE tenant_id = ${context.tenantId}
|
|
157
|
+
AND log_id = ${context.logId}
|
|
158
|
+
AND (projection_state = 'effective' OR (${includePendingDdl ? 1 : 0} = 1 AND projection_state = 'pending_ddl'))
|
|
159
|
+
AND is_tombstoned = 0
|
|
160
|
+
`.execute(tx);
|
|
161
|
+
const result = await materializeDynamicEntities({
|
|
162
|
+
database: tx,
|
|
163
|
+
schema: 'main',
|
|
164
|
+
sourceSchema: 'temp',
|
|
165
|
+
sourceTable: stagedTable,
|
|
166
|
+
scope: context,
|
|
167
|
+
registerLive: false,
|
|
168
|
+
});
|
|
169
|
+
return result.activeProjectionConfigs;
|
|
170
|
+
} finally {
|
|
171
|
+
await sql.raw(`DROP TABLE IF EXISTS temp."${stagedTable}"`).execute(tx);
|
|
172
|
+
}
|
|
173
|
+
}, {});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export { versionTracker };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import type { ReadModelSession } from '../db/read-session.js';
|
|
3
|
+
import { ENTITY_DEFINITIONS, getPayloadKey } from '@hearthlog/contracts/event/entity-definitions';
|
|
4
|
+
import { parseEntityFields } from './dynamic-entities.js';
|
|
5
|
+
import type { DynamicFieldDefinition } from './meta-entity-types.js';
|
|
6
|
+
import { quoteIdentifier } from './projection-builder.js';
|
|
7
|
+
import { withTimeout } from '../utils/async.js';
|
|
8
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
9
|
+
|
|
10
|
+
export interface ReferencingEntity {
|
|
11
|
+
entityType: string;
|
|
12
|
+
entityTypeDisplayName: string;
|
|
13
|
+
entityId: string;
|
|
14
|
+
entityLabel: string;
|
|
15
|
+
fieldName: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReferencedByResult {
|
|
19
|
+
byType: Record<string, ReferencingEntity[]>;
|
|
20
|
+
totalCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createReferenceLookup(session: ReadModelSession) {
|
|
24
|
+
return {
|
|
25
|
+
async findReferencingEntities(
|
|
26
|
+
targetEntityId: string,
|
|
27
|
+
targetEntityType?: string,
|
|
28
|
+
): Promise<ReferencedByResult> {
|
|
29
|
+
const result: ReferencedByResult = { byType: {}, totalCount: 0 };
|
|
30
|
+
const dynamic = await session.selectFrom('entity_type_definitions')
|
|
31
|
+
.selectAll()
|
|
32
|
+
.where('is_archived', '=', 0)
|
|
33
|
+
.execute();
|
|
34
|
+
const entityTypes = [
|
|
35
|
+
...ENTITY_DEFINITIONS
|
|
36
|
+
.filter((definition) => tenant.approvedEntities.entityNames.has(definition.name))
|
|
37
|
+
.map((definition) => ({
|
|
38
|
+
name: definition.name,
|
|
39
|
+
display_name: definition.displayName ?? definition.name,
|
|
40
|
+
table_name: definition.table,
|
|
41
|
+
fields: definition.fields.map((field) => ({
|
|
42
|
+
...field,
|
|
43
|
+
payloadKey: getPayloadKey(field),
|
|
44
|
+
})) as DynamicFieldDefinition[],
|
|
45
|
+
})),
|
|
46
|
+
...(tenant.dynamicEntities ? dynamic : []).map((definition) => ({
|
|
47
|
+
name: definition.name,
|
|
48
|
+
display_name: definition.display_name,
|
|
49
|
+
table_name: definition.table_name,
|
|
50
|
+
fields: parseEntityFields(definition),
|
|
51
|
+
})),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const entityType of entityTypes) {
|
|
55
|
+
const fields = entityType.fields.filter((field) =>
|
|
56
|
+
field.type === 'reference' &&
|
|
57
|
+
(!targetEntityType || field.referenceTo === targetEntityType) &&
|
|
58
|
+
(!field.referenceTo || isVisibleEntityType(field.referenceTo)));
|
|
59
|
+
for (const field of fields) {
|
|
60
|
+
const rows = await withTimeout(
|
|
61
|
+
session.execute(sql<Record<string, unknown>>`
|
|
62
|
+
SELECT *
|
|
63
|
+
FROM ${session.table(entityType.table_name)}
|
|
64
|
+
WHERE ${sql.raw(quoteIdentifier(field.name))} = ${targetEntityId}
|
|
65
|
+
LIMIT 50
|
|
66
|
+
`),
|
|
67
|
+
5000,
|
|
68
|
+
'Reference lookup query timed out',
|
|
69
|
+
);
|
|
70
|
+
for (const row of rows.rows) {
|
|
71
|
+
const item: ReferencingEntity = {
|
|
72
|
+
entityType: entityType.name,
|
|
73
|
+
entityTypeDisplayName: entityType.display_name,
|
|
74
|
+
entityId: String(row.id),
|
|
75
|
+
entityLabel: String(row.name ?? row.title ?? row.label ?? row.id),
|
|
76
|
+
fieldName: field.name,
|
|
77
|
+
};
|
|
78
|
+
(result.byType[entityType.name] ??= []).push(item);
|
|
79
|
+
result.totalCount += 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isVisibleEntityType(name: string): boolean {
|
|
89
|
+
const definition = ENTITY_DEFINITIONS.find((candidate) => candidate.name === name);
|
|
90
|
+
return definition
|
|
91
|
+
? tenant.approvedEntities.entityNames.has(name)
|
|
92
|
+
: tenant.dynamicEntities;
|
|
93
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Registry
|
|
3
|
+
*
|
|
4
|
+
* Hybrid registry supporting both static (compile-time) and dynamic (runtime) entities.
|
|
5
|
+
* Static entities from ENTITY_DEFINITIONS always take precedence over dynamic ones.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ENTITY_DEFINITIONS, getEventPrefix } from '@hearthlog/contracts/event/entity-definitions';
|
|
9
|
+
import type { EntityCalendarSource } from '@hearthlog/contracts/calendar/config';
|
|
10
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
11
|
+
import { PROJECTION_CONFIGS, type EntityProjectionConfig } from './projection-builder.js';
|
|
12
|
+
|
|
13
|
+
export interface EntityRegistryEntry {
|
|
14
|
+
/** Projection configuration for SQL generation */
|
|
15
|
+
projection: EntityProjectionConfig;
|
|
16
|
+
/** Event type prefix for type guards (e.g., 'CALENDAR_EVENT_') */
|
|
17
|
+
eventPrefix: string;
|
|
18
|
+
/** Database table name */
|
|
19
|
+
table: string;
|
|
20
|
+
/** Whether this is a dynamic (runtime-defined) entity */
|
|
21
|
+
isDynamic?: boolean;
|
|
22
|
+
/** Calendar dates the entity exposes (dynamic entities only today). */
|
|
23
|
+
calendar?: EntityCalendarSource[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// Static Registry (Compile-time entities)
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Static registry of the entities this tenant is approved for (feature
|
|
32
|
+
* ownership in entity-definitions.ts; approval derived from the tenant's
|
|
33
|
+
* enabled features). Events for non-approved entities are stored in
|
|
34
|
+
* raw_events but never materialized.
|
|
35
|
+
*/
|
|
36
|
+
const staticRegistry: Record<string, EntityRegistryEntry> = Object.fromEntries(
|
|
37
|
+
ENTITY_DEFINITIONS
|
|
38
|
+
.filter(def => tenant.approvedEntities.entityNames.has(def.name))
|
|
39
|
+
.map(def => [
|
|
40
|
+
def.name,
|
|
41
|
+
{
|
|
42
|
+
projection: PROJECTION_CONFIGS[def.name],
|
|
43
|
+
eventPrefix: getEventPrefix(def),
|
|
44
|
+
table: def.table,
|
|
45
|
+
isDynamic: false,
|
|
46
|
+
}
|
|
47
|
+
])
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// Dynamic Registry (Runtime entities)
|
|
52
|
+
// ============================================================================
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dynamic registry of entities defined at runtime.
|
|
56
|
+
* Mutable - can be updated as new entity types are created.
|
|
57
|
+
*/
|
|
58
|
+
let dynamicRegistry: Record<string, EntityRegistryEntry> = {};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Register a dynamic entity type.
|
|
62
|
+
* If a static entity with the same name exists, the static one takes precedence.
|
|
63
|
+
*/
|
|
64
|
+
export function registerDynamicEntity(name: string, entry: EntityRegistryEntry): void {
|
|
65
|
+
if (staticRegistry[name]) {
|
|
66
|
+
console.warn(`[Registry] Dynamic entity "${name}" shadowed by static entity`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
dynamicRegistry[name] = { ...entry, isDynamic: true };
|
|
70
|
+
console.log(`[Registry] Registered dynamic entity: ${name}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Unregister a dynamic entity type.
|
|
75
|
+
*/
|
|
76
|
+
export function unregisterDynamicEntity(name: string): void {
|
|
77
|
+
delete dynamicRegistry[name];
|
|
78
|
+
console.log(`[Registry] Unregistered dynamic entity: ${name}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Clear all dynamic entities (used during rebuild).
|
|
83
|
+
*/
|
|
84
|
+
export function clearDynamicRegistry(): void {
|
|
85
|
+
dynamicRegistry = {};
|
|
86
|
+
console.log('[Registry] Cleared dynamic registry');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get names of all dynamic entities.
|
|
91
|
+
*/
|
|
92
|
+
export function getDynamicEntityNames(): string[] {
|
|
93
|
+
return Object.keys(dynamicRegistry);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Unified Access (Backwards Compatible)
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the combined registry of static + dynamic entities.
|
|
102
|
+
* Static entities take precedence over dynamic ones.
|
|
103
|
+
*/
|
|
104
|
+
export function getEntityRegistry(): Record<string, EntityRegistryEntry> {
|
|
105
|
+
return { ...dynamicRegistry, ...staticRegistry };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Legacy: Direct access to registry for backwards compatibility.
|
|
110
|
+
* Prefer using getEntityRegistry() for future code.
|
|
111
|
+
*/
|
|
112
|
+
export const entityRegistry: Record<string, EntityRegistryEntry> = new Proxy({} as Record<string, EntityRegistryEntry>, {
|
|
113
|
+
get(_target, prop: string) {
|
|
114
|
+
return getEntityRegistry()[prop];
|
|
115
|
+
},
|
|
116
|
+
has(_target, prop: string) {
|
|
117
|
+
return prop in getEntityRegistry();
|
|
118
|
+
},
|
|
119
|
+
ownKeys() {
|
|
120
|
+
return Object.keys(getEntityRegistry());
|
|
121
|
+
},
|
|
122
|
+
getOwnPropertyDescriptor(_target, prop: string) {
|
|
123
|
+
const registry = getEntityRegistry();
|
|
124
|
+
if (prop in registry) {
|
|
125
|
+
return { configurable: true, enumerable: true, value: registry[prop] };
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Get all registered entity configurations (static + dynamic).
|
|
133
|
+
*/
|
|
134
|
+
export function getAllEntityConfigs(): EntityProjectionConfig[] {
|
|
135
|
+
return Object.values(getEntityRegistry()).map(e => e.projection);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get all registered table names (static + dynamic).
|
|
140
|
+
*/
|
|
141
|
+
export function getAllEntityTables(): string[] {
|
|
142
|
+
return Object.values(getEntityRegistry()).map(e => e.table);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get only static entity names.
|
|
147
|
+
*/
|
|
148
|
+
export function getStaticEntityNames(): string[] {
|
|
149
|
+
return Object.keys(staticRegistry);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if an entity is static (compile-time defined).
|
|
154
|
+
*/
|
|
155
|
+
export function isStaticEntity(name: string): boolean {
|
|
156
|
+
return name in staticRegistry;
|
|
157
|
+
}
|
|
158
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Kysely, Transaction } from 'kysely';
|
|
2
|
+
import type { Database } from '../db/index.js';
|
|
3
|
+
import { EVENTS_EXCISED, WORKSPACE_RESTORED } from '@hearthlog/contracts/history/replay-selection';
|
|
4
|
+
|
|
5
|
+
const SCHEMA_EFFECT_TYPES = [
|
|
6
|
+
'ENTITY_TYPE_DEFINED',
|
|
7
|
+
'ENTITY_TYPE_UPDATED',
|
|
8
|
+
'ENTITY_TYPE_ARCHIVED',
|
|
9
|
+
WORKSPACE_RESTORED,
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Directive events that change the projected schema (dynamic definitions and
|
|
14
|
+
* restore pointers) must take the DDL rebuild path instead of a targeted
|
|
15
|
+
* reprojection. Tombstones and excisions both qualify when they name a schema
|
|
16
|
+
* event.
|
|
17
|
+
*/
|
|
18
|
+
export async function schemaTombstoneIds(
|
|
19
|
+
database: Kysely<Database> | Transaction<Database>,
|
|
20
|
+
scope: { tenantId: string; logId: string },
|
|
21
|
+
events: ReadonlyArray<{ id: string; type: string; payload: unknown }>,
|
|
22
|
+
): Promise<Set<string>> {
|
|
23
|
+
const tombstones = events.filter(event => event.type === 'EVENT_TOMBSTONED');
|
|
24
|
+
const excisions = events.filter(event => event.type === EVENTS_EXCISED);
|
|
25
|
+
if (!tombstones.length && !excisions.length) return new Set();
|
|
26
|
+
const targetIds = [
|
|
27
|
+
...tombstones.map(event => (event.payload as { eventId: string }).eventId),
|
|
28
|
+
...excisions.flatMap(event => (event.payload as { eventIds: string[] }).eventIds),
|
|
29
|
+
];
|
|
30
|
+
if (!targetIds.length) return new Set();
|
|
31
|
+
const targets = await database.selectFrom('raw_events').select('id')
|
|
32
|
+
.where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
|
|
33
|
+
.where('id', 'in', targetIds)
|
|
34
|
+
.where('type', 'in', SCHEMA_EFFECT_TYPES).execute();
|
|
35
|
+
const schemaTargetIds = new Set(targets.map(row => row.id));
|
|
36
|
+
const result = new Set<string>();
|
|
37
|
+
for (const tombstone of tombstones) {
|
|
38
|
+
if (schemaTargetIds.has((tombstone.payload as { eventId: string }).eventId)) result.add(tombstone.id);
|
|
39
|
+
}
|
|
40
|
+
for (const excision of excisions) {
|
|
41
|
+
const targets = (excision.payload as { eventIds: string[] }).eventIds;
|
|
42
|
+
if (targets.some(id => schemaTargetIds.has(id))) result.add(excision.id);
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event Types
|
|
3
|
+
*
|
|
4
|
+
* Re-exports all types from the generated file.
|
|
5
|
+
*
|
|
6
|
+
* IMPORTANT: Do not manually edit event types here.
|
|
7
|
+
* Instead, update entity-definitions.ts and run `pnpm generate:entities`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Re-export all generated types
|
|
11
|
+
export * from '#generated/types';
|
|
12
|
+
|
|
13
|
+
// Re-export meta-entity types (runtime entity type management)
|
|
14
|
+
export * from './meta-entity-types.js';
|
|
15
|
+
|
|
16
|
+
// Extended AppEvent union that includes meta-entity events
|
|
17
|
+
import type { AppEvent as GeneratedAppEvent } from '#generated/types';
|
|
18
|
+
import type { MetaEntityEvent } from './meta-entity-types.js';
|
|
19
|
+
|
|
20
|
+
export type ExtendedAppEvent = GeneratedAppEvent | MetaEntityEvent;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { sql, type SqlBool } from 'kysely';
|
|
2
|
+
|
|
3
|
+
/** A paged download must contain every accepted member before exposing an action. */
|
|
4
|
+
export const completeActionBoundary = sql<SqlBool>`(
|
|
5
|
+
raw_events.action_id IS NULL OR (
|
|
6
|
+
raw_events.action_size > 0
|
|
7
|
+
AND raw_events.action_index = raw_events.action_size - 1
|
|
8
|
+
AND (SELECT COUNT(*) FROM raw_events AS member
|
|
9
|
+
WHERE member.tenant_id = raw_events.tenant_id
|
|
10
|
+
AND member.log_id = raw_events.log_id
|
|
11
|
+
AND member.action_id = raw_events.action_id
|
|
12
|
+
AND member.action_size = raw_events.action_size
|
|
13
|
+
AND member.projection_state IN ('effective', 'restored_out')
|
|
14
|
+
AND member.log_version BETWEEN raw_events.log_version - raw_events.action_size + 1 AND raw_events.log_version
|
|
15
|
+
AND member.action_index = member.log_version - (raw_events.log_version - raw_events.action_size + 1)
|
|
16
|
+
) = raw_events.action_size
|
|
17
|
+
)
|
|
18
|
+
)`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { requireActiveContext } from '../db/index.js';
|
|
2
|
+
import { getAdjacentHistoryPoint, getHistoryPoint, type HistoryPoint } from './history.js';
|
|
3
|
+
import { readWorkspaceSnapshot } from './point-snapshot.js';
|
|
4
|
+
import { compareWorkspaceRecords, type HistoryChange } from './change-comparison.js';
|
|
5
|
+
|
|
6
|
+
export interface HistoryActionDetail {
|
|
7
|
+
point: HistoryPoint;
|
|
8
|
+
previousVersion: number;
|
|
9
|
+
changes: HistoryChange[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Compare only the selected accepted action boundary with its predecessor. */
|
|
13
|
+
export async function loadHistoryActionDetail(version: number): Promise<HistoryActionDetail> {
|
|
14
|
+
const point = await getHistoryPoint(version);
|
|
15
|
+
if (!point) throw new Error('That action is no longer available in this log.');
|
|
16
|
+
const previous = await getAdjacentHistoryPoint(version, 'previous');
|
|
17
|
+
const context = await requireActiveContext();
|
|
18
|
+
const scope = { tenantId: context.tenantId, logId: context.logId };
|
|
19
|
+
const previousVersion = previous?.version ?? 0;
|
|
20
|
+
const before = await readWorkspaceSnapshot(previousVersion, scope);
|
|
21
|
+
const after = await readWorkspaceSnapshot(point.version, scope);
|
|
22
|
+
return { point, previousVersion, changes: compareWorkspaceRecords(before, after) };
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { WorkspaceRecord } from './point-snapshot.js';
|
|
2
|
+
|
|
3
|
+
export interface HistoryChange {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
kind: 'added' | 'changed' | 'removed';
|
|
7
|
+
before?: Record<string, unknown>;
|
|
8
|
+
after?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function compareWorkspaceRecords(before: WorkspaceRecord[], after: WorkspaceRecord[]): HistoryChange[] {
|
|
12
|
+
const previous = new Map(before.map(record => [record.key, record]));
|
|
13
|
+
const next = new Map(after.map(record => [record.key, record]));
|
|
14
|
+
const changes: HistoryChange[] = [];
|
|
15
|
+
for (const key of [...new Set([...previous.keys(), ...next.keys()])].sort()) {
|
|
16
|
+
const left = previous.get(key), right = next.get(key);
|
|
17
|
+
if (left && right && stableJson(left.value) === stableJson(right.value)) continue;
|
|
18
|
+
changes.push({ key, label: (right ?? left)!.label, kind: !left ? 'added' : !right ? 'removed' : 'changed',
|
|
19
|
+
...(left ? { before: left.value } : {}), ...(right ? { after: right.value } : {}) });
|
|
20
|
+
}
|
|
21
|
+
return changes;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stableJson(value: unknown): string {
|
|
25
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
|
26
|
+
if (value && typeof value === 'object') return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b))
|
|
27
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(',')}}`;
|
|
28
|
+
return JSON.stringify(value);
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HistoryChange } from './change-comparison.js';
|
|
2
|
+
import { formatNumber } from '@hearthlog/ui/utils/number-format.js';
|
|
3
|
+
|
|
4
|
+
export interface ChangedField {
|
|
5
|
+
key: string;
|
|
6
|
+
label: string;
|
|
7
|
+
before: string;
|
|
8
|
+
after: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const hiddenFields = new Set(['id', 'tenant_id', 'log_id', 'created_at', 'updated_at']);
|
|
12
|
+
|
|
13
|
+
function sameValue(left: unknown, right: unknown): boolean {
|
|
14
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function fieldChanges(change: HistoryChange): ChangedField[] {
|
|
18
|
+
const before = change.before ?? {};
|
|
19
|
+
const after = change.after ?? {};
|
|
20
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
21
|
+
.filter(key => !hiddenFields.has(key) && !sameValue(before[key], after[key]))
|
|
22
|
+
.sort((left, right) => left.localeCompare(right))
|
|
23
|
+
.map(key => ({
|
|
24
|
+
key,
|
|
25
|
+
label: key.replace(/_/g, ' ').replace(/\b\w/g, letter => letter.toUpperCase()),
|
|
26
|
+
before: displayHistoryValue(before[key]),
|
|
27
|
+
after: displayHistoryValue(after[key]),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function displayHistoryValue(value: unknown): string {
|
|
32
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
33
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
|
34
|
+
if (typeof value === 'number') return formatNumber(value, 4);
|
|
35
|
+
if (typeof value === 'string') return value;
|
|
36
|
+
return JSON.stringify(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function changeVerb(kind: HistoryChange['kind']): string {
|
|
40
|
+
return kind === 'added' ? 'Added' : kind === 'removed' ? 'Removed' : 'Changed';
|
|
41
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { FeatureId } from '@hearthlog/contracts/features/registry';
|
|
2
|
+
|
|
3
|
+
export interface HistoryEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
version: number | null;
|
|
8
|
+
previousVersion: number;
|
|
9
|
+
eventCount: number;
|
|
10
|
+
feature: FeatureId | null;
|
|
11
|
+
/** Who wrote the action, as Ledger recorded it; null for older or local-only actions. */
|
|
12
|
+
actor: string | null;
|
|
13
|
+
status: 'ready' | 'waiting' | 'attention';
|
|
14
|
+
reason: string | null;
|
|
15
|
+
startsDay: boolean;
|
|
16
|
+
total: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A row of the history list: an action, or a place where a log starts. */
|
|
20
|
+
export type HistoryItem =
|
|
21
|
+
| (HistoryEntry & { kind: 'action'; copied: boolean })
|
|
22
|
+
| { kind: 'marker'; id: string; markerKey: string; total: number };
|
|
23
|
+
|
|
24
|
+
/** An action as a point on the log map. */
|
|
25
|
+
export interface HistoryPoint {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
timestamp: number;
|
|
29
|
+
version: number | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface HistoryListOptions {
|
|
33
|
+
search?: string;
|
|
34
|
+
feature?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
/** Keep loading until this action is in the list. */
|
|
37
|
+
through?: string | null;
|
|
38
|
+
/** Actions at or below this version were copied from the source log. */
|
|
39
|
+
sourceVersion?: number | null;
|
|
40
|
+
/** Fork points to place between actions; left out while searching or filtering. */
|
|
41
|
+
markers?: { key: string; version: number }[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HistoryField {
|
|
45
|
+
key: string;
|
|
46
|
+
label: string;
|
|
47
|
+
before: unknown;
|
|
48
|
+
after: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HistoryRecord {
|
|
52
|
+
key: string;
|
|
53
|
+
label: string;
|
|
54
|
+
kind: 'added' | 'changed' | 'removed' | 'saved';
|
|
55
|
+
fields: HistoryField[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ForkReview {
|
|
59
|
+
keptActions: number;
|
|
60
|
+
laterActions: number;
|
|
61
|
+
features: string;
|
|
62
|
+
localHead: number;
|
|
63
|
+
}
|