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