@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,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic DDL Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates SQL DDL for dynamic entity tables at runtime.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DynamicFieldDefinition } from '../event/meta-entity-types.js';
|
|
8
|
+
import { qualifiedIdentifier, quoteIdentifier } from '../event/projection-builder.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Entity type record from the entity_type_definitions table
|
|
12
|
+
*/
|
|
13
|
+
export interface EntityTypeRecord {
|
|
14
|
+
tenant_id: string;
|
|
15
|
+
log_id: string;
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
display_name: string;
|
|
19
|
+
table_name: string;
|
|
20
|
+
fields_json: string;
|
|
21
|
+
calendar_json: string | null;
|
|
22
|
+
is_archived: number;
|
|
23
|
+
created_at: string;
|
|
24
|
+
updated_at: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// DDL generation
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Map DynamicFieldType to SQLite type
|
|
33
|
+
*/
|
|
34
|
+
function getSQLiteType(type: DynamicFieldDefinition['type']): string {
|
|
35
|
+
switch (type) {
|
|
36
|
+
case 'number':
|
|
37
|
+
return 'REAL';
|
|
38
|
+
case 'boolean':
|
|
39
|
+
return 'INTEGER'; // SQLite uses 0/1 for booleans
|
|
40
|
+
case 'string':
|
|
41
|
+
case 'reference':
|
|
42
|
+
case 'datetime':
|
|
43
|
+
default:
|
|
44
|
+
return 'TEXT';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert a default value to SQL-safe format
|
|
50
|
+
*/
|
|
51
|
+
function toSQLDefault(value: string | number | boolean | null | undefined): string {
|
|
52
|
+
if (value === null || value === undefined) {
|
|
53
|
+
return 'NULL';
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'boolean') {
|
|
56
|
+
return value ? '1' : '0';
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === 'number') {
|
|
59
|
+
return String(value);
|
|
60
|
+
}
|
|
61
|
+
// Only allow the two deterministic defaults emitted by the entity editor.
|
|
62
|
+
if (/^(?:date|datetime)\('now'\)$/.test(value)) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
// String value - escape quotes
|
|
66
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse the stored field definitions defensively: a malformed row must not
|
|
71
|
+
* crash table creation with an opaque SyntaxError.
|
|
72
|
+
*/
|
|
73
|
+
function parseStoredFields(record: EntityTypeRecord): DynamicFieldDefinition[] {
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(record.fields_json);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Dynamic entity "${record.name}" has malformed field definitions`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(parsed)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Dynamic entity "${record.name}" has malformed field definitions`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return parsed as DynamicFieldDefinition[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Generate DDL for a dynamic entity table
|
|
92
|
+
*/
|
|
93
|
+
export function generateDynamicTableDDL(record: EntityTypeRecord, schema?: string): string {
|
|
94
|
+
const fields = parseStoredFields(record);
|
|
95
|
+
|
|
96
|
+
const columns = fields.map(f => {
|
|
97
|
+
const sqlType = getSQLiteType(f.type);
|
|
98
|
+
const nullable = f.required ? ' NOT NULL' : '';
|
|
99
|
+
const defaultVal = f.default !== undefined ? ` DEFAULT ${toSQLDefault(f.default)}` : '';
|
|
100
|
+
return ` ${quoteIdentifier(f.name)} ${sqlType}${nullable}${defaultVal}`;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return `
|
|
104
|
+
CREATE TABLE IF NOT EXISTS ${qualifiedIdentifier(schema, record.table_name)} (
|
|
105
|
+
"tenant_id" TEXT NOT NULL,
|
|
106
|
+
"log_id" TEXT NOT NULL,
|
|
107
|
+
"id" TEXT NOT NULL,
|
|
108
|
+
${columns.join(',\n')},
|
|
109
|
+
"created_at" TEXT DEFAULT (datetime('now')),
|
|
110
|
+
"updated_at" TEXT DEFAULT (datetime('now')),
|
|
111
|
+
PRIMARY KEY ("tenant_id", "log_id", "id")
|
|
112
|
+
);`.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Generate DDL to add a new column to an existing table
|
|
117
|
+
*/
|
|
118
|
+
export function generateAddColumnDDL(
|
|
119
|
+
tableName: string,
|
|
120
|
+
field: DynamicFieldDefinition,
|
|
121
|
+
schema?: string,
|
|
122
|
+
): string {
|
|
123
|
+
const sqlType = getSQLiteType(field.type);
|
|
124
|
+
const defaultVal = field.default !== undefined ? ` DEFAULT ${toSQLDefault(field.default)}` : '';
|
|
125
|
+
// Note: SQLite doesn't support NOT NULL on ALTER TABLE ADD COLUMN without a default
|
|
126
|
+
return `ALTER TABLE ${qualifiedIdentifier(schema, tableName)} ADD COLUMN ${quoteIdentifier(field.name)} ${sqlType}${defaultVal};`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { normalizeCalendarSources, serializeCalendarConfig } from '@hearthlog/contracts/calendar/config';
|
|
3
|
+
import type { EntityTypeRecord } from './dynamic-ddl.js';
|
|
4
|
+
import { ENTITY_DEFINITIONS, getEntityDefinition, getPayloadKey } from '@hearthlog/contracts/event/entity-definitions';
|
|
5
|
+
import { parseEntityFields } from '../event/dynamic-entities.js';
|
|
6
|
+
import type { DynamicFieldDefinition } from '../event/meta-entity-types.js';
|
|
7
|
+
import { quoteIdentifier } from '../event/projection-builder.js';
|
|
8
|
+
import type { ReadModelSession } from './read-session.js';
|
|
9
|
+
import { withTimeout } from '../utils/async.js';
|
|
10
|
+
import { tenant } from '@hearthlog/contracts/tenant/config';
|
|
11
|
+
|
|
12
|
+
const DB_TIMEOUT_MS = 5000;
|
|
13
|
+
|
|
14
|
+
export type EntityTypeWithStatic = EntityTypeRecord & { is_static?: boolean };
|
|
15
|
+
|
|
16
|
+
export interface EntityQuery {
|
|
17
|
+
search: string;
|
|
18
|
+
pageSize: number;
|
|
19
|
+
requestedPage: number;
|
|
20
|
+
sortField: string | null;
|
|
21
|
+
sortDirection: 'asc' | 'desc';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createEntityRepository(session: ReadModelSession) {
|
|
25
|
+
const { scope } = session;
|
|
26
|
+
|
|
27
|
+
async function getDynamicDefinition(name: string): Promise<EntityTypeRecord | null> {
|
|
28
|
+
const record = await session.selectFrom('entity_type_definitions')
|
|
29
|
+
.selectAll()
|
|
30
|
+
.where('name', '=', name)
|
|
31
|
+
.where('is_archived', '=', 0)
|
|
32
|
+
.executeTakeFirst();
|
|
33
|
+
return record as EntityTypeRecord | undefined ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function staticRecord(name: string): EntityTypeWithStatic | null {
|
|
37
|
+
const definition = getEntityDefinition(name);
|
|
38
|
+
if (!definition) return null;
|
|
39
|
+
return {
|
|
40
|
+
tenant_id: scope.tenantId,
|
|
41
|
+
log_id: scope.logId,
|
|
42
|
+
id: definition.name,
|
|
43
|
+
name: definition.name,
|
|
44
|
+
display_name: definition.displayName ?? titleCase(definition.name),
|
|
45
|
+
table_name: definition.table,
|
|
46
|
+
fields_json: JSON.stringify(definition.fields),
|
|
47
|
+
calendar_json: serializeCalendarConfig(
|
|
48
|
+
normalizeCalendarSources(definition.calendar),
|
|
49
|
+
),
|
|
50
|
+
is_archived: 0,
|
|
51
|
+
created_at: session.view.effectiveNow,
|
|
52
|
+
updated_at: session.view.effectiveNow,
|
|
53
|
+
is_static: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getEntityType(name: string): Promise<{
|
|
58
|
+
record: EntityTypeWithStatic;
|
|
59
|
+
fields: DynamicFieldDefinition[];
|
|
60
|
+
} | null> {
|
|
61
|
+
// Static names are reserved and take precedence over dynamic records.
|
|
62
|
+
// Check the tenant approval before touching the projection so a direct
|
|
63
|
+
// URL/reference lookup cannot query a disabled feature's table.
|
|
64
|
+
const definition = getEntityDefinition(name);
|
|
65
|
+
if (definition) {
|
|
66
|
+
if (!tenant.approvedEntities.entityNames.has(name)) return null;
|
|
67
|
+
const record = staticRecord(name);
|
|
68
|
+
if (!record) return null;
|
|
69
|
+
return {
|
|
70
|
+
record,
|
|
71
|
+
fields: definition.fields.map((field) => ({
|
|
72
|
+
...field,
|
|
73
|
+
payloadKey: getPayloadKey(field),
|
|
74
|
+
})) as DynamicFieldDefinition[],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!tenant.dynamicEntities) return null;
|
|
79
|
+
const dynamic = await getDynamicDefinition(name);
|
|
80
|
+
if (dynamic) {
|
|
81
|
+
return {
|
|
82
|
+
record: dynamic,
|
|
83
|
+
fields: parseEntityFields(dynamic),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function loadReferenceOptions(entityName: string): Promise<Array<{ id: string; label: string }>> {
|
|
90
|
+
const target = await getEntityType(entityName);
|
|
91
|
+
if (!target) return [];
|
|
92
|
+
const displayField = ['name', 'title', 'label'].find((candidate) =>
|
|
93
|
+
target.fields.some((field) => field.name === candidate));
|
|
94
|
+
const columns = ['id', displayField].filter(Boolean) as string[];
|
|
95
|
+
const orderBy = displayField
|
|
96
|
+
? sql`ORDER BY ${sql.raw(quoteIdentifier(displayField))} ASC`
|
|
97
|
+
: sql``;
|
|
98
|
+
const rows = await withTimeout(
|
|
99
|
+
session.execute(sql<Record<string, unknown>>`
|
|
100
|
+
SELECT ${sql.raw(columns.map(quoteIdentifier).join(', '))}
|
|
101
|
+
FROM ${session.table(target.record.table_name)}
|
|
102
|
+
${orderBy}
|
|
103
|
+
LIMIT 100
|
|
104
|
+
`),
|
|
105
|
+
DB_TIMEOUT_MS,
|
|
106
|
+
`Reference query for ${entityName} timed out`,
|
|
107
|
+
);
|
|
108
|
+
return rows.rows.map((row) => ({
|
|
109
|
+
id: String(row.id),
|
|
110
|
+
label: String(row.name ?? row.title ?? row.label ?? row.id),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
async getEntityCatalog(): Promise<EntityTypeWithStatic[]> {
|
|
116
|
+
const dynamic = await withTimeout(
|
|
117
|
+
session.selectFrom('entity_type_definitions')
|
|
118
|
+
.selectAll()
|
|
119
|
+
.where('is_archived', '=', 0)
|
|
120
|
+
.orderBy('display_name', 'asc')
|
|
121
|
+
.execute(),
|
|
122
|
+
DB_TIMEOUT_MS,
|
|
123
|
+
'Entity catalog query timed out',
|
|
124
|
+
);
|
|
125
|
+
// Feature scoping: only entities the tenant is approved for are
|
|
126
|
+
// part of the catalog (dynamic types are per-log and always shown).
|
|
127
|
+
const approvedStatic = ENTITY_DEFINITIONS.filter(
|
|
128
|
+
(definition) => tenant.approvedEntities.entityNames.has(definition.name),
|
|
129
|
+
).map((definition) => staticRecord(definition.name)!);
|
|
130
|
+
return [
|
|
131
|
+
...approvedStatic,
|
|
132
|
+
...(tenant.dynamicEntities ? dynamic as EntityTypeRecord[] : []),
|
|
133
|
+
].sort((left, right) => left.display_name.localeCompare(right.display_name));
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
getEntityType,
|
|
137
|
+
|
|
138
|
+
async queryEntities(entityName: string, query: EntityQuery) {
|
|
139
|
+
const entityType = await getEntityType(entityName);
|
|
140
|
+
if (!entityType) return null;
|
|
141
|
+
const searchableColumns = [...new Set(['id', ...entityType.fields.map((field) => field.name)])];
|
|
142
|
+
const where = buildSearchWhere(searchableColumns, query.search);
|
|
143
|
+
const countRows = await withTimeout(
|
|
144
|
+
session.execute(sql<{ count: number | string }>`
|
|
145
|
+
SELECT COUNT(*) AS count
|
|
146
|
+
FROM ${session.table(entityType.record.table_name)}
|
|
147
|
+
${where}
|
|
148
|
+
`),
|
|
149
|
+
DB_TIMEOUT_MS,
|
|
150
|
+
`Count query for ${entityName} timed out`,
|
|
151
|
+
);
|
|
152
|
+
const totalRows = Number(countRows.rows[0]?.count ?? 0);
|
|
153
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / query.pageSize));
|
|
154
|
+
const currentPage = Math.min(query.requestedPage, totalPages);
|
|
155
|
+
const offset = (currentPage - 1) * query.pageSize;
|
|
156
|
+
const sortableFields = new Set(entityType.fields.map((field) => field.name));
|
|
157
|
+
const sortField = query.sortField && sortableFields.has(query.sortField)
|
|
158
|
+
? query.sortField
|
|
159
|
+
: null;
|
|
160
|
+
const orderBy = sortField
|
|
161
|
+
? sql`ORDER BY ${sql.raw(quoteIdentifier(sortField))} ${sql.raw(query.sortDirection.toUpperCase())}`
|
|
162
|
+
: sql`ORDER BY ${sql.raw(quoteIdentifier('id'))} ASC`;
|
|
163
|
+
const rows = await withTimeout(
|
|
164
|
+
session.execute(sql<Record<string, unknown>>`
|
|
165
|
+
SELECT *
|
|
166
|
+
FROM ${session.table(entityType.record.table_name)}
|
|
167
|
+
${where}
|
|
168
|
+
${orderBy}
|
|
169
|
+
LIMIT ${query.pageSize}
|
|
170
|
+
OFFSET ${offset}
|
|
171
|
+
`),
|
|
172
|
+
DB_TIMEOUT_MS,
|
|
173
|
+
`Query for ${entityName} timed out`,
|
|
174
|
+
);
|
|
175
|
+
const referenceOptionEntries = await Promise.all(
|
|
176
|
+
entityType.fields
|
|
177
|
+
.filter((field) => field.type === 'reference' && field.referenceTo)
|
|
178
|
+
.map(async (field) => [field.name, await loadReferenceOptions(field.referenceTo!)] as const),
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
entityType: entityType.record,
|
|
182
|
+
fields: entityType.fields,
|
|
183
|
+
entities: rows.rows,
|
|
184
|
+
referenceOptions: Object.fromEntries(referenceOptionEntries),
|
|
185
|
+
referencedBy: {},
|
|
186
|
+
search: query.search,
|
|
187
|
+
sortField,
|
|
188
|
+
sortDirection: query.sortDirection,
|
|
189
|
+
currentPage,
|
|
190
|
+
pageSize: query.pageSize,
|
|
191
|
+
totalRows,
|
|
192
|
+
totalPages,
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildSearchWhere(columns: string[], search: string) {
|
|
199
|
+
if (!search) return sql``;
|
|
200
|
+
const pattern = `%${search.replace(/[\\%_]/g, (character) => `\\${character}`)}%`;
|
|
201
|
+
const clauses = columns.map((column) =>
|
|
202
|
+
sql`CAST(${sql.raw(quoteIdentifier(column))} AS TEXT) LIKE ${pattern} ESCAPE ${'\\'}`);
|
|
203
|
+
return sql`WHERE (${sql.join(clauses, sql` OR `)})`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function titleCase(value: string): string {
|
|
207
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
208
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import type { ReadModelSession } from './read-session.js';
|
|
3
|
+
import { ENTITY_DEFINITIONS, getEventPrefix } from '@hearthlog/contracts/event/entity-definitions';
|
|
4
|
+
import { FEATURES } from '@hearthlog/contracts/features/registry';
|
|
5
|
+
import { visibleEventCondition } from '../event/queries.js';
|
|
6
|
+
import { completeActionBoundary } from '../history/action-boundary.js';
|
|
7
|
+
import type { ForkReview, HistoryEntry, HistoryItem, HistoryListOptions, HistoryPoint, HistoryRecord } from '../history/contracts.js';
|
|
8
|
+
import { withTimeout } from '../utils/async.js';
|
|
9
|
+
|
|
10
|
+
// Registry metadata, never user records. All activity selection and aggregation stays in SQL.
|
|
11
|
+
const metadata = sql.join(ENTITY_DEFINITIONS.map(entity => sql`(
|
|
12
|
+
${getEventPrefix(entity)}, ${entity.feature}, ${entity.displayName ?? entity.name.replace(/_/g, ' ')}, ${entity.table.replace(/_/g, ' ')}
|
|
13
|
+
)`));
|
|
14
|
+
const featureLabels = sql.join(FEATURES.map(feature => sql`(${feature.id}, ${feature.page?.label ?? feature.id})`));
|
|
15
|
+
// Records without a name read as their main value, such as "Weight 81.2 kg";
|
|
16
|
+
// entity definitions declare it (`summary`).
|
|
17
|
+
const summarized = ENTITY_DEFINITIONS.filter((entity) => entity.summary);
|
|
18
|
+
const summaries = summarized.length
|
|
19
|
+
? sql`VALUES ${sql.join(summarized.map((entity) => sql`(${getEventPrefix(entity)}, ${entity.summary!.noun}, ${entity.summary!.key}, ${entity.summary!.unit})`))}`
|
|
20
|
+
: sql`SELECT NULL, NULL, NULL, NULL WHERE 0`;
|
|
21
|
+
const order = sql`(version IS NULL) DESC, COALESCE(version, local_sequence) DESC, id DESC`;
|
|
22
|
+
|
|
23
|
+
export function createHistoryRepository(session: ReadModelSession) {
|
|
24
|
+
const base = sql`
|
|
25
|
+
WITH raw_events AS (SELECT * FROM ${session.table('raw_events')}),
|
|
26
|
+
metadata(prefix, feature, entity_label, plural_label) AS (VALUES ${metadata}),
|
|
27
|
+
feature_labels(feature, label) AS (VALUES ${featureLabels}),
|
|
28
|
+
summaries(prefix, noun, key, unit) AS (${summaries}),
|
|
29
|
+
enriched AS (
|
|
30
|
+
SELECT raw_events.*, COALESCE(action_id, id) AS group_id,
|
|
31
|
+
metadata.feature, metadata.entity_label, metadata.plural_label,
|
|
32
|
+
CASE WHEN ${visibleEventCondition()} THEN 1 ELSE 0 END AS visible,
|
|
33
|
+
CASE WHEN log_version IS NOT NULL AND projection_state IN ('effective', 'restored_out')
|
|
34
|
+
AND ${completeActionBoundary} THEN 1 ELSE 0 END AS complete,
|
|
35
|
+
(SELECT CAST(value AS TEXT) FROM raw_events AS prior, json_each(prior.payload)
|
|
36
|
+
WHERE COALESCE(prior.entity_id, json_extract(prior.payload, '$.id'), prior.id)
|
|
37
|
+
= COALESCE(raw_events.entity_id, json_extract(raw_events.payload, '$.id'), raw_events.id)
|
|
38
|
+
AND key IN ('title', 'name', 'invoiceNumber', 'number', 'description', 'label', 'filename', 'fileName')
|
|
39
|
+
AND json_each.type = 'text' AND trim(CAST(value AS TEXT)) != ''
|
|
40
|
+
AND (prior.id = raw_events.id OR (prior.projection_state IN ('effective', 'restored_out')
|
|
41
|
+
AND prior.is_tombstoned = 0 AND (
|
|
42
|
+
(raw_events.log_version IS NOT NULL AND prior.log_version <= raw_events.log_version)
|
|
43
|
+
OR (raw_events.log_version IS NULL AND prior.local_sequence <= raw_events.local_sequence))))
|
|
44
|
+
ORDER BY (prior.id = raw_events.id) DESC, prior.log_version DESC, prior.local_sequence DESC,
|
|
45
|
+
CASE key WHEN 'title' THEN 0 WHEN 'name' THEN 1 WHEN 'invoiceNumber' THEN 2 WHEN 'number' THEN 3
|
|
46
|
+
WHEN 'description' THEN 4 ELSE 5 END
|
|
47
|
+
LIMIT 1) AS record_label,
|
|
48
|
+
(SELECT noun || ' ' || printf('%g', json_extract(raw_events.payload, '$.' || key)) || unit FROM summaries
|
|
49
|
+
WHERE substr(raw_events.type, 1, length(prefix)) = prefix
|
|
50
|
+
AND json_type(raw_events.payload, '$.' || key) IN ('integer', 'real')) AS summary_label,
|
|
51
|
+
(SELECT lower(noun) FROM summaries WHERE substr(raw_events.type, 1, length(prefix)) = prefix) AS summary_noun,
|
|
52
|
+
(SELECT operation.last_error_message FROM outbox_operation_items AS item
|
|
53
|
+
JOIN ${session.table('outbox_operations')} AS operation ON operation.id = item.operation_id
|
|
54
|
+
WHERE item.event_id = raw_events.id AND operation.status = 'needs_attention'
|
|
55
|
+
ORDER BY operation.updated_at DESC LIMIT 1) AS sync_error
|
|
56
|
+
FROM raw_events LEFT JOIN metadata ON substr(raw_events.type, 1, length(prefix)) = prefix
|
|
57
|
+
), ranked AS (
|
|
58
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY group_id
|
|
59
|
+
ORDER BY log_version DESC, action_index DESC, local_sequence DESC, id DESC) AS rank,
|
|
60
|
+
COUNT(*) OVER (PARTITION BY group_id) AS member_count,
|
|
61
|
+
MAX(visible) OVER (PARTITION BY group_id) AS has_visible,
|
|
62
|
+
MAX(CASE WHEN sync_state IN ('conflicted', 'rejected') OR projection_state = 'excluded'
|
|
63
|
+
OR sync_error IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY group_id) AS attention,
|
|
64
|
+
MAX(COALESCE(rejection_detail, rejection_code, sync_error)) OVER (PARTITION BY group_id) AS issue,
|
|
65
|
+
MIN(log_version) OVER (PARTITION BY group_id) AS first_version
|
|
66
|
+
FROM enriched
|
|
67
|
+
), actions AS (
|
|
68
|
+
SELECT id, group_id, type, timestamp, log_version AS version, local_sequence, feature, actor,
|
|
69
|
+
COALESCE(action_size, member_count) AS eventCount,
|
|
70
|
+
COALESCE(first_version - 1, 0) AS previousVersion,
|
|
71
|
+
CASE WHEN attention = 1 THEN 'attention' WHEN complete = 1 THEN 'ready' ELSE 'waiting' END AS status,
|
|
72
|
+
CASE WHEN attention = 1 THEN COALESCE(issue, 'Open sync issues to review this action.')
|
|
73
|
+
WHEN complete = 1 THEN NULL
|
|
74
|
+
WHEN log_version IS NULL THEN 'Saved on this device. Waiting to sync.'
|
|
75
|
+
ELSE 'This action is still arriving. Wait for sync to finish.' END AS reason,
|
|
76
|
+
CASE WHEN COALESCE(action_size, member_count) > 1 THEN
|
|
77
|
+
CASE WHEN record_label IS NOT NULL AND (
|
|
78
|
+
SELECT COUNT(DISTINCT COALESCE(entity_id, json_extract(payload, '$.id'), id))
|
|
79
|
+
FROM enriched AS member WHERE member.group_id = ranked.group_id
|
|
80
|
+
) = 1 THEN 'Edited ' || record_label
|
|
81
|
+
WHEN (SELECT COUNT(DISTINCT type) FROM enriched AS member WHERE member.group_id = ranked.group_id) = 1
|
|
82
|
+
THEN CASE WHEN type LIKE '%_ADDED' THEN 'Added ' WHEN type LIKE '%_REMOVED' THEN 'Removed ' ELSE 'Edited ' END
|
|
83
|
+
|| member_count || ' ' || COALESCE(plural_label, 'records')
|
|
84
|
+
ELSE COALESCE((SELECT label FROM feature_labels WHERE feature_labels.feature = ranked.feature), 'Log') || ' action' END
|
|
85
|
+
WHEN type = 'WORKSPACE_RESTORED' THEN 'Restored log'
|
|
86
|
+
WHEN type = 'EVENTS_EXCISED' THEN 'Removed events'
|
|
87
|
+
WHEN type = 'EVENT_TOMBSTONED' THEN 'Undid a change'
|
|
88
|
+
ELSE CASE WHEN type LIKE '%_ADDED' THEN 'Added '
|
|
89
|
+
WHEN type LIKE '%_UPDATED' THEN 'Edited '
|
|
90
|
+
WHEN type LIKE '%_REMOVED' OR type LIKE '%_DELETED' THEN 'Removed '
|
|
91
|
+
ELSE 'Changed ' END || COALESCE(record_label, summary_label, summary_noun, entity_label, lower(replace(type, '_', ' '))) END AS label
|
|
92
|
+
FROM ranked WHERE rank = 1 AND has_visible = 1
|
|
93
|
+
)`;
|
|
94
|
+
|
|
95
|
+
function execute<T>(query: import('kysely').RawBuilder<T>) {
|
|
96
|
+
return withTimeout(session.execute(query), 15_000, 'History could not be loaded. Try again.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
async changes(entry: HistoryEntry): Promise<HistoryRecord[]> {
|
|
101
|
+
if (entry.status !== 'ready' || entry.version === null) throw new Error('Choose a complete synced action.');
|
|
102
|
+
const { readActionChanges } = await import('../history/point-snapshot.js');
|
|
103
|
+
return withTimeout(readActionChanges(entry.previousVersion, entry.version, session.scope), 30_000, 'Could not inspect this action. Try again.');
|
|
104
|
+
},
|
|
105
|
+
async list(options: HistoryListOptions = {}): Promise<HistoryItem[]> {
|
|
106
|
+
const search = options.search?.trim() ?? '';
|
|
107
|
+
const feature = options.feature ?? '';
|
|
108
|
+
const markers = search || feature ? [] : options.markers ?? [];
|
|
109
|
+
const marks = markers.length
|
|
110
|
+
? sql`VALUES ${sql.join(markers.map(marker => sql`(${marker.key}, ${marker.version})`))}`
|
|
111
|
+
: sql`SELECT NULL, NULL WHERE 0`;
|
|
112
|
+
const { rows } = await execute<(Omit<HistoryEntry, 'copied'> & { kind: 'action'; copied: number })
|
|
113
|
+
| Extract<HistoryItem, { kind: 'marker' }>>(sql`${base}, filtered AS (
|
|
114
|
+
SELECT *, CASE WHEN date(timestamp / 1000, 'unixepoch', 'localtime') IS
|
|
115
|
+
LAG(date(timestamp / 1000, 'unixepoch', 'localtime')) OVER (ORDER BY ${order})
|
|
116
|
+
THEN 0 ELSE 1 END AS startsDay
|
|
117
|
+
FROM actions WHERE (${feature} = '' OR EXISTS (
|
|
118
|
+
SELECT 1 FROM enriched AS member WHERE member.group_id = actions.group_id AND member.feature = ${feature}
|
|
119
|
+
)) AND (${search} = '' OR instr(lower(label), lower(${search})) > 0
|
|
120
|
+
OR EXISTS (SELECT 1 FROM enriched AS member WHERE member.group_id = actions.group_id
|
|
121
|
+
AND instr(lower(member.payload || ' ' || member.type), lower(${search})) > 0))
|
|
122
|
+
), marks(marker_key, version) AS (${marks}),
|
|
123
|
+
items AS (
|
|
124
|
+
SELECT 'action' AS kind, id, NULL AS markerKey, label, timestamp, version, previousVersion, eventCount,
|
|
125
|
+
feature, actor, status, reason, startsDay,
|
|
126
|
+
CASE WHEN version IS NOT NULL AND version <= ${options.sourceVersion ?? -1} THEN 1 ELSE 0 END AS copied,
|
|
127
|
+
CASE WHEN version IS NULL THEN 1 ELSE 0 END AS pending,
|
|
128
|
+
COALESCE(version, local_sequence) * 2 AS position
|
|
129
|
+
FROM filtered
|
|
130
|
+
UNION ALL
|
|
131
|
+
-- A log that shares version v sits between action v and the one after it.
|
|
132
|
+
SELECT 'marker', 'marker:' || marker_key, marker_key, NULL, NULL, version, NULL, NULL,
|
|
133
|
+
NULL, NULL, NULL, NULL, 0, 0, 0, version * 2 + 1 FROM marks
|
|
134
|
+
), numbered AS (
|
|
135
|
+
SELECT *, ROW_NUMBER() OVER (ORDER BY pending DESC, position DESC, id DESC) AS row_number,
|
|
136
|
+
COUNT(*) OVER () AS total
|
|
137
|
+
FROM items
|
|
138
|
+
) SELECT * FROM numbered
|
|
139
|
+
WHERE row_number <= MAX(${options.limit ?? 60},
|
|
140
|
+
COALESCE((SELECT row_number FROM numbered WHERE id = ${options.through ?? ''}), 0) + 20)
|
|
141
|
+
ORDER BY row_number`);
|
|
142
|
+
return rows.map(row => row.kind === 'action' ? { ...row, copied: Boolean(row.copied) } : row);
|
|
143
|
+
},
|
|
144
|
+
/** The newest synced action, where Copy this log starts. */
|
|
145
|
+
async latest(): Promise<HistoryEntry | null> {
|
|
146
|
+
const { rows } = await execute<HistoryEntry>(sql`${base} SELECT *, 0 AS total, 0 AS startsDay FROM actions
|
|
147
|
+
WHERE status = 'ready' ORDER BY version DESC LIMIT 1`);
|
|
148
|
+
return rows[0] ?? null;
|
|
149
|
+
},
|
|
150
|
+
/** When the newest action happened, so the log map can end there. */
|
|
151
|
+
async newest(): Promise<number | null> {
|
|
152
|
+
const { rows } = await execute<{ newest: number | null }>(sql`${base} SELECT MAX(timestamp) AS newest FROM actions`);
|
|
153
|
+
return rows[0]?.newest ?? null;
|
|
154
|
+
},
|
|
155
|
+
/** Actions within a time span, newest first, for the log map's points. */
|
|
156
|
+
async points(since: number, until: number, limit = 3000): Promise<HistoryPoint[]> {
|
|
157
|
+
const { rows } = await execute<HistoryPoint>(sql`${base}
|
|
158
|
+
SELECT id, label, timestamp, version FROM actions WHERE timestamp >= ${since} AND timestamp <= ${until}
|
|
159
|
+
ORDER BY timestamp DESC, id DESC LIMIT ${limit}`);
|
|
160
|
+
return rows;
|
|
161
|
+
},
|
|
162
|
+
async get(selection: { id?: string; version?: number }): Promise<HistoryEntry | null> {
|
|
163
|
+
const { rows } = await execute<HistoryEntry>(sql`${base} SELECT *, 0 AS total, 0 AS startsDay FROM actions
|
|
164
|
+
WHERE ${selection.id ? sql`id = ${selection.id}` : sql`version = ${selection.version ?? -1}`} LIMIT 1`);
|
|
165
|
+
return rows[0] ?? null;
|
|
166
|
+
},
|
|
167
|
+
async review(targetVersion: number): Promise<ForkReview> {
|
|
168
|
+
const { rows } = await execute<ForkReview>(sql`${base}, later AS (
|
|
169
|
+
SELECT DISTINCT group_id FROM enriched WHERE log_version > ${targetVersion}
|
|
170
|
+
AND projection_state IN ('effective', 'restored_out')
|
|
171
|
+
), kept AS (
|
|
172
|
+
SELECT DISTINCT group_id FROM enriched WHERE log_version <= ${targetVersion}
|
|
173
|
+
AND projection_state IN ('effective', 'restored_out')
|
|
174
|
+
) SELECT (SELECT COUNT(*) FROM kept) AS keptActions, (SELECT COUNT(*) FROM later) AS laterActions,
|
|
175
|
+
COALESCE((SELECT group_concat(label, ', ') FROM (
|
|
176
|
+
SELECT DISTINCT COALESCE(feature_labels.label, 'Other changes') AS label
|
|
177
|
+
FROM enriched LEFT JOIN feature_labels ON feature_labels.feature = enriched.feature
|
|
178
|
+
WHERE log_version > ${targetVersion} AND projection_state IN ('effective', 'restored_out') ORDER BY label
|
|
179
|
+
)), '') AS features,
|
|
180
|
+
COALESCE((SELECT MAX(log_version) FROM raw_events), 0) AS localHead`);
|
|
181
|
+
return rows[0];
|
|
182
|
+
},
|
|
183
|
+
async savedValues(id: string): Promise<HistoryRecord[]> {
|
|
184
|
+
const { rows } = await execute<{ key: string; label: string; fields: string }>(sql`${base}
|
|
185
|
+
SELECT member.id AS key, COALESCE(member.record_label, member.entity_label, member.type) AS label,
|
|
186
|
+
(SELECT json_group_array(json_object('key', key, 'label', replace(key, '_', ' '), 'after', value))
|
|
187
|
+
FROM (SELECT key, value FROM json_each(member.payload)
|
|
188
|
+
WHERE key NOT IN ('id', 'tenant_id', 'log_id', 'created_at', 'updated_at') ORDER BY key)) AS fields
|
|
189
|
+
FROM enriched AS member WHERE member.group_id = (SELECT group_id FROM actions WHERE id = ${id})
|
|
190
|
+
ORDER BY member.action_index, member.local_sequence, member.id`);
|
|
191
|
+
return rows.map(row => ({ ...row, kind: 'saved', fields: JSON.parse(row.fields) }));
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|