amalgm 0.1.249 → 0.1.251
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/cli.js +10 -0
- package/lib/runtime-identity.js +4 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +173 -30
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +29 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +16 -3
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +8 -2
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +26 -4
- package/runtime/scripts/amalgm-mcp/registration/service.js +305 -6
- package/runtime/scripts/amalgm-mcp/registration/tree.js +29 -8
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +4 -5
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +50 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +242 -1
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +37 -0
package/lib/cli.js
CHANGED
|
@@ -1456,6 +1456,8 @@ async function register(positionals) {
|
|
|
1456
1456
|
const targets = (positionals.length > 0 ? positionals : [process.cwd()])
|
|
1457
1457
|
.map((target) => path.resolve(target));
|
|
1458
1458
|
|
|
1459
|
+
console.log(`Registering ${targets.length === 1 ? targets[0] : `${targets.length} locations`}…`);
|
|
1460
|
+
|
|
1459
1461
|
// A resolved port is not a running runtime — the manifest supplies a
|
|
1460
1462
|
// default whether or not anything listens — so both "no port for this
|
|
1461
1463
|
// scope" and "nothing answered" mean the same thing to the user.
|
|
@@ -1487,6 +1489,8 @@ async function register(positionals) {
|
|
|
1487
1489
|
const target = targets[index];
|
|
1488
1490
|
console.log(`Registered ${rootPath}`);
|
|
1489
1491
|
console.log(` ${record.type} ${record.uuid}`);
|
|
1492
|
+
console.log(' Watching: on');
|
|
1493
|
+
console.log(' Cloud: queued');
|
|
1490
1494
|
if (rootPath !== target) console.log(` (${target} is already covered by this tree)`);
|
|
1491
1495
|
}
|
|
1492
1496
|
}
|
|
@@ -1504,6 +1508,7 @@ async function add(positionals) {
|
|
|
1504
1508
|
throw new Error('amalgm add requires a cloud entity UUID');
|
|
1505
1509
|
}
|
|
1506
1510
|
const targetPath = path.resolve(positionals[1] || process.cwd());
|
|
1511
|
+
console.log(`Adding ${uuid} to ${targetPath}…`);
|
|
1507
1512
|
const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
|
|
1508
1513
|
let answer = null;
|
|
1509
1514
|
if (port) {
|
|
@@ -1539,6 +1544,9 @@ async function add(positionals) {
|
|
|
1539
1544
|
return;
|
|
1540
1545
|
}
|
|
1541
1546
|
let job = answer.payload;
|
|
1547
|
+
if (job?.state === 'waiting_for_content' || job?.state === 'materializing') {
|
|
1548
|
+
console.log(' Downloading cloud content…');
|
|
1549
|
+
}
|
|
1542
1550
|
const deadline = Date.now() + 120_000;
|
|
1543
1551
|
while (job?.state === 'waiting_for_content' || job?.state === 'materializing') {
|
|
1544
1552
|
if (Date.now() > deadline) {
|
|
@@ -1555,6 +1563,8 @@ async function add(positionals) {
|
|
|
1555
1563
|
return;
|
|
1556
1564
|
}
|
|
1557
1565
|
console.log(`Added ${job.entityUuid} to ${job.targetPath}`);
|
|
1566
|
+
console.log(' Watching: on');
|
|
1567
|
+
console.log(' Cloud: live');
|
|
1558
1568
|
}
|
|
1559
1569
|
|
|
1560
1570
|
/** POST JSON to a local runtime service, carrying the runtime token. */
|
package/lib/runtime-identity.js
CHANGED
|
@@ -50,7 +50,10 @@ function runtimeBranchForLabel(label, env = process.env) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function npmTagForRuntimeLabel(label) {
|
|
53
|
-
|
|
53
|
+
// Releases are promoted deliberately to `main`; `latest` is retained as
|
|
54
|
+
// the public compatibility channel and may therefore lag the runtime we
|
|
55
|
+
// boot. A main installation must update from the channel it actually runs.
|
|
56
|
+
return label === 'preview' ? 'canary' : 'main';
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function sanitizeScopeSegment(value, fallback = 'local') {
|
package/package.json
CHANGED
|
@@ -14,6 +14,8 @@ const crypto = require('crypto');
|
|
|
14
14
|
const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@1';
|
|
15
15
|
const ENTITY_CLOUD_SCHEMA_VERSION = 1;
|
|
16
16
|
const BOOTSTRAP_RECONCILIATION_STATE = 'reconciling-bootstrap';
|
|
17
|
+
const SNAPSHOT_REPLACE_OPERATION = 'entity.snapshot.replace';
|
|
18
|
+
const SNAPSHOT_REPLACE_MAX_BYTES = 8 * 1024 * 1024;
|
|
17
19
|
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;
|
|
18
20
|
const TYPES = new Set([
|
|
19
21
|
'workspace', 'folder', 'repo.git', 'file.text', 'file.binary', 'link', 'reference',
|
|
@@ -57,12 +59,21 @@ function assertRecord(value) {
|
|
|
57
59
|
|| typeof value.name !== 'string' || !value.name || value.name.includes('/') || value.name.includes('\0')
|
|
58
60
|
|| !['active', 'trashed', 'deleted'].includes(value.status)
|
|
59
61
|
|| typeof value.version !== 'string' || !/^[0-9a-f]{64}$/.test(value.version)
|
|
60
|
-
|| (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')
|
|
62
|
+
|| (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')
|
|
63
|
+
|| (value.transportVersion !== undefined && value.transportVersion !== null
|
|
64
|
+
&& (typeof value.transportVersion !== 'string' || !CONTENT_HEAD.test(value.transportVersion)))) {
|
|
61
65
|
throw new Error('cloud entity snapshot has an invalid record');
|
|
62
66
|
}
|
|
63
67
|
if (value.parentUUID === null && !ROOT_TYPES.has(value.type)) {
|
|
64
68
|
throw new Error(`cloud entity ${value.uuid} is not a valid tree root`);
|
|
65
69
|
}
|
|
70
|
+
const transportVersion = value.transportVersion ?? null;
|
|
71
|
+
if (value.type === 'repo.git' && transportVersion !== null && !CONTENT_HEAD.test(transportVersion)) {
|
|
72
|
+
throw new Error('cloud repository transport head is invalid');
|
|
73
|
+
}
|
|
74
|
+
if (value.type !== 'repo.git' && transportVersion !== null) {
|
|
75
|
+
throw new Error('only repository entities may declare a transport head');
|
|
76
|
+
}
|
|
66
77
|
return {
|
|
67
78
|
uuid: value.uuid.toLowerCase(),
|
|
68
79
|
type: value.type,
|
|
@@ -71,6 +82,7 @@ function assertRecord(value) {
|
|
|
71
82
|
status: value.status,
|
|
72
83
|
version: value.version,
|
|
73
84
|
payloadVersion: value.payloadVersion,
|
|
85
|
+
transportVersion,
|
|
74
86
|
};
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -106,26 +118,48 @@ function parseSnapshot(bytes) {
|
|
|
106
118
|
* no watcher imports: a cloud record may be known on this machine long before
|
|
107
119
|
* `amalgm add <uuid>` chooses and validates local ground for it.
|
|
108
120
|
*/
|
|
109
|
-
function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
121
|
+
function createEntityCloud({ db, userId, deviceId, content = null, repoStates = null, repoTransportFor = null }) {
|
|
110
122
|
if (!db) throw new Error('createEntityCloud requires an identity database');
|
|
111
123
|
const ownerUserId = typeof userId === 'string' && userId ? userId : null;
|
|
112
124
|
const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
|
|
113
125
|
|
|
126
|
+
function cloudRecord(record) {
|
|
127
|
+
const normalized = assertRecord(record);
|
|
128
|
+
if (normalized.type !== 'repo.git' || normalized.transportVersion !== null) return normalized;
|
|
129
|
+
const supplied = repoTransportFor?.(normalized) || null;
|
|
130
|
+
const state = repoStates?.get(normalized.uuid);
|
|
131
|
+
const transportVersion = supplied || state?.transportVersion || null;
|
|
132
|
+
if ((state && state.stateId !== normalized.payloadVersion) || !CONTENT_HEAD.test(String(transportVersion || ''))) {
|
|
133
|
+
throw new Error(`repository ${normalized.uuid} has no immutable Repocard transport for state ${normalized.payloadVersion || 'none'}`);
|
|
134
|
+
}
|
|
135
|
+
return { ...normalized, transportVersion };
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
function contentHead(record) {
|
|
115
|
-
|
|
116
|
-
&& typeof record?.payloadVersion === 'string'
|
|
117
|
-
|
|
139
|
+
if (['file.text', 'file.binary'].includes(record?.type)
|
|
140
|
+
&& typeof record?.payloadVersion === 'string' && CONTENT_HEAD.test(record.payloadVersion)) {
|
|
141
|
+
return record.payloadVersion;
|
|
142
|
+
}
|
|
143
|
+
if (record?.type === 'repo.git' && typeof record?.transportVersion === 'string'
|
|
144
|
+
&& CONTENT_HEAD.test(record.transportVersion)) return record.transportVersion;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function cloudSnapshot(records) {
|
|
149
|
+
return snapshotFromRecords(records.map(cloudRecord));
|
|
118
150
|
}
|
|
119
151
|
|
|
120
|
-
function queueContent(
|
|
121
|
-
|
|
152
|
+
function queueContent(input, resourceId, { queue = false } = {}) {
|
|
153
|
+
const record = cloudRecord(input);
|
|
154
|
+
const head = contentHead(record);
|
|
155
|
+
if (!head) return;
|
|
122
156
|
if (!content || typeof content.has !== 'function') {
|
|
123
157
|
throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
|
|
124
158
|
}
|
|
125
|
-
if (!content.has({ contentHash:
|
|
159
|
+
if (!content.has({ contentHash: head })) {
|
|
126
160
|
throw new Error('file entity bytes were not captured before its cloud head committed');
|
|
127
161
|
}
|
|
128
|
-
if (queue) content.ensureUpload({ resourceId, contentHash:
|
|
162
|
+
if (queue) content.ensureUpload({ resourceId, contentHash: head });
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
function replica() {
|
|
@@ -301,15 +335,16 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
301
335
|
// The SQLite mutation is the durable delivery intent. Its post-commit
|
|
302
336
|
// content drain creates the filesystem upload cursor; doing that here
|
|
303
337
|
// would let a rolled-back identity write upload unreachable bytes.
|
|
304
|
-
|
|
338
|
+
const cloud = cloudRecord(record);
|
|
339
|
+
queueContent(cloud, current.resourceId);
|
|
305
340
|
upsertCatalogRecord({
|
|
306
341
|
resourceId: current.resourceId,
|
|
307
342
|
authorityEpoch: current.authorityEpoch,
|
|
308
343
|
headVersion: current.headVersion,
|
|
309
|
-
record,
|
|
344
|
+
record: cloud,
|
|
310
345
|
});
|
|
311
346
|
const mutationId = `ent_${crypto.randomBytes(18).toString('base64url')}`;
|
|
312
|
-
const operation = { kind: 'entity.upsert', record:
|
|
347
|
+
const operation = { kind: 'entity.upsert', record: cloud };
|
|
313
348
|
const createdAt = now();
|
|
314
349
|
db.prepare(`
|
|
315
350
|
INSERT INTO entity_cloud_mutations (
|
|
@@ -319,6 +354,45 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
319
354
|
`).run(current.resourceId, mutationId, stableJson(operation), createdAt, createdAt);
|
|
320
355
|
}
|
|
321
356
|
|
|
357
|
+
/** Publish one complete logical graph as one atomic cloud operation. A
|
|
358
|
+
* large registration must never expose a half-created parent/child graph
|
|
359
|
+
* between individual acknowledgements. Ordinary watcher edits remain
|
|
360
|
+
* single-record operations. */
|
|
361
|
+
function replaceSnapshot(records) {
|
|
362
|
+
const current = replica();
|
|
363
|
+
if (!current || current.state !== 'active') {
|
|
364
|
+
throw new Error('entity cloud registry is not ready yet; wait for the signed-in runtime to finish its first cloud handshake');
|
|
365
|
+
}
|
|
366
|
+
const unsettled = db.prepare(`
|
|
367
|
+
SELECT COUNT(*) AS count FROM entity_cloud_mutations
|
|
368
|
+
WHERE resource_id = ? AND mutation_state <> 'synced'
|
|
369
|
+
`).get(current.resourceId);
|
|
370
|
+
if (Number(unsettled.count) !== 0) {
|
|
371
|
+
throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
|
|
372
|
+
}
|
|
373
|
+
const snapshot = cloudSnapshot(records);
|
|
374
|
+
const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
|
|
375
|
+
const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
|
|
376
|
+
if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
|
|
377
|
+
throw new Error(`registered entity graph is ${bytes} bytes; the atomic cloud registration limit is ${SNAPSHOT_REPLACE_MAX_BYTES} bytes`);
|
|
378
|
+
}
|
|
379
|
+
for (const record of snapshot.records) queueContent(record, current.resourceId, { queue: true });
|
|
380
|
+
const createdAt = now();
|
|
381
|
+
db.prepare(`
|
|
382
|
+
INSERT INTO entity_cloud_mutations (
|
|
383
|
+
resource_id, mutation_id, operation_json, mutation_state,
|
|
384
|
+
created_at, updated_at
|
|
385
|
+
) VALUES (?, ?, ?, 'pending', ?, ?)
|
|
386
|
+
`).run(
|
|
387
|
+
current.resourceId,
|
|
388
|
+
`ent_${crypto.randomBytes(18).toString('base64url')}`,
|
|
389
|
+
stableJson(operation),
|
|
390
|
+
createdAt,
|
|
391
|
+
createdAt,
|
|
392
|
+
);
|
|
393
|
+
return { records: snapshot.records.length, bytes };
|
|
394
|
+
}
|
|
395
|
+
|
|
322
396
|
function enqueueBootstrapReconciliation(records, current) {
|
|
323
397
|
const pending = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
|
|
324
398
|
.get(current.resourceId);
|
|
@@ -327,7 +401,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
327
401
|
}
|
|
328
402
|
const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
|
|
329
403
|
const createdAt = now();
|
|
330
|
-
for (const record of
|
|
404
|
+
for (const record of cloudSnapshot(records).records) {
|
|
331
405
|
queueContent(record, current.resourceId, { queue: true });
|
|
332
406
|
insert.run(
|
|
333
407
|
current.resourceId,
|
|
@@ -357,7 +431,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
357
431
|
if (existing?.state === 'active') {
|
|
358
432
|
throw new Error('entity cloud registry already exists locally; cloud lookup must not create it again');
|
|
359
433
|
}
|
|
360
|
-
const snapshot =
|
|
434
|
+
const snapshot = cloudSnapshot(registry.syncRecords());
|
|
361
435
|
const cutoff = db.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get().sequence;
|
|
362
436
|
saveReplica({
|
|
363
437
|
resourceId: currentIdentity.resourceId,
|
|
@@ -397,7 +471,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
397
471
|
|| !Number.isInteger(Number(headVersion)) || Number(headVersion) < 0) {
|
|
398
472
|
throw new Error('entity cloud registry authority is invalid');
|
|
399
473
|
}
|
|
400
|
-
const local =
|
|
474
|
+
const local = cloudSnapshot(registry.syncRecords());
|
|
401
475
|
const current = replica();
|
|
402
476
|
const bootstrapping = current?.state === 'bootstrapping';
|
|
403
477
|
const reconciling = current?.state === BOOTSTRAP_RECONCILIATION_STATE;
|
|
@@ -526,7 +600,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
526
600
|
if (typeof targetPath !== 'string' || !targetPath) throw new Error('materialization requires a destination path');
|
|
527
601
|
const tree = catalogTree({ resourceId: current.resourceId, uuid });
|
|
528
602
|
if (tree[0].status !== 'active') throw new Error(`cloud entity ${uuid} is not active`);
|
|
529
|
-
const unsupported = tree.find((record) => ['
|
|
603
|
+
const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
|
|
530
604
|
if (unsupported) {
|
|
531
605
|
throw new Error(`cloud entity ${unsupported.uuid} is ${unsupported.type}; this first materializer supports ordinary workspace and file trees only`);
|
|
532
606
|
}
|
|
@@ -563,13 +637,14 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
563
637
|
if (job.resourceId !== current.resourceId) continue;
|
|
564
638
|
const tree = catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
|
|
565
639
|
for (const record of tree) {
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
640
|
+
if (record.status !== 'active') continue;
|
|
641
|
+
const head = contentHead(record);
|
|
642
|
+
if (!head) continue;
|
|
643
|
+
const part = content.missingPart({ contentHash: head });
|
|
569
644
|
if (!part) continue;
|
|
570
645
|
return {
|
|
571
646
|
resourceId: job.resourceId,
|
|
572
|
-
contentHash:
|
|
647
|
+
contentHash: head,
|
|
573
648
|
kind: part.kind,
|
|
574
649
|
partIndex: part.partIndex,
|
|
575
650
|
sha256: part.sha256,
|
|
@@ -613,6 +688,47 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
613
688
|
return materialization(db.prepare('SELECT * FROM entity_materializations WHERE job_id = ?').get(jobId));
|
|
614
689
|
}
|
|
615
690
|
|
|
691
|
+
/** A completed add is a live binding, not a one-time download. A remote
|
|
692
|
+
* record beneath that binding moves the durable job back to its content
|
|
693
|
+
* gate. The service owns the later filesystem application; this module
|
|
694
|
+
* owns only the durable intent to do it. */
|
|
695
|
+
function refreshMaterializations({ resourceId, record, version }) {
|
|
696
|
+
const refreshed = [];
|
|
697
|
+
for (const job of db.prepare(`
|
|
698
|
+
SELECT * FROM entity_materializations
|
|
699
|
+
WHERE resource_id = ? AND state = 'complete'
|
|
700
|
+
ORDER BY created_at ASC
|
|
701
|
+
`).all(resourceId)) {
|
|
702
|
+
const tree = catalogTree({ resourceId, uuid: job.entity_uuid });
|
|
703
|
+
if (!tree.some((candidate) => candidate.uuid === record.uuid)) continue;
|
|
704
|
+
db.prepare(`
|
|
705
|
+
UPDATE entity_materializations
|
|
706
|
+
SET head_version = ?, state = 'waiting_for_content', error = NULL, updated_at = ?
|
|
707
|
+
WHERE job_id = ?
|
|
708
|
+
`).run(Number(version), now(), job.job_id);
|
|
709
|
+
refreshed.push(job.job_id);
|
|
710
|
+
}
|
|
711
|
+
return refreshed;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function hasUnsettledLocalMutation(uuid) {
|
|
715
|
+
const rows = db.prepare(`
|
|
716
|
+
SELECT operation_json FROM entity_cloud_mutations
|
|
717
|
+
WHERE resource_id = ? AND mutation_state <> 'synced'
|
|
718
|
+
ORDER BY sequence ASC
|
|
719
|
+
`).all(replica()?.resourceId || '');
|
|
720
|
+
return rows.some((row) => {
|
|
721
|
+
try {
|
|
722
|
+
const operation = JSON.parse(row.operation_json);
|
|
723
|
+
return operation?.record?.uuid === uuid
|
|
724
|
+
|| operation?.snapshot?.records?.some((record) => record?.uuid === uuid) === true;
|
|
725
|
+
} catch {
|
|
726
|
+
// A corrupt local outbox must never authorize an overwrite.
|
|
727
|
+
return true;
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
616
732
|
function claim({ limit = 16, leaseMs = 30_000 } = {}) {
|
|
617
733
|
const current = replica();
|
|
618
734
|
if (!current || !['active', BOOTSTRAP_RECONCILIATION_STATE].includes(current.state)) return [];
|
|
@@ -641,8 +757,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
641
757
|
// The logical mutation is intentionally held behind the immutable
|
|
642
758
|
// content manifest. A cloud graph never advances to a file head
|
|
643
759
|
// whose bytes have not reached private R2 storage.
|
|
644
|
-
|
|
645
|
-
|
|
760
|
+
const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
|
|
761
|
+
? operation?.snapshot?.records
|
|
762
|
+
: [operation?.record];
|
|
763
|
+
if ((records || []).some((record) => {
|
|
764
|
+
const head = contentHead(record);
|
|
765
|
+
return head && (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: head }));
|
|
766
|
+
})) {
|
|
646
767
|
return [];
|
|
647
768
|
}
|
|
648
769
|
claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
|
|
@@ -654,7 +775,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
654
775
|
baseVersion: current.headVersion,
|
|
655
776
|
contract: ENTITY_CLOUD_CONTRACT,
|
|
656
777
|
schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
|
|
657
|
-
operationKind:
|
|
778
|
+
operationKind: operation.kind,
|
|
658
779
|
operation,
|
|
659
780
|
}];
|
|
660
781
|
});
|
|
@@ -673,8 +794,11 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
673
794
|
ORDER BY sequence ASC
|
|
674
795
|
`).all(current.resourceId);
|
|
675
796
|
for (const row of rows) {
|
|
676
|
-
const
|
|
677
|
-
|
|
797
|
+
const operation = JSON.parse(row.operation_json);
|
|
798
|
+
const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
|
|
799
|
+
? operation?.snapshot?.records
|
|
800
|
+
: [operation?.record];
|
|
801
|
+
for (const record of records || []) queueContent(record, current.resourceId, { queue: true });
|
|
678
802
|
}
|
|
679
803
|
return content.claim({ resourceId: current.resourceId, leaseMs });
|
|
680
804
|
}
|
|
@@ -760,9 +884,18 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
760
884
|
}
|
|
761
885
|
if (envelope?.resourceId !== current.resourceId
|
|
762
886
|
|| envelope?.contract !== ENTITY_CLOUD_CONTRACT
|
|
763
|
-
|| Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION
|
|
764
|
-
|
|
765
|
-
|
|
887
|
+
|| Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION) {
|
|
888
|
+
throw new Error('cloud entity mutation does not belong to this registry');
|
|
889
|
+
}
|
|
890
|
+
if (envelope?.operationKind === SNAPSHOT_REPLACE_OPERATION
|
|
891
|
+
&& envelope?.operation?.kind === SNAPSHOT_REPLACE_OPERATION) {
|
|
892
|
+
// The complete replacement is imported only from the active snapshot
|
|
893
|
+
// the gateway makes before acknowledgement. Consuming its tail would
|
|
894
|
+
// reintroduce the very partial-graph state this operation prevents.
|
|
895
|
+
if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
|
|
896
|
+
return { duplicate: true, version: Number(version), snapshotRequired: true, materializations: [] };
|
|
897
|
+
}
|
|
898
|
+
if (envelope?.operationKind !== 'entity.upsert' || envelope?.operation?.kind !== 'entity.upsert') {
|
|
766
899
|
throw new Error('cloud entity mutation does not belong to this registry');
|
|
767
900
|
}
|
|
768
901
|
const record = assertRecord(envelope.operation.record);
|
|
@@ -781,9 +914,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
781
914
|
}
|
|
782
915
|
// Remote mutations update the cloud-only catalog. They never create a
|
|
783
916
|
// local watcher; `amalgm add` alone turns catalog identity into ground.
|
|
917
|
+
// A pre-existing local record is normally a conflict, except for a
|
|
918
|
+
// completed add binding with no unsent local edit. That binding will
|
|
919
|
+
// apply the authoritative cloud record through its own watcher-safe
|
|
920
|
+
// materializer below; it must not be stranded at its first snapshot.
|
|
784
921
|
let local;
|
|
785
922
|
try { local = registry.syncRecord(record.uuid); } catch { local = null; }
|
|
786
|
-
if (local && !sameRecord(local, record)) {
|
|
923
|
+
if (local && !sameRecord(local, record) && hasUnsettledLocalMutation(record.uuid)) {
|
|
787
924
|
throw new Error('remote entity mutation conflicts with this device; refresh the compacted cloud head before applying it locally');
|
|
788
925
|
}
|
|
789
926
|
upsertCatalogRecord({
|
|
@@ -793,12 +930,17 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
793
930
|
record,
|
|
794
931
|
});
|
|
795
932
|
if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
|
|
796
|
-
return {
|
|
933
|
+
return {
|
|
934
|
+
duplicate: true,
|
|
935
|
+
version: Number(version),
|
|
936
|
+
materializations: refreshMaterializations({ resourceId: current.resourceId, record, version }),
|
|
937
|
+
};
|
|
797
938
|
}
|
|
798
939
|
|
|
799
940
|
return {
|
|
800
941
|
identity,
|
|
801
942
|
journal,
|
|
943
|
+
replaceSnapshot,
|
|
802
944
|
assertRegistrationReady,
|
|
803
945
|
bootstrap,
|
|
804
946
|
install,
|
|
@@ -816,6 +958,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
816
958
|
completeMaterialization,
|
|
817
959
|
failMaterialization,
|
|
818
960
|
materializationStatus,
|
|
961
|
+
hasUnsettledLocalMutation,
|
|
819
962
|
catalogTree,
|
|
820
963
|
replica,
|
|
821
964
|
};
|
|
@@ -168,6 +168,34 @@ function createEntityContent({ dir }) {
|
|
|
168
168
|
return installed;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/** Capture immutable bytes that do not originate as a standalone file.
|
|
172
|
+
* Repocard transport is the first caller: its Git bundle plus checkpoint
|
|
173
|
+
* are one verified payload, while identity.db stores only the resulting
|
|
174
|
+
* content head. The supplied bytes must already equal that head; this is
|
|
175
|
+
* the same content-addressed rule as captureFile, not a second storage
|
|
176
|
+
* format. */
|
|
177
|
+
function captureBytes({ bytes, contentHash = null }) {
|
|
178
|
+
const fullBytes = Buffer.from(bytes || []);
|
|
179
|
+
const head = contentHash || digest(fullBytes);
|
|
180
|
+
assertHash(head);
|
|
181
|
+
if (digest(fullBytes) !== head) throw new Error('entity content bytes do not match their declared head');
|
|
182
|
+
ensureDirs();
|
|
183
|
+
const existing = manifest(head);
|
|
184
|
+
if (existing) return existing;
|
|
185
|
+
const chunks = [];
|
|
186
|
+
for (let offset = 0; offset < fullBytes.length; offset += CHUNK_BYTES) {
|
|
187
|
+
const part = fullBytes.subarray(offset, Math.min(offset + CHUNK_BYTES, fullBytes.length));
|
|
188
|
+
const checksum = digest(part);
|
|
189
|
+
writeChunk(checksum, part);
|
|
190
|
+
chunks.push({ sha256: checksum, bytes: part.length });
|
|
191
|
+
}
|
|
192
|
+
const captured = { contract: CONTENT_CONTRACT, contentHash: head, bytes: fullBytes.length, chunks };
|
|
193
|
+
writeJson(manifestFile(head), captured);
|
|
194
|
+
const installed = manifest(head);
|
|
195
|
+
if (!installed) throw new Error('captured entity content bytes did not persist');
|
|
196
|
+
return installed;
|
|
197
|
+
}
|
|
198
|
+
|
|
171
199
|
function initialUpload(resourceId, contentHash) {
|
|
172
200
|
return {
|
|
173
201
|
contract: CONTENT_CONTRACT,
|
|
@@ -414,6 +442,7 @@ function createEntityContent({ dir }) {
|
|
|
414
442
|
|
|
415
443
|
return {
|
|
416
444
|
captureFile,
|
|
445
|
+
captureBytes,
|
|
417
446
|
ensureUpload,
|
|
418
447
|
queueRecords,
|
|
419
448
|
claim,
|
|
@@ -84,6 +84,11 @@ function createRegistration(options) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const opened = openIdentity();
|
|
87
|
+
const captureRepo = opened.captureRepo || ((repoPath) => capture(repoPath, { withBundle: false }));
|
|
88
|
+
const repoStateAt = opened.repoStateAt ?? null;
|
|
89
|
+
if (repoStateAt !== null && typeof repoStateAt !== 'function') {
|
|
90
|
+
throw new Error('openIdentity.repoStateAt must be a function when supplied');
|
|
91
|
+
}
|
|
87
92
|
const registry = createRegistry({
|
|
88
93
|
db: opened.db,
|
|
89
94
|
sentinelPath: opened.sentinelPath,
|
|
@@ -92,7 +97,7 @@ function createRegistration(options) {
|
|
|
92
97
|
journal: opened.journal ?? null,
|
|
93
98
|
});
|
|
94
99
|
const memory = createEvidenceMemory(opened.db);
|
|
95
|
-
const repoStates = createRepoStates(opened.db);
|
|
100
|
+
const repoStates = opened.repoStates || createRepoStates(opened.db);
|
|
96
101
|
const lenses = new Map(); // rootId (permanent witness) -> evidence lens
|
|
97
102
|
let reported = []; // the latest edge pass's refusals
|
|
98
103
|
let edgesDirty = false;
|
|
@@ -102,6 +107,7 @@ function createRegistration(options) {
|
|
|
102
107
|
observer,
|
|
103
108
|
registry,
|
|
104
109
|
repoStates,
|
|
110
|
+
prepareRepo: opened.prepareRepo ?? null,
|
|
105
111
|
recordForRoot: repoRecordForRoot,
|
|
106
112
|
onSettled: reconcileSettledRepo,
|
|
107
113
|
});
|
|
@@ -237,8 +243,9 @@ function createRegistration(options) {
|
|
|
237
243
|
registry,
|
|
238
244
|
memory,
|
|
239
245
|
shouldEnroll: observer.shouldEnroll,
|
|
240
|
-
captureRepo
|
|
246
|
+
captureRepo,
|
|
241
247
|
repoStates,
|
|
248
|
+
repoStateAt,
|
|
242
249
|
});
|
|
243
250
|
} catch (error) {
|
|
244
251
|
// An observer root is only coverage until its one structural
|
|
@@ -520,8 +527,9 @@ function createRegistration(options) {
|
|
|
520
527
|
registry,
|
|
521
528
|
memory,
|
|
522
529
|
shouldEnroll: observer.shouldEnroll,
|
|
523
|
-
captureRepo
|
|
530
|
+
captureRepo,
|
|
524
531
|
repoStates,
|
|
532
|
+
repoStateAt,
|
|
525
533
|
identityAt,
|
|
526
534
|
});
|
|
527
535
|
if (suppressJournal) registry.withoutJournal(register);
|
|
@@ -744,6 +752,11 @@ function createRegistration(options) {
|
|
|
744
752
|
|
|
745
753
|
/** The latest edge pass's refusals — visible, never silent. */
|
|
746
754
|
reported: () => [...reported],
|
|
755
|
+
addressOf,
|
|
756
|
+
// Composition owns the one database handle; exposing this narrow state
|
|
757
|
+
// store lets the cloud adapter name verified repository cargo without
|
|
758
|
+
// opening a competing registry over the same SQLite file.
|
|
759
|
+
repoStates,
|
|
747
760
|
repoState: (uuid) => repoStates.get(uuid),
|
|
748
761
|
tree: treeAt,
|
|
749
762
|
trees: () => new Map(treeRoots()
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
const { createFollower, createCaptureWorker } = require('../repocard');
|
|
27
27
|
|
|
28
|
-
function createRepoFollowers({ observer, registry, repoStates, recordForRoot, onSettled }) {
|
|
28
|
+
function createRepoFollowers({ observer, registry, repoStates, recordForRoot, onSettled, prepareRepo = null }) {
|
|
29
29
|
if (!observer || !registry || !repoStates || typeof recordForRoot !== 'function'
|
|
30
30
|
|| typeof onSettled !== 'function') {
|
|
31
31
|
throw new Error('createRepoFollowers requires { observer, registry, repoStates, recordForRoot, onSettled }');
|
|
@@ -71,7 +71,13 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
|
|
|
71
71
|
const follower = createFollower({
|
|
72
72
|
repoPath: root.path,
|
|
73
73
|
last: baseline && { cardId: baseline.cardId, checkpointId: baseline.checkpointId },
|
|
74
|
-
|
|
74
|
+
// The service can turn a full capture into immutable cloud cargo
|
|
75
|
+
// before the one stateId attestation commits. Local-only callers
|
|
76
|
+
// retain the lightweight bundle-free capture used historically.
|
|
77
|
+
captureFn: async (repoPath) => {
|
|
78
|
+
const captured = await worker.capture(repoPath, { withBundle: typeof prepareRepo === 'function' });
|
|
79
|
+
return typeof prepareRepo === 'function' && !captured?.busy ? prepareRepo(captured) : captured;
|
|
80
|
+
},
|
|
75
81
|
emit: (next) => {
|
|
76
82
|
// ONE settled Card + Checkpoint state is ONE registry transition on
|
|
77
83
|
// the repo entity. Raw filesystem events never write Git state.
|
|
@@ -25,17 +25,22 @@ function decodeBytes(encoded) {
|
|
|
25
25
|
return Buffer.from(encoded || '', 'base64');
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/** The durable local state deliberately omits a Git bundle. Bundles are
|
|
29
|
+
* transport cargo: keeping them in identity.db would turn a small registry
|
|
30
|
+
* into a second, unbounded object store. The immutable content store owns
|
|
31
|
+
* the bundle bytes; SQLite retains only the content head that names them. */
|
|
32
|
+
function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null } = {}) {
|
|
29
33
|
return JSON.stringify({
|
|
30
34
|
stateId: state.stateId,
|
|
31
35
|
cardId: state.cardId,
|
|
32
36
|
checkpointId: state.checkpointId,
|
|
37
|
+
transportVersion,
|
|
33
38
|
card: {
|
|
34
39
|
...state.card,
|
|
35
|
-
bundle: state.card.bundle
|
|
40
|
+
bundle: includeBundle && state.card.bundle ? {
|
|
36
41
|
...state.card.bundle,
|
|
37
42
|
bytes: encodeBytes(state.card.bundle.bytes),
|
|
38
|
-
},
|
|
43
|
+
} : null,
|
|
39
44
|
},
|
|
40
45
|
checkpoint: {
|
|
41
46
|
...state.checkpoint,
|
|
@@ -53,6 +58,7 @@ function unpack(text) {
|
|
|
53
58
|
const state = JSON.parse(text);
|
|
54
59
|
return {
|
|
55
60
|
...state,
|
|
61
|
+
transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
|
|
56
62
|
card: {
|
|
57
63
|
...state.card,
|
|
58
64
|
bundle: state.card.bundle && {
|
|
@@ -72,6 +78,22 @@ function unpack(text) {
|
|
|
72
78
|
};
|
|
73
79
|
}
|
|
74
80
|
|
|
81
|
+
/** A Repocard transport document contains its bundle and checkpoint bytes,
|
|
82
|
+
* but never names itself: its SHA-256 is the immutable cloud content head.
|
|
83
|
+
* The semantic `stateId` remains on the repo entity, so bundle packing can
|
|
84
|
+
* vary without changing repository identity. */
|
|
85
|
+
function transportBytes(state) {
|
|
86
|
+
return Buffer.from(pack(state, { includeBundle: true, transportVersion: null }), 'utf8');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function localState(state, transportVersion) {
|
|
90
|
+
return {
|
|
91
|
+
...state,
|
|
92
|
+
transportVersion,
|
|
93
|
+
card: { ...state.card, bundle: null },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
75
97
|
function createRepoStates(db) {
|
|
76
98
|
if (!db) throw new Error('createRepoStates requires the identity database');
|
|
77
99
|
const read = db.prepare('SELECT state_json FROM repo_states WHERE entity_uuid = ?');
|
|
@@ -106,4 +128,4 @@ function createRepoStates(db) {
|
|
|
106
128
|
};
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
module.exports = { createRepoStates };
|
|
131
|
+
module.exports = { createRepoStates, transportBytes, localState, unpack };
|