@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,170 @@
1
+ import { isLedgerServiceError } from '../ledger/errors.js';
2
+
3
+ export const RETRY_BASE_DELAY_MS = 1_000;
4
+ export const RETRY_MAX_DELAY_MS = 60_000;
5
+ export const RETRY_JITTER_RATIO = 0.25;
6
+
7
+ export type SyncTrigger =
8
+ | 'startup'
9
+ | 'local_commit'
10
+ | 'online'
11
+ | 'visible'
12
+ | 'retry_deadline'
13
+ | 'ws_error'
14
+ | 'ws_ready'
15
+ | 'manual';
16
+
17
+ export type RealtimeVersionDisposition = 'duplicate' | 'apply' | 'resynchronize';
18
+
19
+ export function classifyRealtimeVersion(
20
+ localHead: number,
21
+ incomingVersion: number,
22
+ ): RealtimeVersionDisposition {
23
+ if (
24
+ !Number.isSafeInteger(localHead) ||
25
+ localHead < 0 ||
26
+ !Number.isSafeInteger(incomingVersion) ||
27
+ incomingVersion <= 0
28
+ ) {
29
+ throw new RangeError('realtime versions must be nonnegative safe integers');
30
+ }
31
+ if (incomingVersion <= localHead) return 'duplicate';
32
+ if (incomingVersion === localHead + 1) return 'apply';
33
+ return 'resynchronize';
34
+ }
35
+
36
+ /**
37
+ * A failure that a later attempt can resolve: no answer from Ledger, a
38
+ * timeout, a server or rate-limit status, or a fork still preparing. Uploads
39
+ * keep retrying these; any other answer repeats every time.
40
+ */
41
+ export function isTransientSyncFailure(error: unknown): boolean {
42
+ if (error instanceof TypeError) return true;
43
+ if (!isLedgerServiceError(error)) return false;
44
+ const status = error.statusCode;
45
+ return status === undefined
46
+ || status >= 500
47
+ || status === 408
48
+ || status === 429
49
+ || error.code === 'fork_preparing';
50
+ }
51
+
52
+ export function computeRetryDelayMs(
53
+ retryCount: number,
54
+ random: () => number = Math.random,
55
+ ): number {
56
+ const exponent = Math.max(0, retryCount - 1);
57
+ const base = Math.min(
58
+ RETRY_MAX_DELAY_MS,
59
+ RETRY_BASE_DELAY_MS * 2 ** exponent,
60
+ );
61
+ const jitter = 1 - RETRY_JITTER_RATIO + random() * RETRY_JITTER_RATIO * 2;
62
+ return Math.round(Math.min(RETRY_MAX_DELAY_MS, base * jitter));
63
+ }
64
+
65
+ export function computeRetryDeadline(
66
+ retryCount: number,
67
+ now = Date.now(),
68
+ random: () => number = Math.random,
69
+ ): Date {
70
+ return new Date(now + computeRetryDelayMs(retryCount, random));
71
+ }
72
+
73
+ interface LockManagerLike {
74
+ request<T>(
75
+ name: string,
76
+ options: { mode: 'exclusive'; ifAvailable: true },
77
+ callback: (lock: unknown | null) => T | Promise<T>,
78
+ ): Promise<T>;
79
+ }
80
+
81
+ export async function withExclusiveSyncLock(
82
+ name: string,
83
+ task: () => Promise<void>,
84
+ lockManager: LockManagerLike | null = browserLockManager(),
85
+ ): Promise<boolean> {
86
+ if (!lockManager) {
87
+ await task();
88
+ return true;
89
+ }
90
+
91
+ return lockManager.request(
92
+ name,
93
+ { mode: 'exclusive', ifAvailable: true },
94
+ async (lock) => {
95
+ if (!lock) return false;
96
+ await task();
97
+ return true;
98
+ },
99
+ );
100
+ }
101
+
102
+ function browserLockManager(): LockManagerLike | null {
103
+ if (typeof navigator === 'undefined' || !('locks' in navigator)) return null;
104
+ return navigator.locks as unknown as LockManagerLike;
105
+ }
106
+
107
+ export class SerialTaskQueue {
108
+ private tail: Promise<void> = Promise.resolve();
109
+
110
+ enqueue(task: () => Promise<void>): Promise<void> {
111
+ const run = this.tail.then(task, task);
112
+ this.tail = run.catch(() => undefined);
113
+ return run;
114
+ }
115
+
116
+ idle(): Promise<void> {
117
+ return this.tail;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Combine requests until a fixed deadline, then run serially. A request made
123
+ * during a running task always gets a later run, so reads cannot miss a write
124
+ * that completed after they started. flush() brings the pending run forward.
125
+ */
126
+ export class CoalescedTaskQueue {
127
+ #pending: { promise: Promise<void>; resolve: () => void; reject: (error: unknown) => void } | null = null;
128
+ #timer: ReturnType<typeof setTimeout> | null = null;
129
+ #running = false;
130
+
131
+ constructor(private readonly task: () => Promise<void>, private readonly delayMs: number) {}
132
+
133
+ schedule(): Promise<void> {
134
+ if (this.#pending) return this.#pending.promise;
135
+ let resolve!: () => void;
136
+ let reject!: (error: unknown) => void;
137
+ const promise = new Promise<void>((done, fail) => { resolve = done; reject = fail; });
138
+ this.#pending = { promise, resolve, reject };
139
+ this.#timer = setTimeout(() => {
140
+ this.#timer = null;
141
+ void this.#run();
142
+ }, this.delayMs);
143
+ return promise;
144
+ }
145
+
146
+ flush(): Promise<void> {
147
+ const pending = this.schedule();
148
+ if (this.#timer !== null) clearTimeout(this.#timer);
149
+ this.#timer = null;
150
+ void this.#run();
151
+ return pending;
152
+ }
153
+
154
+ async #run(): Promise<void> {
155
+ if (this.#running || !this.#pending) return;
156
+ const pending = this.#pending;
157
+ this.#pending = null;
158
+ this.#running = true;
159
+ try {
160
+ await this.task();
161
+ pending.resolve();
162
+ } catch (error) {
163
+ pending.reject(error);
164
+ } finally {
165
+ this.#running = false;
166
+ // Its deadline may have passed while the preceding read was busy.
167
+ if (this.#pending && this.#timer === null) void this.#run();
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,10 @@
1
+ import { CORE_REFRESH_KEYS } from '@hearthlog/contracts/features/core';
2
+ import { MODULE_REFRESH_KEYS } from '#generated/modules';
3
+
4
+ /**
5
+ * Route data dependencies that must be refreshed after local or remote event
6
+ * application: the core's own keys (`src/lib/features/core.ts`) and the keys
7
+ * modules declare in `hearthlog.module.json`. Keep both in sync with every
8
+ * `depends('app:…')` declaration in route and module loaders.
9
+ */
10
+ export const SYNC_INVALIDATION_TARGETS: readonly string[] = [...CORE_REFRESH_KEYS, ...MODULE_REFRESH_KEYS];
@@ -0,0 +1,406 @@
1
+ import { db, requireActiveContext, type ActiveContext, type Database } from '../db/index.js';
2
+ import { projectEntityById } from '../event/handlers.js';
3
+ import type { Kysely, Transaction } from 'kysely';
4
+ import { withoutClashes, type EntityClash } from './conflict-merge.js';
5
+
6
+ export interface SyncIssueEvent {
7
+ id: string;
8
+ type: string;
9
+ entityId: string | null;
10
+ timestamp: number;
11
+ projectionState: string;
12
+ }
13
+
14
+ export interface SyncIssueHistoryEntry {
15
+ action: string;
16
+ details: string | null;
17
+ createdAt: string;
18
+ }
19
+
20
+ export interface SyncIssue {
21
+ operationId: string;
22
+ batchId: string;
23
+ status: string;
24
+ retryCount: number;
25
+ rebaseCount: number;
26
+ errorCode: string | null;
27
+ errorMessage: string | null;
28
+ createdAt: string;
29
+ updatedAt: string;
30
+ events: SyncIssueEvent[];
31
+ affectedEntities: string[];
32
+ history: SyncIssueHistoryEntry[];
33
+ /** Edits that clash with another device's, for a person to choose between. */
34
+ conflicts: EntityClash[];
35
+ canRebase: boolean;
36
+ canReapply: boolean;
37
+ }
38
+
39
+ interface ResolutionDependencies {
40
+ database?: Kysely<Database>;
41
+ context?: ActiveContext;
42
+ }
43
+
44
+ export async function getSyncIssues(
45
+ dependencies: ResolutionDependencies = {},
46
+ ): Promise<SyncIssue[]> {
47
+ const database = dependencies.database ?? db;
48
+ const context = dependencies.context ?? await requireActiveContext();
49
+ const operations = await database.selectFrom('outbox_operations')
50
+ .selectAll()
51
+ .where('tenant_id', '=', context.tenantId)
52
+ .where('log_id', '=', context.logId)
53
+ .where('status', '=', 'needs_attention')
54
+ .orderBy('updated_at', 'desc')
55
+ .execute();
56
+
57
+ return Promise.all(operations.map(async (operation) => {
58
+ const [rows, history] = await Promise.all([
59
+ database.selectFrom('raw_events')
60
+ .select(['id', 'type', 'entity_id', 'timestamp', 'projection_state'])
61
+ .where('tenant_id', '=', context.tenantId)
62
+ .where('log_id', '=', context.logId)
63
+ .where('local_batch_id', '=', operation.batch_id)
64
+ .orderBy('local_sequence', 'asc')
65
+ .execute(),
66
+ database.selectFrom('resolution_actions')
67
+ .select(['action', 'details', 'created_at'])
68
+ .where('tenant_id', '=', context.tenantId)
69
+ .where('log_id', '=', context.logId)
70
+ .where('batch_id', '=', operation.batch_id)
71
+ .orderBy('created_at', 'desc')
72
+ .execute(),
73
+ ]);
74
+ const events = rows.map((row) => ({
75
+ id: row.id,
76
+ type: row.type,
77
+ entityId: row.entity_id,
78
+ timestamp: row.timestamp,
79
+ projectionState: row.projection_state,
80
+ }));
81
+ const hasMetaEvent = events.some((event) => event.type.startsWith('ENTITY_TYPE_'));
82
+
83
+ return {
84
+ operationId: operation.id,
85
+ batchId: operation.batch_id,
86
+ status: operation.status,
87
+ retryCount: operation.retry_count,
88
+ rebaseCount: operation.rebase_count,
89
+ errorCode: operation.last_error_code,
90
+ errorMessage: operation.last_error_message,
91
+ createdAt: operation.created_at,
92
+ updatedAt: operation.updated_at,
93
+ events,
94
+ affectedEntities: [...new Set(events.flatMap((event) =>
95
+ event.entityId ? [event.entityId] : []
96
+ ))],
97
+ history: history.map((entry) => ({
98
+ action: entry.action,
99
+ details: entry.details,
100
+ createdAt: entry.created_at,
101
+ })),
102
+ conflicts: operation.last_error_code === 'edit_conflict'
103
+ ? recordedClashesFrom(history.map((entry) => ({ action: entry.action, details: entry.details })))
104
+ : [],
105
+ canRebase: events.some((event) => event.projectionState === 'effective'),
106
+ canReapply: !hasMetaEvent && events.length > 0,
107
+ };
108
+ }));
109
+ }
110
+
111
+ /** The clashes recorded when a batch was parked, newest record first in `history`. */
112
+ function recordedClashesFrom(history: ReadonlyArray<{ action: string; details: string | null }>): EntityClash[] {
113
+ const recorded = history.find((entry) => entry.action === 'conflict_detected');
114
+ if (!recorded?.details) return [];
115
+ try {
116
+ const parsed = JSON.parse(recorded.details) as { clashes?: unknown };
117
+ return Array.isArray(parsed.clashes) ? parsed.clashes as EntityClash[] : [];
118
+ } catch {
119
+ return [];
120
+ }
121
+ }
122
+
123
+ export async function rebaseSyncIssue(
124
+ operationId: string,
125
+ dependencies: ResolutionDependencies = {},
126
+ ): Promise<void> {
127
+ const database = dependencies.database ?? db;
128
+ const context = dependencies.context ?? await requireActiveContext();
129
+ await database.transaction().execute(async (tx) => {
130
+ const operation = await requireIssue(tx, context, operationId);
131
+ const effective = await tx.selectFrom('raw_events')
132
+ .select('id')
133
+ .where('tenant_id', '=', context.tenantId)
134
+ .where('log_id', '=', context.logId)
135
+ .where('local_batch_id', '=', operation.batch_id)
136
+ .where('projection_state', '=', 'effective')
137
+ .executeTakeFirst();
138
+ if (!effective) throw new Error('This rejected batch must be reapplied or discarded');
139
+
140
+ await tx.updateTable('outbox_operations').set({
141
+ status: 'superseded',
142
+ next_retry_at: null,
143
+ updated_at: new Date().toISOString(),
144
+ }).where('id', '=', operation.id).execute();
145
+ await tx.updateTable('local_batches').set({
146
+ status: 'conflicted',
147
+ updated_at: new Date().toISOString(),
148
+ }).where('id', '=', operation.batch_id).execute();
149
+ await tx.updateTable('raw_events').set({ sync_state: 'conflicted' })
150
+ .where('tenant_id', '=', context.tenantId)
151
+ .where('log_id', '=', context.logId)
152
+ .where('local_batch_id', '=', operation.batch_id)
153
+ .where('projection_state', '=', 'effective')
154
+ .execute();
155
+ await audit(tx, context, operation.batch_id, 'retry_rebase', {
156
+ operationId,
157
+ previousRetryCount: operation.retry_count,
158
+ previousRebaseCount: operation.rebase_count,
159
+ });
160
+ });
161
+ }
162
+
163
+ export async function discardSyncIssue(
164
+ operationId: string,
165
+ dependencies: ResolutionDependencies = {},
166
+ ): Promise<string[]> {
167
+ const database = dependencies.database ?? db;
168
+ const context = dependencies.context ?? await requireActiveContext();
169
+ const affected = new Set<string>();
170
+
171
+ await database.transaction().execute(async (tx) => {
172
+ const operation = await requireIssue(tx, context, operationId);
173
+ const rows = await issueRows(tx, context, operation.batch_id);
174
+ collectAffected(rows, affected);
175
+ await excludeBatch(tx, context, operation.id, operation.batch_id);
176
+ await refreshTombstoneTargets(tx, context, rows, affected);
177
+ await projectAffected(tx, context, affected);
178
+ await audit(tx, context, operation.batch_id, 'discard_local', {
179
+ operationId,
180
+ affectedEntities: [...affected],
181
+ });
182
+ });
183
+
184
+ return [...affected];
185
+ }
186
+
187
+ export async function reapplyLocalSyncIssue(
188
+ operationId: string,
189
+ dependencies: ResolutionDependencies = {},
190
+ ): Promise<{ batchId: string | null; affectedEntities: string[] }> {
191
+ return replaceIssueBatch(operationId, dependencies, 'reapply_local', (rows) => rows);
192
+ }
193
+
194
+ /**
195
+ * Resolve a clash in the other device's favor: save the batch again without the
196
+ * values that clashed. What did not clash is kept, as new events, and the
197
+ * original batch stays in raw history as discarded.
198
+ */
199
+ export async function useTheirsSyncIssue(
200
+ operationId: string,
201
+ dependencies: ResolutionDependencies = {},
202
+ ): Promise<{ batchId: string | null; affectedEntities: string[] }> {
203
+ return replaceIssueBatch(operationId, dependencies, 'use_theirs', async (rows, tx, context, batchId) => {
204
+ const history = await tx.selectFrom('resolution_actions')
205
+ .select(['action', 'details'])
206
+ .where('tenant_id', '=', context.tenantId)
207
+ .where('log_id', '=', context.logId)
208
+ .where('batch_id', '=', batchId)
209
+ .orderBy('created_at', 'desc')
210
+ .execute();
211
+ const clashes = recordedClashesFrom(history);
212
+ if (clashes.length === 0) throw new Error('This change has no recorded conflict to resolve');
213
+ return withoutClashes(rows, clashes);
214
+ });
215
+ }
216
+
217
+ async function replaceIssueBatch(
218
+ operationId: string,
219
+ dependencies: ResolutionDependencies,
220
+ action: 'reapply_local' | 'use_theirs',
221
+ keep: (
222
+ rows: IssueRow[],
223
+ tx: Transaction<Database>,
224
+ context: ActiveContext,
225
+ batchId: string,
226
+ ) => IssueRow[] | Promise<IssueRow[]>,
227
+ ): Promise<{ batchId: string | null; affectedEntities: string[] }> {
228
+ const database = dependencies.database ?? db;
229
+ const context = dependencies.context ?? await requireActiveContext();
230
+ const affected = new Set<string>();
231
+ const replacementBatchId = `reapply:${crypto.randomUUID()}`;
232
+ let replacedAny = false;
233
+
234
+ await database.transaction().execute(async (tx) => {
235
+ const operation = await requireIssue(tx, context, operationId);
236
+ const rows = await issueRows(tx, context, operation.batch_id);
237
+ if (rows.some((row) => row.type.startsWith('ENTITY_TYPE_'))) {
238
+ throw new Error('Dynamic schema conflicts must be discarded and recreated');
239
+ }
240
+ const kept = await keep(rows, tx, context, operation.batch_id);
241
+ collectAffected(rows, affected);
242
+ await excludeBatch(tx, context, operation.id, operation.batch_id);
243
+ await refreshTombstoneTargets(tx, context, rows, affected);
244
+
245
+ const maxSequence = await tx.selectFrom('raw_events')
246
+ .select((eb) => eb.fn.max('local_sequence').as('max_sequence'))
247
+ .where('tenant_id', '=', context.tenantId)
248
+ .where('log_id', '=', context.logId)
249
+ .executeTakeFirst();
250
+ let nextSequence = Number(maxSequence?.max_sequence ?? 0);
251
+ const now = Date.now();
252
+
253
+ // Nothing may be left to save once what clashed is given up.
254
+ if (kept.length > 0) await tx.insertInto('local_batches').values({
255
+ id: replacementBatchId,
256
+ tenant_id: context.tenantId,
257
+ log_id: context.logId,
258
+ status: 'pending',
259
+ replacement_for: operation.batch_id,
260
+ }).execute();
261
+
262
+ for (const [index, row] of kept.entries()) {
263
+ const eventId = crypto.randomUUID();
264
+ await tx.insertInto('raw_events').values({
265
+ id: eventId,
266
+ tenant_id: context.tenantId,
267
+ log_id: context.logId,
268
+ local_batch_id: replacementBatchId,
269
+ local_sequence: ++nextSequence,
270
+ type: row.type,
271
+ timestamp: now + index,
272
+ payload: row.payload,
273
+ entity_id: row.entity_id,
274
+ entity_version: null,
275
+ log_version: null,
276
+ sync_state: 'pending',
277
+ projection_state: 'effective',
278
+ rejection_code: null,
279
+ rejection_detail: null,
280
+ is_tombstoned: 0,
281
+ }).execute();
282
+ if (row.type === 'EVENT_TOMBSTONED') {
283
+ const targetId = (JSON.parse(row.payload) as { eventId: string }).eventId;
284
+ await tx.updateTable('raw_events').set({ is_tombstoned: 1 })
285
+ .where('tenant_id', '=', context.tenantId)
286
+ .where('log_id', '=', context.logId)
287
+ .where('id', '=', targetId)
288
+ .execute();
289
+ }
290
+ }
291
+
292
+ await projectAffected(tx, context, affected);
293
+ replacedAny = kept.length > 0;
294
+ await audit(tx, context, operation.batch_id, action, {
295
+ operationId,
296
+ replacementBatchId: replacedAny ? replacementBatchId : null,
297
+ affectedEntities: [...affected],
298
+ });
299
+ });
300
+
301
+ return { batchId: replacedAny ? replacementBatchId : null, affectedEntities: [...affected] };
302
+ }
303
+
304
+ type IssueRow = Awaited<ReturnType<typeof issueRows>>[number];
305
+
306
+ async function requireIssue(
307
+ tx: Transaction<Database>,
308
+ context: ActiveContext,
309
+ operationId: string,
310
+ ) {
311
+ return tx.selectFrom('outbox_operations').selectAll()
312
+ .where('tenant_id', '=', context.tenantId)
313
+ .where('log_id', '=', context.logId)
314
+ .where('id', '=', operationId)
315
+ .where('status', '=', 'needs_attention')
316
+ .executeTakeFirstOrThrow();
317
+ }
318
+
319
+ async function issueRows(
320
+ tx: Transaction<Database>,
321
+ context: ActiveContext,
322
+ batchId: string,
323
+ ) {
324
+ return tx.selectFrom('raw_events').selectAll()
325
+ .where('tenant_id', '=', context.tenantId)
326
+ .where('log_id', '=', context.logId)
327
+ .where('local_batch_id', '=', batchId)
328
+ .orderBy('local_sequence', 'asc')
329
+ .execute();
330
+ }
331
+
332
+ function collectAffected(rows: IssueRow[], affected: Set<string>): void {
333
+ rows.forEach((row) => {
334
+ if (row.entity_id && !row.entity_id.startsWith('tombstone:')) {
335
+ affected.add(row.entity_id);
336
+ }
337
+ });
338
+ }
339
+
340
+ async function excludeBatch(
341
+ tx: Transaction<Database>,
342
+ context: ActiveContext,
343
+ operationId: string,
344
+ batchId: string,
345
+ ): Promise<void> {
346
+ await tx.updateTable('raw_events').set({
347
+ sync_state: 'discarded',
348
+ projection_state: 'excluded',
349
+ }).where('tenant_id', '=', context.tenantId)
350
+ .where('log_id', '=', context.logId)
351
+ .where('local_batch_id', '=', batchId).execute();
352
+ await tx.updateTable('local_batches').set({
353
+ status: 'discarded',
354
+ updated_at: new Date().toISOString(),
355
+ }).where('id', '=', batchId).execute();
356
+ await tx.updateTable('outbox_operations').set({
357
+ status: 'superseded',
358
+ next_retry_at: null,
359
+ updated_at: new Date().toISOString(),
360
+ }).where('id', '=', operationId).execute();
361
+ }
362
+
363
+ async function refreshTombstoneTargets(
364
+ tx: Transaction<Database>,
365
+ context: ActiveContext,
366
+ rows: IssueRow[],
367
+ affected: Set<string>,
368
+ ): Promise<void> {
369
+ for (const row of rows.filter((item) => item.type === 'EVENT_TOMBSTONED')) {
370
+ const targetId = (JSON.parse(row.payload) as { eventId: string }).eventId;
371
+ const target = await tx.selectFrom('raw_events').select('entity_id')
372
+ .where('tenant_id', '=', context.tenantId)
373
+ .where('log_id', '=', context.logId)
374
+ .where('id', '=', targetId).executeTakeFirst();
375
+ await tx.updateTable('raw_events').set({ is_tombstoned: 0 })
376
+ .where('tenant_id', '=', context.tenantId)
377
+ .where('log_id', '=', context.logId)
378
+ .where('id', '=', targetId).execute();
379
+ if (target?.entity_id) affected.add(target.entity_id);
380
+ }
381
+ }
382
+
383
+ async function projectAffected(
384
+ tx: Transaction<Database>,
385
+ context: ActiveContext,
386
+ affected: Set<string>,
387
+ ): Promise<void> {
388
+ for (const entityId of affected) await projectEntityById(entityId, tx, context);
389
+ }
390
+
391
+ async function audit(
392
+ tx: Transaction<Database>,
393
+ context: ActiveContext,
394
+ batchId: string,
395
+ action: string,
396
+ details: Record<string, unknown>,
397
+ ): Promise<void> {
398
+ await tx.insertInto('resolution_actions').values({
399
+ id: crypto.randomUUID(),
400
+ tenant_id: context.tenantId,
401
+ log_id: context.logId,
402
+ batch_id: batchId,
403
+ action,
404
+ details: JSON.stringify(details),
405
+ }).execute();
406
+ }
@@ -0,0 +1,54 @@
1
+ import type { Kysely } from 'kysely';
2
+ import type { Database } from '../db/index.js';
3
+
4
+ type Scope = { tenantId: string; logId: string };
5
+
6
+ /** Only successfully ingested positions can extend the contiguous prefix. */
7
+ export function contiguousThrough(previous: number, versions: readonly number[]): number {
8
+ let through = previous;
9
+ for (const version of [...new Set(versions)].sort((a, b) => a - b)) {
10
+ if (version <= through) continue;
11
+ if (version !== through + 1) break;
12
+ through = version;
13
+ }
14
+ return through;
15
+ }
16
+
17
+ export async function recordIngestedProgress(
18
+ database: Kysely<Database>, scope: Scope, versions: readonly number[],
19
+ ): Promise<number> {
20
+ return database.transaction().execute(async (tx) => {
21
+ const previous = await tx.selectFrom('log_sync_state').selectAll()
22
+ .where('tenant_id', '=', scope.tenantId).where('log_id', '=', scope.logId).executeTakeFirst();
23
+ const through = contiguousThrough(previous?.downloaded_head_version ?? 0, versions);
24
+ const projected = contiguousThrough(previous?.projected_head_version ?? 0, versions);
25
+ await tx.insertInto('log_sync_state').values({
26
+ tenant_id: scope.tenantId, log_id: scope.logId,
27
+ downloaded_head_version: through, projected_head_version: projected,
28
+ last_synced_at: new Date().toISOString(),
29
+ }).onConflict(oc => oc.columns(['tenant_id', 'log_id']).doUpdateSet({
30
+ downloaded_head_version: through, projected_head_version: projected,
31
+ last_synced_at: new Date().toISOString(),
32
+ })).execute();
33
+ return through;
34
+ });
35
+ }
36
+
37
+ /** Schema migration: old upload acknowledgements may have overstated progress.
38
+ * Repair every stored scope atomically; never alter events or retry requests.
39
+ */
40
+ export async function repairStoredProgress(database: Kysely<Database>): Promise<void> {
41
+ await database.transaction().execute(async tx => {
42
+ const scopes = await tx.selectFrom('log_sync_state').selectAll().execute();
43
+ for (const scope of scopes) {
44
+ const rows = await tx.selectFrom('raw_events').select('log_version').distinct()
45
+ .where('tenant_id', '=', scope.tenant_id).where('log_id', '=', scope.log_id)
46
+ .where('log_version', 'is not', null).orderBy('log_version').execute();
47
+ const complete = contiguousThrough(0, rows.map(row => row.log_version!));
48
+ await tx.updateTable('log_sync_state').set({
49
+ downloaded_head_version: Math.min(scope.downloaded_head_version, complete),
50
+ projected_head_version: Math.min(scope.projected_head_version, complete),
51
+ }).where('tenant_id', '=', scope.tenant_id).where('log_id', '=', scope.log_id).execute();
52
+ }
53
+ });
54
+ }