@amalgm/shell 0.1.53 → 0.1.54
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 +18 -6
- package/dist/detection/runtime.js +1 -0
- package/dist/detection/runtime.js.map +1 -1
- package/dist/entity-record-store.d.ts +17 -0
- package/dist/entity-record-store.js +144 -0
- package/dist/entity-record-store.js.map +1 -0
- package/dist/user-ground-host.d.ts +3 -8
- package/dist/user-ground-host.js +66 -299
- package/dist/user-ground-host.js.map +1 -1
- package/dist/wire-client.d.ts +6 -0
- package/dist/wire-client.js +19 -2
- package/dist/wire-client.js.map +1 -1
- package/package.json +2 -2
package/dist/user-ground-host.js
CHANGED
|
@@ -3,9 +3,10 @@ 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,
|
|
6
|
+
import { CHUNK_BYTES, CONTENT_CONTRACT, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, createLocalEntityRecord, createEntityRecordAuthorityPort, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, encodeEntityRecord, EntityRecordRail, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, 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
|
+
import { EntityRecordSqliteStore } from "./entity-record-store.js";
|
|
9
10
|
import { ContentCacheDownload, captureContentFile, hashContentFile, sealCapturedArtifact, } from "./content-cache-host.js";
|
|
10
11
|
import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
|
|
11
12
|
import { exactTextReplay, planGroundDetection, reconcileGroundUUIDs, } from "./detection/portable.js";
|
|
@@ -22,9 +23,6 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
|
|
|
22
23
|
const DETECT_QUIET_MS = 12;
|
|
23
24
|
const DETECT_MAX_DEFERRAL_MS = 75;
|
|
24
25
|
const DETECT_RETRY_MS = 250;
|
|
25
|
-
const RECORD_RETRY_MS = 1_000;
|
|
26
|
-
const RECORD_POLL_MS = 1_000;
|
|
27
|
-
const RECORD_TAIL_LIMIT = 256;
|
|
28
26
|
const GROUND_ROW_COLUMNS = [
|
|
29
27
|
"uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
|
|
30
28
|
"payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
|
|
@@ -44,9 +42,8 @@ export class UserGroundHost {
|
|
|
44
42
|
cloudState = null;
|
|
45
43
|
converged = false;
|
|
46
44
|
syncing = null;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
recordSyncStopped = false;
|
|
45
|
+
recordRail = null;
|
|
46
|
+
recordStore = null;
|
|
50
47
|
namedDetect = null;
|
|
51
48
|
coldOperation = false;
|
|
52
49
|
closing = false;
|
|
@@ -90,8 +87,8 @@ export class UserGroundHost {
|
|
|
90
87
|
watcherRoots: watchHealth.contentHandles,
|
|
91
88
|
watching: true,
|
|
92
89
|
};
|
|
90
|
+
await this.startRecordRail(this.activeIdentity);
|
|
93
91
|
this.converged = true;
|
|
94
|
-
this.scheduleRecordSync();
|
|
95
92
|
return evidence;
|
|
96
93
|
},
|
|
97
94
|
};
|
|
@@ -359,7 +356,7 @@ export class UserGroundHost {
|
|
|
359
356
|
throw error;
|
|
360
357
|
}
|
|
361
358
|
} while (!this.watchHost.settled(cutoff));
|
|
362
|
-
await this.
|
|
359
|
+
await this.recordRail?.flush();
|
|
363
360
|
if (!this.closing)
|
|
364
361
|
this.ensureWatchers(identity);
|
|
365
362
|
}
|
|
@@ -383,31 +380,51 @@ export class UserGroundHost {
|
|
|
383
380
|
if (this.syncing)
|
|
384
381
|
await this.syncing;
|
|
385
382
|
}
|
|
386
|
-
|
|
387
|
-
if (this.
|
|
388
|
-
|
|
389
|
-
this.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
})
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
383
|
+
async startRecordRail(identity) {
|
|
384
|
+
if (!this.cloudState)
|
|
385
|
+
throw new Error("entity Record rail requires cloud authority");
|
|
386
|
+
if (this.recordRail || this.recordStore)
|
|
387
|
+
throw new Error("entity Record rail already started");
|
|
388
|
+
await this.wire.open();
|
|
389
|
+
if (this.wire.protocolVersion < 4) {
|
|
390
|
+
throw new Error("gateway does not support the event-driven entity Record wire");
|
|
391
|
+
}
|
|
392
|
+
const store = new EntityRecordSqliteStore(initializeDatabase(this.databasePath(identity)));
|
|
393
|
+
const authority = createEntityRecordAuthorityPort({
|
|
394
|
+
request: (frame, acceptedTypes) => this.wire.request({ ...frame }, acceptedTypes),
|
|
395
|
+
onFrame: (listener) => this.wire.onEvent(listener),
|
|
396
|
+
onDisconnect: (listener) => this.wire.onDisconnect(listener),
|
|
397
|
+
});
|
|
398
|
+
const rail = new EntityRecordRail({
|
|
399
|
+
authorityId: this.cloudState.resourceId,
|
|
400
|
+
store,
|
|
401
|
+
authority,
|
|
402
|
+
cargo: {
|
|
403
|
+
ensure: (record) => this.uploadSnapshotContent(identity, record.authorityId, [record.change.result]),
|
|
404
|
+
},
|
|
405
|
+
schedule: {
|
|
406
|
+
schedule(delayMs, callback) {
|
|
407
|
+
const timer = setTimeout(callback, delayMs);
|
|
408
|
+
timer.unref();
|
|
409
|
+
return { cancel: () => clearTimeout(timer) };
|
|
410
|
+
},
|
|
411
|
+
random: Math.random,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
this.recordStore = store;
|
|
415
|
+
this.recordRail = rail;
|
|
416
|
+
try {
|
|
417
|
+
await rail.start();
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
const stopping = rail.stop();
|
|
421
|
+
await this.wire.close();
|
|
422
|
+
await stopping;
|
|
423
|
+
store.close();
|
|
424
|
+
this.recordRail = null;
|
|
425
|
+
this.recordStore = null;
|
|
426
|
+
throw pipelineStageError("entity record subscribe", error);
|
|
409
427
|
}
|
|
410
|
-
await this.recordSyncing;
|
|
411
428
|
}
|
|
412
429
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
413
430
|
// Files convergence and Watch/Detect own ground currency. Advertising an
|
|
@@ -416,28 +433,27 @@ export class UserGroundHost {
|
|
|
416
433
|
}
|
|
417
434
|
async stopRuntimeTunnel() {
|
|
418
435
|
this.wire.configureRuntime({});
|
|
419
|
-
await this.wire.close();
|
|
420
436
|
}
|
|
421
437
|
async close(options = {}) {
|
|
422
438
|
if (options.catchUp === false) {
|
|
423
439
|
this.closing = true;
|
|
424
|
-
this.recordSyncStopped = true;
|
|
425
440
|
if (this.rescanTimer)
|
|
426
441
|
clearTimeout(this.rescanTimer);
|
|
427
442
|
this.rescanTimer = null;
|
|
428
443
|
this.rescanGenerationStartedAt = null;
|
|
429
|
-
if (this.recordSyncTimer)
|
|
430
|
-
clearTimeout(this.recordSyncTimer);
|
|
431
|
-
this.recordSyncTimer = null;
|
|
432
444
|
this.watchHost.close({ catchUp: false });
|
|
433
445
|
this.namedDetect?.close();
|
|
434
446
|
this.namedDetect = null;
|
|
447
|
+
const recordStopped = this.recordRail?.stop();
|
|
435
448
|
await this.wire.close();
|
|
436
|
-
await
|
|
449
|
+
await recordStopped;
|
|
437
450
|
// An upload already inside its retry helper may have reopened the wire
|
|
438
|
-
// after the first close. The stopped
|
|
451
|
+
// after the first close. The stopped rail prevents new queue work; this
|
|
439
452
|
// second close seals that finite in-flight boundary.
|
|
440
453
|
await this.wire.close();
|
|
454
|
+
this.recordStore?.close();
|
|
455
|
+
this.recordStore = null;
|
|
456
|
+
this.recordRail = null;
|
|
441
457
|
return;
|
|
442
458
|
}
|
|
443
459
|
// Command shutdown is the last catch-up boundary. A filesystem callback
|
|
@@ -449,20 +465,21 @@ export class UserGroundHost {
|
|
|
449
465
|
clearTimeout(this.rescanTimer);
|
|
450
466
|
this.rescanTimer = null;
|
|
451
467
|
this.rescanGenerationStartedAt = null;
|
|
452
|
-
if (this.recordSyncTimer)
|
|
453
|
-
clearTimeout(this.recordSyncTimer);
|
|
454
|
-
this.recordSyncTimer = null;
|
|
455
468
|
// Full suspicion already contains every callback that could still be
|
|
456
469
|
// queued. Retire physical coverage before the final observation so native
|
|
457
470
|
// backends cannot continuously widen a finite shutdown pass. Changes
|
|
458
471
|
// after this cutoff belong to the next startup blind interval.
|
|
459
472
|
this.watchHost.close();
|
|
460
473
|
await this.flushObservedChanges();
|
|
461
|
-
await this.
|
|
462
|
-
this.recordSyncStopped = true;
|
|
474
|
+
await this.recordRail?.flush();
|
|
463
475
|
this.namedDetect?.close();
|
|
464
476
|
this.namedDetect = null;
|
|
477
|
+
const recordStopped = this.recordRail?.stop();
|
|
465
478
|
await this.wire.close();
|
|
479
|
+
await recordStopped;
|
|
480
|
+
this.recordStore?.close();
|
|
481
|
+
this.recordStore = null;
|
|
482
|
+
this.recordRail = null;
|
|
466
483
|
}
|
|
467
484
|
userRoot(identity) {
|
|
468
485
|
return resolve(scopedAmalgmDir(this.options.amalgmRoot, identity.userEmail));
|
|
@@ -868,7 +885,7 @@ export class UserGroundHost {
|
|
|
868
885
|
});
|
|
869
886
|
}
|
|
870
887
|
this.watchHost.settle(observations);
|
|
871
|
-
this.
|
|
888
|
+
this.recordRail?.localRecordsAvailable();
|
|
872
889
|
return;
|
|
873
890
|
}
|
|
874
891
|
}
|
|
@@ -900,7 +917,7 @@ export class UserGroundHost {
|
|
|
900
917
|
throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
|
|
901
918
|
}
|
|
902
919
|
const authorityId = this.cloudState.resourceId;
|
|
903
|
-
const received =
|
|
920
|
+
const received = this.recordStore?.readEntityCursors(authorityId) ?? new Map();
|
|
904
921
|
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
905
922
|
? plan.proposals.map((proposal) => ({
|
|
906
923
|
mutationId: randomUUID(),
|
|
@@ -918,91 +935,7 @@ export class UserGroundHost {
|
|
|
918
935
|
});
|
|
919
936
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
920
937
|
this.watchHost.settle(observations);
|
|
921
|
-
this.
|
|
922
|
-
}
|
|
923
|
-
async syncRecordQueues(identity) {
|
|
924
|
-
if (this.recordSyncStopped)
|
|
925
|
-
return;
|
|
926
|
-
await this.receiveAcceptedRecords();
|
|
927
|
-
while (!this.recordSyncStopped) {
|
|
928
|
-
const selected = nextEntitySends(readOutbox(this.databasePath(identity)));
|
|
929
|
-
if (selected.length === 0)
|
|
930
|
-
break;
|
|
931
|
-
const sent = await Promise.allSettled(selected.map(async (row) => {
|
|
932
|
-
if (this.recordSyncStopped)
|
|
933
|
-
return;
|
|
934
|
-
await this.uploadSnapshotContent(identity, row.record.authorityId, [row.record.change.result]);
|
|
935
|
-
if (this.recordSyncStopped)
|
|
936
|
-
return;
|
|
937
|
-
const frame = await this.wire.request({
|
|
938
|
-
type: "entity.record.submit",
|
|
939
|
-
record_json: encodeEntityRecord(row.record),
|
|
940
|
-
}, ["entity.record.ack"]);
|
|
941
|
-
const accepted = decodeAcceptedEntityRecord(String(frame.record_json ?? ""));
|
|
942
|
-
stampOutboxAcceptance(this.databasePath(identity), row, accepted);
|
|
943
|
-
}));
|
|
944
|
-
const failed = sent.find((result) => result.status === "rejected");
|
|
945
|
-
if (failed)
|
|
946
|
-
throw pipelineStageError("entity record send", failed.reason);
|
|
947
|
-
}
|
|
948
|
-
if (!this.recordSyncStopped)
|
|
949
|
-
await this.receiveAcceptedRecords();
|
|
950
|
-
}
|
|
951
|
-
async receiveAcceptedRecords() {
|
|
952
|
-
const identity = this.activeIdentity;
|
|
953
|
-
const authorityId = this.cloudState?.resourceId;
|
|
954
|
-
if (!identity || !authorityId)
|
|
955
|
-
return;
|
|
956
|
-
const database = this.databasePath(identity);
|
|
957
|
-
while (!this.recordSyncStopped) {
|
|
958
|
-
const after = readAuthorityDeliveryCursor(database, authorityId);
|
|
959
|
-
const frame = await this.wire.request({
|
|
960
|
-
type: "entity.record.tail",
|
|
961
|
-
authority_id: authorityId,
|
|
962
|
-
after_delivery_sequence: after,
|
|
963
|
-
limit: RECORD_TAIL_LIMIT,
|
|
964
|
-
}, ["entity.record.tail-result"]);
|
|
965
|
-
const deliveries = decodeDeliveredRecords(frame.records);
|
|
966
|
-
if (this.recordSyncStopped)
|
|
967
|
-
return;
|
|
968
|
-
if (deliveries.length === 0)
|
|
969
|
-
return;
|
|
970
|
-
for (const delivery of deliveries) {
|
|
971
|
-
await this.receiveDelivery(database, authorityId, delivery);
|
|
972
|
-
}
|
|
973
|
-
if (deliveries.length < RECORD_TAIL_LIMIT)
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
async receiveDelivery(database, authorityId, delivery) {
|
|
978
|
-
if (delivery.record.authorityId !== authorityId) {
|
|
979
|
-
throw new Error(`entity record authority ${delivery.record.authorityId} does not match ${authorityId}`);
|
|
980
|
-
}
|
|
981
|
-
let decision = storeReceivedRecord(database, delivery);
|
|
982
|
-
if (decision.kind === "gap") {
|
|
983
|
-
const frame = await this.wire.request({
|
|
984
|
-
type: "entity.record.entity-tail",
|
|
985
|
-
authority_id: authorityId,
|
|
986
|
-
entity_id: decision.entityId,
|
|
987
|
-
after_global_sequence: decision.after,
|
|
988
|
-
through_global_sequence: decision.received,
|
|
989
|
-
}, ["entity.record.entity-tail-result"]);
|
|
990
|
-
const missing = decodeDeliveredRecords(frame.records);
|
|
991
|
-
if (missing.length === 0) {
|
|
992
|
-
throw new Error(`entity ${decision.entityId} is missing sequence ${decision.after + 1}`);
|
|
993
|
-
}
|
|
994
|
-
for (const record of missing) {
|
|
995
|
-
decision = storeReceivedRecord(database, record);
|
|
996
|
-
if (decision.kind === "gap") {
|
|
997
|
-
throw new Error(`entity ${decision.entityId} history remained gapped after backfill`);
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
decision = storeReceivedRecord(database, delivery);
|
|
1001
|
-
if (decision.kind === "gap") {
|
|
1002
|
-
throw new Error(`entity ${decision.entityId} history did not reach ${decision.received}`);
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
advanceAuthorityDeliveryCursor(database, authorityId, delivery.deliverySequence);
|
|
938
|
+
this.recordRail?.localRecordsAvailable();
|
|
1006
939
|
}
|
|
1007
940
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
1008
941
|
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
@@ -1193,6 +1126,7 @@ function assertHealthyWatch(evidence, rootId) {
|
|
|
1193
1126
|
function initializeDatabase(file) {
|
|
1194
1127
|
ensurePrivateDir(dirname(file));
|
|
1195
1128
|
const database = new Database(file);
|
|
1129
|
+
database.pragma("busy_timeout = 5000");
|
|
1196
1130
|
database.pragma("journal_mode = WAL");
|
|
1197
1131
|
database.pragma("synchronous = FULL");
|
|
1198
1132
|
database.exec(`
|
|
@@ -1481,26 +1415,6 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1481
1415
|
database.close();
|
|
1482
1416
|
}
|
|
1483
1417
|
}
|
|
1484
|
-
function readOutbox(file) {
|
|
1485
|
-
if (!existsSync(file))
|
|
1486
|
-
return [];
|
|
1487
|
-
const database = initializeDatabase(file);
|
|
1488
|
-
try {
|
|
1489
|
-
return database.prepare(`
|
|
1490
|
-
SELECT local_sequence AS localSequence, record_json AS recordJson,
|
|
1491
|
-
status, global_sequence AS globalSequence
|
|
1492
|
-
FROM record_outbox WHERE status = 'pending' ORDER BY local_sequence
|
|
1493
|
-
`).all().map((row) => ({
|
|
1494
|
-
localSequence: row.localSequence,
|
|
1495
|
-
record: decodeLocalEntityRecord(row.recordJson),
|
|
1496
|
-
status: "pending",
|
|
1497
|
-
globalSequence: null,
|
|
1498
|
-
}));
|
|
1499
|
-
}
|
|
1500
|
-
finally {
|
|
1501
|
-
database.close();
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
1418
|
function readSnapshotPublications(file) {
|
|
1505
1419
|
if (!existsSync(file))
|
|
1506
1420
|
return [];
|
|
@@ -1526,153 +1440,6 @@ function readSnapshotPublications(file) {
|
|
|
1526
1440
|
database.close();
|
|
1527
1441
|
}
|
|
1528
1442
|
}
|
|
1529
|
-
function readEntityReceiveCursor(file, authorityId, entityId) {
|
|
1530
|
-
if (!existsSync(file))
|
|
1531
|
-
return 0;
|
|
1532
|
-
const database = initializeDatabase(file);
|
|
1533
|
-
try {
|
|
1534
|
-
const row = database.prepare(`
|
|
1535
|
-
SELECT received_through AS receivedThrough
|
|
1536
|
-
FROM entity_receive_cursors
|
|
1537
|
-
WHERE authority_id = ? AND entity_id = ?
|
|
1538
|
-
`).get(authorityId, entityId);
|
|
1539
|
-
return row?.receivedThrough ?? 0;
|
|
1540
|
-
}
|
|
1541
|
-
finally {
|
|
1542
|
-
database.close();
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
function readEntityReceiveCursors(file, authorityId) {
|
|
1546
|
-
if (!existsSync(file))
|
|
1547
|
-
return new Map();
|
|
1548
|
-
const database = initializeDatabase(file);
|
|
1549
|
-
try {
|
|
1550
|
-
return new Map(database.prepare(`
|
|
1551
|
-
SELECT entity_id AS entityId, received_through AS receivedThrough
|
|
1552
|
-
FROM entity_receive_cursors WHERE authority_id = ?
|
|
1553
|
-
`).all(authorityId)
|
|
1554
|
-
.map((row) => [row.entityId, row.receivedThrough]));
|
|
1555
|
-
}
|
|
1556
|
-
finally {
|
|
1557
|
-
database.close();
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
function readAuthorityDeliveryCursor(file, authorityId) {
|
|
1561
|
-
if (!existsSync(file))
|
|
1562
|
-
return 0;
|
|
1563
|
-
const database = initializeDatabase(file);
|
|
1564
|
-
try {
|
|
1565
|
-
const row = database.prepare(`
|
|
1566
|
-
SELECT received_through AS receivedThrough
|
|
1567
|
-
FROM authority_delivery_cursors WHERE authority_id = ?
|
|
1568
|
-
`).get(authorityId);
|
|
1569
|
-
return row?.receivedThrough ?? 0;
|
|
1570
|
-
}
|
|
1571
|
-
finally {
|
|
1572
|
-
database.close();
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
function decodeDeliveredRecords(value) {
|
|
1576
|
-
if (!Array.isArray(value))
|
|
1577
|
-
throw new Error("entity record tail did not return a records array");
|
|
1578
|
-
return value.map((item) => {
|
|
1579
|
-
if (item === null || typeof item !== "object") {
|
|
1580
|
-
throw new Error("entity record tail contained a non-record delivery");
|
|
1581
|
-
}
|
|
1582
|
-
const delivered = item;
|
|
1583
|
-
const deliverySequence = Number(delivered.delivery_sequence);
|
|
1584
|
-
if (!Number.isSafeInteger(deliverySequence) || deliverySequence < 1
|
|
1585
|
-
|| typeof delivered.record_json !== "string") {
|
|
1586
|
-
throw new Error("entity record delivery has invalid sequence or bytes");
|
|
1587
|
-
}
|
|
1588
|
-
return {
|
|
1589
|
-
deliverySequence,
|
|
1590
|
-
record: decodeAcceptedEntityRecord(delivered.record_json),
|
|
1591
|
-
};
|
|
1592
|
-
});
|
|
1593
|
-
}
|
|
1594
|
-
function stampOutboxAcceptance(file, row, accepted) {
|
|
1595
|
-
const receipt = acceptOutboxRow(row, accepted);
|
|
1596
|
-
if (receipt.status !== "accepted")
|
|
1597
|
-
throw new Error("authority acknowledgement was not accepted");
|
|
1598
|
-
const database = initializeDatabase(file);
|
|
1599
|
-
try {
|
|
1600
|
-
database.transaction(() => {
|
|
1601
|
-
const updated = database.prepare(`
|
|
1602
|
-
UPDATE record_outbox
|
|
1603
|
-
SET status = 'accepted', global_sequence = ?, accepted_at = ?
|
|
1604
|
-
WHERE mutation_id = ? AND status = 'pending'
|
|
1605
|
-
`).run(accepted.globalSequence, accepted.acceptedAt, accepted.mutationId);
|
|
1606
|
-
if (updated.changes === 1)
|
|
1607
|
-
return;
|
|
1608
|
-
const existing = database.prepare(`
|
|
1609
|
-
SELECT status, global_sequence AS globalSequence
|
|
1610
|
-
FROM record_outbox WHERE mutation_id = ?
|
|
1611
|
-
`).get(accepted.mutationId);
|
|
1612
|
-
if (existing?.status !== "accepted"
|
|
1613
|
-
|| existing.globalSequence !== accepted.globalSequence) {
|
|
1614
|
-
throw new Error("outbox acknowledgement did not match its retained row");
|
|
1615
|
-
}
|
|
1616
|
-
})();
|
|
1617
|
-
}
|
|
1618
|
-
finally {
|
|
1619
|
-
database.close();
|
|
1620
|
-
}
|
|
1621
|
-
}
|
|
1622
|
-
function storeReceivedRecord(file, delivery) {
|
|
1623
|
-
const database = initializeDatabase(file);
|
|
1624
|
-
try {
|
|
1625
|
-
return database.transaction(() => {
|
|
1626
|
-
const cursor = database.prepare(`
|
|
1627
|
-
SELECT authority_id AS authorityId, received_through AS receivedThrough
|
|
1628
|
-
FROM entity_receive_cursors WHERE entity_id = ?
|
|
1629
|
-
`).get(delivery.record.entityId);
|
|
1630
|
-
if (cursor && cursor.authorityId !== delivery.record.authorityId) {
|
|
1631
|
-
throw new Error(`entity ${delivery.record.entityId} cannot change authority channels`);
|
|
1632
|
-
}
|
|
1633
|
-
const existingRow = database.prepare(`
|
|
1634
|
-
SELECT record_json AS recordJson FROM record_inbox
|
|
1635
|
-
WHERE authority_id = ? AND entity_id = ? AND global_sequence = ?
|
|
1636
|
-
`).get(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence);
|
|
1637
|
-
const existing = existingRow
|
|
1638
|
-
? decodeAcceptedEntityRecord(existingRow.recordJson)
|
|
1639
|
-
: null;
|
|
1640
|
-
const decision = receiveEntityRecord(cursor?.receivedThrough ?? 0, delivery.record, existing);
|
|
1641
|
-
if (decision.kind !== "insert")
|
|
1642
|
-
return decision;
|
|
1643
|
-
database.prepare(`
|
|
1644
|
-
INSERT INTO record_inbox(
|
|
1645
|
-
authority_id, entity_id, global_sequence, delivery_sequence,
|
|
1646
|
-
mutation_id, record_json, received_at
|
|
1647
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1648
|
-
`).run(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence, delivery.deliverySequence, delivery.record.mutationId, encodeEntityRecord(delivery.record), Date.now());
|
|
1649
|
-
database.prepare(`
|
|
1650
|
-
INSERT INTO entity_receive_cursors(authority_id, entity_id, received_through)
|
|
1651
|
-
VALUES (?, ?, ?)
|
|
1652
|
-
ON CONFLICT(entity_id) DO UPDATE SET
|
|
1653
|
-
received_through = excluded.received_through
|
|
1654
|
-
`).run(delivery.record.authorityId, delivery.record.entityId, decision.receivedThrough);
|
|
1655
|
-
return decision;
|
|
1656
|
-
})();
|
|
1657
|
-
}
|
|
1658
|
-
finally {
|
|
1659
|
-
database.close();
|
|
1660
|
-
}
|
|
1661
|
-
}
|
|
1662
|
-
function advanceAuthorityDeliveryCursor(file, authorityId, deliverySequence) {
|
|
1663
|
-
const database = initializeDatabase(file);
|
|
1664
|
-
try {
|
|
1665
|
-
database.prepare(`
|
|
1666
|
-
INSERT INTO authority_delivery_cursors(authority_id, received_through)
|
|
1667
|
-
VALUES (?, ?)
|
|
1668
|
-
ON CONFLICT(authority_id) DO UPDATE SET
|
|
1669
|
-
received_through = MAX(received_through, excluded.received_through)
|
|
1670
|
-
`).run(authorityId, deliverySequence);
|
|
1671
|
-
}
|
|
1672
|
-
finally {
|
|
1673
|
-
database.close();
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
1443
|
function hasRecordsBeyondSnapshot(file) {
|
|
1677
1444
|
if (!existsSync(file))
|
|
1678
1445
|
return false;
|