amalgm 0.1.238 → 0.1.240
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 +22 -14
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -233,6 +233,26 @@ function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
|
|
|
233
233
|
// the shared channel — their base is the promoted snapshot — so the
|
|
234
234
|
// normal outbox claim delivers them. Their journal identity, order, and
|
|
235
235
|
// already-projected local events are untouched.
|
|
236
|
+
const pendingRehomes = cachedStatement(database, `
|
|
237
|
+
SELECT channel_id, mutation_id
|
|
238
|
+
FROM mutation_journal
|
|
239
|
+
WHERE local_resource = ?
|
|
240
|
+
AND shared_resource_id IS NULL
|
|
241
|
+
AND mutation_origin = 'local'
|
|
242
|
+
AND journal_id > ?
|
|
243
|
+
ORDER BY journal_id ASC
|
|
244
|
+
`).all(promotion.localResource, promotion.journalCursor);
|
|
245
|
+
const sharedChannelId = `resource:${replica.resourceId}`;
|
|
246
|
+
for (const mutation of pendingRehomes) {
|
|
247
|
+
// The permanent mutation ID joins semantic and physical durability.
|
|
248
|
+
// Rekey both halves in this transaction; otherwise restart can see the
|
|
249
|
+
// shared journal row and strand its still-local native file receipt.
|
|
250
|
+
require('./doc-disk').rehomeMutation(database, {
|
|
251
|
+
mutationId: mutation.mutation_id,
|
|
252
|
+
oldChannelId: mutation.channel_id,
|
|
253
|
+
newChannelId: sharedChannelId,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
236
256
|
const rehomed = cachedStatement(database, `
|
|
237
257
|
UPDATE mutation_journal
|
|
238
258
|
SET channel_id = ?,
|
|
@@ -246,7 +266,7 @@ function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
|
|
|
246
266
|
AND mutation_origin = 'local'
|
|
247
267
|
AND journal_id > ?
|
|
248
268
|
`).run(
|
|
249
|
-
|
|
269
|
+
sharedChannelId,
|
|
250
270
|
replica.resourceId,
|
|
251
271
|
require('../workspace/identity').machineId(),
|
|
252
272
|
replica.authorityEpoch,
|
|
@@ -35,6 +35,41 @@ function integer(value, name, minimum) {
|
|
|
35
35
|
return number;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* A snapshot is an official prefix, never an independently numbered value.
|
|
40
|
+
* Validate the complete authority envelope before any caller touches disk,
|
|
41
|
+
* memory, or SQLite so every snapshot contract shares one ordering boundary.
|
|
42
|
+
*/
|
|
43
|
+
function validateSnapshotEnvelope(input, expected = {}) {
|
|
44
|
+
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
45
|
+
if (!isCloudResourceId(resourceId)) {
|
|
46
|
+
throw invalid('resourceId must be a node UUID or legacy cloud id');
|
|
47
|
+
}
|
|
48
|
+
const contract = requiredText(input?.contract, 'contract', 128);
|
|
49
|
+
if (!CONTRACT.test(contract)) throw invalid('contract has an invalid format');
|
|
50
|
+
const schemaVersion = integer(input?.schemaVersion, 'schemaVersion', 1);
|
|
51
|
+
const authorityEpoch = integer(input?.authorityEpoch, 'authorityEpoch', 1);
|
|
52
|
+
const snapshotVersion = integer(input?.snapshotVersion, 'snapshotVersion', 0);
|
|
53
|
+
const headVersion = integer(input?.headVersion, 'headVersion', 0);
|
|
54
|
+
if (expected.contract && contract !== expected.contract) {
|
|
55
|
+
throw invalid(`snapshot contract must be ${expected.contract}`, 409);
|
|
56
|
+
}
|
|
57
|
+
if (expected.schemaVersion && schemaVersion !== expected.schemaVersion) {
|
|
58
|
+
throw invalid(`snapshot schemaVersion must be ${expected.schemaVersion}`, 409);
|
|
59
|
+
}
|
|
60
|
+
if (snapshotVersion > headVersion) {
|
|
61
|
+
throw invalid('snapshotVersion cannot exceed headVersion', 409);
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
resourceId,
|
|
65
|
+
contract,
|
|
66
|
+
schemaVersion,
|
|
67
|
+
authorityEpoch,
|
|
68
|
+
snapshotVersion,
|
|
69
|
+
headVersion,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
function normalizeReplica(row) {
|
|
39
74
|
if (!row) return null;
|
|
40
75
|
return {
|
|
@@ -219,11 +254,17 @@ function markReplicaRevoked(input, options = {}) {
|
|
|
219
254
|
}
|
|
220
255
|
|
|
221
256
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
257
|
+
* Decide what an already-bound replica may do with a snapshot before the
|
|
258
|
+
* caller touches its materialization.
|
|
259
|
+
*
|
|
260
|
+
* Rail version and authority epoch are independent coordinates. Most
|
|
261
|
+
* contracts have one canonical value at a rail version, while an
|
|
262
|
+
* authority-projected contract (the workspace tree) may legitimately have
|
|
263
|
+
* different visible bytes at the same rail version after an access change.
|
|
264
|
+
* Older authority is never allowed to install, even when it carries a larger
|
|
265
|
+
* rail number.
|
|
225
266
|
*/
|
|
226
|
-
function
|
|
267
|
+
function inspectReplicaSnapshot(input, options = {}) {
|
|
227
268
|
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
228
269
|
const snapshotVersion = integer(input?.snapshotVersion, 'snapshotVersion', 0);
|
|
229
270
|
const snapshotChecksum = requiredText(input?.snapshotChecksum, 'snapshotChecksum', 64);
|
|
@@ -232,23 +273,101 @@ function advanceReplicaSnapshot(input, options = {}) {
|
|
|
232
273
|
}
|
|
233
274
|
const authorityEpoch = integer(input?.authorityEpoch, 'authorityEpoch', 1);
|
|
234
275
|
const database = options.database || openLocalDb();
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
276
|
+
const current = getReplicaByResourceId(resourceId, { database });
|
|
277
|
+
if (!current) throw invalid(`unknown shared replica ${resourceId}`, 404);
|
|
278
|
+
const projectedAuthorityEpoch = options.projectedAuthorityEpoch == null
|
|
279
|
+
? current.authorityEpoch
|
|
280
|
+
: integer(options.projectedAuthorityEpoch, 'projectedAuthorityEpoch', 1);
|
|
281
|
+
if (
|
|
282
|
+
authorityEpoch < current.authorityEpoch
|
|
283
|
+
|| authorityEpoch < projectedAuthorityEpoch
|
|
284
|
+
) {
|
|
285
|
+
throw invalid(
|
|
286
|
+
`shared resource ${resourceId} snapshot has stale authority epoch ${authorityEpoch}`,
|
|
287
|
+
409,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
// `authorityEpoch` on the replica is the newest authority advertised by
|
|
291
|
+
// the tunnel. `projectedAuthorityEpoch` is the authority whose bytes and
|
|
292
|
+
// permissions are physically installed. A reconnect may advance the first
|
|
293
|
+
// coordinate before the same-version projection arrives, so projection
|
|
294
|
+
// eligibility must compare with the second.
|
|
295
|
+
const authorityChanged = authorityEpoch > projectedAuthorityEpoch;
|
|
296
|
+
const authorityProjection = options.allowAuthorityProjection === true && authorityChanged;
|
|
297
|
+
if (
|
|
298
|
+
current.snapshotVersion === snapshotVersion
|
|
299
|
+
&& current.snapshotChecksum !== null
|
|
300
|
+
&& current.snapshotChecksum !== snapshotChecksum
|
|
301
|
+
&& !authorityProjection
|
|
302
|
+
) {
|
|
303
|
+
throw invalid(
|
|
304
|
+
`shared resource ${resourceId} has unequal state at version ${snapshotVersion}`,
|
|
305
|
+
409,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (snapshotVersion < current.appliedVersion) {
|
|
309
|
+
return { action: 'stale', authorityChanged, replica: current };
|
|
310
|
+
}
|
|
311
|
+
if (authorityProjection) {
|
|
312
|
+
return { action: 'install', authorityChanged, replica: current };
|
|
313
|
+
}
|
|
314
|
+
if (snapshotVersion === current.appliedVersion) {
|
|
315
|
+
if (
|
|
316
|
+
current.snapshotVersion === snapshotVersion
|
|
317
|
+
&& current.snapshotChecksum === null
|
|
318
|
+
) {
|
|
319
|
+
return { action: 'metadata', authorityChanged, replica: current };
|
|
320
|
+
}
|
|
321
|
+
if (
|
|
322
|
+
current.snapshotVersion === snapshotVersion
|
|
323
|
+
&& current.snapshotChecksum === snapshotChecksum
|
|
324
|
+
) {
|
|
325
|
+
return { action: authorityChanged ? 'metadata' : 'duplicate', authorityChanged, replica: current };
|
|
326
|
+
}
|
|
327
|
+
if (current.snapshotVersion < snapshotVersion) {
|
|
328
|
+
return { action: 'metadata', authorityChanged, replica: current };
|
|
329
|
+
}
|
|
330
|
+
throw invalid(
|
|
331
|
+
`shared resource ${resourceId} has unequal state at version ${snapshotVersion}`,
|
|
332
|
+
409,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
return { action: 'install', authorityChanged, replica: current };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A freshly attached official snapshot subsumes every version at or before
|
|
340
|
+
* it, so the replica's cursors jump forward to the snapshot — never backward:
|
|
341
|
+
* a replica that already applied past the snapshot keeps its position.
|
|
342
|
+
*/
|
|
343
|
+
function advanceReplicaSnapshot(input, options = {}) {
|
|
344
|
+
const database = options.database || openLocalDb();
|
|
345
|
+
const disposition = inspectReplicaSnapshot(input, options);
|
|
346
|
+
const {
|
|
347
|
+
resourceId,
|
|
348
|
+
snapshotVersion,
|
|
349
|
+
snapshotChecksum,
|
|
350
|
+
authorityEpoch,
|
|
351
|
+
} = {
|
|
352
|
+
resourceId: disposition.replica.resourceId,
|
|
353
|
+
snapshotVersion: integer(input?.snapshotVersion, 'snapshotVersion', 0),
|
|
354
|
+
snapshotChecksum: requiredText(input?.snapshotChecksum, 'snapshotChecksum', 64),
|
|
355
|
+
authorityEpoch: integer(input?.authorityEpoch, 'authorityEpoch', 1),
|
|
356
|
+
};
|
|
357
|
+
if (disposition.action === 'stale' || disposition.action === 'duplicate') {
|
|
358
|
+
return disposition.replica;
|
|
359
|
+
}
|
|
240
360
|
cachedStatement(database, `
|
|
241
361
|
UPDATE shared_resource_replicas
|
|
242
|
-
SET applied_version =
|
|
243
|
-
snapshot_version =
|
|
244
|
-
snapshot_checksum =
|
|
362
|
+
SET applied_version = ?,
|
|
363
|
+
snapshot_version = ?,
|
|
364
|
+
snapshot_checksum = ?,
|
|
245
365
|
authority_epoch = MAX(authority_epoch, ?),
|
|
246
366
|
sync_status = 'synced',
|
|
247
367
|
last_error = NULL,
|
|
248
368
|
updated_at = ?
|
|
249
369
|
WHERE resource_id = ?
|
|
250
370
|
`).run(
|
|
251
|
-
snapshotVersion,
|
|
252
371
|
snapshotVersion,
|
|
253
372
|
snapshotVersion,
|
|
254
373
|
snapshotChecksum,
|
|
@@ -268,4 +387,6 @@ module.exports = {
|
|
|
268
387
|
markReplicaRevoked,
|
|
269
388
|
refreshReplicaAuthority,
|
|
270
389
|
registerReplica,
|
|
390
|
+
inspectReplicaSnapshot,
|
|
391
|
+
validateSnapshotEnvelope,
|
|
271
392
|
};
|
|
@@ -118,7 +118,7 @@ async function handleWorkspaceContentDiscover(body, sendJson) {
|
|
|
118
118
|
|
|
119
119
|
async function handlePromotionStart(body, sendJson) {
|
|
120
120
|
try {
|
|
121
|
-
const codeProject = body?.contract === 'code-project-stream@
|
|
121
|
+
const codeProject = body?.contract === 'code-project-stream@2';
|
|
122
122
|
const requestedResourceId = typeof body?.resourceId === 'string' && body.resourceId.trim()
|
|
123
123
|
? body.resourceId.trim()
|
|
124
124
|
: null;
|
|
@@ -130,7 +130,7 @@ async function handlePromotionStart(body, sendJson) {
|
|
|
130
130
|
}
|
|
131
131
|
const projectStream = require('../workspace/project-stream');
|
|
132
132
|
const localPath = require('path').resolve(body?.path || '');
|
|
133
|
-
const snapshot = projectStream.readSnapshot(localPath,
|
|
133
|
+
const snapshot = projectStream.readSnapshot(localPath, requestedResourceId);
|
|
134
134
|
const bytes = Buffer.from(`${JSON.stringify(snapshot)}\n`, 'utf8');
|
|
135
135
|
const result = require('./promotions').enqueuePromotion({
|
|
136
136
|
promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
@@ -138,7 +138,7 @@ async function handlePromotionStart(body, sendJson) {
|
|
|
138
138
|
localResource: `${projectStream.LOCAL_RESOURCE_PREFIX}${requestedResourceId}`,
|
|
139
139
|
localPath,
|
|
140
140
|
contract: projectStream.STREAM_CONTRACT,
|
|
141
|
-
schemaVersion:
|
|
141
|
+
schemaVersion: 2,
|
|
142
142
|
snapshotBase64: bytes.toString('base64'),
|
|
143
143
|
contentBytes: bytes.length,
|
|
144
144
|
name: body?.name,
|
|
@@ -240,7 +240,7 @@ async function handlePromotionFail(body, sendJson) {
|
|
|
240
240
|
async function handleAttachmentStart(body, sendJson) {
|
|
241
241
|
try {
|
|
242
242
|
const tree = body?.contract === 'workspace-tree@1';
|
|
243
|
-
const codeProject = body?.contract === 'code-project-stream@
|
|
243
|
+
const codeProject = body?.contract === 'code-project-stream@2';
|
|
244
244
|
const localPath = tree || codeProject
|
|
245
245
|
? require('path').resolve(body?.path || '')
|
|
246
246
|
: require('./docs').openDoc(body?.path).path;
|
|
@@ -251,8 +251,9 @@ async function handleAttachmentStart(body, sendJson) {
|
|
|
251
251
|
return;
|
|
252
252
|
}
|
|
253
253
|
} else if (codeProject) {
|
|
254
|
-
const
|
|
255
|
-
|
|
254
|
+
const stream = require('../workspace/project-stream');
|
|
255
|
+
const snapshot = stream.readSnapshot(localPath, body?.resourceId);
|
|
256
|
+
if (snapshot.version !== 0 || snapshot.card !== null || snapshot.checkpoint !== null) {
|
|
256
257
|
sendJson(409, { error: 'code project attachment requires an empty local projection' });
|
|
257
258
|
return;
|
|
258
259
|
}
|
|
@@ -352,7 +353,12 @@ async function handleMutationFail(body, sendJson) {
|
|
|
352
353
|
sendJson(200, require('./mutations').failCloudMutation(
|
|
353
354
|
body?.resourceId,
|
|
354
355
|
body?.mutationId,
|
|
355
|
-
{
|
|
356
|
+
{
|
|
357
|
+
code: body?.code,
|
|
358
|
+
message: body?.message,
|
|
359
|
+
retryable: body?.retryable,
|
|
360
|
+
retryAt: body?.retryAt,
|
|
361
|
+
},
|
|
356
362
|
));
|
|
357
363
|
} catch (error) {
|
|
358
364
|
sendHandlerError(error, sendJson);
|
|
@@ -366,7 +372,7 @@ async function handleMutationIngest(body, sendJson) {
|
|
|
366
372
|
? require('../browser/cookie-jar').applyCloudCookieMutation(body)
|
|
367
373
|
: contract === 'workspace-tree@1'
|
|
368
374
|
? require('../workspace/tree-store').applyCloudTreeMutation(body)
|
|
369
|
-
: contract === 'code-project-stream@
|
|
375
|
+
: contract === 'code-project-stream@2'
|
|
370
376
|
? require('../workspace/project-stream').applyCloudMutation(body)
|
|
371
377
|
: require('./docs').applyCloudDocMutation(body);
|
|
372
378
|
sendJson(200, result);
|
|
@@ -23,6 +23,7 @@ const {
|
|
|
23
23
|
claimCloudMutations,
|
|
24
24
|
getMutation,
|
|
25
25
|
} = require('../state/mutations');
|
|
26
|
+
const { getReplicaByResourceId } = require('../state/replicas');
|
|
26
27
|
const {
|
|
27
28
|
COOKIE_CLOUD_CONTRACT,
|
|
28
29
|
applyCloudCookieMutation,
|
|
@@ -89,6 +90,21 @@ test('official cookie mutations materialize in order and deletions stay tombston
|
|
|
89
90
|
version: 1,
|
|
90
91
|
committedAt: '2026-07-20T20:00:00.000Z',
|
|
91
92
|
}, { database: db });
|
|
93
|
+
assert.equal(
|
|
94
|
+
getReplicaByResourceId(localClaim.envelope.resourceId, { database: db }).appliedVersion,
|
|
95
|
+
0,
|
|
96
|
+
'the acknowledgement records the number but does not apply it',
|
|
97
|
+
);
|
|
98
|
+
applyCloudCookieMutation({
|
|
99
|
+
version: 1,
|
|
100
|
+
committedAt: '2026-07-20T20:00:00.000Z',
|
|
101
|
+
envelope: localClaim.envelope,
|
|
102
|
+
}, { database: db });
|
|
103
|
+
assert.equal(
|
|
104
|
+
getReplicaByResourceId(localClaim.envelope.resourceId, { database: db }).appliedVersion,
|
|
105
|
+
1,
|
|
106
|
+
'the writer advances only when its committed event passes through the ordered inbox',
|
|
107
|
+
);
|
|
92
108
|
|
|
93
109
|
const remoteCookie = cookie('remote', 'remote-secret');
|
|
94
110
|
const key = require('../browser/cookie-jar').cookieKey(remoteCookie);
|
|
@@ -178,4 +194,181 @@ test('a stale cloud base never re-emits local cookies as bootstrap mutations', (
|
|
|
178
194
|
assert.equal(result.bootstrapDeferred, true);
|
|
179
195
|
assert.equal(result.bootstrappedMutations, 0);
|
|
180
196
|
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM mutation_journal').get().count, 0);
|
|
197
|
+
assert.equal(
|
|
198
|
+
readCookieJar().cookies.some((item) => item.name === 'already-synced'),
|
|
199
|
+
true,
|
|
200
|
+
'a stale prefix cannot roll the physical jar backward',
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a private snapshot ahead of cloud head is rejected before changing the jar', () => {
|
|
205
|
+
const before = JSON.stringify(readCookieJar());
|
|
206
|
+
const resourceId = privateCookieResourceId(userId);
|
|
207
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
208
|
+
schemaVersion: 1,
|
|
209
|
+
version: 8,
|
|
210
|
+
updatedAt: '2026-07-20T19:00:00.000Z',
|
|
211
|
+
records: [],
|
|
212
|
+
tombstones: [],
|
|
213
|
+
}), 'utf8');
|
|
214
|
+
assert.throws(() => installCookieCloudBootstrap({
|
|
215
|
+
resourceId,
|
|
216
|
+
authorityEpoch: 1,
|
|
217
|
+
headVersion: 7,
|
|
218
|
+
snapshotVersion: 8,
|
|
219
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(bytes).digest('hex'),
|
|
220
|
+
snapshotBase64: bytes.toString('base64'),
|
|
221
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
222
|
+
schemaVersion: 1,
|
|
223
|
+
}), /cannot exceed headVersion/);
|
|
224
|
+
assert.equal(JSON.stringify(readCookieJar()), before);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a stale cookie prefix ahead of the local cursor cannot claim application', () => {
|
|
228
|
+
mergeCookieJar({ sourceId: 'machine-a', upserts: [cookie('local-only', 'keep-me')] });
|
|
229
|
+
const db = openLocalDb();
|
|
230
|
+
db.prepare('DELETE FROM mutation_journal').run();
|
|
231
|
+
const resourceId = privateCookieResourceId(userId);
|
|
232
|
+
const before = JSON.stringify(readCookieJar());
|
|
233
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
version: 3,
|
|
236
|
+
updatedAt: '2026-07-20T19:00:00.000Z',
|
|
237
|
+
records: [],
|
|
238
|
+
tombstones: [],
|
|
239
|
+
}), 'utf8');
|
|
240
|
+
|
|
241
|
+
const result = installCookieCloudBootstrap({
|
|
242
|
+
resourceId,
|
|
243
|
+
authorityEpoch: 1,
|
|
244
|
+
headVersion: 7,
|
|
245
|
+
snapshotVersion: 3,
|
|
246
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(bytes).digest('hex'),
|
|
247
|
+
snapshotBase64: bytes.toString('base64'),
|
|
248
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
249
|
+
schemaVersion: 1,
|
|
250
|
+
}, { database: db });
|
|
251
|
+
|
|
252
|
+
assert.equal(result.bootstrapDeferred, true);
|
|
253
|
+
assert.equal(result.replica.appliedVersion, 0);
|
|
254
|
+
assert.equal(JSON.stringify(readCookieJar()), before);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('cookie bootstrap journals legacy local state before replacing the physical jar', () => {
|
|
258
|
+
mergeCookieJar({
|
|
259
|
+
sourceId: 'legacy-browser',
|
|
260
|
+
upserts: [cookie('legacy-session', 'must-survive')],
|
|
261
|
+
});
|
|
262
|
+
const db = openLocalDb();
|
|
263
|
+
db.prepare('DELETE FROM mutation_journal').run();
|
|
264
|
+
const resourceId = privateCookieResourceId(userId);
|
|
265
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
266
|
+
schemaVersion: 1,
|
|
267
|
+
version: 0,
|
|
268
|
+
updatedAt: '2026-07-20T19:00:00.000Z',
|
|
269
|
+
records: [],
|
|
270
|
+
tombstones: [],
|
|
271
|
+
}), 'utf8');
|
|
272
|
+
const input = {
|
|
273
|
+
resourceId,
|
|
274
|
+
authorityEpoch: 1,
|
|
275
|
+
headVersion: 0,
|
|
276
|
+
snapshotVersion: 0,
|
|
277
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(bytes).digest('hex'),
|
|
278
|
+
snapshotBase64: bytes.toString('base64'),
|
|
279
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
280
|
+
schemaVersion: 1,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
assert.throws(
|
|
284
|
+
() => installCookieCloudBootstrap(input, {
|
|
285
|
+
database: db,
|
|
286
|
+
testHooks: { crashAfterSnapshotPersist: true },
|
|
287
|
+
}),
|
|
288
|
+
/simulated cookie bootstrap crash/,
|
|
289
|
+
);
|
|
290
|
+
assert.equal(
|
|
291
|
+
db.prepare(`
|
|
292
|
+
SELECT COUNT(*) AS count FROM mutation_journal
|
|
293
|
+
WHERE shared_resource_id = ? AND mutation_origin = 'local'
|
|
294
|
+
`).get(resourceId).count,
|
|
295
|
+
1,
|
|
296
|
+
'the local cookie is durable before the jar is replaced',
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
installCookieCloudBootstrap(input, { database: db });
|
|
300
|
+
assert.equal(
|
|
301
|
+
readCookieJar().cookies.some((item) => (
|
|
302
|
+
item.name === 'legacy-session' && item.value === 'must-survive'
|
|
303
|
+
)),
|
|
304
|
+
true,
|
|
305
|
+
);
|
|
306
|
+
assert.equal(
|
|
307
|
+
db.prepare(`
|
|
308
|
+
SELECT COUNT(*) AS count FROM mutation_journal
|
|
309
|
+
WHERE shared_resource_id = ? AND mutation_origin = 'local'
|
|
310
|
+
`).get(resourceId).count,
|
|
311
|
+
1,
|
|
312
|
+
'restart does not mint a second birth certificate',
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('cookie bootstrap completes a crash-left saving-local birth instead of replaying forever', () => {
|
|
317
|
+
mergeCookieJar({
|
|
318
|
+
sourceId: 'crashing-browser',
|
|
319
|
+
upserts: [cookie('crash-left', 'must-recover')],
|
|
320
|
+
});
|
|
321
|
+
const db = openLocalDb();
|
|
322
|
+
const row = db.prepare(`
|
|
323
|
+
SELECT * FROM mutation_journal
|
|
324
|
+
WHERE operation_kind = 'cookie.upsert'
|
|
325
|
+
ORDER BY journal_id DESC
|
|
326
|
+
LIMIT 1
|
|
327
|
+
`).get();
|
|
328
|
+
db.prepare(`
|
|
329
|
+
UPDATE mutation_journal
|
|
330
|
+
SET mutation_state = 'saving-local',
|
|
331
|
+
materialized_at = NULL,
|
|
332
|
+
event_seq = NULL
|
|
333
|
+
WHERE journal_id = ?
|
|
334
|
+
`).run(row.journal_id);
|
|
335
|
+
db.prepare('DELETE FROM event_log').run();
|
|
336
|
+
|
|
337
|
+
const bytes = Buffer.from(JSON.stringify({
|
|
338
|
+
schemaVersion: 1,
|
|
339
|
+
version: 0,
|
|
340
|
+
updatedAt: '2026-07-20T19:00:00.000Z',
|
|
341
|
+
records: [],
|
|
342
|
+
tombstones: [],
|
|
343
|
+
}), 'utf8');
|
|
344
|
+
installCookieCloudBootstrap({
|
|
345
|
+
resourceId: row.shared_resource_id,
|
|
346
|
+
authorityEpoch: 1,
|
|
347
|
+
headVersion: 0,
|
|
348
|
+
snapshotVersion: 0,
|
|
349
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(bytes).digest('hex'),
|
|
350
|
+
snapshotBase64: bytes.toString('base64'),
|
|
351
|
+
contract: COOKIE_CLOUD_CONTRACT,
|
|
352
|
+
schemaVersion: 1,
|
|
353
|
+
}, { database: db });
|
|
354
|
+
|
|
355
|
+
const repaired = db.prepare(`
|
|
356
|
+
SELECT mutation_state, materialized_at, event_seq
|
|
357
|
+
FROM mutation_journal
|
|
358
|
+
WHERE journal_id = ?
|
|
359
|
+
`).get(row.journal_id);
|
|
360
|
+
assert.equal(repaired.mutation_state, 'saved-local');
|
|
361
|
+
assert.ok(repaired.materialized_at);
|
|
362
|
+
assert.ok(repaired.event_seq);
|
|
363
|
+
assert.equal(
|
|
364
|
+
readCookieJar().cookies.some((item) => (
|
|
365
|
+
item.name === 'crash-left' && item.value === 'must-recover'
|
|
366
|
+
)),
|
|
367
|
+
true,
|
|
368
|
+
);
|
|
369
|
+
assert.equal(
|
|
370
|
+
db.prepare('SELECT COUNT(*) AS count FROM mutation_journal').get().count,
|
|
371
|
+
1,
|
|
372
|
+
'recovery completes the original birth instead of minting another',
|
|
373
|
+
);
|
|
181
374
|
});
|