amalgm 0.1.260 → 0.1.261
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/package.json
CHANGED
|
@@ -124,7 +124,15 @@ function parseSnapshot(bytes) {
|
|
|
124
124
|
* no watcher imports: a cloud record may be known on this machine long before
|
|
125
125
|
* `amalgm add <uuid>` chooses and validates local ground for it.
|
|
126
126
|
*/
|
|
127
|
-
function createEntityCloud({
|
|
127
|
+
function createEntityCloud({
|
|
128
|
+
db,
|
|
129
|
+
userId,
|
|
130
|
+
deviceId,
|
|
131
|
+
content = null,
|
|
132
|
+
repoStates = null,
|
|
133
|
+
repoTransportFor = null,
|
|
134
|
+
recordAt = null,
|
|
135
|
+
}) {
|
|
128
136
|
if (!db) throw new Error('createEntityCloud requires an identity database');
|
|
129
137
|
const ownerUserId = typeof userId === 'string' && userId ? userId : null;
|
|
130
138
|
const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
|
|
@@ -155,17 +163,62 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
155
163
|
return snapshotFromRecords(records.map(cloudRecord));
|
|
156
164
|
}
|
|
157
165
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Select bytes by the root representation that reconstructs them. A
|
|
168
|
+
* repository's Repocard transport contains its worktree, so child file rows
|
|
169
|
+
* retain their UUID/version facts but never create duplicate cloud cargo.
|
|
170
|
+
* Ordinary workspace files still carry their own content, and a nested
|
|
171
|
+
* repository carries its own transport.
|
|
172
|
+
*/
|
|
173
|
+
function contentHeads(records, { fallbackRecordAt = recordAt } = {}) {
|
|
174
|
+
const normalized = (records || []).map(cloudRecord);
|
|
175
|
+
const supplied = new Map(normalized.map((record) => [record.uuid, record]));
|
|
176
|
+
const lookup = (uuid) => {
|
|
177
|
+
const known = supplied.get(uuid);
|
|
178
|
+
if (known) return known;
|
|
179
|
+
const fallback = typeof fallbackRecordAt === 'function' ? fallbackRecordAt(uuid) : null;
|
|
180
|
+
return fallback ? cloudRecord(fallback) : null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function enclosedByRepository(record) {
|
|
184
|
+
const seen = new Set([record.uuid]);
|
|
185
|
+
let parentUUID = record.parentUUID;
|
|
186
|
+
while (parentUUID !== null) {
|
|
187
|
+
if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
|
|
188
|
+
seen.add(parentUUID);
|
|
189
|
+
const parent = lookup(parentUUID);
|
|
190
|
+
// Missing ancestry retains content rather than risking an ordinary
|
|
191
|
+
// file whose only bytes were silently skipped.
|
|
192
|
+
if (!parent) return false;
|
|
193
|
+
if (parent.type === 'repo.git') return true;
|
|
194
|
+
parentUUID = parent.parentUUID;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const heads = [];
|
|
200
|
+
const seen = new Set();
|
|
201
|
+
for (const record of normalized) {
|
|
202
|
+
const contentHash = contentHead(record);
|
|
203
|
+
if (!contentHash) continue;
|
|
204
|
+
if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepository(record)) continue;
|
|
205
|
+
if (seen.has(contentHash)) continue;
|
|
206
|
+
seen.add(contentHash);
|
|
207
|
+
heads.push({ record, contentHash });
|
|
164
208
|
}
|
|
165
|
-
|
|
166
|
-
|
|
209
|
+
return heads;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function queueContentHeads(records, resourceId, { queue = false, fallbackRecordAt = recordAt } = {}) {
|
|
213
|
+
for (const { contentHash } of contentHeads(records, { fallbackRecordAt })) {
|
|
214
|
+
if (!content || typeof content.has !== 'function') {
|
|
215
|
+
throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
|
|
216
|
+
}
|
|
217
|
+
if (!content.has({ contentHash })) {
|
|
218
|
+
throw new Error('file entity bytes were not captured before its cloud head committed');
|
|
219
|
+
}
|
|
220
|
+
if (queue) content.ensureUpload({ resourceId, contentHash });
|
|
167
221
|
}
|
|
168
|
-
if (queue) content.ensureUpload({ resourceId, contentHash: head });
|
|
169
222
|
}
|
|
170
223
|
|
|
171
224
|
function replica() {
|
|
@@ -383,7 +436,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
383
436
|
// content drain creates the filesystem upload cursor; doing that here
|
|
384
437
|
// would let a rolled-back identity write upload unreachable bytes.
|
|
385
438
|
const cloud = cloudRecord(record);
|
|
386
|
-
|
|
439
|
+
queueContentHeads([cloud], current.resourceId);
|
|
387
440
|
upsertCatalogRecord({
|
|
388
441
|
resourceId: current.resourceId,
|
|
389
442
|
authorityEpoch: current.authorityEpoch,
|
|
@@ -423,7 +476,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
423
476
|
if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
|
|
424
477
|
throw new Error(`registered entity graph is ${bytes} bytes; the atomic cloud registration limit is ${SNAPSHOT_REPLACE_MAX_BYTES} bytes`);
|
|
425
478
|
}
|
|
426
|
-
|
|
479
|
+
queueContentHeads(snapshot.records, current.resourceId, { queue: true });
|
|
427
480
|
const createdAt = now();
|
|
428
481
|
db.prepare(`
|
|
429
482
|
INSERT INTO entity_cloud_mutations (
|
|
@@ -448,8 +501,9 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
448
501
|
}
|
|
449
502
|
const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
|
|
450
503
|
const createdAt = now();
|
|
451
|
-
|
|
452
|
-
|
|
504
|
+
const snapshot = cloudSnapshot(records);
|
|
505
|
+
queueContentHeads(snapshot.records, current.resourceId, { queue: true });
|
|
506
|
+
for (const record of snapshot.records) {
|
|
453
507
|
insert.run(
|
|
454
508
|
current.resourceId,
|
|
455
509
|
'ent_' + crypto.randomBytes(18).toString('base64url'),
|
|
@@ -632,7 +686,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
632
686
|
// upload. A joining device must NOT queue remote bytes as uploads; it
|
|
633
687
|
// downloads them later through a materialization job instead.
|
|
634
688
|
if (bootstrapping) {
|
|
635
|
-
|
|
689
|
+
queueContentHeads(snapshot.records, resourceId, { queue: true });
|
|
636
690
|
}
|
|
637
691
|
replaceCatalog({
|
|
638
692
|
resourceId,
|
|
@@ -706,15 +760,13 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
706
760
|
for (const job of pendingMaterializations().filter((entry) => entry.state === 'waiting_for_content')) {
|
|
707
761
|
if (job.resourceId !== current.resourceId) continue;
|
|
708
762
|
const tree = catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const
|
|
712
|
-
if (!head) continue;
|
|
713
|
-
const part = content.missingPart({ contentHash: head });
|
|
763
|
+
const active = tree.filter((record) => record.status === 'active');
|
|
764
|
+
for (const { contentHash } of contentHeads(active)) {
|
|
765
|
+
const part = content.missingPart({ contentHash });
|
|
714
766
|
if (!part) continue;
|
|
715
767
|
return {
|
|
716
768
|
resourceId: job.resourceId,
|
|
717
|
-
contentHash
|
|
769
|
+
contentHash,
|
|
718
770
|
kind: part.kind,
|
|
719
771
|
partIndex: part.partIndex,
|
|
720
772
|
sha256: part.sha256,
|
|
@@ -882,10 +934,9 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
882
934
|
const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
|
|
883
935
|
? operation?.snapshot?.records
|
|
884
936
|
: [operation?.record];
|
|
885
|
-
if ((records
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
})) {
|
|
937
|
+
if (contentHeads(records).some(({ contentHash }) => (
|
|
938
|
+
!content || !content.uploaded({ resourceId: current.resourceId, contentHash })
|
|
939
|
+
))) {
|
|
889
940
|
return [];
|
|
890
941
|
}
|
|
891
942
|
claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
|
|
@@ -923,7 +974,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
923
974
|
const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
|
|
924
975
|
? operation?.snapshot?.records
|
|
925
976
|
: [operation?.record];
|
|
926
|
-
|
|
977
|
+
queueContentHeads(records || [], current.resourceId, { queue: true });
|
|
927
978
|
contentQueuedSequences.add(row.sequence);
|
|
928
979
|
}
|
|
929
980
|
return content.claim({ resourceId: current.resourceId, leaseMs });
|
|
@@ -1136,6 +1187,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
|
|
|
1136
1187
|
status,
|
|
1137
1188
|
hasUnsettledLocalMutation,
|
|
1138
1189
|
catalogTree,
|
|
1190
|
+
contentHeads,
|
|
1139
1191
|
replica,
|
|
1140
1192
|
};
|
|
1141
1193
|
}
|
|
@@ -106,6 +106,10 @@ function createRegistrationService({
|
|
|
106
106
|
return localState(captured, artifact.contentHash);
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// Entity-cloud needs only logical ancestry to choose a content plan. It
|
|
110
|
+
// never owns a watcher or address; this facade is available after the
|
|
111
|
+
// registration boundary is constructed below.
|
|
112
|
+
const boundary = { current: null };
|
|
109
113
|
const entityCloud = createEntityCloud({
|
|
110
114
|
db: identityDb,
|
|
111
115
|
userId: cloudUserId,
|
|
@@ -113,11 +117,13 @@ function createRegistrationService({
|
|
|
113
117
|
content: entityContent,
|
|
114
118
|
repoStates: repoStateLookup,
|
|
115
119
|
repoTransportFor: (record) => repoTransportHeads.get(record.payloadVersion) || null,
|
|
120
|
+
recordAt: (uuid) => {
|
|
121
|
+
try { return boundary.current?.registry.syncRecord(uuid) || null; } catch { return null; }
|
|
122
|
+
},
|
|
116
123
|
});
|
|
117
124
|
|
|
118
125
|
// The observer emits before the boundary exists (its constructor
|
|
119
126
|
// scans), so the wire reads through a box that the boundary fills.
|
|
120
|
-
const boundary = { current: null };
|
|
121
127
|
const observer = createObserver({
|
|
122
128
|
db: evidenceDb,
|
|
123
129
|
emit: (event) => boundary.current?.onEvent(event),
|
|
@@ -474,15 +480,8 @@ function createRegistrationService({
|
|
|
474
480
|
return state;
|
|
475
481
|
}
|
|
476
482
|
|
|
477
|
-
for (const
|
|
478
|
-
if (
|
|
479
|
-
&& !entityContent.complete({ contentHash: record.payloadVersion })) {
|
|
480
|
-
return false;
|
|
481
|
-
}
|
|
482
|
-
if (record.type === 'repo.git'
|
|
483
|
-
&& (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) {
|
|
484
|
-
return false;
|
|
485
|
-
}
|
|
483
|
+
for (const { contentHash } of entityCloud.contentHeads(active)) {
|
|
484
|
+
if (!entityContent.complete({ contentHash })) return false;
|
|
486
485
|
}
|
|
487
486
|
if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
|
|
488
487
|
throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace, repository, file, and link trees only`);
|
|
@@ -579,11 +578,8 @@ function createRegistrationService({
|
|
|
579
578
|
if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
|
|
580
579
|
throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace, repository, file, and link trees only`);
|
|
581
580
|
}
|
|
582
|
-
for (const
|
|
583
|
-
if (
|
|
584
|
-
&& !entityContent.complete({ contentHash: record.payloadVersion })) return false;
|
|
585
|
-
if (record.type === 'repo.git'
|
|
586
|
-
&& (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) return false;
|
|
581
|
+
for (const { contentHash } of entityCloud.contentHeads(active)) {
|
|
582
|
+
if (!entityContent.complete({ contentHash })) return false;
|
|
587
583
|
}
|
|
588
584
|
|
|
589
585
|
const parent = fs.realpathSync(job.targetPath);
|
|
@@ -369,20 +369,35 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
369
369
|
snapshotChecksum: sha256(snapshotBytes),
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
372
|
+
const planned = source.entityCloud.contentHeads(records);
|
|
373
|
+
assert.deepEqual(
|
|
374
|
+
planned.map(({ contentHash }) => contentHash),
|
|
375
|
+
[repoCloudRecord.transportVersion],
|
|
376
|
+
'a repository register uploads its one Repocard transport, never duplicate child file bytes',
|
|
377
|
+
);
|
|
378
|
+
const uploaded = [];
|
|
379
|
+
while (true) {
|
|
380
|
+
const part = source.entityCloud.claimContent();
|
|
381
|
+
if (!part) break;
|
|
382
|
+
uploaded.push(part);
|
|
383
|
+
source.entityCloud.acknowledgeContent({
|
|
384
|
+
resourceId: bootstrap.resourceId,
|
|
385
|
+
contentHash: part.contentHash,
|
|
386
|
+
kind: part.kind,
|
|
387
|
+
partIndex: part.partIndex,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
assert.deepEqual(
|
|
391
|
+
[...new Set(uploaded.map((part) => part.contentHash))],
|
|
392
|
+
[repoCloudRecord.transportVersion],
|
|
393
|
+
'the source content pump sends only the repository transport',
|
|
394
|
+
);
|
|
395
|
+
for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
|
|
396
|
+
target.receiveCloudContent({
|
|
397
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
|
|
398
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
399
|
+
sha256: part.sha256, dataBase64: part.dataBase64,
|
|
400
|
+
});
|
|
386
401
|
}
|
|
387
402
|
|
|
388
403
|
const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
|