@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,426 @@
1
+ import { db, requireActiveContext } from '../db/index.js';
2
+ import type { AppendEventsRequest, StoredEvent } from '../ledger/index.js';
3
+ import {
4
+ hashRequest,
5
+ type PendingBatchEvent,
6
+ } from './outbox-request.js';
7
+ import { computeRetryDeadline } from './sync-coordinator.js';
8
+ import { describeClashes, type EntityClash, type EntityEdit } from './conflict-merge.js';
9
+
10
+ export { hashRequest, MAX_BATCH_SIZE, prepareAppendRequest } from './outbox-request.js';
11
+ export type { PendingBatchEvent } from './outbox-request.js';
12
+
13
+ export type OutboxStatus = 'pending' | 'syncing' | 'failed' | 'needs_attention' | 'superseded';
14
+
15
+ export interface OutboxEvent {
16
+ id: string;
17
+ eventId: string;
18
+ status: OutboxStatus;
19
+ retryCount: number;
20
+ lastError: string | null;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ }
24
+
25
+ export interface DurableOperation {
26
+ id: string;
27
+ requestId: string;
28
+ batchId: string;
29
+ request: AppendEventsRequest;
30
+ requestHash: string;
31
+ status: OutboxStatus;
32
+ retryCount: number;
33
+ rebaseCount: number;
34
+ nextRetryAt: string | null;
35
+ }
36
+
37
+ export async function getPendingCount(): Promise<number> {
38
+ const context = await requireActiveContext();
39
+ const result = await db.selectFrom('local_batches')
40
+ .select((eb) => eb.fn.count('id').as('count'))
41
+ .where('tenant_id', '=', context.tenantId)
42
+ .where('log_id', '=', context.logId)
43
+ .where('status', 'in', ['pending', 'conflicted'])
44
+ .executeTakeFirst();
45
+ return Number(result?.count ?? 0);
46
+ }
47
+
48
+ export async function getReadyOperation(): Promise<DurableOperation | null> {
49
+ const context = await requireActiveContext();
50
+ const row = await db.selectFrom('outbox_operations')
51
+ .selectAll()
52
+ .where('tenant_id', '=', context.tenantId)
53
+ .where('log_id', '=', context.logId)
54
+ .where('status', 'in', ['pending', 'syncing', 'failed'])
55
+ .where((eb) => eb.or([
56
+ eb('next_retry_at', 'is', null),
57
+ eb('next_retry_at', '<=', new Date().toISOString()),
58
+ ]))
59
+ .orderBy('created_at', 'asc')
60
+ .executeTakeFirst();
61
+ if (!row) return null;
62
+ return {
63
+ id: row.id,
64
+ requestId: row.request_id,
65
+ batchId: row.batch_id,
66
+ request: JSON.parse(row.request_body) as AppendEventsRequest,
67
+ requestHash: row.request_hash,
68
+ status: row.status as OutboxStatus,
69
+ retryCount: row.retry_count,
70
+ rebaseCount: row.rebase_count,
71
+ nextRetryAt: row.next_retry_at,
72
+ };
73
+ }
74
+
75
+ /** Every request of this tenant that no Ledger has acknowledged, oldest first, exactly as queued. */
76
+ export async function listUnsyncedOperations(): Promise<Array<{ logId: string; request: AppendEventsRequest; status: string; createdAt: string }>> {
77
+ const context = await requireActiveContext();
78
+ const rows = await db.selectFrom('outbox_operations')
79
+ .select(['log_id', 'request_body', 'status', 'created_at'])
80
+ .where('tenant_id', '=', context.tenantId)
81
+ .where('status', 'in', ['pending', 'syncing', 'failed', 'needs_attention'])
82
+ .orderBy('created_at', 'asc')
83
+ .execute();
84
+ return rows.map((row) => ({
85
+ logId: row.log_id,
86
+ request: JSON.parse(row.request_body) as AppendEventsRequest,
87
+ status: row.status,
88
+ createdAt: row.created_at,
89
+ }));
90
+ }
91
+
92
+ export async function getNextPendingBatch(): Promise<{ id: string; events: PendingBatchEvent[] } | null> {
93
+ const context = await requireActiveContext();
94
+ const batches = await db.selectFrom('local_batches')
95
+ .select(['id'])
96
+ .where('tenant_id', '=', context.tenantId)
97
+ .where('log_id', '=', context.logId)
98
+ .where('status', 'in', ['pending', 'conflicted'])
99
+ .orderBy('created_at', 'asc')
100
+ .execute();
101
+
102
+ for (const batch of batches) {
103
+ const existing = await db.selectFrom('outbox_operations')
104
+ .select('id')
105
+ .where('batch_id', '=', batch.id)
106
+ .where('status', 'in', ['pending', 'syncing', 'failed', 'needs_attention'])
107
+ .executeTakeFirst();
108
+ if (existing) continue;
109
+
110
+ const rows = await db.selectFrom('raw_events')
111
+ .select(['id', 'type', 'timestamp', 'entity_id', 'payload', 'local_sequence'])
112
+ .where('tenant_id', '=', context.tenantId)
113
+ .where('log_id', '=', context.logId)
114
+ .where('local_batch_id', '=', batch.id)
115
+ .where('sync_state', 'in', ['pending', 'conflicted'])
116
+ .where('projection_state', '=', 'effective')
117
+ .orderBy('local_sequence', 'asc')
118
+ .execute();
119
+ if (rows.length === 0) continue;
120
+ return {
121
+ id: batch.id,
122
+ events: rows.map((row) => ({
123
+ id: row.id,
124
+ type: row.type,
125
+ timestamp: row.timestamp,
126
+ entityId: row.entity_id ?? '',
127
+ payload: JSON.parse(row.payload) as Record<string, unknown>,
128
+ localSequence: row.local_sequence,
129
+ })),
130
+ };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ export async function persistOperation(
136
+ batchId: string,
137
+ request: AppendEventsRequest,
138
+ rebaseCount = 0,
139
+ expectedScope?: { tenantId: string; logId: string },
140
+ ): Promise<DurableOperation> {
141
+ const context = await requireActiveContext();
142
+ if (expectedScope && (context.tenantId !== expectedScope.tenantId || context.logId !== expectedScope.logId)) {
143
+ throw new Error('The selected log changed. Preview the restore again.');
144
+ }
145
+ const id = crypto.randomUUID();
146
+ const requestHash = await hashRequest(request);
147
+ const requestBody = JSON.stringify(request);
148
+ await db.transaction().execute(async (tx) => {
149
+ if (request.events.some(event => event.type === 'WORKSPACE_RESTORED')) {
150
+ if (request.events.length !== 1 || request.expectedLogVersion === undefined || !request.actionId || !expectedScope) {
151
+ throw new Error('A restore requires a scoped, complete preview');
152
+ }
153
+ const pending = await tx.selectFrom('raw_events').select('id')
154
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
155
+ .where('log_version', 'is', null).where('sync_state', 'in', ['pending', 'conflicted']).executeTakeFirst();
156
+ const progress = await tx.selectFrom('log_sync_state').select('projected_head_version')
157
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId).executeTakeFirst();
158
+ if (pending || progress?.projected_head_version !== request.expectedLogVersion) throw new Error('The log changed. Preview the restore again.');
159
+ const sequence = await tx.selectFrom('raw_events').select(eb => eb.fn.max('local_sequence').as('last'))
160
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId).executeTakeFirst();
161
+ const event = request.events[0]!;
162
+ await tx.insertInto('local_batches').values({ id: batchId, tenant_id: context.tenantId, log_id: context.logId,
163
+ status: 'pending', replacement_for: null }).execute();
164
+ await tx.insertInto('raw_events').values({ id: event.id!, tenant_id: context.tenantId, log_id: context.logId,
165
+ local_batch_id: batchId, local_sequence: Number(sequence?.last ?? 0) + 1,
166
+ type: event.type, timestamp: event.timestamp!, payload: JSON.stringify(event.payload), entity_id: event.entityId,
167
+ entity_version: null, log_version: null, sync_state: 'pending', projection_state: 'pending_restore',
168
+ rejection_code: null, rejection_detail: null, is_tombstoned: 0 }).execute();
169
+ }
170
+ await tx.insertInto('outbox_operations').values({
171
+ id,
172
+ request_id: request.requestId,
173
+ tenant_id: context.tenantId,
174
+ log_id: context.logId,
175
+ batch_id: batchId,
176
+ request_body: requestBody,
177
+ request_hash: requestHash,
178
+ status: 'pending',
179
+ retry_count: 0,
180
+ rebase_count: rebaseCount,
181
+ last_error_code: null,
182
+ last_error_message: null,
183
+ superseded_by: null,
184
+ next_retry_at: null,
185
+ }).execute();
186
+ await tx.insertInto('outbox_operation_items').values(
187
+ request.events.map((event, ordinal) => ({
188
+ operation_id: id,
189
+ event_id: event.id as string,
190
+ ordinal,
191
+ })),
192
+ ).execute();
193
+ });
194
+ return {
195
+ id,
196
+ requestId: request.requestId,
197
+ batchId,
198
+ request,
199
+ requestHash,
200
+ status: 'pending',
201
+ retryCount: 0,
202
+ rebaseCount,
203
+ nextRetryAt: null,
204
+ };
205
+ }
206
+
207
+ export async function markOperationSyncing(operationId: string): Promise<void> {
208
+ const context = await requireActiveContext();
209
+ await db.updateTable('outbox_operations').set({
210
+ status: 'syncing',
211
+ next_retry_at: null,
212
+ updated_at: new Date().toISOString(),
213
+ })
214
+ .where('tenant_id', '=', context.tenantId)
215
+ .where('log_id', '=', context.logId)
216
+ .where('id', '=', operationId).execute();
217
+ }
218
+
219
+ export async function markOperationSynced(operation: DurableOperation, storedEvents: StoredEvent[]): Promise<void> {
220
+ const context = await requireActiveContext();
221
+ await db.transaction().execute(async (tx) => {
222
+ for (const event of storedEvents) {
223
+ await tx.updateTable('raw_events').set({
224
+ entity_id: event.entityId,
225
+ entity_version: event.entityVersion,
226
+ log_version: event.version,
227
+ sync_state: 'synced',
228
+ rejection_code: null,
229
+ rejection_detail: null,
230
+ }).where('tenant_id', '=', context.tenantId)
231
+ .where('log_id', '=', context.logId)
232
+ .where('id', '=', event.id).execute();
233
+ }
234
+ await tx.updateTable('local_batches').set({ status: 'synced', updated_at: new Date().toISOString() })
235
+ .where('tenant_id', '=', context.tenantId)
236
+ .where('log_id', '=', context.logId)
237
+ .where('id', '=', operation.batchId).execute();
238
+ await tx.updateTable('outbox_operations').set({
239
+ status: 'synced',
240
+ next_retry_at: null,
241
+ updated_at: new Date().toISOString(),
242
+ })
243
+ .where('tenant_id', '=', context.tenantId)
244
+ .where('log_id', '=', context.logId)
245
+ .where('id', '=', operation.id).execute();
246
+ });
247
+ }
248
+
249
+ export async function markOperationFailed(
250
+ operation: DurableOperation,
251
+ code: string,
252
+ message: string,
253
+ needsAttention = false,
254
+ now = Date.now(),
255
+ random = Math.random,
256
+ ): Promise<void> {
257
+ // Retryable failures never park: the count only drives the capped backoff.
258
+ const context = await requireActiveContext();
259
+ const retryCount = operation.retryCount + 1;
260
+ const terminal = needsAttention;
261
+ await db.updateTable('outbox_operations').set({
262
+ status: terminal ? 'needs_attention' : 'failed',
263
+ retry_count: retryCount,
264
+ next_retry_at: terminal
265
+ ? null
266
+ : computeRetryDeadline(retryCount, now, random).toISOString(),
267
+ last_error_code: code,
268
+ last_error_message: message,
269
+ updated_at: new Date().toISOString(),
270
+ })
271
+ .where('tenant_id', '=', context.tenantId)
272
+ .where('log_id', '=', context.logId)
273
+ .where('id', '=', operation.id).execute();
274
+ }
275
+
276
+ export async function getNextRetryAt(): Promise<Date | null> {
277
+ const context = await requireActiveContext();
278
+ const row = await db.selectFrom('outbox_operations')
279
+ .select('next_retry_at')
280
+ .where('tenant_id', '=', context.tenantId)
281
+ .where('log_id', '=', context.logId)
282
+ .where('status', '=', 'failed')
283
+ .where('next_retry_at', 'is not', null)
284
+ .orderBy('next_retry_at', 'asc')
285
+ .executeTakeFirst();
286
+ return row?.next_retry_at ? new Date(row.next_retry_at) : null;
287
+ }
288
+
289
+ export async function supersedeOperation(operation: DurableOperation): Promise<void> {
290
+ const context = await requireActiveContext();
291
+ // Both rows move together: a superseded operation always leaves its batch
292
+ // conflicted, ready for one rebased operation.
293
+ await db.transaction().execute(async (tx) => {
294
+ await tx.updateTable('outbox_operations').set({
295
+ status: 'superseded',
296
+ next_retry_at: null,
297
+ updated_at: new Date().toISOString(),
298
+ })
299
+ .where('tenant_id', '=', context.tenantId)
300
+ .where('log_id', '=', context.logId)
301
+ .where('id', '=', operation.id).execute();
302
+ await tx.updateTable('local_batches').set({ status: 'conflicted', updated_at: new Date().toISOString() })
303
+ .where('tenant_id', '=', context.tenantId)
304
+ .where('log_id', '=', context.logId)
305
+ .where('id', '=', operation.batchId).execute();
306
+ });
307
+ }
308
+
309
+ export async function rejectBatch(operation: DurableOperation, code: string, message: string): Promise<void> {
310
+ const context = await requireActiveContext();
311
+ await db.transaction().execute(async (tx) => {
312
+ await tx.updateTable('raw_events').set({
313
+ sync_state: 'rejected',
314
+ projection_state: 'excluded',
315
+ rejection_code: code,
316
+ rejection_detail: message,
317
+ }).where('tenant_id', '=', context.tenantId)
318
+ .where('log_id', '=', context.logId)
319
+ .where('local_batch_id', '=', operation.batchId).execute();
320
+ await tx.updateTable('local_batches').set({ status: 'rejected', updated_at: new Date().toISOString() })
321
+ .where('tenant_id', '=', context.tenantId)
322
+ .where('log_id', '=', context.logId)
323
+ .where('id', '=', operation.batchId).execute();
324
+ await tx.updateTable('outbox_operations').set({
325
+ status: 'needs_attention', last_error_code: code,
326
+ last_error_message: message, next_retry_at: null,
327
+ updated_at: new Date().toISOString(),
328
+ })
329
+ .where('tenant_id', '=', context.tenantId)
330
+ .where('log_id', '=', context.logId)
331
+ .where('id', '=', operation.id).execute();
332
+ await tx.insertInto('resolution_actions').values({
333
+ id: crypto.randomUUID(), tenant_id: context.tenantId, log_id: context.logId,
334
+ batch_id: operation.batchId, action: 'rejected', details: JSON.stringify({ code, message }),
335
+ }).execute();
336
+ });
337
+ }
338
+
339
+ export async function clearOutbox(): Promise<void> {
340
+ const context = await requireActiveContext();
341
+ await db.transaction().execute(async (tx) => {
342
+ const operations = await tx.selectFrom('outbox_operations').select('id')
343
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId).execute();
344
+ if (operations.length > 0) {
345
+ await tx.deleteFrom('outbox_operation_items')
346
+ .where('operation_id', 'in', operations.map((operation) => operation.id)).execute();
347
+ }
348
+ await tx.deleteFrom('outbox_operations')
349
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId).execute();
350
+ await tx.updateTable('raw_events').set({ sync_state: 'discarded', projection_state: 'excluded' })
351
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
352
+ .where('sync_state', 'in', ['pending', 'conflicted']).execute();
353
+ await tx.updateTable('local_batches').set({ status: 'discarded', updated_at: new Date().toISOString() })
354
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
355
+ .where('status', 'in', ['pending', 'conflicted']).execute();
356
+ });
357
+ }
358
+
359
+ export async function getAllOutboxEvents(): Promise<OutboxEvent[]> {
360
+ const context = await requireActiveContext();
361
+ const rows = await db.selectFrom('outbox_operations').selectAll()
362
+ .where('tenant_id', '=', context.tenantId).where('log_id', '=', context.logId)
363
+ .where('status', 'in', ['pending', 'syncing', 'failed', 'needs_attention'])
364
+ .orderBy('created_at', 'asc').execute();
365
+ return rows.map((row) => ({
366
+ id: row.id,
367
+ eventId: row.batch_id,
368
+ status: row.status as OutboxStatus,
369
+ retryCount: row.retry_count,
370
+ lastError: row.last_error_message,
371
+ createdAt: row.created_at,
372
+ updatedAt: row.updated_at,
373
+ }));
374
+ }
375
+
376
+ /**
377
+ * Edits other devices had accepted for one record after the version a local
378
+ * batch was based on, oldest first. Only accepted, effective events count.
379
+ */
380
+ export async function getAcceptedEntityEdits(
381
+ entityId: string,
382
+ afterEntityVersion: number,
383
+ ): Promise<EntityEdit[]> {
384
+ const context = await requireActiveContext();
385
+ const rows = await db.selectFrom('raw_events')
386
+ .select(['type', 'payload'])
387
+ .where('tenant_id', '=', context.tenantId)
388
+ .where('log_id', '=', context.logId)
389
+ .where('entity_id', '=', entityId)
390
+ .where('entity_version', '>', afterEntityVersion)
391
+ .where('log_version', 'is not', null)
392
+ .where('projection_state', '=', 'effective')
393
+ .where('is_tombstoned', '=', 0)
394
+ .orderBy('entity_version', 'asc')
395
+ .execute();
396
+ return rows.map((row) => ({ type: row.type, payload: JSON.parse(row.payload) as Record<string, unknown> }));
397
+ }
398
+
399
+ /** Park a batch whose edits clash with another device's, keeping both sides for a person to choose. */
400
+ export async function parkOperationForConflict(
401
+ operation: DurableOperation,
402
+ clashes: readonly EntityClash[],
403
+ ): Promise<void> {
404
+ const context = await requireActiveContext();
405
+ const message = describeClashes(clashes);
406
+ await db.transaction().execute(async (tx) => {
407
+ await tx.updateTable('outbox_operations').set({
408
+ status: 'needs_attention',
409
+ last_error_code: 'edit_conflict',
410
+ last_error_message: message,
411
+ next_retry_at: null,
412
+ updated_at: new Date().toISOString(),
413
+ })
414
+ .where('tenant_id', '=', context.tenantId)
415
+ .where('log_id', '=', context.logId)
416
+ .where('id', '=', operation.id).execute();
417
+ await tx.insertInto('resolution_actions').values({
418
+ id: crypto.randomUUID(),
419
+ tenant_id: context.tenantId,
420
+ log_id: context.logId,
421
+ batch_id: operation.batchId,
422
+ action: 'conflict_detected',
423
+ details: JSON.stringify({ clashes }),
424
+ }).execute();
425
+ });
426
+ }
@@ -0,0 +1,49 @@
1
+ import { listUnsyncedOperations } from './outbox.js';
2
+ import { isLedgerServiceError } from '../ledger/errors.js';
3
+ import { movedWorkspace } from './moved.svelte.js';
4
+ import { getLedgerClient, preparePendingOperations, sync } from './sync.svelte.js';
5
+ import {
6
+ buildPendingWorkFile,
7
+ classifyImportFailure,
8
+ summarizeImport,
9
+ type ImportReport,
10
+ type ImportResult,
11
+ type PendingWorkFile,
12
+ } from './pending-work.js';
13
+
14
+ /** This device's unsynced requests as a file, to import at the workspace's new home. */
15
+ export async function exportPendingWork(origin = location.origin): Promise<{ file: PendingWorkFile; name: string }> {
16
+ await preparePendingOperations().catch(() => undefined);
17
+ const operations = await listUnsyncedOperations();
18
+ const file = buildPendingWorkFile({ origin, destination: movedWorkspace.value?.destination ?? null, operations });
19
+ return { file, name: `pending-work-${new URL(origin).hostname}-${file.exportedAt.slice(0, 10)}.json` };
20
+ }
21
+
22
+ /**
23
+ * Sends the requests of an exported file to this origin's Ledger, in order, as
24
+ * they were queued. A request the Ledger already accepted is recognized by its
25
+ * ID and counts as applied; one that clashes with newer work is reported, not
26
+ * forced. An answer that means "not now" stops the run so it can be repeated.
27
+ */
28
+ export async function importPendingWork(file: PendingWorkFile): Promise<ImportReport> {
29
+ const client = getLedgerClient();
30
+ const results: ImportResult[] = [];
31
+ for (const operation of file.operations) {
32
+ const requestId = operation.request.requestId;
33
+ const unavailable = results.some((result) => result.outcome === 'unavailable');
34
+ if (unavailable) {
35
+ results.push({ requestId, outcome: 'unavailable', message: 'Not tried: an earlier request could not be sent. Import the file again later.' });
36
+ continue;
37
+ }
38
+ try {
39
+ await client.appendEvents(operation.logId, operation.request);
40
+ results.push({ requestId, outcome: 'applied', message: 'Applied' });
41
+ } catch (error) {
42
+ const status = isLedgerServiceError(error) ? error.statusCode : undefined;
43
+ const code = isLedgerServiceError(error) ? error.code : undefined;
44
+ results.push({ requestId, outcome: classifyImportFailure(status, code), message: error instanceof Error ? error.message : String(error) });
45
+ }
46
+ }
47
+ await sync('manual').catch(() => undefined);
48
+ return summarizeImport(results);
49
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Pending work, carried by hand from a workspace that moved to its new home.
3
+ *
4
+ * When a tenant moves to another origin, a device that had unsynced changes
5
+ * cannot send them to the old one (it answers 410 "moved"), and its local
6
+ * database is bound to the old origin. It exports its queued requests as a
7
+ * file; the new origin sends them to the Ledger as they were: same request
8
+ * IDs, same event IDs, same bodies. The app and log IDs and the idempotency
9
+ * records survived the move, so a request that had in fact been accepted
10
+ * before the move is recognized and answered again, not applied twice.
11
+ *
12
+ * Nothing here touches the database: building and reading the file are pure.
13
+ */
14
+
15
+ import type { AppendEventsRequest } from '../ledger/index.js';
16
+
17
+ export const PENDING_WORK_FORMAT = 'stor-pending-work';
18
+ export const PENDING_WORK_VERSION = 1;
19
+
20
+ export interface PendingWorkOperation {
21
+ logId: string;
22
+ /** The request exactly as it was queued, so its idempotency key and event IDs are the original ones. */
23
+ request: AppendEventsRequest;
24
+ /** Where the request stood on the device when it was exported. */
25
+ status: string;
26
+ createdAt: string;
27
+ }
28
+
29
+ export interface PendingWorkFile {
30
+ format: typeof PENDING_WORK_FORMAT;
31
+ version: typeof PENDING_WORK_VERSION;
32
+ exportedAt: string;
33
+ /** The origin the work was queued at. */
34
+ origin: string;
35
+ /** Where the workspace lives now, when the old origin said so. */
36
+ destination: string | null;
37
+ operations: PendingWorkOperation[];
38
+ }
39
+
40
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
41
+
42
+ export class PendingWorkFileError extends Error {
43
+ constructor(message: string) {
44
+ super(message);
45
+ this.name = 'PendingWorkFileError';
46
+ }
47
+ }
48
+
49
+ export function buildPendingWorkFile(input: {
50
+ origin: string;
51
+ destination: string | null;
52
+ operations: PendingWorkOperation[];
53
+ now?: Date;
54
+ }): PendingWorkFile {
55
+ return {
56
+ format: PENDING_WORK_FORMAT,
57
+ version: PENDING_WORK_VERSION,
58
+ exportedAt: (input.now ?? new Date()).toISOString(),
59
+ origin: input.origin,
60
+ destination: input.destination,
61
+ operations: input.operations,
62
+ };
63
+ }
64
+
65
+ function object(value: unknown, label: string): Record<string, unknown> {
66
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new PendingWorkFileError(`${label} must be an object`);
67
+ return value as Record<string, unknown>;
68
+ }
69
+
70
+ /** Reads and strictly checks a file; anything unexpected is refused rather than sent to the Ledger. */
71
+ export function parsePendingWorkFile(text: string): PendingWorkFile {
72
+ let value: unknown;
73
+ try {
74
+ value = JSON.parse(text);
75
+ } catch {
76
+ throw new PendingWorkFileError('This is not a pending-work file');
77
+ }
78
+ const file = object(value, 'The file');
79
+ if (file.format !== PENDING_WORK_FORMAT) throw new PendingWorkFileError('This is not a pending-work file');
80
+ if (file.version !== PENDING_WORK_VERSION) throw new PendingWorkFileError(`Unsupported pending-work version ${String(file.version)}`);
81
+ if (!Array.isArray(file.operations)) throw new PendingWorkFileError('The file lists no operations');
82
+ const operations = file.operations.map((entry, index): PendingWorkOperation => {
83
+ const label = `Operation ${index + 1}`;
84
+ const operation = object(entry, label);
85
+ const request = object(operation.request, `${label} request`);
86
+ if (typeof operation.logId !== 'string' || !UUID.test(operation.logId)) throw new PendingWorkFileError(`${label} has no valid log`);
87
+ if (typeof request.requestId !== 'string' || !UUID.test(request.requestId)) throw new PendingWorkFileError(`${label} has no request ID`);
88
+ if (!Array.isArray(request.events) || request.events.length === 0) throw new PendingWorkFileError(`${label} carries no events`);
89
+ for (const [at, event] of request.events.entries()) {
90
+ const item = object(event, `${label} event ${at + 1}`);
91
+ if (typeof item.id !== 'string' || !UUID.test(item.id)) throw new PendingWorkFileError(`${label} event ${at + 1} has no ID`);
92
+ if (typeof item.type !== 'string' || typeof item.entityId !== 'string') throw new PendingWorkFileError(`${label} event ${at + 1} is incomplete`);
93
+ object(item.payload, `${label} event ${at + 1} payload`);
94
+ }
95
+ return {
96
+ logId: operation.logId.toLowerCase(),
97
+ request: request as unknown as AppendEventsRequest,
98
+ status: typeof operation.status === 'string' ? operation.status : 'pending',
99
+ createdAt: typeof operation.createdAt === 'string' ? operation.createdAt : '',
100
+ };
101
+ });
102
+ const ids = new Set<string>();
103
+ for (const operation of operations) {
104
+ if (ids.has(operation.request.requestId)) throw new PendingWorkFileError('The file repeats a request');
105
+ ids.add(operation.request.requestId);
106
+ }
107
+ return {
108
+ format: PENDING_WORK_FORMAT,
109
+ version: PENDING_WORK_VERSION,
110
+ exportedAt: typeof file.exportedAt === 'string' ? file.exportedAt : '',
111
+ origin: typeof file.origin === 'string' ? file.origin : '',
112
+ destination: typeof file.destination === 'string' ? file.destination : null,
113
+ operations,
114
+ };
115
+ }
116
+
117
+ export type ImportOutcome = 'applied' | 'conflict' | 'missing_log' | 'refused' | 'unavailable';
118
+
119
+ export interface ImportResult {
120
+ requestId: string;
121
+ outcome: ImportOutcome;
122
+ message: string;
123
+ }
124
+
125
+ export interface ImportReport {
126
+ results: ImportResult[];
127
+ applied: number;
128
+ notApplied: number;
129
+ }
130
+
131
+ export function summarizeImport(results: ImportResult[]): ImportReport {
132
+ const applied = results.filter((result) => result.outcome === 'applied').length;
133
+ return { results, applied, notApplied: results.length - applied };
134
+ }
135
+
136
+ /** What a failed send means for the person importing: nothing here is retried silently. */
137
+ export function classifyImportFailure(status: number | undefined, code: string | undefined): ImportOutcome {
138
+ if (code === 'tenant_moved' || code === 'tenant_migrating') return 'unavailable';
139
+ if (status === 404) return 'missing_log';
140
+ if (status === 409) return 'conflict';
141
+ if (status === undefined || status >= 500 || status === 408 || status === 429) return 'unavailable';
142
+ return 'refused';
143
+ }