@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,184 @@
1
+ import { db, requireActiveContext } from '../db/index.js';
2
+ import {
3
+ applyMetaEvents,
4
+ cacheEventId,
5
+ projectEntityById,
6
+ } from '../event/handlers.js';
7
+ import { schemaTombstoneIds } from '../event/schema-effects.js';
8
+ import type {
9
+ ExtendedAppEvent,
10
+ } from '../event/types.js';
11
+ import { commitLocalEventBatch, SubmissionMismatchError, submissionContent } from './local-commit.js';
12
+ import { validateCommandEvents } from './validation.js';
13
+
14
+ export { CommandValidationError, validateCommandEvents } from './validation.js';
15
+ export { commitLocalEventBatch, localBatchId, SubmissionMismatchError } from './local-commit.js';
16
+
17
+ export interface LocalCommandOptions {
18
+ submissionId?: string;
19
+ }
20
+
21
+ export interface LocalCommandResult {
22
+ batchId: string;
23
+ duplicate: boolean;
24
+ eventIds: string[];
25
+ projectionPending: boolean;
26
+ }
27
+
28
+ type CommitListener = (result: LocalCommandResult) => void | Promise<void>;
29
+
30
+ const commitListeners = new Set<CommitListener>();
31
+ const inFlightCommands = new Map<string, { content: string; execution: Promise<LocalCommandResult> }>();
32
+
33
+ export function onLocalCommandCommitted(listener: CommitListener): () => void {
34
+ commitListeners.add(listener);
35
+ return () => commitListeners.delete(listener);
36
+ }
37
+
38
+ export async function executeLocalCommand(
39
+ eventOrEvents: ExtendedAppEvent | ExtendedAppEvent[],
40
+ options: LocalCommandOptions = {},
41
+ ): Promise<LocalCommandResult> {
42
+ const events = Array.isArray(eventOrEvents) ? eventOrEvents : [eventOrEvents];
43
+ validateCommandEvents(events);
44
+
45
+ const context = await requireActiveContext();
46
+ const submissionId = options.submissionId ?? events.map((event) => event.id).join(':');
47
+ const inFlightKey = JSON.stringify([context.tenantId, context.logId, submissionId]);
48
+ const content = submissionContent(events);
49
+ const existing = inFlightCommands.get(inFlightKey);
50
+ if (existing) {
51
+ if (existing.content !== content) throw new SubmissionMismatchError();
52
+ return existing.execution;
53
+ }
54
+
55
+ const execution = execute(events, submissionId, context);
56
+ inFlightCommands.set(inFlightKey, { content, execution });
57
+ try {
58
+ return await execution;
59
+ } finally {
60
+ inFlightCommands.delete(inFlightKey);
61
+ }
62
+ }
63
+
64
+ const BUSY_RETRY_DELAYS_MS = [50, 100, 200, 400];
65
+
66
+ /**
67
+ * Another tab may hold the database's write lock (a rebuild, an ingestion).
68
+ * A commit is atomic and its submission identity makes a repeat harmless, so
69
+ * a busy answer waits briefly and tries again.
70
+ */
71
+ export async function withBusyRetry<T>(
72
+ task: () => Promise<T>,
73
+ delaysMs: readonly number[] = BUSY_RETRY_DELAYS_MS,
74
+ wait: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
75
+ ): Promise<T> {
76
+ for (let attempt = 0; ; attempt++) {
77
+ try {
78
+ return await task();
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ const delay = delaysMs[attempt];
82
+ if (delay === undefined || !/database is locked|SQLITE_BUSY/i.test(message)) throw error;
83
+ await wait(delay);
84
+ }
85
+ }
86
+ }
87
+
88
+ async function execute(
89
+ events: ExtendedAppEvent[],
90
+ submissionId: string,
91
+ context: Awaited<ReturnType<typeof requireActiveContext>>,
92
+ ): Promise<LocalCommandResult> {
93
+ const committed = await withBusyRetry(() => commitLocalEventBatch({
94
+ database: db,
95
+ context,
96
+ events,
97
+ submissionId,
98
+ projectEntity: projectEntityById,
99
+ }));
100
+
101
+ let projectionPending = committed.pendingDdlEventIds.length > 0;
102
+ if (!committed.duplicate) {
103
+ committed.eventIds.forEach(cacheEventId);
104
+ if (committed.pendingDdlEventIds.length > 0) {
105
+ projectionPending = !(await applyPendingDdlEvents(events, context));
106
+ }
107
+ }
108
+
109
+ const result: LocalCommandResult = {
110
+ batchId: committed.batchId,
111
+ duplicate: committed.duplicate,
112
+ eventIds: committed.eventIds,
113
+ projectionPending,
114
+ };
115
+
116
+ if (!committed.duplicate) await notifyCommitted(result);
117
+ return result;
118
+ }
119
+
120
+ async function applyPendingDdlEvents(
121
+ events: ExtendedAppEvent[],
122
+ context: Awaited<ReturnType<typeof requireActiveContext>>,
123
+ ): Promise<boolean> {
124
+ const metaEvents = events.filter((event) =>
125
+ event.type === 'ENTITY_TYPE_DEFINED' ||
126
+ event.type === 'ENTITY_TYPE_UPDATED' ||
127
+ event.type === 'ENTITY_TYPE_ARCHIVED'
128
+ );
129
+ const schemaUndos = await schemaTombstoneIds(db, context, events);
130
+ if (metaEvents.length === 0 && schemaUndos.size === 0) return true;
131
+
132
+ try {
133
+ if (schemaUndos.size > 0) {
134
+ const { rebuildReadModel } = await import('../event/rebuild.js');
135
+ await rebuildReadModel({ includePendingDdl: true });
136
+ } else {
137
+ await applyMetaEvents(metaEvents);
138
+ }
139
+ await db.updateTable('raw_events')
140
+ .set({ projection_state: 'effective' })
141
+ .where('tenant_id', '=', context.tenantId)
142
+ .where('log_id', '=', context.logId)
143
+ .where('id', 'in', [...metaEvents.map(event => event.id), ...schemaUndos])
144
+ .execute();
145
+ return true;
146
+ } catch (error) {
147
+ console.error('[Command] Dynamic schema projection remains pending', error);
148
+ return false;
149
+ }
150
+ }
151
+
152
+ export async function retryPendingCommandProjections(): Promise<number> {
153
+ const context = await requireActiveContext();
154
+ const rows = await db.selectFrom('raw_events')
155
+ .select(['id', 'type', 'timestamp', 'payload'])
156
+ .where('tenant_id', '=', context.tenantId)
157
+ .where('log_id', '=', context.logId)
158
+ .where('projection_state', '=', 'pending_ddl')
159
+ .orderBy('local_sequence', 'asc')
160
+ .execute();
161
+ let completed = 0;
162
+
163
+ for (const row of rows) {
164
+ const event = {
165
+ id: row.id,
166
+ type: row.type,
167
+ timestamp: row.timestamp,
168
+ payload: JSON.parse(row.payload),
169
+ } as ExtendedAppEvent;
170
+ if (await applyPendingDdlEvents([event], context)) completed++;
171
+ }
172
+
173
+ return completed;
174
+ }
175
+
176
+ async function notifyCommitted(result: LocalCommandResult): Promise<void> {
177
+ for (const listener of commitListeners) {
178
+ try {
179
+ await listener(result);
180
+ } catch (error) {
181
+ console.error('[Command] Post-commit listener failed', error);
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,214 @@
1
+ import { schemaTombstoneIds } from '../event/schema-effects.js';
2
+ import type { ActiveContext, Database } from '../db/index.js';
3
+ import { extractEntityId } from '../ledger/entity-id.js';
4
+ import type { ExtendedAppEvent } from '../event/types.js';
5
+ import { isMetaEntityEvent } from '../event/meta-entity-types.js';
6
+ import { reconcileRestoredEvents } from '../history/legacy-replay.js';
7
+ import { EVENTS_EXCISED, WORKSPACE_RESTORED } from '@hearthlog/contracts/history/replay-selection';
8
+ import type { Kysely, Transaction } from 'kysely';
9
+
10
+ export type ProjectEntity = (
11
+ entityId: string,
12
+ tx: Transaction<Database>,
13
+ context: ActiveContext,
14
+ ) => Promise<void>;
15
+
16
+ export interface LocalCommitInput {
17
+ database: Kysely<Database>;
18
+ context: ActiveContext;
19
+ events: ExtendedAppEvent[];
20
+ submissionId: string;
21
+ projectEntity: ProjectEntity;
22
+ }
23
+
24
+ export interface LocalCommitResult {
25
+ batchId: string;
26
+ duplicate: boolean;
27
+ eventIds: string[];
28
+ pendingDdlEventIds: string[];
29
+ }
30
+
31
+ /** A submission identity was reused for different content. */
32
+ export class SubmissionMismatchError extends Error {
33
+ constructor() {
34
+ super('This action was already saved with different values. Reopen it to make another change.');
35
+ this.name = 'SubmissionMismatchError';
36
+ }
37
+ }
38
+
39
+ /** What a retry must repeat exactly: each event's type and payload, in order. */
40
+ export function submissionContent(
41
+ events: ReadonlyArray<{ type: string; payload: unknown }>,
42
+ ): string {
43
+ return JSON.stringify(events.map((event) => [event.type, event.payload]));
44
+ }
45
+
46
+ export function localBatchId(
47
+ context: Pick<ActiveContext, 'tenantId' | 'logId'>,
48
+ submissionId: string,
49
+ ): string {
50
+ return `local:${context.tenantId}:${context.logId}:${submissionId}`;
51
+ }
52
+
53
+ export async function commitLocalEventBatch(
54
+ input: LocalCommitInput,
55
+ ): Promise<LocalCommitResult> {
56
+ const { database, context, events, submissionId, projectEntity } = input;
57
+ const batchId = localBatchId(context, submissionId);
58
+ let eventIds = events.map((event) => event.id);
59
+ let pendingDdlEventIds = events
60
+ .filter(isMetaEntityEvent)
61
+ .map((event) => event.id);
62
+ let duplicate = false;
63
+
64
+ await database.transaction().execute(async (tx) => {
65
+ const existingBatch = await tx
66
+ .selectFrom('local_batches')
67
+ .select('id')
68
+ .where('id', '=', batchId)
69
+ .where('tenant_id', '=', context.tenantId)
70
+ .where('log_id', '=', context.logId)
71
+ .executeTakeFirst();
72
+
73
+ if (existingBatch) {
74
+ const persisted = await tx.selectFrom('raw_events')
75
+ .select(['id', 'type', 'payload', 'projection_state'])
76
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
77
+ .where('local_batch_id', '=', batchId).orderBy('local_sequence').execute();
78
+ const recorded = persisted.map((event) => ({ type: event.type, payload: JSON.parse(event.payload) as unknown }));
79
+ if (submissionContent(recorded) !== submissionContent(events)) throw new SubmissionMismatchError();
80
+ eventIds = persisted.map(event => event.id);
81
+ pendingDdlEventIds = persisted.filter(event => event.projection_state === 'pending_ddl').map(event => event.id);
82
+ duplicate = true;
83
+ return;
84
+ }
85
+
86
+ const schemaUndos = await schemaTombstoneIds(tx, context, events);
87
+ pendingDdlEventIds.push(...schemaUndos);
88
+
89
+ const maxSequence = await tx
90
+ .selectFrom('raw_events')
91
+ .select((eb) => eb.fn.max('local_sequence').as('max_sequence'))
92
+ .where('tenant_id', '=', context.tenantId)
93
+ .where('log_id', '=', context.logId)
94
+ .executeTakeFirst();
95
+ let nextSequence = Number(maxSequence?.max_sequence ?? 0);
96
+ const affectedEntityIds = new Set<string>();
97
+ let replayMembershipChanged = false;
98
+
99
+ await tx.insertInto('local_batches').values({
100
+ id: batchId,
101
+ tenant_id: context.tenantId,
102
+ log_id: context.logId,
103
+ status: 'pending',
104
+ replacement_for: null,
105
+ }).execute();
106
+
107
+ for (const event of events) {
108
+ const entityId = extractEntityId(event);
109
+ const pendingDdl = isMetaEntityEvent(event) || schemaUndos.has(event.id);
110
+
111
+ await tx.insertInto('raw_events').values({
112
+ id: event.id,
113
+ tenant_id: context.tenantId,
114
+ log_id: context.logId,
115
+ local_batch_id: batchId,
116
+ local_sequence: ++nextSequence,
117
+ type: event.type,
118
+ timestamp: event.timestamp,
119
+ payload: JSON.stringify(event.payload),
120
+ entity_id: entityId,
121
+ entity_version: null,
122
+ log_version: null,
123
+ sync_state: 'pending',
124
+ projection_state: pendingDdl ? 'pending_ddl' : 'effective',
125
+ rejection_code: null,
126
+ rejection_detail: null,
127
+ is_tombstoned: 0,
128
+ }).execute();
129
+
130
+ if (entityId) affectedEntityIds.add(entityId);
131
+
132
+ if (event.type === 'EVENT_TOMBSTONED') {
133
+ const targetEventId = (event.payload as { eventId: string }).eventId;
134
+ const target = await tx.selectFrom('raw_events')
135
+ .select(['entity_id', 'type', 'payload'])
136
+ .where('tenant_id', '=', context.tenantId)
137
+ .where('log_id', '=', context.logId)
138
+ .where('id', '=', targetEventId)
139
+ .executeTakeFirst();
140
+
141
+ if (target) {
142
+ await tx.updateTable('raw_events')
143
+ .set({ is_tombstoned: 1 })
144
+ .where('tenant_id', '=', context.tenantId)
145
+ .where('log_id', '=', context.logId)
146
+ .where('id', '=', targetEventId)
147
+ .execute();
148
+ if (target.entity_id) affectedEntityIds.add(target.entity_id);
149
+ // Undoing an excision or a restore changes replay
150
+ // membership; recompute it and reproject the events the
151
+ // directive had excluded.
152
+ if (target.type === EVENTS_EXCISED || target.type === WORKSPACE_RESTORED) {
153
+ replayMembershipChanged = true;
154
+ await collectDirectiveTargetEntities(tx, context, target.type, target.payload, affectedEntityIds);
155
+ }
156
+ }
157
+ }
158
+
159
+ if (event.type === EVENTS_EXCISED) {
160
+ replayMembershipChanged = true;
161
+ await collectDirectiveTargetEntities(tx, context, event.type, JSON.stringify(event.payload), affectedEntityIds);
162
+ }
163
+ }
164
+
165
+ if (replayMembershipChanged) {
166
+ // Membership is derived, not incremental: one pass recomputes
167
+ // projection_state/is_tombstoned for the whole scope so excision
168
+ // and revive stay exact.
169
+ await reconcileRestoredEvents(tx, context);
170
+ }
171
+
172
+ for (const entityId of affectedEntityIds) {
173
+ await projectEntity(entityId, tx, context);
174
+ }
175
+ });
176
+
177
+ return {
178
+ batchId,
179
+ duplicate,
180
+ eventIds,
181
+ pendingDdlEventIds,
182
+ };
183
+ }
184
+
185
+ /** Entities whose events are hidden or revealed by a replay directive. */
186
+ async function collectDirectiveTargetEntities(
187
+ tx: Transaction<Database>,
188
+ context: ActiveContext,
189
+ type: string,
190
+ payload: string,
191
+ affectedEntityIds: Set<string>,
192
+ ): Promise<void> {
193
+ if (type === WORKSPACE_RESTORED) return;
194
+ if (type !== EVENTS_EXCISED) return;
195
+ let eventIds: string[] = [];
196
+ try {
197
+ const parsed = JSON.parse(payload) as { eventIds?: unknown };
198
+ if (Array.isArray(parsed.eventIds)) {
199
+ eventIds = parsed.eventIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
200
+ }
201
+ } catch {
202
+ return;
203
+ }
204
+ if (eventIds.length === 0) return;
205
+ const rows = await tx.selectFrom('raw_events')
206
+ .select('entity_id')
207
+ .where('tenant_id', '=', context.tenantId)
208
+ .where('log_id', '=', context.logId)
209
+ .where('id', 'in', eventIds)
210
+ .execute();
211
+ for (const row of rows) {
212
+ if (row.entity_id) affectedEntityIds.add(row.entity_id);
213
+ }
214
+ }
@@ -0,0 +1,8 @@
1
+ import { executeLocalCommand, type LocalCommandOptions } from './index.js';
2
+ import type { AppEvent } from '../event/types.js';
3
+
4
+ /** Typed convenience around the existing command boundary; no separate write path. */
5
+ export function standardCommand<T extends AppEvent['type']>(type: T) {
6
+ return (payload: Extract<AppEvent, { type: T }>['payload'], options?: LocalCommandOptions) =>
7
+ executeLocalCommand({ id: crypto.randomUUID(), type, timestamp: Date.now(), payload } as AppEvent, options);
8
+ }
@@ -0,0 +1,206 @@
1
+ import {
2
+ ENTITY_DEFINITIONS,
3
+ getEventTypes,
4
+ getPayloadKey,
5
+ } from '@hearthlog/contracts/event/entity-definitions';
6
+ import type { ExtendedAppEvent } from '../event/types.js';
7
+ import type { EntityDefinition } from '@hearthlog/contracts/event/entity-definitions';
8
+ import { tenant } from '@hearthlog/contracts/tenant/config';
9
+ import {
10
+ sanitizeCalendarSource,
11
+ validateCalendarConfig,
12
+ type CalendarFieldLike,
13
+ type EntityCalendarSource,
14
+ } from '@hearthlog/contracts/calendar/config';
15
+
16
+ export class CommandValidationError extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = 'CommandValidationError';
20
+ }
21
+ }
22
+
23
+ type KnownEvent = {
24
+ definition: EntityDefinition;
25
+ kind: 'added' | 'updated' | 'removed';
26
+ };
27
+
28
+ const eventDefinitions = new Map<string, KnownEvent>(
29
+ ENTITY_DEFINITIONS.flatMap((definition) => {
30
+ const [added, updated, removed] = getEventTypes(definition);
31
+ return [
32
+ [added, { definition, kind: 'added' as const }],
33
+ [updated, { definition, kind: 'updated' as const }],
34
+ [removed, { definition, kind: 'removed' as const }],
35
+ ];
36
+ }),
37
+ );
38
+
39
+ export function validateCommandEvents(events: ExtendedAppEvent[]): void {
40
+ if (events.length === 0) {
41
+ throw new CommandValidationError('A command must contain at least one event');
42
+ }
43
+
44
+ const ids = new Set<string>();
45
+ for (const event of events) {
46
+ if (!event.id?.trim()) throw new CommandValidationError('Event ID is required');
47
+ if (ids.has(event.id)) {
48
+ throw new CommandValidationError(`Duplicate event ID in command: ${event.id}`);
49
+ }
50
+ ids.add(event.id);
51
+
52
+ if (!event.type?.trim()) throw new CommandValidationError('Event type is required');
53
+ if (!Number.isFinite(event.timestamp)) {
54
+ throw new CommandValidationError(`Event ${event.id} has an invalid timestamp`);
55
+ }
56
+ if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) {
57
+ throw new CommandValidationError(`Event ${event.id} must have an object payload`);
58
+ }
59
+
60
+ validateKnownEvent(event);
61
+ }
62
+ }
63
+
64
+ function validateKnownEvent(event: ExtendedAppEvent): void {
65
+ if (['EVENT_TOMBSTONED', 'EVENTS_EXCISED', 'WORKSPACE_RESTORED'].includes(event.type)) {
66
+ throw new CommandValidationError(`Event ${event.id}: history removal and restore commands are retired`);
67
+ }
68
+
69
+ if (
70
+ event.type === 'ENTITY_TYPE_DEFINED' ||
71
+ event.type === 'ENTITY_TYPE_UPDATED' ||
72
+ event.type === 'ENTITY_TYPE_ARCHIVED'
73
+ ) {
74
+ if (!tenant.dynamicEntities) {
75
+ throw new CommandValidationError(
76
+ `Event ${event.id}: this site is not allowed to manage dynamic entity types`,
77
+ );
78
+ }
79
+ if (event.type !== 'ENTITY_TYPE_DEFINED') {
80
+ requireString(event, 'id');
81
+ sanitizeCalendarPayload(event);
82
+ return;
83
+ }
84
+ requireString(event, 'id');
85
+ requireString(event, 'name');
86
+ requireString(event, 'displayName');
87
+ requireString(event, 'tableName');
88
+ // SAFETY: payload was checked to be a non-array object by validateCommandEvents.
89
+ const payload = event.payload as unknown as Record<string, unknown>;
90
+ if (!Array.isArray(payload.fields)) {
91
+ throw new CommandValidationError(`Event ${event.id} requires fields`);
92
+ }
93
+ const sources = sanitizeCalendarPayload(event);
94
+ if (sources.length > 0) {
95
+ const fields: CalendarFieldLike[] = (payload.fields as unknown[])
96
+ .filter(
97
+ (field): field is { name: string; type: string } =>
98
+ typeof field === 'object' &&
99
+ field !== null &&
100
+ typeof (field as { name?: unknown }).name === 'string' &&
101
+ typeof (field as { type?: unknown }).type === 'string',
102
+ )
103
+ .map((field) => ({ name: field.name, type: field.type }));
104
+ const errors = validateCalendarConfig(
105
+ payload.name as string,
106
+ sources,
107
+ fields,
108
+ { parentTables: new Set<string>() },
109
+ );
110
+ if (errors.length > 0) {
111
+ throw new CommandValidationError(
112
+ `Event ${event.id}: ${errors[0]}`,
113
+ );
114
+ }
115
+ }
116
+ return;
117
+ }
118
+
119
+ const known = eventDefinitions.get(event.type);
120
+ if (!known) {
121
+ if (/_(ADDED|UPDATED|REMOVED)$/.test(event.type)) requireString(event, 'id');
122
+ return;
123
+ }
124
+
125
+ // Feature scoping (defense in depth — the owning pages are already
126
+ // gated): commands may never append events for entities the tenant is
127
+ // not approved for. Dynamic (runtime-defined) entity events are not in
128
+ // this map and pass through; their creation is gated separately.
129
+ const approvalError = eventApprovalError(
130
+ event.type,
131
+ tenant.features as ReadonlySet<string>,
132
+ );
133
+ if (approvalError) {
134
+ throw new CommandValidationError(`Event ${event.id}: ${approvalError}`);
135
+ }
136
+
137
+ requireString(event, 'id');
138
+ if (known.kind !== 'added') return;
139
+
140
+ for (const field of known.definition.fields.filter((item) => item.required)) {
141
+ const key = getPayloadKey(field);
142
+ // SAFETY: payload was checked to be a non-array object by validateCommandEvents.
143
+ const value = (event.payload as unknown as Record<string, unknown>)[key];
144
+ if (value === undefined || value === null || value === '') {
145
+ throw new CommandValidationError(`Event ${event.id} requires ${key}`);
146
+ }
147
+ if (field.type === 'number' && (typeof value !== 'number' || !Number.isFinite(value))) {
148
+ throw new CommandValidationError(`Event ${event.id} requires a finite ${key}`);
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Returns an error message when the event type belongs to an entity the
155
+ * given feature set does not approve; null when allowed (meta events and
156
+ * dynamic entity events are not feature-scoped here).
157
+ */
158
+ export function eventApprovalError(
159
+ eventType: string,
160
+ features: ReadonlySet<string>,
161
+ ): string | null {
162
+ const known = eventDefinitions.get(eventType);
163
+ if (!known) return null;
164
+ if (!features.has(known.definition.feature)) {
165
+ return (
166
+ `the entity "${known.definition.table}" belongs to the ` +
167
+ `"${known.definition.feature}" feature, which is not enabled for this site`
168
+ );
169
+ }
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * Validate the optional `calendar` payload of a meta event. Returns the
175
+ * sanitized sources; an omitted or null value means "no calendar dates".
176
+ * Semantic field checks run in the DEFINED branch where fields are present.
177
+ */
178
+ function sanitizeCalendarPayload(event: ExtendedAppEvent): EntityCalendarSource[] {
179
+ // SAFETY: payload was checked to be a non-array object by validateCommandEvents.
180
+ const payload = event.payload as unknown as Record<string, unknown>;
181
+ if (payload.calendar === undefined || payload.calendar === null) return [];
182
+ if (!Array.isArray(payload.calendar)) {
183
+ throw new CommandValidationError(
184
+ `Event ${event.id} requires calendar to be a list`,
185
+ );
186
+ }
187
+ const sources: EntityCalendarSource[] = [];
188
+ for (const entry of payload.calendar) {
189
+ const source = sanitizeCalendarSource(entry);
190
+ if (!source) {
191
+ throw new CommandValidationError(
192
+ `Event ${event.id} has an invalid calendar source`,
193
+ );
194
+ }
195
+ sources.push(source);
196
+ }
197
+ return sources;
198
+ }
199
+
200
+ function requireString(event: ExtendedAppEvent, key: string): void {
201
+ // SAFETY: payload was checked to be a non-array object by validateCommandEvents.
202
+ const value = (event.payload as unknown as Record<string, unknown>)[key];
203
+ if (typeof value !== 'string' || !value.trim()) {
204
+ throw new CommandValidationError(`Event ${event.id} requires ${key}`);
205
+ }
206
+ }
@@ -0,0 +1,72 @@
1
+ import { rebuildIfSchemaChanged } from '../event/rebuild.js';
2
+ import { eventLogFilename } from '../store/event-log.svelte.js';
3
+ import { activateLogContext } from '../store/local-context.js';
4
+ import {
5
+ markLocalStoreBooting,
6
+ markLocalStoreDegraded,
7
+ markLocalStoreFailed,
8
+ markLocalStoreReady,
9
+ type LocalStorePhase,
10
+ } from '../store/local-store.svelte.js';
11
+ import { getLocalSchemaVersion } from './index.js';
12
+ import { retryPendingCommandProjections } from '../commands/index.js';
13
+
14
+ export interface LocalStoreBootstrapResult {
15
+ activeLogId: string | null;
16
+ schemaRebuilt: boolean;
17
+ schemaVersion: number;
18
+ phase: LocalStorePhase;
19
+ }
20
+
21
+ let bootstrapPromise: Promise<LocalStoreBootstrapResult> | null = null;
22
+
23
+ async function runBootstrap(): Promise<LocalStoreBootstrapResult> {
24
+ markLocalStoreBooting();
25
+ await eventLogFilename.ready();
26
+
27
+ const activeLogId = eventLogFilename.value;
28
+ if (!activeLogId) {
29
+ const schemaVersion = await getLocalSchemaVersion();
30
+ markLocalStoreDegraded({
31
+ message: 'No event log is selected',
32
+ schemaVersion,
33
+ });
34
+ return {
35
+ activeLogId: null,
36
+ schemaRebuilt: false,
37
+ schemaVersion,
38
+ phase: 'degraded',
39
+ };
40
+ }
41
+
42
+ await activateLogContext(activeLogId);
43
+ const schemaRebuilt = await rebuildIfSchemaChanged();
44
+ await retryPendingCommandProjections();
45
+
46
+ const result: LocalStoreBootstrapResult = {
47
+ activeLogId,
48
+ schemaRebuilt,
49
+ schemaVersion: await getLocalSchemaVersion(),
50
+ phase: 'ready',
51
+ };
52
+ markLocalStoreReady({
53
+ activeLogId,
54
+ schemaVersion: result.schemaVersion,
55
+ schemaRebuilt,
56
+ });
57
+ return result;
58
+ }
59
+
60
+ /**
61
+ * Initializes the selected local Ledger scope exactly once per tab. Route loaders
62
+ * await this through the root layout before issuing scoped projection queries.
63
+ * Failed attempts are not cached so a navigation or explicit retry can recover.
64
+ */
65
+ export function bootstrapLocalStore(): Promise<LocalStoreBootstrapResult> {
66
+ bootstrapPromise ??= runBootstrap().catch((error) => {
67
+ bootstrapPromise = null;
68
+ markLocalStoreFailed(error);
69
+ throw error;
70
+ });
71
+ return bootstrapPromise;
72
+ }