@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.
@@ -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
  };
@@ -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
- await this.syncNow();
349
- await this.syncRecordsNow();
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
- scheduleRecordSync(delay = 0) {
374
- if (this.closing || this.recordSyncStopped || this.recordSyncTimer)
375
- return;
376
- this.recordSyncTimer = setTimeout(() => {
377
- this.recordSyncTimer = null;
378
- void this.syncRecordsNow().then(() => {
379
- if (!this.closing && !this.recordSyncStopped)
380
- this.scheduleRecordSync(RECORD_POLL_MS);
381
- }).catch(() => {
382
- if (!this.closing && !this.recordSyncStopped)
383
- this.scheduleRecordSync(RECORD_RETRY_MS);
384
- });
385
- }, delay);
386
- this.recordSyncTimer.unref();
387
- }
388
- async syncRecordsNow() {
389
- const identity = this.activeIdentity;
390
- if (this.recordSyncStopped || !identity || !this.cloudState)
391
- return;
392
- if (!this.recordSyncing) {
393
- this.recordSyncing = this.syncRecordQueues(identity).finally(() => {
394
- this.recordSyncing = null;
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 this.recordSyncing?.catch(() => undefined);
449
+ await recordStopped;
424
450
  // An upload already inside its retry helper may have reopened the wire
425
- // 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
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.syncRecordsNow();
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.scheduleRecordSync();
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 = readEntityReceiveCursors(this.databasePath(identity), authorityId);
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.scheduleRecordSync();
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;