korajs 0.6.0 → 1.0.0-beta.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.
package/dist/index.cjs CHANGED
@@ -251,6 +251,7 @@ async function buildSideEffectEntry(ctx, effect, parentOpId, transactionId, muta
251
251
  }
252
252
 
253
253
  // src/apply-pipeline.ts
254
+ var MERGE_APPLY_RETRY_LIMIT = 8;
254
255
  var ApplyPipeline = class {
255
256
  constructor(deps) {
256
257
  this.deps = deps;
@@ -452,15 +453,37 @@ var ApplyPipeline = class {
452
453
  if (!collectionDef) {
453
454
  return this.deps.store.applyRemoteOperation(op2);
454
455
  }
456
+ let lockError = null;
457
+ for (let attempt = 0; attempt < MERGE_APPLY_RETRY_LIMIT; attempt++) {
458
+ try {
459
+ return await this.applyRemoteUpdateAttempt(op2, collectionDef);
460
+ } catch (error) {
461
+ if (!(error instanceof import_store.OptimisticLockError)) {
462
+ throw error;
463
+ }
464
+ lockError = error;
465
+ }
466
+ }
467
+ throw lockError ?? new import_store.OptimisticLockError(op2.collection, op2.recordId);
468
+ }
469
+ async applyRemoteUpdateAttempt(op2, collectionDef) {
470
+ const guard = await this.deps.store.getRowVersionState(
471
+ op2.collection,
472
+ op2.recordId
473
+ ) ?? { version: null, fieldVersions: null };
455
474
  const accessor = this.deps.store.collection(op2.collection);
456
475
  const currentRecord = await accessor.findById(op2.recordId);
457
476
  if (!currentRecord) {
458
- const tombstoneResult = await this.applyRemoteUpdateOnDeletedRow(op2, collectionDef);
477
+ const tombstoneResult = await this.applyRemoteUpdateOnDeletedRow(op2, collectionDef, guard);
459
478
  if (tombstoneResult !== null) {
460
479
  return tombstoneResult;
461
480
  }
462
481
  return this.deps.store.applyRemoteOperation(op2);
463
482
  }
483
+ if (!updateNeedsMergeEngine(op2, collectionDef)) {
484
+ await this.observeScalarMerge(op2, collectionDef);
485
+ return this.deps.store.applyRemoteOperation(op2);
486
+ }
464
487
  let hasConflict = false;
465
488
  for (const field of Object.keys(op2.data ?? {})) {
466
489
  const fieldDef = collectionDef.fields[field];
@@ -478,14 +501,50 @@ var ApplyPipeline = class {
478
501
  }
479
502
  }
480
503
  if (!hasConflict) {
481
- return this.deps.store.applyRemoteOperation(op2);
504
+ return this.deps.store.applyRemoteOperation(op2, {
505
+ forceMaterialize: true,
506
+ guardRowState: guard
507
+ });
482
508
  }
483
- return this.applyMergedUpdate(op2, collectionDef, currentRecord, op2.previousData ?? {});
509
+ return this.applyMergedUpdate(op2, collectionDef, currentRecord, op2.previousData ?? {}, guard);
484
510
  }
485
511
  /**
486
512
  * Remote update vs local soft-delete: merge before materializing to avoid zombie rows.
487
513
  */
488
- async applyRemoteUpdateOnDeletedRow(op2, collectionDef) {
514
+ /**
515
+ * Emit merge observability (trace + conflict counter) for a scalar update that
516
+ * concurrently touches a field a local operation also changed.
517
+ *
518
+ * The store resolves the actual value by deterministic per-field LWW; this
519
+ * method exists only so the decision is loggable (CLAUDE.md: every merge
520
+ * decision must be traceable). It reads the local operation from the append-
521
+ * only log — which is immutable — rather than the materialized row, so it is
522
+ * not subject to the read-then-write race the store write path was hardened
523
+ * against. The merge engine is invoked purely to build an accurate trace; its
524
+ * timestamps match the store's LWW comparison, so the trace never lies.
525
+ */
526
+ async observeScalarMerge(op2, collectionDef) {
527
+ const localOp = await this.deps.store.getLatestLocalOperationForRecord(
528
+ op2.collection,
529
+ op2.recordId
530
+ );
531
+ if (!localOp || !localOp.data) {
532
+ return;
533
+ }
534
+ const remoteFields = Object.keys(op2.data ?? {});
535
+ const sharesField = remoteFields.some((field) => localOp.data?.[field] !== void 0);
536
+ if (!sharesField) {
537
+ return;
538
+ }
539
+ const mergeResult = await this.deps.mergeEngine.merge({
540
+ local: localOp,
541
+ remote: op2,
542
+ baseState: op2.previousData ?? {},
543
+ collectionDef
544
+ });
545
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
546
+ }
547
+ async applyRemoteUpdateOnDeletedRow(op2, collectionDef, guard) {
489
548
  const snapshot = await this.deps.store.findMaterializedRow(op2.collection, op2.recordId);
490
549
  if (!snapshot?.deleted) {
491
550
  return null;
@@ -503,37 +562,41 @@ var ApplyPipeline = class {
503
562
  baseState: op2.previousData ?? {},
504
563
  collectionDef
505
564
  });
506
- this.emitMergeLifecycle(op2, localOp, mergeResult);
507
565
  if (mergeResult.appliedOperation === "local") {
566
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
508
567
  return "skipped";
509
568
  }
510
- const mergedOp = {
511
- ...op2,
512
- data: { ...mergeResult.mergedData },
513
- timestamp: maxTimestamp(op2.timestamp, localOp.timestamp)
514
- };
515
- const applyResult = await this.deps.store.applyRemoteOperation(mergedOp, {
516
- reactivateIfDeleted: true
569
+ const applyResult = await this.deps.store.applyRemoteOperation(op2, {
570
+ reactivateIfDeleted: true,
571
+ forceMaterialize: true,
572
+ materializeData: { ...mergeResult.mergedData },
573
+ materializeTimestamp: maxTimestamp(op2.timestamp, localOp.timestamp),
574
+ guardRowState: guard
517
575
  });
576
+ this.emitMergeLifecycle(op2, localOp, mergeResult);
518
577
  if (applyResult === "applied" && mergeResult.sideEffects.length > 0) {
519
578
  await applySideEffectOps(this.deps.store, mergeResult.sideEffects, op2.id);
520
579
  }
521
580
  return applyResult;
522
581
  }
523
- async applyMergedUpdate(op2, collectionDef, currentRecord, baseState, applyOptions) {
524
- const localTimestamp = await resolveLocalTimestamp(
525
- this.deps.store,
582
+ async applyMergedUpdate(op2, collectionDef, currentRecord, baseState, guard, applyOptions) {
583
+ const latestLocal = await this.deps.store.getLatestLocalOperationForRecord(
526
584
  op2.collection,
527
- op2.recordId,
585
+ op2.recordId
586
+ );
587
+ const localTimestamp = resolveLocalTimestamp(
588
+ latestLocal,
528
589
  currentRecord,
529
590
  this.deps.store.getNodeId()
530
591
  );
592
+ const { atomicOps: _remoteAtomicOps, ...opWithoutAtomic } = op2;
531
593
  const localOp = {
532
- ...op2,
594
+ ...opWithoutAtomic,
533
595
  data: buildLocalDiff(baseState, currentRecord, Object.keys(op2.data ?? {})),
534
596
  previousData: op2.previousData,
535
597
  nodeId: this.deps.store.getNodeId(),
536
- timestamp: localTimestamp
598
+ timestamp: localTimestamp,
599
+ ...latestLocal?.atomicOps !== void 0 ? { atomicOps: latestLocal.atomicOps } : {}
537
600
  };
538
601
  const constraintContext = createConstraintContext(this.deps.store);
539
602
  const mergeResult = await this.deps.mergeEngine.merge(
@@ -545,13 +608,14 @@ var ApplyPipeline = class {
545
608
  },
546
609
  constraintContext
547
610
  );
611
+ const applyResult = await this.deps.store.applyRemoteOperation(op2, {
612
+ ...applyOptions,
613
+ forceMaterialize: true,
614
+ materializeData: mergeResult.mergedData,
615
+ materializeTimestamp: maxTimestamp(op2.timestamp, localTimestamp),
616
+ guardRowState: guard
617
+ });
548
618
  this.emitMergeLifecycle(op2, localOp, mergeResult);
549
- const mergedOp = {
550
- ...op2,
551
- data: mergeResult.mergedData,
552
- timestamp: maxTimestamp(op2.timestamp, localTimestamp)
553
- };
554
- const applyResult = await this.deps.store.applyRemoteOperation(mergedOp, applyOptions);
555
619
  if (applyResult === "applied" && mergeResult.sideEffects.length > 0) {
556
620
  await applySideEffectOps(this.deps.store, mergeResult.sideEffects, op2.id);
557
621
  }
@@ -562,9 +626,27 @@ var ApplyPipeline = class {
562
626
  if (!collectionDef || !op2.data) {
563
627
  return this.deps.store.applyRemoteOperation(op2);
564
628
  }
629
+ let lockError = null;
630
+ for (let attempt = 0; attempt < MERGE_APPLY_RETRY_LIMIT; attempt++) {
631
+ try {
632
+ return await this.applyRemoteInsertAttempt(op2, collectionDef);
633
+ } catch (error) {
634
+ if (!(error instanceof import_store.OptimisticLockError)) {
635
+ throw error;
636
+ }
637
+ lockError = error;
638
+ }
639
+ }
640
+ throw lockError ?? new import_store.OptimisticLockError(op2.collection, op2.recordId);
641
+ }
642
+ async applyRemoteInsertAttempt(op2, collectionDef) {
643
+ const guard = await this.deps.store.getRowVersionState(
644
+ op2.collection,
645
+ op2.recordId
646
+ ) ?? { version: null, fieldVersions: null };
565
647
  const snapshot = await this.deps.store.findMaterializedRow(op2.collection, op2.recordId);
566
648
  if (!snapshot || snapshot.deleted) {
567
- return this.deps.store.applyRemoteOperation(op2);
649
+ return this.deps.store.applyRemoteOperation(op2, { guardRowState: guard });
568
650
  }
569
651
  const localOp = await resolveLocalOpForRecord(
570
652
  this.deps.store,
@@ -578,13 +660,14 @@ var ApplyPipeline = class {
578
660
  baseState: {},
579
661
  collectionDef
580
662
  });
663
+ const applyResult = await this.deps.store.applyRemoteOperation(op2, {
664
+ forceMaterialize: true,
665
+ materializeData: mergeResult.mergedData,
666
+ materializeTimestamp: maxTimestamp(op2.timestamp, localOp.timestamp),
667
+ guardRowState: guard
668
+ });
581
669
  this.emitMergeLifecycle(op2, localOp, mergeResult);
582
- const mergedOp = {
583
- ...op2,
584
- data: mergeResult.mergedData,
585
- timestamp: maxTimestamp(op2.timestamp, localOp.timestamp)
586
- };
587
- return this.deps.store.applyRemoteOperation(mergedOp);
670
+ return applyResult;
588
671
  }
589
672
  emitMergeLifecycle(remote, local, mergeResult) {
590
673
  this.deps.emitter?.emit({
@@ -676,6 +759,24 @@ async function applySideEffectOps(store, sideEffects, parentOpId) {
676
759
  }
677
760
  }
678
761
  }
762
+ function updateNeedsMergeEngine(op2, collectionDef) {
763
+ if (collectionDef.constraints.length > 0) {
764
+ return true;
765
+ }
766
+ if (op2.atomicOps && Object.keys(op2.atomicOps).length > 0) {
767
+ return true;
768
+ }
769
+ for (const field of Object.keys(op2.data ?? {})) {
770
+ const kind = collectionDef.fields[field]?.kind;
771
+ if (kind === "richtext" || kind === "array") {
772
+ return true;
773
+ }
774
+ if (collectionDef.resolvers[field]) {
775
+ return true;
776
+ }
777
+ }
778
+ return false;
779
+ }
679
780
  function buildLocalDiff(baseState, currentRecord, fields) {
680
781
  const diff = {};
681
782
  for (const field of fields) {
@@ -683,8 +784,7 @@ function buildLocalDiff(baseState, currentRecord, fields) {
683
784
  }
684
785
  return diff;
685
786
  }
686
- async function resolveLocalTimestamp(store, collection, recordId, currentRecord, nodeId) {
687
- const latestLocal = await store.getLatestLocalOperationForRecord(collection, recordId);
787
+ function resolveLocalTimestamp(latestLocal, currentRecord, nodeId) {
688
788
  if (latestLocal) {
689
789
  return latestLocal.timestamp;
690
790
  }
@@ -775,6 +875,13 @@ var MergeAwareSyncStore = class {
775
875
  async applyRemoteOperation(op2) {
776
876
  return this.pipeline.applyRemote(op2);
777
877
  }
878
+ /**
879
+ * Delegates timestamp rebase to the store so the sync engine can re-stamp
880
+ * never-acknowledged operations after a fast device clock is corrected.
881
+ */
882
+ async rebaseUnsyncedOperations(ids, correctedNowMs) {
883
+ return this.store.rebaseUnsyncedOperations(ids, correctedNowMs);
884
+ }
778
885
  };
779
886
 
780
887
  // src/store-queue-storage.ts
@@ -1091,7 +1198,8 @@ function createSyncControl(options) {
1091
1198
  lastSyncedAt: null,
1092
1199
  lastSuccessfulPush: null,
1093
1200
  lastSuccessfulPull: null,
1094
- conflicts: 0
1201
+ conflicts: 0,
1202
+ clockSkewMs: null
1095
1203
  },
1096
1204
  nodeId: "",
1097
1205
  url: config.sync?.url ?? "",
@@ -1100,6 +1208,7 @@ function createSyncControl(options) {
1100
1208
  lastSuccessfulPush: null,
1101
1209
  lastSuccessfulPull: null,
1102
1210
  conflicts: 0,
1211
+ clockSkewMs: null,
1103
1212
  pendingOperations: 0,
1104
1213
  hasInFlightBatch: false,
1105
1214
  reconnecting: false,
@@ -1109,6 +1218,9 @@ function createSyncControl(options) {
1109
1218
  };
1110
1219
  }
1111
1220
 
1221
+ // src/sync-lifecycle.ts
1222
+ var import_sync6 = require("@korajs/sync");
1223
+
1112
1224
  // src/auth-sync-coordinator.ts
1113
1225
  var AuthSyncCoordinator = class {
1114
1226
  constructor(getEngine, authBinding) {
@@ -1185,7 +1297,6 @@ function createSyncStatusBridge(emitter, getSyncEngine) {
1185
1297
  }
1186
1298
 
1187
1299
  // src/sync-lifecycle.ts
1188
- var import_sync6 = require("@korajs/sync");
1189
1300
  function wireSyncLifecycleAfterReady(config, emitter, state, init) {
1190
1301
  state.syncEngine = init.syncEngine;
1191
1302
  if (!config.sync) {
@@ -1208,6 +1319,9 @@ function wireSyncLifecycleAfterReady(config, emitter, state, init) {
1208
1319
  initialDelay: config.sync.reconnectInterval,
1209
1320
  maxDelay: config.sync.maxReconnectInterval
1210
1321
  });
1322
+ emitter.on("sync:clock-skew", (event) => {
1323
+ init.store.setClockReferenceOffset(event.skewMs);
1324
+ });
1211
1325
  emitter.on("sync:sent", () => state.connectionMonitor?.recordActivity());
1212
1326
  emitter.on("sync:received", () => state.connectionMonitor?.recordActivity());
1213
1327
  emitter.on("sync:acknowledged", () => state.connectionMonitor?.recordActivity());