@hearthlog/runtime 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/access/session.ts +223 -0
  4. package/src/access/user-session.ts +61 -0
  5. package/src/ai/decisions.ts +89 -0
  6. package/src/calendar/draft.ts +84 -0
  7. package/src/calendar/feeds.ts +91 -0
  8. package/src/calendar/groups.ts +198 -0
  9. package/src/calendar/logic.ts +141 -0
  10. package/src/calendar/preferences.ts +40 -0
  11. package/src/calendar/weeks.ts +56 -0
  12. package/src/commands/index.ts +184 -0
  13. package/src/commands/local-commit.ts +214 -0
  14. package/src/commands/standard.ts +8 -0
  15. package/src/commands/validation.ts +206 -0
  16. package/src/db/bootstrap.ts +72 -0
  17. package/src/db/calendar.ts +638 -0
  18. package/src/db/dynamic-ddl.ts +127 -0
  19. package/src/db/entities.ts +208 -0
  20. package/src/db/history.ts +194 -0
  21. package/src/db/index.ts +1240 -0
  22. package/src/db/kv.ts +33 -0
  23. package/src/db/local-statistics.ts +106 -0
  24. package/src/db/module-projections.ts +46 -0
  25. package/src/db/raw-browser.ts +361 -0
  26. package/src/db/read-repositories.ts +29 -0
  27. package/src/db/read-session.ts +47 -0
  28. package/src/db/schema-version.ts +76 -0
  29. package/src/db/session-context.ts +33 -0
  30. package/src/event/code-export.ts +65 -0
  31. package/src/event/dynamic-entities.ts +503 -0
  32. package/src/event/dynamic-materializer.ts +288 -0
  33. package/src/event/handlers.ts +490 -0
  34. package/src/event/index.ts +53 -0
  35. package/src/event/meta-entity-types.ts +165 -0
  36. package/src/event/projection-builder.ts +384 -0
  37. package/src/event/queries.ts +126 -0
  38. package/src/event/rebuild.ts +176 -0
  39. package/src/event/reference-lookup.ts +93 -0
  40. package/src/event/registry.ts +158 -0
  41. package/src/event/schema-effects.ts +45 -0
  42. package/src/event/types.ts +20 -0
  43. package/src/history/action-boundary.ts +18 -0
  44. package/src/history/action-detail.ts +23 -0
  45. package/src/history/change-comparison.ts +29 -0
  46. package/src/history/change-display.ts +41 -0
  47. package/src/history/contracts.ts +63 -0
  48. package/src/history/history.ts +372 -0
  49. package/src/history/legacy-replay.ts +69 -0
  50. package/src/history/legacy-url.ts +9 -0
  51. package/src/history/lineage.ts +123 -0
  52. package/src/history/log-format.ts +17 -0
  53. package/src/history/log-map.ts +110 -0
  54. package/src/history/navigation.ts +14 -0
  55. package/src/history/point-snapshot.ts +229 -0
  56. package/src/history/timeline.ts +215 -0
  57. package/src/history/url.ts +18 -0
  58. package/src/ledger/README.md +118 -0
  59. package/src/ledger/admin-client.ts +616 -0
  60. package/src/ledger/client.ts +10 -0
  61. package/src/ledger/config.ts +117 -0
  62. package/src/ledger/entity-id.ts +115 -0
  63. package/src/ledger/errors.ts +144 -0
  64. package/src/ledger/http-client.ts +590 -0
  65. package/src/ledger/index.ts +67 -0
  66. package/src/ledger/socket.ts +400 -0
  67. package/src/ledger/types.ts +202 -0
  68. package/src/ledger/version-tracker-singleton.ts +12 -0
  69. package/src/ledger/version-tracker.ts +259 -0
  70. package/src/log-management.ts +123 -0
  71. package/src/log-validation.ts +19 -0
  72. package/src/native/reminders.ts +2 -0
  73. package/src/native/shell.svelte.ts +136 -0
  74. package/src/native/shell.ts +102 -0
  75. package/src/pwa/registration.ts +169 -0
  76. package/src/recovery/local-recovery.ts +59 -0
  77. package/src/recovery/reset-sequence.ts +13 -0
  78. package/src/runtime/clock.ts +43 -0
  79. package/src/runtime/kept-page-data.svelte.ts +42 -0
  80. package/src/runtime/page-data.svelte.ts +160 -0
  81. package/src/runtime/phone-header.svelte.ts +17 -0
  82. package/src/runtime/read-coordinator.ts +103 -0
  83. package/src/runtime/refresh.ts +15 -0
  84. package/src/runtime/start-page.ts +56 -0
  85. package/src/runtime/view.ts +83 -0
  86. package/src/runtime/workspace.ts +62 -0
  87. package/src/storage/indexeddb-statistics.ts +153 -0
  88. package/src/store/attention.svelte.ts +51 -0
  89. package/src/store/conflict-merge.ts +169 -0
  90. package/src/store/entity-sync-state.svelte.ts +36 -0
  91. package/src/store/event-log.svelte.ts +109 -0
  92. package/src/store/events.svelte.ts +14 -0
  93. package/src/store/local-context.ts +93 -0
  94. package/src/store/local-store.svelte.ts +58 -0
  95. package/src/store/log-version.svelte.ts +35 -0
  96. package/src/store/logs.svelte.ts +60 -0
  97. package/src/store/moved.svelte.ts +43 -0
  98. package/src/store/offline-readiness.svelte.ts +188 -0
  99. package/src/store/outbox-request.ts +61 -0
  100. package/src/store/outbox.ts +426 -0
  101. package/src/store/pending-work-transfer.ts +49 -0
  102. package/src/store/pending-work.ts +143 -0
  103. package/src/store/sync-coordinator.ts +170 -0
  104. package/src/store/sync-invalidation.ts +10 -0
  105. package/src/store/sync-issues.ts +406 -0
  106. package/src/store/sync-progress.ts +54 -0
  107. package/src/store/sync-state.ts +127 -0
  108. package/src/store/sync.svelte.ts +1010 -0
  109. package/src/store/tab-coordination.ts +164 -0
  110. package/src/testing/e2e-seed.ts +40 -0
  111. package/src/testing/local-database.ts +120 -0
  112. package/src/testing/projection-database.ts +105 -0
  113. package/src/testing/test-entities.ts +28 -0
  114. package/src/theme/appearance.svelte.ts +111 -0
  115. package/src/utils/address.ts +5 -0
  116. package/src/utils/async.ts +30 -0
  117. package/src/utils/long-press.ts +70 -0
  118. package/src/utils/perf-dataset.ts +405 -0
  119. package/src/utils/perf.ts +627 -0
  120. package/README.md +0 -3
@@ -0,0 +1,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
+ }