@korajs/merge 0.3.1 → 0.4.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 +650 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +206 -2
- package/dist/index.d.ts +206 -2
- package/dist/index.js +640 -7
- package/dist/index.js.map +1 -1
- package/package.json +2 -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.
|
|
@@ -619,6 +1228,20 @@ var MergeEngine = class {
|
|
|
619
1228
|
if (fieldDef === void 0) {
|
|
620
1229
|
continue;
|
|
621
1230
|
}
|
|
1231
|
+
if (collectionDef.stateMachine !== void 0 && isStateMachineField(collectionDef, fieldName)) {
|
|
1232
|
+
const smResult = resolveStateMachineMerge(
|
|
1233
|
+
fieldName,
|
|
1234
|
+
local,
|
|
1235
|
+
remote,
|
|
1236
|
+
baseState,
|
|
1237
|
+
collectionDef.stateMachine
|
|
1238
|
+
);
|
|
1239
|
+
mergedData[fieldName] = smResult.value;
|
|
1240
|
+
if (smResult.trace.strategy !== "state-machine-no-conflict-unchanged") {
|
|
1241
|
+
traces.push(smResult.trace);
|
|
1242
|
+
}
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
622
1245
|
const resolver = collectionDef.resolvers[fieldName];
|
|
623
1246
|
const result = mergeField(fieldName, local, remote, baseState, fieldDef, resolver);
|
|
624
1247
|
mergedData[fieldName] = result.value;
|
|
@@ -638,7 +1261,7 @@ var MergeEngine = class {
|
|
|
638
1261
|
*/
|
|
639
1262
|
mergeWithDelete(input) {
|
|
640
1263
|
const { local, remote } = input;
|
|
641
|
-
const comparison =
|
|
1264
|
+
const comparison = HybridLogicalClock6.compare(local.timestamp, remote.timestamp);
|
|
642
1265
|
if (comparison >= 0) {
|
|
643
1266
|
if (local.type === "delete") {
|
|
644
1267
|
return { mergedData: {}, traces: [], appliedOperation: "local" };
|
|
@@ -707,12 +1330,22 @@ function determineAppliedOperation(traces) {
|
|
|
707
1330
|
export {
|
|
708
1331
|
MergeEngine,
|
|
709
1332
|
addWinsSet,
|
|
1333
|
+
appendOnlyMerge,
|
|
1334
|
+
applySchemaStrategy,
|
|
1335
|
+
buildMergeRelationLookup,
|
|
710
1336
|
checkConstraints,
|
|
1337
|
+
checkReferentialIntegrityOnDelete,
|
|
1338
|
+
counterMerge,
|
|
711
1339
|
lastWriteWins,
|
|
1340
|
+
maxMerge,
|
|
1341
|
+
mergeAtomicOps,
|
|
712
1342
|
mergeField,
|
|
713
1343
|
mergeRichtext,
|
|
1344
|
+
minMerge,
|
|
714
1345
|
resolveConstraintViolation,
|
|
1346
|
+
resolveDeleteVsInsertConflict,
|
|
715
1347
|
richtextToString,
|
|
1348
|
+
serverAuthoritativeMerge,
|
|
716
1349
|
stringToRichtextUpdate
|
|
717
1350
|
};
|
|
718
1351
|
//# sourceMappingURL=index.js.map
|