@hearthlog/runtime 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +51 -3
  3. package/src/access/session.ts +223 -0
  4. package/src/access/user-session.ts +61 -0
  5. package/src/ai/decisions.ts +89 -0
  6. package/src/calendar/draft.ts +84 -0
  7. package/src/calendar/feeds.ts +91 -0
  8. package/src/calendar/groups.ts +198 -0
  9. package/src/calendar/logic.ts +141 -0
  10. package/src/calendar/preferences.ts +40 -0
  11. package/src/calendar/weeks.ts +56 -0
  12. package/src/commands/index.ts +184 -0
  13. package/src/commands/local-commit.ts +214 -0
  14. package/src/commands/standard.ts +8 -0
  15. package/src/commands/validation.ts +206 -0
  16. package/src/db/bootstrap.ts +72 -0
  17. package/src/db/calendar.ts +638 -0
  18. package/src/db/dynamic-ddl.ts +127 -0
  19. package/src/db/entities.ts +208 -0
  20. package/src/db/history.ts +194 -0
  21. package/src/db/index.ts +1240 -0
  22. package/src/db/kv.ts +33 -0
  23. package/src/db/local-statistics.ts +106 -0
  24. package/src/db/module-projections.ts +46 -0
  25. package/src/db/raw-browser.ts +361 -0
  26. package/src/db/read-repositories.ts +29 -0
  27. package/src/db/read-session.ts +47 -0
  28. package/src/db/schema-version.ts +76 -0
  29. package/src/db/session-context.ts +33 -0
  30. package/src/event/code-export.ts +65 -0
  31. package/src/event/dynamic-entities.ts +503 -0
  32. package/src/event/dynamic-materializer.ts +288 -0
  33. package/src/event/handlers.ts +490 -0
  34. package/src/event/index.ts +53 -0
  35. package/src/event/meta-entity-types.ts +165 -0
  36. package/src/event/projection-builder.ts +384 -0
  37. package/src/event/queries.ts +126 -0
  38. package/src/event/rebuild.ts +176 -0
  39. package/src/event/reference-lookup.ts +93 -0
  40. package/src/event/registry.ts +158 -0
  41. package/src/event/schema-effects.ts +45 -0
  42. package/src/event/types.ts +20 -0
  43. package/src/history/action-boundary.ts +18 -0
  44. package/src/history/action-detail.ts +23 -0
  45. package/src/history/change-comparison.ts +29 -0
  46. package/src/history/change-display.ts +41 -0
  47. package/src/history/contracts.ts +63 -0
  48. package/src/history/history.ts +372 -0
  49. package/src/history/legacy-replay.ts +69 -0
  50. package/src/history/legacy-url.ts +9 -0
  51. package/src/history/lineage.ts +123 -0
  52. package/src/history/log-format.ts +17 -0
  53. package/src/history/log-map.ts +110 -0
  54. package/src/history/navigation.ts +14 -0
  55. package/src/history/point-snapshot.ts +229 -0
  56. package/src/history/timeline.ts +215 -0
  57. package/src/history/url.ts +18 -0
  58. package/src/ledger/README.md +118 -0
  59. package/src/ledger/admin-client.ts +616 -0
  60. package/src/ledger/client.ts +10 -0
  61. package/src/ledger/config.ts +117 -0
  62. package/src/ledger/entity-id.ts +115 -0
  63. package/src/ledger/errors.ts +144 -0
  64. package/src/ledger/http-client.ts +590 -0
  65. package/src/ledger/index.ts +67 -0
  66. package/src/ledger/socket.ts +400 -0
  67. package/src/ledger/types.ts +202 -0
  68. package/src/ledger/version-tracker-singleton.ts +12 -0
  69. package/src/ledger/version-tracker.ts +259 -0
  70. package/src/log-management.ts +123 -0
  71. package/src/log-validation.ts +19 -0
  72. package/src/native/reminders.ts +2 -0
  73. package/src/native/shell.svelte.ts +136 -0
  74. package/src/native/shell.ts +102 -0
  75. package/src/pwa/registration.ts +169 -0
  76. package/src/recovery/local-recovery.ts +59 -0
  77. package/src/recovery/reset-sequence.ts +13 -0
  78. package/src/runtime/clock.ts +43 -0
  79. package/src/runtime/kept-page-data.svelte.ts +42 -0
  80. package/src/runtime/page-data.svelte.ts +160 -0
  81. package/src/runtime/phone-header.svelte.ts +17 -0
  82. package/src/runtime/read-coordinator.ts +103 -0
  83. package/src/runtime/refresh.ts +15 -0
  84. package/src/runtime/start-page.ts +56 -0
  85. package/src/runtime/view.ts +83 -0
  86. package/src/runtime/workspace.ts +62 -0
  87. package/src/storage/indexeddb-statistics.ts +153 -0
  88. package/src/store/attention.svelte.ts +51 -0
  89. package/src/store/conflict-merge.ts +169 -0
  90. package/src/store/entity-sync-state.svelte.ts +36 -0
  91. package/src/store/event-log.svelte.ts +109 -0
  92. package/src/store/events.svelte.ts +14 -0
  93. package/src/store/local-context.ts +93 -0
  94. package/src/store/local-store.svelte.ts +58 -0
  95. package/src/store/log-version.svelte.ts +35 -0
  96. package/src/store/logs.svelte.ts +60 -0
  97. package/src/store/moved.svelte.ts +43 -0
  98. package/src/store/offline-readiness.svelte.ts +188 -0
  99. package/src/store/outbox-request.ts +61 -0
  100. package/src/store/outbox.ts +426 -0
  101. package/src/store/pending-work-transfer.ts +49 -0
  102. package/src/store/pending-work.ts +143 -0
  103. package/src/store/sync-coordinator.ts +170 -0
  104. package/src/store/sync-invalidation.ts +10 -0
  105. package/src/store/sync-issues.ts +406 -0
  106. package/src/store/sync-progress.ts +54 -0
  107. package/src/store/sync-state.ts +127 -0
  108. package/src/store/sync.svelte.ts +1010 -0
  109. package/src/store/tab-coordination.ts +164 -0
  110. package/src/testing/e2e-seed.ts +40 -0
  111. package/src/testing/local-database.ts +120 -0
  112. package/src/testing/projection-database.ts +105 -0
  113. package/src/testing/test-entities.ts +28 -0
  114. package/src/theme/appearance.svelte.ts +111 -0
  115. package/src/utils/address.ts +5 -0
  116. package/src/utils/async.ts +30 -0
  117. package/src/utils/long-press.ts +70 -0
  118. package/src/utils/perf-dataset.ts +405 -0
  119. package/src/utils/perf.ts +627 -0
  120. package/README.md +0 -3
@@ -0,0 +1,1010 @@
1
+ import { waitForPageUpdates } from '../runtime/page-data.svelte.js';
2
+ import { measurementMetadata } from '../utils/perf.js';
3
+ import { invalidate } from '$app/navigation';
4
+ import { db, requireActiveContext } from '../db/index.js';
5
+ import {
6
+ clearActiveProjections,
7
+ handleIncomingStoredEvents,
8
+ rebuildReadModel,
9
+ } from '../event/index.js';
10
+ import {
11
+ AuthenticationError,
12
+ EntityVersionChainError,
13
+ IdempotencyMismatchError,
14
+ getLedgerClient,
15
+ LedgerServiceError,
16
+ NotFoundError,
17
+ VersionConflictError,
18
+ loadLedgerConfig,
19
+ type StoredEvent,
20
+ } from '../ledger/index.js';
21
+ import {
22
+ AccessSessionExpiredError,
23
+ requestAccessReauthentication,
24
+ } from '../access/session.js';
25
+ import { versionTracker } from '../ledger/version-tracker-singleton.js';
26
+ import { notifyEventsChanged } from './events.svelte.js';
27
+ import { markWorkspaceMoved, movedDestination, movedWorkspace } from './moved.svelte.js';
28
+ import { get as getKv, del as delKv } from '../db/kv.js';
29
+ import {
30
+ getPendingCount,
31
+ getNextRetryAt,
32
+ getNextPendingBatch,
33
+ getAcceptedEntityEdits,
34
+ getReadyOperation,
35
+ markOperationFailed,
36
+ parkOperationForConflict,
37
+ markOperationSynced,
38
+ markOperationSyncing,
39
+ persistOperation,
40
+ prepareAppendRequest,
41
+ rejectBatch,
42
+ supersedeOperation,
43
+ type DurableOperation,
44
+ type PendingBatchEvent,
45
+ } from './outbox.js';
46
+ import {
47
+ activateLogContext,
48
+ deactivateLogContext,
49
+ getActiveContext,
50
+ getLedgerCacheKey,
51
+ } from './local-context.js';
52
+ import { activateCurrentReadTarget } from '../runtime/read-coordinator.js';
53
+ import { withTiming } from '../utils/perf.js';
54
+ import { deriveSyncPhase, type SyncPhase } from './sync-state.js';
55
+ import { refreshEntitySyncState } from './entity-sync-state.svelte.js';
56
+ import { onLocalCommandCommitted } from '../commands/index.js';
57
+ import {
58
+ SerialTaskQueue,
59
+ CoalescedTaskQueue,
60
+ RETRY_BASE_DELAY_MS,
61
+ classifyRealtimeVersion,
62
+ isTransientSyncFailure,
63
+ withExclusiveSyncLock,
64
+ type SyncTrigger,
65
+ } from './sync-coordinator.js';
66
+ import { SYNC_INVALIDATION_TARGETS } from './sync-invalidation.js';
67
+ import { findClashes, type EntityClash } from './conflict-merge.js';
68
+ import {
69
+ holdTabLock,
70
+ onTabChange,
71
+ postTabChange,
72
+ realtimeLockName,
73
+ scopeKey,
74
+ syncCycleLockName,
75
+ withTabLock,
76
+ type TabChange,
77
+ type TabChangeKind,
78
+ } from './tab-coordination.js';
79
+ import { loadDynamicEntityTypes } from '../event/dynamic-entities.js';
80
+ const UPLOAD_DEBOUNCE_MS = 100;
81
+ const REALTIME_INVALIDATION_DEBOUNCE_MS = 150;
82
+ const REALTIME_HEALTH_RECOVERY_MS = 5_000;
83
+
84
+ export type SyncLogType =
85
+ | 'upload'
86
+ | 'download'
87
+ | 'ws_receive'
88
+ | 'ws_ready'
89
+ | 'ws_close'
90
+ | 'ws_error'
91
+ | 'ws_resync'
92
+ | 'drain'
93
+ | 'error';
94
+
95
+ export interface SyncLogEntry {
96
+ id: string;
97
+ timestamp: Date;
98
+ type: SyncLogType;
99
+ message: string;
100
+ details?: string;
101
+ }
102
+
103
+ export const syncLog = $state<{ entries: SyncLogEntry[]; hasMore: boolean }>({
104
+ entries: [],
105
+ hasMore: false,
106
+ });
107
+
108
+ export let syncStatus = $state({
109
+ isOnline: typeof navigator === 'undefined' ? true : navigator.onLine,
110
+ isLedgerReachable: null as boolean | null,
111
+ isSyncing: false,
112
+ hasCompletedSync: false,
113
+ hasError: false,
114
+ /** The last failure was Ledger being unreachable; uploads keep retrying. */
115
+ waitingForLedger: false,
116
+ errorMessage: null as string | null,
117
+ lastSyncedAt: null as Date | null,
118
+ pendingUploads: 0,
119
+ tenantId: null as string | null,
120
+ logId: null as string | null,
121
+ needsAttention: 0,
122
+ });
123
+
124
+ export function getSyncPhase(): SyncPhase {
125
+ return deriveSyncPhase(syncStatus);
126
+ }
127
+
128
+ let catchUpRevision = 0;
129
+ let caughtUpScope: string | null = null;
130
+ let eventSocket: import('../ledger/socket.js').EventSocket | null = null;
131
+ let uploadDebounceTimeout: ReturnType<typeof setTimeout> | null = null;
132
+ let syncInProgress = false;
133
+ let syncRequestedWhileRunning = false;
134
+ let synchronizationSuspended = false;
135
+ let syncIdleWaiters: Array<() => void> = [];
136
+ let retryWakeupTimeout: ReturnType<typeof setTimeout> | null = null;
137
+ let realtimeHealthTimeout: ReturnType<typeof setTimeout> | null = null;
138
+ let realtimeHealthy = false;
139
+ let realtimeStopRequested = false;
140
+ let accessProbeInFlight = false;
141
+ let suspendedForAccessExpiry = false;
142
+ const realtimeEventQueue = new SerialTaskQueue();
143
+ // One visible tab per log owns the live connection to Ledger. The others hold
144
+ // no socket: they hear about ingested events over the tab channel.
145
+ let realtimeLeaseKey: string | null = null;
146
+ let releaseRealtimeLeaseLock: (() => void) | null = null;
147
+ let realtimeLeaseAcquired = false;
148
+ let openingRealtime = false;
149
+ let realtimeGeneration = 0;
150
+ const pendingRealtimeEvents: StoredEvent[] = [];
151
+ const SYNC_LOG_PAGE_SIZE = 50;
152
+ /** The persisted sync log is a diagnostic trail, not history: keep the newest entries. */
153
+ const SYNC_LOG_RETAINED_ENTRIES = 1_000;
154
+
155
+ export { getLedgerClient };
156
+
157
+ async function getSelectedLogId(): Promise<string | null> {
158
+ const selection = await getKv<{ logId: string }>(await getLedgerCacheKey('selected-log'));
159
+ return selection?.logId || null;
160
+ }
161
+
162
+ async function ensureSelectedContext(): Promise<string> {
163
+ // Prefer this tab's session context so another tab's log switch cannot redirect sync/writes.
164
+ const active = await getActiveContext();
165
+ if (active?.logId) {
166
+ syncStatus.tenantId = active.tenantId;
167
+ syncStatus.logId = active.logId;
168
+ return active.logId;
169
+ }
170
+
171
+ const logId = await getSelectedLogId();
172
+ if (!logId) throw new Error('No Ledger log selected');
173
+ await activateLogContext(logId);
174
+ const context = await requireActiveContext();
175
+ syncStatus.tenantId = context.tenantId;
176
+ syncStatus.logId = context.logId;
177
+ return logId;
178
+ }
179
+
180
+ export const accessSessionState = $state({ expired: false });
181
+
182
+ function setSyncError(error: unknown): void {
183
+ syncStatus.hasError = true;
184
+ syncStatus.waitingForLedger = isTransientSyncFailure(error);
185
+ accessSessionState.expired = error instanceof AccessSessionExpiredError;
186
+ if (error instanceof AccessSessionExpiredError) {
187
+ // Reauthentication is automatic when online: the page navigates to a
188
+ // cache-busted app URL so the edge can render the Access login flow.
189
+ // Offline, the toast's "Sign in again" action is the way back.
190
+ syncStatus.errorMessage = syncStatus.isOnline
191
+ ? 'Your sign-in session expired. Redirecting to sign in…'
192
+ : 'Your sign-in session expired. Sign in again to continue.';
193
+ } else if (error instanceof AuthenticationError) {
194
+ syncStatus.errorMessage = 'Ledger authentication failed. Local changes remain on this device.';
195
+ } else if (error instanceof NotFoundError) {
196
+ syncStatus.errorMessage = 'The selected Ledger log is no longer available.';
197
+ } else if (error instanceof Error) {
198
+ syncStatus.errorMessage = error.message;
199
+ } else {
200
+ syncStatus.errorMessage = 'Synchronization failed';
201
+ }
202
+ console.error('[Sync]', error);
203
+ }
204
+
205
+ function markLedgerUnreachable(error: unknown): void {
206
+ syncStatus.isLedgerReachable = false;
207
+ setSyncError(error);
208
+ }
209
+
210
+ function clearSyncError(): void {
211
+ syncStatus.hasError = false;
212
+ syncStatus.waitingForLedger = false;
213
+ syncStatus.errorMessage = null;
214
+ accessSessionState.expired = false;
215
+ }
216
+
217
+ function markLedgerReachable(): void {
218
+ syncStatus.isLedgerReachable = true;
219
+ clearSyncError();
220
+ }
221
+
222
+ export async function addSyncLogEntry(
223
+ type: SyncLogType,
224
+ message: string,
225
+ details?: string,
226
+ ): Promise<void> {
227
+ const entry: SyncLogEntry = {
228
+ id: crypto.randomUUID(),
229
+ timestamp: new Date(),
230
+ type,
231
+ message,
232
+ details,
233
+ };
234
+ syncLog.entries = [entry, ...syncLog.entries].slice(0, 100);
235
+ try {
236
+ await db.insertInto('sync_logs').values({
237
+ id: entry.id,
238
+ timestamp: entry.timestamp.getTime(),
239
+ type,
240
+ message,
241
+ details: details ?? null,
242
+ }).execute();
243
+ await db.deleteFrom('sync_logs')
244
+ .where('id', 'in', db.selectFrom('sync_logs').select('id')
245
+ .orderBy('timestamp', 'desc').limit(-1).offset(SYNC_LOG_RETAINED_ENTRIES))
246
+ .execute();
247
+ } catch (error) {
248
+ console.error('[SyncLog] Failed to persist entry', error);
249
+ }
250
+ }
251
+
252
+ export async function loadSyncLog(limit = SYNC_LOG_PAGE_SIZE, offset = 0): Promise<void> {
253
+ const rows = await db.selectFrom('sync_logs').selectAll()
254
+ .orderBy('timestamp', 'desc').limit(limit + 1).offset(offset).execute();
255
+ const entries = rows.slice(0, limit).map((row) => ({
256
+ id: row.id,
257
+ timestamp: new Date(row.timestamp),
258
+ type: normalizePersistedSyncLogType(row.type),
259
+ message: row.message,
260
+ details: row.details ?? undefined,
261
+ }));
262
+ syncLog.entries = offset === 0 ? entries : [...syncLog.entries, ...entries];
263
+ syncLog.hasMore = rows.length > limit;
264
+ }
265
+
266
+ export async function loadMoreSyncLog(): Promise<void> {
267
+ await loadSyncLog(SYNC_LOG_PAGE_SIZE, syncLog.entries.length);
268
+ }
269
+
270
+ function normalizePersistedSyncLogType(type: string): SyncLogType {
271
+ const legacy: Record<string, SyncLogType> = {
272
+ sse_receive: 'ws_receive',
273
+ sse_open: 'ws_ready',
274
+ sse_close: 'ws_close',
275
+ sse_error: 'ws_error',
276
+ };
277
+ return legacy[type] ?? type as SyncLogType;
278
+ }
279
+
280
+ export async function clearSyncLog(): Promise<void> {
281
+ await db.deleteFrom('sync_logs').execute();
282
+ syncLog.entries = [];
283
+ syncLog.hasMore = false;
284
+ }
285
+
286
+ export async function getSyncLogCount(): Promise<number> {
287
+ const result = await db.selectFrom('sync_logs')
288
+ .select((eb) => eb.fn.count('id').as('count')).executeTakeFirst();
289
+ return Number(result?.count ?? 0);
290
+ }
291
+
292
+ function announceChange(kind: TabChangeKind): void {
293
+ void requireActiveContext()
294
+ .then((context) => postTabChange({ kind, tenantId: context.tenantId, logId: context.logId }))
295
+ .catch(() => undefined);
296
+ }
297
+
298
+ let remoteChangePending = false;
299
+
300
+ const syncRefreshQueue = new CoalescedTaskQueue(async () => {
301
+ await updatePendingCount();
302
+ try {
303
+ await invalidateViews();
304
+ } finally {
305
+ notifyEventsChanged({ routesInvalidated: true });
306
+ }
307
+ }, REALTIME_INVALIDATION_DEBOUNCE_MS);
308
+
309
+ function scheduleRemoteRefresh(): void {
310
+ remoteChangePending = false;
311
+ void syncRefreshQueue.schedule().catch(setSyncError);
312
+ }
313
+
314
+ /** Another tab changed this log's data: refresh now if visible, on return otherwise. */
315
+ async function handleTabChange(change: TabChange): Promise<void> {
316
+ const context = await getActiveContext();
317
+ if (!context || context.tenantId !== change.tenantId || context.logId !== change.logId) return;
318
+ if (change.kind === 'rebuild' || change.kind === 'schema') {
319
+ // The registry of runtime-defined entities lives in memory per tab.
320
+ await loadDynamicEntityTypes();
321
+ }
322
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
323
+ remoteChangePending = true;
324
+ return;
325
+ }
326
+ scheduleRemoteRefresh();
327
+ }
328
+
329
+ if (typeof window !== 'undefined') {
330
+ onTabChange((change) => {
331
+ void handleTabChange(change).catch((error) => console.warn('[Sync] Could not apply a change from another tab', error));
332
+ });
333
+ }
334
+
335
+ async function invalidateViews(): Promise<void> {
336
+ await withTiming('ui', 'invalidateTargets', async () => {
337
+ await Promise.all(SYNC_INVALIDATION_TARGETS.map((target) => invalidate(target)));
338
+ await waitForPageUpdates();
339
+ }, { ...measurementMetadata(), kind: 'refresh', baselineMetric: 'invalidationCost', route: typeof location === 'undefined' ? '' : location.pathname, targetCount: SYNC_INVALIDATION_TARGETS.length });
340
+ }
341
+
342
+ function scheduleRealtimeInvalidation(): void {
343
+ void syncRefreshQueue.schedule().catch(setSyncError);
344
+ }
345
+
346
+ async function getLocalHeadVersion(): Promise<number> {
347
+ const context = await requireActiveContext();
348
+ const row = await db.selectFrom('log_sync_state')
349
+ .select('downloaded_head_version')
350
+ .where('tenant_id', '=', context.tenantId)
351
+ .where('log_id', '=', context.logId)
352
+ .executeTakeFirst();
353
+ return row?.downloaded_head_version ?? 0;
354
+ }
355
+
356
+ async function updatePendingCount(): Promise<void> {
357
+ try {
358
+ syncStatus.pendingUploads = await getPendingCount();
359
+ const context = await requireActiveContext();
360
+ const attention = await db.selectFrom('outbox_operations')
361
+ .select((eb) => eb.fn.count('id').as('count'))
362
+ .where('tenant_id', '=', context.tenantId)
363
+ .where('log_id', '=', context.logId)
364
+ .where('status', '=', 'needs_attention').executeTakeFirst();
365
+ syncStatus.needsAttention = Number(attention?.count ?? 0);
366
+ await refreshEntitySyncState();
367
+ } catch {
368
+ syncStatus.pendingUploads = 0;
369
+ syncStatus.needsAttention = 0;
370
+ }
371
+ }
372
+
373
+ function clearRetryWakeup(): void {
374
+ if (retryWakeupTimeout) clearTimeout(retryWakeupTimeout);
375
+ retryWakeupTimeout = null;
376
+ }
377
+
378
+ async function scheduleRetryWakeup(): Promise<void> {
379
+ clearRetryWakeup();
380
+ if (!syncStatus.isOnline) return;
381
+ const nextRetryAt = await getNextRetryAt();
382
+ if (!nextRetryAt) return;
383
+ const delay = Math.max(0, nextRetryAt.getTime() - Date.now());
384
+ retryWakeupTimeout = setTimeout(() => {
385
+ retryWakeupTimeout = null;
386
+ void runSyncCoordinator('retry_deadline');
387
+ }, delay);
388
+ }
389
+
390
+ async function suspendForAccessSessionExpiry(error: AccessSessionExpiredError): Promise<void> {
391
+ if (synchronizationSuspended) return;
392
+ synchronizationSuspended = true;
393
+ suspendedForAccessExpiry = true;
394
+ syncRequestedWhileRunning = false;
395
+ clearRetryWakeup();
396
+ if (uploadDebounceTimeout) clearTimeout(uploadDebounceTimeout);
397
+ uploadDebounceTimeout = null;
398
+ realtimeHealthy = false;
399
+ syncStatus.isLedgerReachable = false;
400
+ setSyncError(error);
401
+ await closeRealtime();
402
+ requestAccessReauthentication();
403
+ }
404
+
405
+ function probeAccessAfterRealtimeFailure(): void {
406
+ if (accessProbeInFlight || synchronizationSuspended || !syncStatus.isOnline) return;
407
+ if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
408
+ accessProbeInFlight = true;
409
+ void getLedgerClient().getSession()
410
+ .catch(async (error) => {
411
+ if (error instanceof AccessSessionExpiredError) {
412
+ await suspendForAccessSessionExpiry(error);
413
+ }
414
+ })
415
+ .finally(() => {
416
+ accessProbeInFlight = false;
417
+ });
418
+ }
419
+
420
+ function scheduleRealtimeHealthRecovery(): void {
421
+ if (realtimeStopRequested || realtimeHealthTimeout) return;
422
+ realtimeHealthTimeout = setTimeout(() => {
423
+ realtimeHealthTimeout = null;
424
+ if (!realtimeHealthy) {
425
+ void runSyncCoordinator('ws_error').finally(() => {
426
+ if (!realtimeHealthy) scheduleRealtimeHealthRecovery();
427
+ });
428
+ }
429
+ }, REALTIME_HEALTH_RECOVERY_MS);
430
+ }
431
+
432
+ function clearRealtimeHealthRecovery(): void {
433
+ if (realtimeHealthTimeout) clearTimeout(realtimeHealthTimeout);
434
+ realtimeHealthTimeout = null;
435
+ }
436
+
437
+ export async function loadStoredLogSnapshot(
438
+ logId: string | null,
439
+ storedEvents: StoredEvent[],
440
+ ): Promise<void> {
441
+ if (!logId) {
442
+ const active = await getActiveContext();
443
+ if (active) await clearActiveProjections();
444
+ await deactivateLogContext();
445
+ syncStatus.logId = null;
446
+ syncStatus.tenantId = null;
447
+ syncStatus.lastSyncedAt = null;
448
+ syncStatus.hasCompletedSync = false;
449
+ syncStatus.isLedgerReachable = null;
450
+ await updatePendingCount();
451
+ notifyEventsChanged();
452
+ return;
453
+ }
454
+
455
+ await activateLogContext(logId);
456
+ syncStatus.hasCompletedSync = false;
457
+ syncStatus.isLedgerReachable = null;
458
+ if (storedEvents.length > 0) await handleIncomingStoredEvents(storedEvents);
459
+ await rebuildReadModel();
460
+ // Route invalidation reads through the active target. Activate the rebuilt
461
+ // log first so a guarded log transition cannot deadlock waiting on itself.
462
+ await activateCurrentReadTarget();
463
+ syncStatus.lastSyncedAt = storedEvents.length > 0 ? new Date() : null;
464
+ await updatePendingCount();
465
+ await syncRefreshQueue.flush();
466
+ clearSyncError();
467
+ }
468
+
469
+ async function download(): Promise<number> {
470
+ return withTiming('sync', 'download', async () => {
471
+ const logId = await ensureSelectedContext();
472
+ const context = await requireActiveContext();
473
+ let cursor = await getLocalHeadVersion();
474
+ let downloaded = 0;
475
+ while (true) {
476
+ const storedEvents = await getLedgerClient().getEventPage(logId, cursor, 512);
477
+ if (storedEvents.length === 0) break;
478
+ await handleIncomingStoredEvents(storedEvents);
479
+ const next = await getLocalHeadVersion();
480
+ if (next <= cursor) throw new Error('Ledger download did not fill the next event-log position');
481
+ cursor = next;
482
+ downloaded += storedEvents.length;
483
+ eventSocket?.acknowledge(cursor);
484
+ if (storedEvents.length < 512) break;
485
+ }
486
+ catchUpRevision += 1;
487
+ caughtUpScope = scopeKey({ tenantId: context.tenantId, logId });
488
+ if (downloaded === 0) return 0;
489
+ await addSyncLogEntry('download', `Downloaded ${downloaded} events`, `Through version ${cursor}`);
490
+ await syncRefreshQueue.flush();
491
+ announceChange('ingest');
492
+ return downloaded;
493
+ });
494
+ }
495
+
496
+ async function currentVersionsFor(events: PendingBatchEvent[]): Promise<Map<string, number>> {
497
+ const versions = new Map<string, number>();
498
+ for (const entityId of new Set(events.map((event) => event.entityId))) {
499
+ const version = await versionTracker.getCurrentVersion(entityId);
500
+ if (version !== null) versions.set(entityId, version);
501
+ }
502
+ return versions;
503
+ }
504
+
505
+ async function prepareNextOperation(rebaseCount = 0): Promise<DurableOperation | null> {
506
+ const batch = await getNextPendingBatch();
507
+ if (!batch) return null;
508
+ const request = prepareAppendRequest(batch.events, await currentVersionsFor(batch.events));
509
+ // Existing durable requests are retried byte-for-byte. Only freshly prepared
510
+ // requests gain metadata after the server has advertised support.
511
+ if (await getLedgerClient().getActionMetadataSupport()) request.actionId = batch.events[0]!.id;
512
+ return persistOperation(batch.id, request, rebaseCount);
513
+ }
514
+
515
+ type OperationResult = 'synced' | 'retry_later' | 'continue';
516
+
517
+ /** Automatic merges attempted for one batch while other devices keep writing. */
518
+ const MAX_AUTOMATIC_REBASES = 5;
519
+
520
+ /**
521
+ * What of this batch's edits clashes with edits other devices had accepted for
522
+ * the same records since the versions the batch was prepared against.
523
+ */
524
+ async function findBatchClashes(operation: DurableOperation): Promise<EntityClash[]> {
525
+ const byEntity = new Map<string, DurableOperation['request']['events']>();
526
+ for (const event of operation.request.events) {
527
+ byEntity.set(event.entityId, [...byEntity.get(event.entityId) ?? [], event]);
528
+ }
529
+ const clashes: EntityClash[] = [];
530
+ for (const [entityId, events] of byEntity) {
531
+ const base = events[0]?.baseEntityVersion;
532
+ // A record this batch creates has no history to clash with.
533
+ if (base === undefined || base === null) continue;
534
+ const theirs = await getAcceptedEntityEdits(entityId, base);
535
+ if (theirs.length === 0) continue;
536
+ const mine = events.map((event) => ({ type: event.type, payload: event.payload }));
537
+ clashes.push(...findClashes(entityId, mine, theirs));
538
+ }
539
+ return clashes;
540
+ }
541
+
542
+ async function executeOperation(operation: DurableOperation): Promise<OperationResult> {
543
+ const logId = await ensureSelectedContext();
544
+ await markOperationSyncing(operation.id);
545
+ let accepted = false;
546
+ let applied = false;
547
+ try {
548
+ const response = await getLedgerClient().appendEvents(logId, operation.request);
549
+ accepted = true;
550
+ // An append response contains only this request's events, not intervening
551
+ // writes from other devices. Ingest/re-project it, then catch up any gap.
552
+ await handleIncomingStoredEvents(response.events);
553
+ await markOperationSynced(operation, response.events);
554
+ applied = true;
555
+ const through = await getLocalHeadVersion();
556
+ eventSocket?.acknowledge(through);
557
+ if (through < response.newVersion) await download();
558
+ await addSyncLogEntry('upload', `Uploaded ${response.events.length} events`, `requestId: ${operation.requestId}`);
559
+ announceChange('ingest');
560
+ return 'synced';
561
+ } catch (error) {
562
+ const message = error instanceof Error ? error.message : String(error);
563
+ if (accepted) {
564
+ if (!applied) await markOperationFailed(operation, 'local_apply_failed', message);
565
+ throw error; // An accepted append must never become a rejected local action.
566
+ }
567
+ if (operation.request.expectedLogVersion !== undefined && error instanceof LedgerServiceError
568
+ && (error.code === 'stale_restore_preview' || error instanceof VersionConflictError)) {
569
+ await rejectBatch(operation, 'stale_restore_preview', 'The log changed. Preview the restore again.');
570
+ return 'continue';
571
+ }
572
+ if (error instanceof VersionConflictError) {
573
+ // The other devices' events are needed to tell a merge from a clash.
574
+ await download();
575
+ const clashes = await findBatchClashes(operation);
576
+ if (clashes.length > 0) {
577
+ await parkOperationForConflict(operation, clashes);
578
+ return 'retry_later';
579
+ }
580
+ if (operation.rebaseCount >= MAX_AUTOMATIC_REBASES) {
581
+ await markOperationFailed(operation, error.code ?? 'entity_conflict', message, true);
582
+ return 'retry_later';
583
+ }
584
+ // Different fields, or the same values: send the batch again on top.
585
+ await supersedeOperation(operation);
586
+ await rebuildReadModel();
587
+ await prepareNextOperation(operation.rebaseCount + 1);
588
+ return 'continue';
589
+ }
590
+ // A retry cannot change these answers, so park the batch now instead
591
+ // of spending its retries on a guaranteed conflict.
592
+ const alreadyAccepted = error instanceof LedgerServiceError && error.statusCode === 409
593
+ && (error.code === 'event_already_accepted' || error.code === 'action_already_accepted');
594
+ if (error instanceof EntityVersionChainError || error instanceof IdempotencyMismatchError || alreadyAccepted) {
595
+ await markOperationFailed(operation, error.code ?? 'sync_invariant', message, true);
596
+ return 'retry_later';
597
+ }
598
+ const moved = movedDestination(error);
599
+ if (moved) {
600
+ // Not a bad change: the workspace lives elsewhere now. Keep the request, stop sending, tell the person.
601
+ await markWorkspaceMoved(moved.destination);
602
+ await markOperationFailed(operation, 'tenant_moved', message, false);
603
+ throw error;
604
+ }
605
+ if (error instanceof AccessSessionExpiredError) {
606
+ // The operation remains in its durable, retryable state. Access
607
+ // expiry is not a bad local change and must not require repair
608
+ // after the user signs in again.
609
+ throw error;
610
+ }
611
+ if (error instanceof AuthenticationError || error instanceof NotFoundError) {
612
+ await markOperationFailed(operation, error.code ?? 'client_error', message, true);
613
+ throw error;
614
+ }
615
+ if (error instanceof LedgerServiceError && [400, 422].includes(error.statusCode ?? 0)) {
616
+ await rejectBatch(operation, error.code ?? 'invalid_event_envelope', message);
617
+ await rebuildReadModel();
618
+ return 'continue';
619
+ }
620
+ // Ledger being unreachable never needs a person; any other answer
621
+ // would repeat on every attempt, so park it now.
622
+ await markOperationFailed(
623
+ operation,
624
+ error instanceof LedgerServiceError ? error.code ?? 'ledger_error' : 'network_error',
625
+ message,
626
+ !isTransientSyncFailure(error),
627
+ );
628
+ throw error instanceof Error ? error : new Error(message);
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Turns every queued local batch of the active log into a durable request, so a
634
+ * file exported after a move holds all of this device's unsynced work.
635
+ */
636
+ export async function preparePendingOperations(): Promise<void> {
637
+ const context = await requireActiveContext();
638
+ await withExclusiveSyncLock(`stor:outbox:${context.tenantId}:${context.logId}`, async () => {
639
+ while (await prepareNextOperation()) {
640
+ // Each pass persists the next batch that has no request yet, so this ends.
641
+ }
642
+ });
643
+ }
644
+
645
+ async function drainDurableOutbox(): Promise<void> {
646
+ // A moved workspace keeps its queue for export; sending would only repeat the 410.
647
+ if (movedWorkspace.value) return;
648
+ const context = await requireActiveContext();
649
+ const acquired = await withExclusiveSyncLock(
650
+ `stor:outbox:${context.tenantId}:${context.logId}`,
651
+ async () => {
652
+ let logged = false;
653
+ while (true) {
654
+ const operation = await getReadyOperation() ?? await prepareNextOperation();
655
+ if (!operation) break;
656
+ if (!logged) await addSyncLogEntry('drain', 'Draining durable outbox');
657
+ logged = true;
658
+ const result = await executeOperation(operation);
659
+ if (result === 'retry_later') break;
660
+ }
661
+ },
662
+ );
663
+ await updatePendingCount();
664
+ await scheduleRetryWakeup();
665
+ if (!acquired && !retryWakeupTimeout && syncStatus.isOnline) {
666
+ retryWakeupTimeout = setTimeout(() => {
667
+ retryWakeupTimeout = null;
668
+ void runSyncCoordinator('retry_deadline');
669
+ }, RETRY_BASE_DELAY_MS);
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Applies every event delivered since the last drain in one ingestion, so a
675
+ * batch appended elsewhere costs one transaction instead of one per event.
676
+ */
677
+ async function handleRealtimeEvents(): Promise<void> {
678
+ const delivered = pendingRealtimeEvents.splice(0).sort((a, b) => a.version - b.version);
679
+ try {
680
+ const localHead = await getLocalHeadVersion();
681
+ let head = localHead;
682
+ const batch: StoredEvent[] = [];
683
+ for (const storedEvent of delivered) {
684
+ const disposition = classifyRealtimeVersion(head, storedEvent.version);
685
+ if (disposition === 'duplicate') continue;
686
+ if (disposition === 'resynchronize') {
687
+ await resynchronizeRealtime(delivered.at(-1)!.version, 'gap_too_large');
688
+ return;
689
+ }
690
+ batch.push(storedEvent);
691
+ head = storedEvent.version;
692
+ }
693
+ if (batch.length === 0) {
694
+ eventSocket?.acknowledge(localHead);
695
+ return;
696
+ }
697
+ await handleIncomingStoredEvents(batch);
698
+ eventSocket?.acknowledge(await getLocalHeadVersion());
699
+ syncStatus.lastSyncedAt = new Date();
700
+ syncStatus.hasCompletedSync = true;
701
+ markLedgerReachable();
702
+ const last = batch.at(-1)!;
703
+ await addSyncLogEntry('ws_receive', batch.length === 1
704
+ ? `Received event v${last.version}: ${last.type}`
705
+ : `Received ${batch.length} events through v${last.version}`);
706
+ scheduleRealtimeInvalidation();
707
+ announceChange('ingest');
708
+ } catch (error) {
709
+ await addSyncLogEntry('error', 'Failed to process WebSocket event', error instanceof Error ? error.message : String(error));
710
+ setSyncError(error);
711
+ }
712
+ }
713
+
714
+ function enqueueRealtimeEvent(storedEvent: StoredEvent): void {
715
+ pendingRealtimeEvents.push(storedEvent);
716
+ // A drain is already queued; it takes every event delivered before it runs.
717
+ if (pendingRealtimeEvents.length > 1) return;
718
+ void realtimeEventQueue
719
+ .enqueue(handleRealtimeEvents)
720
+ .catch((error) => {
721
+ void addSyncLogEntry(
722
+ 'error',
723
+ 'Failed to process queued WebSocket event',
724
+ error instanceof Error ? error.message : String(error),
725
+ );
726
+ setSyncError(error);
727
+ });
728
+ }
729
+
730
+ async function handleRealtimeReady(headVersion: number): Promise<void> {
731
+ const localHead = await getLocalHeadVersion();
732
+ if (localHead < headVersion) {
733
+ await resynchronizeRealtime(headVersion, 'gap_too_large');
734
+ return;
735
+ }
736
+ eventSocket?.acknowledge(localHead);
737
+ realtimeHealthy = true;
738
+ markLedgerReachable();
739
+ clearRealtimeHealthRecovery();
740
+ await addSyncLogEntry('ws_ready', `WebSocket ready at version ${headVersion}`);
741
+ }
742
+
743
+ async function resynchronizeRealtime(
744
+ headVersion: number,
745
+ reason: import('../ledger/socket.js').SocketResyncReason,
746
+ ): Promise<void> {
747
+ realtimeHealthy = false;
748
+ const socket = eventSocket;
749
+ socket?.pause();
750
+ await addSyncLogEntry('ws_resync', `Resynchronizing through version ${headVersion}`, reason);
751
+ await download();
752
+ const localHead = await getLocalHeadVersion();
753
+ socket?.resetAfterVersion(localHead);
754
+ if (
755
+ socket &&
756
+ socket === eventSocket &&
757
+ !realtimeStopRequested &&
758
+ syncStatus.isOnline &&
759
+ (typeof document === 'undefined' || document.visibilityState === 'visible')
760
+ ) {
761
+ await socket.open();
762
+ }
763
+ }
764
+
765
+ async function initializeRealtime(catchUpScope: string | null = null): Promise<void> {
766
+ if (!loadLedgerConfig().enableRealtime) return;
767
+ await ensureSelectedContext();
768
+ const context = await requireActiveContext();
769
+ const key = scopeKey(context);
770
+ if (releaseRealtimeLeaseLock && realtimeLeaseKey === key) {
771
+ // Already ours or queued behind another tab; retry a connection that
772
+ // failed to open.
773
+ if (realtimeLeaseAcquired) await openRealtime(catchUpScope);
774
+ return;
775
+ }
776
+ releaseRealtimeLease();
777
+ const generation = ++realtimeGeneration;
778
+ realtimeLeaseKey = key;
779
+ releaseRealtimeLeaseLock = holdTabLock(realtimeLockName(context), async () => {
780
+ if (generation !== realtimeGeneration) return;
781
+ realtimeLeaseAcquired = true;
782
+ try {
783
+ await openRealtime(catchUpScope);
784
+ } catch (error) {
785
+ setSyncError(error);
786
+ scheduleRealtimeHealthRecovery();
787
+ }
788
+ });
789
+ }
790
+
791
+ function releaseRealtimeLease(): void {
792
+ realtimeGeneration += 1;
793
+ releaseRealtimeLeaseLock?.();
794
+ releaseRealtimeLeaseLock = null;
795
+ realtimeLeaseKey = null;
796
+ realtimeLeaseAcquired = false;
797
+ }
798
+
799
+ async function openRealtime(catchUpScope: string | null = null): Promise<void> {
800
+ if (eventSocket || openingRealtime) return;
801
+ openingRealtime = true;
802
+ const generation = realtimeGeneration;
803
+ try {
804
+ const logId = await ensureSelectedContext();
805
+ // A completed coordinator catch-up already supplies this scope's cursor.
806
+ // Ordered socket replay covers the connection gap; oversized gaps still
807
+ // return to HTTP through resynchronizeRealtime.
808
+ if (catchUpScope !== scopeKey(await requireActiveContext())) await download();
809
+ await getLedgerClient().getWorkspaceRestoreSupport();
810
+ // The lease may have been given up while this was waiting on the network.
811
+ if (generation !== realtimeGeneration) return;
812
+ await connectRealtime(logId, generation);
813
+ } finally {
814
+ openingRealtime = false;
815
+ }
816
+ // A newer lease may have been granted while this one was opening.
817
+ if (generation !== realtimeGeneration && realtimeLeaseAcquired && !eventSocket) await openRealtime();
818
+ }
819
+
820
+ async function connectRealtime(logId: string, generation: number): Promise<void> {
821
+ const socket = getLedgerClient().createEventSocket(logId, await getLocalHeadVersion());
822
+ if (generation !== realtimeGeneration) return;
823
+ eventSocket = socket;
824
+ socket.on('event', enqueueRealtimeEvent);
825
+ socket.on('ready', (headVersion) => {
826
+ void realtimeEventQueue.enqueue(() => handleRealtimeReady(headVersion)).catch(setSyncError);
827
+ });
828
+ socket.on('resync_required', (headVersion, reason) => {
829
+ void realtimeEventQueue
830
+ .enqueue(() => resynchronizeRealtime(headVersion, reason))
831
+ .catch(setSyncError);
832
+ });
833
+ socket.on('error', (error: Error) => {
834
+ realtimeHealthy = false;
835
+ syncStatus.isLedgerReachable = false;
836
+ void addSyncLogEntry('ws_error', 'WebSocket error', error.message);
837
+ probeAccessAfterRealtimeFailure();
838
+ scheduleRealtimeHealthRecovery();
839
+ });
840
+ socket.on('close', () => {
841
+ realtimeHealthy = false;
842
+ void addSyncLogEntry('ws_close', 'WebSocket disconnected');
843
+ if (!realtimeStopRequested) {
844
+ probeAccessAfterRealtimeFailure();
845
+ scheduleRealtimeHealthRecovery();
846
+ }
847
+ });
848
+ await socket.open();
849
+ if (generation !== realtimeGeneration && eventSocket === socket) await closeSocket();
850
+ }
851
+
852
+ async function closeRealtime(): Promise<void> {
853
+ releaseRealtimeLease();
854
+ await closeSocket();
855
+ }
856
+
857
+ async function closeSocket(): Promise<void> {
858
+ if (!eventSocket) return;
859
+ const socket = eventSocket;
860
+ eventSocket = null;
861
+ realtimeStopRequested = true;
862
+ realtimeHealthy = false;
863
+ clearRealtimeHealthRecovery();
864
+ socket.close();
865
+ await realtimeEventQueue.idle();
866
+ realtimeStopRequested = false;
867
+ }
868
+
869
+ export async function sync(trigger: SyncTrigger = 'manual'): Promise<void> {
870
+ if (synchronizationSuspended) return;
871
+ if (syncInProgress) {
872
+ syncRequestedWhileRunning = true;
873
+ return;
874
+ }
875
+ syncInProgress = true;
876
+ syncStatus.isSyncing = true;
877
+ try {
878
+ do {
879
+ syncRequestedWhileRunning = false;
880
+ try {
881
+ await withTiming('sync', 'fullSyncCycle', async () => {
882
+ await ensureSelectedContext();
883
+ const context = await requireActiveContext();
884
+ // One cycle per log across tabs: a tab that waits its turn
885
+ // finds the earlier cycle's downloads and uploads done.
886
+ await withTabLock(syncCycleLockName(context), async () => {
887
+ if (!realtimeHealthy || !eventSocket?.isReady()) await download();
888
+ if (syncStatus.isOnline) await drainDurableOutbox();
889
+ syncStatus.lastSyncedAt = new Date();
890
+ syncStatus.hasCompletedSync = true;
891
+ markLedgerReachable();
892
+ });
893
+ }, { trigger });
894
+ } catch (error) {
895
+ markLedgerUnreachable(error);
896
+ if (error instanceof AccessSessionExpiredError) {
897
+ await suspendForAccessSessionExpiry(error);
898
+ return;
899
+ }
900
+ }
901
+ } while (syncRequestedWhileRunning && syncStatus.isOnline);
902
+ } finally {
903
+ syncInProgress = false;
904
+ syncIdleWaiters.splice(0).forEach((resolve) => resolve());
905
+ syncStatus.isSyncing = false;
906
+ if (!synchronizationSuspended) {
907
+ await updatePendingCount();
908
+ await scheduleRetryWakeup();
909
+ }
910
+ }
911
+ }
912
+
913
+ export async function runSyncCoordinator(trigger: SyncTrigger): Promise<void> {
914
+ if (synchronizationSuspended) return;
915
+ // Another tab changed the data while this one was hidden.
916
+ if (remoteChangePending && (typeof document === 'undefined' || document.visibilityState === 'visible')) {
917
+ scheduleRemoteRefresh();
918
+ }
919
+ if (!syncStatus.isOnline) {
920
+ await updatePendingCount();
921
+ return;
922
+ }
923
+ if (
924
+ trigger !== 'manual' &&
925
+ typeof document !== 'undefined' &&
926
+ document.visibilityState !== 'visible'
927
+ ) {
928
+ await updatePendingCount();
929
+ clearRetryWakeup();
930
+ return;
931
+ }
932
+
933
+ const beforeCatchUp = catchUpRevision;
934
+ await sync(trigger);
935
+ if (!synchronizationSuspended && (
936
+ typeof document === 'undefined' ||
937
+ document.visibilityState === 'visible'
938
+ )) {
939
+ await initializeRealtime(catchUpRevision !== beforeCatchUp && !syncStatus.hasError ? caughtUpScope : null);
940
+ }
941
+ }
942
+
943
+ export function signalLocalChange(): void {
944
+ notifyEventsChanged();
945
+ void updatePendingCount();
946
+ announceChange('commit');
947
+ if (import.meta.env.VITE_E2E === '1') return;
948
+ if (synchronizationSuspended || typeof window === 'undefined' || !syncStatus.isOnline) return;
949
+ if (uploadDebounceTimeout) clearTimeout(uploadDebounceTimeout);
950
+ uploadDebounceTimeout = setTimeout(() => {
951
+ uploadDebounceTimeout = null;
952
+ void runSyncCoordinator('local_commit');
953
+ }, UPLOAD_DEBOUNCE_MS);
954
+ }
955
+
956
+ onLocalCommandCommitted(() => signalLocalChange());
957
+
958
+ export async function startRealtime(): Promise<void> {
959
+ await initializeRealtime();
960
+ }
961
+
962
+ export async function stopRealtime(): Promise<void> {
963
+ clearRetryWakeup();
964
+ await closeRealtime();
965
+ }
966
+
967
+ export async function shutdownSynchronization(): Promise<void> {
968
+ synchronizationSuspended = true;
969
+ syncRequestedWhileRunning = false;
970
+ clearRetryWakeup();
971
+ if (uploadDebounceTimeout) clearTimeout(uploadDebounceTimeout);
972
+ uploadDebounceTimeout = null;
973
+ await closeRealtime();
974
+ if (syncInProgress) {
975
+ await new Promise<void>((resolve) => syncIdleWaiters.push(resolve));
976
+ }
977
+ }
978
+
979
+ export async function handleOnline(): Promise<void> {
980
+ syncStatus.isOnline = true;
981
+ if (suspendedForAccessExpiry && requestAccessReauthentication()) return;
982
+ await runSyncCoordinator('online');
983
+ }
984
+
985
+ export async function handleOffline(): Promise<void> {
986
+ syncStatus.isOnline = false;
987
+ syncStatus.isLedgerReachable = false;
988
+ clearRetryWakeup();
989
+ await closeRealtime();
990
+ }
991
+
992
+ export async function resetHeadVersion(): Promise<void> {
993
+ const context = await getActiveContext();
994
+ if (context) {
995
+ await db.updateTable('log_sync_state').set({
996
+ downloaded_head_version: 0,
997
+ projected_head_version: 0,
998
+ last_synced_at: null,
999
+ }).where('tenant_id', '=', context.tenantId)
1000
+ .where('log_id', '=', context.logId).execute();
1001
+ }
1002
+ await delKv('log-head-version');
1003
+ }
1004
+
1005
+
1006
+ /** Await the active cycle as well as a newly requested one. */
1007
+ export async function syncAndWait(): Promise<void> {
1008
+ await sync();
1009
+ if (syncInProgress) await new Promise<void>(resolve => syncIdleWaiters.push(resolve));
1010
+ }