@unrdf/kgc-runtime 26.4.2
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/IMPLEMENTATION_SUMMARY.json +150 -0
- package/PLUGIN_SYSTEM_SUMMARY.json +149 -0
- package/README.md +98 -0
- package/TRANSACTION_IMPLEMENTATION.json +119 -0
- package/capability-map.md +93 -0
- package/docs/api-stability.md +269 -0
- package/docs/extensions/plugin-development.md +382 -0
- package/package.json +40 -0
- package/plugins/registry.json +35 -0
- package/src/admission-gate.mjs +414 -0
- package/src/api-version.mjs +373 -0
- package/src/atomic-admission.mjs +310 -0
- package/src/bounds.mjs +289 -0
- package/src/bulkhead-manager.mjs +280 -0
- package/src/capsule.mjs +524 -0
- package/src/crdt.mjs +361 -0
- package/src/enhanced-bounds.mjs +614 -0
- package/src/executor.mjs +73 -0
- package/src/freeze-restore.mjs +521 -0
- package/src/index.mjs +62 -0
- package/src/materialized-views.mjs +371 -0
- package/src/merge.mjs +472 -0
- package/src/plugin-isolation.mjs +392 -0
- package/src/plugin-manager.mjs +441 -0
- package/src/projections-api.mjs +336 -0
- package/src/projections-cli.mjs +238 -0
- package/src/projections-docs.mjs +300 -0
- package/src/projections-ide.mjs +278 -0
- package/src/receipt.mjs +340 -0
- package/src/rollback.mjs +258 -0
- package/src/saga-orchestrator.mjs +355 -0
- package/src/schemas.mjs +1330 -0
- package/src/storage-optimization.mjs +359 -0
- package/src/tool-registry.mjs +272 -0
- package/src/transaction.mjs +466 -0
- package/src/validators.mjs +485 -0
- package/src/work-item.mjs +449 -0
- package/templates/plugin-template/README.md +58 -0
- package/templates/plugin-template/index.mjs +162 -0
- package/templates/plugin-template/plugin.json +19 -0
- package/test/admission-gate.test.mjs +583 -0
- package/test/api-version.test.mjs +74 -0
- package/test/atomic-admission.test.mjs +155 -0
- package/test/bounds.test.mjs +341 -0
- package/test/bulkhead-manager.test.mjs +236 -0
- package/test/capsule.test.mjs +625 -0
- package/test/crdt.test.mjs +215 -0
- package/test/enhanced-bounds.test.mjs +487 -0
- package/test/freeze-restore.test.mjs +472 -0
- package/test/materialized-views.test.mjs +243 -0
- package/test/merge.test.mjs +665 -0
- package/test/plugin-isolation.test.mjs +109 -0
- package/test/plugin-manager.test.mjs +208 -0
- package/test/projections-api.test.mjs +293 -0
- package/test/projections-cli.test.mjs +204 -0
- package/test/projections-docs.test.mjs +173 -0
- package/test/projections-ide.test.mjs +230 -0
- package/test/receipt.test.mjs +295 -0
- package/test/rollback.test.mjs +132 -0
- package/test/saga-orchestrator.test.mjs +279 -0
- package/test/schemas.test.mjs +716 -0
- package/test/storage-optimization.test.mjs +503 -0
- package/test/tool-registry.test.mjs +341 -0
- package/test/transaction.test.mjs +189 -0
- package/test/validators.test.mjs +463 -0
- package/test/work-item.test.mjs +548 -0
- package/test/work-item.test.mjs.bak +548 -0
- package/var/kgc/test-atomic-log.json +519 -0
- package/var/kgc/test-cascading-log.json +145 -0
- package/vitest.config.mjs +18 -0
package/src/merge.mjs
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Multi-agent capsule merge and conflict resolution system
|
|
3
|
+
* Implements deterministic merge strategies for concurrent agent operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Schemas
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
const CapsuleSchema = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
o_hash: z.string(),
|
|
15
|
+
file_edits: z.array(
|
|
16
|
+
z.object({
|
|
17
|
+
file_path: z.string(),
|
|
18
|
+
line_start: z.number(),
|
|
19
|
+
line_end: z.number(),
|
|
20
|
+
content: z.string(),
|
|
21
|
+
operation: z.enum(['insert', 'replace', 'delete']).default('replace'),
|
|
22
|
+
})
|
|
23
|
+
),
|
|
24
|
+
metadata: z.object({}).passthrough().optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const ConflictRuleSchema = z.object({
|
|
28
|
+
strategy: z.enum(['earlier_wins', 'later_wins', 'lexicographic', 'merge_all']),
|
|
29
|
+
priority_field: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const TotalOrderSchema = z.object({
|
|
33
|
+
rules: z.array(ConflictRuleSchema),
|
|
34
|
+
default_rule: ConflictRuleSchema,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const ConflictReceiptSchema = z.object({
|
|
38
|
+
conflict_id: z.string(),
|
|
39
|
+
file_path: z.string(),
|
|
40
|
+
capsules_involved: z.array(z.string()),
|
|
41
|
+
line_ranges: z.array(
|
|
42
|
+
z.object({
|
|
43
|
+
capsule_id: z.string(),
|
|
44
|
+
line_start: z.number(),
|
|
45
|
+
line_end: z.number(),
|
|
46
|
+
})
|
|
47
|
+
),
|
|
48
|
+
resolution_rule: z.string(),
|
|
49
|
+
winner: z.string().optional(),
|
|
50
|
+
denied: z.array(z.string()).optional(),
|
|
51
|
+
timestamp: z.string(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const MergeResultSchema = z.object({
|
|
55
|
+
admitted: z.array(z.string()),
|
|
56
|
+
denied: z.array(z.string()),
|
|
57
|
+
conflict_receipts: z.array(ConflictReceiptSchema),
|
|
58
|
+
merged_state: z.record(z.string(), z.any()).optional(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Type definitions (JSDoc)
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {Object} FileEdit
|
|
67
|
+
* @property {string} file_path - Path to the file
|
|
68
|
+
* @property {number} line_start - Starting line number
|
|
69
|
+
* @property {number} line_end - Ending line number
|
|
70
|
+
* @property {string} content - Content to apply
|
|
71
|
+
* @property {'insert'|'replace'|'delete'} operation - Type of edit operation
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @typedef {Object} Capsule
|
|
76
|
+
* @property {string} id - Unique capsule identifier
|
|
77
|
+
* @property {string} o_hash - Ordering hash for deterministic resolution
|
|
78
|
+
* @property {FileEdit[]} file_edits - Array of file edits
|
|
79
|
+
* @property {Object} [metadata] - Optional metadata
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {Object} ConflictRule
|
|
84
|
+
* @property {'earlier_wins'|'later_wins'|'lexicographic'|'merge_all'} strategy - Resolution strategy
|
|
85
|
+
* @property {string} [priority_field] - Field to use for priority
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @typedef {Object} TotalOrder
|
|
90
|
+
* @property {ConflictRule[]} rules - Array of resolution rules
|
|
91
|
+
* @property {ConflictRule} default_rule - Default resolution rule
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {Object} ConflictRegion
|
|
96
|
+
* @property {string} file_path - Path to the file
|
|
97
|
+
* @property {number} line_start - Starting line of conflict
|
|
98
|
+
* @property {number} line_end - Ending line of conflict
|
|
99
|
+
* @property {Array<{capsule_id: string, edit: FileEdit}>} overlapping_edits - Conflicting edits
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {Object} ConflictReceipt
|
|
104
|
+
* @property {string} conflict_id - Unique conflict identifier
|
|
105
|
+
* @property {string} file_path - Path to the conflicting file
|
|
106
|
+
* @property {string[]} capsules_involved - IDs of capsules in conflict
|
|
107
|
+
* @property {Array<{capsule_id: string, line_start: number, line_end: number}>} line_ranges - Line ranges for each capsule
|
|
108
|
+
* @property {string} resolution_rule - Rule applied to resolve
|
|
109
|
+
* @property {string} [winner] - ID of winning capsule
|
|
110
|
+
* @property {string[]} [denied] - IDs of denied capsules
|
|
111
|
+
* @property {string} timestamp - ISO timestamp of resolution
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {Object} MergeResult
|
|
116
|
+
* @property {string[]} admitted - IDs of admitted capsules
|
|
117
|
+
* @property {string[]} denied - IDs of denied capsules
|
|
118
|
+
* @property {ConflictReceipt[]} conflict_receipts - Array of conflict receipts
|
|
119
|
+
* @property {Object} [merged_state] - Optional merged state object
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// ConflictDetector
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Detects conflicts between capsule file edits
|
|
128
|
+
*/
|
|
129
|
+
export class ConflictDetector {
|
|
130
|
+
/**
|
|
131
|
+
* Check if two line ranges overlap
|
|
132
|
+
* @param {number} start1 - Start of first range
|
|
133
|
+
* @param {number} end1 - End of first range
|
|
134
|
+
* @param {number} start2 - Start of second range
|
|
135
|
+
* @param {number} end2 - End of second range
|
|
136
|
+
* @returns {boolean} True if ranges overlap
|
|
137
|
+
*/
|
|
138
|
+
static rangesOverlap(start1, end1, start2, end2) {
|
|
139
|
+
return start1 <= end2 && start2 <= end1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect conflicts in an array of capsules
|
|
144
|
+
* @param {Capsule[]} capsules - Array of capsules to check
|
|
145
|
+
* @returns {ConflictRegion[]} Array of detected conflicts
|
|
146
|
+
*/
|
|
147
|
+
static detectConflicts(capsules) {
|
|
148
|
+
const conflicts = [];
|
|
149
|
+
const fileEditMap = new Map();
|
|
150
|
+
|
|
151
|
+
// Group edits by file path
|
|
152
|
+
for (const capsule of capsules) {
|
|
153
|
+
for (const edit of capsule.file_edits) {
|
|
154
|
+
if (!fileEditMap.has(edit.file_path)) {
|
|
155
|
+
fileEditMap.set(edit.file_path, []);
|
|
156
|
+
}
|
|
157
|
+
fileEditMap.get(edit.file_path).push({
|
|
158
|
+
capsule_id: capsule.id,
|
|
159
|
+
edit,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check for overlaps within each file
|
|
165
|
+
for (const [file_path, edits] of fileEditMap.entries()) {
|
|
166
|
+
if (edits.length < 2) continue;
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < edits.length; i++) {
|
|
169
|
+
const overlapping = [edits[i]];
|
|
170
|
+
|
|
171
|
+
for (let j = i + 1; j < edits.length; j++) {
|
|
172
|
+
if (
|
|
173
|
+
this.rangesOverlap(
|
|
174
|
+
edits[i].edit.line_start,
|
|
175
|
+
edits[i].edit.line_end,
|
|
176
|
+
edits[j].edit.line_start,
|
|
177
|
+
edits[j].edit.line_end
|
|
178
|
+
)
|
|
179
|
+
) {
|
|
180
|
+
overlapping.push(edits[j]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (overlapping.length > 1) {
|
|
185
|
+
// Create conflict region
|
|
186
|
+
const line_start = Math.min(...overlapping.map((e) => e.edit.line_start));
|
|
187
|
+
const line_end = Math.max(...overlapping.map((e) => e.edit.line_end));
|
|
188
|
+
|
|
189
|
+
conflicts.push({
|
|
190
|
+
file_path,
|
|
191
|
+
line_start,
|
|
192
|
+
line_end,
|
|
193
|
+
overlapping_edits: overlapping,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Skip already processed edits
|
|
197
|
+
i += overlapping.length - 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return conflicts;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Create a conflict receipt for a detected conflict
|
|
207
|
+
* @param {ConflictRegion} conflict - Detected conflict region
|
|
208
|
+
* @param {string} resolution_rule - Rule applied to resolve
|
|
209
|
+
* @param {string} [winner] - ID of winning capsule
|
|
210
|
+
* @param {string[]} [denied] - IDs of denied capsules
|
|
211
|
+
* @returns {ConflictReceipt} Conflict receipt
|
|
212
|
+
*/
|
|
213
|
+
static createReceipt(conflict, resolution_rule, winner = null, denied = []) {
|
|
214
|
+
const capsules_involved = conflict.overlapping_edits.map((e) => e.capsule_id);
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
conflict_id: `conflict_${conflict.file_path}_${conflict.line_start}_${Date.now()}`,
|
|
218
|
+
file_path: conflict.file_path,
|
|
219
|
+
capsules_involved,
|
|
220
|
+
line_ranges: conflict.overlapping_edits.map((e) => ({
|
|
221
|
+
capsule_id: e.capsule_id,
|
|
222
|
+
line_start: e.edit.line_start,
|
|
223
|
+
line_end: e.edit.line_end,
|
|
224
|
+
})),
|
|
225
|
+
resolution_rule,
|
|
226
|
+
winner,
|
|
227
|
+
denied,
|
|
228
|
+
timestamp: new Date().toISOString(),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ============================================================================
|
|
234
|
+
// Resolution Strategies
|
|
235
|
+
// ============================================================================
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resolves conflicts using deterministic rules
|
|
239
|
+
*/
|
|
240
|
+
export class ConflictResolver {
|
|
241
|
+
/**
|
|
242
|
+
* Apply earlier_wins strategy - earlier o_hash wins
|
|
243
|
+
* @param {ConflictRegion} conflict - Conflict to resolve
|
|
244
|
+
* @param {Capsule[]} capsules - All capsules
|
|
245
|
+
* @returns {{winner: string, denied: string[]}} Resolution result
|
|
246
|
+
*/
|
|
247
|
+
static earlierWins(conflict, capsules) {
|
|
248
|
+
const capsuleMap = new Map(capsules.map((c) => [c.id, c]));
|
|
249
|
+
const involved = conflict.overlapping_edits
|
|
250
|
+
.map((e) => ({
|
|
251
|
+
id: e.capsule_id,
|
|
252
|
+
hash: capsuleMap.get(e.capsule_id)?.o_hash || '',
|
|
253
|
+
}))
|
|
254
|
+
.sort((a, b) => a.hash.localeCompare(b.hash));
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
winner: involved[0].id,
|
|
258
|
+
denied: involved.slice(1).map((c) => c.id),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Apply lexicographic strategy - lexicographically first capsule_id wins
|
|
264
|
+
* @param {ConflictRegion} conflict - Conflict to resolve
|
|
265
|
+
* @returns {{winner: string, denied: string[]}} Resolution result
|
|
266
|
+
*/
|
|
267
|
+
static lexicographic(conflict) {
|
|
268
|
+
const involved = conflict.overlapping_edits
|
|
269
|
+
.map((e) => e.capsule_id)
|
|
270
|
+
.sort((a, b) => a.localeCompare(b));
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
winner: involved[0],
|
|
274
|
+
denied: involved.slice(1),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Apply later_wins strategy - later o_hash wins
|
|
280
|
+
* @param {ConflictRegion} conflict - Conflict to resolve
|
|
281
|
+
* @param {Capsule[]} capsules - All capsules
|
|
282
|
+
* @returns {{winner: string, denied: string[]}} Resolution result
|
|
283
|
+
*/
|
|
284
|
+
static laterWins(conflict, capsules) {
|
|
285
|
+
const capsuleMap = new Map(capsules.map((c) => [c.id, c]));
|
|
286
|
+
const involved = conflict.overlapping_edits
|
|
287
|
+
.map((e) => ({
|
|
288
|
+
id: e.capsule_id,
|
|
289
|
+
hash: capsuleMap.get(e.capsule_id)?.o_hash || '',
|
|
290
|
+
}))
|
|
291
|
+
.sort((a, b) => b.hash.localeCompare(a.hash));
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
winner: involved[0].id,
|
|
295
|
+
denied: involved.slice(1).map((c) => c.id),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolve conflict using specified rule
|
|
301
|
+
* @param {ConflictRegion} conflict - Conflict to resolve
|
|
302
|
+
* @param {ConflictRule} rule - Resolution rule
|
|
303
|
+
* @param {Capsule[]} capsules - All capsules
|
|
304
|
+
* @returns {{winner: string, denied: string[], rule: string}} Resolution result
|
|
305
|
+
*/
|
|
306
|
+
static resolveConflict(conflict, rule, capsules) {
|
|
307
|
+
let result;
|
|
308
|
+
|
|
309
|
+
switch (rule.strategy) {
|
|
310
|
+
case 'earlier_wins':
|
|
311
|
+
result = this.earlierWins(conflict, capsules);
|
|
312
|
+
break;
|
|
313
|
+
case 'later_wins':
|
|
314
|
+
result = this.laterWins(conflict, capsules);
|
|
315
|
+
break;
|
|
316
|
+
case 'lexicographic':
|
|
317
|
+
result = this.lexicographic(conflict);
|
|
318
|
+
break;
|
|
319
|
+
case 'merge_all':
|
|
320
|
+
// Admit all capsules, no winner
|
|
321
|
+
return {
|
|
322
|
+
winner: null,
|
|
323
|
+
denied: [],
|
|
324
|
+
rule: rule.strategy,
|
|
325
|
+
};
|
|
326
|
+
default:
|
|
327
|
+
// Default to earlier_wins
|
|
328
|
+
result = this.earlierWins(conflict, capsules);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
...result,
|
|
333
|
+
rule: rule.strategy,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ============================================================================
|
|
339
|
+
// ShardMerge
|
|
340
|
+
// ============================================================================
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Performs shard merge with deterministic conflict resolution
|
|
344
|
+
* @param {Capsule[]} capsules - Array of capsules to merge
|
|
345
|
+
* @param {TotalOrder} totalOrder - Ordering rules for resolution
|
|
346
|
+
* @returns {{merged_state: Object|null, conflict_receipts: ConflictReceipt[]}} Merge result
|
|
347
|
+
*/
|
|
348
|
+
export function shardMerge(capsules, totalOrder) {
|
|
349
|
+
// Validate inputs
|
|
350
|
+
const validatedCapsules = z.array(CapsuleSchema).parse(capsules);
|
|
351
|
+
const validatedOrder = TotalOrderSchema.parse(totalOrder);
|
|
352
|
+
|
|
353
|
+
// Detect conflicts
|
|
354
|
+
const conflicts = ConflictDetector.detectConflicts(validatedCapsules);
|
|
355
|
+
|
|
356
|
+
if (conflicts.length === 0) {
|
|
357
|
+
// No conflicts - simple merge
|
|
358
|
+
const merged_state = {};
|
|
359
|
+
for (const capsule of validatedCapsules) {
|
|
360
|
+
merged_state[capsule.id] = {
|
|
361
|
+
file_edits: capsule.file_edits,
|
|
362
|
+
metadata: capsule.metadata,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return { merged_state, conflict_receipts: [] };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Resolve conflicts
|
|
369
|
+
const conflict_receipts = [];
|
|
370
|
+
const denied_capsules = new Set();
|
|
371
|
+
|
|
372
|
+
for (const conflict of conflicts) {
|
|
373
|
+
const rule = validatedOrder.default_rule;
|
|
374
|
+
const resolution = ConflictResolver.resolveConflict(
|
|
375
|
+
conflict,
|
|
376
|
+
rule,
|
|
377
|
+
validatedCapsules
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// For merge_all strategy, don't deny any capsules - just emit receipts
|
|
381
|
+
if (rule.strategy !== 'merge_all' && resolution.denied) {
|
|
382
|
+
resolution.denied.forEach((id) => denied_capsules.add(id));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Create receipt
|
|
386
|
+
const receipt = ConflictDetector.createReceipt(
|
|
387
|
+
conflict,
|
|
388
|
+
resolution.rule,
|
|
389
|
+
resolution.winner,
|
|
390
|
+
resolution.denied
|
|
391
|
+
);
|
|
392
|
+
conflict_receipts.push(receipt);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Build merged state
|
|
396
|
+
const merged_state = {};
|
|
397
|
+
for (const capsule of validatedCapsules) {
|
|
398
|
+
// For merge_all, include all capsules; otherwise only admitted
|
|
399
|
+
if (validatedOrder.default_rule.strategy === 'merge_all' || !denied_capsules.has(capsule.id)) {
|
|
400
|
+
merged_state[capsule.id] = {
|
|
401
|
+
file_edits: capsule.file_edits,
|
|
402
|
+
metadata: capsule.metadata,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { merged_state, conflict_receipts };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ============================================================================
|
|
411
|
+
// mergeCapsules - Main API
|
|
412
|
+
// ============================================================================
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Merge multiple capsules with conflict resolution
|
|
416
|
+
* @param {Capsule[]} capsules - Array of capsules to merge
|
|
417
|
+
* @param {TotalOrder} totalOrder - Ordering rules for resolution
|
|
418
|
+
* @returns {MergeResult} Merge result with admitted/denied capsules and receipts
|
|
419
|
+
*/
|
|
420
|
+
export function mergeCapsules(capsules, totalOrder) {
|
|
421
|
+
try {
|
|
422
|
+
// Validate inputs
|
|
423
|
+
const validatedCapsules = z.array(CapsuleSchema).parse(capsules);
|
|
424
|
+
const validatedOrder = TotalOrderSchema.parse(totalOrder);
|
|
425
|
+
|
|
426
|
+
// Perform shard merge
|
|
427
|
+
const { merged_state, conflict_receipts } = shardMerge(
|
|
428
|
+
validatedCapsules,
|
|
429
|
+
validatedOrder
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// Determine admitted and denied
|
|
433
|
+
const admitted = [];
|
|
434
|
+
const denied = [];
|
|
435
|
+
|
|
436
|
+
const deniedSet = new Set();
|
|
437
|
+
for (const receipt of conflict_receipts) {
|
|
438
|
+
if (receipt.denied) {
|
|
439
|
+
receipt.denied.forEach((id) => deniedSet.add(id));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const capsule of validatedCapsules) {
|
|
444
|
+
if (deniedSet.has(capsule.id)) {
|
|
445
|
+
denied.push(capsule.id);
|
|
446
|
+
} else {
|
|
447
|
+
admitted.push(capsule.id);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
admitted,
|
|
453
|
+
denied,
|
|
454
|
+
conflict_receipts,
|
|
455
|
+
merged_state,
|
|
456
|
+
};
|
|
457
|
+
} catch (error) {
|
|
458
|
+
// Handle validation errors
|
|
459
|
+
throw new Error(`Merge failed: ${error.message}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ============================================================================
|
|
464
|
+
// Exports
|
|
465
|
+
// ============================================================================
|
|
466
|
+
|
|
467
|
+
export default {
|
|
468
|
+
shardMerge,
|
|
469
|
+
mergeCapsules,
|
|
470
|
+
ConflictDetector,
|
|
471
|
+
ConflictResolver,
|
|
472
|
+
};
|