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/LICENSE +21 -0
- package/dist/{chunk-WALHLVVF.js → chunk-HPBI67CZ.js} +167 -35
- package/dist/chunk-HPBI67CZ.js.map +1 -0
- package/dist/index.cjs +149 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +12 -31
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +168 -35
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +30 -1
- package/dist/testing.d.ts +30 -1
- package/dist/testing.js +5 -3
- package/package.json +13 -13
- package/dist/chunk-WALHLVVF.js.map +0 -1
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
|
-
|
|
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
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
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());
|