amalgm 0.1.155 → 0.1.156

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.
@@ -0,0 +1,800 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Durable mutation journal for @amalgm/live.
5
+ *
6
+ * Every durable SDK edit has exactly one row here, whether its channel is
7
+ * local-only or cloud-authoritative. The cloud outbox is a query over shared
8
+ * rows whose lifecycle still needs delivery; it is not a second table.
9
+ *
10
+ * event_log stays the published Local Live delivery cursor. A journal row is
11
+ * projected into event_log only after the file/local store is materialized,
12
+ * so current snapshot/replay/stream ordering never observes an unsaved edit.
13
+ */
14
+
15
+ const { cachedStatement, openLocalDb } = require('./db');
16
+ const { insertStateEvent, publishStateEvent } = require('./events');
17
+
18
+ const MAX_MUTATION_BYTES = 4 * 1024 * 1024;
19
+ const DEFAULT_CLAIM_LIMIT = 100;
20
+ const DEFAULT_CLAIM_LEASE_MS = 30_000;
21
+ const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
22
+ const MUTATION_ID = /^[A-Za-z0-9_-]{16,160}$/;
23
+ const OPERATION_KIND = /^[a-z][a-z0-9._:-]{0,63}$/;
24
+ const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
25
+
26
+ function invalid(message, statusCode = 400) {
27
+ const error = new Error(message);
28
+ error.statusCode = statusCode;
29
+ return error;
30
+ }
31
+
32
+ function cleanText(value, name, max = 1024) {
33
+ const text = typeof value === 'string' ? value.trim() : '';
34
+ if (!text || text.length > max || /[\u0000-\u001f\u007f]/.test(text)) {
35
+ throw invalid(`${name} must be a non-empty string of at most ${max} characters`);
36
+ }
37
+ return text;
38
+ }
39
+
40
+ function nonNegativeInteger(value, name, fallback = 0) {
41
+ const candidate = value === undefined || value === null ? fallback : Number(value);
42
+ if (!Number.isInteger(candidate) || candidate < 0) {
43
+ throw invalid(`${name} must be a non-negative integer`);
44
+ }
45
+ return candidate;
46
+ }
47
+
48
+ function positiveInteger(value, name) {
49
+ const candidate = Number(value);
50
+ if (!Number.isInteger(candidate) || candidate <= 0) {
51
+ throw invalid(`${name} must be a positive integer`);
52
+ }
53
+ return candidate;
54
+ }
55
+
56
+ function safeJsonParse(value) {
57
+ try {
58
+ return JSON.parse(value);
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function normalizeMutationInput(input) {
65
+ if (!input || typeof input !== 'object') throw invalid('mutation input is required');
66
+
67
+ const channelId = cleanText(input.channelId, 'channelId');
68
+ const localResource = cleanText(input.localResource, 'localResource');
69
+ const mutationId = cleanText(input.mutationId, 'mutationId', 160);
70
+ if (!MUTATION_ID.test(mutationId)) {
71
+ throw invalid('mutationId must be a stable opaque id using letters, numbers, _ or -');
72
+ }
73
+
74
+ const contract = cleanText(input.contract, 'contract', 128);
75
+ if (!CONTRACT.test(contract)) throw invalid('contract has an invalid format');
76
+ const schemaVersion = positiveInteger(input.schemaVersion, 'schemaVersion');
77
+
78
+ const operation = input.operation;
79
+ if (operation === undefined) throw invalid('operation is required');
80
+ const operationKind = cleanText(
81
+ input.operationKind ?? (operation && typeof operation === 'object' ? operation.kind : ''),
82
+ 'operationKind',
83
+ 64,
84
+ );
85
+ if (!OPERATION_KIND.test(operationKind)) throw invalid('operationKind has an invalid format');
86
+
87
+ let operationJson;
88
+ try {
89
+ operationJson = JSON.stringify(operation);
90
+ } catch {
91
+ throw invalid('operation must be JSON-serializable');
92
+ }
93
+ if (operationJson === undefined) throw invalid('operation must be JSON-serializable');
94
+ if (Buffer.byteLength(operationJson, 'utf8') > MAX_MUTATION_BYTES) {
95
+ throw invalid(`operation exceeds the ${MAX_MUTATION_BYTES}-byte durable mutation limit`, 413);
96
+ }
97
+
98
+ const sharedResourceId = input.sharedResourceId == null
99
+ ? null
100
+ : cleanText(input.sharedResourceId, 'sharedResourceId', 132);
101
+ if (sharedResourceId && !SHARED_RESOURCE_ID.test(sharedResourceId)) {
102
+ throw invalid('sharedResourceId must be an opaque id beginning with shr_');
103
+ }
104
+ if (sharedResourceId && channelId !== `resource:${sharedResourceId}`) {
105
+ throw invalid('a shared mutation channelId must be resource:<sharedResourceId>');
106
+ }
107
+
108
+ const authorityEpoch = nonNegativeInteger(input.authorityEpoch, 'authorityEpoch');
109
+ const baseCloudVersion = nonNegativeInteger(input.baseCloudVersion, 'baseCloudVersion');
110
+ const deviceId = input.deviceId == null ? null : cleanText(input.deviceId, 'deviceId', 256);
111
+ if (sharedResourceId && authorityEpoch < 1) {
112
+ throw invalid('shared mutations require authorityEpoch >= 1');
113
+ }
114
+ if (sharedResourceId && !deviceId) {
115
+ throw invalid('shared mutations require deviceId');
116
+ }
117
+
118
+ const localRevision = input.localRevision == null
119
+ ? null
120
+ : nonNegativeInteger(input.localRevision, 'localRevision');
121
+
122
+ return {
123
+ channelId,
124
+ localResource,
125
+ sharedResourceId,
126
+ mutationId,
127
+ deviceId,
128
+ authorityEpoch,
129
+ baseCloudVersion,
130
+ contract,
131
+ schemaVersion,
132
+ operationKind,
133
+ operationJson,
134
+ operation,
135
+ localRevision,
136
+ };
137
+ }
138
+
139
+ function normalizeJournalRow(row) {
140
+ if (!row) return null;
141
+ return {
142
+ journalId: Number(row.journal_id),
143
+ channelId: row.channel_id,
144
+ localResource: row.local_resource,
145
+ sharedResourceId: row.shared_resource_id || null,
146
+ mutationId: row.mutation_id,
147
+ deviceId: row.device_id || null,
148
+ authorityEpoch: Number(row.authority_epoch || 0),
149
+ baseCloudVersion: Number(row.base_cloud_version || 0),
150
+ officialCloudVersion: row.official_cloud_version == null
151
+ ? null
152
+ : Number(row.official_cloud_version),
153
+ contract: row.contract,
154
+ schemaVersion: Number(row.schema_version),
155
+ operationKind: row.operation_kind,
156
+ operation: safeJsonParse(row.operation_json),
157
+ origin: row.mutation_origin,
158
+ state: row.mutation_state,
159
+ localRevision: row.local_revision == null ? null : Number(row.local_revision),
160
+ materializedAt: row.materialized_at || null,
161
+ eventSeq: row.event_seq == null ? null : Number(row.event_seq),
162
+ attemptCount: Number(row.attempt_count || 0),
163
+ nextAttemptAt: row.next_attempt_at || null,
164
+ lastError: row.last_error || null,
165
+ cloudCommittedAt: row.cloud_committed_at || null,
166
+ createdAt: row.created_at,
167
+ updatedAt: row.updated_at,
168
+ };
169
+ }
170
+
171
+ function getMutationRow(database, channelId, mutationId) {
172
+ return cachedStatement(
173
+ database,
174
+ 'SELECT * FROM mutation_journal WHERE channel_id = ? AND mutation_id = ?',
175
+ ).get(channelId, mutationId) || null;
176
+ }
177
+
178
+ function sameMutation(existing, mutation) {
179
+ return existing.local_resource === mutation.localResource
180
+ && (existing.shared_resource_id || null) === mutation.sharedResourceId
181
+ && (existing.device_id || null) === mutation.deviceId
182
+ && Number(existing.authority_epoch || 0) === mutation.authorityEpoch
183
+ && Number(existing.base_cloud_version || 0) === mutation.baseCloudVersion
184
+ && existing.contract === mutation.contract
185
+ && Number(existing.schema_version) === mutation.schemaVersion
186
+ && existing.operation_kind === mutation.operationKind
187
+ && existing.operation_json === mutation.operationJson
188
+ && (existing.local_revision == null ? null : Number(existing.local_revision)) === mutation.localRevision;
189
+ }
190
+
191
+ function sameCloudEnvelope(existing, mutation) {
192
+ return existing.local_resource === mutation.localResource
193
+ && (existing.shared_resource_id || null) === mutation.sharedResourceId
194
+ && (existing.device_id || null) === mutation.deviceId
195
+ && Number(existing.authority_epoch || 0) === mutation.authorityEpoch
196
+ && Number(existing.base_cloud_version || 0) === mutation.baseCloudVersion
197
+ && existing.contract === mutation.contract
198
+ && Number(existing.schema_version) === mutation.schemaVersion
199
+ && existing.operation_kind === mutation.operationKind
200
+ && existing.operation_json === mutation.operationJson;
201
+ }
202
+
203
+ function journalMutation(input, options = {}) {
204
+ const mutation = normalizeMutationInput(input);
205
+ const database = options.database || openLocalDb();
206
+ const persist = typeof options.persist === 'function' ? options.persist : null;
207
+
208
+ return database.transaction(() => {
209
+ const existing = getMutationRow(database, mutation.channelId, mutation.mutationId);
210
+ if (existing) {
211
+ if (!sameMutation(existing, mutation)) {
212
+ throw invalid(
213
+ `mutationId ${mutation.mutationId} was reused with different content`,
214
+ 409,
215
+ );
216
+ }
217
+ return { mutation: normalizeJournalRow(existing), duplicate: true };
218
+ }
219
+
220
+ if (persist) persist(database, mutation);
221
+
222
+ const info = cachedStatement(database, `
223
+ INSERT INTO mutation_journal (
224
+ channel_id,
225
+ local_resource,
226
+ shared_resource_id,
227
+ mutation_id,
228
+ device_id,
229
+ authority_epoch,
230
+ base_cloud_version,
231
+ contract,
232
+ schema_version,
233
+ operation_kind,
234
+ operation_json,
235
+ mutation_origin,
236
+ mutation_state,
237
+ local_revision
238
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'local', 'saving-local', ?)
239
+ `).run(
240
+ mutation.channelId,
241
+ mutation.localResource,
242
+ mutation.sharedResourceId,
243
+ mutation.mutationId,
244
+ mutation.deviceId,
245
+ mutation.authorityEpoch,
246
+ mutation.baseCloudVersion,
247
+ mutation.contract,
248
+ mutation.schemaVersion,
249
+ mutation.operationKind,
250
+ mutation.operationJson,
251
+ mutation.localRevision,
252
+ );
253
+
254
+ const row = cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
255
+ .get(Number(info.lastInsertRowid));
256
+ return { mutation: normalizeJournalRow(row), duplicate: false };
257
+ })();
258
+ }
259
+
260
+ function recordMaterializationError(database, channelId, mutationId, error) {
261
+ const message = error instanceof Error ? error.message : String(error);
262
+ cachedStatement(database, `
263
+ UPDATE mutation_journal
264
+ SET last_error = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
265
+ WHERE channel_id = ? AND mutation_id = ? AND mutation_state = 'saving-local'
266
+ `).run(message.slice(0, 4000), channelId, mutationId);
267
+ }
268
+
269
+ function advanceReplicaCursor(database, sharedResourceId) {
270
+ if (!sharedResourceId) return null;
271
+ const readReplica = cachedStatement(
272
+ database,
273
+ 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?',
274
+ );
275
+ const readNext = cachedStatement(database, `
276
+ SELECT official_cloud_version
277
+ FROM mutation_journal
278
+ WHERE shared_resource_id = ?
279
+ AND official_cloud_version = ?
280
+ AND mutation_state = 'synced'
281
+ AND materialized_at IS NOT NULL
282
+ LIMIT 1
283
+ `);
284
+ const update = cachedStatement(database, `
285
+ UPDATE shared_resource_replicas
286
+ SET applied_version = ?,
287
+ sync_status = 'synced',
288
+ last_error = NULL,
289
+ updated_at = ?
290
+ WHERE resource_id = ? AND applied_version = ?
291
+ `);
292
+
293
+ let replica = readReplica.get(sharedResourceId);
294
+ if (!replica) return null;
295
+ let appliedVersion = Number(replica.applied_version);
296
+ while (readNext.get(sharedResourceId, appliedVersion + 1)) {
297
+ const next = appliedVersion + 1;
298
+ const result = update.run(next, new Date().toISOString(), sharedResourceId, appliedVersion);
299
+ if (!result.changes) break;
300
+ appliedVersion = next;
301
+ }
302
+ replica = readReplica.get(sharedResourceId);
303
+ return replica ? Number(replica.applied_version) : null;
304
+ }
305
+
306
+ function completeMaterializedMutation(channelIdInput, mutationIdInput, eventInput, options = {}) {
307
+ const channelId = cleanText(channelIdInput, 'channelId');
308
+ const mutationId = cleanText(mutationIdInput, 'mutationId', 160);
309
+ const database = options.database || openLocalDb();
310
+ let event = null;
311
+
312
+ const result = database.transaction(() => {
313
+ const row = getMutationRow(database, channelId, mutationId);
314
+ if (!row) throw invalid(`unknown mutation ${mutationId}`, 404);
315
+ if (row.event_seq != null) {
316
+ return { mutation: normalizeJournalRow(row), duplicate: true };
317
+ }
318
+ if (row.mutation_state !== 'saving-local') {
319
+ throw invalid(`mutation ${mutationId} cannot materialize from ${row.mutation_state}`, 409);
320
+ }
321
+ if (!eventInput || eventInput.resource !== row.local_resource) {
322
+ throw invalid('materialized event resource must match the journal localResource');
323
+ }
324
+
325
+ event = insertStateEvent(database, {
326
+ ...eventInput,
327
+ mutationId: row.mutation_id,
328
+ sharedResourceId: row.shared_resource_id,
329
+ });
330
+ const now = new Date().toISOString();
331
+ const nextState = row.mutation_origin === 'cloud' ? 'synced' : 'saved-local';
332
+ cachedStatement(database, `
333
+ UPDATE mutation_journal
334
+ SET mutation_state = ?,
335
+ materialized_at = ?,
336
+ event_seq = ?,
337
+ last_error = NULL,
338
+ updated_at = ?
339
+ WHERE journal_id = ?
340
+ `).run(nextState, now, event.seq, now, row.journal_id);
341
+
342
+ if (row.mutation_origin === 'cloud') {
343
+ advanceReplicaCursor(database, row.shared_resource_id);
344
+ }
345
+
346
+ return {
347
+ mutation: normalizeJournalRow(
348
+ cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
349
+ .get(row.journal_id),
350
+ ),
351
+ duplicate: false,
352
+ };
353
+ })();
354
+
355
+ if (event) publishStateEvent(event);
356
+ return { ...result, event };
357
+ }
358
+
359
+ function journalCloudMutation(input, options = {}) {
360
+ const envelope = input?.envelope || {};
361
+ const sharedResourceId = cleanText(envelope.resourceId, 'resourceId', 132);
362
+ const localResource = cleanText(input?.localResource, 'localResource');
363
+ const mutation = normalizeMutationInput({
364
+ channelId: `resource:${sharedResourceId}`,
365
+ localResource,
366
+ sharedResourceId,
367
+ mutationId: envelope.mutationId,
368
+ deviceId: envelope.deviceId,
369
+ authorityEpoch: envelope.authorityEpoch,
370
+ baseCloudVersion: envelope.baseVersion,
371
+ contract: envelope.contract,
372
+ schemaVersion: envelope.schemaVersion,
373
+ operationKind: envelope.operationKind,
374
+ operation: envelope.operation,
375
+ });
376
+ const version = positiveInteger(input?.version, 'version');
377
+ const committedAt = cleanText(input?.committedAt, 'committedAt', 64);
378
+ const database = options.database || openLocalDb();
379
+
380
+ return database.transaction(() => {
381
+ const replica = cachedStatement(
382
+ database,
383
+ 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?',
384
+ ).get(sharedResourceId);
385
+ if (!replica) throw invalid(`shared resource ${sharedResourceId} has no local replica`, 404);
386
+ // authority_epoch fences new writes. An already-official tail may contain
387
+ // older epochs and still must replay; only its contract/schema must match
388
+ // the local surface adapter.
389
+ if (
390
+ replica.contract !== mutation.contract
391
+ || Number(replica.schema_version) !== mutation.schemaVersion
392
+ ) {
393
+ throw invalid(`shared resource ${sharedResourceId} contract changed`, 409);
394
+ }
395
+
396
+ const existing = findSharedMutationRow(database, sharedResourceId, mutation.mutationId);
397
+ if (existing) {
398
+ if (!sameCloudEnvelope(existing, mutation)) {
399
+ throw invalid(`mutationId ${mutation.mutationId} was reused with different content`, 409);
400
+ }
401
+ if (
402
+ existing.official_cloud_version != null
403
+ && Number(existing.official_cloud_version) !== version
404
+ ) {
405
+ throw invalid(`mutationId ${mutation.mutationId} was reused with different content`, 409);
406
+ }
407
+
408
+ // The cloud tail is also the lost-ack recovery path for this runtime's
409
+ // own optimistic mutation. Reconcile the official version onto the
410
+ // existing row; never create or materialize a second local edit.
411
+ if (existing.mutation_origin === 'local' && existing.official_cloud_version == null) {
412
+ const now = new Date().toISOString();
413
+ cachedStatement(database, `
414
+ UPDATE mutation_journal
415
+ SET mutation_state = 'synced',
416
+ official_cloud_version = ?,
417
+ cloud_committed_at = ?,
418
+ next_attempt_at = NULL,
419
+ last_error = NULL,
420
+ updated_at = ?
421
+ WHERE journal_id = ?
422
+ `).run(version, committedAt, now, existing.journal_id);
423
+ advanceReplicaCursor(database, sharedResourceId);
424
+ const reconciled = cachedStatement(
425
+ database,
426
+ 'SELECT * FROM mutation_journal WHERE journal_id = ?',
427
+ ).get(existing.journal_id);
428
+ return {
429
+ mutation: normalizeJournalRow(reconciled),
430
+ duplicate: true,
431
+ alreadyApplied: reconciled.event_seq != null,
432
+ };
433
+ }
434
+
435
+ return {
436
+ mutation: normalizeJournalRow(existing),
437
+ duplicate: true,
438
+ alreadyApplied: existing.event_seq != null,
439
+ };
440
+ }
441
+
442
+ const appliedVersion = Number(replica.applied_version);
443
+ if (version <= appliedVersion) {
444
+ return { mutation: null, duplicate: true, alreadyApplied: true };
445
+ }
446
+ if (version !== appliedVersion + 1) {
447
+ const error = invalid(
448
+ `shared mutation gap for ${sharedResourceId}: expected ${appliedVersion + 1}, received ${version}`,
449
+ 409,
450
+ );
451
+ error.code = 'shared_mutation_gap';
452
+ error.expectedVersion = appliedVersion + 1;
453
+ error.receivedVersion = version;
454
+ throw error;
455
+ }
456
+
457
+ const now = new Date().toISOString();
458
+ const info = cachedStatement(database, `
459
+ INSERT INTO mutation_journal (
460
+ channel_id,
461
+ local_resource,
462
+ shared_resource_id,
463
+ mutation_id,
464
+ device_id,
465
+ authority_epoch,
466
+ base_cloud_version,
467
+ official_cloud_version,
468
+ contract,
469
+ schema_version,
470
+ operation_kind,
471
+ operation_json,
472
+ mutation_origin,
473
+ mutation_state,
474
+ cloud_committed_at,
475
+ created_at,
476
+ updated_at
477
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'cloud', 'saving-local', ?, ?, ?)
478
+ `).run(
479
+ mutation.channelId,
480
+ mutation.localResource,
481
+ mutation.sharedResourceId,
482
+ mutation.mutationId,
483
+ mutation.deviceId,
484
+ mutation.authorityEpoch,
485
+ mutation.baseCloudVersion,
486
+ version,
487
+ mutation.contract,
488
+ mutation.schemaVersion,
489
+ mutation.operationKind,
490
+ mutation.operationJson,
491
+ committedAt,
492
+ now,
493
+ now,
494
+ );
495
+ return {
496
+ mutation: normalizeJournalRow(
497
+ cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
498
+ .get(Number(info.lastInsertRowid)),
499
+ ),
500
+ duplicate: false,
501
+ alreadyApplied: false,
502
+ };
503
+ })();
504
+ }
505
+
506
+ function ingestCloudMutation(input, options = {}) {
507
+ const database = options.database || openLocalDb();
508
+ const started = journalCloudMutation(input, { database });
509
+ if (started.alreadyApplied) return { ...started, event: null };
510
+
511
+ try {
512
+ if (typeof options.materialize === 'function') options.materialize(started.mutation);
513
+ } catch (error) {
514
+ recordMaterializationError(
515
+ database,
516
+ started.mutation.channelId,
517
+ started.mutation.mutationId,
518
+ error,
519
+ );
520
+ throw error;
521
+ }
522
+ const eventInput = typeof options.event === 'function'
523
+ ? options.event(started.mutation)
524
+ : options.event;
525
+ const completed = completeMaterializedMutation(
526
+ started.mutation.channelId,
527
+ started.mutation.mutationId,
528
+ eventInput,
529
+ { database },
530
+ );
531
+ return { ...completed, duplicate: started.duplicate || completed.duplicate };
532
+ }
533
+
534
+ function commitLocalMutation(input, options = {}) {
535
+ const database = options.database || openLocalDb();
536
+ const started = journalMutation(input, { database, persist: options.persist });
537
+ if (started.duplicate && started.mutation.state !== 'saving-local') {
538
+ return { ...started, event: null };
539
+ }
540
+
541
+ try {
542
+ if (typeof options.materialize === 'function') {
543
+ options.materialize(started.mutation);
544
+ }
545
+ } catch (error) {
546
+ recordMaterializationError(
547
+ database,
548
+ started.mutation.channelId,
549
+ started.mutation.mutationId,
550
+ error,
551
+ );
552
+ throw error;
553
+ }
554
+
555
+ const eventInput = typeof options.event === 'function'
556
+ ? options.event(started.mutation)
557
+ : options.event;
558
+ const completed = completeMaterializedMutation(
559
+ started.mutation.channelId,
560
+ started.mutation.mutationId,
561
+ eventInput,
562
+ { database },
563
+ );
564
+ return {
565
+ ...completed,
566
+ duplicate: started.duplicate || completed.duplicate,
567
+ };
568
+ }
569
+
570
+ function getMutation(channelId, mutationId, options = {}) {
571
+ const database = options.database || openLocalDb();
572
+ return normalizeJournalRow(getMutationRow(database, channelId, mutationId));
573
+ }
574
+
575
+ function cloudEnvelope(mutation) {
576
+ if (!mutation.sharedResourceId || !mutation.deviceId) {
577
+ throw invalid(`mutation ${mutation.mutationId} is not cloud-deliverable`, 409);
578
+ }
579
+ return {
580
+ resourceId: mutation.sharedResourceId,
581
+ authorityEpoch: mutation.authorityEpoch,
582
+ mutationId: mutation.mutationId,
583
+ deviceId: mutation.deviceId,
584
+ baseVersion: mutation.baseCloudVersion,
585
+ contract: mutation.contract,
586
+ schemaVersion: mutation.schemaVersion,
587
+ operationKind: mutation.operationKind,
588
+ operation: mutation.operation,
589
+ };
590
+ }
591
+
592
+ function claimCloudMutations(options = {}) {
593
+ const database = options.database || openLocalDb();
594
+ const limit = Math.max(1, Math.min(Number(options.limit) || DEFAULT_CLAIM_LIMIT, 500));
595
+ const leaseMs = Math.max(1000, Number(options.leaseMs) || DEFAULT_CLAIM_LEASE_MS);
596
+ const now = typeof options.now === 'string' ? options.now : new Date().toISOString();
597
+ const leaseUntil = typeof options.leaseUntil === 'string'
598
+ ? options.leaseUntil
599
+ : new Date(new Date(now).getTime() + leaseMs).toISOString();
600
+
601
+ return database.transaction(() => {
602
+ const rows = cachedStatement(database, `
603
+ SELECT *
604
+ FROM mutation_journal
605
+ WHERE shared_resource_id IS NOT NULL
606
+ AND mutation_origin = 'local'
607
+ AND materialized_at IS NOT NULL
608
+ AND (
609
+ mutation_state = 'saved-local'
610
+ OR (
611
+ mutation_state = 'syncing'
612
+ AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
613
+ )
614
+ )
615
+ ORDER BY journal_id ASC
616
+ LIMIT ?
617
+ `).all(now, limit);
618
+
619
+ const claim = cachedStatement(database, `
620
+ UPDATE mutation_journal
621
+ SET mutation_state = 'syncing',
622
+ attempt_count = attempt_count + 1,
623
+ next_attempt_at = ?,
624
+ updated_at = ?
625
+ WHERE journal_id = ?
626
+ `);
627
+ const read = cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?');
628
+
629
+ return rows.map((row) => {
630
+ claim.run(leaseUntil, now, row.journal_id);
631
+ const mutation = normalizeJournalRow(read.get(row.journal_id));
632
+ return { mutation, envelope: cloudEnvelope(mutation) };
633
+ });
634
+ })();
635
+ }
636
+
637
+ function findSharedMutationRow(database, sharedResourceId, mutationId) {
638
+ return cachedStatement(database, `
639
+ SELECT * FROM mutation_journal
640
+ WHERE shared_resource_id = ? AND mutation_id = ?
641
+ `).get(sharedResourceId, mutationId) || null;
642
+ }
643
+
644
+ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknowledgement, options = {}) {
645
+ const sharedResourceId = cleanText(sharedResourceIdInput, 'sharedResourceId', 132);
646
+ const mutationId = cleanText(mutationIdInput, 'mutationId', 160);
647
+ const version = positiveInteger(acknowledgement?.version, 'version');
648
+ const committedAt = cleanText(
649
+ acknowledgement?.committedAt || new Date().toISOString(),
650
+ 'committedAt',
651
+ 64,
652
+ );
653
+ const database = options.database || openLocalDb();
654
+
655
+ return database.transaction(() => {
656
+ const row = findSharedMutationRow(database, sharedResourceId, mutationId);
657
+ if (!row) throw invalid(`unknown shared mutation ${mutationId}`, 404);
658
+ if (row.mutation_state === 'synced') {
659
+ if (Number(row.official_cloud_version) !== version) {
660
+ throw invalid(`mutation ${mutationId} was acknowledged with a different version`, 409);
661
+ }
662
+ return { mutation: normalizeJournalRow(row), duplicate: true };
663
+ }
664
+ if (!['saved-local', 'syncing'].includes(row.mutation_state)) {
665
+ throw invalid(`mutation ${mutationId} cannot sync from ${row.mutation_state}`, 409);
666
+ }
667
+
668
+ const now = new Date().toISOString();
669
+ cachedStatement(database, `
670
+ UPDATE mutation_journal
671
+ SET mutation_state = 'synced',
672
+ official_cloud_version = ?,
673
+ cloud_committed_at = ?,
674
+ next_attempt_at = NULL,
675
+ last_error = NULL,
676
+ updated_at = ?
677
+ WHERE journal_id = ?
678
+ `).run(version, committedAt, now, row.journal_id);
679
+ advanceReplicaCursor(database, sharedResourceId);
680
+ return {
681
+ mutation: normalizeJournalRow(
682
+ cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
683
+ .get(row.journal_id),
684
+ ),
685
+ duplicate: false,
686
+ };
687
+ })();
688
+ }
689
+
690
+ function failCloudMutation(sharedResourceIdInput, mutationIdInput, failure, options = {}) {
691
+ const sharedResourceId = cleanText(sharedResourceIdInput, 'sharedResourceId', 132);
692
+ const mutationId = cleanText(mutationIdInput, 'mutationId', 160);
693
+ const message = cleanText(failure?.message || failure || 'cloud mutation failed', 'message', 4000);
694
+ const retryable = failure?.retryable !== false;
695
+ const retryAt = retryable
696
+ ? (failure?.retryAt || new Date(Date.now() + 1000).toISOString())
697
+ : null;
698
+ const database = options.database || openLocalDb();
699
+ const row = findSharedMutationRow(database, sharedResourceId, mutationId);
700
+ if (!row) throw invalid(`unknown shared mutation ${mutationId}`, 404);
701
+ if (row.mutation_state === 'synced') return normalizeJournalRow(row);
702
+
703
+ const now = new Date().toISOString();
704
+ cachedStatement(database, `
705
+ UPDATE mutation_journal
706
+ SET mutation_state = ?,
707
+ next_attempt_at = ?,
708
+ last_error = ?,
709
+ updated_at = ?
710
+ WHERE journal_id = ?
711
+ `).run(retryable ? 'syncing' : 'rejected', retryAt, message, now, row.journal_id);
712
+ return normalizeJournalRow(
713
+ cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
714
+ .get(row.journal_id),
715
+ );
716
+ }
717
+
718
+ function markMutationConflicted(sharedResourceIdInput, mutationIdInput, messageInput, options = {}) {
719
+ const sharedResourceId = cleanText(sharedResourceIdInput, 'sharedResourceId', 132);
720
+ const mutationId = cleanText(mutationIdInput, 'mutationId', 160);
721
+ const message = cleanText(messageInput || 'mutation requires conflict resolution', 'message', 4000);
722
+ const database = options.database || openLocalDb();
723
+ const now = new Date().toISOString();
724
+ const result = cachedStatement(database, `
725
+ UPDATE mutation_journal
726
+ SET mutation_state = 'conflicted',
727
+ next_attempt_at = NULL,
728
+ last_error = ?,
729
+ updated_at = ?
730
+ WHERE shared_resource_id = ? AND mutation_id = ? AND mutation_state != 'synced'
731
+ `).run(message, now, sharedResourceId, mutationId);
732
+ if (!result.changes) throw invalid(`unknown or synced shared mutation ${mutationId}`, 404);
733
+ return normalizeJournalRow(findSharedMutationRow(database, sharedResourceId, mutationId));
734
+ }
735
+
736
+ function listMutations(options = {}) {
737
+ const database = options.database || openLocalDb();
738
+ const limit = Math.max(1, Math.min(Number(options.limit) || 100, 1000));
739
+ const rows = options.state
740
+ ? cachedStatement(database, `
741
+ SELECT * FROM mutation_journal
742
+ WHERE mutation_state = ?
743
+ ORDER BY journal_id ASC
744
+ LIMIT ?
745
+ `).all(options.state, limit)
746
+ : cachedStatement(database, `
747
+ SELECT * FROM mutation_journal
748
+ ORDER BY journal_id ASC
749
+ LIMIT ?
750
+ `).all(limit);
751
+ return rows.map(normalizeJournalRow);
752
+ }
753
+
754
+ function pruneMutationJournal(options = {}) {
755
+ const database = options.database || openLocalDb();
756
+ const hours = Number(options.retentionHours ?? process.env.AMALGM_EVENT_LOG_RETENTION_HOURS ?? 0.25);
757
+ if (!Number.isFinite(hours) || hours <= 0) {
758
+ return { deleted: 0, skipped: true, reason: 'disabled' };
759
+ }
760
+ const cutoffIso = options.cutoffIso
761
+ || new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
762
+ const batchSize = Math.max(1, Math.floor(Number(options.batchSize) || 1000));
763
+ const result = cachedStatement(database, `
764
+ DELETE FROM mutation_journal
765
+ WHERE journal_id IN (
766
+ SELECT journal_id
767
+ FROM mutation_journal
768
+ WHERE updated_at < ?
769
+ AND event_seq IS NOT NULL
770
+ AND (
771
+ (shared_resource_id IS NULL AND mutation_state = 'saved-local')
772
+ OR mutation_state = 'synced'
773
+ )
774
+ ORDER BY journal_id ASC
775
+ LIMIT ?
776
+ )
777
+ `).run(cutoffIso, batchSize);
778
+ return {
779
+ deleted: result.changes || 0,
780
+ cutoffIso,
781
+ retentionHours: hours,
782
+ batchSize,
783
+ };
784
+ }
785
+
786
+ module.exports = {
787
+ MAX_MUTATION_BYTES,
788
+ acknowledgeCloudMutation,
789
+ claimCloudMutations,
790
+ commitLocalMutation,
791
+ completeMaterializedMutation,
792
+ failCloudMutation,
793
+ getMutation,
794
+ ingestCloudMutation,
795
+ journalCloudMutation,
796
+ journalMutation,
797
+ listMutations,
798
+ markMutationConflicted,
799
+ pruneMutationJournal,
800
+ };