@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,288 @@
1
+ import { sql, type Kysely, type Transaction } from 'kysely';
2
+ import type { Database } from '../db/index.js';
3
+ import {
4
+ generateAddColumnDDL,
5
+ generateDynamicTableDDL,
6
+ type EntityTypeRecord,
7
+ } from '../db/dynamic-ddl.js';
8
+ import {
9
+ sanitizeCalendarSource,
10
+ serializeCalendarConfig,
11
+ validateCalendarConfig,
12
+ type EntityCalendarSource,
13
+ } from '@hearthlog/contracts/calendar/config';
14
+ import { ENTITY_DEFINITIONS } from '@hearthlog/contracts/event/entity-definitions';
15
+ import { createProjectionConfigFromDynamic, loadDynamicEntityTypes } from './dynamic-entities.js';
16
+ import type { DynamicFieldDefinition } from './meta-entity-types.js';
17
+ import type { EntityProjectionConfig } from './projection-builder.js';
18
+ import { qualifiedIdentifier } from './projection-builder.js';
19
+
20
+ type MaterializerDatabase = Kysely<Database> | Transaction<Database>;
21
+
22
+ export interface DynamicMaterializerOptions {
23
+ database: MaterializerDatabase;
24
+ schema: string;
25
+ scope: { tenantId: string; logId: string };
26
+ registerLive: boolean;
27
+ sourceSchema?: string;
28
+ sourceTable?: string;
29
+ }
30
+
31
+ export interface DynamicMaterializationResult {
32
+ records: EntityTypeRecord[];
33
+ activeProjectionConfigs: EntityProjectionConfig[];
34
+ }
35
+
36
+ type MetaRow = {
37
+ id: string;
38
+ type: string;
39
+ timestamp: number;
40
+ payload: string;
41
+ };
42
+
43
+ /**
44
+ * Rebuild dynamic definitions into a target schema from an already bounded,
45
+ * tombstone-aware event source. Snapshot callers use their staged event table;
46
+ * live rebuild callers can use a similarly prepared source in main.
47
+ */
48
+ export async function materializeDynamicEntities(
49
+ options: DynamicMaterializerOptions,
50
+ ): Promise<DynamicMaterializationResult> {
51
+ const sourceTable = options.sourceTable ?? '__effective_events';
52
+ const source = qualifiedIdentifier(options.sourceSchema ?? options.schema, sourceTable);
53
+ const rows = await sql<MetaRow>`
54
+ SELECT id, type, timestamp, payload
55
+ FROM ${sql.raw(source)}
56
+ WHERE type IN ('ENTITY_TYPE_DEFINED', 'ENTITY_TYPE_UPDATED', 'ENTITY_TYPE_ARCHIVED')
57
+ ORDER BY CASE WHEN log_version IS NULL THEN 1 ELSE 0 END,
58
+ log_version ASC, timestamp ASC, local_sequence ASC, id ASC
59
+ `.execute(options.database);
60
+
61
+ const records = replayDynamicDefinitions(rows.rows, options.scope);
62
+ const catalog = qualifiedIdentifier(options.schema, 'entity_type_definitions');
63
+
64
+ for (const record of records) {
65
+ await sql`
66
+ INSERT INTO ${sql.raw(catalog)} (
67
+ tenant_id, log_id, id, name, display_name, table_name,
68
+ fields_json, calendar_json, is_archived, created_at, updated_at
69
+ ) VALUES (
70
+ ${record.tenant_id}, ${record.log_id}, ${record.id}, ${record.name},
71
+ ${record.display_name}, ${record.table_name}, ${record.fields_json},
72
+ ${record.calendar_json},
73
+ ${record.is_archived}, ${record.created_at}, ${record.updated_at}
74
+ )
75
+ `.execute(options.database);
76
+ }
77
+
78
+ const activeProjectionConfigs: EntityProjectionConfig[] = [];
79
+ for (const record of records) {
80
+ if (record.is_archived) continue;
81
+ await sql.raw(generateDynamicTableDDL(record, options.schema)).execute(options.database);
82
+ const fields = parseStoredFields(record.fields_json, record.id);
83
+ const tableInfo = await sql<{ name: string }>`
84
+ PRAGMA ${sql.raw(qualifiedIdentifier(options.schema, 'table_info'))}(${sql.raw(`'${record.table_name.replace(/'/g, "''")}'`)})
85
+ `.execute(options.database);
86
+ const existingColumns = new Set(tableInfo.rows.map((column) => column.name));
87
+ for (const field of fields) {
88
+ if (!existingColumns.has(field.name)) {
89
+ await sql.raw(generateAddColumnDDL(record.table_name, field, options.schema))
90
+ .execute(options.database);
91
+ }
92
+ }
93
+ activeProjectionConfigs.push(
94
+ createProjectionConfigFromDynamic(record.name, record.table_name, fields),
95
+ );
96
+ }
97
+
98
+ if (options.registerLive) {
99
+ await loadDynamicEntityTypes();
100
+ }
101
+
102
+ return { records, activeProjectionConfigs };
103
+ }
104
+
105
+ export function replayDynamicDefinitions(
106
+ rows: readonly MetaRow[],
107
+ scope: { tenantId: string; logId: string },
108
+ ): EntityTypeRecord[] {
109
+ const definitions = new Map<string, EntityTypeRecord>();
110
+ const names = new Set(ENTITY_DEFINITIONS.map((definition) => definition.name));
111
+ const tables = new Set(ENTITY_DEFINITIONS.map((definition) => definition.table));
112
+
113
+ for (const row of rows) {
114
+ const payload = parsePayload(row);
115
+ const id = typeof payload.id === 'string' ? payload.id : null;
116
+ if (!id) throw new Error(`dynamic-definition-invalid-id:${row.id}`);
117
+ const occurredAt = toSqliteDateTime(row.timestamp);
118
+
119
+ if (row.type === 'ENTITY_TYPE_DEFINED') {
120
+ if (definitions.has(id)) continue;
121
+ const name = requiredString(payload.name, row.id, 'name');
122
+ const tableName = requiredString(payload.tableName, row.id, 'tableName');
123
+ const displayName = requiredString(payload.displayName, row.id, 'displayName');
124
+ assertSafeIdentifier(name, row.id, 'name');
125
+ assertSafeIdentifier(tableName, row.id, 'table');
126
+ if (names.has(name) || tables.has(tableName)) {
127
+ throw new Error(`dynamic-definition-name-conflict:${row.id}`);
128
+ }
129
+ if (!Array.isArray(payload.fields)) {
130
+ throw new Error(`dynamic-definition-invalid-fields:${row.id}`);
131
+ }
132
+ validateFields(payload.fields as DynamicFieldDefinition[], row.id);
133
+ const definedFields = payload.fields as DynamicFieldDefinition[];
134
+ names.add(name);
135
+ tables.add(tableName);
136
+ definitions.set(id, {
137
+ tenant_id: scope.tenantId,
138
+ log_id: scope.logId,
139
+ id,
140
+ name,
141
+ display_name: displayName,
142
+ table_name: tableName,
143
+ fields_json: JSON.stringify(definedFields),
144
+ calendar_json:
145
+ payload.calendar === undefined
146
+ ? null
147
+ : calendarJsonFromPayload(
148
+ payload.calendar,
149
+ name,
150
+ definedFields,
151
+ row.id,
152
+ new Set<string>(),
153
+ ),
154
+ is_archived: 0,
155
+ created_at: occurredAt,
156
+ updated_at: occurredAt,
157
+ });
158
+ continue;
159
+ }
160
+
161
+ const existing = definitions.get(id);
162
+ if (!existing) throw new Error(`dynamic-definition-missing:${row.id}`);
163
+
164
+ if (row.type === 'ENTITY_TYPE_ARCHIVED') {
165
+ existing.is_archived = 1;
166
+ existing.updated_at = occurredAt;
167
+ continue;
168
+ }
169
+
170
+ const fields = parseStoredFields(existing.fields_json, row.id);
171
+ if (payload.addFields !== undefined) {
172
+ if (!Array.isArray(payload.addFields)) {
173
+ throw new Error(`dynamic-definition-invalid-add-fields:${row.id}`);
174
+ }
175
+ for (const field of payload.addFields as DynamicFieldDefinition[]) {
176
+ if (!field || typeof field.name !== 'string' || fields.some((item) => item.name === field.name)) {
177
+ throw new Error(`dynamic-definition-invalid-added-field:${row.id}`);
178
+ }
179
+ validateFields([field], row.id);
180
+ fields.push(field);
181
+ }
182
+ }
183
+ if (payload.updateFields !== undefined) {
184
+ if (!Array.isArray(payload.updateFields)) {
185
+ throw new Error(`dynamic-definition-invalid-updates:${row.id}`);
186
+ }
187
+ for (const update of payload.updateFields as Array<Partial<DynamicFieldDefinition> & { name: string }>) {
188
+ const fieldIndex = fields.findIndex((field) => field.name === update.name);
189
+ if (fieldIndex < 0) throw new Error(`dynamic-definition-field-missing:${row.id}`);
190
+ fields[fieldIndex] = { ...fields[fieldIndex], ...update };
191
+ }
192
+ }
193
+ if (payload.calendar !== undefined) {
194
+ existing.calendar_json =
195
+ payload.calendar === null
196
+ ? null
197
+ : calendarJsonFromPayload(
198
+ payload.calendar,
199
+ existing.name,
200
+ fields,
201
+ row.id,
202
+ new Set<string>(),
203
+ );
204
+ }
205
+ if (typeof payload.displayName === 'string') existing.display_name = payload.displayName;
206
+ existing.fields_json = JSON.stringify(fields);
207
+ existing.updated_at = occurredAt;
208
+ }
209
+
210
+ return [...definitions.values()];
211
+ }
212
+
213
+ function parsePayload(row: MetaRow): Record<string, unknown> {
214
+ try {
215
+ const payload = JSON.parse(row.payload);
216
+ if (payload && typeof payload === 'object') return payload as Record<string, unknown>;
217
+ } catch {
218
+ // Typed below so callers receive a safe build category rather than payload data.
219
+ }
220
+ throw new Error(`dynamic-definition-invalid-payload:${row.id}`);
221
+ }
222
+
223
+ /** Validate and serialize a calendar config arriving in a meta event. */
224
+ function calendarJsonFromPayload(
225
+ value: unknown,
226
+ entityName: string,
227
+ fields: readonly DynamicFieldDefinition[],
228
+ eventId: string,
229
+ parentTables: ReadonlySet<string>,
230
+ ): string | null {
231
+ if (!Array.isArray(value)) {
232
+ throw new Error(`dynamic-definition-invalid-calendar:${eventId}`);
233
+ }
234
+ const sources: EntityCalendarSource[] = [];
235
+ for (const entry of value) {
236
+ const source = sanitizeCalendarSource(entry);
237
+ if (!source) throw new Error(`dynamic-definition-invalid-calendar:${eventId}`);
238
+ sources.push(source);
239
+ }
240
+ const errors = validateCalendarConfig(entityName, sources, fields, { parentTables });
241
+ if (errors.length > 0) {
242
+ throw new Error(`dynamic-definition-invalid-calendar:${eventId}`);
243
+ }
244
+ return serializeCalendarConfig(sources);
245
+ }
246
+
247
+ /** Parse projected field JSON defensively; malformed rows fail the rebuild. */
248
+ function parseStoredFields(json: string, eventId: string): DynamicFieldDefinition[] {
249
+ try {
250
+ const parsed: unknown = JSON.parse(json);
251
+ if (Array.isArray(parsed)) return parsed as DynamicFieldDefinition[];
252
+ } catch {
253
+ // Fall through to the typed error below.
254
+ }
255
+ throw new Error(`dynamic-definition-invalid-fields:${eventId}`);
256
+ }
257
+
258
+ function requiredString(value: unknown, eventId: string, field: string): string {
259
+ if (typeof value !== 'string' || !value) {
260
+ throw new Error(`dynamic-definition-invalid-${field}:${eventId}`);
261
+ }
262
+ return value;
263
+ }
264
+
265
+ function validateFields(fields: DynamicFieldDefinition[], eventId: string): void {
266
+ const names = new Set<string>();
267
+ for (const field of fields) {
268
+ if (!field || typeof field.name !== 'string' || typeof field.payloadKey !== 'string') {
269
+ throw new Error(`dynamic-definition-invalid-field:${eventId}`);
270
+ }
271
+ assertSafeIdentifier(field.name, eventId, 'field');
272
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(field.payloadKey)) {
273
+ throw new Error(`dynamic-definition-invalid-payload-key:${eventId}`);
274
+ }
275
+ if (names.has(field.name)) throw new Error(`dynamic-definition-duplicate-field:${eventId}`);
276
+ names.add(field.name);
277
+ }
278
+ }
279
+
280
+ function assertSafeIdentifier(value: string, eventId: string, field: string): void {
281
+ if (!/^[a-z][a-z0-9_]*$/.test(value)) {
282
+ throw new Error(`dynamic-definition-invalid-${field}-identifier:${eventId}`);
283
+ }
284
+ }
285
+
286
+ function toSqliteDateTime(timestamp: number): string {
287
+ return new Date(timestamp).toISOString().slice(0, 19).replace('T', ' ');
288
+ }