@hearthlog/runtime 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/access/session.ts +223 -0
  4. package/src/access/user-session.ts +61 -0
  5. package/src/ai/decisions.ts +89 -0
  6. package/src/calendar/draft.ts +84 -0
  7. package/src/calendar/feeds.ts +91 -0
  8. package/src/calendar/groups.ts +198 -0
  9. package/src/calendar/logic.ts +141 -0
  10. package/src/calendar/preferences.ts +40 -0
  11. package/src/calendar/weeks.ts +56 -0
  12. package/src/commands/index.ts +184 -0
  13. package/src/commands/local-commit.ts +214 -0
  14. package/src/commands/standard.ts +8 -0
  15. package/src/commands/validation.ts +206 -0
  16. package/src/db/bootstrap.ts +72 -0
  17. package/src/db/calendar.ts +638 -0
  18. package/src/db/dynamic-ddl.ts +127 -0
  19. package/src/db/entities.ts +208 -0
  20. package/src/db/history.ts +194 -0
  21. package/src/db/index.ts +1240 -0
  22. package/src/db/kv.ts +33 -0
  23. package/src/db/local-statistics.ts +106 -0
  24. package/src/db/module-projections.ts +46 -0
  25. package/src/db/raw-browser.ts +361 -0
  26. package/src/db/read-repositories.ts +29 -0
  27. package/src/db/read-session.ts +47 -0
  28. package/src/db/schema-version.ts +76 -0
  29. package/src/db/session-context.ts +33 -0
  30. package/src/event/code-export.ts +65 -0
  31. package/src/event/dynamic-entities.ts +503 -0
  32. package/src/event/dynamic-materializer.ts +288 -0
  33. package/src/event/handlers.ts +490 -0
  34. package/src/event/index.ts +53 -0
  35. package/src/event/meta-entity-types.ts +165 -0
  36. package/src/event/projection-builder.ts +384 -0
  37. package/src/event/queries.ts +126 -0
  38. package/src/event/rebuild.ts +176 -0
  39. package/src/event/reference-lookup.ts +93 -0
  40. package/src/event/registry.ts +158 -0
  41. package/src/event/schema-effects.ts +45 -0
  42. package/src/event/types.ts +20 -0
  43. package/src/history/action-boundary.ts +18 -0
  44. package/src/history/action-detail.ts +23 -0
  45. package/src/history/change-comparison.ts +29 -0
  46. package/src/history/change-display.ts +41 -0
  47. package/src/history/contracts.ts +63 -0
  48. package/src/history/history.ts +372 -0
  49. package/src/history/legacy-replay.ts +69 -0
  50. package/src/history/legacy-url.ts +9 -0
  51. package/src/history/lineage.ts +123 -0
  52. package/src/history/log-format.ts +17 -0
  53. package/src/history/log-map.ts +110 -0
  54. package/src/history/navigation.ts +14 -0
  55. package/src/history/point-snapshot.ts +229 -0
  56. package/src/history/timeline.ts +215 -0
  57. package/src/history/url.ts +18 -0
  58. package/src/ledger/README.md +118 -0
  59. package/src/ledger/admin-client.ts +616 -0
  60. package/src/ledger/client.ts +10 -0
  61. package/src/ledger/config.ts +117 -0
  62. package/src/ledger/entity-id.ts +115 -0
  63. package/src/ledger/errors.ts +144 -0
  64. package/src/ledger/http-client.ts +590 -0
  65. package/src/ledger/index.ts +67 -0
  66. package/src/ledger/socket.ts +400 -0
  67. package/src/ledger/types.ts +202 -0
  68. package/src/ledger/version-tracker-singleton.ts +12 -0
  69. package/src/ledger/version-tracker.ts +259 -0
  70. package/src/log-management.ts +123 -0
  71. package/src/log-validation.ts +19 -0
  72. package/src/native/reminders.ts +2 -0
  73. package/src/native/shell.svelte.ts +136 -0
  74. package/src/native/shell.ts +102 -0
  75. package/src/pwa/registration.ts +169 -0
  76. package/src/recovery/local-recovery.ts +59 -0
  77. package/src/recovery/reset-sequence.ts +13 -0
  78. package/src/runtime/clock.ts +43 -0
  79. package/src/runtime/kept-page-data.svelte.ts +42 -0
  80. package/src/runtime/page-data.svelte.ts +160 -0
  81. package/src/runtime/phone-header.svelte.ts +17 -0
  82. package/src/runtime/read-coordinator.ts +103 -0
  83. package/src/runtime/refresh.ts +15 -0
  84. package/src/runtime/start-page.ts +56 -0
  85. package/src/runtime/view.ts +83 -0
  86. package/src/runtime/workspace.ts +62 -0
  87. package/src/storage/indexeddb-statistics.ts +153 -0
  88. package/src/store/attention.svelte.ts +51 -0
  89. package/src/store/conflict-merge.ts +169 -0
  90. package/src/store/entity-sync-state.svelte.ts +36 -0
  91. package/src/store/event-log.svelte.ts +109 -0
  92. package/src/store/events.svelte.ts +14 -0
  93. package/src/store/local-context.ts +93 -0
  94. package/src/store/local-store.svelte.ts +58 -0
  95. package/src/store/log-version.svelte.ts +35 -0
  96. package/src/store/logs.svelte.ts +60 -0
  97. package/src/store/moved.svelte.ts +43 -0
  98. package/src/store/offline-readiness.svelte.ts +188 -0
  99. package/src/store/outbox-request.ts +61 -0
  100. package/src/store/outbox.ts +426 -0
  101. package/src/store/pending-work-transfer.ts +49 -0
  102. package/src/store/pending-work.ts +143 -0
  103. package/src/store/sync-coordinator.ts +170 -0
  104. package/src/store/sync-invalidation.ts +10 -0
  105. package/src/store/sync-issues.ts +406 -0
  106. package/src/store/sync-progress.ts +54 -0
  107. package/src/store/sync-state.ts +127 -0
  108. package/src/store/sync.svelte.ts +1010 -0
  109. package/src/store/tab-coordination.ts +164 -0
  110. package/src/testing/e2e-seed.ts +40 -0
  111. package/src/testing/local-database.ts +120 -0
  112. package/src/testing/projection-database.ts +105 -0
  113. package/src/testing/test-entities.ts +28 -0
  114. package/src/theme/appearance.svelte.ts +111 -0
  115. package/src/utils/address.ts +5 -0
  116. package/src/utils/async.ts +30 -0
  117. package/src/utils/long-press.ts +70 -0
  118. package/src/utils/perf-dataset.ts +405 -0
  119. package/src/utils/perf.ts +627 -0
  120. package/README.md +0 -3
@@ -0,0 +1,1240 @@
1
+ import { repairStoredProgress } from "../store/sync-progress.js";
2
+ /**
3
+ * Database Configuration
4
+ *
5
+ * Entity types and tables are auto-generated from entity-definitions.ts.
6
+ * Core tables (raw_events, entity_versions) are defined here.
7
+ *
8
+ * IMPORTANT: Do not manually edit entity types here.
9
+ * Instead, update entity-definitions.ts and run `pnpm generate:entities`.
10
+ */
11
+
12
+ import { SQLocalKysely } from "sqlocal/kysely";
13
+ import { Kysely, type ColumnType, sql } from "kysely";
14
+
15
+ import {
16
+ type EntityDatabaseTables,
17
+ getEntityDDLForTables,
18
+ getEntityIndexDDLForTables,
19
+ ENTITY_TABLE_NAMES,
20
+ } from "#generated/schema";
21
+ import {
22
+ getSessionContext,
23
+ setSessionContext,
24
+ type ActiveContext,
25
+ } from "./session-context.js";
26
+ import { tenant } from "@hearthlog/contracts/tenant/config";
27
+ import { SCHEMA_LOCK_NAME, withTabLock } from "../store/tab-coordination.js";
28
+ import { MIN_IN_PLACE_SCHEMA_VERSION, SCHEMA_VERSION } from "./schema-version.js";
29
+ import { MODULE_PROJECTIONS } from "#generated/modules";
30
+ import { allModuleTables, BASELINE_PROJECTION_VERSION } from "./module-projections.js";
31
+
32
+ export type { ActiveContext } from "./session-context.js";
33
+
34
+ // ============================================================================
35
+ // Core Tables (not entity-driven)
36
+ // ============================================================================
37
+
38
+ export type RawEventTable = {
39
+ id: string;
40
+ tenant_id: string;
41
+ log_id: string;
42
+ local_batch_id: string;
43
+ local_sequence: number;
44
+ action_id: ColumnType<
45
+ string | null,
46
+ string | null | undefined,
47
+ string | null
48
+ >;
49
+ action_index: ColumnType<
50
+ number | null,
51
+ number | null | undefined,
52
+ number | null
53
+ >;
54
+ action_size: ColumnType<
55
+ number | null,
56
+ number | null | undefined,
57
+ number | null
58
+ >;
59
+ /** Identity Ledger recorded for the request that wrote the event. */
60
+ actor: ColumnType<
61
+ string | null,
62
+ string | null | undefined,
63
+ string | null
64
+ >;
65
+ type: string;
66
+ timestamp: number;
67
+ payload: string;
68
+ entity_id: string | null;
69
+ entity_version: number | null;
70
+ log_version: number | null;
71
+ sync_state: string;
72
+ projection_state: string;
73
+ rejection_code: string | null;
74
+ rejection_detail: string | null;
75
+ is_tombstoned: ColumnType<number, number | undefined, number>;
76
+ created_at: ColumnType<string, string | undefined, never>;
77
+ };
78
+
79
+ export type EntityVersionTable = {
80
+ tenant_id: string;
81
+ log_id: string;
82
+ entity_id: string;
83
+ current_version: number;
84
+ log_version: number;
85
+ updated_at: number;
86
+ };
87
+
88
+ export type ActiveContextTable = {
89
+ id: number;
90
+ tenant_id: string;
91
+ principal_id: string;
92
+ log_id: string;
93
+ ledger_origin: string;
94
+ updated_at: string;
95
+ };
96
+
97
+ export type LocalBatchTable = {
98
+ id: string;
99
+ tenant_id: string;
100
+ log_id: string;
101
+ status: string;
102
+ replacement_for: string | null;
103
+ created_at: ColumnType<string, string | undefined, never>;
104
+ updated_at: ColumnType<string, string | undefined, string>;
105
+ };
106
+
107
+ export type OutboxOperationTable = {
108
+ id: string;
109
+ request_id: string;
110
+ tenant_id: string;
111
+ log_id: string;
112
+ batch_id: string;
113
+ request_body: string;
114
+ request_hash: string;
115
+ status: string;
116
+ retry_count: number;
117
+ rebase_count: number;
118
+ last_error_code: string | null;
119
+ last_error_message: string | null;
120
+ superseded_by: string | null;
121
+ next_retry_at: string | null;
122
+ created_at: ColumnType<string, string | undefined, never>;
123
+ updated_at: ColumnType<string, string | undefined, string>;
124
+ };
125
+
126
+ export type OutboxOperationItemTable = {
127
+ operation_id: string;
128
+ event_id: string;
129
+ ordinal: number;
130
+ };
131
+
132
+ export type LogSyncStateTable = {
133
+ tenant_id: string;
134
+ log_id: string;
135
+ downloaded_head_version: number;
136
+ projected_head_version: number;
137
+ last_synced_at: string | null;
138
+ };
139
+
140
+ export type ResolutionActionTable = {
141
+ id: string;
142
+ tenant_id: string;
143
+ log_id: string;
144
+ batch_id: string;
145
+ action: string;
146
+ details: string | null;
147
+ created_at: ColumnType<string, string | undefined, never>;
148
+ };
149
+
150
+ export type SyncLogTable = {
151
+ id: string;
152
+ timestamp: number;
153
+ type: string;
154
+ message: string;
155
+ details: string | null;
156
+ created_at: ColumnType<string, string | undefined, never>;
157
+ };
158
+
159
+ export type EntityTypeDefinitionTable = {
160
+ tenant_id: string;
161
+ log_id: string;
162
+ id: string;
163
+ name: string;
164
+ display_name: string;
165
+ table_name: string;
166
+ fields_json: string; // JSON array of DynamicFieldDefinition
167
+ /** JSON array of EntityCalendarSource; null when not on the calendar. */
168
+ calendar_json: ColumnType<string | null, string | null | undefined, string | null>;
169
+ is_archived: ColumnType<number, number | undefined, number>;
170
+ created_at: ColumnType<string, string | undefined, never>;
171
+ updated_at: ColumnType<string, string | undefined, string>;
172
+ };
173
+
174
+ // ============================================================================
175
+ // Database Interface
176
+ // ============================================================================
177
+
178
+ /** One row per module: the projection version of its read-model tables. */
179
+ export type ModuleMigrationTable = {
180
+ module_id: string;
181
+ projection_version: number;
182
+ applied_at: string;
183
+ };
184
+
185
+ export interface Database extends EntityDatabaseTables {
186
+ module_migrations: ModuleMigrationTable;
187
+ raw_events: RawEventTable;
188
+ entity_versions: EntityVersionTable;
189
+ active_context: ActiveContextTable;
190
+ local_batches: LocalBatchTable;
191
+ outbox_operations: OutboxOperationTable;
192
+ outbox_operation_items: OutboxOperationItemTable;
193
+ log_sync_state: LogSyncStateTable;
194
+ resolution_actions: ResolutionActionTable;
195
+ sync_logs: SyncLogTable;
196
+ entity_type_definitions: EntityTypeDefinitionTable;
197
+ }
198
+
199
+ // ============================================================================
200
+ // Database Initialization
201
+ // ============================================================================
202
+
203
+ // Singleton pattern for HMR support
204
+ // We attach the DB instance to globalThis to prevent multiple workers spawning during hot reloads
205
+ // which causes database locking issues.
206
+
207
+ // SAFETY: `globalThis` is an untyped host object; the only property this
208
+ // module reads from it is the SQLocal singleton it wrote itself under HMR.
209
+ const globalForDb = globalThis as unknown as {
210
+ sqlocalDb: SQLocalKysely | undefined;
211
+ };
212
+
213
+ export const sqlocalDb =
214
+ globalForDb.sqlocalDb ??
215
+ new SQLocalKysely({
216
+ databasePath: import.meta.env.VITE_E2E === "1" ? "session" : "stor",
217
+ onInit: (sql) => {
218
+ // Get entity DDLs from the generated schema, restricted to the
219
+ // entities this tenant is approved for (feature-scoped SQLite):
220
+ // tenants only get projection tables for their enabled features.
221
+ const entityDDLs = getEntityDDLForTables(
222
+ tenant.approvedEntities.tables,
223
+ ).map((ddl) => ({ sql: ddl, params: [] }));
224
+
225
+ // Core table DDLs
226
+ const rawEventsDDL = sql`
227
+ CREATE TABLE IF NOT EXISTS "raw_events" (
228
+ "id" TEXT NOT NULL,
229
+ "tenant_id" TEXT NOT NULL DEFAULT 'legacy-unassigned',
230
+ "log_id" TEXT NOT NULL DEFAULT 'legacy-unassigned',
231
+ "local_batch_id" TEXT NOT NULL,
232
+ "local_sequence" INTEGER NOT NULL DEFAULT 0,
233
+ action_id TEXT, action_index INTEGER, action_size INTEGER,
234
+ actor TEXT,
235
+ "type" TEXT NOT NULL,
236
+ "timestamp" INTEGER NOT NULL,
237
+ "payload" TEXT NOT NULL,
238
+ "entity_id" TEXT,
239
+ "entity_version" INTEGER,
240
+ "log_version" INTEGER,
241
+ "sync_state" TEXT NOT NULL DEFAULT 'pending',
242
+ "projection_state" TEXT NOT NULL DEFAULT 'effective',
243
+ "rejection_code" TEXT,
244
+ "rejection_detail" TEXT,
245
+ "is_tombstoned" INTEGER DEFAULT 0,
246
+ "created_at" TEXT DEFAULT (datetime('now')),
247
+ PRIMARY KEY ("tenant_id", "log_id", "id")
248
+ );
249
+ `;
250
+
251
+ const entityVersionsDDL = sql`
252
+ CREATE TABLE IF NOT EXISTS "entity_versions" (
253
+ "tenant_id" TEXT NOT NULL,
254
+ "log_id" TEXT NOT NULL,
255
+ "entity_id" TEXT NOT NULL,
256
+ "current_version" INTEGER NOT NULL,
257
+ "log_version" INTEGER NOT NULL,
258
+ "updated_at" INTEGER NOT NULL,
259
+ PRIMARY KEY ("tenant_id", "log_id", "entity_id")
260
+ );
261
+ `;
262
+
263
+ const activeContextDDL = sql`
264
+ CREATE TABLE IF NOT EXISTS "active_context" (
265
+ "id" INTEGER PRIMARY KEY CHECK ("id" = 1),
266
+ "tenant_id" TEXT NOT NULL,
267
+ "principal_id" TEXT NOT NULL,
268
+ "log_id" TEXT NOT NULL,
269
+ "ledger_origin" TEXT NOT NULL,
270
+ "updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
271
+ );
272
+ `;
273
+
274
+ const localBatchesDDL = sql`
275
+ CREATE TABLE IF NOT EXISTS "local_batches" (
276
+ "id" TEXT PRIMARY KEY,
277
+ "tenant_id" TEXT NOT NULL,
278
+ "log_id" TEXT NOT NULL,
279
+ "status" TEXT NOT NULL DEFAULT 'pending',
280
+ "replacement_for" TEXT,
281
+ "created_at" TEXT DEFAULT (datetime('now')),
282
+ "updated_at" TEXT DEFAULT (datetime('now'))
283
+ );
284
+ `;
285
+
286
+ const outboxOperationsDDL = sql`
287
+ CREATE TABLE IF NOT EXISTS "outbox_operations" (
288
+ "id" TEXT PRIMARY KEY,
289
+ "request_id" TEXT NOT NULL,
290
+ "tenant_id" TEXT NOT NULL,
291
+ "log_id" TEXT NOT NULL,
292
+ "batch_id" TEXT NOT NULL,
293
+ "request_body" TEXT NOT NULL,
294
+ "request_hash" TEXT NOT NULL,
295
+ "status" TEXT NOT NULL DEFAULT 'pending',
296
+ "retry_count" INTEGER NOT NULL DEFAULT 0,
297
+ "rebase_count" INTEGER NOT NULL DEFAULT 0,
298
+ "last_error_code" TEXT,
299
+ "last_error_message" TEXT,
300
+ "superseded_by" TEXT,
301
+ "next_retry_at" TEXT,
302
+ "created_at" TEXT DEFAULT (datetime('now')),
303
+ "updated_at" TEXT DEFAULT (datetime('now')),
304
+ UNIQUE ("tenant_id", "log_id", "request_id")
305
+ );
306
+ `;
307
+
308
+ const outboxOperationItemsDDL = sql`
309
+ CREATE TABLE IF NOT EXISTS "outbox_operation_items" (
310
+ "operation_id" TEXT NOT NULL,
311
+ "event_id" TEXT NOT NULL,
312
+ "ordinal" INTEGER NOT NULL,
313
+ PRIMARY KEY ("operation_id", "ordinal")
314
+ );
315
+ `;
316
+
317
+ const logSyncStateDDL = sql`
318
+ CREATE TABLE IF NOT EXISTS "log_sync_state" (
319
+ "tenant_id" TEXT NOT NULL,
320
+ "log_id" TEXT NOT NULL,
321
+ "downloaded_head_version" INTEGER NOT NULL DEFAULT 0,
322
+ "projected_head_version" INTEGER NOT NULL DEFAULT 0,
323
+ "last_synced_at" TEXT,
324
+ PRIMARY KEY ("tenant_id", "log_id")
325
+ );
326
+ `;
327
+
328
+ const resolutionActionsDDL = sql`
329
+ CREATE TABLE IF NOT EXISTS "resolution_actions" (
330
+ "id" TEXT PRIMARY KEY,
331
+ "tenant_id" TEXT NOT NULL,
332
+ "log_id" TEXT NOT NULL,
333
+ "batch_id" TEXT NOT NULL,
334
+ "action" TEXT NOT NULL,
335
+ "details" TEXT,
336
+ "created_at" TEXT DEFAULT (datetime('now'))
337
+ );
338
+ `;
339
+
340
+ // Indexes
341
+ const rawEventsTimestampIdx = sql`
342
+ CREATE INDEX IF NOT EXISTS "idx_raw_events_timestamp"
343
+ ON "raw_events" ("timestamp");
344
+ `;
345
+
346
+ const rawEventsTypeIdx = sql`
347
+ CREATE INDEX IF NOT EXISTS "idx_raw_events_type"
348
+ ON "raw_events" ("type");
349
+ `;
350
+
351
+ const rawEventsEntityIdx = sql`
352
+ CREATE INDEX IF NOT EXISTS "idx_raw_events_entity_id"
353
+ ON "raw_events" ("entity_id");
354
+ `;
355
+
356
+ const rawEventsTypeTsIdx = sql`
357
+ CREATE INDEX IF NOT EXISTS "idx_raw_events_type_ts"
358
+ ON "raw_events" ("type", "timestamp");
359
+ `;
360
+
361
+ const rawEventsEntityVersionIdx = sql`
362
+ CREATE INDEX IF NOT EXISTS "idx_raw_events_entity_version"
363
+ ON "raw_events" ("entity_id", "entity_version");
364
+ `;
365
+
366
+ const rawEventsLogVersionIdx = sql`
367
+ CREATE INDEX IF NOT EXISTS "idx_raw_events_log_version"
368
+ ON "raw_events" ("log_version");
369
+ `;
370
+
371
+ const localBatchesScopeIdx = sql`
372
+ CREATE INDEX IF NOT EXISTS "idx_local_batches_scope_status"
373
+ ON "local_batches" ("tenant_id", "log_id", "status", "created_at");
374
+ `;
375
+
376
+ const outboxOperationsScopeIdx = sql`
377
+ CREATE INDEX IF NOT EXISTS "idx_outbox_operations_scope_status"
378
+ ON "outbox_operations" ("tenant_id", "log_id", "status", "created_at");
379
+ `;
380
+
381
+ const syncLogsDDL = sql`
382
+ CREATE TABLE IF NOT EXISTS "sync_logs" (
383
+ "id" TEXT PRIMARY KEY,
384
+ "timestamp" INTEGER NOT NULL,
385
+ "type" TEXT NOT NULL,
386
+ "message" TEXT NOT NULL,
387
+ "details" TEXT,
388
+ "created_at" TEXT DEFAULT (datetime('now'))
389
+ );
390
+ `;
391
+
392
+ const syncLogsTimestampIdx = sql`
393
+ CREATE INDEX IF NOT EXISTS "idx_sync_logs_timestamp"
394
+ ON "sync_logs" ("timestamp" DESC);
395
+ `;
396
+
397
+ const entityTypeDefinitionsDDL = sql`
398
+ CREATE TABLE IF NOT EXISTS "entity_type_definitions" (
399
+ "tenant_id" TEXT NOT NULL,
400
+ "log_id" TEXT NOT NULL,
401
+ "id" TEXT NOT NULL,
402
+ "name" TEXT NOT NULL,
403
+ "display_name" TEXT NOT NULL,
404
+ "table_name" TEXT NOT NULL,
405
+ "fields_json" TEXT NOT NULL,
406
+ "calendar_json" TEXT,
407
+ "is_archived" INTEGER DEFAULT 0,
408
+ "created_at" TEXT DEFAULT (datetime('now')),
409
+ "updated_at" TEXT DEFAULT (datetime('now')),
410
+ PRIMARY KEY ("tenant_id", "log_id", "id"),
411
+ UNIQUE ("tenant_id", "log_id", "name"),
412
+ UNIQUE ("tenant_id", "log_id", "table_name")
413
+ );
414
+ `;
415
+
416
+ return [
417
+ ...entityDDLs,
418
+ rawEventsDDL,
419
+ rawEventsTimestampIdx,
420
+ rawEventsTypeIdx,
421
+ rawEventsEntityIdx,
422
+ rawEventsTypeTsIdx,
423
+ rawEventsEntityVersionIdx,
424
+ rawEventsLogVersionIdx,
425
+ entityVersionsDDL,
426
+ activeContextDDL,
427
+ localBatchesDDL,
428
+ localBatchesScopeIdx,
429
+ outboxOperationsDDL,
430
+ outboxOperationsScopeIdx,
431
+ outboxOperationItemsDDL,
432
+ logSyncStateDDL,
433
+ resolutionActionsDDL,
434
+ // Sync logs:
435
+ syncLogsDDL,
436
+ syncLogsTimestampIdx,
437
+ // Entity type definitions:
438
+ entityTypeDefinitionsDDL,
439
+ ];
440
+ },
441
+ });
442
+
443
+ if (process.env.NODE_ENV !== "production") {
444
+ globalForDb.sqlocalDb = sqlocalDb;
445
+ }
446
+
447
+ const { dialect } = sqlocalDb;
448
+ export const db = new Kysely<Database>({ dialect });
449
+
450
+ /**
451
+ * Ensures core tables have required columns that might have been added in later versions.
452
+ * This is a simple migration helper for core tables.
453
+ */
454
+ export interface SchemaMigrationContext {
455
+ tenantId: string;
456
+ logId: string;
457
+ }
458
+
459
+ export async function ensureCoreSchema(
460
+ context?: SchemaMigrationContext,
461
+ database: Kysely<Database> = db,
462
+ approvedTables: ReadonlySet<string> = tenant.approvedEntities.tables,
463
+ ): Promise<void> {
464
+ // Current-version feature reconciliation can change tables too. Inspect
465
+ // and repair inside the same lock used by migrations and read-model rebuilds.
466
+ await withTabLock(SCHEMA_LOCK_NAME, () =>
467
+ migrateCoreSchema(context, database, approvedTables),
468
+ );
469
+ }
470
+
471
+ /** Check the actual schema in one worker exchange, without a persisted cache. */
472
+ async function inspectCoreSchema(
473
+ database: Kysely<Database>,
474
+ approvedTables: ReadonlySet<string>,
475
+ ): Promise<{ user_version: number; healthy: number }> {
476
+ const projectionTables = ENTITY_TABLE_NAMES.filter((name) => approvedTables.has(name));
477
+ const expectedTables = [
478
+ { name: 'raw_events', pk: ['tenant_id', 'log_id', 'id'] },
479
+ { name: 'entity_versions', pk: ['tenant_id', 'log_id', 'entity_id'] },
480
+ { name: 'entity_type_definitions', pk: ['tenant_id', 'log_id', 'id'] },
481
+ { name: 'module_migrations', pk: ['module_id'] },
482
+ ...projectionTables.map((name) => ({ name, pk: ['tenant_id', 'log_id', 'id'] })),
483
+ ];
484
+ // Names come from generated DDL, so the check follows entity definitions
485
+ // without maintaining another index registry.
486
+ const expectedIndexes = getEntityIndexDDLForTables(approvedTables).map((ddl) => {
487
+ const match = ddl.match(/^\s*CREATE INDEX IF NOT EXISTS "([^"]+)"\s+ON "([^"]+)"/i);
488
+ if (!match) throw new Error('Unrecognized generated entity index DDL');
489
+ return { name: match[1], table: match[2] };
490
+ });
491
+ const result = await sql<{ user_version: number; healthy: number }>`
492
+ WITH expected_tables AS (
493
+ SELECT json_extract(value, '$.name') AS name,
494
+ json_extract(value, '$.pk') AS primary_key
495
+ FROM json_each(${JSON.stringify(expectedTables)})
496
+ ), expected_indexes AS (
497
+ SELECT json_extract(value, '$.name') AS name,
498
+ json_extract(value, '$.table') AS table_name
499
+ FROM json_each(${JSON.stringify(expectedIndexes)})
500
+ )
501
+ SELECT (SELECT user_version FROM pragma_user_version) AS user_version,
502
+ NOT EXISTS (
503
+ SELECT 1 FROM expected_tables AS expected
504
+ WHERE (
505
+ SELECT json_group_array(name) FROM (
506
+ SELECT name FROM pragma_table_info(expected.name, 'main')
507
+ WHERE pk > 0 ORDER BY pk
508
+ )
509
+ ) != expected.primary_key
510
+ ) AND NOT EXISTS (
511
+ SELECT 1 FROM sqlite_master
512
+ WHERE type = 'table'
513
+ AND name IN (SELECT value FROM json_each(${JSON.stringify(ENTITY_TABLE_NAMES)}))
514
+ AND name NOT IN (SELECT value FROM json_each(${JSON.stringify(projectionTables)}))
515
+ ) AND NOT EXISTS (
516
+ SELECT 1 FROM expected_indexes AS expected
517
+ WHERE NOT EXISTS (
518
+ SELECT 1 FROM sqlite_master
519
+ WHERE type = 'index' AND name = expected.name AND tbl_name = expected.table_name
520
+ )
521
+ ) AS healthy
522
+ `.execute(database);
523
+ return result.rows[0];
524
+ }
525
+
526
+ /**
527
+ * Drops the given module projection tables and recreates the approved ones empty, with their
528
+ * indexes. Read models are derived from `raw_events`, so the next rebuild refills them; events,
529
+ * the outbox, and the core's own tables are never touched. The caller holds the schema lock.
530
+ */
531
+ export async function recreateModuleProjectionTables(
532
+ database: Kysely<Database>,
533
+ tables: readonly string[],
534
+ approvedTables: ReadonlySet<string> = tenant.approvedEntities.tables,
535
+ ): Promise<void> {
536
+ for (const table of tables) {
537
+ await sql.raw(`DROP TABLE IF EXISTS "${table}"`).execute(database);
538
+ }
539
+ const recreated = new Set(tables.filter((table) => approvedTables.has(table)));
540
+ for (const ddl of getEntityDDLForTables(recreated)) await sql.raw(ddl).execute(database);
541
+ for (const ddl of getEntityIndexDDLForTables(recreated)) await sql.raw(ddl).execute(database);
542
+ }
543
+
544
+ async function migrateCoreSchema(
545
+ context: SchemaMigrationContext | undefined,
546
+ database: Kysely<Database>,
547
+ approvedTables: ReadonlySet<string>,
548
+ ): Promise<void> {
549
+ const schema = await inspectCoreSchema(database, approvedTables);
550
+ if (schema.user_version === SCHEMA_VERSION) {
551
+ if (schema.healthy) return;
552
+ // Feature sets can change between builds without a schema version
553
+ // bump: reconcile when the actual schema differs. Dropping is safe —
554
+ // the ledger remains authoritative and raw_events keeps the history.
555
+ await reconcileEntityScope(database, approvedTables);
556
+ await validateCoreSchema(database, approvedTables);
557
+ return;
558
+ }
559
+
560
+ // A database older than the floor is not repaired column by column: its module read models
561
+ // are recreated empty and the rebuild that follows a version change refills them.
562
+ if (schema.user_version < MIN_IN_PLACE_SCHEMA_VERSION) {
563
+ await recreateModuleProjectionTables(database, allModuleTables(), approvedTables);
564
+ }
565
+
566
+ async function addColumn(statement: ReturnType<typeof sql>): Promise<void> {
567
+ try {
568
+ await statement.execute(database);
569
+ } catch (error: any) {
570
+ if (error?.message?.includes("duplicate column name")) {
571
+ return;
572
+ }
573
+ if (error?.message?.includes("no such table")) {
574
+ return;
575
+ }
576
+ throw error;
577
+ }
578
+ }
579
+
580
+ // SQLite doesn't have ADD COLUMN IF NOT EXISTS, so duplicate-column errors are ignored.
581
+ await addColumn(
582
+ sql`ALTER TABLE "raw_events" ADD COLUMN "is_tombstoned" INTEGER DEFAULT 0`,
583
+ );
584
+ await addColumn(
585
+ sql`ALTER TABLE "raw_events" ADD COLUMN "log_version" INTEGER`,
586
+ );
587
+ await addColumn(sql`ALTER TABLE "raw_events" ADD COLUMN "tenant_id" TEXT`);
588
+ await addColumn(sql`ALTER TABLE "raw_events" ADD COLUMN "log_id" TEXT`);
589
+ await addColumn(
590
+ sql`ALTER TABLE "raw_events" ADD COLUMN "local_batch_id" TEXT`,
591
+ );
592
+ await addColumn(
593
+ sql`ALTER TABLE "raw_events" ADD COLUMN "local_sequence" INTEGER DEFAULT 0`,
594
+ );
595
+ await addColumn(
596
+ sql`ALTER TABLE "raw_events" ADD COLUMN "sync_state" TEXT DEFAULT 'pending'`,
597
+ );
598
+ await addColumn(
599
+ sql`ALTER TABLE "raw_events" ADD COLUMN "projection_state" TEXT DEFAULT 'effective'`,
600
+ );
601
+ await addColumn(
602
+ sql`ALTER TABLE "raw_events" ADD COLUMN "rejection_code" TEXT`,
603
+ );
604
+ await addColumn(
605
+ sql`ALTER TABLE "raw_events" ADD COLUMN "rejection_detail" TEXT`,
606
+ );
607
+
608
+ await addColumn(sql`ALTER TABLE raw_events ADD COLUMN action_id TEXT`);
609
+ await addColumn(
610
+ sql`ALTER TABLE raw_events ADD COLUMN action_index INTEGER`,
611
+ );
612
+ await addColumn(sql`ALTER TABLE raw_events ADD COLUMN action_size INTEGER`);
613
+ await addColumn(sql`ALTER TABLE raw_events ADD COLUMN actor TEXT`);
614
+
615
+ // Generated CREATE TABLE statements are idempotent and ensure newly added
616
+ // approved entities exist even when Vite HMR keeps the SQLite worker
617
+ // alive across code updates. Non-approved entities are never created.
618
+ for (const ddl of getEntityDDLForTables(approvedTables)) {
619
+ await sql.raw(ddl).execute(database);
620
+ }
621
+
622
+ await createScopedSupportTables(database);
623
+ // The pre-Ledger outbox was replaced by outbox_operations; nothing reads it.
624
+ await sql`DROP TABLE IF EXISTS outbox_events`.execute(database);
625
+ await addColumn(
626
+ sql`ALTER TABLE "outbox_operations" ADD COLUMN "next_retry_at" TEXT`,
627
+ );
628
+ await sql`CREATE INDEX IF NOT EXISTS idx_outbox_operations_retry ON outbox_operations (tenant_id, log_id, status, next_retry_at)`.execute(
629
+ database,
630
+ );
631
+
632
+ const tenantId = context?.tenantId ?? "legacy-unassigned";
633
+ const logId = context?.logId ?? "legacy-unassigned";
634
+ await sql`
635
+ UPDATE raw_events
636
+ SET tenant_id = COALESCE(tenant_id, ${tenantId}),
637
+ log_id = COALESCE(log_id, ${logId}),
638
+ local_batch_id = COALESCE(local_batch_id, id),
639
+ local_sequence = CASE WHEN local_sequence = 0 THEN rowid ELSE local_sequence END,
640
+ sync_state = CASE
641
+ WHEN sync_state IS NULL THEN CASE WHEN log_version IS NULL THEN 'pending' ELSE 'synced' END
642
+ WHEN sync_state = 'pending' AND log_version IS NOT NULL THEN 'synced'
643
+ ELSE sync_state
644
+ END,
645
+ projection_state = COALESCE(projection_state, 'effective')
646
+ `.execute(database);
647
+
648
+ await migrateRawEventsTable(database);
649
+ await migrateEntityVersionsTable(context, database);
650
+ await migrateEntityProjectionTables(context, database, approvedTables);
651
+ await migrateEntityTypeDefinitionsTable(context, database);
652
+ await addColumn(
653
+ sql`ALTER TABLE entity_type_definitions ADD COLUMN calendar_json TEXT`,
654
+ );
655
+ await createScopedSupportTables(database);
656
+ // v42: tables that predate the projection ledger are at the baseline version.
657
+ for (const { module } of MODULE_PROJECTIONS) {
658
+ await sql`INSERT OR IGNORE INTO module_migrations (module_id, projection_version)
659
+ VALUES (${module}, ${BASELINE_PROJECTION_VERSION})`.execute(database);
660
+ }
661
+ await reconcileEntityScope(database, approvedTables);
662
+ await createEntityIndexes(database, approvedTables);
663
+
664
+ await sql`
665
+ INSERT OR IGNORE INTO local_batches (id, tenant_id, log_id, status, created_at, updated_at)
666
+ SELECT local_batch_id, tenant_id, log_id,
667
+ CASE WHEN MAX(log_version) IS NULL THEN 'pending' ELSE 'synced' END,
668
+ MIN(created_at), datetime('now')
669
+ FROM raw_events
670
+ GROUP BY tenant_id, log_id, local_batch_id
671
+ `.execute(database);
672
+
673
+ await validateCoreSchema(database, approvedTables);
674
+ await repairStoredProgress(database);
675
+ await sql.raw(`PRAGMA user_version = ${SCHEMA_VERSION}`).execute(database);
676
+ }
677
+
678
+ async function createScopedSupportTables(
679
+ database: Kysely<Database>,
680
+ ): Promise<void> {
681
+ await sql`CREATE TABLE IF NOT EXISTS active_context (
682
+ id INTEGER PRIMARY KEY CHECK (id = 1), tenant_id TEXT NOT NULL,
683
+ principal_id TEXT NOT NULL, log_id TEXT NOT NULL, ledger_origin TEXT NOT NULL,
684
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
685
+ )`.execute(database);
686
+ await sql`CREATE TABLE IF NOT EXISTS local_batches (
687
+ id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, log_id TEXT NOT NULL,
688
+ status TEXT NOT NULL DEFAULT 'pending', replacement_for TEXT,
689
+ created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))
690
+ )`.execute(database);
691
+ await sql`CREATE TABLE IF NOT EXISTS outbox_operations (
692
+ id TEXT PRIMARY KEY, request_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
693
+ log_id TEXT NOT NULL, batch_id TEXT NOT NULL, request_body TEXT NOT NULL,
694
+ request_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
695
+ retry_count INTEGER NOT NULL DEFAULT 0, rebase_count INTEGER NOT NULL DEFAULT 0,
696
+ last_error_code TEXT, last_error_message TEXT, superseded_by TEXT,
697
+ next_retry_at TEXT,
698
+ created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
699
+ UNIQUE (tenant_id, log_id, request_id)
700
+ )`.execute(database);
701
+ await sql`CREATE TABLE IF NOT EXISTS outbox_operation_items (
702
+ operation_id TEXT NOT NULL, event_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
703
+ PRIMARY KEY (operation_id, ordinal)
704
+ )`.execute(database);
705
+ await sql`CREATE TABLE IF NOT EXISTS log_sync_state (
706
+ tenant_id TEXT NOT NULL, log_id TEXT NOT NULL,
707
+ downloaded_head_version INTEGER NOT NULL DEFAULT 0,
708
+ projected_head_version INTEGER NOT NULL DEFAULT 0, last_synced_at TEXT,
709
+ PRIMARY KEY (tenant_id, log_id)
710
+ )`.execute(database);
711
+ await sql`CREATE TABLE IF NOT EXISTS module_migrations (
712
+ module_id TEXT PRIMARY KEY, projection_version INTEGER NOT NULL,
713
+ applied_at TEXT NOT NULL DEFAULT (datetime('now'))
714
+ )`.execute(database);
715
+ await sql`CREATE TABLE IF NOT EXISTS resolution_actions (
716
+ id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, log_id TEXT NOT NULL,
717
+ batch_id TEXT NOT NULL, action TEXT NOT NULL, details TEXT,
718
+ created_at TEXT DEFAULT (datetime('now'))
719
+ )`.execute(database);
720
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_scope_order ON raw_events (tenant_id, log_id, log_version, local_sequence)`.execute(
721
+ database,
722
+ );
723
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_batch ON raw_events (tenant_id, log_id, local_batch_id, local_sequence)`.execute(
724
+ database,
725
+ );
726
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_timestamp ON raw_events (timestamp)`.execute(
727
+ database,
728
+ );
729
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_type ON raw_events (type)`.execute(
730
+ database,
731
+ );
732
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_entity_id ON raw_events (entity_id)`.execute(
733
+ database,
734
+ );
735
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_type_ts ON raw_events (type, timestamp)`.execute(
736
+ database,
737
+ );
738
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_entity_version ON raw_events (entity_id, entity_version)`.execute(
739
+ database,
740
+ );
741
+ await sql`CREATE INDEX IF NOT EXISTS idx_raw_events_log_version ON raw_events (log_version)`.execute(
742
+ database,
743
+ );
744
+ await sql`CREATE INDEX IF NOT EXISTS idx_local_batches_scope_status ON local_batches (tenant_id, log_id, status, created_at)`.execute(
745
+ database,
746
+ );
747
+ await sql`CREATE INDEX IF NOT EXISTS idx_outbox_operations_scope_status ON outbox_operations (tenant_id, log_id, status, created_at)`.execute(
748
+ database,
749
+ );
750
+ }
751
+
752
+ async function createEntityIndexes(
753
+ database: Kysely<Database>,
754
+ approvedTables: ReadonlySet<string>,
755
+ ): Promise<void> {
756
+ // Generated entity indexes are deliberately created after legacy projection
757
+ // tables have been migrated to their current shape. Creating scoped indexes in
758
+ // SQLocal's onInit hook would fail before an unscoped database can be migrated.
759
+ for (const ddl of getEntityIndexDDLForTables(approvedTables)) {
760
+ await sql.raw(ddl).execute(database);
761
+ }
762
+ await sql`CREATE INDEX IF NOT EXISTS idx_entity_type_definitions_scope_name ON entity_type_definitions (tenant_id, log_id, name)`.execute(
763
+ database,
764
+ );
765
+ }
766
+
767
+ /**
768
+ * Reconciles the entity projection tables with the tenant's approved entity
769
+ * set: tables of non-approved entities are dropped (the ledger remains
770
+ * authoritative and raw_events keeps the full history, so re-enabling a
771
+ * feature later re-projects cleanly), and approved tables that are missing
772
+ * are recreated idempotently (feature re-enabled).
773
+ */
774
+ async function reconcileEntityScope(
775
+ database: Kysely<Database>,
776
+ approvedTables: ReadonlySet<string>,
777
+ ): Promise<void> {
778
+ for (const tableName of ENTITY_TABLE_NAMES) {
779
+ const exists = await sql<{ name: string }>`
780
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}
781
+ `.execute(database);
782
+ if (exists.rows.length === 0) continue;
783
+ if (approvedTables.has(tableName)) continue;
784
+ await sql.raw(`DROP TABLE "${tableName}"`).execute(database);
785
+ }
786
+ for (const ddl of getEntityDDLForTables(approvedTables)) {
787
+ await sql.raw(ddl).execute(database);
788
+ }
789
+ for (const ddl of getEntityIndexDDLForTables(approvedTables)) {
790
+ await sql.raw(ddl).execute(database);
791
+ }
792
+ }
793
+
794
+ interface SchemaColumnInfo {
795
+ name: string;
796
+ pk: number;
797
+ }
798
+
799
+ async function validateScopedPrimaryKey(
800
+ database: Kysely<Database>,
801
+ tableName: string,
802
+ expectedPrimaryKey: string[],
803
+ ): Promise<void> {
804
+ const columns = await sql<SchemaColumnInfo>`
805
+ PRAGMA table_info(${sql.raw(`"${tableName.replace(/"/g, '""')}"`)})
806
+ `.execute(database);
807
+ if (columns.rows.length === 0) {
808
+ throw new Error(
809
+ `Schema migration did not create required table "${tableName}"`,
810
+ );
811
+ }
812
+
813
+ const columnNames = new Set(columns.rows.map((column) => column.name));
814
+ for (const requiredColumn of expectedPrimaryKey) {
815
+ if (!columnNames.has(requiredColumn)) {
816
+ throw new Error(
817
+ `Schema migration left table "${tableName}" without required column "${requiredColumn}"`,
818
+ );
819
+ }
820
+ }
821
+
822
+ const actualPrimaryKey = columns.rows
823
+ .filter((column) => column.pk > 0)
824
+ .sort((left, right) => left.pk - right.pk)
825
+ .map((column) => column.name);
826
+ if (actualPrimaryKey.join(",") !== expectedPrimaryKey.join(",")) {
827
+ throw new Error(
828
+ `Schema migration left table "${tableName}" with primary key (${actualPrimaryKey.join(", ")}) instead of (${expectedPrimaryKey.join(", ")})`,
829
+ );
830
+ }
831
+ }
832
+
833
+ async function validateCoreSchema(
834
+ database: Kysely<Database>,
835
+ approvedTables: ReadonlySet<string> = tenant.approvedEntities.tables,
836
+ ): Promise<void> {
837
+ await validateScopedPrimaryKey(database, "raw_events", [
838
+ "tenant_id",
839
+ "log_id",
840
+ "id",
841
+ ]);
842
+ await validateScopedPrimaryKey(database, "entity_versions", [
843
+ "tenant_id",
844
+ "log_id",
845
+ "entity_id",
846
+ ]);
847
+ await validateScopedPrimaryKey(database, "entity_type_definitions", [
848
+ "tenant_id",
849
+ "log_id",
850
+ "id",
851
+ ]);
852
+ for (const tableName of ENTITY_TABLE_NAMES) {
853
+ if (!approvedTables.has(tableName)) continue;
854
+ await validateScopedPrimaryKey(database, tableName, [
855
+ "tenant_id",
856
+ "log_id",
857
+ "id",
858
+ ]);
859
+ }
860
+ }
861
+
862
+ export async function getLocalSchemaVersion(
863
+ database: Kysely<Database> = db,
864
+ ): Promise<number> {
865
+ const result = await sql<{
866
+ user_version: number;
867
+ }>`PRAGMA user_version`.execute(database);
868
+ return Number(result.rows[0]?.user_version ?? 0);
869
+ }
870
+
871
+ async function migrateRawEventsTable(
872
+ database: Kysely<Database>,
873
+ ): Promise<void> {
874
+ const columns = await sql<{
875
+ name: string;
876
+ pk: number;
877
+ }>`PRAGMA table_info(raw_events)`.execute(database);
878
+ const primaryKey = columns.rows
879
+ .filter((column) => column.pk > 0)
880
+ .sort((left, right) => left.pk - right.pk)
881
+ .map((column) => column.name);
882
+ if (primaryKey.join(",") === "tenant_id,log_id,id") return;
883
+
884
+ await database.transaction().execute(async (tx) => {
885
+ await sql`DROP TABLE IF EXISTS raw_events_scoped`.execute(tx);
886
+ await sql`CREATE TABLE raw_events_scoped (
887
+ id TEXT NOT NULL, tenant_id TEXT NOT NULL, log_id TEXT NOT NULL,
888
+ local_batch_id TEXT NOT NULL, local_sequence INTEGER NOT NULL DEFAULT 0,
889
+ action_id TEXT, action_index INTEGER, action_size INTEGER,
890
+ actor TEXT,
891
+ type TEXT NOT NULL, timestamp INTEGER NOT NULL, payload TEXT NOT NULL,
892
+ entity_id TEXT, entity_version INTEGER, log_version INTEGER,
893
+ sync_state TEXT NOT NULL DEFAULT 'pending',
894
+ projection_state TEXT NOT NULL DEFAULT 'effective',
895
+ rejection_code TEXT, rejection_detail TEXT,
896
+ is_tombstoned INTEGER DEFAULT 0,
897
+ created_at TEXT DEFAULT (datetime('now')),
898
+ PRIMARY KEY (tenant_id, log_id, id)
899
+ )`.execute(tx);
900
+ await sql`
901
+ INSERT INTO raw_events_scoped (
902
+ id, tenant_id, log_id, local_batch_id, local_sequence, type,
903
+ timestamp, payload, entity_id, entity_version, log_version,
904
+ sync_state, projection_state, rejection_code, rejection_detail,
905
+ action_id, action_index, action_size, actor,
906
+ is_tombstoned, created_at
907
+ )
908
+ SELECT id, tenant_id, log_id, local_batch_id, local_sequence, type,
909
+ timestamp, payload, entity_id, entity_version, log_version,
910
+ sync_state, projection_state, rejection_code, rejection_detail,
911
+ action_id, action_index, action_size, actor,
912
+ is_tombstoned, created_at
913
+ FROM raw_events
914
+ `.execute(tx);
915
+ await sql`DROP TABLE raw_events`.execute(tx);
916
+ await sql`ALTER TABLE raw_events_scoped RENAME TO raw_events`.execute(
917
+ tx,
918
+ );
919
+ });
920
+ }
921
+
922
+ async function migrateEntityVersionsTable(
923
+ context: SchemaMigrationContext | undefined,
924
+ database: Kysely<Database>,
925
+ ): Promise<void> {
926
+ const columns = await sql<{
927
+ name: string;
928
+ }>`PRAGMA table_info(entity_versions)`.execute(database);
929
+ if (columns.rows.some((column) => column.name === "tenant_id")) return;
930
+
931
+ const tenantId = context?.tenantId ?? "legacy-unassigned";
932
+ const logId = context?.logId ?? "legacy-unassigned";
933
+ await database.transaction().execute(async (tx) => {
934
+ await sql`DROP TABLE IF EXISTS entity_versions_scoped`.execute(tx);
935
+ await sql`CREATE TABLE entity_versions_scoped (
936
+ tenant_id TEXT NOT NULL, log_id TEXT NOT NULL, entity_id TEXT NOT NULL,
937
+ current_version INTEGER NOT NULL, log_version INTEGER NOT NULL, updated_at INTEGER NOT NULL,
938
+ PRIMARY KEY (tenant_id, log_id, entity_id)
939
+ )`.execute(tx);
940
+ await sql`
941
+ INSERT INTO entity_versions_scoped
942
+ (tenant_id, log_id, entity_id, current_version, log_version, updated_at)
943
+ SELECT ${tenantId}, ${logId}, entity_id, current_version, log_version, updated_at
944
+ FROM entity_versions
945
+ `.execute(tx);
946
+ await sql`DROP TABLE entity_versions`.execute(tx);
947
+ await sql`ALTER TABLE entity_versions_scoped RENAME TO entity_versions`.execute(
948
+ tx,
949
+ );
950
+ });
951
+ }
952
+
953
+ /**
954
+ * Activates context for this tab and persists last-used defaults for cold start.
955
+ * Concurrent tabs keep their own session context; only the shared last-used row is overwritten.
956
+ */
957
+ export async function setActiveContext(context: ActiveContext): Promise<void> {
958
+ setSessionContext(context);
959
+ await ensureCoreSchema({
960
+ tenantId: context.tenantId,
961
+ logId: context.logId,
962
+ });
963
+ const persisted = await db
964
+ .selectFrom("active_context")
965
+ .select(["tenant_id", "principal_id", "log_id", "ledger_origin"])
966
+ .where("id", "=", 1)
967
+ .executeTakeFirst();
968
+ const contextChanged =
969
+ !persisted ||
970
+ persisted.tenant_id !== context.tenantId ||
971
+ persisted.principal_id !== context.principalId ||
972
+ persisted.log_id !== context.logId ||
973
+ persisted.ledger_origin !== context.ledgerOrigin;
974
+ if (contextChanged) {
975
+ await db
976
+ .insertInto("active_context")
977
+ .values({
978
+ id: 1,
979
+ tenant_id: context.tenantId,
980
+ principal_id: context.principalId,
981
+ log_id: context.logId,
982
+ ledger_origin: context.ledgerOrigin,
983
+ updated_at: new Date().toISOString(),
984
+ })
985
+ .onConflict((oc) =>
986
+ oc.column("id").doUpdateSet({
987
+ tenant_id: context.tenantId,
988
+ principal_id: context.principalId,
989
+ log_id: context.logId,
990
+ ledger_origin: context.ledgerOrigin,
991
+ updated_at: new Date().toISOString(),
992
+ }),
993
+ )
994
+ .execute();
995
+ }
996
+
997
+ const syncState = await db
998
+ .selectFrom("log_sync_state")
999
+ .select("log_id")
1000
+ .where("tenant_id", "=", context.tenantId)
1001
+ .where("log_id", "=", context.logId)
1002
+ .executeTakeFirst();
1003
+ if (!syncState) {
1004
+ await db
1005
+ .insertInto("log_sync_state")
1006
+ .values({
1007
+ tenant_id: context.tenantId,
1008
+ log_id: context.logId,
1009
+ downloaded_head_version: 0,
1010
+ projected_head_version: 0,
1011
+ last_synced_at: null,
1012
+ })
1013
+ .execute();
1014
+ }
1015
+ }
1016
+
1017
+ /** Last-used context persisted in OPFS (shared across tabs). Prefer getActiveContext(). */
1018
+ export async function getPersistedActiveContext(): Promise<ActiveContext | null> {
1019
+ const row = await db
1020
+ .selectFrom("active_context")
1021
+ .selectAll()
1022
+ .where("id", "=", 1)
1023
+ .executeTakeFirst();
1024
+ return row
1025
+ ? {
1026
+ tenantId: row.tenant_id,
1027
+ principalId: row.principal_id,
1028
+ logId: row.log_id,
1029
+ ledgerOrigin: row.ledger_origin,
1030
+ }
1031
+ : null;
1032
+ }
1033
+
1034
+ /**
1035
+ * This tab's active context. Session (tab-local) wins; OPFS last-used is cold-start fallback only.
1036
+ */
1037
+ export async function getActiveContext(): Promise<ActiveContext | null> {
1038
+ return getSessionContext() ?? (await getPersistedActiveContext());
1039
+ }
1040
+
1041
+ export async function requireActiveContext(): Promise<ActiveContext> {
1042
+ const session = getSessionContext();
1043
+ if (session) return session;
1044
+
1045
+ const persisted = await getPersistedActiveContext();
1046
+ if (!persisted)
1047
+ throw new Error("Local Ledger context has not been initialized");
1048
+
1049
+ // Hydrate this tab so subsequent work does not re-read a row another tab may overwrite.
1050
+ setSessionContext(persisted);
1051
+ return persisted;
1052
+ }
1053
+
1054
+ export async function clearActiveContext(): Promise<void> {
1055
+ setSessionContext(null);
1056
+ await db.deleteFrom("active_context").where("id", "=", 1).execute();
1057
+ }
1058
+
1059
+ /**
1060
+ * Migrates entity projection tables to composite (tenant_id, log_id, id) primary keys.
1061
+ * Existing rows are attributed to the provided context (or legacy-unassigned).
1062
+ * Schema version rebuild will re-project cleanly; this preserves offline data when possible.
1063
+ */
1064
+ async function migrateEntityProjectionTables(
1065
+ context: SchemaMigrationContext | undefined,
1066
+ database: Kysely<Database>,
1067
+ approvedTables: ReadonlySet<string>,
1068
+ ): Promise<void> {
1069
+ const tenantId = context?.tenantId ?? "legacy-unassigned";
1070
+ const logId = context?.logId ?? "legacy-unassigned";
1071
+
1072
+ for (const tableName of ENTITY_TABLE_NAMES) {
1073
+ if (!approvedTables.has(tableName)) continue;
1074
+ const exists = await sql<{ name: string }>`
1075
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}
1076
+ `.execute(database);
1077
+ if (exists.rows.length === 0) continue;
1078
+
1079
+ const typedCols = await sql<{
1080
+ name: string;
1081
+ type: string;
1082
+ notnull: number;
1083
+ dflt_value: string | null;
1084
+ pk: number;
1085
+ }>`
1086
+ PRAGMA table_info(${sql.raw(`"${tableName}"`)})
1087
+ `.execute(database);
1088
+
1089
+ const hasTenant = typedCols.rows.some(
1090
+ (column) => column.name === "tenant_id",
1091
+ );
1092
+ const primaryKey = typedCols.rows
1093
+ .filter((column) => column.pk > 0)
1094
+ .sort((left, right) => left.pk - right.pk)
1095
+ .map((column) => column.name);
1096
+ if (hasTenant && primaryKey.join(",") === "tenant_id,log_id,id")
1097
+ continue;
1098
+
1099
+ const dataColumns = typedCols.rows
1100
+ .map((column) => column.name)
1101
+ .filter((name) => name !== "tenant_id" && name !== "log_id");
1102
+ const scopedName = `${tableName}__scoped`;
1103
+
1104
+ const bodyCols = typedCols.rows
1105
+ .filter((c) => c.name !== "tenant_id" && c.name !== "log_id")
1106
+ .map((c) => {
1107
+ if (c.name === "id") return '"id" TEXT NOT NULL';
1108
+ const notNull = c.notnull ? " NOT NULL" : "";
1109
+ const defaultValue = c.dflt_value?.trim();
1110
+ const normalizedDefault =
1111
+ defaultValue &&
1112
+ /^[A-Za-z_][A-Za-z0-9_]*\s*\(/.test(defaultValue)
1113
+ ? `(${defaultValue})`
1114
+ : defaultValue;
1115
+ const dflt =
1116
+ normalizedDefault != null
1117
+ ? ` DEFAULT ${normalizedDefault}`
1118
+ : "";
1119
+ return `"${c.name}" ${c.type || "TEXT"}${notNull}${dflt}`;
1120
+ });
1121
+
1122
+ await database.transaction().execute(async (tx) => {
1123
+ await sql`DROP TABLE IF EXISTS ${sql.raw(`"${scopedName}"`)}`.execute(
1124
+ tx,
1125
+ );
1126
+ await sql`
1127
+ CREATE TABLE ${sql.raw(`"${scopedName}"`)} (
1128
+ "tenant_id" TEXT NOT NULL,
1129
+ "log_id" TEXT NOT NULL,
1130
+ ${sql.raw(bodyCols.join(", "))},
1131
+ PRIMARY KEY ("tenant_id", "log_id", "id")
1132
+ )
1133
+ `.execute(tx);
1134
+
1135
+ const insertList = ["tenant_id", "log_id", ...dataColumns]
1136
+ .map((name) => `"${name}"`)
1137
+ .join(", ");
1138
+ const selectList = [
1139
+ sql`${tenantId}`,
1140
+ sql`${logId}`,
1141
+ ...dataColumns.map((name) => sql.raw(`"${name}"`)),
1142
+ ];
1143
+
1144
+ // Bind scope values; copy remaining columns by name.
1145
+ await sql`
1146
+ INSERT INTO ${sql.raw(`"${scopedName}"`)} (${sql.raw(insertList)})
1147
+ SELECT ${sql.join(selectList, sql`, `)}
1148
+ FROM ${sql.raw(`"${tableName}"`)}
1149
+ `.execute(tx);
1150
+
1151
+ await sql`DROP TABLE ${sql.raw(`"${tableName}"`)}`.execute(tx);
1152
+ await sql`ALTER TABLE ${sql.raw(`"${scopedName}"`)} RENAME TO ${sql.raw(`"${tableName}"`)}`.execute(
1153
+ tx,
1154
+ );
1155
+ });
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Migrates entity_type_definitions to composite (tenant_id, log_id, id) primary keys.
1161
+ * Name/table uniqueness becomes per-log rather than global.
1162
+ */
1163
+ async function migrateEntityTypeDefinitionsTable(
1164
+ context: SchemaMigrationContext | undefined,
1165
+ database: Kysely<Database>,
1166
+ ): Promise<void> {
1167
+ const exists = await sql<{ name: string }>`
1168
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_type_definitions'
1169
+ `.execute(database);
1170
+ if (exists.rows.length === 0) return;
1171
+
1172
+ const typedCols = await sql<{ name: string; pk: number }>`
1173
+ PRAGMA table_info(entity_type_definitions)
1174
+ `.execute(database);
1175
+ const hasTenant = typedCols.rows.some(
1176
+ (column) => column.name === "tenant_id",
1177
+ );
1178
+ const primaryKey = typedCols.rows
1179
+ .filter((column) => column.pk > 0)
1180
+ .sort((left, right) => left.pk - right.pk)
1181
+ .map((column) => column.name);
1182
+ if (hasTenant && primaryKey.join(",") === "tenant_id,log_id,id") return;
1183
+
1184
+ const tenantId = context?.tenantId ?? "legacy-unassigned";
1185
+ const logId = context?.logId ?? "legacy-unassigned";
1186
+
1187
+ await database.transaction().execute(async (tx) => {
1188
+ await sql`DROP TABLE IF EXISTS entity_type_definitions_scoped`.execute(
1189
+ tx,
1190
+ );
1191
+ await sql`
1192
+ CREATE TABLE entity_type_definitions_scoped (
1193
+ tenant_id TEXT NOT NULL,
1194
+ log_id TEXT NOT NULL,
1195
+ id TEXT NOT NULL,
1196
+ name TEXT NOT NULL,
1197
+ display_name TEXT NOT NULL,
1198
+ table_name TEXT NOT NULL,
1199
+ fields_json TEXT NOT NULL,
1200
+ is_archived INTEGER DEFAULT 0,
1201
+ created_at TEXT DEFAULT (datetime('now')),
1202
+ updated_at TEXT DEFAULT (datetime('now')),
1203
+ PRIMARY KEY (tenant_id, log_id, id),
1204
+ UNIQUE (tenant_id, log_id, name),
1205
+ UNIQUE (tenant_id, log_id, table_name)
1206
+ )
1207
+ `.execute(tx);
1208
+
1209
+ if (hasTenant) {
1210
+ await sql`
1211
+ INSERT INTO entity_type_definitions_scoped (
1212
+ tenant_id, log_id, id, name, display_name, table_name,
1213
+ fields_json, is_archived, created_at, updated_at
1214
+ )
1215
+ SELECT tenant_id, log_id, id, name, display_name, table_name,
1216
+ fields_json, is_archived, created_at, updated_at
1217
+ FROM entity_type_definitions
1218
+ `.execute(tx);
1219
+ } else {
1220
+ await sql`
1221
+ INSERT INTO entity_type_definitions_scoped (
1222
+ tenant_id, log_id, id, name, display_name, table_name,
1223
+ fields_json, is_archived, created_at, updated_at
1224
+ )
1225
+ SELECT ${tenantId}, ${logId}, id, name, display_name, table_name,
1226
+ fields_json, is_archived, created_at, updated_at
1227
+ FROM entity_type_definitions
1228
+ `.execute(tx);
1229
+ }
1230
+
1231
+ await sql`DROP TABLE entity_type_definitions`.execute(tx);
1232
+ await sql`ALTER TABLE entity_type_definitions_scoped RENAME TO entity_type_definitions`.execute(
1233
+ tx,
1234
+ );
1235
+ await sql`
1236
+ CREATE INDEX IF NOT EXISTS idx_entity_type_definitions_scope_name
1237
+ ON entity_type_definitions (tenant_id, log_id, name)
1238
+ `.execute(tx);
1239
+ });
1240
+ }