amalgm 0.1.155 → 0.1.156
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/lib/shared-realtime-tunnel.js +518 -0
- package/lib/tunnel-events.js +27 -0
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +75 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +187 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +144 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +230 -24
- package/runtime/scripts/amalgm-mcp/state/events.js +20 -2
- package/runtime/scripts/amalgm-mcp/state/mutations.js +800 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +271 -0
- package/runtime/scripts/amalgm-mcp/state/replicas.js +200 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +267 -0
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +337 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +283 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runtime-internal HTTP boundary for cloud-shared resources.
|
|
5
|
+
*
|
|
6
|
+
* Domain rules stay in mutations/replicas/promotions/attachments/docs. This
|
|
7
|
+
* file only translates the authenticated local state routes into those APIs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require('crypto');
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
|
|
13
|
+
function sendHandlerError(error, sendJson) {
|
|
14
|
+
const statusCode = Number(error?.statusCode) || 500;
|
|
15
|
+
sendJson(statusCode, { error: error?.message || 'internal error' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parsePositiveInt(value, fallback) {
|
|
19
|
+
const parsed = Number(value);
|
|
20
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function handleReplicaRegister(body, sendJson) {
|
|
24
|
+
try {
|
|
25
|
+
const opened = require('./docs').openDoc(body?.path);
|
|
26
|
+
sendJson(200, require('./replicas').registerReplica({
|
|
27
|
+
resourceId: body?.resourceId,
|
|
28
|
+
localResource: opened.resource,
|
|
29
|
+
localPath: opened.path,
|
|
30
|
+
contract: body?.contract,
|
|
31
|
+
schemaVersion: body?.schemaVersion,
|
|
32
|
+
authorityEpoch: body?.authorityEpoch,
|
|
33
|
+
appliedVersion: body?.appliedVersion,
|
|
34
|
+
snapshotVersion: body?.snapshotVersion,
|
|
35
|
+
snapshotChecksum: body?.snapshotChecksum,
|
|
36
|
+
}));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
sendHandlerError(error, sendJson);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handleReplicaList(sendJson) {
|
|
43
|
+
try {
|
|
44
|
+
sendJson(200, { replicas: require('./replicas').listReplicas() });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
sendHandlerError(error, sendJson);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function handleReplicaAuthority(body, sendJson) {
|
|
51
|
+
try {
|
|
52
|
+
sendJson(200, require('./replicas').refreshReplicaAuthority({
|
|
53
|
+
resourceId: body?.resourceId,
|
|
54
|
+
authorityEpoch: body?.authorityEpoch,
|
|
55
|
+
headVersion: body?.headVersion,
|
|
56
|
+
contract: body?.contract,
|
|
57
|
+
schemaVersion: body?.schemaVersion,
|
|
58
|
+
}));
|
|
59
|
+
} catch (error) {
|
|
60
|
+
sendHandlerError(error, sendJson);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function handlePromotionStart(body, sendJson) {
|
|
65
|
+
try {
|
|
66
|
+
const opened = require('./docs').openDoc(body?.path);
|
|
67
|
+
const result = require('./promotions').enqueuePromotion({
|
|
68
|
+
promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
69
|
+
resourceId: body?.resourceId || `shr_${crypto.randomBytes(18).toString('base64url')}`,
|
|
70
|
+
localResource: opened.resource,
|
|
71
|
+
localPath: opened.path,
|
|
72
|
+
contract: body?.contract || 'text-yjs@1',
|
|
73
|
+
schemaVersion: body?.schemaVersion || 1,
|
|
74
|
+
snapshotBase64: opened.state,
|
|
75
|
+
contentBytes: fs.statSync(opened.path).size,
|
|
76
|
+
name: body?.name,
|
|
77
|
+
mediaType: body?.mediaType || 'application/vnd.amalgm.text-yjs-snapshot',
|
|
78
|
+
});
|
|
79
|
+
sendJson(result.duplicate ? 200 : 202, result);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
sendHandlerError(error, sendJson);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function handlePromotionGet(promotionId, sendJson) {
|
|
86
|
+
try {
|
|
87
|
+
const promotion = require('./promotions').getPromotion(promotionId);
|
|
88
|
+
if (!promotion) {
|
|
89
|
+
sendJson(404, { error: `unknown promotion ${promotionId}` });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
sendJson(200, { promotion });
|
|
93
|
+
} catch (error) {
|
|
94
|
+
sendHandlerError(error, sendJson);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function handlePromotionClaim(body, sendJson) {
|
|
99
|
+
try {
|
|
100
|
+
sendJson(200, { promotions: require('./promotions').claimPromotions({
|
|
101
|
+
limit: parsePositiveInt(body?.limit, 4),
|
|
102
|
+
leaseMs: parsePositiveInt(body?.leaseMs, 30_000),
|
|
103
|
+
}) });
|
|
104
|
+
} catch (error) {
|
|
105
|
+
sendHandlerError(error, sendJson);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function handlePromotionAck(body, sendJson) {
|
|
110
|
+
try {
|
|
111
|
+
sendJson(200, require('./promotions').acknowledgePromotion(body?.promotionId, {
|
|
112
|
+
resourceId: body?.resourceId,
|
|
113
|
+
authorityEpoch: body?.authorityEpoch,
|
|
114
|
+
headVersion: body?.headVersion,
|
|
115
|
+
snapshotVersion: body?.snapshotVersion,
|
|
116
|
+
snapshotKey: body?.snapshotKey,
|
|
117
|
+
snapshotChecksum: body?.snapshotChecksum,
|
|
118
|
+
}));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
sendHandlerError(error, sendJson);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function handlePromotionFail(body, sendJson) {
|
|
125
|
+
try {
|
|
126
|
+
sendJson(200, require('./promotions').failPromotion(body?.promotionId, {
|
|
127
|
+
message: body?.message,
|
|
128
|
+
retryable: body?.retryable,
|
|
129
|
+
}));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
sendHandlerError(error, sendJson);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handleAttachmentStart(body, sendJson) {
|
|
136
|
+
try {
|
|
137
|
+
const opened = require('./docs').openDoc(body?.path);
|
|
138
|
+
if (require('./docs').getDocText(opened.path).length > 0) {
|
|
139
|
+
sendJson(409, { error: 'cloud attachment requires an empty local text file' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const result = require('./attachments').enqueueAttachment({
|
|
143
|
+
attachmentId: body?.attachmentId || `att_${crypto.randomBytes(16).toString('base64url')}`,
|
|
144
|
+
resourceId: body?.resourceId,
|
|
145
|
+
localPath: opened.path,
|
|
146
|
+
});
|
|
147
|
+
sendJson(result.duplicate ? 200 : 202, result);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
sendHandlerError(error, sendJson);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function handleAttachmentGet(attachmentId, sendJson) {
|
|
154
|
+
try {
|
|
155
|
+
const attachment = require('./attachments').getAttachment(attachmentId);
|
|
156
|
+
if (!attachment) {
|
|
157
|
+
sendJson(404, { error: `unknown attachment ${attachmentId}` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
sendJson(200, { attachment });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
sendHandlerError(error, sendJson);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function handleAttachmentClaim(body, sendJson) {
|
|
167
|
+
try {
|
|
168
|
+
sendJson(200, { attachments: require('./attachments').claimAttachments({
|
|
169
|
+
limit: parsePositiveInt(body?.limit, 4),
|
|
170
|
+
leaseMs: parsePositiveInt(body?.leaseMs, 30_000),
|
|
171
|
+
}) });
|
|
172
|
+
} catch (error) {
|
|
173
|
+
sendHandlerError(error, sendJson);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function handleAttachmentAck(body, sendJson) {
|
|
178
|
+
try {
|
|
179
|
+
sendJson(200, require('./attachments').acknowledgeAttachment(body?.attachmentId, {
|
|
180
|
+
resourceId: body?.resourceId,
|
|
181
|
+
authorityEpoch: body?.authorityEpoch,
|
|
182
|
+
headVersion: body?.headVersion,
|
|
183
|
+
snapshotVersion: body?.snapshotVersion,
|
|
184
|
+
snapshotChecksum: body?.snapshotChecksum,
|
|
185
|
+
snapshotBase64: body?.snapshotBase64,
|
|
186
|
+
contract: body?.contract,
|
|
187
|
+
schemaVersion: body?.schemaVersion,
|
|
188
|
+
}));
|
|
189
|
+
} catch (error) {
|
|
190
|
+
sendHandlerError(error, sendJson);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function handleAttachmentFail(body, sendJson) {
|
|
195
|
+
try {
|
|
196
|
+
sendJson(200, require('./attachments').failAttachment(body?.attachmentId, {
|
|
197
|
+
message: body?.message,
|
|
198
|
+
retryable: body?.retryable,
|
|
199
|
+
}));
|
|
200
|
+
} catch (error) {
|
|
201
|
+
sendHandlerError(error, sendJson);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function handleMutationClaim(body, sendJson) {
|
|
206
|
+
try {
|
|
207
|
+
const mutations = require('./mutations').claimCloudMutations({
|
|
208
|
+
limit: parsePositiveInt(body?.limit, 100),
|
|
209
|
+
leaseMs: parsePositiveInt(body?.leaseMs, 30_000),
|
|
210
|
+
});
|
|
211
|
+
sendJson(200, { mutations });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
sendHandlerError(error, sendJson);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function handleMutationAck(body, sendJson) {
|
|
218
|
+
try {
|
|
219
|
+
sendJson(200, require('./mutations').acknowledgeCloudMutation(
|
|
220
|
+
body?.resourceId,
|
|
221
|
+
body?.mutationId,
|
|
222
|
+
{ version: body?.version, committedAt: body?.committedAt },
|
|
223
|
+
));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
sendHandlerError(error, sendJson);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function handleMutationFail(body, sendJson) {
|
|
230
|
+
try {
|
|
231
|
+
sendJson(200, require('./mutations').failCloudMutation(
|
|
232
|
+
body?.resourceId,
|
|
233
|
+
body?.mutationId,
|
|
234
|
+
{ message: body?.message, retryable: body?.retryable, retryAt: body?.retryAt },
|
|
235
|
+
));
|
|
236
|
+
} catch (error) {
|
|
237
|
+
sendHandlerError(error, sendJson);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function handleMutationIngest(body, sendJson) {
|
|
242
|
+
try {
|
|
243
|
+
sendJson(200, require('./docs').applyCloudDocMutation(body));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
sendHandlerError(error, sendJson);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
handleAttachmentAck,
|
|
251
|
+
handleAttachmentClaim,
|
|
252
|
+
handleAttachmentFail,
|
|
253
|
+
handleAttachmentGet,
|
|
254
|
+
handleAttachmentStart,
|
|
255
|
+
handleMutationAck,
|
|
256
|
+
handleMutationClaim,
|
|
257
|
+
handleMutationFail,
|
|
258
|
+
handleMutationIngest,
|
|
259
|
+
handlePromotionAck,
|
|
260
|
+
handlePromotionClaim,
|
|
261
|
+
handlePromotionFail,
|
|
262
|
+
handlePromotionGet,
|
|
263
|
+
handlePromotionStart,
|
|
264
|
+
handleReplicaAuthority,
|
|
265
|
+
handleReplicaList,
|
|
266
|
+
handleReplicaRegister,
|
|
267
|
+
};
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-state-mutations-test-'));
|
|
10
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
11
|
+
process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS = '0';
|
|
12
|
+
|
|
13
|
+
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
14
|
+
const {
|
|
15
|
+
appendStateEvent,
|
|
16
|
+
listEventsAfter,
|
|
17
|
+
subscribeStateEvents,
|
|
18
|
+
} = require('../state/events');
|
|
19
|
+
const {
|
|
20
|
+
acknowledgeCloudMutation,
|
|
21
|
+
claimCloudMutations,
|
|
22
|
+
commitLocalMutation,
|
|
23
|
+
completeMaterializedMutation,
|
|
24
|
+
failCloudMutation,
|
|
25
|
+
getMutation,
|
|
26
|
+
journalCloudMutation,
|
|
27
|
+
listMutations,
|
|
28
|
+
pruneMutationJournal,
|
|
29
|
+
} = require('../state/mutations');
|
|
30
|
+
const { getReplicaByResourceId, registerReplica } = require('../state/replicas');
|
|
31
|
+
|
|
32
|
+
const localInput = {
|
|
33
|
+
channelId: 'doc:local-journal-test',
|
|
34
|
+
localResource: 'doc:local-journal-test',
|
|
35
|
+
mutationId: 'mut_local_00000001',
|
|
36
|
+
contract: 'text-yjs@1',
|
|
37
|
+
schemaVersion: 1,
|
|
38
|
+
operationKind: 'delta',
|
|
39
|
+
operation: { kind: 'delta', yjs: 'AQID' },
|
|
40
|
+
localRevision: 1,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const sharedResourceId = 'shr_abcdefghijklmnop';
|
|
44
|
+
const sharedInput = {
|
|
45
|
+
channelId: `resource:${sharedResourceId}`,
|
|
46
|
+
localResource: 'doc:shared-journal-test',
|
|
47
|
+
sharedResourceId,
|
|
48
|
+
mutationId: 'mut_shared_0000001',
|
|
49
|
+
deviceId: 'device-test-1',
|
|
50
|
+
authorityEpoch: 1,
|
|
51
|
+
baseCloudVersion: 0,
|
|
52
|
+
contract: 'text-yjs@1',
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
operationKind: 'delta',
|
|
55
|
+
operation: { kind: 'delta', yjs: 'BAUG' },
|
|
56
|
+
localRevision: 1,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function eventFor(input) {
|
|
60
|
+
return {
|
|
61
|
+
resource: input.localResource,
|
|
62
|
+
op: 'update',
|
|
63
|
+
id: `/tmp/${input.mutationId}.md`,
|
|
64
|
+
patch: { yjs: input.operation.yjs },
|
|
65
|
+
source: 'test:mutation',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test.after(() => {
|
|
70
|
+
closeLocalDb();
|
|
71
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('legacy Local Live events keep their exact optional-field shape', () => {
|
|
75
|
+
const event = appendStateEvent({
|
|
76
|
+
resource: 'apps',
|
|
77
|
+
op: 'replace',
|
|
78
|
+
value: [{ id: 'legacy' }],
|
|
79
|
+
source: 'test',
|
|
80
|
+
});
|
|
81
|
+
assert.deepEqual(Object.keys(event), ['seq', 'ts', 'resource', 'op', 'source', 'value']);
|
|
82
|
+
assert.equal(event.mutationId, undefined);
|
|
83
|
+
assert.equal(event.sharedResourceId, undefined);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('one local mutation is persisted, materialized, then projected once', () => {
|
|
87
|
+
const db = openLocalDb();
|
|
88
|
+
const target = path.join(tempRoot, 'local.md');
|
|
89
|
+
const observed = [];
|
|
90
|
+
const unsubscribe = subscribeStateEvents((event) => {
|
|
91
|
+
if (event.mutationId === localInput.mutationId) {
|
|
92
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'local bytes\n');
|
|
93
|
+
observed.push(event);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result = commitLocalMutation(localInput, {
|
|
98
|
+
database: db,
|
|
99
|
+
persist(database) {
|
|
100
|
+
database.exec('CREATE TABLE IF NOT EXISTS mutation_test_state (id TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
|
101
|
+
database.prepare('INSERT INTO mutation_test_state (id, value) VALUES (?, ?)').run('local', 'persisted');
|
|
102
|
+
},
|
|
103
|
+
materialize() {
|
|
104
|
+
fs.writeFileSync(target, 'local bytes\n', 'utf8');
|
|
105
|
+
},
|
|
106
|
+
event: eventFor(localInput),
|
|
107
|
+
});
|
|
108
|
+
unsubscribe();
|
|
109
|
+
|
|
110
|
+
assert.equal(result.duplicate, false);
|
|
111
|
+
assert.equal(result.mutation.state, 'saved-local');
|
|
112
|
+
assert.ok(result.mutation.materializedAt);
|
|
113
|
+
assert.equal(result.mutation.eventSeq, result.event.seq);
|
|
114
|
+
assert.equal(observed.length, 1);
|
|
115
|
+
assert.equal(db.prepare('SELECT value FROM mutation_test_state WHERE id = ?').get('local').value, 'persisted');
|
|
116
|
+
|
|
117
|
+
const replayed = listEventsAfter(result.event.seq - 1).filter(
|
|
118
|
+
(event) => event.mutationId === localInput.mutationId,
|
|
119
|
+
);
|
|
120
|
+
assert.equal(replayed.length, 1);
|
|
121
|
+
assert.equal(replayed[0].sharedResourceId, undefined);
|
|
122
|
+
assert.equal(claimCloudMutations({ database: db }).length, 0, 'local-only rows never enter cloud delivery');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('a lost local acknowledgement reuses the journal row and event', () => {
|
|
126
|
+
const db = openLocalDb();
|
|
127
|
+
let materializeCalls = 0;
|
|
128
|
+
const result = commitLocalMutation(localInput, {
|
|
129
|
+
database: db,
|
|
130
|
+
materialize() {
|
|
131
|
+
materializeCalls += 1;
|
|
132
|
+
},
|
|
133
|
+
event: eventFor(localInput),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
assert.equal(result.duplicate, true);
|
|
137
|
+
assert.equal(materializeCalls, 0);
|
|
138
|
+
assert.equal(
|
|
139
|
+
listMutations({ database: db }).filter((mutation) => mutation.mutationId === localInput.mutationId).length,
|
|
140
|
+
1,
|
|
141
|
+
);
|
|
142
|
+
assert.equal(
|
|
143
|
+
listEventsAfter(0).filter((event) => event.mutationId === localInput.mutationId).length,
|
|
144
|
+
1,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('materialization failure stays durable but invisible until recovery completes', () => {
|
|
149
|
+
const db = openLocalDb();
|
|
150
|
+
const input = {
|
|
151
|
+
...localInput,
|
|
152
|
+
channelId: 'doc:materialization-failure',
|
|
153
|
+
localResource: 'doc:materialization-failure',
|
|
154
|
+
mutationId: 'mut_local_00000002',
|
|
155
|
+
};
|
|
156
|
+
const before = listEventsAfter(0).length;
|
|
157
|
+
|
|
158
|
+
assert.throws(() => commitLocalMutation(input, {
|
|
159
|
+
database: db,
|
|
160
|
+
materialize() {
|
|
161
|
+
throw new Error('disk unavailable');
|
|
162
|
+
},
|
|
163
|
+
event: eventFor(input),
|
|
164
|
+
}), /disk unavailable/);
|
|
165
|
+
|
|
166
|
+
const pending = getMutation(input.channelId, input.mutationId, { database: db });
|
|
167
|
+
assert.equal(pending.state, 'saving-local');
|
|
168
|
+
assert.equal(pending.eventSeq, null);
|
|
169
|
+
assert.equal(pending.lastError, 'disk unavailable');
|
|
170
|
+
assert.equal(listEventsAfter(0).length, before, 'unsaved mutation never enters the delivery cursor');
|
|
171
|
+
|
|
172
|
+
const target = path.join(tempRoot, 'recovered.md');
|
|
173
|
+
fs.writeFileSync(target, 'recovered bytes\n', 'utf8');
|
|
174
|
+
const recovered = completeMaterializedMutation(
|
|
175
|
+
input.channelId,
|
|
176
|
+
input.mutationId,
|
|
177
|
+
eventFor(input),
|
|
178
|
+
{ database: db },
|
|
179
|
+
);
|
|
180
|
+
assert.equal(recovered.mutation.state, 'saved-local');
|
|
181
|
+
assert.equal(recovered.mutation.lastError, null);
|
|
182
|
+
assert.equal(
|
|
183
|
+
listEventsAfter(0).filter((event) => event.mutationId === input.mutationId).length,
|
|
184
|
+
1,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('shared rows alone form the leased cloud outbox and acknowledgements deduplicate', () => {
|
|
189
|
+
const db = openLocalDb();
|
|
190
|
+
const result = commitLocalMutation(sharedInput, {
|
|
191
|
+
database: db,
|
|
192
|
+
materialize() {},
|
|
193
|
+
event: eventFor(sharedInput),
|
|
194
|
+
});
|
|
195
|
+
assert.equal(result.mutation.state, 'saved-local');
|
|
196
|
+
assert.equal(result.event.sharedResourceId, sharedResourceId);
|
|
197
|
+
|
|
198
|
+
const claimed = claimCloudMutations({
|
|
199
|
+
database: db,
|
|
200
|
+
now: '2026-07-14T18:00:00.000Z',
|
|
201
|
+
leaseUntil: '2026-07-14T18:00:30.000Z',
|
|
202
|
+
});
|
|
203
|
+
assert.equal(claimed.length, 1);
|
|
204
|
+
assert.deepEqual(claimed[0].envelope, {
|
|
205
|
+
resourceId: sharedResourceId,
|
|
206
|
+
authorityEpoch: 1,
|
|
207
|
+
mutationId: sharedInput.mutationId,
|
|
208
|
+
deviceId: 'device-test-1',
|
|
209
|
+
baseVersion: 0,
|
|
210
|
+
contract: 'text-yjs@1',
|
|
211
|
+
schemaVersion: 1,
|
|
212
|
+
operationKind: 'delta',
|
|
213
|
+
operation: sharedInput.operation,
|
|
214
|
+
});
|
|
215
|
+
assert.equal(claimed[0].mutation.state, 'syncing');
|
|
216
|
+
assert.equal(claimed[0].mutation.attemptCount, 1);
|
|
217
|
+
|
|
218
|
+
assert.equal(claimCloudMutations({
|
|
219
|
+
database: db,
|
|
220
|
+
now: '2026-07-14T18:00:15.000Z',
|
|
221
|
+
}).length, 0, 'an active lease prevents a duplicate concurrent delivery');
|
|
222
|
+
|
|
223
|
+
const retrying = failCloudMutation(
|
|
224
|
+
sharedResourceId,
|
|
225
|
+
sharedInput.mutationId,
|
|
226
|
+
{ message: 'temporary network error', retryable: true, retryAt: '2026-07-14T18:00:16.000Z' },
|
|
227
|
+
{ database: db },
|
|
228
|
+
);
|
|
229
|
+
assert.equal(retrying.state, 'syncing');
|
|
230
|
+
assert.equal(claimCloudMutations({
|
|
231
|
+
database: db,
|
|
232
|
+
now: '2026-07-14T18:00:17.000Z',
|
|
233
|
+
}).length, 1);
|
|
234
|
+
|
|
235
|
+
const acknowledged = acknowledgeCloudMutation(
|
|
236
|
+
sharedResourceId,
|
|
237
|
+
sharedInput.mutationId,
|
|
238
|
+
{ version: 1, committedAt: '2026-07-14T18:00:18.000Z' },
|
|
239
|
+
{ database: db },
|
|
240
|
+
);
|
|
241
|
+
assert.equal(acknowledged.mutation.state, 'synced');
|
|
242
|
+
assert.equal(acknowledged.mutation.officialCloudVersion, 1);
|
|
243
|
+
|
|
244
|
+
const duplicate = acknowledgeCloudMutation(
|
|
245
|
+
sharedResourceId,
|
|
246
|
+
sharedInput.mutationId,
|
|
247
|
+
{ version: 1, committedAt: '2026-07-14T18:00:18.000Z' },
|
|
248
|
+
{ database: db },
|
|
249
|
+
);
|
|
250
|
+
assert.equal(duplicate.duplicate, true);
|
|
251
|
+
assert.equal(claimCloudMutations({ database: db }).length, 0);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('the official tail reconciles a locally materialized mutation after its ack was lost', () => {
|
|
255
|
+
const db = openLocalDb();
|
|
256
|
+
const resourceId = 'shr_tailackabcdefghijkl';
|
|
257
|
+
const input = {
|
|
258
|
+
...sharedInput,
|
|
259
|
+
channelId: `resource:${resourceId}`,
|
|
260
|
+
sharedResourceId: resourceId,
|
|
261
|
+
localResource: 'doc:shared-tail-ack-test',
|
|
262
|
+
mutationId: 'mut_shared_tailack_01',
|
|
263
|
+
operation: { kind: 'delta', yjs: 'DQ4P' },
|
|
264
|
+
};
|
|
265
|
+
registerReplica({
|
|
266
|
+
resourceId,
|
|
267
|
+
localResource: input.localResource,
|
|
268
|
+
localPath: path.join(tempRoot, 'tail-ack.md'),
|
|
269
|
+
contract: input.contract,
|
|
270
|
+
schemaVersion: input.schemaVersion,
|
|
271
|
+
authorityEpoch: input.authorityEpoch,
|
|
272
|
+
appliedVersion: 0,
|
|
273
|
+
snapshotVersion: 0,
|
|
274
|
+
}, { database: db });
|
|
275
|
+
commitLocalMutation(input, { database: db, materialize() {}, event: eventFor(input) });
|
|
276
|
+
|
|
277
|
+
const recovered = journalCloudMutation({
|
|
278
|
+
localResource: input.localResource,
|
|
279
|
+
version: 1,
|
|
280
|
+
committedAt: '2026-07-14T18:00:18.000Z',
|
|
281
|
+
envelope: {
|
|
282
|
+
resourceId,
|
|
283
|
+
authorityEpoch: input.authorityEpoch,
|
|
284
|
+
mutationId: input.mutationId,
|
|
285
|
+
deviceId: input.deviceId,
|
|
286
|
+
baseVersion: input.baseCloudVersion,
|
|
287
|
+
contract: input.contract,
|
|
288
|
+
schemaVersion: input.schemaVersion,
|
|
289
|
+
operationKind: input.operationKind,
|
|
290
|
+
operation: input.operation,
|
|
291
|
+
},
|
|
292
|
+
}, { database: db });
|
|
293
|
+
|
|
294
|
+
assert.equal(recovered.duplicate, true);
|
|
295
|
+
assert.equal(recovered.alreadyApplied, true);
|
|
296
|
+
assert.equal(recovered.mutation.state, 'synced');
|
|
297
|
+
assert.equal(recovered.mutation.officialCloudVersion, 1);
|
|
298
|
+
assert.equal(getReplicaByResourceId(resourceId, { database: db }).appliedVersion, 1);
|
|
299
|
+
assert.equal(
|
|
300
|
+
listEventsAfter(0).filter((event) => event.mutationId === input.mutationId).length,
|
|
301
|
+
1,
|
|
302
|
+
'tail recovery never projects a second local event',
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('retention deletes only completed rows and preserves unresolved cloud work', () => {
|
|
307
|
+
const db = openLocalDb();
|
|
308
|
+
const pendingInput = {
|
|
309
|
+
...sharedInput,
|
|
310
|
+
mutationId: 'mut_shared_0000002',
|
|
311
|
+
operation: { kind: 'delta', yjs: 'BwgJ' },
|
|
312
|
+
};
|
|
313
|
+
const rejectedInput = {
|
|
314
|
+
...sharedInput,
|
|
315
|
+
mutationId: 'mut_shared_0000003',
|
|
316
|
+
operation: { kind: 'delta', yjs: 'CgsM' },
|
|
317
|
+
};
|
|
318
|
+
commitLocalMutation(pendingInput, { database: db, materialize() {}, event: eventFor(pendingInput) });
|
|
319
|
+
commitLocalMutation(rejectedInput, { database: db, materialize() {}, event: eventFor(rejectedInput) });
|
|
320
|
+
failCloudMutation(
|
|
321
|
+
sharedResourceId,
|
|
322
|
+
rejectedInput.mutationId,
|
|
323
|
+
{ message: 'viewer cannot write', retryable: false },
|
|
324
|
+
{ database: db },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
db.prepare("UPDATE mutation_journal SET updated_at = '2026-07-13T00:00:00.000Z'").run();
|
|
328
|
+
const pruned = pruneMutationJournal({
|
|
329
|
+
database: db,
|
|
330
|
+
cutoffIso: '2026-07-14T00:00:00.000Z',
|
|
331
|
+
batchSize: 100,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
assert.ok(pruned.deleted >= 2, 'completed local and synced rows age out');
|
|
335
|
+
assert.equal(getMutation(pendingInput.channelId, pendingInput.mutationId, { database: db }).state, 'saved-local');
|
|
336
|
+
assert.equal(getMutation(rejectedInput.channelId, rejectedInput.mutationId, { database: db }).state, 'rejected');
|
|
337
|
+
});
|