@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.
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,372 @@
1
+ import { db, requireActiveContext } from '../db/index.js';
2
+ import { completeActionBoundary } from './action-boundary.js';
3
+ import { isVisibleEventType } from '../event/queries.js';
4
+ import { EVENTS_EXCISED, WORKSPACE_RESTORED } from '@hearthlog/contracts/history/replay-selection';
5
+ import { sql, type SqlBool } from 'kysely';
6
+
7
+ export interface HistoryPoint {
8
+ version: number;
9
+ id: string;
10
+ type: string;
11
+ timestamp: number;
12
+ entityId: string | null;
13
+ label: string;
14
+ actionId: string;
15
+ eventCount: number;
16
+ }
17
+
18
+ export interface HistoryActivity {
19
+ id: string;
20
+ label: string;
21
+ timestamp: number;
22
+ version: number | null;
23
+ eventCount: number;
24
+ status: 'ready' | 'waiting' | 'attention';
25
+ reason: string | null;
26
+ point: HistoryPoint | null;
27
+ }
28
+
29
+ type HistoryPointRow = {
30
+ id: string;
31
+ type: string;
32
+ timestamp: number;
33
+ payload: string;
34
+ entity_id: string | null;
35
+ log_version: number | null;
36
+ action_id: string | null;
37
+ action_size: number | null;
38
+ };
39
+
40
+ type HistoryActivityRow = HistoryPointRow & {
41
+ local_sequence: number;
42
+ activity_event_count: number;
43
+ activity_status: HistoryActivity['status'];
44
+ sync_error_message: string | null;
45
+ rejection_code: string | null;
46
+ rejection_detail: string | null;
47
+ projection_state: string;
48
+ };
49
+
50
+ /** List visible log activity, including actions that are not ready to fork. */
51
+ export async function listHistoryActivity(): Promise<HistoryActivity[]> {
52
+ const context = await requireActiveContext();
53
+ const visibleTypes = await visibleHistoryTypes(context);
54
+ if (visibleTypes.length === 0) return [];
55
+
56
+ const { rows } = await sql<HistoryActivityRow>`
57
+ WITH scoped_activity AS (
58
+ SELECT
59
+ raw_events.id,
60
+ raw_events.type,
61
+ raw_events.timestamp,
62
+ raw_events.payload,
63
+ raw_events.entity_id,
64
+ raw_events.log_version,
65
+ raw_events.action_id,
66
+ raw_events.action_size,
67
+ raw_events.local_sequence,
68
+ raw_events.sync_state,
69
+ raw_events.projection_state,
70
+ raw_events.rejection_code,
71
+ raw_events.rejection_detail,
72
+ COALESCE(raw_events.action_id, raw_events.id) AS activity_group,
73
+ ROW_NUMBER() OVER (
74
+ PARTITION BY COALESCE(raw_events.action_id, raw_events.id)
75
+ ORDER BY raw_events.local_sequence DESC, raw_events.timestamp DESC, raw_events.id DESC
76
+ ) AS activity_rank,
77
+ COUNT(*) OVER (
78
+ PARTITION BY COALESCE(raw_events.action_id, raw_events.id)
79
+ ) AS activity_event_count,
80
+ MAX(CASE
81
+ WHEN raw_events.sync_state IN ('conflicted', 'rejected')
82
+ OR raw_events.projection_state = 'excluded'
83
+ OR EXISTS (
84
+ SELECT 1
85
+ FROM outbox_operation_items AS item
86
+ JOIN outbox_operations AS operation ON operation.id = item.operation_id
87
+ WHERE item.event_id = raw_events.id
88
+ AND operation.status = 'needs_attention'
89
+ )
90
+ THEN 1 ELSE 0
91
+ END) OVER (
92
+ PARTITION BY COALESCE(raw_events.action_id, raw_events.id)
93
+ ) AS has_attention,
94
+ CASE
95
+ WHEN raw_events.log_version IS NOT NULL
96
+ AND raw_events.projection_state IN ('effective', 'restored_out')
97
+ AND ${completeActionBoundary}
98
+ THEN 1 ELSE 0
99
+ END AS is_forkable,
100
+ (
101
+ SELECT operation.last_error_message
102
+ FROM outbox_operation_items AS item
103
+ JOIN outbox_operations AS operation ON operation.id = item.operation_id
104
+ WHERE item.event_id = raw_events.id
105
+ AND operation.status = 'needs_attention'
106
+ ORDER BY operation.updated_at DESC
107
+ LIMIT 1
108
+ ) AS sync_error_message
109
+ FROM raw_events
110
+ WHERE raw_events.tenant_id = ${context.tenantId}
111
+ AND raw_events.log_id = ${context.logId}
112
+ ),
113
+ classified_activity AS (
114
+ SELECT *, CASE
115
+ WHEN is_forkable = 1 THEN 'ready'
116
+ WHEN has_attention = 1 THEN 'attention'
117
+ ELSE 'waiting'
118
+ END AS activity_status
119
+ FROM scoped_activity
120
+ )
121
+ SELECT
122
+ activity.id,
123
+ activity.type,
124
+ activity.timestamp,
125
+ activity.payload,
126
+ activity.entity_id,
127
+ activity.log_version,
128
+ activity.action_id,
129
+ activity.action_size,
130
+ activity.local_sequence,
131
+ activity.activity_event_count,
132
+ activity.activity_status,
133
+ activity.sync_error_message,
134
+ activity.rejection_code,
135
+ activity.rejection_detail,
136
+ activity.projection_state
137
+ FROM classified_activity AS activity
138
+ WHERE activity.activity_rank = 1
139
+ AND EXISTS (
140
+ SELECT 1 FROM scoped_activity AS visible_member
141
+ WHERE visible_member.activity_group = activity.activity_group
142
+ AND visible_member.type IN (${sql.join(visibleTypes)})
143
+ )
144
+ ORDER BY COALESCE(activity.log_version, activity.local_sequence) DESC,
145
+ activity.timestamp DESC,
146
+ activity.local_sequence DESC,
147
+ activity.id DESC
148
+ `.execute(db);
149
+
150
+ return rows.map((row) => {
151
+ const eventCount = row.action_size ?? row.activity_event_count;
152
+ const point = row.activity_status === 'ready' && row.log_version !== null
153
+ ? toHistoryPoint({ ...row, action_size: eventCount, log_version: row.log_version })
154
+ : null;
155
+ const label = eventCount > 1
156
+ ? `Action (${eventCount} changes)`
157
+ : directiveLabel(row) ?? descriptiveEventLabel(row);
158
+ const reason = row.activity_status === 'ready'
159
+ ? null
160
+ : row.activity_status === 'attention'
161
+ ? row.rejection_detail?.trim() || row.rejection_code?.replace(/_/g, ' ') || row.sync_error_message?.trim() || (row.projection_state === 'excluded'
162
+ ? 'This event was excluded during sync. Open sync issues to review it.'
163
+ : 'Resolve this sync issue before forking.')
164
+ : row.log_version !== null
165
+ ? 'This action is still arriving. Wait for sync to finish before forking.'
166
+ : 'Waiting to sync. Sync this log before forking.';
167
+
168
+ return {
169
+ id: row.id,
170
+ label,
171
+ timestamp: row.timestamp,
172
+ version: row.log_version,
173
+ eventCount,
174
+ status: row.activity_status,
175
+ reason,
176
+ point,
177
+ };
178
+ });
179
+ }
180
+
181
+ export async function listHistoryPoints(limit?: number, search = '', beforeVersion?: number): Promise<HistoryPoint[]> {
182
+ const query = await historyQuery();
183
+ const normalizedSearch = search.trim().toLowerCase();
184
+ let builder = query.where('log_version', 'is not', null);
185
+ if (beforeVersion !== undefined) builder = builder.where('log_version', '<', beforeVersion);
186
+ if (normalizedSearch) {
187
+ // Search accepted action metadata and payload entirely in SQL.
188
+ builder = builder.where(sql<boolean>`instr(
189
+ lower(
190
+ CAST(log_version AS TEXT) || ' ' ||
191
+ CASE
192
+ WHEN action_size > 1 THEN 'action (' || action_size || ' changes)'
193
+ ELSE replace(lower(type), '_', ' ')
194
+ END || ' ' ||
195
+ type || ' ' ||
196
+ COALESCE(entity_id, '') || ' ' || payload
197
+ ),
198
+ ${normalizedSearch}
199
+ ) > 0`);
200
+ }
201
+ const ordered = builder.orderBy('log_version', 'desc');
202
+ const rows = limit === undefined
203
+ ? await ordered.execute()
204
+ : await ordered.limit(limit).execute();
205
+
206
+ return rows
207
+ .filter((row): row is HistoryPointRow & { log_version: number } => row.log_version !== null)
208
+ .map(toHistoryPoint);
209
+ }
210
+
211
+ export async function getHistoryPoint(version: number): Promise<HistoryPoint | null> {
212
+ const query = await historyQuery();
213
+ const row = await query
214
+ .where('log_version', '=', version)
215
+ .executeTakeFirst();
216
+
217
+ return row?.log_version
218
+ ? toHistoryPoint(row as HistoryPointRow & { log_version: number })
219
+ : null;
220
+ }
221
+
222
+ export async function resolveVersionAtOrBefore(timestamp: number): Promise<HistoryPoint | null> {
223
+ const query = await historyQuery();
224
+ const row = await query
225
+ .where('log_version', 'is not', null)
226
+ .where('timestamp', '<=', timestamp)
227
+ .orderBy('log_version', 'desc')
228
+ .executeTakeFirst();
229
+
230
+ return row?.log_version
231
+ ? toHistoryPoint(row as HistoryPointRow & { log_version: number })
232
+ : null;
233
+ }
234
+
235
+ /** Nearest selectable point at or before a log version (scrubber seeking). */
236
+ export async function getHistoryPointAtOrBefore(version: number): Promise<HistoryPoint | null> {
237
+ const query = await historyQuery();
238
+ const row = await query
239
+ .where('log_version', 'is not', null)
240
+ .where('log_version', '<=', version)
241
+ .orderBy('log_version', 'desc')
242
+ .executeTakeFirst();
243
+
244
+ return row?.log_version
245
+ ? toHistoryPoint(row as HistoryPointRow & { log_version: number })
246
+ : null;
247
+ }
248
+
249
+ export async function getAdjacentHistoryPoint(
250
+ currentVersion: number,
251
+ direction: 'previous' | 'next',
252
+ ): Promise<HistoryPoint | null> {
253
+ const query = await historyQuery();
254
+ const row = await query
255
+ .where('log_version', direction === 'previous' ? '<' : '>', currentVersion)
256
+ .orderBy('log_version', direction === 'previous' ? 'desc' : 'asc')
257
+ .executeTakeFirst();
258
+
259
+ return row?.log_version
260
+ ? toHistoryPoint(row as HistoryPointRow & { log_version: number })
261
+ : null;
262
+ }
263
+
264
+ async function historyQuery() {
265
+ const context = await requireActiveContext();
266
+ const visibleTypes = await visibleHistoryTypes(context);
267
+ return db.selectFrom('raw_events')
268
+ .select(['id', 'type', 'timestamp', 'payload', 'entity_id', 'log_version', 'action_id', 'action_size'])
269
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
270
+ .where('projection_state', 'in', ['effective', 'restored_out'])
271
+ .where(completeActionBoundary)
272
+ // Events hidden by a live excision marker are not selectable history
273
+ // points; the marker itself remains as the audit entry.
274
+ .where(sql<SqlBool>`NOT EXISTS (
275
+ SELECT 1 FROM raw_events AS excision_marker,
276
+ json_each(excision_marker.payload, '$.eventIds') AS excision_target
277
+ WHERE excision_marker.tenant_id = raw_events.tenant_id
278
+ AND excision_marker.log_id = raw_events.log_id
279
+ AND excision_marker.type = ${EVENTS_EXCISED}
280
+ AND excision_marker.projection_state = 'effective'
281
+ AND excision_marker.is_tombstoned = 0
282
+ AND excision_target.value = raw_events.id
283
+ )`)
284
+ .where(eb => eb.or([
285
+ eb('type', 'in', visibleTypes),
286
+ eb.exists(eb.selectFrom('raw_events as visible_member').select('visible_member.id')
287
+ .whereRef('visible_member.tenant_id', '=', 'raw_events.tenant_id')
288
+ .whereRef('visible_member.log_id', '=', 'raw_events.log_id')
289
+ .whereRef('visible_member.action_id', '=', 'raw_events.action_id')
290
+ .where('visible_member.type', 'in', visibleTypes)),
291
+ ]));
292
+ }
293
+
294
+ // Resolve runtime-defined types using the same visibility policy as event views,
295
+ // before applying limits or selecting a neighbouring accepted position.
296
+ async function visibleHistoryTypes(context: { tenantId: string; logId: string }): Promise<string[]> {
297
+ const types = await db.selectFrom('raw_events').select('type').distinct()
298
+ .where('tenant_id', '=', context.tenantId)
299
+ .where('log_id', '=', context.logId).execute();
300
+ return types.map(row => row.type).filter(isVisibleEventType);
301
+ }
302
+
303
+ function toHistoryPoint(row: HistoryPointRow & { log_version: number }): HistoryPoint {
304
+ return {
305
+ version: row.log_version,
306
+ id: row.id,
307
+ type: row.type,
308
+ timestamp: row.timestamp,
309
+ entityId: row.entity_id,
310
+ label: directiveLabel(row) ?? (row.action_size && row.action_size > 1 ? `Action (${row.action_size} changes)` : descriptiveEventLabel(row)),
311
+ actionId: row.action_id ?? row.id,
312
+ eventCount: row.action_size ?? 1,
313
+ };
314
+ }
315
+
316
+ function descriptiveEventLabel(row: HistoryPointRow): string {
317
+ let title: string | undefined;
318
+ try {
319
+ const payload = JSON.parse(row.payload) as Record<string, unknown>;
320
+ title = [payload.description, payload.title, payload.name].find(
321
+ value => typeof value === 'string' && value.trim().length > 0,
322
+ ) as string | undefined;
323
+ } catch {
324
+ return eventLabel(row.type);
325
+ }
326
+ if (!title) return eventLabel(row.type);
327
+ const action = row.type.endsWith('_ADDED') ? 'Added'
328
+ : row.type.endsWith('_UPDATED') ? 'Edited'
329
+ : row.type.endsWith('_DELETED') || row.type.endsWith('_REMOVED') ? 'Removed'
330
+ : null;
331
+ return action ? `${action} ${title}` : eventLabel(row.type);
332
+ }
333
+
334
+ function directiveLabel(row: HistoryPointRow): string | null {
335
+ if (row.type === EVENTS_EXCISED) {
336
+ try {
337
+ const payload = JSON.parse(row.payload) as { eventIds?: unknown };
338
+ const count = Array.isArray(payload.eventIds) ? payload.eventIds.length : 0;
339
+ return count > 0 ? `Removed ${count} event${count === 1 ? '' : 's'}` : 'Removed events';
340
+ } catch {
341
+ return 'Removed events';
342
+ }
343
+ }
344
+ if (row.type === WORKSPACE_RESTORED) {
345
+ try {
346
+ const payload = JSON.parse(row.payload) as { targetVersion?: unknown };
347
+ return typeof payload.targetVersion === 'number' ? `Restored log to v${payload.targetVersion}` : 'Restored log';
348
+ } catch {
349
+ return 'Restored log';
350
+ }
351
+ }
352
+ return null;
353
+ }
354
+
355
+ export function eventLabel(type: string): string {
356
+ return type
357
+ .toLowerCase()
358
+ .split('_')
359
+ .filter(Boolean)
360
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
361
+ .join(' ');
362
+ }
363
+
364
+ export function formatHistoryDateTime(timestamp: number): string {
365
+ return new Intl.DateTimeFormat(undefined, {
366
+ month: 'short',
367
+ day: 'numeric',
368
+ year: 'numeric',
369
+ hour: '2-digit',
370
+ minute: '2-digit',
371
+ }).format(new Date(timestamp));
372
+ }
@@ -0,0 +1,69 @@
1
+ import { sql, type Kysely, type Transaction } from 'kysely';
2
+ import type { Database } from '../db/index.js';
3
+ import { contiguousThrough } from '../store/sync-progress.js';
4
+ import { selectReplayEvents, WORKSPACE_RESTORED, EVENTS_EXCISED, type ReplayEvent } from '@hearthlog/contracts/history/replay-selection';
5
+
6
+ type ReplayDatabase = Kysely<Database> | Transaction<Database>;
7
+ type Scope = { tenantId: string; logId: string };
8
+
9
+ /** Restores and excisions both change replay membership without changing data. */
10
+ export async function hasReplayDirectives(database: ReplayDatabase, scope: Scope, through?: number): Promise<boolean> {
11
+ let query = database.selectFrom('raw_events').select('id')
12
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
13
+ .where('type', 'in', [WORKSPACE_RESTORED, EVENTS_EXCISED])
14
+ .where('projection_state', 'in', ['effective', 'restored_out']);
15
+ // An accepted boundary only counts directives up to that version; live
16
+ // membership must also see pending local directives so a removal applies
17
+ // before it uploads.
18
+ if (through !== undefined) query = query.where('log_version', '<=', through);
19
+ return Boolean(await query.executeTakeFirst());
20
+ }
21
+
22
+ /** Derive live projection membership; raw facts and immutable outbox bodies remain intact. */
23
+ export async function reconcileRestoredEvents(database: ReplayDatabase, scope: Scope, includePendingDdl = false): Promise<void> {
24
+ if (!await hasReplayDirectives(database, scope)) return;
25
+ const reconcile = async (tx: Transaction<Database>) => {
26
+ const rows = await tx.selectFrom('raw_events').selectAll()
27
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
28
+ .where('projection_state', 'in', includePendingDdl ? ['effective', 'restored_out', 'pending_ddl'] : ['effective', 'restored_out'])
29
+ .orderBy('local_sequence').execute();
30
+ const through = contiguousThrough(0, rows.flatMap(row => row.log_version === null ? [] : [row.log_version]));
31
+ let pendingPosition = through;
32
+ const replay: ReplayEvent[] = rows.filter(row => row.log_version === null || row.log_version <= through)
33
+ .map(row => ({ id: row.id, type: row.type, payload: JSON.parse(row.payload),
34
+ logVersion: row.log_version ?? ++pendingPosition }));
35
+ const selected = selectReplayEvents(replay, pendingPosition).map(event => event.id);
36
+ await tx.updateTable('raw_events').set({ projection_state: 'restored_out', is_tombstoned: 0 })
37
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
38
+ .where('projection_state', 'in', ['effective', 'restored_out']).execute();
39
+ for (let offset = 0; offset < selected.length; offset += 400) {
40
+ await tx.updateTable('raw_events').set({ projection_state: 'effective' })
41
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
42
+ .where('id', 'in', selected.slice(offset, offset + 400))
43
+ .where('projection_state', 'in', ['effective', 'restored_out']).execute();
44
+ }
45
+ // Undo changes projection visibility, never delivery of the original
46
+ // pending action. Its immutable upload still precedes its compensation.
47
+ const selectedIds = new Set(selected);
48
+ for (const row of rows.filter(row => row.log_version === null && row.projection_state !== 'pending_ddl')) {
49
+ await tx.updateTable('raw_events').set({ projection_state: 'effective', is_tombstoned: selectedIds.has(row.id) ? 0 : 1 })
50
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId).where('id', '=', row.id).execute();
51
+ }
52
+ };
53
+ if (database.isTransaction) await reconcile(database as Transaction<Database>);
54
+ else await database.transaction().execute(reconcile);
55
+ }
56
+
57
+ /** Apply the identical interpretation to an isolated, bounded snapshot table. */
58
+ export async function filterRestoredSnapshot(database: ReplayDatabase, table: string, boundary: number): Promise<void> {
59
+ // table is a qualified identifier supplied by the snapshot builder, never user text.
60
+ const rows = await sql<{ id: string; type: string; payload: string; log_version: number }>`
61
+ SELECT id,type,payload,log_version FROM ${sql.raw(table)}
62
+ `.execute(database);
63
+ const selected = new Set(selectReplayEvents(rows.rows.map(row => ({ id: row.id, type: row.type,
64
+ logVersion: row.log_version, payload: JSON.parse(row.payload) })), boundary).map(event => event.id));
65
+ const removed = rows.rows.filter(row => !selected.has(row.id)).map(row => row.id);
66
+ for (let offset = 0; offset < removed.length; offset += 400) {
67
+ await sql`DELETE FROM ${sql.raw(table)} WHERE id IN (${sql.join(removed.slice(offset, offset + 400))})`.execute(database);
68
+ }
69
+ }
@@ -0,0 +1,9 @@
1
+ /** Accepted only to redirect old snapshot bookmarks into Log history. */
2
+ export const TIME_TRAVEL_QUERY_PARAM = 'asOfVersion';
3
+
4
+ export function getAsOfVersion(url: URL): number | null {
5
+ const raw = url.searchParams.get(TIME_TRAVEL_QUERY_PARAM);
6
+ if (!raw) return null;
7
+ const version = Number(raw);
8
+ return Number.isSafeInteger(version) && version > 0 ? version : null;
9
+ }
@@ -0,0 +1,123 @@
1
+ import { format } from 'date-fns';
2
+ import type { LogLineageIndex, LogSummary } from '../ledger/index.js';
3
+
4
+ /** A log in the tree. Hidden logs drop out and their forks move up to their place. */
5
+ export interface LogNode {
6
+ log: LogSummary;
7
+ depth: number;
8
+ /** The nearest shown ancestor. */
9
+ parentId: string | null;
10
+ children: LogNode[];
11
+ }
12
+
13
+ /** A place in the current log's history where a log starts. */
14
+ export interface ForkMarker {
15
+ key: string;
16
+ kind: 'start' | 'branch';
17
+ /** The last version the log shares; the marker sits just above that action. */
18
+ version: number;
19
+ log: LogSummary;
20
+ }
21
+
22
+ function index(logs: readonly LogSummary[], lineage: LogLineageIndex | null) {
23
+ const byId = new Map(logs.map(log => [log.id, log]));
24
+ const childIds = (logId: string): string[] =>
25
+ lineage?.childIdsByLogId[logId] ?? logs.filter(log => log.sourceLogId === logId).map(log => log.id);
26
+ const rootIds = lineage?.rootIds
27
+ ?? logs.filter(log => !log.sourceLogId || !byId.has(log.sourceLogId)).map(log => log.id);
28
+ return { byId, childIds, rootIds };
29
+ }
30
+
31
+ export function logTree(
32
+ logs: readonly LogSummary[],
33
+ lineage: LogLineageIndex | null,
34
+ options: { currentLogId: string | null; showHidden: boolean },
35
+ ): LogNode[] {
36
+ const { byId, childIds, rootIds } = index(logs, lineage);
37
+ const seen = new Set<string>();
38
+ const build = (ids: readonly string[], depth: number, parentId: string | null): LogNode[] => ids.flatMap(id => {
39
+ const log = byId.get(id);
40
+ if (!log || seen.has(id)) return [];
41
+ seen.add(id);
42
+ const visible = options.showHidden || !log.hiddenAt || log.id === options.currentLogId;
43
+ if (!visible) return build(childIds(id), depth, parentId);
44
+ return [{ log, depth, parentId, children: build(childIds(id), depth + 1, log.id) }];
45
+ });
46
+ return build(rootIds, 0, null);
47
+ }
48
+
49
+ export function flattenTree(nodes: readonly LogNode[]): LogNode[] {
50
+ return nodes.flatMap(node => [node, ...flattenTree(node.children)]);
51
+ }
52
+
53
+ /** The current log's ancestors, oldest first. */
54
+ export function logAncestors(logs: readonly LogSummary[], currentLogId: string | null): LogSummary[] {
55
+ const byId = new Map(logs.map(log => [log.id, log]));
56
+ const chain: LogSummary[] = [];
57
+ let next = currentLogId ? byId.get(currentLogId)?.sourceLogId : null;
58
+ while (next && !chain.some(log => log.id === next)) {
59
+ const log = byId.get(next);
60
+ if (!log) break;
61
+ chain.unshift(log);
62
+ next = log.sourceLogId;
63
+ }
64
+ return chain;
65
+ }
66
+
67
+ /**
68
+ * Where the current log began, and where other logs left the part of history
69
+ * it holds: its own forks anywhere, and forks of its ancestors up to the point
70
+ * it shares with them.
71
+ */
72
+ export function forkMarkers(
73
+ logs: readonly LogSummary[],
74
+ lineage: LogLineageIndex | null,
75
+ options: { currentLogId: string | null; showHidden: boolean },
76
+ ): ForkMarker[] {
77
+ const { byId, childIds } = index(logs, lineage);
78
+ const current = options.currentLogId ? byId.get(options.currentLogId) : undefined;
79
+ if (!current) return [];
80
+ const markers: ForkMarker[] = [];
81
+ if (current.sourceLogId) {
82
+ markers.push({ key: 'start', kind: 'start', version: current.sourceVersion ?? 0, log: current });
83
+ }
84
+ let node: LogSummary | undefined = current;
85
+ let bound = Number.POSITIVE_INFINITY;
86
+ let from: string | null = null;
87
+ const visited = new Set<string>();
88
+ while (node && !visited.has(node.id)) {
89
+ visited.add(node.id);
90
+ for (const id of childIds(node.id)) {
91
+ const child = byId.get(id);
92
+ if (!child || id === from || child.sourceVersion === null || child.sourceVersion === undefined) continue;
93
+ if (child.sourceVersion > bound) continue;
94
+ if (child.hiddenAt && !options.showHidden) continue;
95
+ markers.push({ key: `branch:${child.id}`, kind: 'branch', version: child.sourceVersion, log: child });
96
+ }
97
+ if (node.sourceVersion !== null && node.sourceVersion !== undefined) bound = Math.min(bound, node.sourceVersion);
98
+ from = node.id;
99
+ node = node.sourceLogId ? byId.get(node.sourceLogId) : undefined;
100
+ }
101
+ return markers;
102
+ }
103
+
104
+ /** Changes a log made after it forked; for a separate log, all of them. */
105
+ export function ownChanges(log: LogSummary): number {
106
+ return Math.max(0, log.headVersion - (log.sourceVersion ?? 0));
107
+ }
108
+
109
+ /** The root's name and the day of the fork point, numbered when taken. */
110
+ export function suggestForkName(
111
+ logs: readonly LogSummary[],
112
+ currentLogId: string | null,
113
+ timestamp: number,
114
+ ): string {
115
+ const root = logAncestors(logs, currentLogId)[0] ?? logs.find(log => log.id === currentLogId);
116
+ const base = `${root?.name ?? 'Log'} · ${format(timestamp, 'MMM d')}`;
117
+ const taken = new Set(logs.map(log => log.name.trim().toLocaleLowerCase()));
118
+ if (!taken.has(base.toLocaleLowerCase())) return base;
119
+ for (let number = 2; ; number++) {
120
+ const candidate = `${base} (${number})`;
121
+ if (!taken.has(candidate.toLocaleLowerCase())) return candidate;
122
+ }
123
+ }
@@ -0,0 +1,17 @@
1
+ import { format, isSameYear, isToday, isYesterday } from 'date-fns';
2
+
3
+ /** A day in log metadata: today, yesterday, Sep 21, or Sep 21, 2025. */
4
+ export function formatLogDay(value: string | number | null | undefined, now = Date.now()): string {
5
+ const time = typeof value === 'number' ? value : value ? Date.parse(value) : Number.NaN;
6
+ if (!Number.isFinite(time)) return '';
7
+ if (isToday(time)) return 'today';
8
+ if (isYesterday(time)) return 'yesterday';
9
+ return format(time, isSameYear(time, now) ? 'MMM d' : 'MMM d, yyyy');
10
+ }
11
+
12
+ /** A fork point with its time: Sep 21, 08:12. */
13
+ export function formatForkPoint(value: string | number | null | undefined): string {
14
+ const time = typeof value === 'number' ? value : value ? Date.parse(value) : Number.NaN;
15
+ if (!Number.isFinite(time)) return '';
16
+ return format(time, isSameYear(time, Date.now()) ? 'MMM d, HH:mm' : 'MMM d, yyyy, HH:mm');
17
+ }