@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,110 @@
1
+ import { format, startOfMonth, addMonths } from 'date-fns';
2
+ import type { LogNode } from './lineage.js';
3
+
4
+ const DAY = 86_400_000;
5
+
6
+ export interface MapRow {
7
+ node: LogNode;
8
+ y: number;
9
+ x0: number;
10
+ x1: number;
11
+ /** Where the line leaves its parent's row. */
12
+ fork: { x: number; y: number } | null;
13
+ /** The log began before the map does. */
14
+ clipped: boolean;
15
+ label: { x: number; anchor: 'start' | 'end' };
16
+ }
17
+
18
+ export interface LogMapModel {
19
+ width: number;
20
+ height: number;
21
+ axisY: number;
22
+ rows: MapRow[];
23
+ ticks: { x: number; label: string; anchor: 'start' | 'middle' | 'end' }[];
24
+ x: (time: number) => number;
25
+ }
26
+
27
+ function time(value: string | null | undefined): number | null {
28
+ const parsed = value ? Date.parse(value) : Number.NaN;
29
+ return Number.isFinite(parsed) ? parsed : null;
30
+ }
31
+
32
+ function forkTime(node: LogNode): number | null {
33
+ return node.log.sourceLogId
34
+ ? time(node.log.sourceTimestamp) ?? time(node.log.createdAt)
35
+ : time(node.log.createdAt);
36
+ }
37
+
38
+ export interface MapSpan {
39
+ start: number;
40
+ end: number;
41
+ }
42
+
43
+ /**
44
+ * The time the map covers. It ends at the latest activity, which is today for a
45
+ * log in use. Forks cluster near the end, so it covers the month before it,
46
+ * reaching back to the oldest fork within four months; older logs run in from
47
+ * the left edge.
48
+ */
49
+ export function mapSpan(nodes: readonly LogNode[], now: number, newestAction: number | null): MapSpan {
50
+ const latest = Math.max(newestAction ?? Number.NEGATIVE_INFINITY, ...nodes.map(node =>
51
+ time(node.log.lastUpdatedAt) ?? time(node.log.createdAt) ?? Number.NEGATIVE_INFINITY));
52
+ // Recent activity counts as today, so the axis reads "Today".
53
+ const end = !Number.isFinite(latest) || latest > now - 2 * DAY ? now : Math.min(latest, now);
54
+ const forks = nodes.filter(node => node.log.sourceLogId).map(forkTime)
55
+ .filter((value): value is number => value !== null && value <= end);
56
+ const start = Math.max(end - 120 * DAY, Math.min(end - 30 * DAY, ...forks));
57
+ return { start: start - (end - start) * 0.03, end: end + (end === now ? 0 : (end - start) * 0.02) };
58
+ }
59
+
60
+ /** One line per log on a shared time axis, in tree order. Pure geometry. */
61
+ export function logMap(
62
+ nodes: readonly LogNode[],
63
+ options: { currentLogId: string | null; now: number; span: MapSpan; width?: number; rowGap?: number },
64
+ ): LogMapModel {
65
+ const width = options.width ?? 360;
66
+ // Rows tighten as logs are added, so the whole tree stays in view.
67
+ const rowGap = options.rowGap ?? Math.max(16, Math.min(26, 104 / Math.max(1, nodes.length - 1)));
68
+ const top = 22;
69
+ const left = 8;
70
+ const right = 8;
71
+ const now = options.now;
72
+ const { start, end } = options.span;
73
+ const x = (value: number) => left + ((Math.min(Math.max(value, start), end) - start) / (end - start)) * (width - left - right);
74
+
75
+ const rowY = new Map<string, number>();
76
+ const rows = nodes.map((node, index): MapRow => {
77
+ const y = top + index * rowGap;
78
+ rowY.set(node.log.id, y);
79
+ const began = forkTime(node) ?? start;
80
+ const x0 = x(began);
81
+ const current = node.log.id === options.currentLogId;
82
+ const last = current || node.log.status === 'preparing'
83
+ ? end
84
+ : time(node.log.lastUpdatedAt) ?? time(node.log.createdAt) ?? began;
85
+ const x1 = Math.max(x(last), x0 + 4);
86
+ const parentY = node.parentId ? rowY.get(node.parentId) : undefined;
87
+ const estimate = node.log.name.length * 6.2 + 4;
88
+ return {
89
+ node, y, x0, x1,
90
+ fork: node.log.sourceLogId && parentY !== undefined ? { x: x0, y: parentY } : null,
91
+ clipped: began < start,
92
+ // After the start when it fits, otherwise just before it, clear of the fork line.
93
+ label: x0 + estimate <= width - 4 ? { x: x0 + 2, anchor: 'start' }
94
+ : x0 - estimate >= 4 ? { x: x0 - 6, anchor: 'end' } : { x: 4, anchor: 'start' },
95
+ };
96
+ });
97
+
98
+ const axisY = top + Math.max(0, nodes.length - 1) * rowGap + 18;
99
+ const ticks: LogMapModel['ticks'] = [
100
+ { x: left, label: format(start, 'MMM d'), anchor: 'start' },
101
+ { x: width - right, label: end >= now - DAY ? 'Today' : format(end, 'MMM d'), anchor: 'end' },
102
+ ];
103
+ if (end - start > 60 * DAY) {
104
+ for (let month = startOfMonth(addMonths(start, 1)).getTime(); month < end - 20 * DAY; month = addMonths(month, 1).getTime()) {
105
+ const at = x(month);
106
+ if (at > left + 44 && at < width - right - 44) ticks.push({ x: at, label: format(month, 'MMM'), anchor: 'middle' });
107
+ }
108
+ }
109
+ return { width, height: axisY + 16, axisY, rows, ticks, x };
110
+ }
@@ -0,0 +1,14 @@
1
+ import { browser } from '$app/env';
2
+ import { goto } from '$app/navigation';
3
+ import { page } from '$app/state';
4
+
5
+ /** Shallow navigation changes page.state; the address bar remains authoritative. */
6
+ export function historyRouteUrl(): URL {
7
+ void page.state;
8
+ void page.url;
9
+ return browser ? new URL(window.location.href) : new URL(page.url.href);
10
+ }
11
+
12
+ export function navigateHistory(url: URL, replace = false): void {
13
+ void goto(url, { shallow: true, replace, state: { ...page.state, historyUrl: url.href } });
14
+ }
@@ -0,0 +1,229 @@
1
+ import { sql, type Kysely } from 'kysely';
2
+ import { db, type Database } from '../db/index.js';
3
+ import { generateDynamicTableDDL } from '../db/dynamic-ddl.js';
4
+ import { getAllEntityDDL, getAllEntityIndexDDL } from '#generated/schema';
5
+ import { createProjectionConfigFromDynamic } from '../event/dynamic-entities.js';
6
+ import { materializeDynamicEntities } from '../event/dynamic-materializer.js';
7
+ import {
8
+ buildBulkProjectionSQL,
9
+ PROJECTION_CONFIGS,
10
+ qualifiedIdentifier,
11
+ quoteIdentifier,
12
+ } from '../event/projection-builder.js';
13
+ import { completeActionBoundary } from './action-boundary.js';
14
+ import { EVENTS_EXCISED, WORKSPACE_RESTORED } from '@hearthlog/contracts/history/replay-selection';
15
+ import { filterRestoredSnapshot } from './legacy-replay.js';
16
+ import type { DynamicFieldDefinition } from '../event/meta-entity-types.js';
17
+ import type { ReadScope } from '../db/read-session.js';
18
+ import type { HistoryRecord } from './contracts.js';
19
+
20
+ const STAGED_EVENTS_TABLE = '__stor_effective_events';
21
+ const aliases = globalThis as typeof globalThis & { storPointSnapshotAliasCounter?: number };
22
+
23
+ /** Parse staged dynamic field JSON; a malformed row fails the snapshot. */
24
+ function parseDefinitionFields(fieldsJson: string): DynamicFieldDefinition[] {
25
+ try {
26
+ const parsed: unknown = JSON.parse(fieldsJson);
27
+ if (Array.isArray(parsed)) return parsed as DynamicFieldDefinition[];
28
+ } catch {
29
+ // Fall through to the typed error below.
30
+ }
31
+ throw new Error('point-snapshot-invalid-dynamic-fields');
32
+ }
33
+
34
+ export interface WorkspaceRecord {
35
+ key: string;
36
+ label: string;
37
+ value: Record<string, unknown>;
38
+ }
39
+
40
+ /** Build one accepted prefix for a Log history comparison, then release it. */
41
+ export async function readWorkspaceSnapshot(version: number, scope: ReadScope): Promise<WorkspaceRecord[]> {
42
+ return withPointSnapshot(version, scope, async (schema, projectionConfigs) => {
43
+ const records: WorkspaceRecord[] = [];
44
+ for (const config of projectionConfigs) {
45
+ const result = await sql<Record<string, unknown>>`SELECT * FROM ${sql.raw(qualifiedIdentifier(schema, config.tableName))}`.execute(db);
46
+ for (const row of result.rows) {
47
+ const { tenant_id: _tenant, log_id: _log, ...value } = row;
48
+ records.push({ key: `${config.entityName}:${String(row.id)}`, label: String(row.title ?? row.name ?? row.id), value });
49
+ }
50
+ }
51
+ const definitions = await sql<Record<string, unknown>>`SELECT * FROM ${sql.raw(qualifiedIdentifier(schema, 'entity_type_definitions'))}`.execute(db);
52
+ for (const row of definitions.rows) {
53
+ const { tenant_id: _tenant, log_id: _log, ...value } = row;
54
+ records.push({ key: `entity_type:${String(row.id)}`, label: String(row.display_name ?? row.name ?? row.id), value });
55
+ }
56
+ return records.sort((a, b) => a.key.localeCompare(b.key));
57
+ });
58
+ }
59
+
60
+ /** Keep both materialized prefixes leased while SQLite compares records and fields. */
61
+ export async function readActionChanges(previousVersion: number, version: number, scope: ReadScope): Promise<HistoryRecord[]> {
62
+ return withPointSnapshot(previousVersion, scope, async (beforeSchema, beforeConfigs) =>
63
+ withPointSnapshot(version, scope, async (afterSchema, afterConfigs) => {
64
+ function snapshotRows(schema: string, configs: typeof beforeConfigs) {
65
+ const tables = [...configs.map(config => ({ entity: config.entityName, table: config.tableName,
66
+ fields: config.fields.map(field => field.name) })),
67
+ { entity: 'entity_type', table: 'entity_type_definitions',
68
+ fields: ['name', 'display_name', 'table_name', 'fields_json', 'calendar_json', 'is_archived'] }];
69
+ return sql.join(tables.map(table => sql`SELECT ${table.entity} || ':' || id AS key,
70
+ json_object(${sql.join(table.fields.flatMap(field => [sql`${field}`, sql.ref(field)]))}) AS value
71
+ FROM ${sql.raw(qualifiedIdentifier(schema, table.table))}`), sql` UNION ALL `);
72
+ }
73
+ const { rows } = await sql<{ key: string; label: string; kind: HistoryRecord['kind']; fields: string }>`
74
+ WITH before_records AS (${snapshotRows(beforeSchema, beforeConfigs)}),
75
+ after_records AS (${snapshotRows(afterSchema, afterConfigs)}),
76
+ keys AS (SELECT key FROM before_records UNION SELECT key FROM after_records),
77
+ changes AS (
78
+ SELECT keys.key, b.value AS before_value, a.value AS after_value,
79
+ CASE WHEN b.key IS NULL THEN 'added' WHEN a.key IS NULL THEN 'removed' ELSE 'changed' END AS kind,
80
+ (SELECT json_group_array(json_object('key', field, 'label', replace(field, '_', ' '),
81
+ 'before', json_extract(b.value, '$.' || field), 'after', json_extract(a.value, '$.' || field)))
82
+ FROM (SELECT key AS field FROM json_each(b.value) UNION SELECT key FROM json_each(a.value))
83
+ WHERE json_extract(b.value, '$.' || field) IS NOT json_extract(a.value, '$.' || field)
84
+ ORDER BY field) AS fields
85
+ FROM keys LEFT JOIN before_records b ON b.key = keys.key LEFT JOIN after_records a ON a.key = keys.key
86
+ ) SELECT key, kind, fields,
87
+ COALESCE(json_extract(after_value, '$.title'), json_extract(after_value, '$.name'), json_extract(after_value, '$.number'),
88
+ json_extract(before_value, '$.title'), json_extract(before_value, '$.name'), json_extract(before_value, '$.number'), key) AS label
89
+ FROM changes WHERE kind != 'changed' OR json_array_length(fields) > 0 ORDER BY key
90
+ `.execute(db);
91
+ return rows.map(row => ({ ...row, fields: JSON.parse(row.fields) }));
92
+ }));
93
+ }
94
+
95
+ async function withPointSnapshot<T>(version: number, scope: ReadScope,
96
+ read: (schema: string, configs: Awaited<ReturnType<typeof buildPointSnapshot>>['projectionConfigs']) => Promise<T>): Promise<T> {
97
+ if (!Number.isSafeInteger(version) || version < 0) throw new Error('Invalid history version');
98
+ const head = await downloadedHead(scope);
99
+ if (version > (head ?? 0)) throw new Error('Download this log before inspecting its history');
100
+ if (version > 0) {
101
+ const boundary = await db.selectFrom('raw_events').select('id')
102
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
103
+ .where('log_version', '=', version).where('projection_state', 'in', ['effective', 'restored_out'])
104
+ .where(completeActionBoundary).executeTakeFirst();
105
+ if (!boundary) throw new Error('Choose a complete accepted action');
106
+ }
107
+
108
+ const schema = `stor_point_${aliases.storPointSnapshotAliasCounter = (aliases.storPointSnapshotAliasCounter ?? 0) + 1}`;
109
+ await sql.raw(`ATTACH DATABASE ':memory:' AS ${quoteIdentifier(schema)}`).execute(db);
110
+ try {
111
+ const { projectionConfigs, knownEventTypes } = await buildPointSnapshot(schema, scope, version);
112
+ const types = await sql<{ type: string }>`
113
+ SELECT DISTINCT type FROM ${sql.raw(qualifiedIdentifier(schema, STAGED_EVENTS_TABLE))}
114
+ `.execute(db);
115
+ const unknown = types.rows.filter(row => !knownEventTypes.has(row.type));
116
+ if (unknown.length) throw new Error(`Cannot inspect unrecognized event types: ${unknown.map(row => row.type).join(', ')}`);
117
+
118
+ return await read(schema, projectionConfigs);
119
+ } finally {
120
+ await sql.raw(`DETACH DATABASE ${quoteIdentifier(schema)}`).execute(db);
121
+ }
122
+ }
123
+
124
+ async function buildPointSnapshot(schema: string, scope: ReadScope, version: number) {
125
+ return db.transaction().execute(async (tx) => {
126
+ for (const ddl of getAllEntityDDL().map(statement => qualifyGeneratedDDL(statement, schema))) {
127
+ await sql.raw(ddl).execute(tx);
128
+ }
129
+ for (const ddl of getAllEntityIndexDDL().map(statement => qualifyGeneratedDDL(statement, schema))) {
130
+ await sql.raw(ddl).execute(tx);
131
+ }
132
+ await sql.raw(entityTypeDefinitionsDDL(schema)).execute(tx);
133
+ await stageAcceptedPrefix(tx, schema, scope, version);
134
+
135
+ const dynamic = await materializeDynamicEntities({
136
+ database: tx,
137
+ schema,
138
+ scope,
139
+ registerLive: false,
140
+ sourceTable: STAGED_EVENTS_TABLE,
141
+ });
142
+ for (const record of dynamic.records.filter(record => record.is_archived)) {
143
+ await sql.raw(generateDynamicTableDDL(record, schema)).execute(tx);
144
+ dynamic.activeProjectionConfigs.push(createProjectionConfigFromDynamic(
145
+ record.name, record.table_name, parseDefinitionFields(record.fields_json),
146
+ ));
147
+ }
148
+ const projectionConfigs = [...Object.values(PROJECTION_CONFIGS), ...dynamic.activeProjectionConfigs];
149
+ const projectionOptions = {
150
+ target: { schema },
151
+ source: { schema, table: STAGED_EVENTS_TABLE, staged: true },
152
+ } as const;
153
+ for (const config of projectionConfigs) {
154
+ await buildBulkProjectionSQL(config, scope, projectionOptions).execute(tx);
155
+ }
156
+ const knownEventTypes = new Set([
157
+ 'EVENT_TOMBSTONED', WORKSPACE_RESTORED, EVENTS_EXCISED,
158
+ 'ENTITY_TYPE_DEFINED', 'ENTITY_TYPE_UPDATED', 'ENTITY_TYPE_ARCHIVED',
159
+ ...projectionConfigs.flatMap(config => config.eventTypes),
160
+ ...dynamic.records.flatMap(record => createProjectionConfigFromDynamic(
161
+ record.name, record.table_name, parseDefinitionFields(record.fields_json),
162
+ ).eventTypes),
163
+ ]);
164
+ return { projectionConfigs, knownEventTypes };
165
+ });
166
+ }
167
+
168
+ async function stageAcceptedPrefix(database: Kysely<Database>, schema: string, scope: ReadScope, version: number): Promise<void> {
169
+ const staged = qualifiedIdentifier(schema, STAGED_EVENTS_TABLE);
170
+ await sql`
171
+ CREATE TABLE ${sql.raw(staged)} AS
172
+ SELECT * FROM main.raw_events
173
+ WHERE tenant_id = ${scope.tenantId}
174
+ AND log_id = ${scope.logId}
175
+ AND projection_state IN ('effective', 'restored_out')
176
+ AND log_version IS NOT NULL
177
+ AND log_version <= ${version}
178
+ `.execute(database);
179
+ // Old restore/removal facts in the prefix still affect its projected state.
180
+ await filterRestoredSnapshot(database, staged, version);
181
+ const indexSchema = quoteIdentifier(schema);
182
+ await sql.raw(`CREATE INDEX ${indexSchema}."idx_stor_history_type" ON "${STAGED_EVENTS_TABLE}" ("type")`).execute(database);
183
+ await sql.raw(`CREATE INDEX ${indexSchema}."idx_stor_history_entity" ON "${STAGED_EVENTS_TABLE}" ("entity_id", "entity_version")`).execute(database);
184
+ await sql.raw(`CREATE INDEX ${indexSchema}."idx_stor_history_version" ON "${STAGED_EVENTS_TABLE}" ("log_version")`).execute(database);
185
+ }
186
+
187
+ function qualifyGeneratedDDL(statement: string, schema: string): string {
188
+ const qualifiedSchema = quoteIdentifier(schema);
189
+ return statement.replace(
190
+ /^(\s*CREATE TABLE IF NOT EXISTS )"([^"]+)"/,
191
+ `$1${qualifiedSchema}."$2"`,
192
+ ).replace(
193
+ /^(\s*CREATE (?:UNIQUE )?INDEX IF NOT EXISTS )"([^"]+)"/,
194
+ `$1${qualifiedSchema}."$2"`,
195
+ );
196
+ }
197
+
198
+ async function downloadedHead(scope: ReadScope): Promise<number | null> {
199
+ const syncState = await db.selectFrom('log_sync_state').select('downloaded_head_version')
200
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId).executeTakeFirst();
201
+ if (syncState) return syncState.downloaded_head_version;
202
+ const available = await db.selectFrom('raw_events')
203
+ .select(({ fn }) => fn.max('log_version').as('head'))
204
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId)
205
+ .where('projection_state', 'in', ['effective', 'restored_out'])
206
+ .where('log_version', 'is not', null).executeTakeFirst();
207
+ return available?.head == null ? null : Number(available.head);
208
+ }
209
+
210
+ function entityTypeDefinitionsDDL(schema: string): string {
211
+ return `
212
+ CREATE TABLE ${qualifiedIdentifier(schema, 'entity_type_definitions')} (
213
+ "tenant_id" TEXT NOT NULL,
214
+ "log_id" TEXT NOT NULL,
215
+ "id" TEXT NOT NULL,
216
+ "name" TEXT NOT NULL,
217
+ "display_name" TEXT NOT NULL,
218
+ "table_name" TEXT NOT NULL,
219
+ "fields_json" TEXT NOT NULL,
220
+ "calendar_json" TEXT,
221
+ "is_archived" INTEGER DEFAULT 0,
222
+ "created_at" TEXT DEFAULT (datetime('now')),
223
+ "updated_at" TEXT DEFAULT (datetime('now')),
224
+ PRIMARY KEY ("tenant_id", "log_id", "id"),
225
+ UNIQUE ("tenant_id", "log_id", "name"),
226
+ UNIQUE ("tenant_id", "log_id", "table_name")
227
+ )
228
+ `;
229
+ }
@@ -0,0 +1,215 @@
1
+ import { db, requireActiveContext } from '../db/index.js';
2
+ import { sql } from 'kysely';
3
+ import { eventLabel, getAdjacentHistoryPoint, getHistoryPointAtOrBefore, type HistoryPoint } from './history.js';
4
+ import { EVENTS_EXCISED, WORKSPACE_RESTORED } from '@hearthlog/contracts/history/replay-selection';
5
+
6
+ export interface HistoryBucket {
7
+ bucket: number;
8
+ count: number;
9
+ startVersion: number;
10
+ endVersion: number;
11
+ startTimestamp: number;
12
+ endTimestamp: number;
13
+ }
14
+
15
+ export interface HistoryActivity {
16
+ buckets: HistoryBucket[];
17
+ minVersion: number;
18
+ maxVersion: number;
19
+ maxCount: number;
20
+ firstTimestamp: number;
21
+ lastTimestamp: number;
22
+ }
23
+
24
+ export interface HistoryMarker {
25
+ id: string;
26
+ type: 'restore' | 'excision';
27
+ /** Accepted markers have a version; a pending local marker is legend-only. */
28
+ version: number | null;
29
+ timestamp: number;
30
+ label: string;
31
+ count: number;
32
+ active: boolean;
33
+ }
34
+
35
+ export interface HistoryGhost {
36
+ id: string;
37
+ version: number;
38
+ timestamp: number;
39
+ label: string;
40
+ }
41
+
42
+ /**
43
+ * Density series for the history scrubber. Bucketing and counting happen in
44
+ * SQLite over accepted complete-action boundaries; the UI never loads the log
45
+ * to draw the chart.
46
+ */
47
+ export async function loadHistoryActivity(bucketCount = 96): Promise<HistoryActivity | null> {
48
+ const context = await requireActiveContext();
49
+ const buckets = Math.max(8, Math.min(240, Math.floor(bucketCount)));
50
+ const rows = await sql<{
51
+ bucket: number;
52
+ count: number;
53
+ start_version: number;
54
+ end_version: number;
55
+ start_timestamp: number;
56
+ end_timestamp: number;
57
+ }>`
58
+ WITH bounds AS (
59
+ SELECT MIN(log_version) AS min_v, MAX(log_version) AS max_v
60
+ FROM raw_events
61
+ WHERE tenant_id = ${context.tenantId}
62
+ AND log_id = ${context.logId}
63
+ AND log_version IS NOT NULL
64
+ AND projection_state IN ('effective', 'restored_out')
65
+ AND (action_id IS NULL OR action_index = action_size - 1)
66
+ )
67
+ SELECT
68
+ CAST((events.log_version - bounds.min_v) * ${buckets} / (bounds.max_v - bounds.min_v + 1) AS INTEGER) AS bucket,
69
+ COUNT(*) AS count,
70
+ MIN(events.log_version) AS start_version,
71
+ MAX(events.log_version) AS end_version,
72
+ MIN(events.timestamp) AS start_timestamp,
73
+ MAX(events.timestamp) AS end_timestamp
74
+ FROM raw_events events, bounds
75
+ WHERE events.tenant_id = ${context.tenantId}
76
+ AND events.log_id = ${context.logId}
77
+ AND events.log_version IS NOT NULL
78
+ AND events.projection_state IN ('effective', 'restored_out')
79
+ AND (events.action_id IS NULL OR events.action_index = events.action_size - 1)
80
+ GROUP BY bucket
81
+ ORDER BY bucket
82
+ `.execute(db);
83
+
84
+ if (rows.rows.length === 0) return null;
85
+ const first = rows.rows[0]!;
86
+ const last = rows.rows[rows.rows.length - 1]!;
87
+ const minVersion = Number(first.start_version);
88
+ const maxVersion = Number(last.end_version);
89
+ const series: HistoryBucket[] = rows.rows.map((row) => ({
90
+ bucket: Number(row.bucket),
91
+ count: Number(row.count),
92
+ startVersion: Number(row.start_version),
93
+ endVersion: Number(row.end_version),
94
+ startTimestamp: Number(row.start_timestamp),
95
+ endTimestamp: Number(row.end_timestamp),
96
+ }));
97
+ return {
98
+ buckets: series,
99
+ minVersion,
100
+ maxVersion,
101
+ maxCount: Math.max(1, ...series.map((bucket) => bucket.count)),
102
+ firstTimestamp: Number(first.start_timestamp),
103
+ lastTimestamp: Number(last.end_timestamp),
104
+ };
105
+ }
106
+
107
+ /** Restore and excision directives, positioned on the same version axis. */
108
+ export async function loadHistoryMarkers(): Promise<HistoryMarker[]> {
109
+ const context = await requireActiveContext();
110
+ const rows = await db.selectFrom('raw_events')
111
+ .select(['id', 'type', 'timestamp', 'payload', 'log_version', 'projection_state', 'is_tombstoned'])
112
+ .where('tenant_id', '=', context.tenantId)
113
+ .where('log_id', '=', context.logId)
114
+ .where('type', 'in', [WORKSPACE_RESTORED, EVENTS_EXCISED])
115
+ .where('projection_state', 'in', ['effective', 'restored_out'])
116
+ .orderBy(sql`log_version is null`, 'asc')
117
+ .orderBy('log_version', 'asc')
118
+ .execute();
119
+ return rows.map((row) => {
120
+ let label = row.type === WORKSPACE_RESTORED ? 'Restored log' : 'Removed events';
121
+ let count = 1;
122
+ try {
123
+ const payload = JSON.parse(row.payload) as { targetVersion?: unknown; eventIds?: unknown };
124
+ if (row.type === WORKSPACE_RESTORED && typeof payload.targetVersion === 'number') {
125
+ label = `Restored log to v${payload.targetVersion}`;
126
+ }
127
+ if (row.type === EVENTS_EXCISED && Array.isArray(payload.eventIds)) {
128
+ count = payload.eventIds.length;
129
+ label = `Removed ${count} event${count === 1 ? '' : 's'}`;
130
+ }
131
+ } catch {
132
+ // Keep the fallback label for a malformed marker.
133
+ }
134
+ return {
135
+ id: row.id,
136
+ type: row.type === WORKSPACE_RESTORED ? 'restore' as const : 'excision' as const,
137
+ version: row.log_version ?? null,
138
+ timestamp: row.timestamp,
139
+ label,
140
+ count,
141
+ active: row.projection_state === 'effective' && row.is_tombstoned === 0,
142
+ };
143
+ });
144
+ }
145
+
146
+ /** Events hidden by a live excision, drawn as hollow ticks under the density. */
147
+ export async function loadHistoryGhosts(limit = 300): Promise<HistoryGhost[]> {
148
+ const context = await requireActiveContext();
149
+ const markers = await db.selectFrom('raw_events')
150
+ .select('payload')
151
+ .where('tenant_id', '=', context.tenantId)
152
+ .where('log_id', '=', context.logId)
153
+ .where('type', '=', EVENTS_EXCISED)
154
+ .where('projection_state', '=', 'effective')
155
+ .where('is_tombstoned', '=', 0)
156
+ .execute();
157
+ const ids = new Set<string>();
158
+ for (const marker of markers) {
159
+ try {
160
+ const payload = JSON.parse(marker.payload) as { eventIds?: unknown };
161
+ if (Array.isArray(payload.eventIds)) {
162
+ for (const id of payload.eventIds) {
163
+ if (typeof id === 'string') ids.add(id);
164
+ }
165
+ }
166
+ } catch {
167
+ // A malformed marker contributes no ghosts.
168
+ }
169
+ }
170
+ if (ids.size === 0) return [];
171
+ const rows = await db.selectFrom('raw_events')
172
+ .select(['id', 'type', 'timestamp', 'log_version'])
173
+ .where('tenant_id', '=', context.tenantId)
174
+ .where('log_id', '=', context.logId)
175
+ .where('id', 'in', [...ids])
176
+ .where('log_version', 'is not', null)
177
+ .orderBy('log_version', 'asc')
178
+ .limit(limit)
179
+ .execute();
180
+ return rows.map((row) => ({
181
+ id: row.id,
182
+ version: row.log_version!,
183
+ timestamp: row.timestamp,
184
+ label: eventLabel(row.type),
185
+ }));
186
+ }
187
+
188
+ /** Snap a free timeline position to the nearest selectable action boundary. */
189
+ export async function resolveNearestHistoryPoint(version: number): Promise<HistoryPoint | null> {
190
+ const [before, after] = await Promise.all([
191
+ getHistoryPointAtOrBefore(version),
192
+ getAdjacentHistoryPoint(version, 'next'),
193
+ ]);
194
+ if (!before) return after;
195
+ if (!after) return before;
196
+ return Math.abs(before.version - version) <= Math.abs(after.version - version) ? before : after;
197
+ }
198
+
199
+ /** The nearest bucket for a free position, used by the scrubber tooltip. */
200
+ export function nearestBucket(activity: HistoryActivity, version: number): HistoryBucket | null {
201
+ let best: HistoryBucket | null = null;
202
+ let bestDistance = Number.POSITIVE_INFINITY;
203
+ for (const bucket of activity.buckets) {
204
+ const distance = version < bucket.startVersion
205
+ ? bucket.startVersion - version
206
+ : version > bucket.endVersion
207
+ ? version - bucket.endVersion
208
+ : 0;
209
+ if (distance < bestDistance) {
210
+ bestDistance = distance;
211
+ best = bucket;
212
+ }
213
+ }
214
+ return best;
215
+ }
@@ -0,0 +1,18 @@
1
+ // `historyLimit` and `historySide` are no longer read; they stay listed so closing the panel clears old links.
2
+ export const HISTORY_PARAMS = ['logPanel', 'historyAction', 'historyVersion', 'historySearch', 'historyFeature', 'historyLimit', 'historyStep', 'historySide', 'hiddenLogs'] as const;
3
+
4
+ export function historyUrlState(url: URL) {
5
+ const version = Number(url.searchParams.get('historyVersion'));
6
+ const step = url.searchParams.get('historyStep');
7
+ return {
8
+ tab: url.searchParams.get('logPanel') === 'logs' ? 'logs' : 'history',
9
+ /** The action opened in place. */
10
+ action: url.searchParams.get('historyAction'),
11
+ version: Number.isSafeInteger(version) && version > 0 ? version : null,
12
+ search: url.searchParams.get('historySearch') ?? '',
13
+ feature: url.searchParams.get('historyFeature') ?? '',
14
+ /** `fork` starts a log after the opened action; `copy` after the latest one. */
15
+ step: step === 'review' || step === 'fork' ? 'fork' : step === 'copy' ? 'copy' : null,
16
+ showHidden: url.searchParams.get('hiddenLogs') === '1',
17
+ } as const;
18
+ }