@amalgm/shell 0.1.47 → 0.1.49
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 +26 -2
- 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/files-register-host.js +6 -2
- package/dist/files-register-host.js.map +1 -1
- package/dist/user-ground-host.d.ts +9 -1
- package/dist/user-ground-host.js +404 -65
- 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
|
};
|
|
@@ -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 () => {
|
|
@@ -802,10 +847,11 @@ export class UserGroundHost {
|
|
|
802
847
|
durationMs: evidence.durationMs,
|
|
803
848
|
plannedRows: evidence.plannedRows,
|
|
804
849
|
records: evidence.records,
|
|
805
|
-
|
|
850
|
+
recordBytes: evidence.recordBytes,
|
|
806
851
|
});
|
|
807
852
|
}
|
|
808
853
|
this.watchHost.settle(observations);
|
|
854
|
+
this.scheduleRecordSync();
|
|
809
855
|
return;
|
|
810
856
|
}
|
|
811
857
|
}
|
|
@@ -836,8 +882,15 @@ export class UserGroundHost {
|
|
|
836
882
|
if (retry?.kind === "retry") {
|
|
837
883
|
throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
|
|
838
884
|
}
|
|
885
|
+
const authorityId = this.cloudState.resourceId;
|
|
886
|
+
const received = readEntityReceiveCursors(this.databasePath(identity), authorityId);
|
|
839
887
|
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
840
|
-
? plan.proposals.map((proposal) => ({
|
|
888
|
+
? plan.proposals.map((proposal) => ({
|
|
889
|
+
mutationId: randomUUID(),
|
|
890
|
+
authorityId,
|
|
891
|
+
lastSeenGlobalSequence: received.get(proposal.entityId) ?? 0,
|
|
892
|
+
proposal,
|
|
893
|
+
}))
|
|
841
894
|
: []);
|
|
842
895
|
commitDetectedState(this.databasePath(identity), {
|
|
843
896
|
acceptedMaterializedRows,
|
|
@@ -848,15 +901,99 @@ export class UserGroundHost {
|
|
|
848
901
|
});
|
|
849
902
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
850
903
|
this.watchHost.settle(observations);
|
|
904
|
+
this.scheduleRecordSync();
|
|
905
|
+
}
|
|
906
|
+
async syncRecordQueues(identity) {
|
|
907
|
+
if (this.recordSyncStopped)
|
|
908
|
+
return;
|
|
909
|
+
await this.receiveAcceptedRecords();
|
|
910
|
+
while (!this.recordSyncStopped) {
|
|
911
|
+
const selected = nextEntitySends(readOutbox(this.databasePath(identity)));
|
|
912
|
+
if (selected.length === 0)
|
|
913
|
+
break;
|
|
914
|
+
const sent = await Promise.allSettled(selected.map(async (row) => {
|
|
915
|
+
if (this.recordSyncStopped)
|
|
916
|
+
return;
|
|
917
|
+
await this.uploadSnapshotContent(identity, row.record.authorityId, [row.record.change.result]);
|
|
918
|
+
if (this.recordSyncStopped)
|
|
919
|
+
return;
|
|
920
|
+
const frame = await this.wire.request({
|
|
921
|
+
type: "entity.record.submit",
|
|
922
|
+
record_json: encodeEntityRecord(row.record),
|
|
923
|
+
}, ["entity.record.ack"]);
|
|
924
|
+
const accepted = decodeAcceptedEntityRecord(String(frame.record_json ?? ""));
|
|
925
|
+
stampOutboxAcceptance(this.databasePath(identity), row, accepted);
|
|
926
|
+
}));
|
|
927
|
+
const failed = sent.find((result) => result.status === "rejected");
|
|
928
|
+
if (failed)
|
|
929
|
+
throw pipelineStageError("entity record send", failed.reason);
|
|
930
|
+
}
|
|
931
|
+
if (!this.recordSyncStopped)
|
|
932
|
+
await this.receiveAcceptedRecords();
|
|
933
|
+
}
|
|
934
|
+
async receiveAcceptedRecords() {
|
|
935
|
+
const identity = this.activeIdentity;
|
|
936
|
+
const authorityId = this.cloudState?.resourceId;
|
|
937
|
+
if (!identity || !authorityId)
|
|
938
|
+
return;
|
|
939
|
+
const database = this.databasePath(identity);
|
|
940
|
+
while (!this.recordSyncStopped) {
|
|
941
|
+
const after = readAuthorityDeliveryCursor(database, authorityId);
|
|
942
|
+
const frame = await this.wire.request({
|
|
943
|
+
type: "entity.record.tail",
|
|
944
|
+
authority_id: authorityId,
|
|
945
|
+
after_delivery_sequence: after,
|
|
946
|
+
limit: RECORD_TAIL_LIMIT,
|
|
947
|
+
}, ["entity.record.tail-result"]);
|
|
948
|
+
const deliveries = decodeDeliveredRecords(frame.records);
|
|
949
|
+
if (this.recordSyncStopped)
|
|
950
|
+
return;
|
|
951
|
+
if (deliveries.length === 0)
|
|
952
|
+
return;
|
|
953
|
+
for (const delivery of deliveries) {
|
|
954
|
+
await this.receiveDelivery(database, authorityId, delivery);
|
|
955
|
+
}
|
|
956
|
+
if (deliveries.length < RECORD_TAIL_LIMIT)
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async receiveDelivery(database, authorityId, delivery) {
|
|
961
|
+
if (delivery.record.authorityId !== authorityId) {
|
|
962
|
+
throw new Error(`entity record authority ${delivery.record.authorityId} does not match ${authorityId}`);
|
|
963
|
+
}
|
|
964
|
+
let decision = storeReceivedRecord(database, delivery);
|
|
965
|
+
if (decision.kind === "gap") {
|
|
966
|
+
const frame = await this.wire.request({
|
|
967
|
+
type: "entity.record.entity-tail",
|
|
968
|
+
authority_id: authorityId,
|
|
969
|
+
entity_id: decision.entityId,
|
|
970
|
+
after_global_sequence: decision.after,
|
|
971
|
+
through_global_sequence: decision.received,
|
|
972
|
+
}, ["entity.record.entity-tail-result"]);
|
|
973
|
+
const missing = decodeDeliveredRecords(frame.records);
|
|
974
|
+
if (missing.length === 0) {
|
|
975
|
+
throw new Error(`entity ${decision.entityId} is missing sequence ${decision.after + 1}`);
|
|
976
|
+
}
|
|
977
|
+
for (const record of missing) {
|
|
978
|
+
decision = storeReceivedRecord(database, record);
|
|
979
|
+
if (decision.kind === "gap") {
|
|
980
|
+
throw new Error(`entity ${decision.entityId} history remained gapped after backfill`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
decision = storeReceivedRecord(database, delivery);
|
|
984
|
+
if (decision.kind === "gap") {
|
|
985
|
+
throw new Error(`entity ${decision.entityId} history did not reach ${decision.received}`);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
advanceAuthorityDeliveryCursor(database, authorityId, delivery.deliverySequence);
|
|
851
989
|
}
|
|
852
990
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
853
|
-
const pending =
|
|
854
|
-
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
991
|
+
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
855
992
|
if (pending.length === 0)
|
|
856
993
|
return;
|
|
857
994
|
const first = pending[0];
|
|
858
995
|
if (first.resourceId !== resourceId) {
|
|
859
|
-
throw new Error("local snapshot
|
|
996
|
+
throw new Error("local snapshot publication belongs to a different user-ground resource");
|
|
860
997
|
}
|
|
861
998
|
this.cloudState = {
|
|
862
999
|
resourceId: first.resourceId,
|
|
@@ -889,8 +1026,7 @@ export class UserGroundHost {
|
|
|
889
1026
|
const checksum = sha256Hex(snapshotJson);
|
|
890
1027
|
if (checksum === state.checksum)
|
|
891
1028
|
return;
|
|
892
|
-
const pending =
|
|
893
|
-
.filter((entry) => entry.kind === "entity.snapshot.replace");
|
|
1029
|
+
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
894
1030
|
const identical = pending.find((entry) => entry.resourceId === state.resourceId
|
|
895
1031
|
&& entry.snapshotChecksum === checksum);
|
|
896
1032
|
if (identical) {
|
|
@@ -901,7 +1037,6 @@ export class UserGroundHost {
|
|
|
901
1037
|
throw new Error("a different graph snapshot is already pending publication");
|
|
902
1038
|
}
|
|
903
1039
|
appendSnapshotJournal(this.databasePath(identity), {
|
|
904
|
-
kind: "entity.snapshot.replace",
|
|
905
1040
|
mutationId: randomUUID(),
|
|
906
1041
|
resourceId: state.resourceId,
|
|
907
1042
|
authorityEpoch: state.authorityEpoch,
|
|
@@ -912,9 +1047,7 @@ export class UserGroundHost {
|
|
|
912
1047
|
await this.publishPendingSnapshots(identity);
|
|
913
1048
|
}
|
|
914
1049
|
async publishPendingSnapshots(identity) {
|
|
915
|
-
for (const pending of
|
|
916
|
-
if (pending.kind !== "entity.snapshot.replace")
|
|
917
|
-
continue;
|
|
1050
|
+
for (const pending of readSnapshotPublications(this.databasePath(identity))) {
|
|
918
1051
|
const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
|
|
919
1052
|
const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
|
|
920
1053
|
.map((row) => row.uuid));
|
|
@@ -956,6 +1089,15 @@ export class UserGroundHost {
|
|
|
956
1089
|
}
|
|
957
1090
|
}
|
|
958
1091
|
const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
|
|
1092
|
+
function encodedRecordBytes(authorityId, proposal, lastSeenGlobalSequence = 0) {
|
|
1093
|
+
return Buffer.byteLength(encodeEntityRecord(createLocalEntityRecord({
|
|
1094
|
+
mutationId: "00000000-0000-4000-8000-000000000000",
|
|
1095
|
+
authorityId,
|
|
1096
|
+
lastSeenGlobalSequence,
|
|
1097
|
+
change: proposal,
|
|
1098
|
+
createdAt: 0,
|
|
1099
|
+
})));
|
|
1100
|
+
}
|
|
959
1101
|
function contentCacheFile(cacheDir, contentHash) {
|
|
960
1102
|
return join(cacheDir, `${contentHash}.bin`);
|
|
961
1103
|
}
|
|
@@ -1086,13 +1228,46 @@ function initializeDatabase(file) {
|
|
|
1086
1228
|
filesystem_mode INTEGER,
|
|
1087
1229
|
content_verified_at_ms REAL
|
|
1088
1230
|
);
|
|
1089
|
-
CREATE TABLE IF NOT EXISTS
|
|
1231
|
+
CREATE TABLE IF NOT EXISTS record_outbox (
|
|
1232
|
+
local_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1233
|
+
mutation_id TEXT NOT NULL UNIQUE,
|
|
1234
|
+
authority_id TEXT NOT NULL,
|
|
1235
|
+
entity_id TEXT NOT NULL,
|
|
1236
|
+
record_json TEXT NOT NULL,
|
|
1237
|
+
status TEXT NOT NULL CHECK(status IN ('pending', 'accepted')),
|
|
1238
|
+
global_sequence INTEGER,
|
|
1239
|
+
created_at INTEGER NOT NULL,
|
|
1240
|
+
accepted_at INTEGER,
|
|
1241
|
+
CHECK((status = 'pending' AND global_sequence IS NULL AND accepted_at IS NULL)
|
|
1242
|
+
OR (status = 'accepted' AND global_sequence > 0 AND accepted_at IS NOT NULL))
|
|
1243
|
+
);
|
|
1244
|
+
CREATE TABLE IF NOT EXISTS record_inbox (
|
|
1245
|
+
authority_id TEXT NOT NULL,
|
|
1246
|
+
entity_id TEXT NOT NULL,
|
|
1247
|
+
global_sequence INTEGER NOT NULL CHECK(global_sequence > 0),
|
|
1248
|
+
delivery_sequence INTEGER NOT NULL CHECK(delivery_sequence > 0),
|
|
1249
|
+
mutation_id TEXT NOT NULL,
|
|
1250
|
+
record_json TEXT NOT NULL,
|
|
1251
|
+
received_at INTEGER NOT NULL,
|
|
1252
|
+
PRIMARY KEY(entity_id, global_sequence),
|
|
1253
|
+
UNIQUE(entity_id, mutation_id),
|
|
1254
|
+
UNIQUE(authority_id, delivery_sequence)
|
|
1255
|
+
);
|
|
1256
|
+
CREATE TABLE IF NOT EXISTS entity_receive_cursors (
|
|
1257
|
+
entity_id TEXT PRIMARY KEY,
|
|
1258
|
+
authority_id TEXT NOT NULL,
|
|
1259
|
+
received_through INTEGER NOT NULL CHECK(received_through >= 0)
|
|
1260
|
+
);
|
|
1261
|
+
CREATE TABLE IF NOT EXISTS authority_delivery_cursors (
|
|
1262
|
+
authority_id TEXT PRIMARY KEY,
|
|
1263
|
+
received_through INTEGER NOT NULL CHECK(received_through >= 0)
|
|
1264
|
+
);
|
|
1265
|
+
CREATE TABLE IF NOT EXISTS snapshot_publications (
|
|
1090
1266
|
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1091
1267
|
mutation_id TEXT NOT NULL UNIQUE,
|
|
1092
1268
|
resource_id TEXT NOT NULL,
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
created_at TEXT NOT NULL
|
|
1269
|
+
publication_json TEXT NOT NULL,
|
|
1270
|
+
created_at INTEGER NOT NULL
|
|
1096
1271
|
);
|
|
1097
1272
|
CREATE TABLE IF NOT EXISTS workspace_add_intents (
|
|
1098
1273
|
workspace_uuid TEXT PRIMARY KEY,
|
|
@@ -1113,9 +1288,11 @@ function initializeDatabase(file) {
|
|
|
1113
1288
|
ON detection_notebook(absolute_path);
|
|
1114
1289
|
CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
|
|
1115
1290
|
ON detection_notebook(device_number, inode);
|
|
1116
|
-
CREATE INDEX IF NOT EXISTS
|
|
1117
|
-
ON
|
|
1291
|
+
CREATE INDEX IF NOT EXISTS record_outbox_pending
|
|
1292
|
+
ON record_outbox(status, local_sequence);
|
|
1118
1293
|
`);
|
|
1294
|
+
database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
|
|
1295
|
+
database.exec("DROP TABLE IF EXISTS mutation_journal");
|
|
1119
1296
|
return database;
|
|
1120
1297
|
}
|
|
1121
1298
|
function readGroundRows(file, table) {
|
|
@@ -1272,51 +1449,205 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1272
1449
|
database.close();
|
|
1273
1450
|
}
|
|
1274
1451
|
}
|
|
1275
|
-
function
|
|
1452
|
+
function readOutbox(file) {
|
|
1453
|
+
if (!existsSync(file))
|
|
1454
|
+
return [];
|
|
1455
|
+
const database = initializeDatabase(file);
|
|
1456
|
+
try {
|
|
1457
|
+
return database.prepare(`
|
|
1458
|
+
SELECT local_sequence AS localSequence, record_json AS recordJson,
|
|
1459
|
+
status, global_sequence AS globalSequence
|
|
1460
|
+
FROM record_outbox WHERE status = 'pending' ORDER BY local_sequence
|
|
1461
|
+
`).all().map((row) => ({
|
|
1462
|
+
localSequence: row.localSequence,
|
|
1463
|
+
record: decodeLocalEntityRecord(row.recordJson),
|
|
1464
|
+
status: "pending",
|
|
1465
|
+
globalSequence: null,
|
|
1466
|
+
}));
|
|
1467
|
+
}
|
|
1468
|
+
finally {
|
|
1469
|
+
database.close();
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
function readSnapshotPublications(file) {
|
|
1276
1473
|
if (!existsSync(file))
|
|
1277
1474
|
return [];
|
|
1278
1475
|
const database = initializeDatabase(file);
|
|
1279
1476
|
try {
|
|
1280
1477
|
return database.prepare(`
|
|
1281
1478
|
SELECT mutation_id AS mutationId, resource_id AS resourceId,
|
|
1282
|
-
|
|
1283
|
-
FROM
|
|
1479
|
+
publication_json AS publicationJson
|
|
1480
|
+
FROM snapshot_publications ORDER BY sequence
|
|
1284
1481
|
`).all().map((row) => {
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1482
|
+
const publication = JSON.parse(row.publicationJson);
|
|
1483
|
+
return {
|
|
1484
|
+
mutationId: row.mutationId,
|
|
1485
|
+
resourceId: row.resourceId,
|
|
1486
|
+
authorityEpoch: Number(publication.authorityEpoch),
|
|
1487
|
+
baseVersion: Number(publication.baseVersion),
|
|
1488
|
+
snapshotChecksum: String(publication.snapshotChecksum),
|
|
1489
|
+
snapshotJson: String(publication.snapshotJson),
|
|
1490
|
+
};
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
finally {
|
|
1494
|
+
database.close();
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
function readEntityReceiveCursor(file, authorityId, entityId) {
|
|
1498
|
+
if (!existsSync(file))
|
|
1499
|
+
return 0;
|
|
1500
|
+
const database = initializeDatabase(file);
|
|
1501
|
+
try {
|
|
1502
|
+
const row = database.prepare(`
|
|
1503
|
+
SELECT received_through AS receivedThrough
|
|
1504
|
+
FROM entity_receive_cursors
|
|
1505
|
+
WHERE authority_id = ? AND entity_id = ?
|
|
1506
|
+
`).get(authorityId, entityId);
|
|
1507
|
+
return row?.receivedThrough ?? 0;
|
|
1508
|
+
}
|
|
1509
|
+
finally {
|
|
1510
|
+
database.close();
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
function readEntityReceiveCursors(file, authorityId) {
|
|
1514
|
+
if (!existsSync(file))
|
|
1515
|
+
return new Map();
|
|
1516
|
+
const database = initializeDatabase(file);
|
|
1517
|
+
try {
|
|
1518
|
+
return new Map(database.prepare(`
|
|
1519
|
+
SELECT entity_id AS entityId, received_through AS receivedThrough
|
|
1520
|
+
FROM entity_receive_cursors WHERE authority_id = ?
|
|
1521
|
+
`).all(authorityId)
|
|
1522
|
+
.map((row) => [row.entityId, row.receivedThrough]));
|
|
1523
|
+
}
|
|
1524
|
+
finally {
|
|
1525
|
+
database.close();
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
function readAuthorityDeliveryCursor(file, authorityId) {
|
|
1529
|
+
if (!existsSync(file))
|
|
1530
|
+
return 0;
|
|
1531
|
+
const database = initializeDatabase(file);
|
|
1532
|
+
try {
|
|
1533
|
+
const row = database.prepare(`
|
|
1534
|
+
SELECT received_through AS receivedThrough
|
|
1535
|
+
FROM authority_delivery_cursors WHERE authority_id = ?
|
|
1536
|
+
`).get(authorityId);
|
|
1537
|
+
return row?.receivedThrough ?? 0;
|
|
1538
|
+
}
|
|
1539
|
+
finally {
|
|
1540
|
+
database.close();
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
function decodeDeliveredRecords(value) {
|
|
1544
|
+
if (!Array.isArray(value))
|
|
1545
|
+
throw new Error("entity record tail did not return a records array");
|
|
1546
|
+
return value.map((item) => {
|
|
1547
|
+
if (item === null || typeof item !== "object") {
|
|
1548
|
+
throw new Error("entity record tail contained a non-record delivery");
|
|
1549
|
+
}
|
|
1550
|
+
const delivered = item;
|
|
1551
|
+
const deliverySequence = Number(delivered.delivery_sequence);
|
|
1552
|
+
if (!Number.isSafeInteger(deliverySequence) || deliverySequence < 1
|
|
1553
|
+
|| typeof delivered.record_json !== "string") {
|
|
1554
|
+
throw new Error("entity record delivery has invalid sequence or bytes");
|
|
1555
|
+
}
|
|
1556
|
+
return {
|
|
1557
|
+
deliverySequence,
|
|
1558
|
+
record: decodeAcceptedEntityRecord(delivered.record_json),
|
|
1559
|
+
};
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
function stampOutboxAcceptance(file, row, accepted) {
|
|
1563
|
+
const receipt = acceptOutboxRow(row, accepted);
|
|
1564
|
+
if (receipt.status !== "accepted")
|
|
1565
|
+
throw new Error("authority acknowledgement was not accepted");
|
|
1566
|
+
const database = initializeDatabase(file);
|
|
1567
|
+
try {
|
|
1568
|
+
database.transaction(() => {
|
|
1569
|
+
const updated = database.prepare(`
|
|
1570
|
+
UPDATE record_outbox
|
|
1571
|
+
SET status = 'accepted', global_sequence = ?, accepted_at = ?
|
|
1572
|
+
WHERE mutation_id = ? AND status = 'pending'
|
|
1573
|
+
`).run(accepted.globalSequence, accepted.acceptedAt, accepted.mutationId);
|
|
1574
|
+
if (updated.changes === 1)
|
|
1575
|
+
return;
|
|
1576
|
+
const existing = database.prepare(`
|
|
1577
|
+
SELECT status, global_sequence AS globalSequence
|
|
1578
|
+
FROM record_outbox WHERE mutation_id = ?
|
|
1579
|
+
`).get(accepted.mutationId);
|
|
1580
|
+
if (existing?.status !== "accepted"
|
|
1581
|
+
|| existing.globalSequence !== accepted.globalSequence) {
|
|
1582
|
+
throw new Error("outbox acknowledgement did not match its retained row");
|
|
1296
1583
|
}
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1584
|
+
})();
|
|
1585
|
+
}
|
|
1586
|
+
finally {
|
|
1587
|
+
database.close();
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function storeReceivedRecord(file, delivery) {
|
|
1591
|
+
const database = initializeDatabase(file);
|
|
1592
|
+
try {
|
|
1593
|
+
return database.transaction(() => {
|
|
1594
|
+
const cursor = database.prepare(`
|
|
1595
|
+
SELECT authority_id AS authorityId, received_through AS receivedThrough
|
|
1596
|
+
FROM entity_receive_cursors WHERE entity_id = ?
|
|
1597
|
+
`).get(delivery.record.entityId);
|
|
1598
|
+
if (cursor && cursor.authorityId !== delivery.record.authorityId) {
|
|
1599
|
+
throw new Error(`entity ${delivery.record.entityId} cannot change authority channels`);
|
|
1304
1600
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1601
|
+
const existingRow = database.prepare(`
|
|
1602
|
+
SELECT record_json AS recordJson FROM record_inbox
|
|
1603
|
+
WHERE authority_id = ? AND entity_id = ? AND global_sequence = ?
|
|
1604
|
+
`).get(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence);
|
|
1605
|
+
const existing = existingRow
|
|
1606
|
+
? decodeAcceptedEntityRecord(existingRow.recordJson)
|
|
1607
|
+
: null;
|
|
1608
|
+
const decision = receiveEntityRecord(cursor?.receivedThrough ?? 0, delivery.record, existing);
|
|
1609
|
+
if (decision.kind !== "insert")
|
|
1610
|
+
return decision;
|
|
1611
|
+
database.prepare(`
|
|
1612
|
+
INSERT INTO record_inbox(
|
|
1613
|
+
authority_id, entity_id, global_sequence, delivery_sequence,
|
|
1614
|
+
mutation_id, record_json, received_at
|
|
1615
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1616
|
+
`).run(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence, delivery.deliverySequence, delivery.record.mutationId, encodeEntityRecord(delivery.record), Date.now());
|
|
1617
|
+
database.prepare(`
|
|
1618
|
+
INSERT INTO entity_receive_cursors(authority_id, entity_id, received_through)
|
|
1619
|
+
VALUES (?, ?, ?)
|
|
1620
|
+
ON CONFLICT(entity_id) DO UPDATE SET
|
|
1621
|
+
received_through = excluded.received_through
|
|
1622
|
+
`).run(delivery.record.authorityId, delivery.record.entityId, decision.receivedThrough);
|
|
1623
|
+
return decision;
|
|
1624
|
+
})();
|
|
1625
|
+
}
|
|
1626
|
+
finally {
|
|
1627
|
+
database.close();
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
function advanceAuthorityDeliveryCursor(file, authorityId, deliverySequence) {
|
|
1631
|
+
const database = initializeDatabase(file);
|
|
1632
|
+
try {
|
|
1633
|
+
database.prepare(`
|
|
1634
|
+
INSERT INTO authority_delivery_cursors(authority_id, received_through)
|
|
1635
|
+
VALUES (?, ?)
|
|
1636
|
+
ON CONFLICT(authority_id) DO UPDATE SET
|
|
1637
|
+
received_through = MAX(received_through, excluded.received_through)
|
|
1638
|
+
`).run(authorityId, deliverySequence);
|
|
1307
1639
|
}
|
|
1308
1640
|
finally {
|
|
1309
1641
|
database.close();
|
|
1310
1642
|
}
|
|
1311
1643
|
}
|
|
1312
|
-
function
|
|
1644
|
+
function hasRecordsBeyondSnapshot(file) {
|
|
1313
1645
|
if (!existsSync(file))
|
|
1314
1646
|
return false;
|
|
1315
1647
|
const database = initializeDatabase(file);
|
|
1316
1648
|
try {
|
|
1317
1649
|
return database.prepare(`
|
|
1318
|
-
SELECT 1 AS present FROM
|
|
1319
|
-
WHERE operation_kind = 'detected.change' LIMIT 1
|
|
1650
|
+
SELECT 1 AS present FROM record_outbox LIMIT 1
|
|
1320
1651
|
`).get() !== undefined;
|
|
1321
1652
|
}
|
|
1322
1653
|
finally {
|
|
@@ -1325,19 +1656,27 @@ function hasDetectedChanges(file) {
|
|
|
1325
1656
|
}
|
|
1326
1657
|
/** Record is the SQLite transaction that accepts Detect's exact proposals and
|
|
1327
1658
|
* advances the last-verified notebook. Watch may settle only after this
|
|
1328
|
-
* function returns. The
|
|
1659
|
+
* function returns. The record table is the local-only outbox. */
|
|
1329
1660
|
function commitDetectedState(file, input) {
|
|
1330
1661
|
const database = initializeDatabase(file);
|
|
1331
1662
|
try {
|
|
1332
1663
|
database.transaction(() => {
|
|
1333
1664
|
const append = database.prepare(`
|
|
1334
|
-
INSERT INTO
|
|
1335
|
-
mutation_id,
|
|
1336
|
-
|
|
1665
|
+
INSERT INTO record_outbox(
|
|
1666
|
+
mutation_id, authority_id, entity_id, record_json, status,
|
|
1667
|
+
global_sequence, created_at, accepted_at
|
|
1668
|
+
) VALUES (?, ?, ?, ?, 'pending', NULL, ?, NULL)
|
|
1337
1669
|
`);
|
|
1338
1670
|
for (const entry of input.proposals ?? []) {
|
|
1339
|
-
const
|
|
1340
|
-
|
|
1671
|
+
const createdAt = Date.now();
|
|
1672
|
+
const record = createLocalEntityRecord({
|
|
1673
|
+
mutationId: entry.mutationId,
|
|
1674
|
+
authorityId: entry.authorityId,
|
|
1675
|
+
lastSeenGlobalSequence: entry.lastSeenGlobalSequence,
|
|
1676
|
+
change: entry.proposal,
|
|
1677
|
+
createdAt,
|
|
1678
|
+
});
|
|
1679
|
+
append.run(record.mutationId, record.authorityId, record.entityId, encodeEntityRecord(record), createdAt);
|
|
1341
1680
|
}
|
|
1342
1681
|
const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
|
|
1343
1682
|
const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
|
|
@@ -1362,15 +1701,15 @@ function appendSnapshotJournal(file, entry) {
|
|
|
1362
1701
|
const database = initializeDatabase(file);
|
|
1363
1702
|
try {
|
|
1364
1703
|
database.prepare(`
|
|
1365
|
-
INSERT INTO
|
|
1366
|
-
mutation_id, resource_id,
|
|
1367
|
-
) VALUES (?, ?, ?,
|
|
1368
|
-
`).run(entry.mutationId, entry.resourceId,
|
|
1704
|
+
INSERT INTO snapshot_publications(
|
|
1705
|
+
mutation_id, resource_id, publication_json, created_at
|
|
1706
|
+
) VALUES (?, ?, ?, ?)
|
|
1707
|
+
`).run(entry.mutationId, entry.resourceId, stableJson({
|
|
1369
1708
|
authorityEpoch: entry.authorityEpoch,
|
|
1370
1709
|
baseVersion: entry.baseVersion,
|
|
1371
1710
|
snapshotChecksum: entry.snapshotChecksum,
|
|
1372
1711
|
snapshotJson: entry.snapshotJson,
|
|
1373
|
-
}),
|
|
1712
|
+
}), Date.now());
|
|
1374
1713
|
}
|
|
1375
1714
|
finally {
|
|
1376
1715
|
database.close();
|
|
@@ -1379,7 +1718,7 @@ function appendSnapshotJournal(file, entry) {
|
|
|
1379
1718
|
function deleteJournalEntry(file, mutationId) {
|
|
1380
1719
|
const database = initializeDatabase(file);
|
|
1381
1720
|
try {
|
|
1382
|
-
database.prepare("DELETE FROM
|
|
1721
|
+
database.prepare("DELETE FROM snapshot_publications WHERE mutation_id = ?").run(mutationId);
|
|
1383
1722
|
}
|
|
1384
1723
|
finally {
|
|
1385
1724
|
database.close();
|
|
@@ -1758,7 +2097,7 @@ async function reconcileGround(input) {
|
|
|
1758
2097
|
durationMs: performance.now() - started,
|
|
1759
2098
|
plannedRows: proposals.length,
|
|
1760
2099
|
records: proposals.length,
|
|
1761
|
-
|
|
2100
|
+
recordBytes: proposals.reduce((bytes, proposal) => bytes + encodedRecordBytes(parameters.resourceId, proposal), 0),
|
|
1762
2101
|
});
|
|
1763
2102
|
// Each root commits its local projection before Detect yields. Native
|
|
1764
2103
|
// callbacks can therefore preserve new suspicion between large roots.
|