amalgm 0.1.247 → 0.1.249
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 +6 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +142 -7
- package/runtime/scripts/amalgm-mcp/registration/service.js +30 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +44 -3
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +14 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +63 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +36 -2
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +8 -0
|
@@ -936,13 +936,16 @@ function createSharedRealtimeTunnel({
|
|
|
936
936
|
mutationId: entityIdentity.mutationId,
|
|
937
937
|
version: frame.version,
|
|
938
938
|
committedAt: frame.committed_at,
|
|
939
|
-
}).then(() => {
|
|
939
|
+
}).then((ack) => {
|
|
940
940
|
entityMutationInflight.delete(frame.request_id);
|
|
941
941
|
// An acknowledgement advances the official head. Re-read the
|
|
942
942
|
// private resource immediately: the gateway compacts that head into
|
|
943
943
|
// one immutable snapshot before replying, so this device never has
|
|
944
|
-
// to use its mutation tail as an `amalgm add` starting point.
|
|
945
|
-
|
|
944
|
+
// to use its mutation tail as an `amalgm add` starting point. A
|
|
945
|
+
// legacy empty entity resource is seeded in ordered records first;
|
|
946
|
+
// intermediate heads are intentionally not imported as a usable
|
|
947
|
+
// snapshot, because they are not the complete declared home yet.
|
|
948
|
+
if (ack?.requiresSnapshot !== false) void ensurePrivateEntityRegistry();
|
|
946
949
|
scheduleEntityMutationDrain();
|
|
947
950
|
}).catch(() => {
|
|
948
951
|
setTimeout(() => enabled && handleAck(frame), RETRY_MS).unref?.();
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ const crypto = require('crypto');
|
|
|
13
13
|
|
|
14
14
|
const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@1';
|
|
15
15
|
const ENTITY_CLOUD_SCHEMA_VERSION = 1;
|
|
16
|
+
const BOOTSTRAP_RECONCILIATION_STATE = 'reconciling-bootstrap';
|
|
16
17
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
18
|
const TYPES = new Set([
|
|
18
19
|
'workspace', 'folder', 'repo.git', 'file.text', 'file.binary', 'link', 'reference',
|
|
@@ -40,6 +41,15 @@ function sameRecord(left, right) {
|
|
|
40
41
|
return stableJson(left) === stableJson(right);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
function sameRecords(left, right) {
|
|
45
|
+
return sameRecord(snapshotFromRecords(left), snapshotFromRecords(right));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordsAreSubset(records, expected) {
|
|
49
|
+
const expectedByUuid = new Map(snapshotFromRecords(expected).records.map((record) => [record.uuid, record]));
|
|
50
|
+
return snapshotFromRecords(records).records.every((record) => sameRecord(record, expectedByUuid.get(record.uuid)));
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
function assertRecord(value) {
|
|
44
54
|
if (!value || typeof value !== 'object' || typeof value.uuid !== 'string' || !UUID.test(value.uuid)
|
|
45
55
|
|| typeof value.type !== 'string' || !TYPES.has(value.type)
|
|
@@ -309,6 +319,26 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
309
319
|
`).run(current.resourceId, mutationId, stableJson(operation), createdAt, createdAt);
|
|
310
320
|
}
|
|
311
321
|
|
|
322
|
+
function enqueueBootstrapReconciliation(records, current) {
|
|
323
|
+
const pending = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
|
|
324
|
+
.get(current.resourceId);
|
|
325
|
+
if (Number(pending.count) !== 0) {
|
|
326
|
+
throw new Error('entity bootstrap reconciliation already has an unfinished outbox');
|
|
327
|
+
}
|
|
328
|
+
const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
|
|
329
|
+
const createdAt = now();
|
|
330
|
+
for (const record of snapshotFromRecords(records).records) {
|
|
331
|
+
queueContent(record, current.resourceId, { queue: true });
|
|
332
|
+
insert.run(
|
|
333
|
+
current.resourceId,
|
|
334
|
+
'ent_' + crypto.randomBytes(18).toString('base64url'),
|
|
335
|
+
stableJson({ kind: 'entity.upsert', record }),
|
|
336
|
+
createdAt,
|
|
337
|
+
createdAt,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
312
342
|
function assertRegistrationReady() {
|
|
313
343
|
if (!ownerUserId) return;
|
|
314
344
|
const current = replica();
|
|
@@ -346,7 +376,15 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
346
376
|
};
|
|
347
377
|
}
|
|
348
378
|
|
|
349
|
-
function install({
|
|
379
|
+
function install({
|
|
380
|
+
registry,
|
|
381
|
+
resourceId,
|
|
382
|
+
authorityEpoch,
|
|
383
|
+
headVersion,
|
|
384
|
+
snapshotBase64,
|
|
385
|
+
snapshotChecksum,
|
|
386
|
+
bootstrapRecords = null,
|
|
387
|
+
}) {
|
|
350
388
|
const currentIdentity = identity();
|
|
351
389
|
if (!currentIdentity.enabled || resourceId !== currentIdentity.resourceId) {
|
|
352
390
|
throw new Error('cloud entity registry identity does not belong to this runtime user');
|
|
@@ -355,8 +393,86 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
355
393
|
const checksum = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
356
394
|
if (checksum !== snapshotChecksum) throw new Error('cloud entity snapshot checksum mismatch');
|
|
357
395
|
const snapshot = parseSnapshot(bytes);
|
|
396
|
+
if (!Number.isInteger(Number(authorityEpoch)) || Number(authorityEpoch) < 1
|
|
397
|
+
|| !Number.isInteger(Number(headVersion)) || Number(headVersion) < 0) {
|
|
398
|
+
throw new Error('entity cloud registry authority is invalid');
|
|
399
|
+
}
|
|
358
400
|
const local = snapshotFromRecords(registry.syncRecords());
|
|
359
|
-
const
|
|
401
|
+
const current = replica();
|
|
402
|
+
const bootstrapping = current?.state === 'bootstrapping';
|
|
403
|
+
const reconciling = current?.state === BOOTSTRAP_RECONCILIATION_STATE;
|
|
404
|
+
const trustedBootstrap = Array.isArray(bootstrapRecords)
|
|
405
|
+
? snapshotFromRecords(bootstrapRecords).records
|
|
406
|
+
: null;
|
|
407
|
+
const emptyCloudGraph = snapshot.records.length === 0 && Number(headVersion) === 0;
|
|
408
|
+
|
|
409
|
+
// A pre-release client could create the automatic entity resource before
|
|
410
|
+
// the user-home handshake was complete, leaving a real private resource
|
|
411
|
+
// with an empty v0 graph. It is safe to repair only if the local graph is
|
|
412
|
+
// exactly the already-verified declared user home. This is not a general
|
|
413
|
+
// local-wins merge: user-selected ground remains refused until an active
|
|
414
|
+
// authoritative snapshot exists.
|
|
415
|
+
if (!current && emptyCloudGraph && trustedBootstrap && sameRecords(local.records, trustedBootstrap)) {
|
|
416
|
+
const next = {
|
|
417
|
+
resourceId,
|
|
418
|
+
deviceId: currentIdentity.deviceId,
|
|
419
|
+
authorityEpoch: Number(authorityEpoch),
|
|
420
|
+
headVersion: Number(headVersion),
|
|
421
|
+
state: BOOTSTRAP_RECONCILIATION_STATE,
|
|
422
|
+
snapshotCutoff: null,
|
|
423
|
+
};
|
|
424
|
+
db.transaction(() => {
|
|
425
|
+
saveReplica(next);
|
|
426
|
+
replaceCatalog({
|
|
427
|
+
resourceId,
|
|
428
|
+
authorityEpoch: Number(authorityEpoch),
|
|
429
|
+
headVersion: Number(headVersion),
|
|
430
|
+
records: snapshot.records,
|
|
431
|
+
});
|
|
432
|
+
enqueueBootstrapReconciliation(trustedBootstrap, next);
|
|
433
|
+
})();
|
|
434
|
+
return {
|
|
435
|
+
resourceId,
|
|
436
|
+
records: snapshot.records.length,
|
|
437
|
+
headVersion: Number(headVersion),
|
|
438
|
+
snapshotVersion: Number(headVersion),
|
|
439
|
+
reconciling: true,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (reconciling) {
|
|
444
|
+
if (!trustedBootstrap || !sameRecords(local.records, trustedBootstrap)) {
|
|
445
|
+
throw new Error('entity bootstrap reconciliation no longer matches the verified user-home graph');
|
|
446
|
+
}
|
|
447
|
+
if (current.resourceId !== resourceId || Number(authorityEpoch) !== current.authorityEpoch
|
|
448
|
+
|| Number(headVersion) < current.headVersion || !recordsAreSubset(snapshot.records, trustedBootstrap)) {
|
|
449
|
+
throw new Error('entity bootstrap reconciliation received an invalid cloud head');
|
|
450
|
+
}
|
|
451
|
+
const unsettled = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
|
|
452
|
+
.get(resourceId);
|
|
453
|
+
const complete = Number(unsettled.count) === 0 && sameRecords(snapshot.records, trustedBootstrap);
|
|
454
|
+
replaceCatalog({
|
|
455
|
+
resourceId,
|
|
456
|
+
authorityEpoch: Number(authorityEpoch),
|
|
457
|
+
headVersion: Number(headVersion),
|
|
458
|
+
records: snapshot.records,
|
|
459
|
+
});
|
|
460
|
+
saveReplica({
|
|
461
|
+
resourceId,
|
|
462
|
+
deviceId: currentIdentity.deviceId,
|
|
463
|
+
authorityEpoch: Number(authorityEpoch),
|
|
464
|
+
headVersion: Number(headVersion),
|
|
465
|
+
state: complete ? 'active' : BOOTSTRAP_RECONCILIATION_STATE,
|
|
466
|
+
snapshotCutoff: null,
|
|
467
|
+
});
|
|
468
|
+
return {
|
|
469
|
+
resourceId,
|
|
470
|
+
records: snapshot.records.length,
|
|
471
|
+
headVersion: Number(headVersion),
|
|
472
|
+
snapshotVersion: Number(headVersion),
|
|
473
|
+
reconciling: !complete,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
360
476
|
if (bootstrapping && !sameRecord(local, snapshot)) {
|
|
361
477
|
throw new Error('first cloud entity snapshot does not match the locally bootstrapped identity graph');
|
|
362
478
|
}
|
|
@@ -499,7 +615,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
499
615
|
|
|
500
616
|
function claim({ limit = 16, leaseMs = 30_000 } = {}) {
|
|
501
617
|
const current = replica();
|
|
502
|
-
if (!current || current.state
|
|
618
|
+
if (!current || !['active', BOOTSTRAP_RECONCILIATION_STATE].includes(current.state)) return [];
|
|
503
619
|
const boundedLimit = Math.max(1, Math.min(Number(limit) || 16, 100));
|
|
504
620
|
const leaseUntil = new Date(Date.now() + Math.max(1_000, Number(leaseMs) || 30_000)).toISOString();
|
|
505
621
|
const started = now();
|
|
@@ -547,7 +663,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
547
663
|
|
|
548
664
|
function claimContent({ leaseMs = 30_000 } = {}) {
|
|
549
665
|
const current = replica();
|
|
550
|
-
if (!current || current.state
|
|
666
|
+
if (!current || !['active', BOOTSTRAP_RECONCILIATION_STATE].includes(current.state) || !content) return null;
|
|
551
667
|
// Later registrations journal their record transactionally in SQLite.
|
|
552
668
|
// Derive its content-upload cursor only after that commit exists, so a
|
|
553
669
|
// failed transaction cannot publish a private object no entity names.
|
|
@@ -587,7 +703,15 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
587
703
|
const row = db.prepare('SELECT * FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_id = ?')
|
|
588
704
|
.get(resourceId, mutationId);
|
|
589
705
|
if (!row) throw new Error(`unknown entity cloud mutation ${mutationId}`);
|
|
590
|
-
if (row.mutation_state === 'synced')
|
|
706
|
+
if (row.mutation_state === 'synced') {
|
|
707
|
+
const unsettled = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
|
|
708
|
+
.get(resourceId);
|
|
709
|
+
return {
|
|
710
|
+
duplicate: true,
|
|
711
|
+
version: Number(row.official_version),
|
|
712
|
+
requiresSnapshot: current.state !== BOOTSTRAP_RECONCILIATION_STATE || Number(unsettled.count) === 0,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
591
715
|
if (!['pending', 'syncing'].includes(row.mutation_state)) throw new Error(`entity cloud mutation ${mutationId} cannot sync from ${row.mutation_state}`);
|
|
592
716
|
db.prepare(`
|
|
593
717
|
UPDATE entity_cloud_mutations
|
|
@@ -598,7 +722,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
598
722
|
if (Number(version) > current.headVersion) {
|
|
599
723
|
saveReplica({ ...current, headVersion: Number(version) });
|
|
600
724
|
}
|
|
601
|
-
|
|
725
|
+
const unsettled = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
|
|
726
|
+
.get(resourceId);
|
|
727
|
+
return {
|
|
728
|
+
duplicate: false,
|
|
729
|
+
version: Number(version),
|
|
730
|
+
requiresSnapshot: current.state !== BOOTSTRAP_RECONCILIATION_STATE || Number(unsettled.count) === 0,
|
|
731
|
+
};
|
|
602
732
|
})();
|
|
603
733
|
}
|
|
604
734
|
|
|
@@ -625,7 +755,9 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
625
755
|
|
|
626
756
|
function ingest({ registry, envelope, version, committedAt }) {
|
|
627
757
|
const current = replica();
|
|
628
|
-
if (!current ||
|
|
758
|
+
if (!current || !['active', BOOTSTRAP_RECONCILIATION_STATE].includes(current.state)) {
|
|
759
|
+
throw new Error('entity cloud registry is not active');
|
|
760
|
+
}
|
|
629
761
|
if (envelope?.resourceId !== current.resourceId
|
|
630
762
|
|| envelope?.contract !== ENTITY_CLOUD_CONTRACT
|
|
631
763
|
|| Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION
|
|
@@ -644,6 +776,9 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
644
776
|
committedAt,
|
|
645
777
|
});
|
|
646
778
|
}
|
|
779
|
+
if (current.state === BOOTSTRAP_RECONCILIATION_STATE) {
|
|
780
|
+
throw new Error('entity bootstrap reconciliation cannot accept an unrelated remote mutation');
|
|
781
|
+
}
|
|
647
782
|
// Remote mutations update the cloud-only catalog. They never create a
|
|
648
783
|
// local watcher; `amalgm add` alone turns catalog identity into ground.
|
|
649
784
|
let local;
|
|
@@ -259,6 +259,35 @@ function createRegistrationService({
|
|
|
259
259
|
};
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/** The only local graph allowed to repair an old empty entity resource is
|
|
263
|
+
* the exact home that the user-home handshake has already verified. Any
|
|
264
|
+
* extra local entity would be user-selected ground and must wait for an
|
|
265
|
+
* active entity-cloud authority instead. */
|
|
266
|
+
async function exactUserHomeCloudRecords() {
|
|
267
|
+
const home = await bootstrapUserHome();
|
|
268
|
+
const expected = new Map();
|
|
269
|
+
for (const entry of [...home.roots, ...home.exactManaged]) {
|
|
270
|
+
// `bootstrapUserHome` returns the public record used by the local
|
|
271
|
+
// registration boundary. The cloud proof must instead compare its
|
|
272
|
+
// complete logical form, including the null payload head carried by
|
|
273
|
+
// containers, with the registry's sync representation.
|
|
274
|
+
expected.set(entry.record.uuid, boundary.current.registry.syncRecord(entry.record.uuid));
|
|
275
|
+
}
|
|
276
|
+
const records = boundary.current.registry.syncRecords();
|
|
277
|
+
if (records.length !== expected.size) {
|
|
278
|
+
throw new Error('local entity graph contains ground outside the declared user home');
|
|
279
|
+
}
|
|
280
|
+
for (const record of records) {
|
|
281
|
+
const declared = expected.get(record.uuid);
|
|
282
|
+
if (!declared || ![
|
|
283
|
+
'uuid', 'type', 'parentUUID', 'name', 'status', 'version', 'payloadVersion',
|
|
284
|
+
].every((field) => record[field] === declared[field])) {
|
|
285
|
+
throw new Error('local entity graph does not exactly match the declared user home');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return records;
|
|
289
|
+
}
|
|
290
|
+
|
|
262
291
|
/** Materialize one already-pinned cloud tree into a fresh destination, then
|
|
263
292
|
* bind its cloud UUID graph to Watch. The destination is never merged with
|
|
264
293
|
* user ground: bytes are assembled in a sibling staging path and renamed
|
|
@@ -375,6 +404,7 @@ function createRegistrationService({
|
|
|
375
404
|
},
|
|
376
405
|
advanceMaterializations,
|
|
377
406
|
bootstrapUserHome,
|
|
407
|
+
exactUserHomeCloudRecords,
|
|
378
408
|
entityCloud,
|
|
379
409
|
entityContent,
|
|
380
410
|
/**
|
|
@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS entity_cloud_replica (
|
|
|
69
69
|
device_id TEXT NOT NULL,
|
|
70
70
|
authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1),
|
|
71
71
|
head_version INTEGER NOT NULL CHECK (head_version >= 0),
|
|
72
|
-
state TEXT NOT NULL CHECK (state IN ('active', 'bootstrapping')),
|
|
72
|
+
state TEXT NOT NULL CHECK (state IN ('active', 'bootstrapping', 'reconciling-bootstrap')),
|
|
73
73
|
snapshot_cutoff INTEGER,
|
|
74
74
|
updated_at TEXT NOT NULL
|
|
75
75
|
);
|
|
@@ -140,12 +140,17 @@ const TABLES = [...REPO_STATE_TABLES, ...CLOUD_TABLES, ...MATERIALIZATION_TABLES
|
|
|
140
140
|
// be known locally before any observer is bound, so they cannot masquerade
|
|
141
141
|
// as local registered ground. Generation 6 records the one snapshot version
|
|
142
142
|
// that populated that catalog; a later mutation tail cannot pose as a fresh
|
|
143
|
-
// active head for `amalgm add`.
|
|
143
|
+
// active head for `amalgm add`. Generation 7 adds the explicit
|
|
144
|
+
// `reconciling-bootstrap` state: an old, empty entity resource may be
|
|
145
|
+
// populated only from the already-verified exact user-home graph, and cannot
|
|
146
|
+
// admit ordinary registration or materialization until its ordered outbox
|
|
147
|
+
// becomes an active cloud snapshot. Generations 2 and 3 are
|
|
144
148
|
// additive predecessors: migrations only add tables beside existing identity
|
|
145
149
|
// records, never rewrite UUIDs or evidence bindings. The per-tree era never
|
|
146
150
|
// stamped a version and reads 0 — an older generation, refused distinctly
|
|
147
151
|
// from loss until an explicit migration exists.
|
|
148
|
-
const GENERATION =
|
|
152
|
+
const GENERATION = 7;
|
|
153
|
+
const BOOTSTRAP_RECONCILIATION_GENERATION = 6;
|
|
149
154
|
const MATERIALIZATION_GENERATION = 5;
|
|
150
155
|
const CLOUD_GENERATION = 4;
|
|
151
156
|
const REPO_STATE_GENERATION = 3;
|
|
@@ -173,6 +178,9 @@ function inspect(db) {
|
|
|
173
178
|
|
|
174
179
|
function isMigratableGenerationShape(db, inspection) {
|
|
175
180
|
const generation = db.pragma('user_version', { simple: true });
|
|
181
|
+
if (generation === BOOTSTRAP_RECONCILIATION_GENERATION) {
|
|
182
|
+
return inspection.missing.length === 0;
|
|
183
|
+
}
|
|
176
184
|
if (generation === MATERIALIZATION_GENERATION) {
|
|
177
185
|
return inspection.missing.length === 1
|
|
178
186
|
&& inspection.missing[0] === 'entity_cloud_catalog_state'
|
|
@@ -196,12 +204,45 @@ function isMigratableGenerationShape(db, inspection) {
|
|
|
196
204
|
&& REPO_STATE_TABLES.every((name) => !inspection.missing.includes(name));
|
|
197
205
|
}
|
|
198
206
|
|
|
207
|
+
/** SQLite cannot widen a CHECK constraint in place. This is a schema
|
|
208
|
+
* migration—not a state rewrite: every old row is copied byte-for-byte into
|
|
209
|
+
* the wider replica table in one transaction. */
|
|
210
|
+
function migrateBootstrapReconciliationState(db) {
|
|
211
|
+
db.transaction(() => {
|
|
212
|
+
db.exec(`
|
|
213
|
+
ALTER TABLE entity_cloud_replica RENAME TO entity_cloud_replica_v6;
|
|
214
|
+
CREATE TABLE entity_cloud_replica (
|
|
215
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
216
|
+
resource_id TEXT NOT NULL,
|
|
217
|
+
device_id TEXT NOT NULL,
|
|
218
|
+
authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1),
|
|
219
|
+
head_version INTEGER NOT NULL CHECK (head_version >= 0),
|
|
220
|
+
state TEXT NOT NULL CHECK (state IN ('active', 'bootstrapping', 'reconciling-bootstrap')),
|
|
221
|
+
snapshot_cutoff INTEGER,
|
|
222
|
+
updated_at TEXT NOT NULL
|
|
223
|
+
);
|
|
224
|
+
INSERT INTO entity_cloud_replica (
|
|
225
|
+
singleton, resource_id, device_id, authority_epoch, head_version,
|
|
226
|
+
state, snapshot_cutoff, updated_at
|
|
227
|
+
)
|
|
228
|
+
SELECT
|
|
229
|
+
singleton, resource_id, device_id, authority_epoch, head_version,
|
|
230
|
+
state, snapshot_cutoff, updated_at
|
|
231
|
+
FROM entity_cloud_replica_v6;
|
|
232
|
+
DROP TABLE entity_cloud_replica_v6;
|
|
233
|
+
`);
|
|
234
|
+
})();
|
|
235
|
+
}
|
|
236
|
+
|
|
199
237
|
function createStore(db) {
|
|
200
238
|
const generation = db.pragma('user_version', { simple: true });
|
|
201
239
|
const inspection = inspect(db);
|
|
202
240
|
if (!inspection.fresh && generation !== GENERATION && !isMigratableGenerationShape(db, inspection)) {
|
|
203
241
|
throw new Error(`identity registry is schema generation ${generation}, this code speaks ${GENERATION} — migration is an explicit act, never a silent table creation`);
|
|
204
242
|
}
|
|
243
|
+
if (generation === BOOTSTRAP_RECONCILIATION_GENERATION) {
|
|
244
|
+
migrateBootstrapReconciliationState(db);
|
|
245
|
+
}
|
|
205
246
|
db.exec(SCHEMA);
|
|
206
247
|
db.pragma(`user_version = ${GENERATION}`);
|
|
207
248
|
|
|
@@ -146,6 +146,19 @@ async function handleEntityRoutes(ctx) {
|
|
|
146
146
|
const body = await ctx.readJsonBody();
|
|
147
147
|
const service = registrationService();
|
|
148
148
|
try {
|
|
149
|
+
const existing = service.entityCloud.replica();
|
|
150
|
+
let requiresBootstrapProof = existing?.state === 'reconciling-bootstrap';
|
|
151
|
+
try {
|
|
152
|
+
const snapshot = JSON.parse(Buffer.from(String(body?.snapshotBase64 || ''), 'base64').toString('utf8'));
|
|
153
|
+
requiresBootstrapProof ||= Number(body?.headVersion) === 0
|
|
154
|
+
&& Array.isArray(snapshot?.records) && snapshot.records.length === 0;
|
|
155
|
+
} catch {
|
|
156
|
+
// Install validates malformed snapshots. This lookahead merely decides
|
|
157
|
+
// whether the legacy empty-resource repair needs its exact local proof.
|
|
158
|
+
}
|
|
159
|
+
const bootstrapRecords = requiresBootstrapProof
|
|
160
|
+
? await service.exactUserHomeCloudRecords()
|
|
161
|
+
: null;
|
|
149
162
|
ctx.sendJson(200, service.entityCloud.install({
|
|
150
163
|
registry: service.registration.registry,
|
|
151
164
|
resourceId: body?.resourceId,
|
|
@@ -153,6 +166,7 @@ async function handleEntityRoutes(ctx) {
|
|
|
153
166
|
headVersion: body?.headVersion,
|
|
154
167
|
snapshotBase64: body?.snapshotBase64,
|
|
155
168
|
snapshotChecksum: body?.snapshotChecksum,
|
|
169
|
+
bootstrapRecords,
|
|
156
170
|
}));
|
|
157
171
|
} catch (error) {
|
|
158
172
|
ctx.sendJson(409, { error: error.message });
|
|
@@ -76,6 +76,69 @@ test('a signed-in registration is refused until the cloud entity resource is act
|
|
|
76
76
|
assert.doesNotThrow(() => cloud.assertRegistrationReady());
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('only an exact verified bootstrap graph can repair a legacy empty entity resource', () => {
|
|
80
|
+
const db = new Database(':memory:');
|
|
81
|
+
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
|
|
82
|
+
const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
83
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'agents' });
|
|
84
|
+
const resourceId = cloud.identity().resourceId;
|
|
85
|
+
const empty = Buffer.from(JSON.stringify(snapshotFromRecords([])), 'utf8');
|
|
86
|
+
|
|
87
|
+
const first = cloud.install({
|
|
88
|
+
registry,
|
|
89
|
+
resourceId,
|
|
90
|
+
authorityEpoch: 1,
|
|
91
|
+
headVersion: 0,
|
|
92
|
+
snapshotBase64: empty.toString('base64'),
|
|
93
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(empty).digest('hex'),
|
|
94
|
+
bootstrapRecords: registry.syncRecords(),
|
|
95
|
+
});
|
|
96
|
+
assert.equal(first.reconciling, true);
|
|
97
|
+
assert.equal(cloud.replica().state, 'reconciling-bootstrap');
|
|
98
|
+
assert.throws(() => cloud.assertRegistrationReady(), /not ready/,
|
|
99
|
+
'ordinary register stays closed until the ordered repair reaches a full cloud head');
|
|
100
|
+
|
|
101
|
+
const claimed = cloud.claim();
|
|
102
|
+
assert.equal(claimed.length, 1);
|
|
103
|
+
assert.equal(claimed[0].baseVersion, 0);
|
|
104
|
+
assert.equal(cloud.acknowledge({
|
|
105
|
+
resourceId,
|
|
106
|
+
mutationId: claimed[0].mutationId,
|
|
107
|
+
version: 1,
|
|
108
|
+
committedAt: '2026-08-05T00:00:00.000Z',
|
|
109
|
+
}).requiresSnapshot, true, 'only the final ordered acknowledgement asks for a usable snapshot');
|
|
110
|
+
|
|
111
|
+
const completed = snapshotFromRecords(registry.syncRecords());
|
|
112
|
+
const final = cloud.install({
|
|
113
|
+
registry,
|
|
114
|
+
resourceId,
|
|
115
|
+
authorityEpoch: 1,
|
|
116
|
+
headVersion: 1,
|
|
117
|
+
snapshotBase64: Buffer.from(JSON.stringify(completed), 'utf8').toString('base64'),
|
|
118
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(JSON.stringify(completed)).digest('hex'),
|
|
119
|
+
bootstrapRecords: registry.syncRecords(),
|
|
120
|
+
});
|
|
121
|
+
assert.equal(final.reconciling, false);
|
|
122
|
+
assert.equal(cloud.replica().state, 'active');
|
|
123
|
+
assert.doesNotThrow(() => cloud.assertRegistrationReady());
|
|
124
|
+
|
|
125
|
+
const unsafeDb = new Database(':memory:');
|
|
126
|
+
const unsafeCloud = createEntityCloud({ db: unsafeDb, userId: 'user-1', deviceId: 'unsafe-device' });
|
|
127
|
+
const unsafeRegistry = createRegistry({ db: unsafeDb, ephemeral: true, emit: () => {}, journal: unsafeCloud.journal });
|
|
128
|
+
const safeRoot = unsafeRegistry.enrollRoot({ type: 'workspace', name: 'agents' });
|
|
129
|
+
unsafeRegistry.create({ type: 'file.text', parentUUID: safeRoot.uuid, name: 'not-a-default.md', payloadVersion: 'a'.repeat(64) });
|
|
130
|
+
assert.throws(() => unsafeCloud.install({
|
|
131
|
+
registry: unsafeRegistry,
|
|
132
|
+
resourceId,
|
|
133
|
+
authorityEpoch: 1,
|
|
134
|
+
headVersion: 0,
|
|
135
|
+
snapshotBase64: empty.toString('base64'),
|
|
136
|
+
snapshotChecksum: require('crypto').createHash('sha256').update(empty).digest('hex'),
|
|
137
|
+
bootstrapRecords: unsafeRegistry.syncRecords().filter((record) => record.uuid === safeRoot.uuid),
|
|
138
|
+
}), /conflicts with locally bound ground/,
|
|
139
|
+
'an empty cloud resource never authorizes arbitrary local ground');
|
|
140
|
+
});
|
|
141
|
+
|
|
79
142
|
test('a compacted remote snapshot enters the cloud catalog, not locally watched identity, and add claims its immutable content', () => {
|
|
80
143
|
const sourceDb = new Database(':memory:');
|
|
81
144
|
const sourceCloud = createEntityCloud({ db: sourceDb, userId: 'user-1', deviceId: 'source-device' });
|
|
@@ -97,7 +97,7 @@ test('the additive entity-cloud migration preserves a generation-2 registry', ()
|
|
|
97
97
|
|
|
98
98
|
const registry = createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
99
99
|
assert.equal(registry.epoch(), 'legacy-epoch', 'the existing identity epoch survives');
|
|
100
|
-
assert.equal(db.pragma('user_version', { simple: true }),
|
|
100
|
+
assert.equal(db.pragma('user_version', { simple: true }), 7);
|
|
101
101
|
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'repo_states'").get(),
|
|
102
102
|
'the repo-state document table is added without replacing identity');
|
|
103
103
|
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_mutations'").get(),
|
|
@@ -114,11 +114,45 @@ test('the active-snapshot marker migrates beside an existing materialization cat
|
|
|
114
114
|
|
|
115
115
|
const reopened = createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
116
116
|
assert.ok(reopened.epoch(), 'the previous identity database remains usable');
|
|
117
|
-
assert.equal(db.pragma('user_version', { simple: true }),
|
|
117
|
+
assert.equal(db.pragma('user_version', { simple: true }), 7);
|
|
118
118
|
assert.ok(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'entity_cloud_catalog_state'").get(),
|
|
119
119
|
'the separate pinned-snapshot marker is created without rewriting identities');
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
test('the bootstrap-reconciliation migration preserves a generation-6 cloud replica', () => {
|
|
123
|
+
const db = new Database(':memory:');
|
|
124
|
+
createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
125
|
+
db.exec([
|
|
126
|
+
'ALTER TABLE entity_cloud_replica RENAME TO entity_cloud_replica_v7;',
|
|
127
|
+
'CREATE TABLE entity_cloud_replica (',
|
|
128
|
+
' singleton INTEGER PRIMARY KEY CHECK (singleton = 1),',
|
|
129
|
+
' resource_id TEXT NOT NULL,',
|
|
130
|
+
' device_id TEXT NOT NULL,',
|
|
131
|
+
' authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1),',
|
|
132
|
+
' head_version INTEGER NOT NULL CHECK (head_version >= 0),',
|
|
133
|
+
" state TEXT NOT NULL CHECK (state IN ('active', 'bootstrapping')),",
|
|
134
|
+
' snapshot_cutoff INTEGER,',
|
|
135
|
+
' updated_at TEXT NOT NULL',
|
|
136
|
+
');',
|
|
137
|
+
'INSERT INTO entity_cloud_replica SELECT * FROM entity_cloud_replica_v7;',
|
|
138
|
+
'DROP TABLE entity_cloud_replica_v7;',
|
|
139
|
+
].join('\n'));
|
|
140
|
+
db.prepare([
|
|
141
|
+
'INSERT INTO entity_cloud_replica (',
|
|
142
|
+
' singleton, resource_id, device_id, authority_epoch, head_version,',
|
|
143
|
+
' state, snapshot_cutoff, updated_at',
|
|
144
|
+
") VALUES (1, 'pvr_legacy', 'device', 1, 4, 'active', NULL, 'now')",
|
|
145
|
+
].join('\n')).run();
|
|
146
|
+
db.pragma('user_version = 6');
|
|
147
|
+
|
|
148
|
+
createRegistry({ db, ephemeral: true, emit: () => {} });
|
|
149
|
+
assert.equal(db.pragma('user_version', { simple: true }), 7);
|
|
150
|
+
assert.equal(db.prepare('SELECT state FROM entity_cloud_replica WHERE singleton = 1').get().state, 'active');
|
|
151
|
+
assert.doesNotThrow(() => db.prepare(
|
|
152
|
+
"UPDATE entity_cloud_replica SET state = 'reconciling-bootstrap' WHERE singleton = 1",
|
|
153
|
+
).run(), 'the widened state is durable after migration');
|
|
154
|
+
});
|
|
155
|
+
|
|
122
156
|
test('an entity commit and its cloud delivery intent are one SQLite transaction', () => {
|
|
123
157
|
const db = new Database(':memory:');
|
|
124
158
|
let nextMutation = 0;
|
|
@@ -217,6 +217,14 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
const cloudRecords = await service.exactUserHomeCloudRecords();
|
|
221
|
+
assert.equal(cloudRecords.length, 9,
|
|
222
|
+
'the declared home is the entire graph eligible to repair a legacy empty cloud registry');
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
new Set(cloudRecords.map((record) => record.uuid)),
|
|
225
|
+
new Set([...bootstrapped.roots, ...bootstrapped.exactManaged].map((entry) => entry.record.uuid)),
|
|
226
|
+
);
|
|
227
|
+
|
|
220
228
|
const repeat = await service.bootstrapUserHome();
|
|
221
229
|
assert.deepEqual(
|
|
222
230
|
repeat.roots.map((entry) => entry.record.uuid),
|