@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.
@@ -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 (!hasDetectedChanges(database))
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 official local projection remains the accepted cloud graph;
470
- // Detect's newer materialization is represented exactly once by its
471
- // durable journal records. Returning that baseline prevents login
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
- records: this.cloudState.records,
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
- journalBytes: evidence.journalBytes,
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) => ({ mutationId: randomUUID(), 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 = readJournal(this.databasePath(identity))
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 journal belongs to a different user-ground resource");
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 = readJournal(this.databasePath(identity))
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 readJournal(this.databasePath(identity))) {
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 mutation_journal (
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
- operation_kind TEXT NOT NULL,
1094
- operation_json TEXT NOT NULL,
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 mutation_journal_by_kind
1117
- ON mutation_journal(operation_kind);
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 readJournal(file) {
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
- operation_kind AS kind, operation_json AS operationJson
1283
- FROM mutation_journal ORDER BY sequence
1479
+ publication_json AS publicationJson
1480
+ FROM snapshot_publications ORDER BY sequence
1284
1481
  `).all().map((row) => {
1285
- const operation = decodeMutationOperation(row.operationJson);
1286
- if (row.kind === "entity.snapshot.replace") {
1287
- return {
1288
- kind: row.kind,
1289
- mutationId: row.mutationId,
1290
- resourceId: row.resourceId,
1291
- authorityEpoch: Number(operation.authorityEpoch),
1292
- baseVersion: Number(operation.baseVersion),
1293
- snapshotChecksum: String(operation.snapshotChecksum),
1294
- snapshotJson: String(operation.snapshotJson),
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
- if (row.kind === "detected.change") {
1298
- return {
1299
- kind: row.kind,
1300
- mutationId: row.mutationId,
1301
- resourceId: row.resourceId,
1302
- proposal: operation.proposal,
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
- throw new Error(`unknown mutation journal operation: ${String(row.kind)}`);
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 hasDetectedChanges(file) {
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 mutation_journal
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 mutation journal is the one durable queue. */
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 mutation_journal(
1335
- mutation_id, resource_id, operation_kind, operation_json, created_at
1336
- ) VALUES (?, ?, ?, ?, ?)
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 proposal = entry.proposal;
1340
- append.run(entry.mutationId, String(proposal.resourceId ?? ""), "detected.change", encodeMutationOperation({ proposal: entry.proposal }), new Date().toISOString());
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 mutation_journal(
1366
- mutation_id, resource_id, operation_kind, operation_json, created_at
1367
- ) VALUES (?, ?, ?, ?, ?)
1368
- `).run(entry.mutationId, entry.resourceId, entry.kind, stableJson({
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
- }), new Date().toISOString());
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 mutation_journal WHERE mutation_id = ?").run(mutationId);
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
- journalBytes: proposals.reduce((bytes, proposal) => bytes + Buffer.byteLength(encodeMutationOperation({ proposal })), 0),
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.