@amalgm/shell 0.1.52 → 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 +23 -9
- 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 +80 -300
- package/dist/user-ground-host.js.map +1 -1
- package/dist/watching/host.d.ts +3 -0
- package/dist/watching/host.js +14 -1
- package/dist/watching/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
|
};
|
|
@@ -341,12 +338,25 @@ export class UserGroundHost {
|
|
|
341
338
|
return;
|
|
342
339
|
const identity = this.activeIdentity;
|
|
343
340
|
this.watchHost.suspectAll();
|
|
341
|
+
const cutoff = this.watchHost.observations();
|
|
344
342
|
// The complete suspicion above contains everything known at this cutoff.
|
|
345
343
|
// A native callback after this assignment sets watchDirty again and must
|
|
346
344
|
// remain a later generation; flush never waits for global Watch silence.
|
|
347
345
|
this.watchDirty = false;
|
|
348
|
-
|
|
349
|
-
|
|
346
|
+
do {
|
|
347
|
+
const joinedEarlierGeneration = this.syncing !== null;
|
|
348
|
+
try {
|
|
349
|
+
await this.syncNow();
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
// A job that was already in flight froze older observations. Its
|
|
353
|
+
// success or failure cannot prove this flush's later cutoff; the next
|
|
354
|
+
// pass owns that proof. A pass started here still fails honestly.
|
|
355
|
+
if (!joinedEarlierGeneration)
|
|
356
|
+
throw error;
|
|
357
|
+
}
|
|
358
|
+
} while (!this.watchHost.settled(cutoff));
|
|
359
|
+
await this.recordRail?.flush();
|
|
350
360
|
if (!this.closing)
|
|
351
361
|
this.ensureWatchers(identity);
|
|
352
362
|
}
|
|
@@ -370,31 +380,51 @@ export class UserGroundHost {
|
|
|
370
380
|
if (this.syncing)
|
|
371
381
|
await this.syncing;
|
|
372
382
|
}
|
|
373
|
-
|
|
374
|
-
if (this.
|
|
375
|
-
|
|
376
|
-
this.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
})
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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);
|
|
396
427
|
}
|
|
397
|
-
await this.recordSyncing;
|
|
398
428
|
}
|
|
399
429
|
async activateRuntimeTunnel(gatewayPort, runtimeToken) {
|
|
400
430
|
// Files convergence and Watch/Detect own ground currency. Advertising an
|
|
@@ -403,28 +433,27 @@ export class UserGroundHost {
|
|
|
403
433
|
}
|
|
404
434
|
async stopRuntimeTunnel() {
|
|
405
435
|
this.wire.configureRuntime({});
|
|
406
|
-
await this.wire.close();
|
|
407
436
|
}
|
|
408
437
|
async close(options = {}) {
|
|
409
438
|
if (options.catchUp === false) {
|
|
410
439
|
this.closing = true;
|
|
411
|
-
this.recordSyncStopped = true;
|
|
412
440
|
if (this.rescanTimer)
|
|
413
441
|
clearTimeout(this.rescanTimer);
|
|
414
442
|
this.rescanTimer = null;
|
|
415
443
|
this.rescanGenerationStartedAt = null;
|
|
416
|
-
if (this.recordSyncTimer)
|
|
417
|
-
clearTimeout(this.recordSyncTimer);
|
|
418
|
-
this.recordSyncTimer = null;
|
|
419
444
|
this.watchHost.close({ catchUp: false });
|
|
420
445
|
this.namedDetect?.close();
|
|
421
446
|
this.namedDetect = null;
|
|
447
|
+
const recordStopped = this.recordRail?.stop();
|
|
422
448
|
await this.wire.close();
|
|
423
|
-
await
|
|
449
|
+
await recordStopped;
|
|
424
450
|
// An upload already inside its retry helper may have reopened the wire
|
|
425
|
-
// after the first close. The stopped
|
|
451
|
+
// after the first close. The stopped rail prevents new queue work; this
|
|
426
452
|
// second close seals that finite in-flight boundary.
|
|
427
453
|
await this.wire.close();
|
|
454
|
+
this.recordStore?.close();
|
|
455
|
+
this.recordStore = null;
|
|
456
|
+
this.recordRail = null;
|
|
428
457
|
return;
|
|
429
458
|
}
|
|
430
459
|
// Command shutdown is the last catch-up boundary. A filesystem callback
|
|
@@ -436,20 +465,21 @@ export class UserGroundHost {
|
|
|
436
465
|
clearTimeout(this.rescanTimer);
|
|
437
466
|
this.rescanTimer = null;
|
|
438
467
|
this.rescanGenerationStartedAt = null;
|
|
439
|
-
if (this.recordSyncTimer)
|
|
440
|
-
clearTimeout(this.recordSyncTimer);
|
|
441
|
-
this.recordSyncTimer = null;
|
|
442
468
|
// Full suspicion already contains every callback that could still be
|
|
443
469
|
// queued. Retire physical coverage before the final observation so native
|
|
444
470
|
// backends cannot continuously widen a finite shutdown pass. Changes
|
|
445
471
|
// after this cutoff belong to the next startup blind interval.
|
|
446
472
|
this.watchHost.close();
|
|
447
473
|
await this.flushObservedChanges();
|
|
448
|
-
await this.
|
|
449
|
-
this.recordSyncStopped = true;
|
|
474
|
+
await this.recordRail?.flush();
|
|
450
475
|
this.namedDetect?.close();
|
|
451
476
|
this.namedDetect = null;
|
|
477
|
+
const recordStopped = this.recordRail?.stop();
|
|
452
478
|
await this.wire.close();
|
|
479
|
+
await recordStopped;
|
|
480
|
+
this.recordStore?.close();
|
|
481
|
+
this.recordStore = null;
|
|
482
|
+
this.recordRail = null;
|
|
453
483
|
}
|
|
454
484
|
userRoot(identity) {
|
|
455
485
|
return resolve(scopedAmalgmDir(this.options.amalgmRoot, identity.userEmail));
|
|
@@ -855,7 +885,7 @@ export class UserGroundHost {
|
|
|
855
885
|
});
|
|
856
886
|
}
|
|
857
887
|
this.watchHost.settle(observations);
|
|
858
|
-
this.
|
|
888
|
+
this.recordRail?.localRecordsAvailable();
|
|
859
889
|
return;
|
|
860
890
|
}
|
|
861
891
|
}
|
|
@@ -887,7 +917,7 @@ export class UserGroundHost {
|
|
|
887
917
|
throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
|
|
888
918
|
}
|
|
889
919
|
const authorityId = this.cloudState.resourceId;
|
|
890
|
-
const received =
|
|
920
|
+
const received = this.recordStore?.readEntityCursors(authorityId) ?? new Map();
|
|
891
921
|
const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
|
|
892
922
|
? plan.proposals.map((proposal) => ({
|
|
893
923
|
mutationId: randomUUID(),
|
|
@@ -905,91 +935,7 @@ export class UserGroundHost {
|
|
|
905
935
|
});
|
|
906
936
|
this.namedDetect?.refreshEnrollmentPolicy();
|
|
907
937
|
this.watchHost.settle(observations);
|
|
908
|
-
this.
|
|
909
|
-
}
|
|
910
|
-
async syncRecordQueues(identity) {
|
|
911
|
-
if (this.recordSyncStopped)
|
|
912
|
-
return;
|
|
913
|
-
await this.receiveAcceptedRecords();
|
|
914
|
-
while (!this.recordSyncStopped) {
|
|
915
|
-
const selected = nextEntitySends(readOutbox(this.databasePath(identity)));
|
|
916
|
-
if (selected.length === 0)
|
|
917
|
-
break;
|
|
918
|
-
const sent = await Promise.allSettled(selected.map(async (row) => {
|
|
919
|
-
if (this.recordSyncStopped)
|
|
920
|
-
return;
|
|
921
|
-
await this.uploadSnapshotContent(identity, row.record.authorityId, [row.record.change.result]);
|
|
922
|
-
if (this.recordSyncStopped)
|
|
923
|
-
return;
|
|
924
|
-
const frame = await this.wire.request({
|
|
925
|
-
type: "entity.record.submit",
|
|
926
|
-
record_json: encodeEntityRecord(row.record),
|
|
927
|
-
}, ["entity.record.ack"]);
|
|
928
|
-
const accepted = decodeAcceptedEntityRecord(String(frame.record_json ?? ""));
|
|
929
|
-
stampOutboxAcceptance(this.databasePath(identity), row, accepted);
|
|
930
|
-
}));
|
|
931
|
-
const failed = sent.find((result) => result.status === "rejected");
|
|
932
|
-
if (failed)
|
|
933
|
-
throw pipelineStageError("entity record send", failed.reason);
|
|
934
|
-
}
|
|
935
|
-
if (!this.recordSyncStopped)
|
|
936
|
-
await this.receiveAcceptedRecords();
|
|
937
|
-
}
|
|
938
|
-
async receiveAcceptedRecords() {
|
|
939
|
-
const identity = this.activeIdentity;
|
|
940
|
-
const authorityId = this.cloudState?.resourceId;
|
|
941
|
-
if (!identity || !authorityId)
|
|
942
|
-
return;
|
|
943
|
-
const database = this.databasePath(identity);
|
|
944
|
-
while (!this.recordSyncStopped) {
|
|
945
|
-
const after = readAuthorityDeliveryCursor(database, authorityId);
|
|
946
|
-
const frame = await this.wire.request({
|
|
947
|
-
type: "entity.record.tail",
|
|
948
|
-
authority_id: authorityId,
|
|
949
|
-
after_delivery_sequence: after,
|
|
950
|
-
limit: RECORD_TAIL_LIMIT,
|
|
951
|
-
}, ["entity.record.tail-result"]);
|
|
952
|
-
const deliveries = decodeDeliveredRecords(frame.records);
|
|
953
|
-
if (this.recordSyncStopped)
|
|
954
|
-
return;
|
|
955
|
-
if (deliveries.length === 0)
|
|
956
|
-
return;
|
|
957
|
-
for (const delivery of deliveries) {
|
|
958
|
-
await this.receiveDelivery(database, authorityId, delivery);
|
|
959
|
-
}
|
|
960
|
-
if (deliveries.length < RECORD_TAIL_LIMIT)
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
async receiveDelivery(database, authorityId, delivery) {
|
|
965
|
-
if (delivery.record.authorityId !== authorityId) {
|
|
966
|
-
throw new Error(`entity record authority ${delivery.record.authorityId} does not match ${authorityId}`);
|
|
967
|
-
}
|
|
968
|
-
let decision = storeReceivedRecord(database, delivery);
|
|
969
|
-
if (decision.kind === "gap") {
|
|
970
|
-
const frame = await this.wire.request({
|
|
971
|
-
type: "entity.record.entity-tail",
|
|
972
|
-
authority_id: authorityId,
|
|
973
|
-
entity_id: decision.entityId,
|
|
974
|
-
after_global_sequence: decision.after,
|
|
975
|
-
through_global_sequence: decision.received,
|
|
976
|
-
}, ["entity.record.entity-tail-result"]);
|
|
977
|
-
const missing = decodeDeliveredRecords(frame.records);
|
|
978
|
-
if (missing.length === 0) {
|
|
979
|
-
throw new Error(`entity ${decision.entityId} is missing sequence ${decision.after + 1}`);
|
|
980
|
-
}
|
|
981
|
-
for (const record of missing) {
|
|
982
|
-
decision = storeReceivedRecord(database, record);
|
|
983
|
-
if (decision.kind === "gap") {
|
|
984
|
-
throw new Error(`entity ${decision.entityId} history remained gapped after backfill`);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
decision = storeReceivedRecord(database, delivery);
|
|
988
|
-
if (decision.kind === "gap") {
|
|
989
|
-
throw new Error(`entity ${decision.entityId} history did not reach ${decision.received}`);
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
advanceAuthorityDeliveryCursor(database, authorityId, delivery.deliverySequence);
|
|
938
|
+
this.recordRail?.localRecordsAvailable();
|
|
993
939
|
}
|
|
994
940
|
async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
|
|
995
941
|
const pending = readSnapshotPublications(this.databasePath(identity));
|
|
@@ -1180,6 +1126,7 @@ function assertHealthyWatch(evidence, rootId) {
|
|
|
1180
1126
|
function initializeDatabase(file) {
|
|
1181
1127
|
ensurePrivateDir(dirname(file));
|
|
1182
1128
|
const database = new Database(file);
|
|
1129
|
+
database.pragma("busy_timeout = 5000");
|
|
1183
1130
|
database.pragma("journal_mode = WAL");
|
|
1184
1131
|
database.pragma("synchronous = FULL");
|
|
1185
1132
|
database.exec(`
|
|
@@ -1468,26 +1415,6 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
|
|
|
1468
1415
|
database.close();
|
|
1469
1416
|
}
|
|
1470
1417
|
}
|
|
1471
|
-
function readOutbox(file) {
|
|
1472
|
-
if (!existsSync(file))
|
|
1473
|
-
return [];
|
|
1474
|
-
const database = initializeDatabase(file);
|
|
1475
|
-
try {
|
|
1476
|
-
return database.prepare(`
|
|
1477
|
-
SELECT local_sequence AS localSequence, record_json AS recordJson,
|
|
1478
|
-
status, global_sequence AS globalSequence
|
|
1479
|
-
FROM record_outbox WHERE status = 'pending' ORDER BY local_sequence
|
|
1480
|
-
`).all().map((row) => ({
|
|
1481
|
-
localSequence: row.localSequence,
|
|
1482
|
-
record: decodeLocalEntityRecord(row.recordJson),
|
|
1483
|
-
status: "pending",
|
|
1484
|
-
globalSequence: null,
|
|
1485
|
-
}));
|
|
1486
|
-
}
|
|
1487
|
-
finally {
|
|
1488
|
-
database.close();
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
1418
|
function readSnapshotPublications(file) {
|
|
1492
1419
|
if (!existsSync(file))
|
|
1493
1420
|
return [];
|
|
@@ -1513,153 +1440,6 @@ function readSnapshotPublications(file) {
|
|
|
1513
1440
|
database.close();
|
|
1514
1441
|
}
|
|
1515
1442
|
}
|
|
1516
|
-
function readEntityReceiveCursor(file, authorityId, entityId) {
|
|
1517
|
-
if (!existsSync(file))
|
|
1518
|
-
return 0;
|
|
1519
|
-
const database = initializeDatabase(file);
|
|
1520
|
-
try {
|
|
1521
|
-
const row = database.prepare(`
|
|
1522
|
-
SELECT received_through AS receivedThrough
|
|
1523
|
-
FROM entity_receive_cursors
|
|
1524
|
-
WHERE authority_id = ? AND entity_id = ?
|
|
1525
|
-
`).get(authorityId, entityId);
|
|
1526
|
-
return row?.receivedThrough ?? 0;
|
|
1527
|
-
}
|
|
1528
|
-
finally {
|
|
1529
|
-
database.close();
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
function readEntityReceiveCursors(file, authorityId) {
|
|
1533
|
-
if (!existsSync(file))
|
|
1534
|
-
return new Map();
|
|
1535
|
-
const database = initializeDatabase(file);
|
|
1536
|
-
try {
|
|
1537
|
-
return new Map(database.prepare(`
|
|
1538
|
-
SELECT entity_id AS entityId, received_through AS receivedThrough
|
|
1539
|
-
FROM entity_receive_cursors WHERE authority_id = ?
|
|
1540
|
-
`).all(authorityId)
|
|
1541
|
-
.map((row) => [row.entityId, row.receivedThrough]));
|
|
1542
|
-
}
|
|
1543
|
-
finally {
|
|
1544
|
-
database.close();
|
|
1545
|
-
}
|
|
1546
|
-
}
|
|
1547
|
-
function readAuthorityDeliveryCursor(file, authorityId) {
|
|
1548
|
-
if (!existsSync(file))
|
|
1549
|
-
return 0;
|
|
1550
|
-
const database = initializeDatabase(file);
|
|
1551
|
-
try {
|
|
1552
|
-
const row = database.prepare(`
|
|
1553
|
-
SELECT received_through AS receivedThrough
|
|
1554
|
-
FROM authority_delivery_cursors WHERE authority_id = ?
|
|
1555
|
-
`).get(authorityId);
|
|
1556
|
-
return row?.receivedThrough ?? 0;
|
|
1557
|
-
}
|
|
1558
|
-
finally {
|
|
1559
|
-
database.close();
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
function decodeDeliveredRecords(value) {
|
|
1563
|
-
if (!Array.isArray(value))
|
|
1564
|
-
throw new Error("entity record tail did not return a records array");
|
|
1565
|
-
return value.map((item) => {
|
|
1566
|
-
if (item === null || typeof item !== "object") {
|
|
1567
|
-
throw new Error("entity record tail contained a non-record delivery");
|
|
1568
|
-
}
|
|
1569
|
-
const delivered = item;
|
|
1570
|
-
const deliverySequence = Number(delivered.delivery_sequence);
|
|
1571
|
-
if (!Number.isSafeInteger(deliverySequence) || deliverySequence < 1
|
|
1572
|
-
|| typeof delivered.record_json !== "string") {
|
|
1573
|
-
throw new Error("entity record delivery has invalid sequence or bytes");
|
|
1574
|
-
}
|
|
1575
|
-
return {
|
|
1576
|
-
deliverySequence,
|
|
1577
|
-
record: decodeAcceptedEntityRecord(delivered.record_json),
|
|
1578
|
-
};
|
|
1579
|
-
});
|
|
1580
|
-
}
|
|
1581
|
-
function stampOutboxAcceptance(file, row, accepted) {
|
|
1582
|
-
const receipt = acceptOutboxRow(row, accepted);
|
|
1583
|
-
if (receipt.status !== "accepted")
|
|
1584
|
-
throw new Error("authority acknowledgement was not accepted");
|
|
1585
|
-
const database = initializeDatabase(file);
|
|
1586
|
-
try {
|
|
1587
|
-
database.transaction(() => {
|
|
1588
|
-
const updated = database.prepare(`
|
|
1589
|
-
UPDATE record_outbox
|
|
1590
|
-
SET status = 'accepted', global_sequence = ?, accepted_at = ?
|
|
1591
|
-
WHERE mutation_id = ? AND status = 'pending'
|
|
1592
|
-
`).run(accepted.globalSequence, accepted.acceptedAt, accepted.mutationId);
|
|
1593
|
-
if (updated.changes === 1)
|
|
1594
|
-
return;
|
|
1595
|
-
const existing = database.prepare(`
|
|
1596
|
-
SELECT status, global_sequence AS globalSequence
|
|
1597
|
-
FROM record_outbox WHERE mutation_id = ?
|
|
1598
|
-
`).get(accepted.mutationId);
|
|
1599
|
-
if (existing?.status !== "accepted"
|
|
1600
|
-
|| existing.globalSequence !== accepted.globalSequence) {
|
|
1601
|
-
throw new Error("outbox acknowledgement did not match its retained row");
|
|
1602
|
-
}
|
|
1603
|
-
})();
|
|
1604
|
-
}
|
|
1605
|
-
finally {
|
|
1606
|
-
database.close();
|
|
1607
|
-
}
|
|
1608
|
-
}
|
|
1609
|
-
function storeReceivedRecord(file, delivery) {
|
|
1610
|
-
const database = initializeDatabase(file);
|
|
1611
|
-
try {
|
|
1612
|
-
return database.transaction(() => {
|
|
1613
|
-
const cursor = database.prepare(`
|
|
1614
|
-
SELECT authority_id AS authorityId, received_through AS receivedThrough
|
|
1615
|
-
FROM entity_receive_cursors WHERE entity_id = ?
|
|
1616
|
-
`).get(delivery.record.entityId);
|
|
1617
|
-
if (cursor && cursor.authorityId !== delivery.record.authorityId) {
|
|
1618
|
-
throw new Error(`entity ${delivery.record.entityId} cannot change authority channels`);
|
|
1619
|
-
}
|
|
1620
|
-
const existingRow = database.prepare(`
|
|
1621
|
-
SELECT record_json AS recordJson FROM record_inbox
|
|
1622
|
-
WHERE authority_id = ? AND entity_id = ? AND global_sequence = ?
|
|
1623
|
-
`).get(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence);
|
|
1624
|
-
const existing = existingRow
|
|
1625
|
-
? decodeAcceptedEntityRecord(existingRow.recordJson)
|
|
1626
|
-
: null;
|
|
1627
|
-
const decision = receiveEntityRecord(cursor?.receivedThrough ?? 0, delivery.record, existing);
|
|
1628
|
-
if (decision.kind !== "insert")
|
|
1629
|
-
return decision;
|
|
1630
|
-
database.prepare(`
|
|
1631
|
-
INSERT INTO record_inbox(
|
|
1632
|
-
authority_id, entity_id, global_sequence, delivery_sequence,
|
|
1633
|
-
mutation_id, record_json, received_at
|
|
1634
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1635
|
-
`).run(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence, delivery.deliverySequence, delivery.record.mutationId, encodeEntityRecord(delivery.record), Date.now());
|
|
1636
|
-
database.prepare(`
|
|
1637
|
-
INSERT INTO entity_receive_cursors(authority_id, entity_id, received_through)
|
|
1638
|
-
VALUES (?, ?, ?)
|
|
1639
|
-
ON CONFLICT(entity_id) DO UPDATE SET
|
|
1640
|
-
received_through = excluded.received_through
|
|
1641
|
-
`).run(delivery.record.authorityId, delivery.record.entityId, decision.receivedThrough);
|
|
1642
|
-
return decision;
|
|
1643
|
-
})();
|
|
1644
|
-
}
|
|
1645
|
-
finally {
|
|
1646
|
-
database.close();
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
function advanceAuthorityDeliveryCursor(file, authorityId, deliverySequence) {
|
|
1650
|
-
const database = initializeDatabase(file);
|
|
1651
|
-
try {
|
|
1652
|
-
database.prepare(`
|
|
1653
|
-
INSERT INTO authority_delivery_cursors(authority_id, received_through)
|
|
1654
|
-
VALUES (?, ?)
|
|
1655
|
-
ON CONFLICT(authority_id) DO UPDATE SET
|
|
1656
|
-
received_through = MAX(received_through, excluded.received_through)
|
|
1657
|
-
`).run(authorityId, deliverySequence);
|
|
1658
|
-
}
|
|
1659
|
-
finally {
|
|
1660
|
-
database.close();
|
|
1661
|
-
}
|
|
1662
|
-
}
|
|
1663
1443
|
function hasRecordsBeyondSnapshot(file) {
|
|
1664
1444
|
if (!existsSync(file))
|
|
1665
1445
|
return false;
|