@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.
@@ -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, 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";
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
- recordSyncing = null;
48
- recordSyncTimer = null;
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.syncRecordsNow();
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
- scheduleRecordSync(delay = 0) {
387
- if (this.closing || this.recordSyncStopped || this.recordSyncTimer)
388
- return;
389
- this.recordSyncTimer = setTimeout(() => {
390
- this.recordSyncTimer = null;
391
- void this.syncRecordsNow().then(() => {
392
- if (!this.closing && !this.recordSyncStopped)
393
- this.scheduleRecordSync(RECORD_POLL_MS);
394
- }).catch(() => {
395
- if (!this.closing && !this.recordSyncStopped)
396
- this.scheduleRecordSync(RECORD_RETRY_MS);
397
- });
398
- }, delay);
399
- this.recordSyncTimer.unref();
400
- }
401
- async syncRecordsNow() {
402
- const identity = this.activeIdentity;
403
- if (this.recordSyncStopped || !identity || !this.cloudState)
404
- return;
405
- if (!this.recordSyncing) {
406
- this.recordSyncing = this.syncRecordQueues(identity).finally(() => {
407
- this.recordSyncing = null;
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 this.recordSyncing?.catch(() => undefined);
449
+ await recordStopped;
437
450
  // An upload already inside its retry helper may have reopened the wire
438
- // after the first close. The stopped flag prevents new queue work; this
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.syncRecordsNow();
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.scheduleRecordSync();
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 = readEntityReceiveCursors(this.databasePath(identity), authorityId);
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.scheduleRecordSync();
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;