@amalgm/shell 0.1.48 → 0.1.50
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/PURPOSE.md +34 -4
- package/dist/detection/runtime.d.ts +6 -2
- package/dist/detection/runtime.js +46 -18
- package/dist/detection/runtime.js.map +1 -1
- package/dist/user-ground-host.d.ts +12 -1
- package/dist/user-ground-host.js +416 -72
- package/dist/user-ground-host.js.map +1 -1
- package/package.json +2 -2
- package/dist/detection/journal-codec.d.ts +0 -3
- package/dist/detection/journal-codec.js +0 -34
- package/dist/detection/journal-codec.js.map +0 -1
package/dist/user-ground-host.js
CHANGED
|
@@ -3,14 +3,13 @@ import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlin
|
|
|
3
3
|
import { open } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
|
|
6
|
-
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
6
|
+
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, acceptOutboxRow, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, decodeAcceptedEntityRecord, decodeLocalEntityRecord, encodeEntityRecord, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, nextEntitySends, receiveEntityRecord, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
|
|
7
7
|
import Database from "better-sqlite3";
|
|
8
8
|
import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
|
|
9
9
|
import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
|
|
10
10
|
import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
|
|
11
11
|
import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
|
|
12
12
|
import { NamedDetectRuntime } from "./detection/runtime.js";
|
|
13
|
-
import { decodeMutationOperation, encodeMutationOperation, } from "./detection/journal-codec.js";
|
|
14
13
|
import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
|
|
15
14
|
import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
|
|
16
15
|
import { inspectGitRegistration, } from "./git-registration-host.js";
|
|
@@ -27,6 +26,9 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
|
|
|
27
26
|
const DETECT_QUIET_MS = 12;
|
|
28
27
|
const DETECT_MAX_DEFERRAL_MS = 75;
|
|
29
28
|
const DETECT_RETRY_MS = 250;
|
|
29
|
+
const RECORD_RETRY_MS = 1_000;
|
|
30
|
+
const RECORD_POLL_MS = 1_000;
|
|
31
|
+
const RECORD_TAIL_LIMIT = 256;
|
|
30
32
|
const GROUND_ROW_COLUMNS = [
|
|
31
33
|
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
32
34
|
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
@@ -46,6 +48,9 @@ export class UserGroundHost {
|
|
|
46
48
|
cloudState = null;
|
|
47
49
|
converged = false;
|
|
48
50
|
syncing = null;
|
|
51
|
+
recordSyncing = null;
|
|
52
|
+
recordSyncTimer = null;
|
|
53
|
+
recordSyncStopped = false;
|
|
49
54
|
namedDetect = null;
|
|
50
55
|
coldOperation = false;
|
|
51
56
|
closing = false;
|
|
@@ -90,6 +95,7 @@ export class UserGroundHost {
|
|
|
90
95
|
watching: true,
|
|
91
96
|
};
|
|
92
97
|
this.converged = true;
|
|
98
|
+
this.scheduleRecordSync();
|
|
93
99
|
return evidence;
|
|
94
100
|
},
|
|
95
101
|
};
|
|
@@ -255,13 +261,13 @@ export class UserGroundHost {
|
|
|
255
261
|
assertHealthyWatch(watch, workspaceId);
|
|
256
262
|
}
|
|
257
263
|
catch (error) {
|
|
258
|
-
this.
|
|
264
|
+
this.finishColdOperation();
|
|
259
265
|
throw stageError("Watch coverage", error);
|
|
260
266
|
}
|
|
261
267
|
let rows = readRows(this.databasePath(identity));
|
|
262
268
|
if (rows.some((row) => row.uuid === workspaceId)
|
|
263
269
|
&& this.cloudState.records.some((record) => record.uuid === workspaceId)) {
|
|
264
|
-
this.
|
|
270
|
+
this.finishColdOperation();
|
|
265
271
|
return {
|
|
266
272
|
registered: true,
|
|
267
273
|
entityCount: descendantRows(rows, workspaceId).length,
|
|
@@ -307,7 +313,7 @@ export class UserGroundHost {
|
|
|
307
313
|
throw stageError("entity registration or cloud publication", error);
|
|
308
314
|
}
|
|
309
315
|
finally {
|
|
310
|
-
this.
|
|
316
|
+
this.finishColdOperation();
|
|
311
317
|
}
|
|
312
318
|
try {
|
|
313
319
|
this.ensureWatchers(identity);
|
|
@@ -344,6 +350,7 @@ export class UserGroundHost {
|
|
|
344
350
|
// remain a later generation; flush never waits for global Watch silence.
|
|
345
351
|
this.watchDirty = false;
|
|
346
352
|
await this.syncNow();
|
|
353
|
+
await this.syncRecordsNow();
|
|
347
354
|
if (!this.closing)
|
|
348
355
|
this.ensureWatchers(identity);
|
|
349
356
|
}
|
|
@@ -367,6 +374,32 @@ export class UserGroundHost {
|
|
|
367
374
|
if (this.syncing)
|
|
368
375
|
await this.syncing;
|
|
369
376
|
}
|
|
377
|
+
scheduleRecordSync(delay = 0) {
|
|
378
|
+
if (this.closing || this.recordSyncStopped || this.recordSyncTimer)
|
|
379
|
+
return;
|
|
380
|
+
this.recordSyncTimer = setTimeout(() => {
|
|
381
|
+
this.recordSyncTimer = null;
|
|
382
|
+
void this.syncRecordsNow().then(() => {
|
|
383
|
+
if (!this.closing && !this.recordSyncStopped)
|
|
384
|
+
this.scheduleRecordSync(RECORD_POLL_MS);
|
|
385
|
+
}).catch(() => {
|
|
386
|
+
if (!this.closing && !this.recordSyncStopped)
|
|
387
|
+
this.scheduleRecordSync(RECORD_RETRY_MS);
|
|
388
|
+
});
|
|
389
|
+
}, delay);
|
|
390
|
+
this.recordSyncTimer.unref();
|
|
391
|
+
}
|
|
392
|
+
async syncRecordsNow() {
|
|
393
|
+
const identity = this.activeIdentity;
|
|
394
|
+
if (this.recordSyncStopped || !identity || !this.cloudState)
|
|
395
|
+
return;
|
|
396
|
+
if (!this.recordSyncing) {
|
|
397
|
+
this.recordSyncing = this.syncRecordQueues(identity).finally(() => {
|
|
398
|
+
this.recordSyncing = null;
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
await this.recordSyncing;
|
|
402
|
+
}
|
|
370
403
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
371
404
|
// Files convergence and Watch/Detect own ground currency. Advertising an
|
|
372
405
|
// HTTP tunnel must never trigger or wait for a second filesystem pass.
|
|
@@ -379,14 +412,23 @@ export class UserGroundHost {
|
|
|
379
412
|
async close(options = {}) {
|
|
380
413
|
if (options.catchUp === false) {
|
|
381
414
|
this.closing = true;
|
|
415
|
+
this.recordSyncStopped = true;
|
|
382
416
|
if (this.rescanTimer)
|
|
383
417
|
clearTimeout(this.rescanTimer);
|
|
384
418
|
this.rescanTimer = null;
|
|
385
419
|
this.rescanGenerationStartedAt = null;
|
|
420
|
+
if (this.recordSyncTimer)
|
|
421
|
+
clearTimeout(this.recordSyncTimer);
|
|
422
|
+
this.recordSyncTimer = null;
|
|
386
423
|
this.watchHost.close({ catchUp: false });
|
|
387
424
|
this.namedDetect?.close();
|
|
388
425
|
this.namedDetect = null;
|
|
389
426
|
await this.wire.close();
|
|
427
|
+
await this.recordSyncing?.catch(() => undefined);
|
|
428
|
+
// An upload already inside its retry helper may have reopened the wire
|
|
429
|
+
// after the first close. The stopped flag prevents new queue work; this
|
|
430
|
+
// second close seals that finite in-flight boundary.
|
|
431
|
+
await this.wire.close();
|
|
390
432
|
return;
|
|
391
433
|
}
|
|
392
434
|
// Command shutdown is the last catch-up boundary. A filesystem callback
|
|
@@ -398,12 +440,17 @@ export class UserGroundHost {
|
|
|
398
440
|
clearTimeout(this.rescanTimer);
|
|
399
441
|
this.rescanTimer = null;
|
|
400
442
|
this.rescanGenerationStartedAt = null;
|
|
443
|
+
if (this.recordSyncTimer)
|
|
444
|
+
clearTimeout(this.recordSyncTimer);
|
|
445
|
+
this.recordSyncTimer = null;
|
|
401
446
|
// Full suspicion already contains every callback that could still be
|
|
402
447
|
// queued. Retire physical coverage before the final observation so native
|
|
403
448
|
// backends cannot continuously widen a finite shutdown pass. Changes
|
|
404
449
|
// after this cutoff belong to the next startup blind interval.
|
|
405
450
|
this.watchHost.close();
|
|
406
451
|
await this.flushObservedChanges();
|
|
452
|
+
await this.syncRecordsNow();
|
|
453
|
+
this.recordSyncStopped = true;
|
|
407
454
|
this.namedDetect?.close();
|
|
408
455
|
this.namedDetect = null;
|
|
409
456
|
await this.wire.close();
|
|
@@ -461,19 +508,17 @@ export class UserGroundHost {
|
|
|
461
508
|
if (readRows(database).length === 0)
|
|
462
509
|
return null;
|
|
463
510
|
const local = localValue(identity, userRoot, database);
|
|
464
|
-
if (!
|
|
511
|
+
if (!hasRecordsBeyondSnapshot(database))
|
|
465
512
|
return local;
|
|
466
513
|
if (!this.cloudState) {
|
|
467
514
|
throw new Error("pending local mutations have no accepted cloud baseline");
|
|
468
515
|
}
|
|
469
|
-
// The
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
// convergence from overwriting saved-local work before Send accepts it.
|
|
516
|
+
// The cold graph snapshot is only a bootstrap image. Any retained
|
|
517
|
+
// outbox row proves this machine has materialized entity records after
|
|
518
|
+
// that image, whether those records are pending or already accepted.
|
|
473
519
|
return {
|
|
474
520
|
...local,
|
|
475
|
-
|
|
476
|
-
pendingMutations: true,
|
|
521
|
+
recordsBeyondSnapshot: true,
|
|
477
522
|
};
|
|
478
523
|
},
|
|
479
524
|
initialize: async () => {
|
|
@@ -777,6 +822,14 @@ export class UserGroundHost {
|
|
|
777
822
|
}, delay);
|
|
778
823
|
this.rescanTimer.unref();
|
|
779
824
|
}
|
|
825
|
+
/** A finite cold command may suppress Detect while it owns publication, but
|
|
826
|
+
* it may never consume a Watch generation that arrived during that window. */
|
|
827
|
+
finishColdOperation() {
|
|
828
|
+
this.coldOperation = false;
|
|
829
|
+
if (!this.closing && (this.watchDirty || this.watchHost.hasPending)) {
|
|
830
|
+
this.scheduleRescan();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
780
833
|
async syncLocalChanges(identity) {
|
|
781
834
|
if (!this.cloudState)
|
|
782
835
|
throw new Error("cloud state is unavailable for user-ground Watch");
|
|
@@ -802,10 +855,11 @@ export class UserGroundHost {
|
|
|
802
855
|
durationMs: evidence.durationMs,
|
|
803
856
|
plannedRows: evidence.plannedRows,
|
|
804
857
|
records: evidence.records,
|
|
805
|
-
|
|
858
|
+
recordBytes: evidence.recordBytes,
|
|
806
859
|
});
|
|
807
860
|
}
|
|
808
861
|
this.watchHost.settle(observations);
|
|
862
|
+
this.scheduleRecordSync();
|
|
809
863
|
return;
|
|
810
864
|
}
|
|
811
865
|
}
|
|
@@ -836,8 +890,15 @@ export class UserGroundHost {
|
|
|
836
890
|
if (retry?.kind === "retry") {
|
|
837
891
|
throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
|
|
838
892
|
}
|
|
893
|
+
const authorityId = this.cloudState.resourceId;
|
|
894
|
+
const received = readEntityReceiveCursors(this.databasePath(identity), authorityId);
|
|
839
895
|
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
840
|
-
? plan.proposals.map((proposal) => ({
|
|
896
|
+
? plan.proposals.map((proposal) => ({
|
|
897
|
+
mutationId: randomUUID(),
|
|
898
|
+
authorityId,
|
|
899
|
+
lastSeenGlobalSequence: received.get(proposal.entityId) ?? 0,
|
|
900
|
+
proposal,
|
|
901
|
+
}))
|
|
841
902
|
: []);
|
|
842
903
|
commitDetectedState(this.databasePath(identity), {
|
|
843
904
|
acceptedMaterializedRows,
|
|
@@ -848,15 +909,99 @@ export class UserGroundHost {
|
|
|
848
909
|
});
|
|
849
910
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
850
911
|
this.watchHost.settle(observations);
|
|
912
|
+
this.scheduleRecordSync();
|
|
913
|
+
}
|
|
914
|
+
async syncRecordQueues(identity) {
|
|
915
|
+
if (this.recordSyncStopped)
|
|
916
|
+
return;
|
|
917
|
+
await this.receiveAcceptedRecords();
|
|
918
|
+
while (!this.recordSyncStopped) {
|
|
919
|
+
const selected = nextEntitySends(readOutbox(this.databasePath(identity)));
|
|
920
|
+
if (selected.length === 0)
|
|
921
|
+
break;
|
|
922
|
+
const sent = await Promise.allSettled(selected.map(async (row) => {
|
|
923
|
+
if (this.recordSyncStopped)
|
|
924
|
+
return;
|
|
925
|
+
await this.uploadSnapshotContent(identity, row.record.authorityId, [row.record.change.result]);
|
|
926
|
+
if (this.recordSyncStopped)
|
|
927
|
+
return;
|
|
928
|
+
const frame = await this.wire.request({
|
|
929
|
+
type: "entity.record.submit",
|
|
930
|
+
record_json: encodeEntityRecord(row.record),
|
|
931
|
+
}, ["entity.record.ack"]);
|
|
932
|
+
const accepted = decodeAcceptedEntityRecord(String(frame.record_json ?? ""));
|
|
933
|
+
stampOutboxAcceptance(this.databasePath(identity), row, accepted);
|
|
934
|
+
}));
|
|
935
|
+
const failed = sent.find((result) => result.status === "rejected");
|
|
936
|
+
if (failed)
|
|
937
|
+
throw pipelineStageError("entity record send", failed.reason);
|
|
938
|
+
}
|
|
939
|
+
if (!this.recordSyncStopped)
|
|
940
|
+
await this.receiveAcceptedRecords();
|
|
941
|
+
}
|
|
942
|
+
async receiveAcceptedRecords() {
|
|
943
|
+
const identity = this.activeIdentity;
|
|
944
|
+
const authorityId = this.cloudState?.resourceId;
|
|
945
|
+
if (!identity || !authorityId)
|
|
946
|
+
return;
|
|
947
|
+
const database = this.databasePath(identity);
|
|
948
|
+
while (!this.recordSyncStopped) {
|
|
949
|
+
const after = readAuthorityDeliveryCursor(database, authorityId);
|
|
950
|
+
const frame = await this.wire.request({
|
|
951
|
+
type: "entity.record.tail",
|
|
952
|
+
authority_id: authorityId,
|
|
953
|
+
after_delivery_sequence: after,
|
|
954
|
+
limit: RECORD_TAIL_LIMIT,
|
|
955
|
+
}, ["entity.record.tail-result"]);
|
|
956
|
+
const deliveries = decodeDeliveredRecords(frame.records);
|
|
957
|
+
if (this.recordSyncStopped)
|
|
958
|
+
return;
|
|
959
|
+
if (deliveries.length === 0)
|
|
960
|
+
return;
|
|
961
|
+
for (const delivery of deliveries) {
|
|
962
|
+
await this.receiveDelivery(database, authorityId, delivery);
|
|
963
|
+
}
|
|
964
|
+
if (deliveries.length < RECORD_TAIL_LIMIT)
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
async receiveDelivery(database, authorityId, delivery) {
|
|
969
|
+
if (delivery.record.authorityId !== authorityId) {
|
|
970
|
+
throw new Error(`entity record authority ${delivery.record.authorityId} does not match ${authorityId}`);
|
|
971
|
+
}
|
|
972
|
+
let decision = storeReceivedRecord(database, delivery);
|
|
973
|
+
if (decision.kind === "gap") {
|
|
974
|
+
const frame = await this.wire.request({
|
|
975
|
+
type: "entity.record.entity-tail",
|
|
976
|
+
authority_id: authorityId,
|
|
977
|
+
entity_id: decision.entityId,
|
|
978
|
+
after_global_sequence: decision.after,
|
|
979
|
+
through_global_sequence: decision.received,
|
|
980
|
+
}, ["entity.record.entity-tail-result"]);
|
|
981
|
+
const missing = decodeDeliveredRecords(frame.records);
|
|
982
|
+
if (missing.length === 0) {
|
|
983
|
+
throw new Error(`entity ${decision.entityId} is missing sequence ${decision.after + 1}`);
|
|
984
|
+
}
|
|
985
|
+
for (const record of missing) {
|
|
986
|
+
decision = storeReceivedRecord(database, record);
|
|
987
|
+
if (decision.kind === "gap") {
|
|
988
|
+
throw new Error(`entity ${decision.entityId} history remained gapped after backfill`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
decision = storeReceivedRecord(database, delivery);
|
|
992
|
+
if (decision.kind === "gap") {
|
|
993
|
+
throw new Error(`entity ${decision.entityId} history did not reach ${decision.received}`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
advanceAuthorityDeliveryCursor(database, authorityId, delivery.deliverySequence);
|
|
851
997
|
}
|
|
852
998
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
853
|
-
const pending =
|
|
854
|
-
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
999
|
+
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
855
1000
|
if (pending.length === 0)
|
|
856
1001
|
return;
|
|
857
1002
|
const first = pending[0];
|
|
858
1003
|
if (first.resourceId !== resourceId) {
|
|
859
|
-
throw new Error("local snapshot
|
|
1004
|
+
throw new Error("local snapshot publication belongs to a different user-ground resource");
|
|
860
1005
|
}
|
|
861
1006
|
this.cloudState = {
|
|
862
1007
|
resourceId: first.resourceId,
|
|
@@ -889,8 +1034,7 @@ export class UserGroundHost {
|
|
|
889
1034
|
const checksum = sha256Hex(snapshotJson);
|
|
890
1035
|
if (checksum === state.checksum)
|
|
891
1036
|
return;
|
|
892
|
-
const pending =
|
|
893
|
-
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
1037
|
+
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
894
1038
|
const identical = pending.find((entry) => entry.resourceId === state.resourceId
|
|
895
1039
|
&& entry.snapshotChecksum === checksum);
|
|
896
1040
|
if (identical) {
|
|
@@ -901,7 +1045,6 @@ export class UserGroundHost {
|
|
|
901
1045
|
throw new Error("a different graph snapshot is already pending publication");
|
|
902
1046
|
}
|
|
903
1047
|
appendSnapshotJournal(this.databasePath(identity), {
|
|
904
|
-
kind: "entity.snapshot.replace",
|
|
905
1048
|
mutationId: randomUUID(),
|
|
906
1049
|
resourceId: state.resourceId,
|
|
907
1050
|
authorityEpoch: state.authorityEpoch,
|
|
@@ -912,9 +1055,7 @@ export class UserGroundHost {
|
|
|
912
1055
|
await this.publishPendingSnapshots(identity);
|
|
913
1056
|
}
|
|
914
1057
|
async publishPendingSnapshots(identity) {
|
|
915
|
-
for (const pending of
|
|
916
|
-
if (pending.kind !== "entity.snapshot.replace")
|
|
917
|
-
continue;
|
|
1058
|
+
for (const pending of readSnapshotPublications(this.databasePath(identity))) {
|
|
918
1059
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
919
1060
|
const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
|
|
920
1061
|
.map((row) => row.uuid));
|
|
@@ -956,6 +1097,15 @@ export class UserGroundHost {
|
|
|
956
1097
|
}
|
|
957
1098
|
}
|
|
958
1099
|
const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
1100
|
+
function encodedRecordBytes(authorityId, proposal, lastSeenGlobalSequence = 0) {
|
|
1101
|
+
return Buffer.byteLength(encodeEntityRecord(createLocalEntityRecord({
|
|
1102
|
+
mutationId: "00000000-0000-4000-8000-000000000000",
|
|
1103
|
+
authorityId,
|
|
1104
|
+
lastSeenGlobalSequence,
|
|
1105
|
+
change: proposal,
|
|
1106
|
+
createdAt: 0,
|
|
1107
|
+
})));
|
|
1108
|
+
}
|
|
959
1109
|
function contentCacheFile(cacheDir, contentHash) {
|
|
960
1110
|
return join(cacheDir, `${contentHash}.bin`);
|
|
961
1111
|
}
|
|
@@ -1086,13 +1236,46 @@ function initializeDatabase(file) {
|
|
|
1086
1236
|
filesystem_mode INTEGER,
|
|
1087
1237
|
content_verified_at_ms REAL
|
|
1088
1238
|
);
|
|
1089
|
-
CREATE TABLE IF NOT EXISTS
|
|
1239
|
+
CREATE TABLE IF NOT EXISTS record_outbox (
|
|
1240
|
+
local_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1241
|
+
mutation_id TEXT NOT NULL UNIQUE,
|
|
1242
|
+
authority_id TEXT NOT NULL,
|
|
1243
|
+
entity_id TEXT NOT NULL,
|
|
1244
|
+
record_json TEXT NOT NULL,
|
|
1245
|
+
status TEXT NOT NULL CHECK(status IN ('pending', 'accepted')),
|
|
1246
|
+
global_sequence INTEGER,
|
|
1247
|
+
created_at INTEGER NOT NULL,
|
|
1248
|
+
accepted_at INTEGER,
|
|
1249
|
+
CHECK((status = 'pending' AND global_sequence IS NULL AND accepted_at IS NULL)
|
|
1250
|
+
OR (status = 'accepted' AND global_sequence > 0 AND accepted_at IS NOT NULL))
|
|
1251
|
+
);
|
|
1252
|
+
CREATE TABLE IF NOT EXISTS record_inbox (
|
|
1253
|
+
authority_id TEXT NOT NULL,
|
|
1254
|
+
entity_id TEXT NOT NULL,
|
|
1255
|
+
global_sequence INTEGER NOT NULL CHECK(global_sequence > 0),
|
|
1256
|
+
delivery_sequence INTEGER NOT NULL CHECK(delivery_sequence > 0),
|
|
1257
|
+
mutation_id TEXT NOT NULL,
|
|
1258
|
+
record_json TEXT NOT NULL,
|
|
1259
|
+
received_at INTEGER NOT NULL,
|
|
1260
|
+
PRIMARY KEY(entity_id, global_sequence),
|
|
1261
|
+
UNIQUE(entity_id, mutation_id),
|
|
1262
|
+
UNIQUE(authority_id, delivery_sequence)
|
|
1263
|
+
);
|
|
1264
|
+
CREATE TABLE IF NOT EXISTS entity_receive_cursors (
|
|
1265
|
+
entity_id TEXT PRIMARY KEY,
|
|
1266
|
+
authority_id TEXT NOT NULL,
|
|
1267
|
+
received_through INTEGER NOT NULL CHECK(received_through >= 0)
|
|
1268
|
+
);
|
|
1269
|
+
CREATE TABLE IF NOT EXISTS authority_delivery_cursors (
|
|
1270
|
+
authority_id TEXT PRIMARY KEY,
|
|
1271
|
+
received_through INTEGER NOT NULL CHECK(received_through >= 0)
|
|
1272
|
+
);
|
|
1273
|
+
CREATE TABLE IF NOT EXISTS snapshot_publications (
|
|
1090
1274
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1091
1275
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
1092
1276
|
resource_id TEXT NOT NULL,
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
created_at TEXT NOT NULL
|
|
1277
|
+
publication_json TEXT NOT NULL,
|
|
1278
|
+
created_at INTEGER NOT NULL
|
|
1096
1279
|
);
|
|
1097
1280
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
1098
1281
|
workspace_uuid TEXT PRIMARY KEY,
|
|
@@ -1113,9 +1296,11 @@ function initializeDatabase(file) {
|
|
|
1113
1296
|
ON detection_notebook(absolute_path);
|
|
1114
1297
|
CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
|
|
1115
1298
|
ON detection_notebook(device_number, inode);
|
|
1116
|
-
CREATE INDEX IF NOT EXISTS
|
|
1117
|
-
ON
|
|
1299
|
+
CREATE INDEX IF NOT EXISTS record_outbox_pending
|
|
1300
|
+
ON record_outbox(status, local_sequence);
|
|
1118
1301
|
`);
|
|
1302
|
+
database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
|
|
1303
|
+
database.exec("DROP TABLE IF EXISTS mutation_journal");
|
|
1119
1304
|
return database;
|
|
1120
1305
|
}
|
|
1121
1306
|
function readGroundRows(file, table) {
|
|
@@ -1272,51 +1457,205 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1272
1457
|
database.close();
|
|
1273
1458
|
}
|
|
1274
1459
|
}
|
|
1275
|
-
function
|
|
1460
|
+
function readOutbox(file) {
|
|
1461
|
+
if (!existsSync(file))
|
|
1462
|
+
return [];
|
|
1463
|
+
const database = initializeDatabase(file);
|
|
1464
|
+
try {
|
|
1465
|
+
return database.prepare(`
|
|
1466
|
+
SELECT local_sequence AS localSequence, record_json AS recordJson,
|
|
1467
|
+
status, global_sequence AS globalSequence
|
|
1468
|
+
FROM record_outbox WHERE status = 'pending' ORDER BY local_sequence
|
|
1469
|
+
`).all().map((row) => ({
|
|
1470
|
+
localSequence: row.localSequence,
|
|
1471
|
+
record: decodeLocalEntityRecord(row.recordJson),
|
|
1472
|
+
status: "pending",
|
|
1473
|
+
globalSequence: null,
|
|
1474
|
+
}));
|
|
1475
|
+
}
|
|
1476
|
+
finally {
|
|
1477
|
+
database.close();
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
function readSnapshotPublications(file) {
|
|
1276
1481
|
if (!existsSync(file))
|
|
1277
1482
|
return [];
|
|
1278
1483
|
const database = initializeDatabase(file);
|
|
1279
1484
|
try {
|
|
1280
1485
|
return database.prepare(`
|
|
1281
1486
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
1282
|
-
|
|
1283
|
-
FROM
|
|
1487
|
+
publication_json AS publicationJson
|
|
1488
|
+
FROM snapshot_publications ORDER BY sequence
|
|
1284
1489
|
`).all().map((row) => {
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1490
|
+
const publication = JSON.parse(row.publicationJson);
|
|
1491
|
+
return {
|
|
1492
|
+
mutationId: row.mutationId,
|
|
1493
|
+
resourceId: row.resourceId,
|
|
1494
|
+
authorityEpoch: Number(publication.authorityEpoch),
|
|
1495
|
+
baseVersion: Number(publication.baseVersion),
|
|
1496
|
+
snapshotChecksum: String(publication.snapshotChecksum),
|
|
1497
|
+
snapshotJson: String(publication.snapshotJson),
|
|
1498
|
+
};
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
finally {
|
|
1502
|
+
database.close();
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function readEntityReceiveCursor(file, authorityId, entityId) {
|
|
1506
|
+
if (!existsSync(file))
|
|
1507
|
+
return 0;
|
|
1508
|
+
const database = initializeDatabase(file);
|
|
1509
|
+
try {
|
|
1510
|
+
const row = database.prepare(`
|
|
1511
|
+
SELECT received_through AS receivedThrough
|
|
1512
|
+
FROM entity_receive_cursors
|
|
1513
|
+
WHERE authority_id = ? AND entity_id = ?
|
|
1514
|
+
`).get(authorityId, entityId);
|
|
1515
|
+
return row?.receivedThrough ?? 0;
|
|
1516
|
+
}
|
|
1517
|
+
finally {
|
|
1518
|
+
database.close();
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
function readEntityReceiveCursors(file, authorityId) {
|
|
1522
|
+
if (!existsSync(file))
|
|
1523
|
+
return new Map();
|
|
1524
|
+
const database = initializeDatabase(file);
|
|
1525
|
+
try {
|
|
1526
|
+
return new Map(database.prepare(`
|
|
1527
|
+
SELECT entity_id AS entityId, received_through AS receivedThrough
|
|
1528
|
+
FROM entity_receive_cursors WHERE authority_id = ?
|
|
1529
|
+
`).all(authorityId)
|
|
1530
|
+
.map((row) => [row.entityId, row.receivedThrough]));
|
|
1531
|
+
}
|
|
1532
|
+
finally {
|
|
1533
|
+
database.close();
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
function readAuthorityDeliveryCursor(file, authorityId) {
|
|
1537
|
+
if (!existsSync(file))
|
|
1538
|
+
return 0;
|
|
1539
|
+
const database = initializeDatabase(file);
|
|
1540
|
+
try {
|
|
1541
|
+
const row = database.prepare(`
|
|
1542
|
+
SELECT received_through AS receivedThrough
|
|
1543
|
+
FROM authority_delivery_cursors WHERE authority_id = ?
|
|
1544
|
+
`).get(authorityId);
|
|
1545
|
+
return row?.receivedThrough ?? 0;
|
|
1546
|
+
}
|
|
1547
|
+
finally {
|
|
1548
|
+
database.close();
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
function decodeDeliveredRecords(value) {
|
|
1552
|
+
if (!Array.isArray(value))
|
|
1553
|
+
throw new Error("entity record tail did not return a records array");
|
|
1554
|
+
return value.map((item) => {
|
|
1555
|
+
if (item === null || typeof item !== "object") {
|
|
1556
|
+
throw new Error("entity record tail contained a non-record delivery");
|
|
1557
|
+
}
|
|
1558
|
+
const delivered = item;
|
|
1559
|
+
const deliverySequence = Number(delivered.delivery_sequence);
|
|
1560
|
+
if (!Number.isSafeInteger(deliverySequence) || deliverySequence < 1
|
|
1561
|
+
|| typeof delivered.record_json !== "string") {
|
|
1562
|
+
throw new Error("entity record delivery has invalid sequence or bytes");
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
deliverySequence,
|
|
1566
|
+
record: decodeAcceptedEntityRecord(delivered.record_json),
|
|
1567
|
+
};
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
function stampOutboxAcceptance(file, row, accepted) {
|
|
1571
|
+
const receipt = acceptOutboxRow(row, accepted);
|
|
1572
|
+
if (receipt.status !== "accepted")
|
|
1573
|
+
throw new Error("authority acknowledgement was not accepted");
|
|
1574
|
+
const database = initializeDatabase(file);
|
|
1575
|
+
try {
|
|
1576
|
+
database.transaction(() => {
|
|
1577
|
+
const updated = database.prepare(`
|
|
1578
|
+
UPDATE record_outbox
|
|
1579
|
+
SET status = 'accepted', global_sequence = ?, accepted_at = ?
|
|
1580
|
+
WHERE mutation_id = ? AND status = 'pending'
|
|
1581
|
+
`).run(accepted.globalSequence, accepted.acceptedAt, accepted.mutationId);
|
|
1582
|
+
if (updated.changes === 1)
|
|
1583
|
+
return;
|
|
1584
|
+
const existing = database.prepare(`
|
|
1585
|
+
SELECT status, global_sequence AS globalSequence
|
|
1586
|
+
FROM record_outbox WHERE mutation_id = ?
|
|
1587
|
+
`).get(accepted.mutationId);
|
|
1588
|
+
if (existing?.status !== "accepted"
|
|
1589
|
+
|| existing.globalSequence !== accepted.globalSequence) {
|
|
1590
|
+
throw new Error("outbox acknowledgement did not match its retained row");
|
|
1296
1591
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1592
|
+
})();
|
|
1593
|
+
}
|
|
1594
|
+
finally {
|
|
1595
|
+
database.close();
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
function storeReceivedRecord(file, delivery) {
|
|
1599
|
+
const database = initializeDatabase(file);
|
|
1600
|
+
try {
|
|
1601
|
+
return database.transaction(() => {
|
|
1602
|
+
const cursor = database.prepare(`
|
|
1603
|
+
SELECT authority_id AS authorityId, received_through AS receivedThrough
|
|
1604
|
+
FROM entity_receive_cursors WHERE entity_id = ?
|
|
1605
|
+
`).get(delivery.record.entityId);
|
|
1606
|
+
if (cursor && cursor.authorityId !== delivery.record.authorityId) {
|
|
1607
|
+
throw new Error(`entity ${delivery.record.entityId} cannot change authority channels`);
|
|
1304
1608
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1609
|
+
const existingRow = database.prepare(`
|
|
1610
|
+
SELECT record_json AS recordJson FROM record_inbox
|
|
1611
|
+
WHERE authority_id = ? AND entity_id = ? AND global_sequence = ?
|
|
1612
|
+
`).get(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence);
|
|
1613
|
+
const existing = existingRow
|
|
1614
|
+
? decodeAcceptedEntityRecord(existingRow.recordJson)
|
|
1615
|
+
: null;
|
|
1616
|
+
const decision = receiveEntityRecord(cursor?.receivedThrough ?? 0, delivery.record, existing);
|
|
1617
|
+
if (decision.kind !== "insert")
|
|
1618
|
+
return decision;
|
|
1619
|
+
database.prepare(`
|
|
1620
|
+
INSERT INTO record_inbox(
|
|
1621
|
+
authority_id, entity_id, global_sequence, delivery_sequence,
|
|
1622
|
+
mutation_id, record_json, received_at
|
|
1623
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1624
|
+
`).run(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence, delivery.deliverySequence, delivery.record.mutationId, encodeEntityRecord(delivery.record), Date.now());
|
|
1625
|
+
database.prepare(`
|
|
1626
|
+
INSERT INTO entity_receive_cursors(authority_id, entity_id, received_through)
|
|
1627
|
+
VALUES (?, ?, ?)
|
|
1628
|
+
ON CONFLICT(entity_id) DO UPDATE SET
|
|
1629
|
+
received_through = excluded.received_through
|
|
1630
|
+
`).run(delivery.record.authorityId, delivery.record.entityId, decision.receivedThrough);
|
|
1631
|
+
return decision;
|
|
1632
|
+
})();
|
|
1633
|
+
}
|
|
1634
|
+
finally {
|
|
1635
|
+
database.close();
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
function advanceAuthorityDeliveryCursor(file, authorityId, deliverySequence) {
|
|
1639
|
+
const database = initializeDatabase(file);
|
|
1640
|
+
try {
|
|
1641
|
+
database.prepare(`
|
|
1642
|
+
INSERT INTO authority_delivery_cursors(authority_id, received_through)
|
|
1643
|
+
VALUES (?, ?)
|
|
1644
|
+
ON CONFLICT(authority_id) DO UPDATE SET
|
|
1645
|
+
received_through = MAX(received_through, excluded.received_through)
|
|
1646
|
+
`).run(authorityId, deliverySequence);
|
|
1307
1647
|
}
|
|
1308
1648
|
finally {
|
|
1309
1649
|
database.close();
|
|
1310
1650
|
}
|
|
1311
1651
|
}
|
|
1312
|
-
function
|
|
1652
|
+
function hasRecordsBeyondSnapshot(file) {
|
|
1313
1653
|
if (!existsSync(file))
|
|
1314
1654
|
return false;
|
|
1315
1655
|
const database = initializeDatabase(file);
|
|
1316
1656
|
try {
|
|
1317
1657
|
return database.prepare(`
|
|
1318
|
-
SELECT 1 AS present FROM
|
|
1319
|
-
WHERE operation_kind = 'detected.change' LIMIT 1
|
|
1658
|
+
SELECT 1 AS present FROM record_outbox LIMIT 1
|
|
1320
1659
|
`).get() !== undefined;
|
|
1321
1660
|
}
|
|
1322
1661
|
finally {
|
|
@@ -1325,19 +1664,27 @@ function hasDetectedChanges(file) {
|
|
|
1325
1664
|
}
|
|
1326
1665
|
/** Record is the SQLite transaction that accepts Detect's exact proposals and
|
|
1327
1666
|
* advances the last-verified notebook. Watch may settle only after this
|
|
1328
|
-
* function returns. The
|
|
1667
|
+
* function returns. The record table is the local-only outbox. */
|
|
1329
1668
|
function commitDetectedState(file, input) {
|
|
1330
1669
|
const database = initializeDatabase(file);
|
|
1331
1670
|
try {
|
|
1332
1671
|
database.transaction(() => {
|
|
1333
1672
|
const append = database.prepare(`
|
|
1334
|
-
INSERT INTO
|
|
1335
|
-
mutation_id,
|
|
1336
|
-
|
|
1673
|
+
INSERT INTO record_outbox(
|
|
1674
|
+
mutation_id, authority_id, entity_id, record_json, status,
|
|
1675
|
+
global_sequence, created_at, accepted_at
|
|
1676
|
+
) VALUES (?, ?, ?, ?, 'pending', NULL, ?, NULL)
|
|
1337
1677
|
`);
|
|
1338
1678
|
for (const entry of input.proposals ?? []) {
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1679
|
+
const createdAt = Date.now();
|
|
1680
|
+
const record = createLocalEntityRecord({
|
|
1681
|
+
mutationId: entry.mutationId,
|
|
1682
|
+
authorityId: entry.authorityId,
|
|
1683
|
+
lastSeenGlobalSequence: entry.lastSeenGlobalSequence,
|
|
1684
|
+
change: entry.proposal,
|
|
1685
|
+
createdAt,
|
|
1686
|
+
});
|
|
1687
|
+
append.run(record.mutationId, record.authorityId, record.entityId, encodeEntityRecord(record), createdAt);
|
|
1341
1688
|
}
|
|
1342
1689
|
const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
|
|
1343
1690
|
const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
|
|
@@ -1362,15 +1709,15 @@ function appendSnapshotJournal(file, entry) {
|
|
|
1362
1709
|
const database = initializeDatabase(file);
|
|
1363
1710
|
try {
|
|
1364
1711
|
database.prepare(`
|
|
1365
|
-
INSERT INTO
|
|
1366
|
-
mutation_id, resource_id,
|
|
1367
|
-
) VALUES (?, ?, ?,
|
|
1368
|
-
`).run(entry.mutationId, entry.resourceId,
|
|
1712
|
+
INSERT INTO snapshot_publications(
|
|
1713
|
+
mutation_id, resource_id, publication_json, created_at
|
|
1714
|
+
) VALUES (?, ?, ?, ?)
|
|
1715
|
+
`).run(entry.mutationId, entry.resourceId, stableJson({
|
|
1369
1716
|
authorityEpoch: entry.authorityEpoch,
|
|
1370
1717
|
baseVersion: entry.baseVersion,
|
|
1371
1718
|
snapshotChecksum: entry.snapshotChecksum,
|
|
1372
1719
|
snapshotJson: entry.snapshotJson,
|
|
1373
|
-
}),
|
|
1720
|
+
}), Date.now());
|
|
1374
1721
|
}
|
|
1375
1722
|
finally {
|
|
1376
1723
|
database.close();
|
|
@@ -1379,7 +1726,7 @@ function appendSnapshotJournal(file, entry) {
|
|
|
1379
1726
|
function deleteJournalEntry(file, mutationId) {
|
|
1380
1727
|
const database = initializeDatabase(file);
|
|
1381
1728
|
try {
|
|
1382
|
-
database.prepare("DELETE FROM
|
|
1729
|
+
database.prepare("DELETE FROM snapshot_publications WHERE mutation_id = ?").run(mutationId);
|
|
1383
1730
|
}
|
|
1384
1731
|
finally {
|
|
1385
1732
|
database.close();
|
|
@@ -1758,7 +2105,7 @@ async function reconcileGround(input) {
|
|
|
1758
2105
|
durationMs: performance.now() - started,
|
|
1759
2106
|
plannedRows: proposals.length,
|
|
1760
2107
|
records: proposals.length,
|
|
1761
|
-
|
|
2108
|
+
recordBytes: proposals.reduce((bytes, proposal) => bytes + encodedRecordBytes(parameters.resourceId, proposal), 0),
|
|
1762
2109
|
});
|
|
1763
2110
|
// Each root commits its local projection before Detect yields. Native
|
|
1764
2111
|
// callbacks can therefore preserve new suspicion between large roots.
|
|
@@ -1832,12 +2179,9 @@ async function reconcileRoot(input) {
|
|
|
1832
2179
|
return [[local, row]];
|
|
1833
2180
|
}));
|
|
1834
2181
|
const registeredBoundaries = input.registeredBoundaries ?? new Map();
|
|
1835
|
-
const completeRepositoryEvidence = existingRows.every((row) => row.type !== "repo.git"
|
|
1836
|
-
|| (Boolean(row.payloadVersion) && Boolean(row.transportVersion)));
|
|
1837
2182
|
if (suspects !== null
|
|
1838
2183
|
&& suspects.length === 0
|
|
1839
|
-
&& existingRows.length > 0
|
|
1840
|
-
&& completeRepositoryEvidence) {
|
|
2184
|
+
&& existingRows.length > 0) {
|
|
1841
2185
|
const rows = existingRows.map((row) => ({
|
|
1842
2186
|
record: portableRecord(row),
|
|
1843
2187
|
relativePath: row.relativePath,
|