@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,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
|
+
}
|