@korajs/merge 0.3.2 → 0.5.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 +666 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +218 -2
- package/dist/index.d.ts +218 -2
- package/dist/index.js +656 -14
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -70,6 +70,68 @@ function addWinsSet(localArray, remoteArray, baseArray) {
|
|
|
70
70
|
return result;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// src/strategies/atomic-merge.ts
|
|
74
|
+
function mergeAtomicOps(localAtomicOp, remoteAtomicOp, baseValue) {
|
|
75
|
+
if (localAtomicOp.type !== remoteAtomicOp.type) {
|
|
76
|
+
return { merged: false };
|
|
77
|
+
}
|
|
78
|
+
switch (localAtomicOp.type) {
|
|
79
|
+
case "increment": {
|
|
80
|
+
const base = typeof baseValue === "number" ? baseValue : 0;
|
|
81
|
+
const localDelta = localAtomicOp.value;
|
|
82
|
+
const remoteDelta = remoteAtomicOp.value;
|
|
83
|
+
return {
|
|
84
|
+
merged: true,
|
|
85
|
+
value: base + localDelta + remoteDelta,
|
|
86
|
+
strategy: "atomic-increment"
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
case "max": {
|
|
90
|
+
const base = typeof baseValue === "number" ? baseValue : Number.NEGATIVE_INFINITY;
|
|
91
|
+
const localVal = localAtomicOp.value;
|
|
92
|
+
const remoteVal = remoteAtomicOp.value;
|
|
93
|
+
return {
|
|
94
|
+
merged: true,
|
|
95
|
+
value: Math.max(base, localVal, remoteVal),
|
|
96
|
+
strategy: "atomic-max"
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
case "min": {
|
|
100
|
+
const base = typeof baseValue === "number" ? baseValue : Number.POSITIVE_INFINITY;
|
|
101
|
+
const localVal = localAtomicOp.value;
|
|
102
|
+
const remoteVal = remoteAtomicOp.value;
|
|
103
|
+
return {
|
|
104
|
+
merged: true,
|
|
105
|
+
value: Math.min(base, localVal, remoteVal),
|
|
106
|
+
strategy: "atomic-min"
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case "append": {
|
|
110
|
+
const base = Array.isArray(baseValue) ? [...baseValue] : [];
|
|
111
|
+
base.push(localAtomicOp.value);
|
|
112
|
+
base.push(remoteAtomicOp.value);
|
|
113
|
+
return {
|
|
114
|
+
merged: true,
|
|
115
|
+
value: base,
|
|
116
|
+
strategy: "atomic-append"
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
case "remove": {
|
|
120
|
+
const base = Array.isArray(baseValue) ? [...baseValue] : [];
|
|
121
|
+
const localItem = localAtomicOp.value;
|
|
122
|
+
const remoteItem = remoteAtomicOp.value;
|
|
123
|
+
const result = base.filter((item) => item !== localItem && item !== remoteItem);
|
|
124
|
+
return {
|
|
125
|
+
merged: true,
|
|
126
|
+
value: result,
|
|
127
|
+
strategy: "atomic-remove"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
return { merged: false };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
73
135
|
// src/strategies/yjs-richtext.ts
|
|
74
136
|
import * as Y from "yjs";
|
|
75
137
|
var TEXT_KEY = "content";
|
|
@@ -106,6 +168,88 @@ function toYjsUpdate(value) {
|
|
|
106
168
|
throw new Error("Richtext value must be a string, Uint8Array, ArrayBuffer, null, or undefined.");
|
|
107
169
|
}
|
|
108
170
|
|
|
171
|
+
// src/strategies/schema-strategies.ts
|
|
172
|
+
import "@korajs/core";
|
|
173
|
+
function counterMerge(localValue, remoteValue, baseValue) {
|
|
174
|
+
const base = typeof baseValue === "number" ? baseValue : 0;
|
|
175
|
+
const local = typeof localValue === "number" ? localValue : base;
|
|
176
|
+
const remote = typeof remoteValue === "number" ? remoteValue : base;
|
|
177
|
+
const localDelta = local - base;
|
|
178
|
+
const remoteDelta = remote - base;
|
|
179
|
+
return base + localDelta + remoteDelta;
|
|
180
|
+
}
|
|
181
|
+
function maxMerge(localValue, remoteValue, baseValue) {
|
|
182
|
+
const vals = [];
|
|
183
|
+
if (typeof baseValue === "number") vals.push(baseValue);
|
|
184
|
+
if (typeof localValue === "number") vals.push(localValue);
|
|
185
|
+
if (typeof remoteValue === "number") vals.push(remoteValue);
|
|
186
|
+
if (vals.length === 0) return baseValue;
|
|
187
|
+
return Math.max(...vals);
|
|
188
|
+
}
|
|
189
|
+
function minMerge(localValue, remoteValue, baseValue) {
|
|
190
|
+
const vals = [];
|
|
191
|
+
if (typeof baseValue === "number") vals.push(baseValue);
|
|
192
|
+
if (typeof localValue === "number") vals.push(localValue);
|
|
193
|
+
if (typeof remoteValue === "number") vals.push(remoteValue);
|
|
194
|
+
if (vals.length === 0) return baseValue;
|
|
195
|
+
return Math.min(...vals);
|
|
196
|
+
}
|
|
197
|
+
function appendOnlyMerge(localValue, remoteValue, baseValue) {
|
|
198
|
+
const base = Array.isArray(baseValue) ? baseValue : [];
|
|
199
|
+
const local = Array.isArray(localValue) ? localValue : [];
|
|
200
|
+
const remote = Array.isArray(remoteValue) ? remoteValue : [];
|
|
201
|
+
const serialize = (v) => JSON.stringify(v);
|
|
202
|
+
const baseSet = new Set(base.map(serialize));
|
|
203
|
+
const result = [...base];
|
|
204
|
+
const resultSet = new Set(base.map(serialize));
|
|
205
|
+
for (const item of local) {
|
|
206
|
+
const s = serialize(item);
|
|
207
|
+
if (!baseSet.has(s) && !resultSet.has(s)) {
|
|
208
|
+
result.push(item);
|
|
209
|
+
resultSet.add(s);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const item of remote) {
|
|
213
|
+
const s = serialize(item);
|
|
214
|
+
if (!baseSet.has(s) && !resultSet.has(s)) {
|
|
215
|
+
result.push(item);
|
|
216
|
+
resultSet.add(s);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
function serverAuthoritativeMerge(_localValue, remoteValue, _baseValue) {
|
|
222
|
+
return remoteValue;
|
|
223
|
+
}
|
|
224
|
+
function applySchemaStrategy(strategy, localValue, remoteValue, baseValue, localTimestamp, remoteTimestamp) {
|
|
225
|
+
switch (strategy) {
|
|
226
|
+
case "counter":
|
|
227
|
+
return {
|
|
228
|
+
value: counterMerge(localValue, remoteValue, baseValue),
|
|
229
|
+
strategyName: "schema-counter"
|
|
230
|
+
};
|
|
231
|
+
case "max":
|
|
232
|
+
return { value: maxMerge(localValue, remoteValue, baseValue), strategyName: "schema-max" };
|
|
233
|
+
case "min":
|
|
234
|
+
return { value: minMerge(localValue, remoteValue, baseValue), strategyName: "schema-min" };
|
|
235
|
+
case "append-only":
|
|
236
|
+
return {
|
|
237
|
+
value: appendOnlyMerge(localValue, remoteValue, baseValue),
|
|
238
|
+
strategyName: "schema-append-only"
|
|
239
|
+
};
|
|
240
|
+
case "server-authoritative":
|
|
241
|
+
return {
|
|
242
|
+
value: serverAuthoritativeMerge(localValue, remoteValue, baseValue),
|
|
243
|
+
strategyName: "schema-server-authoritative"
|
|
244
|
+
};
|
|
245
|
+
case "lww":
|
|
246
|
+
case "union":
|
|
247
|
+
return null;
|
|
248
|
+
default:
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
109
253
|
// src/engine/field-merger.ts
|
|
110
254
|
function mergeField(fieldName, localOp, remoteOp, baseState, fieldDescriptor, resolver) {
|
|
111
255
|
const startTime = Date.now();
|
|
@@ -175,6 +319,53 @@ function mergeField(fieldName, localOp, remoteOp, baseState, fieldDescriptor, re
|
|
|
175
319
|
startTime
|
|
176
320
|
);
|
|
177
321
|
}
|
|
322
|
+
const localAtomicOp = localOp.atomicOps?.[fieldName];
|
|
323
|
+
const remoteAtomicOp = remoteOp.atomicOps?.[fieldName];
|
|
324
|
+
if (localAtomicOp !== void 0 && remoteAtomicOp !== void 0) {
|
|
325
|
+
const atomicResult = mergeAtomicOps(
|
|
326
|
+
localAtomicOp,
|
|
327
|
+
remoteAtomicOp,
|
|
328
|
+
baseValue
|
|
329
|
+
);
|
|
330
|
+
if (atomicResult.merged) {
|
|
331
|
+
return createResult(
|
|
332
|
+
atomicResult.value,
|
|
333
|
+
fieldName,
|
|
334
|
+
localOp,
|
|
335
|
+
remoteOp,
|
|
336
|
+
localValue,
|
|
337
|
+
remoteValue,
|
|
338
|
+
baseValue,
|
|
339
|
+
atomicResult.strategy,
|
|
340
|
+
1,
|
|
341
|
+
startTime
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (fieldDescriptor.mergeStrategy !== null && fieldDescriptor.mergeStrategy !== void 0) {
|
|
346
|
+
const schemaResult = applySchemaStrategy(
|
|
347
|
+
fieldDescriptor.mergeStrategy,
|
|
348
|
+
localValue,
|
|
349
|
+
remoteValue,
|
|
350
|
+
baseValue,
|
|
351
|
+
localOp.timestamp,
|
|
352
|
+
remoteOp.timestamp
|
|
353
|
+
);
|
|
354
|
+
if (schemaResult !== null) {
|
|
355
|
+
return createResult(
|
|
356
|
+
schemaResult.value,
|
|
357
|
+
fieldName,
|
|
358
|
+
localOp,
|
|
359
|
+
remoteOp,
|
|
360
|
+
localValue,
|
|
361
|
+
remoteValue,
|
|
362
|
+
baseValue,
|
|
363
|
+
schemaResult.strategyName,
|
|
364
|
+
1,
|
|
365
|
+
startTime
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
178
369
|
return autoMerge(
|
|
179
370
|
fieldName,
|
|
180
371
|
localOp,
|
|
@@ -369,13 +560,13 @@ function matchesWhere(record, where) {
|
|
|
369
560
|
}
|
|
370
561
|
|
|
371
562
|
// src/constraints/resolvers.ts
|
|
372
|
-
import { HybridLogicalClock as
|
|
563
|
+
import { HybridLogicalClock as HybridLogicalClock3 } from "@korajs/core";
|
|
373
564
|
function resolveConstraintViolation(violation, mergedRecord, localOp, remoteOp, baseState) {
|
|
374
565
|
const startTime = Date.now();
|
|
375
566
|
const { constraint } = violation;
|
|
376
567
|
switch (constraint.onConflict) {
|
|
377
568
|
case "last-write-wins": {
|
|
378
|
-
const comparison =
|
|
569
|
+
const comparison = HybridLogicalClock3.compare(localOp.timestamp, remoteOp.timestamp);
|
|
379
570
|
const winner = comparison >= 0 ? localOp : remoteOp;
|
|
380
571
|
const resolvedRecord = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
381
572
|
return createResolution(
|
|
@@ -389,7 +580,7 @@ function resolveConstraintViolation(violation, mergedRecord, localOp, remoteOp,
|
|
|
389
580
|
);
|
|
390
581
|
}
|
|
391
582
|
case "first-write-wins": {
|
|
392
|
-
const comparison =
|
|
583
|
+
const comparison = HybridLogicalClock3.compare(localOp.timestamp, remoteOp.timestamp);
|
|
393
584
|
const winner = comparison <= 0 ? localOp : remoteOp;
|
|
394
585
|
const resolvedRecord = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
395
586
|
return createResolution(
|
|
@@ -405,7 +596,7 @@ function resolveConstraintViolation(violation, mergedRecord, localOp, remoteOp,
|
|
|
405
596
|
case "priority-field": {
|
|
406
597
|
const priorityField = constraint.priorityField;
|
|
407
598
|
if (priorityField === void 0) {
|
|
408
|
-
const comparison =
|
|
599
|
+
const comparison = HybridLogicalClock3.compare(localOp.timestamp, remoteOp.timestamp);
|
|
409
600
|
const winner2 = comparison >= 0 ? localOp : remoteOp;
|
|
410
601
|
const resolvedRecord2 = applyWinnerFields(mergedRecord, winner2, violation.fields);
|
|
411
602
|
return createResolution(
|
|
@@ -449,7 +640,7 @@ function resolveConstraintViolation(violation, mergedRecord, localOp, remoteOp,
|
|
|
449
640
|
}
|
|
450
641
|
case "custom": {
|
|
451
642
|
if (constraint.resolve === void 0) {
|
|
452
|
-
const comparison =
|
|
643
|
+
const comparison = HybridLogicalClock3.compare(localOp.timestamp, remoteOp.timestamp);
|
|
453
644
|
const winner = comparison >= 0 ? localOp : remoteOp;
|
|
454
645
|
const resolvedRecord2 = applyWinnerFields(mergedRecord, winner, violation.fields);
|
|
455
646
|
return createResolution(
|
|
@@ -540,8 +731,426 @@ function extractFields(record, fields) {
|
|
|
540
731
|
return result;
|
|
541
732
|
}
|
|
542
733
|
|
|
734
|
+
// src/constraints/referential-integrity.ts
|
|
735
|
+
import "@korajs/core";
|
|
736
|
+
function buildMergeRelationLookup(schema) {
|
|
737
|
+
const lookup = /* @__PURE__ */ new Map();
|
|
738
|
+
const relationNames = Object.keys(schema.relations).sort();
|
|
739
|
+
for (const relationName of relationNames) {
|
|
740
|
+
const relation = schema.relations[relationName];
|
|
741
|
+
if (relation === void 0) {
|
|
742
|
+
continue;
|
|
743
|
+
}
|
|
744
|
+
const targetCollection = relation.to;
|
|
745
|
+
const existing = lookup.get(targetCollection) ?? [];
|
|
746
|
+
existing.push({
|
|
747
|
+
relationName,
|
|
748
|
+
sourceCollection: relation.from,
|
|
749
|
+
foreignKeyField: relation.field,
|
|
750
|
+
onDelete: relation.onDelete
|
|
751
|
+
});
|
|
752
|
+
lookup.set(targetCollection, existing);
|
|
753
|
+
}
|
|
754
|
+
for (const [, relations] of lookup) {
|
|
755
|
+
relations.sort(
|
|
756
|
+
(a, b) => a.relationName < b.relationName ? -1 : a.relationName > b.relationName ? 1 : 0
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
return lookup;
|
|
760
|
+
}
|
|
761
|
+
async function checkReferentialIntegrityOnDelete(deleteOp, schema, ctx, relationLookup) {
|
|
762
|
+
const lookup = relationLookup ?? buildMergeRelationLookup(schema);
|
|
763
|
+
const incomingRelations = lookup.get(deleteOp.collection);
|
|
764
|
+
if (incomingRelations === void 0 || incomingRelations.length === 0) {
|
|
765
|
+
return { allowed: true, sideEffectOps: [], traces: [] };
|
|
766
|
+
}
|
|
767
|
+
const allSideEffects = [];
|
|
768
|
+
const allTraces = [];
|
|
769
|
+
for (const relation of incomingRelations) {
|
|
770
|
+
const startTime = Date.now();
|
|
771
|
+
const referencingRecords = await ctx.queryRecords(relation.sourceCollection, {
|
|
772
|
+
[relation.foreignKeyField]: deleteOp.recordId
|
|
773
|
+
});
|
|
774
|
+
const sortedRecords = [...referencingRecords].sort((a, b) => {
|
|
775
|
+
const idA = String(a.id ?? "");
|
|
776
|
+
const idB = String(b.id ?? "");
|
|
777
|
+
return idA < idB ? -1 : idA > idB ? 1 : 0;
|
|
778
|
+
});
|
|
779
|
+
if (sortedRecords.length === 0) {
|
|
780
|
+
const trace = createReferentialTrace(
|
|
781
|
+
deleteOp,
|
|
782
|
+
relation,
|
|
783
|
+
`referential-${relation.onDelete}`,
|
|
784
|
+
null,
|
|
785
|
+
null,
|
|
786
|
+
true,
|
|
787
|
+
startTime
|
|
788
|
+
);
|
|
789
|
+
allTraces.push(trace);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
switch (relation.onDelete) {
|
|
793
|
+
case "restrict": {
|
|
794
|
+
const trace = createReferentialTrace(
|
|
795
|
+
deleteOp,
|
|
796
|
+
relation,
|
|
797
|
+
"referential-restrict",
|
|
798
|
+
sortedRecords.map((r) => String(r.id ?? "")),
|
|
799
|
+
null,
|
|
800
|
+
false,
|
|
801
|
+
startTime
|
|
802
|
+
);
|
|
803
|
+
allTraces.push(trace);
|
|
804
|
+
return {
|
|
805
|
+
allowed: false,
|
|
806
|
+
sideEffectOps: [],
|
|
807
|
+
traces: allTraces
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
case "cascade": {
|
|
811
|
+
for (const record of sortedRecords) {
|
|
812
|
+
const recordId = String(record.id ?? "");
|
|
813
|
+
allSideEffects.push({
|
|
814
|
+
type: "delete",
|
|
815
|
+
collection: relation.sourceCollection,
|
|
816
|
+
recordId,
|
|
817
|
+
data: null,
|
|
818
|
+
previousData: null,
|
|
819
|
+
policy: "cascade",
|
|
820
|
+
relationName: relation.relationName
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const trace = createReferentialTrace(
|
|
824
|
+
deleteOp,
|
|
825
|
+
relation,
|
|
826
|
+
"referential-cascade",
|
|
827
|
+
sortedRecords.map((r) => String(r.id ?? "")),
|
|
828
|
+
sortedRecords.map((r) => ({ type: "delete", recordId: String(r.id ?? "") })),
|
|
829
|
+
true,
|
|
830
|
+
startTime
|
|
831
|
+
);
|
|
832
|
+
allTraces.push(trace);
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
case "set-null": {
|
|
836
|
+
for (const record of sortedRecords) {
|
|
837
|
+
const recordId = String(record.id ?? "");
|
|
838
|
+
const previousValue = record[relation.foreignKeyField];
|
|
839
|
+
allSideEffects.push({
|
|
840
|
+
type: "update",
|
|
841
|
+
collection: relation.sourceCollection,
|
|
842
|
+
recordId,
|
|
843
|
+
data: { [relation.foreignKeyField]: null },
|
|
844
|
+
previousData: { [relation.foreignKeyField]: previousValue },
|
|
845
|
+
policy: "set-null",
|
|
846
|
+
relationName: relation.relationName
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
const trace = createReferentialTrace(
|
|
850
|
+
deleteOp,
|
|
851
|
+
relation,
|
|
852
|
+
"referential-set-null",
|
|
853
|
+
sortedRecords.map((r) => String(r.id ?? "")),
|
|
854
|
+
sortedRecords.map((r) => ({
|
|
855
|
+
type: "update",
|
|
856
|
+
recordId: String(r.id ?? ""),
|
|
857
|
+
field: relation.foreignKeyField,
|
|
858
|
+
newValue: null
|
|
859
|
+
})),
|
|
860
|
+
true,
|
|
861
|
+
startTime
|
|
862
|
+
);
|
|
863
|
+
allTraces.push(trace);
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
case "no-action": {
|
|
867
|
+
const trace = createReferentialTrace(
|
|
868
|
+
deleteOp,
|
|
869
|
+
relation,
|
|
870
|
+
"referential-no-action",
|
|
871
|
+
sortedRecords.map((r) => String(r.id ?? "")),
|
|
872
|
+
null,
|
|
873
|
+
true,
|
|
874
|
+
startTime
|
|
875
|
+
);
|
|
876
|
+
allTraces.push(trace);
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
allowed: true,
|
|
883
|
+
sideEffectOps: allSideEffects,
|
|
884
|
+
traces: allTraces
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function resolveDeleteVsInsertConflict(deleteOp, insertOp, relation) {
|
|
888
|
+
const startTime = Date.now();
|
|
889
|
+
switch (relation.onDelete) {
|
|
890
|
+
case "restrict": {
|
|
891
|
+
const trace = createDeleteVsInsertTrace(
|
|
892
|
+
deleteOp,
|
|
893
|
+
insertOp,
|
|
894
|
+
relation,
|
|
895
|
+
"referential-restrict",
|
|
896
|
+
"block-delete",
|
|
897
|
+
startTime
|
|
898
|
+
);
|
|
899
|
+
return {
|
|
900
|
+
action: "block-delete",
|
|
901
|
+
sideEffects: [],
|
|
902
|
+
trace
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
case "cascade": {
|
|
906
|
+
const sideEffect = {
|
|
907
|
+
type: "delete",
|
|
908
|
+
collection: relation.sourceCollection,
|
|
909
|
+
recordId: insertOp.recordId,
|
|
910
|
+
data: null,
|
|
911
|
+
previousData: null,
|
|
912
|
+
policy: "cascade",
|
|
913
|
+
relationName: relation.relationName
|
|
914
|
+
};
|
|
915
|
+
const trace = createDeleteVsInsertTrace(
|
|
916
|
+
deleteOp,
|
|
917
|
+
insertOp,
|
|
918
|
+
relation,
|
|
919
|
+
"referential-cascade",
|
|
920
|
+
"allow-delete",
|
|
921
|
+
startTime
|
|
922
|
+
);
|
|
923
|
+
return {
|
|
924
|
+
action: "allow-delete",
|
|
925
|
+
sideEffects: [sideEffect],
|
|
926
|
+
trace
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
case "set-null": {
|
|
930
|
+
const fkValue = insertOp.data !== null ? insertOp.data[relation.foreignKeyField] : void 0;
|
|
931
|
+
const sideEffect = {
|
|
932
|
+
type: "update",
|
|
933
|
+
collection: relation.sourceCollection,
|
|
934
|
+
recordId: insertOp.recordId,
|
|
935
|
+
data: { [relation.foreignKeyField]: null },
|
|
936
|
+
previousData: { [relation.foreignKeyField]: fkValue ?? null },
|
|
937
|
+
policy: "set-null",
|
|
938
|
+
relationName: relation.relationName
|
|
939
|
+
};
|
|
940
|
+
const trace = createDeleteVsInsertTrace(
|
|
941
|
+
deleteOp,
|
|
942
|
+
insertOp,
|
|
943
|
+
relation,
|
|
944
|
+
"referential-set-null",
|
|
945
|
+
"allow-delete",
|
|
946
|
+
startTime
|
|
947
|
+
);
|
|
948
|
+
return {
|
|
949
|
+
action: "allow-delete",
|
|
950
|
+
sideEffects: [sideEffect],
|
|
951
|
+
trace
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
case "no-action": {
|
|
955
|
+
const trace = createDeleteVsInsertTrace(
|
|
956
|
+
deleteOp,
|
|
957
|
+
insertOp,
|
|
958
|
+
relation,
|
|
959
|
+
"referential-no-action",
|
|
960
|
+
"allow-delete",
|
|
961
|
+
startTime
|
|
962
|
+
);
|
|
963
|
+
return {
|
|
964
|
+
action: "allow-delete",
|
|
965
|
+
sideEffects: [],
|
|
966
|
+
trace
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function createReferentialTrace(deleteOp, relation, strategy, referencingRecordIds, sideEffects, allowed, startTime) {
|
|
972
|
+
return {
|
|
973
|
+
operationA: deleteOp,
|
|
974
|
+
// For referential integrity checks, operationB is the same as operationA
|
|
975
|
+
// because we are checking constraints against the delete operation itself,
|
|
976
|
+
// not merging two conflicting operations.
|
|
977
|
+
operationB: deleteOp,
|
|
978
|
+
field: `${relation.sourceCollection}.${relation.foreignKeyField}`,
|
|
979
|
+
strategy,
|
|
980
|
+
inputA: { recordId: deleteOp.recordId, collection: deleteOp.collection },
|
|
981
|
+
inputB: referencingRecordIds,
|
|
982
|
+
base: null,
|
|
983
|
+
output: { allowed, sideEffects },
|
|
984
|
+
tier: 2,
|
|
985
|
+
constraintViolated: `referential:${relation.relationName}`,
|
|
986
|
+
duration: Date.now() - startTime
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
function createDeleteVsInsertTrace(deleteOp, insertOp, relation, strategy, action, startTime) {
|
|
990
|
+
return {
|
|
991
|
+
operationA: deleteOp,
|
|
992
|
+
operationB: insertOp,
|
|
993
|
+
field: `${relation.sourceCollection}.${relation.foreignKeyField}`,
|
|
994
|
+
strategy,
|
|
995
|
+
inputA: { type: "delete", recordId: deleteOp.recordId, collection: deleteOp.collection },
|
|
996
|
+
inputB: { type: "insert", recordId: insertOp.recordId, collection: insertOp.collection },
|
|
997
|
+
base: null,
|
|
998
|
+
output: { action },
|
|
999
|
+
tier: 2,
|
|
1000
|
+
constraintViolated: `referential:${relation.relationName}`,
|
|
1001
|
+
duration: Date.now() - startTime
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// src/engine/merge-engine.ts
|
|
1006
|
+
import { HybridLogicalClock as HybridLogicalClock6 } from "@korajs/core";
|
|
1007
|
+
|
|
1008
|
+
// src/constraints/state-machine-constraint.ts
|
|
1009
|
+
import { HybridLogicalClock as HybridLogicalClock5, validateTransition } from "@korajs/core";
|
|
1010
|
+
function isValid(sm, from, to) {
|
|
1011
|
+
const constraint = {
|
|
1012
|
+
field: sm.field,
|
|
1013
|
+
collection: "",
|
|
1014
|
+
transitions: sm.transitions
|
|
1015
|
+
};
|
|
1016
|
+
return validateTransition(constraint, from, to).valid;
|
|
1017
|
+
}
|
|
1018
|
+
function resolveStateMachineMerge(fieldName, localOp, remoteOp, baseState, stateMachine) {
|
|
1019
|
+
const startTime = Date.now();
|
|
1020
|
+
const baseValue = baseState[fieldName];
|
|
1021
|
+
const localData = localOp.data ?? {};
|
|
1022
|
+
const remoteData = remoteOp.data ?? {};
|
|
1023
|
+
const localValue = localData[fieldName];
|
|
1024
|
+
const remoteValue = remoteData[fieldName];
|
|
1025
|
+
const baseStateStr = typeof baseValue === "string" ? baseValue : "";
|
|
1026
|
+
const localStr = typeof localValue === "string" ? localValue : baseStateStr;
|
|
1027
|
+
const remoteStr = typeof remoteValue === "string" ? remoteValue : baseStateStr;
|
|
1028
|
+
const localChanged = fieldName in localData;
|
|
1029
|
+
const remoteChanged = fieldName in remoteData;
|
|
1030
|
+
if (localChanged && !remoteChanged) {
|
|
1031
|
+
const valid = isValid(stateMachine, baseStateStr, localStr);
|
|
1032
|
+
return makeResult(
|
|
1033
|
+
valid ? localStr : baseStateStr,
|
|
1034
|
+
fieldName,
|
|
1035
|
+
localOp,
|
|
1036
|
+
remoteOp,
|
|
1037
|
+
localValue,
|
|
1038
|
+
baseValue,
|
|
1039
|
+
baseValue,
|
|
1040
|
+
valid ? "state-machine-no-conflict-local" : "state-machine-invalid-local",
|
|
1041
|
+
valid ? null : `Invalid transition from "${baseStateStr}" to "${localStr}"`,
|
|
1042
|
+
startTime
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
if (!localChanged && remoteChanged) {
|
|
1046
|
+
const valid = isValid(stateMachine, baseStateStr, remoteStr);
|
|
1047
|
+
return makeResult(
|
|
1048
|
+
valid ? remoteStr : baseStateStr,
|
|
1049
|
+
fieldName,
|
|
1050
|
+
localOp,
|
|
1051
|
+
remoteOp,
|
|
1052
|
+
baseValue,
|
|
1053
|
+
remoteValue,
|
|
1054
|
+
baseValue,
|
|
1055
|
+
valid ? "state-machine-no-conflict-remote" : "state-machine-invalid-remote",
|
|
1056
|
+
valid ? null : `Invalid transition from "${baseStateStr}" to "${remoteStr}"`,
|
|
1057
|
+
startTime
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
if (!localChanged && !remoteChanged) {
|
|
1061
|
+
return makeResult(
|
|
1062
|
+
baseStateStr,
|
|
1063
|
+
fieldName,
|
|
1064
|
+
localOp,
|
|
1065
|
+
remoteOp,
|
|
1066
|
+
baseValue,
|
|
1067
|
+
baseValue,
|
|
1068
|
+
baseValue,
|
|
1069
|
+
"state-machine-no-conflict-unchanged",
|
|
1070
|
+
null,
|
|
1071
|
+
startTime
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
const localValid = isValid(stateMachine, baseStateStr, localStr);
|
|
1075
|
+
const remoteValid = isValid(stateMachine, baseStateStr, remoteStr);
|
|
1076
|
+
if (localValid && remoteValid) {
|
|
1077
|
+
const comparison = HybridLogicalClock5.compare(localOp.timestamp, remoteOp.timestamp);
|
|
1078
|
+
const winner = comparison >= 0 ? localStr : remoteStr;
|
|
1079
|
+
return makeResult(
|
|
1080
|
+
winner,
|
|
1081
|
+
fieldName,
|
|
1082
|
+
localOp,
|
|
1083
|
+
remoteOp,
|
|
1084
|
+
localValue,
|
|
1085
|
+
remoteValue,
|
|
1086
|
+
baseValue,
|
|
1087
|
+
"state-machine-lww",
|
|
1088
|
+
null,
|
|
1089
|
+
startTime
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (localValid && !remoteValid) {
|
|
1093
|
+
return makeResult(
|
|
1094
|
+
localStr,
|
|
1095
|
+
fieldName,
|
|
1096
|
+
localOp,
|
|
1097
|
+
remoteOp,
|
|
1098
|
+
localValue,
|
|
1099
|
+
remoteValue,
|
|
1100
|
+
baseValue,
|
|
1101
|
+
"state-machine-valid-wins",
|
|
1102
|
+
`Remote transition from "${baseStateStr}" to "${remoteStr}" is invalid; local "${localStr}" wins`,
|
|
1103
|
+
startTime
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (!localValid && remoteValid) {
|
|
1107
|
+
return makeResult(
|
|
1108
|
+
remoteStr,
|
|
1109
|
+
fieldName,
|
|
1110
|
+
localOp,
|
|
1111
|
+
remoteOp,
|
|
1112
|
+
localValue,
|
|
1113
|
+
remoteValue,
|
|
1114
|
+
baseValue,
|
|
1115
|
+
"state-machine-valid-wins",
|
|
1116
|
+
`Local transition from "${baseStateStr}" to "${localStr}" is invalid; remote "${remoteStr}" wins`,
|
|
1117
|
+
startTime
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
return makeResult(
|
|
1121
|
+
baseStateStr,
|
|
1122
|
+
fieldName,
|
|
1123
|
+
localOp,
|
|
1124
|
+
remoteOp,
|
|
1125
|
+
localValue,
|
|
1126
|
+
remoteValue,
|
|
1127
|
+
baseValue,
|
|
1128
|
+
"state-machine-both-invalid",
|
|
1129
|
+
`Both transitions invalid from "${baseStateStr}": local to "${localStr}", remote to "${remoteStr}". Keeping base state.`,
|
|
1130
|
+
startTime
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
function isStateMachineField(collectionDef, fieldName) {
|
|
1134
|
+
return collectionDef.stateMachine !== void 0 && collectionDef.stateMachine.field === fieldName;
|
|
1135
|
+
}
|
|
1136
|
+
function makeResult(value, field, operationA, operationB, inputA, inputB, base, strategy, constraintViolated, startTime) {
|
|
1137
|
+
const trace = {
|
|
1138
|
+
operationA,
|
|
1139
|
+
operationB,
|
|
1140
|
+
field,
|
|
1141
|
+
strategy,
|
|
1142
|
+
inputA,
|
|
1143
|
+
inputB,
|
|
1144
|
+
base,
|
|
1145
|
+
output: value,
|
|
1146
|
+
tier: 2,
|
|
1147
|
+
constraintViolated,
|
|
1148
|
+
duration: Date.now() - startTime
|
|
1149
|
+
};
|
|
1150
|
+
return { value, trace };
|
|
1151
|
+
}
|
|
1152
|
+
|
|
543
1153
|
// src/engine/merge-engine.ts
|
|
544
|
-
import { HybridLogicalClock as HybridLogicalClock3 } from "@korajs/core";
|
|
545
1154
|
var MergeEngine = class {
|
|
546
1155
|
/**
|
|
547
1156
|
* Merge two concurrent operations with all three tiers.
|
|
@@ -563,7 +1172,8 @@ var MergeEngine = class {
|
|
|
563
1172
|
return {
|
|
564
1173
|
mergedData: {},
|
|
565
1174
|
traces: [],
|
|
566
|
-
appliedOperation: "merged"
|
|
1175
|
+
appliedOperation: "merged",
|
|
1176
|
+
sideEffects: []
|
|
567
1177
|
};
|
|
568
1178
|
}
|
|
569
1179
|
if (input.local.type === "delete" || input.remote.type === "delete") {
|
|
@@ -581,6 +1191,7 @@ var MergeEngine = class {
|
|
|
581
1191
|
);
|
|
582
1192
|
let mergedData = fieldResult.mergedData;
|
|
583
1193
|
const allTraces = [...fieldResult.traces];
|
|
1194
|
+
const allSideEffects = [...fieldResult.sideEffects];
|
|
584
1195
|
for (const violation of violations) {
|
|
585
1196
|
const resolution = resolveConstraintViolation(
|
|
586
1197
|
violation,
|
|
@@ -591,11 +1202,15 @@ var MergeEngine = class {
|
|
|
591
1202
|
);
|
|
592
1203
|
mergedData = resolution.resolvedRecord;
|
|
593
1204
|
allTraces.push(resolution.trace);
|
|
1205
|
+
if (resolution.sideEffects) {
|
|
1206
|
+
allSideEffects.push(...resolution.sideEffects);
|
|
1207
|
+
}
|
|
594
1208
|
}
|
|
595
1209
|
return {
|
|
596
1210
|
mergedData,
|
|
597
1211
|
traces: allTraces,
|
|
598
|
-
appliedOperation: determineAppliedOperation(allTraces)
|
|
1212
|
+
appliedOperation: determineAppliedOperation(allTraces),
|
|
1213
|
+
sideEffects: allSideEffects
|
|
599
1214
|
};
|
|
600
1215
|
}
|
|
601
1216
|
return fieldResult;
|
|
@@ -619,6 +1234,20 @@ var MergeEngine = class {
|
|
|
619
1234
|
if (fieldDef === void 0) {
|
|
620
1235
|
continue;
|
|
621
1236
|
}
|
|
1237
|
+
if (collectionDef.stateMachine !== void 0 && isStateMachineField(collectionDef, fieldName)) {
|
|
1238
|
+
const smResult = resolveStateMachineMerge(
|
|
1239
|
+
fieldName,
|
|
1240
|
+
local,
|
|
1241
|
+
remote,
|
|
1242
|
+
baseState,
|
|
1243
|
+
collectionDef.stateMachine
|
|
1244
|
+
);
|
|
1245
|
+
mergedData[fieldName] = smResult.value;
|
|
1246
|
+
if (smResult.trace.strategy !== "state-machine-no-conflict-unchanged") {
|
|
1247
|
+
traces.push(smResult.trace);
|
|
1248
|
+
}
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
622
1251
|
const resolver = collectionDef.resolvers[fieldName];
|
|
623
1252
|
const result = mergeField(fieldName, local, remote, baseState, fieldDef, resolver);
|
|
624
1253
|
mergedData[fieldName] = result.value;
|
|
@@ -629,7 +1258,8 @@ var MergeEngine = class {
|
|
|
629
1258
|
return {
|
|
630
1259
|
mergedData,
|
|
631
1260
|
traces,
|
|
632
|
-
appliedOperation: determineAppliedOperation(traces)
|
|
1261
|
+
appliedOperation: determineAppliedOperation(traces),
|
|
1262
|
+
sideEffects: []
|
|
633
1263
|
};
|
|
634
1264
|
}
|
|
635
1265
|
/**
|
|
@@ -638,24 +1268,26 @@ var MergeEngine = class {
|
|
|
638
1268
|
*/
|
|
639
1269
|
mergeWithDelete(input) {
|
|
640
1270
|
const { local, remote } = input;
|
|
641
|
-
const comparison =
|
|
1271
|
+
const comparison = HybridLogicalClock6.compare(local.timestamp, remote.timestamp);
|
|
642
1272
|
if (comparison >= 0) {
|
|
643
1273
|
if (local.type === "delete") {
|
|
644
|
-
return { mergedData: {}, traces: [], appliedOperation: "local" };
|
|
1274
|
+
return { mergedData: {}, traces: [], appliedOperation: "local", sideEffects: [] };
|
|
645
1275
|
}
|
|
646
1276
|
return {
|
|
647
1277
|
mergedData: { ...input.baseState, ...local.data ?? {} },
|
|
648
1278
|
traces: [],
|
|
649
|
-
appliedOperation: "local"
|
|
1279
|
+
appliedOperation: "local",
|
|
1280
|
+
sideEffects: []
|
|
650
1281
|
};
|
|
651
1282
|
}
|
|
652
1283
|
if (remote.type === "delete") {
|
|
653
|
-
return { mergedData: {}, traces: [], appliedOperation: "remote" };
|
|
1284
|
+
return { mergedData: {}, traces: [], appliedOperation: "remote", sideEffects: [] };
|
|
654
1285
|
}
|
|
655
1286
|
return {
|
|
656
1287
|
mergedData: { ...input.baseState, ...remote.data ?? {} },
|
|
657
1288
|
traces: [],
|
|
658
|
-
appliedOperation: "remote"
|
|
1289
|
+
appliedOperation: "remote",
|
|
1290
|
+
sideEffects: []
|
|
659
1291
|
};
|
|
660
1292
|
}
|
|
661
1293
|
};
|
|
@@ -707,12 +1339,22 @@ function determineAppliedOperation(traces) {
|
|
|
707
1339
|
export {
|
|
708
1340
|
MergeEngine,
|
|
709
1341
|
addWinsSet,
|
|
1342
|
+
appendOnlyMerge,
|
|
1343
|
+
applySchemaStrategy,
|
|
1344
|
+
buildMergeRelationLookup,
|
|
710
1345
|
checkConstraints,
|
|
1346
|
+
checkReferentialIntegrityOnDelete,
|
|
1347
|
+
counterMerge,
|
|
711
1348
|
lastWriteWins,
|
|
1349
|
+
maxMerge,
|
|
1350
|
+
mergeAtomicOps,
|
|
712
1351
|
mergeField,
|
|
713
1352
|
mergeRichtext,
|
|
1353
|
+
minMerge,
|
|
714
1354
|
resolveConstraintViolation,
|
|
1355
|
+
resolveDeleteVsInsertConflict,
|
|
715
1356
|
richtextToString,
|
|
1357
|
+
serverAuthoritativeMerge,
|
|
716
1358
|
stringToRichtextUpdate
|
|
717
1359
|
};
|
|
718
1360
|
//# sourceMappingURL=index.js.map
|