@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,164 @@
1
+ /**
2
+ * Coordination between tabs that share one local database.
3
+ *
4
+ * Tabs keep their own session state but write the same SQLite file, so three
5
+ * things need an agreement: who owns the live connection to Ledger, that sync
6
+ * and migrations do not overlap, and that a change one tab made reaches the
7
+ * others. Web Locks decide the first two, a BroadcastChannel carries the third.
8
+ * Without those APIs (server rendering, old browsers) every function degrades to
9
+ * a single tab's behavior.
10
+ */
11
+
12
+ interface LockRequestOptions {
13
+ mode?: 'exclusive';
14
+ ifAvailable?: boolean;
15
+ signal?: AbortSignal;
16
+ }
17
+
18
+ export interface LockManagerLike {
19
+ request<T>(
20
+ name: string,
21
+ options: LockRequestOptions,
22
+ callback: (lock: unknown | null) => T | Promise<T>,
23
+ ): Promise<T>;
24
+ }
25
+
26
+ export interface TabScope {
27
+ tenantId: string;
28
+ logId: string;
29
+ }
30
+
31
+ /** What changed, so a tab refreshes only what it must. */
32
+ export type TabChangeKind =
33
+ /** A local command was committed; pending counts and pages change. */
34
+ | 'commit'
35
+ /** Events from Ledger were ingested or acknowledged. */
36
+ | 'ingest'
37
+ /** The read model was rebuilt; in-memory caches are stale. */
38
+ | 'rebuild'
39
+ /** Entity type definitions or the schema changed; the registry is stale. */
40
+ | 'schema';
41
+
42
+ export interface TabChange extends TabScope {
43
+ kind: TabChangeKind;
44
+ }
45
+
46
+ export function scopeKey(scope: TabScope): string {
47
+ return `${scope.tenantId}:${scope.logId}`;
48
+ }
49
+
50
+ export const realtimeLockName = (scope: TabScope) => `stor:realtime:${scopeKey(scope)}`;
51
+ export const syncCycleLockName = (scope: TabScope) => `stor:sync:${scopeKey(scope)}`;
52
+ export const SCHEMA_LOCK_NAME = 'stor:schema';
53
+
54
+ function browserLocks(): LockManagerLike | null {
55
+ if (typeof navigator === 'undefined' || !('locks' in navigator)) return null;
56
+ return navigator.locks as unknown as LockManagerLike;
57
+ }
58
+
59
+ /** Run a task while no other tab holds the lock; waits its turn. */
60
+ export async function withTabLock<T>(
61
+ name: string,
62
+ task: () => Promise<T>,
63
+ locks: LockManagerLike | null = browserLocks(),
64
+ ): Promise<T> {
65
+ if (!locks) return task();
66
+ return locks.request(name, { mode: 'exclusive' }, task);
67
+ }
68
+
69
+ /**
70
+ * Wait for a lock and keep it until the returned function is called. Calling it
71
+ * before the lock is granted withdraws the request. `onAcquired` runs once the
72
+ * lock is this tab's, and the lock is released if the tab closes.
73
+ */
74
+ export function holdTabLock(
75
+ name: string,
76
+ onAcquired: () => void | Promise<void>,
77
+ locks: LockManagerLike | null = browserLocks(),
78
+ ): () => void {
79
+ if (!locks) {
80
+ void Promise.resolve(onAcquired());
81
+ return () => undefined;
82
+ }
83
+ const withdraw = new AbortController();
84
+ let release: () => void = () => undefined;
85
+ const held = new Promise<void>((resolve) => { release = resolve; });
86
+ locks.request(name, { mode: 'exclusive', signal: withdraw.signal }, async () => {
87
+ await onAcquired();
88
+ await held;
89
+ }).catch((error: unknown) => {
90
+ // Withdrawing a queued request rejects it; nothing else may fail here.
91
+ if (!(error instanceof DOMException && error.name === 'AbortError')) {
92
+ console.error('[Tabs] Lock request failed', error);
93
+ }
94
+ });
95
+ return () => {
96
+ withdraw.abort();
97
+ release();
98
+ };
99
+ }
100
+
101
+ const CHANNEL_NAME = 'stor:tab-changes';
102
+
103
+ export interface TabChannel {
104
+ post(change: TabChange): void;
105
+ subscribe(listener: (change: TabChange) => void): () => void;
106
+ close(): void;
107
+ }
108
+
109
+ function isTabChange(value: unknown): value is TabChange {
110
+ if (!value || typeof value !== 'object') return false;
111
+ const change = value as Partial<TabChange>;
112
+ return typeof change.tenantId === 'string'
113
+ && typeof change.logId === 'string'
114
+ && (change.kind === 'commit' || change.kind === 'ingest'
115
+ || change.kind === 'rebuild' || change.kind === 'schema');
116
+ }
117
+
118
+ /** A channel never delivers to the tab that posted, so a handler cannot echo itself. */
119
+ export function createTabChannel(name = CHANNEL_NAME): TabChannel {
120
+ if (typeof BroadcastChannel === 'undefined') {
121
+ return { post() {}, subscribe: () => () => undefined, close() {} };
122
+ }
123
+ const channel = new BroadcastChannel(name);
124
+ // Node keeps the process alive for an open channel; browsers have no unref.
125
+ (channel as unknown as { unref?: () => void }).unref?.();
126
+ const listeners = new Set<(change: TabChange) => void>();
127
+ channel.onmessage = (message: MessageEvent<unknown>) => {
128
+ if (!isTabChange(message.data)) return;
129
+ for (const listener of listeners) listener(message.data);
130
+ };
131
+ return {
132
+ post: (change) => channel.postMessage(change),
133
+ subscribe(listener) {
134
+ listeners.add(listener);
135
+ return () => listeners.delete(listener);
136
+ },
137
+ close() {
138
+ listeners.clear();
139
+ channel.close();
140
+ },
141
+ };
142
+ }
143
+
144
+ let shared: TabChannel | null = null;
145
+ // Kept on globalThis so a hot reload does not leave two open channels.
146
+ const globalForChannel = globalThis as unknown as { storTabChannel?: TabChannel };
147
+
148
+ function sharedChannel(): TabChannel {
149
+ shared ??= globalForChannel.storTabChannel ??= createTabChannel();
150
+ return shared;
151
+ }
152
+
153
+ export function postTabChange(change: TabChange): void {
154
+ try {
155
+ sharedChannel().post(change);
156
+ } catch (error) {
157
+ // A closed channel must never fail the write that triggered the post.
158
+ console.warn('[Tabs] Could not notify other tabs', error);
159
+ }
160
+ }
161
+
162
+ export function onTabChange(listener: (change: TabChange) => void): () => void {
163
+ return sharedChannel().subscribe(listener);
164
+ }
@@ -0,0 +1,40 @@
1
+ import { handleIncomingStoredEvents } from '../event/index.js';
2
+ import type { StoredEvent } from '../ledger/types.js';
3
+ import { notifyEventsChanged } from '../store/events.svelte.js';
4
+ import { db } from '../db/index.js';
5
+ import { sql } from 'kysely';
6
+
7
+ export interface StorE2ESeedApi {
8
+ seedSyncedEvents(events: StoredEvent[]): Promise<{ hasNewEvents: boolean; newCount: number }>;
9
+ resetDatabase(): Promise<void>;
10
+ }
11
+
12
+ export function installE2ESeedHelper(): void {
13
+ if (import.meta.env.VITE_E2E !== '1' || typeof window === 'undefined') return;
14
+ window.__storE2E = {
15
+ async resetDatabase() {
16
+ const tables = await sql<{ name: string }>`
17
+ SELECT name
18
+ FROM sqlite_master
19
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
20
+ `.execute(db);
21
+ await sql.raw('PRAGMA foreign_keys = OFF').execute(db);
22
+ try {
23
+ await db.transaction().execute(async (tx) => {
24
+ for (const { name } of tables.rows) {
25
+ const quoted = `"${name.replace(/"/g, '""')}"`;
26
+ await sql.raw(`DELETE FROM ${quoted}`).execute(tx);
27
+ }
28
+ });
29
+ } finally {
30
+ await sql.raw('PRAGMA foreign_keys = ON').execute(db);
31
+ }
32
+ },
33
+ async seedSyncedEvents(events) {
34
+ const cloned = structuredClone(events).sort((left, right) => left.version - right.version);
35
+ const result = await handleIncomingStoredEvents(cloned);
36
+ notifyEventsChanged();
37
+ return result;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,120 @@
1
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
2
+ import { Kysely, SqliteDialect } from 'kysely';
3
+ import type { Database } from '../db/index.js';
4
+ import { getEntityDDLForTables } from '#generated/schema';
5
+
6
+ /** Independent SQLite stores for persistence/convergence tests; no browser globals. */
7
+ export function createLocalDatabase(tables: readonly string[] = ["calendar_events"]) {
8
+ const sqlite = new DatabaseSync(':memory:');
9
+ sqlite.exec(`
10
+ CREATE TABLE IF NOT EXISTS "raw_events" (
11
+ "id" TEXT NOT NULL,
12
+ "tenant_id" TEXT NOT NULL DEFAULT 'legacy-unassigned',
13
+ "log_id" TEXT NOT NULL DEFAULT 'legacy-unassigned',
14
+ "local_batch_id" TEXT NOT NULL,
15
+ "local_sequence" INTEGER NOT NULL DEFAULT 0,
16
+ action_id TEXT, action_index INTEGER, action_size INTEGER, actor TEXT,
17
+ "type" TEXT NOT NULL,
18
+ "timestamp" INTEGER NOT NULL,
19
+ "payload" TEXT NOT NULL,
20
+ "entity_id" TEXT,
21
+ "entity_version" INTEGER,
22
+ "log_version" INTEGER,
23
+ "sync_state" TEXT NOT NULL DEFAULT 'pending',
24
+ "projection_state" TEXT NOT NULL DEFAULT 'effective',
25
+ "rejection_code" TEXT,
26
+ "rejection_detail" TEXT,
27
+ "is_tombstoned" INTEGER DEFAULT 0,
28
+ "created_at" TEXT DEFAULT (datetime('now')),
29
+ PRIMARY KEY ("tenant_id", "log_id", "id")
30
+ );
31
+
32
+
33
+ CREATE TABLE IF NOT EXISTS "entity_versions" (
34
+ "tenant_id" TEXT NOT NULL,
35
+ "log_id" TEXT NOT NULL,
36
+ "entity_id" TEXT NOT NULL,
37
+ "current_version" INTEGER NOT NULL,
38
+ "log_version" INTEGER NOT NULL,
39
+ "updated_at" INTEGER NOT NULL,
40
+ PRIMARY KEY ("tenant_id", "log_id", "entity_id")
41
+ );
42
+
43
+
44
+ CREATE TABLE IF NOT EXISTS "local_batches" (
45
+ "id" TEXT PRIMARY KEY,
46
+ "tenant_id" TEXT NOT NULL,
47
+ "log_id" TEXT NOT NULL,
48
+ "status" TEXT NOT NULL DEFAULT 'pending',
49
+ "replacement_for" TEXT,
50
+ "created_at" TEXT DEFAULT (datetime('now')),
51
+ "updated_at" TEXT DEFAULT (datetime('now'))
52
+ );
53
+
54
+
55
+ CREATE TABLE IF NOT EXISTS "outbox_operations" (
56
+ "id" TEXT PRIMARY KEY,
57
+ "request_id" TEXT NOT NULL,
58
+ "tenant_id" TEXT NOT NULL,
59
+ "log_id" TEXT NOT NULL,
60
+ "batch_id" TEXT NOT NULL,
61
+ "request_body" TEXT NOT NULL,
62
+ "request_hash" TEXT NOT NULL,
63
+ "status" TEXT NOT NULL DEFAULT 'pending',
64
+ "retry_count" INTEGER NOT NULL DEFAULT 0,
65
+ "rebase_count" INTEGER NOT NULL DEFAULT 0,
66
+ "last_error_code" TEXT,
67
+ "last_error_message" TEXT,
68
+ "superseded_by" TEXT,
69
+ "next_retry_at" TEXT,
70
+ "created_at" TEXT DEFAULT (datetime('now')),
71
+ "updated_at" TEXT DEFAULT (datetime('now')),
72
+ UNIQUE ("tenant_id", "log_id", "request_id")
73
+ );
74
+
75
+
76
+ CREATE TABLE IF NOT EXISTS "outbox_operation_items" (
77
+ "operation_id" TEXT NOT NULL,
78
+ "event_id" TEXT NOT NULL,
79
+ "ordinal" INTEGER NOT NULL,
80
+ PRIMARY KEY ("operation_id", "ordinal")
81
+ );
82
+
83
+
84
+ CREATE TABLE IF NOT EXISTS "log_sync_state" (
85
+ "tenant_id" TEXT NOT NULL,
86
+ "log_id" TEXT NOT NULL,
87
+ "downloaded_head_version" INTEGER NOT NULL DEFAULT 0,
88
+ "projected_head_version" INTEGER NOT NULL DEFAULT 0,
89
+ "last_synced_at" TEXT,
90
+ PRIMARY KEY ("tenant_id", "log_id")
91
+ );
92
+
93
+
94
+ CREATE TABLE IF NOT EXISTS "resolution_actions" (
95
+ "id" TEXT PRIMARY KEY,
96
+ "tenant_id" TEXT NOT NULL,
97
+ "log_id" TEXT NOT NULL,
98
+ "batch_id" TEXT NOT NULL,
99
+ "action" TEXT NOT NULL,
100
+ "details" TEXT,
101
+ "created_at" TEXT DEFAULT (datetime('now'))
102
+ );
103
+
104
+ CREATE TABLE sync_logs (id TEXT PRIMARY KEY, timestamp INTEGER, type TEXT, message TEXT, details TEXT);
105
+ `);
106
+ for (const ddl of getEntityDDLForTables(new Set(tables))) sqlite.exec(ddl);
107
+ const database = new Kysely<Database>({ dialect: new SqliteDialect({ database: {
108
+ close: () => sqlite.close(),
109
+ prepare(query: string) {
110
+ const statement = sqlite.prepare(query);
111
+ return {
112
+ reader: /^\s*(?:PRAGMA|SELECT|WITH|EXPLAIN)/i.test(query),
113
+ all: (params: readonly unknown[]) => statement.all(...params as SQLInputValue[]),
114
+ run: (params: readonly unknown[]) => statement.run(...params as SQLInputValue[]),
115
+ iterate: (params: readonly unknown[]) => statement.iterate(...params as SQLInputValue[]),
116
+ };
117
+ },
118
+ } }) });
119
+ return { database, sqlite };
120
+ }
@@ -0,0 +1,105 @@
1
+ import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
2
+ import { Kysely, SqliteDialect } from 'kysely';
3
+ import type { Database } from '../db/index.js';
4
+ import { getEntityDDLForTables } from '#generated/schema';
5
+ import { buildBulkProjectionSQL, projectEntitySet, PROJECTION_CONFIGS } from '../event/projection-builder.js';
6
+
7
+ export type TestScope = { tenantId: string; logId: string };
8
+
9
+ export interface TestEvent {
10
+ id: string;
11
+ type: string;
12
+ timestamp: number;
13
+ payload: Record<string, unknown>;
14
+ entityId: string | null;
15
+ entityVersion: number | null;
16
+ logVersion: number | null;
17
+ sequence?: number;
18
+ syncState?: 'pending' | 'synced';
19
+ projectionState?: 'effective' | 'rejected';
20
+ tombstoned?: boolean;
21
+ }
22
+
23
+ /**
24
+ * An in-memory database for projection tests: `raw_events`, the read-model tables of the entities
25
+ * named, and optionally a copy of those tables in a `history` schema. No browser globals.
26
+ * Modules reach it through `@hearthlog/sdk/testing`; it never exposes the app's database type.
27
+ */
28
+ export function createProjectionTestDatabase(options: { tables: readonly string[]; scope?: TestScope; history?: boolean }) {
29
+ const scope = options.scope ?? { tenantId: 'tenant-1', logId: 'log-1' };
30
+ const sqlite = new DatabaseSync(':memory:');
31
+ sqlite.exec(`
32
+ CREATE TABLE raw_events (
33
+ id TEXT NOT NULL, tenant_id TEXT NOT NULL, log_id TEXT NOT NULL,
34
+ local_batch_id TEXT NOT NULL, local_sequence INTEGER NOT NULL,
35
+ type TEXT NOT NULL, timestamp INTEGER NOT NULL, payload TEXT NOT NULL,
36
+ entity_id TEXT, entity_version INTEGER, log_version INTEGER,
37
+ sync_state TEXT NOT NULL, projection_state TEXT NOT NULL,
38
+ rejection_code TEXT, rejection_detail TEXT, is_tombstoned INTEGER NOT NULL DEFAULT 0,
39
+ created_at TEXT DEFAULT (datetime('now')),
40
+ PRIMARY KEY (tenant_id, log_id, id)
41
+ );
42
+ `);
43
+ for (const ddl of getEntityDDLForTables(new Set(options.tables))) sqlite.exec(ddl);
44
+ if (options.history) {
45
+ sqlite.exec(`ATTACH DATABASE ':memory:' AS history;`);
46
+ for (const table of options.tables) sqlite.exec(`CREATE TABLE history."${table}" AS SELECT * FROM main."${table}" WHERE 0;`);
47
+ }
48
+ const database = new Kysely<Database>({
49
+ dialect: new SqliteDialect({
50
+ database: {
51
+ close: () => sqlite.close(),
52
+ prepare(query: string) {
53
+ const statement = sqlite.prepare(query);
54
+ return {
55
+ reader: /^\s*(?:PRAGMA|SELECT|WITH|EXPLAIN)\b/i.test(query),
56
+ all: (parameters: ReadonlyArray<unknown>) => statement.all(...(parameters as SQLInputValue[])),
57
+ run: (parameters: ReadonlyArray<unknown>) => statement.run(...(parameters as SQLInputValue[])),
58
+ iterate: (parameters: ReadonlyArray<unknown>) => statement.iterate(...(parameters as SQLInputValue[])),
59
+ };
60
+ },
61
+ },
62
+ }),
63
+ });
64
+ const insert = sqlite.prepare(`
65
+ INSERT INTO raw_events (
66
+ id, tenant_id, log_id, local_batch_id, local_sequence, type, timestamp,
67
+ payload, entity_id, entity_version, log_version, sync_state, projection_state, is_tombstoned
68
+ ) VALUES (?, ?, ?, 'batch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
69
+ `);
70
+ let sequence = 0;
71
+ const config = (entity: string) => {
72
+ const found = PROJECTION_CONFIGS[entity];
73
+ if (!found) throw new Error(`No projection config for entity "${entity}"`);
74
+ return found;
75
+ };
76
+ return {
77
+ scope,
78
+ sqlite,
79
+ /** Appends one event to `raw_events` (accepted and effective unless said otherwise). */
80
+ addEvent(event: TestEvent): void {
81
+ sequence += 1;
82
+ insert.run(
83
+ event.id, scope.tenantId, scope.logId, event.sequence ?? sequence, event.type, event.timestamp,
84
+ JSON.stringify(event.payload), event.entityId, event.entityVersion, event.logVersion,
85
+ event.syncState ?? 'synced', event.projectionState ?? 'effective', event.tombstoned ? 1 : 0,
86
+ );
87
+ },
88
+ /** Projects the given entities from `raw_events` into their read-model tables. */
89
+ async project(entity: string, entityIds: string[]): Promise<void> {
90
+ await database.transaction().execute((tx) => projectEntitySet(config(entity), entityIds, tx, scope));
91
+ },
92
+ /** Rebuilds one entity's table from every event, optionally as of a log version into `history`. */
93
+ async rebuild(entity: string, boundary?: { version: number; schema: 'history' }): Promise<void> {
94
+ const query = boundary
95
+ ? buildBulkProjectionSQL(config(entity), scope, { boundary: { kind: 'log-version', version: boundary.version }, target: { schema: boundary.schema } })
96
+ : buildBulkProjectionSQL(config(entity), scope);
97
+ await query.execute(database);
98
+ },
99
+ async destroy(): Promise<void> {
100
+ await database.destroy();
101
+ },
102
+ };
103
+ }
104
+
105
+ export type ProjectionTestDatabase = ReturnType<typeof createProjectionTestDatabase>;
@@ -0,0 +1,28 @@
1
+ import type { EntityDefinition } from '@hearthlog/contracts/event/entity-definitions';
2
+
3
+ /**
4
+ * An entity that exists only in tests, for rules the core's own entity cannot show: a numeric field
5
+ * (`test_amount`, in the always-enabled calendar feature). A test adds it with
6
+ *
7
+ * vi.mock('@hearthlog/contracts/event/entity-definitions', async (importOriginal) =>
8
+ * (await import('./test-entities.js')).withTestEntities(await importOriginal()));
9
+ *
10
+ * so the registry, validation, and projection configs all see it. It has no generated table: a test
11
+ * that projects it creates the table itself.
12
+ */
13
+ export const TEST_ENTITY_DEFINITIONS: EntityDefinition[] = [
14
+ {
15
+ name: 'test_amount',
16
+ feature: 'calendar',
17
+ displayName: 'Test amount',
18
+ table: 'test_amounts',
19
+ fields: [
20
+ { name: 'name', type: 'string', payloadKey: 'name', required: true },
21
+ { name: 'amount', type: 'number', payloadKey: 'amount', required: true },
22
+ ],
23
+ },
24
+ ];
25
+
26
+ export function withTestEntities<T extends { ENTITY_DEFINITIONS: EntityDefinition[] }>(original: T): T {
27
+ return { ...original, ENTITY_DEFINITIONS: [...original.ENTITY_DEFINITIONS, ...TEST_ENTITY_DEFINITIONS] };
28
+ }
@@ -0,0 +1,111 @@
1
+ import type { ResolvedTheme, ThemePreference } from "@hearthlog/contracts/tenant/theme";
2
+
3
+ export const APPEARANCE_EVENT = "stor:appearance-change";
4
+
5
+ export function appearanceStorageKey(tenantId: string): string {
6
+ return `stor:appearance:${tenantId}`;
7
+ }
8
+
9
+ export function parseThemePreference(value: unknown): ThemePreference {
10
+ return value === "light" || value === "dark" || value === "system"
11
+ ? value
12
+ : "system";
13
+ }
14
+
15
+ export function resolveTheme(
16
+ preference: ThemePreference,
17
+ systemPrefersDark: boolean,
18
+ ): ResolvedTheme {
19
+ return preference === "system"
20
+ ? systemPrefersDark
21
+ ? "dark"
22
+ : "light"
23
+ : preference;
24
+ }
25
+
26
+ function initialPreference(): ThemePreference {
27
+ if (typeof document === "undefined") return "system";
28
+ return parseThemePreference(document.documentElement.dataset.appearance);
29
+ }
30
+
31
+ function initialTheme(): ResolvedTheme {
32
+ if (typeof document === "undefined") return "light";
33
+ return document.documentElement.dataset.theme === "dark" ? "dark" : "light";
34
+ }
35
+
36
+ let preference = $state<ThemePreference>(initialPreference());
37
+ let resolved = $state<ResolvedTheme>(initialTheme());
38
+ let storageKey = "";
39
+ let mediaQuery: MediaQueryList | null = null;
40
+ let removeListeners: (() => void) | null = null;
41
+
42
+ function applyResolvedTheme(next: ResolvedTheme): void {
43
+ resolved = next;
44
+ document.documentElement.dataset.theme = next;
45
+ document.documentElement.dataset.appearance = preference;
46
+ document.documentElement.style.colorScheme = next;
47
+ const themeColor = getComputedStyle(document.documentElement)
48
+ .getPropertyValue("--stor-theme-color")
49
+ .trim();
50
+ const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]');
51
+ if (meta && themeColor) meta.content = themeColor;
52
+ window.dispatchEvent(new CustomEvent(APPEARANCE_EVENT, {
53
+ detail: { preference, resolved: next },
54
+ }));
55
+ }
56
+
57
+ function applyPreference(next: ThemePreference, persist: boolean): void {
58
+ preference = next;
59
+ if (persist && storageKey) {
60
+ try {
61
+ localStorage.setItem(storageKey, next);
62
+ } catch {
63
+ // The in-memory selection still works when storage is unavailable.
64
+ }
65
+ }
66
+ applyResolvedTheme(resolveTheme(next, mediaQuery?.matches ?? false));
67
+ }
68
+
69
+ export function initializeAppearance(tenantId: string): () => void {
70
+ removeListeners?.();
71
+ storageKey = appearanceStorageKey(tenantId);
72
+ mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
73
+ let stored: unknown = document.documentElement.dataset.appearance;
74
+ try {
75
+ stored = localStorage.getItem(storageKey);
76
+ } catch {
77
+ // Use the preference resolved by the boot script.
78
+ }
79
+ applyPreference(parseThemePreference(stored), false);
80
+
81
+ const handleSystemChange = (event: MediaQueryListEvent) => {
82
+ if (preference === "system") {
83
+ applyResolvedTheme(event.matches ? "dark" : "light");
84
+ }
85
+ };
86
+ const handleStorage = (event: StorageEvent) => {
87
+ if (event.key === storageKey) {
88
+ applyPreference(parseThemePreference(event.newValue), false);
89
+ }
90
+ };
91
+ mediaQuery.addEventListener("change", handleSystemChange);
92
+ window.addEventListener("storage", handleStorage);
93
+ removeListeners = () => {
94
+ mediaQuery?.removeEventListener("change", handleSystemChange);
95
+ window.removeEventListener("storage", handleStorage);
96
+ removeListeners = null;
97
+ };
98
+ return removeListeners;
99
+ }
100
+
101
+ export const appearance = {
102
+ get preference(): ThemePreference {
103
+ return preference;
104
+ },
105
+ get resolved(): ResolvedTheme {
106
+ return resolved;
107
+ },
108
+ setPreference(next: ThemePreference): void {
109
+ applyPreference(next, true);
110
+ },
111
+ };
@@ -0,0 +1,5 @@
1
+ /** A page number or size read from the address; `fallback` when it is missing or not a whole number above zero. */
2
+ export function parsePositiveInt(value: string | null, fallback: number): number {
3
+ const parsed = Number(value);
4
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
5
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Async Utilities
3
+ */
4
+
5
+ /**
6
+ * Wraps a promise with a timeout.
7
+ * If the promise does not resolve within the specified milliseconds, it rejects with an error.
8
+ *
9
+ * @param promise The promise to wrap
10
+ * @param ms Timeout in milliseconds
11
+ * @param errorMessage Optional error message
12
+ */
13
+ export function withTimeout<T>(
14
+ promise: Promise<T>,
15
+ ms: number,
16
+ errorMessage = 'Operation timed out'
17
+ ): Promise<T> {
18
+ const timeout = new Promise<never>((_, reject) =>
19
+ setTimeout(() => reject(new Error(errorMessage)), ms)
20
+ );
21
+
22
+ return Promise.race([promise, timeout]);
23
+ }
24
+
25
+ /**
26
+ * Creates a promise that resolves after a specified delay.
27
+ */
28
+ export function delay(ms: number): Promise<void> {
29
+ return new Promise(resolve => setTimeout(resolve, ms));
30
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Press and hold on touch: calls `onLongPress` with the element matching
3
+ * `selector` after `delay` ms unless the finger moved more than 8 px, then
4
+ * swallows the click that follows so the press does not also open the row.
5
+ * A mouse never long-presses; desktop rows keep their checkboxes.
6
+ */
7
+ export function longPress(
8
+ node: HTMLElement,
9
+ options: { selector: string; onLongPress: (element: HTMLElement) => void; delay?: number },
10
+ ) {
11
+ let current = options;
12
+ let timer: ReturnType<typeof setTimeout> | undefined;
13
+ let start = { x: 0, y: 0 };
14
+ let fired = false;
15
+
16
+ const cancel = () => clearTimeout(timer);
17
+
18
+ function down(event: PointerEvent): void {
19
+ if (event.pointerType === 'mouse') return;
20
+ const target = (event.target as HTMLElement).closest<HTMLElement>(current.selector);
21
+ if (!target || !node.contains(target)) return;
22
+ fired = false;
23
+ start = { x: event.clientX, y: event.clientY };
24
+ cancel();
25
+ timer = setTimeout(() => {
26
+ fired = true;
27
+ // A press that never ends in a click (the finger slid off) must not eat a later tap.
28
+ setTimeout(() => { fired = false; }, 800);
29
+ navigator.vibrate?.(10);
30
+ current.onLongPress(target);
31
+ }, current.delay ?? 450);
32
+ }
33
+
34
+ function move(event: PointerEvent): void {
35
+ if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 8) cancel();
36
+ }
37
+
38
+ function click(event: MouseEvent): void {
39
+ if (!fired) return;
40
+ fired = false;
41
+ event.preventDefault();
42
+ event.stopPropagation();
43
+ }
44
+
45
+ function contextMenu(event: Event): void {
46
+ if ((event.target as HTMLElement).closest(current.selector)) event.preventDefault();
47
+ }
48
+
49
+ node.addEventListener('pointerdown', down);
50
+ node.addEventListener('pointermove', move);
51
+ node.addEventListener('pointerup', cancel);
52
+ node.addEventListener('pointercancel', cancel);
53
+ node.addEventListener('scroll', cancel, true);
54
+ node.addEventListener('click', click, true);
55
+ node.addEventListener('contextmenu', contextMenu);
56
+
57
+ return {
58
+ update(next: typeof options) { current = next; },
59
+ destroy() {
60
+ cancel();
61
+ node.removeEventListener('pointerdown', down);
62
+ node.removeEventListener('pointermove', move);
63
+ node.removeEventListener('pointerup', cancel);
64
+ node.removeEventListener('pointercancel', cancel);
65
+ node.removeEventListener('scroll', cancel, true);
66
+ node.removeEventListener('click', click, true);
67
+ node.removeEventListener('contextmenu', contextMenu);
68
+ },
69
+ };
70
+ }