@amalgm/shell 0.1.48 → 0.1.50

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
  };
@@ -255,13 +261,13 @@ export class UserGroundHost {
255
261
  assertHealthyWatch(watch, workspaceId);
256
262
  }
257
263
  catch (error) {
258
- this.coldOperation = false;
264
+ this.finishColdOperation();
259
265
  throw stageError("Watch coverage", error);
260
266
  }
261
267
  let rows = readRows(this.databasePath(identity));
262
268
  if (rows.some((row) => row.uuid === workspaceId)
263
269
  && this.cloudState.records.some((record) => record.uuid === workspaceId)) {
264
- this.coldOperation = false;
270
+ this.finishColdOperation();
265
271
  return {
266
272
  registered: true,
267
273
  entityCount: descendantRows(rows, workspaceId).length,
@@ -307,7 +313,7 @@ export class UserGroundHost {
307
313
  throw stageError("entity registration or cloud publication", error);
308
314
  }
309
315
  finally {
310
- this.coldOperation = false;
316
+ this.finishColdOperation();
311
317
  }
312
318
  try {
313
319
  this.ensureWatchers(identity);
@@ -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 () => {
@@ -777,6 +822,14 @@ export class UserGroundHost {
777
822
  }, delay);
778
823
  this.rescanTimer.unref();
779
824
  }
825
+ /** A finite cold command may suppress Detect while it owns publication, but
826
+ * it may never consume a Watch generation that arrived during that window. */
827
+ finishColdOperation() {
828
+ this.coldOperation = false;
829
+ if (!this.closing && (this.watchDirty || this.watchHost.hasPending)) {
830
+ this.scheduleRescan();
831
+ }
832
+ }
780
833
  async syncLocalChanges(identity) {
781
834
  if (!this.cloudState)
782
835
  throw new Error("cloud state is unavailable for user-ground Watch");
@@ -802,10 +855,11 @@ export class UserGroundHost {
802
855
  durationMs: evidence.durationMs,
803
856
  plannedRows: evidence.plannedRows,
804
857
  records: evidence.records,
805
- journalBytes: evidence.journalBytes,
858
+ recordBytes: evidence.recordBytes,
806
859
  });
807
860
  }
808
861
  this.watchHost.settle(observations);
862
+ this.scheduleRecordSync();
809
863
  return;
810
864
  }
811
865
  }
@@ -836,8 +890,15 @@ export class UserGroundHost {
836
890
  if (retry?.kind === "retry") {
837
891
  throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
838
892
  }
893
+ const authorityId = this.cloudState.resourceId;
894
+ const received = readEntityReceiveCursors(this.databasePath(identity), authorityId);
839
895
  const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
840
- ? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
896
+ ? plan.proposals.map((proposal) => ({
897
+ mutationId: randomUUID(),
898
+ authorityId,
899
+ lastSeenGlobalSequence: received.get(proposal.entityId) ?? 0,
900
+ proposal,
901
+ }))
841
902
  : []);
842
903
  commitDetectedState(this.databasePath(identity), {
843
904
  acceptedMaterializedRows,
@@ -848,15 +909,99 @@ export class UserGroundHost {
848
909
  });
849
910
  this.namedDetect?.refreshEnrollmentPolicy();
850
911
  this.watchHost.settle(observations);
912
+ this.scheduleRecordSync();
913
+ }
914
+ async syncRecordQueues(identity) {
915
+ if (this.recordSyncStopped)
916
+ return;
917
+ await this.receiveAcceptedRecords();
918
+ while (!this.recordSyncStopped) {
919
+ const selected = nextEntitySends(readOutbox(this.databasePath(identity)));
920
+ if (selected.length === 0)
921
+ break;
922
+ const sent = await Promise.allSettled(selected.map(async (row) => {
923
+ if (this.recordSyncStopped)
924
+ return;
925
+ await this.uploadSnapshotContent(identity, row.record.authorityId, [row.record.change.result]);
926
+ if (this.recordSyncStopped)
927
+ return;
928
+ const frame = await this.wire.request({
929
+ type: "entity.record.submit",
930
+ record_json: encodeEntityRecord(row.record),
931
+ }, ["entity.record.ack"]);
932
+ const accepted = decodeAcceptedEntityRecord(String(frame.record_json ?? ""));
933
+ stampOutboxAcceptance(this.databasePath(identity), row, accepted);
934
+ }));
935
+ const failed = sent.find((result) => result.status === "rejected");
936
+ if (failed)
937
+ throw pipelineStageError("entity record send", failed.reason);
938
+ }
939
+ if (!this.recordSyncStopped)
940
+ await this.receiveAcceptedRecords();
941
+ }
942
+ async receiveAcceptedRecords() {
943
+ const identity = this.activeIdentity;
944
+ const authorityId = this.cloudState?.resourceId;
945
+ if (!identity || !authorityId)
946
+ return;
947
+ const database = this.databasePath(identity);
948
+ while (!this.recordSyncStopped) {
949
+ const after = readAuthorityDeliveryCursor(database, authorityId);
950
+ const frame = await this.wire.request({
951
+ type: "entity.record.tail",
952
+ authority_id: authorityId,
953
+ after_delivery_sequence: after,
954
+ limit: RECORD_TAIL_LIMIT,
955
+ }, ["entity.record.tail-result"]);
956
+ const deliveries = decodeDeliveredRecords(frame.records);
957
+ if (this.recordSyncStopped)
958
+ return;
959
+ if (deliveries.length === 0)
960
+ return;
961
+ for (const delivery of deliveries) {
962
+ await this.receiveDelivery(database, authorityId, delivery);
963
+ }
964
+ if (deliveries.length < RECORD_TAIL_LIMIT)
965
+ return;
966
+ }
967
+ }
968
+ async receiveDelivery(database, authorityId, delivery) {
969
+ if (delivery.record.authorityId !== authorityId) {
970
+ throw new Error(`entity record authority ${delivery.record.authorityId} does not match ${authorityId}`);
971
+ }
972
+ let decision = storeReceivedRecord(database, delivery);
973
+ if (decision.kind === "gap") {
974
+ const frame = await this.wire.request({
975
+ type: "entity.record.entity-tail",
976
+ authority_id: authorityId,
977
+ entity_id: decision.entityId,
978
+ after_global_sequence: decision.after,
979
+ through_global_sequence: decision.received,
980
+ }, ["entity.record.entity-tail-result"]);
981
+ const missing = decodeDeliveredRecords(frame.records);
982
+ if (missing.length === 0) {
983
+ throw new Error(`entity ${decision.entityId} is missing sequence ${decision.after + 1}`);
984
+ }
985
+ for (const record of missing) {
986
+ decision = storeReceivedRecord(database, record);
987
+ if (decision.kind === "gap") {
988
+ throw new Error(`entity ${decision.entityId} history remained gapped after backfill`);
989
+ }
990
+ }
991
+ decision = storeReceivedRecord(database, delivery);
992
+ if (decision.kind === "gap") {
993
+ throw new Error(`entity ${decision.entityId} history did not reach ${decision.received}`);
994
+ }
995
+ }
996
+ advanceAuthorityDeliveryCursor(database, authorityId, delivery.deliverySequence);
851
997
  }
852
998
  async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
853
- const pending = readJournal(this.databasePath(identity))
854
- .filter((entry) => entry.kind === "entity.snapshot.replace");
999
+ const pending = readSnapshotPublications(this.databasePath(identity));
855
1000
  if (pending.length === 0)
856
1001
  return;
857
1002
  const first = pending[0];
858
1003
  if (first.resourceId !== resourceId) {
859
- throw new Error("local snapshot journal belongs to a different user-ground resource");
1004
+ throw new Error("local snapshot publication belongs to a different user-ground resource");
860
1005
  }
861
1006
  this.cloudState = {
862
1007
  resourceId: first.resourceId,
@@ -889,8 +1034,7 @@ export class UserGroundHost {
889
1034
  const checksum = sha256Hex(snapshotJson);
890
1035
  if (checksum === state.checksum)
891
1036
  return;
892
- const pending = readJournal(this.databasePath(identity))
893
- .filter((entry) => entry.kind === "entity.snapshot.replace");
1037
+ const pending = readSnapshotPublications(this.databasePath(identity));
894
1038
  const identical = pending.find((entry) => entry.resourceId === state.resourceId
895
1039
  && entry.snapshotChecksum === checksum);
896
1040
  if (identical) {
@@ -901,7 +1045,6 @@ export class UserGroundHost {
901
1045
  throw new Error("a different graph snapshot is already pending publication");
902
1046
  }
903
1047
  appendSnapshotJournal(this.databasePath(identity), {
904
- kind: "entity.snapshot.replace",
905
1048
  mutationId: randomUUID(),
906
1049
  resourceId: state.resourceId,
907
1050
  authorityEpoch: state.authorityEpoch,
@@ -912,9 +1055,7 @@ export class UserGroundHost {
912
1055
  await this.publishPendingSnapshots(identity);
913
1056
  }
914
1057
  async publishPendingSnapshots(identity) {
915
- for (const pending of readJournal(this.databasePath(identity))) {
916
- if (pending.kind !== "entity.snapshot.replace")
917
- continue;
1058
+ for (const pending of readSnapshotPublications(this.databasePath(identity))) {
918
1059
  const snapshot = snapshotFromRecords(JSON.parse(pending.snapshotJson).records || []);
919
1060
  const locallyMaterializedIds = new Set(readRows(this.databasePath(identity))
920
1061
  .map((row) => row.uuid));
@@ -956,6 +1097,15 @@ export class UserGroundHost {
956
1097
  }
957
1098
  }
958
1099
  const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
1100
+ function encodedRecordBytes(authorityId, proposal, lastSeenGlobalSequence = 0) {
1101
+ return Buffer.byteLength(encodeEntityRecord(createLocalEntityRecord({
1102
+ mutationId: "00000000-0000-4000-8000-000000000000",
1103
+ authorityId,
1104
+ lastSeenGlobalSequence,
1105
+ change: proposal,
1106
+ createdAt: 0,
1107
+ })));
1108
+ }
959
1109
  function contentCacheFile(cacheDir, contentHash) {
960
1110
  return join(cacheDir, `${contentHash}.bin`);
961
1111
  }
@@ -1086,13 +1236,46 @@ function initializeDatabase(file) {
1086
1236
  filesystem_mode INTEGER,
1087
1237
  content_verified_at_ms REAL
1088
1238
  );
1089
- CREATE TABLE IF NOT EXISTS mutation_journal (
1239
+ CREATE TABLE IF NOT EXISTS record_outbox (
1240
+ local_sequence INTEGER PRIMARY KEY AUTOINCREMENT,
1241
+ mutation_id TEXT NOT NULL UNIQUE,
1242
+ authority_id TEXT NOT NULL,
1243
+ entity_id TEXT NOT NULL,
1244
+ record_json TEXT NOT NULL,
1245
+ status TEXT NOT NULL CHECK(status IN ('pending', 'accepted')),
1246
+ global_sequence INTEGER,
1247
+ created_at INTEGER NOT NULL,
1248
+ accepted_at INTEGER,
1249
+ CHECK((status = 'pending' AND global_sequence IS NULL AND accepted_at IS NULL)
1250
+ OR (status = 'accepted' AND global_sequence > 0 AND accepted_at IS NOT NULL))
1251
+ );
1252
+ CREATE TABLE IF NOT EXISTS record_inbox (
1253
+ authority_id TEXT NOT NULL,
1254
+ entity_id TEXT NOT NULL,
1255
+ global_sequence INTEGER NOT NULL CHECK(global_sequence > 0),
1256
+ delivery_sequence INTEGER NOT NULL CHECK(delivery_sequence > 0),
1257
+ mutation_id TEXT NOT NULL,
1258
+ record_json TEXT NOT NULL,
1259
+ received_at INTEGER NOT NULL,
1260
+ PRIMARY KEY(entity_id, global_sequence),
1261
+ UNIQUE(entity_id, mutation_id),
1262
+ UNIQUE(authority_id, delivery_sequence)
1263
+ );
1264
+ CREATE TABLE IF NOT EXISTS entity_receive_cursors (
1265
+ entity_id TEXT PRIMARY KEY,
1266
+ authority_id TEXT NOT NULL,
1267
+ received_through INTEGER NOT NULL CHECK(received_through >= 0)
1268
+ );
1269
+ CREATE TABLE IF NOT EXISTS authority_delivery_cursors (
1270
+ authority_id TEXT PRIMARY KEY,
1271
+ received_through INTEGER NOT NULL CHECK(received_through >= 0)
1272
+ );
1273
+ CREATE TABLE IF NOT EXISTS snapshot_publications (
1090
1274
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
1091
1275
  mutation_id TEXT NOT NULL UNIQUE,
1092
1276
  resource_id TEXT NOT NULL,
1093
- operation_kind TEXT NOT NULL,
1094
- operation_json TEXT NOT NULL,
1095
- created_at TEXT NOT NULL
1277
+ publication_json TEXT NOT NULL,
1278
+ created_at INTEGER NOT NULL
1096
1279
  );
1097
1280
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
1098
1281
  workspace_uuid TEXT PRIMARY KEY,
@@ -1113,9 +1296,11 @@ function initializeDatabase(file) {
1113
1296
  ON detection_notebook(absolute_path);
1114
1297
  CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
1115
1298
  ON detection_notebook(device_number, inode);
1116
- CREATE INDEX IF NOT EXISTS mutation_journal_by_kind
1117
- ON mutation_journal(operation_kind);
1299
+ CREATE INDEX IF NOT EXISTS record_outbox_pending
1300
+ ON record_outbox(status, local_sequence);
1118
1301
  `);
1302
+ database.exec("DROP INDEX IF EXISTS record_inbox_delivery");
1303
+ database.exec("DROP TABLE IF EXISTS mutation_journal");
1119
1304
  return database;
1120
1305
  }
1121
1306
  function readGroundRows(file, table) {
@@ -1272,51 +1457,205 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
1272
1457
  database.close();
1273
1458
  }
1274
1459
  }
1275
- function readJournal(file) {
1460
+ function readOutbox(file) {
1461
+ if (!existsSync(file))
1462
+ return [];
1463
+ const database = initializeDatabase(file);
1464
+ try {
1465
+ return database.prepare(`
1466
+ SELECT local_sequence AS localSequence, record_json AS recordJson,
1467
+ status, global_sequence AS globalSequence
1468
+ FROM record_outbox WHERE status = 'pending' ORDER BY local_sequence
1469
+ `).all().map((row) => ({
1470
+ localSequence: row.localSequence,
1471
+ record: decodeLocalEntityRecord(row.recordJson),
1472
+ status: "pending",
1473
+ globalSequence: null,
1474
+ }));
1475
+ }
1476
+ finally {
1477
+ database.close();
1478
+ }
1479
+ }
1480
+ function readSnapshotPublications(file) {
1276
1481
  if (!existsSync(file))
1277
1482
  return [];
1278
1483
  const database = initializeDatabase(file);
1279
1484
  try {
1280
1485
  return database.prepare(`
1281
1486
  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
1487
+ publication_json AS publicationJson
1488
+ FROM snapshot_publications ORDER BY sequence
1284
1489
  `).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
- };
1490
+ const publication = JSON.parse(row.publicationJson);
1491
+ return {
1492
+ mutationId: row.mutationId,
1493
+ resourceId: row.resourceId,
1494
+ authorityEpoch: Number(publication.authorityEpoch),
1495
+ baseVersion: Number(publication.baseVersion),
1496
+ snapshotChecksum: String(publication.snapshotChecksum),
1497
+ snapshotJson: String(publication.snapshotJson),
1498
+ };
1499
+ });
1500
+ }
1501
+ finally {
1502
+ database.close();
1503
+ }
1504
+ }
1505
+ function readEntityReceiveCursor(file, authorityId, entityId) {
1506
+ if (!existsSync(file))
1507
+ return 0;
1508
+ const database = initializeDatabase(file);
1509
+ try {
1510
+ const row = database.prepare(`
1511
+ SELECT received_through AS receivedThrough
1512
+ FROM entity_receive_cursors
1513
+ WHERE authority_id = ? AND entity_id = ?
1514
+ `).get(authorityId, entityId);
1515
+ return row?.receivedThrough ?? 0;
1516
+ }
1517
+ finally {
1518
+ database.close();
1519
+ }
1520
+ }
1521
+ function readEntityReceiveCursors(file, authorityId) {
1522
+ if (!existsSync(file))
1523
+ return new Map();
1524
+ const database = initializeDatabase(file);
1525
+ try {
1526
+ return new Map(database.prepare(`
1527
+ SELECT entity_id AS entityId, received_through AS receivedThrough
1528
+ FROM entity_receive_cursors WHERE authority_id = ?
1529
+ `).all(authorityId)
1530
+ .map((row) => [row.entityId, row.receivedThrough]));
1531
+ }
1532
+ finally {
1533
+ database.close();
1534
+ }
1535
+ }
1536
+ function readAuthorityDeliveryCursor(file, authorityId) {
1537
+ if (!existsSync(file))
1538
+ return 0;
1539
+ const database = initializeDatabase(file);
1540
+ try {
1541
+ const row = database.prepare(`
1542
+ SELECT received_through AS receivedThrough
1543
+ FROM authority_delivery_cursors WHERE authority_id = ?
1544
+ `).get(authorityId);
1545
+ return row?.receivedThrough ?? 0;
1546
+ }
1547
+ finally {
1548
+ database.close();
1549
+ }
1550
+ }
1551
+ function decodeDeliveredRecords(value) {
1552
+ if (!Array.isArray(value))
1553
+ throw new Error("entity record tail did not return a records array");
1554
+ return value.map((item) => {
1555
+ if (item === null || typeof item !== "object") {
1556
+ throw new Error("entity record tail contained a non-record delivery");
1557
+ }
1558
+ const delivered = item;
1559
+ const deliverySequence = Number(delivered.delivery_sequence);
1560
+ if (!Number.isSafeInteger(deliverySequence) || deliverySequence < 1
1561
+ || typeof delivered.record_json !== "string") {
1562
+ throw new Error("entity record delivery has invalid sequence or bytes");
1563
+ }
1564
+ return {
1565
+ deliverySequence,
1566
+ record: decodeAcceptedEntityRecord(delivered.record_json),
1567
+ };
1568
+ });
1569
+ }
1570
+ function stampOutboxAcceptance(file, row, accepted) {
1571
+ const receipt = acceptOutboxRow(row, accepted);
1572
+ if (receipt.status !== "accepted")
1573
+ throw new Error("authority acknowledgement was not accepted");
1574
+ const database = initializeDatabase(file);
1575
+ try {
1576
+ database.transaction(() => {
1577
+ const updated = database.prepare(`
1578
+ UPDATE record_outbox
1579
+ SET status = 'accepted', global_sequence = ?, accepted_at = ?
1580
+ WHERE mutation_id = ? AND status = 'pending'
1581
+ `).run(accepted.globalSequence, accepted.acceptedAt, accepted.mutationId);
1582
+ if (updated.changes === 1)
1583
+ return;
1584
+ const existing = database.prepare(`
1585
+ SELECT status, global_sequence AS globalSequence
1586
+ FROM record_outbox WHERE mutation_id = ?
1587
+ `).get(accepted.mutationId);
1588
+ if (existing?.status !== "accepted"
1589
+ || existing.globalSequence !== accepted.globalSequence) {
1590
+ throw new Error("outbox acknowledgement did not match its retained row");
1296
1591
  }
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
- };
1592
+ })();
1593
+ }
1594
+ finally {
1595
+ database.close();
1596
+ }
1597
+ }
1598
+ function storeReceivedRecord(file, delivery) {
1599
+ const database = initializeDatabase(file);
1600
+ try {
1601
+ return database.transaction(() => {
1602
+ const cursor = database.prepare(`
1603
+ SELECT authority_id AS authorityId, received_through AS receivedThrough
1604
+ FROM entity_receive_cursors WHERE entity_id = ?
1605
+ `).get(delivery.record.entityId);
1606
+ if (cursor && cursor.authorityId !== delivery.record.authorityId) {
1607
+ throw new Error(`entity ${delivery.record.entityId} cannot change authority channels`);
1304
1608
  }
1305
- throw new Error(`unknown mutation journal operation: ${String(row.kind)}`);
1306
- });
1609
+ const existingRow = database.prepare(`
1610
+ SELECT record_json AS recordJson FROM record_inbox
1611
+ WHERE authority_id = ? AND entity_id = ? AND global_sequence = ?
1612
+ `).get(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence);
1613
+ const existing = existingRow
1614
+ ? decodeAcceptedEntityRecord(existingRow.recordJson)
1615
+ : null;
1616
+ const decision = receiveEntityRecord(cursor?.receivedThrough ?? 0, delivery.record, existing);
1617
+ if (decision.kind !== "insert")
1618
+ return decision;
1619
+ database.prepare(`
1620
+ INSERT INTO record_inbox(
1621
+ authority_id, entity_id, global_sequence, delivery_sequence,
1622
+ mutation_id, record_json, received_at
1623
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1624
+ `).run(delivery.record.authorityId, delivery.record.entityId, delivery.record.globalSequence, delivery.deliverySequence, delivery.record.mutationId, encodeEntityRecord(delivery.record), Date.now());
1625
+ database.prepare(`
1626
+ INSERT INTO entity_receive_cursors(authority_id, entity_id, received_through)
1627
+ VALUES (?, ?, ?)
1628
+ ON CONFLICT(entity_id) DO UPDATE SET
1629
+ received_through = excluded.received_through
1630
+ `).run(delivery.record.authorityId, delivery.record.entityId, decision.receivedThrough);
1631
+ return decision;
1632
+ })();
1633
+ }
1634
+ finally {
1635
+ database.close();
1636
+ }
1637
+ }
1638
+ function advanceAuthorityDeliveryCursor(file, authorityId, deliverySequence) {
1639
+ const database = initializeDatabase(file);
1640
+ try {
1641
+ database.prepare(`
1642
+ INSERT INTO authority_delivery_cursors(authority_id, received_through)
1643
+ VALUES (?, ?)
1644
+ ON CONFLICT(authority_id) DO UPDATE SET
1645
+ received_through = MAX(received_through, excluded.received_through)
1646
+ `).run(authorityId, deliverySequence);
1307
1647
  }
1308
1648
  finally {
1309
1649
  database.close();
1310
1650
  }
1311
1651
  }
1312
- function hasDetectedChanges(file) {
1652
+ function hasRecordsBeyondSnapshot(file) {
1313
1653
  if (!existsSync(file))
1314
1654
  return false;
1315
1655
  const database = initializeDatabase(file);
1316
1656
  try {
1317
1657
  return database.prepare(`
1318
- SELECT 1 AS present FROM mutation_journal
1319
- WHERE operation_kind = 'detected.change' LIMIT 1
1658
+ SELECT 1 AS present FROM record_outbox LIMIT 1
1320
1659
  `).get() !== undefined;
1321
1660
  }
1322
1661
  finally {
@@ -1325,19 +1664,27 @@ function hasDetectedChanges(file) {
1325
1664
  }
1326
1665
  /** Record is the SQLite transaction that accepts Detect's exact proposals and
1327
1666
  * advances the last-verified notebook. Watch may settle only after this
1328
- * function returns. The mutation journal is the one durable queue. */
1667
+ * function returns. The record table is the local-only outbox. */
1329
1668
  function commitDetectedState(file, input) {
1330
1669
  const database = initializeDatabase(file);
1331
1670
  try {
1332
1671
  database.transaction(() => {
1333
1672
  const append = database.prepare(`
1334
- INSERT INTO mutation_journal(
1335
- mutation_id, resource_id, operation_kind, operation_json, created_at
1336
- ) VALUES (?, ?, ?, ?, ?)
1673
+ INSERT INTO record_outbox(
1674
+ mutation_id, authority_id, entity_id, record_json, status,
1675
+ global_sequence, created_at, accepted_at
1676
+ ) VALUES (?, ?, ?, ?, 'pending', NULL, ?, NULL)
1337
1677
  `);
1338
1678
  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());
1679
+ const createdAt = Date.now();
1680
+ const record = createLocalEntityRecord({
1681
+ mutationId: entry.mutationId,
1682
+ authorityId: entry.authorityId,
1683
+ lastSeenGlobalSequence: entry.lastSeenGlobalSequence,
1684
+ change: entry.proposal,
1685
+ createdAt,
1686
+ });
1687
+ append.run(record.mutationId, record.authorityId, record.entityId, encodeEntityRecord(record), createdAt);
1341
1688
  }
1342
1689
  const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
1343
1690
  const removeMaterialized = database.prepare("DELETE FROM entities WHERE uuid = ?");
@@ -1362,15 +1709,15 @@ function appendSnapshotJournal(file, entry) {
1362
1709
  const database = initializeDatabase(file);
1363
1710
  try {
1364
1711
  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({
1712
+ INSERT INTO snapshot_publications(
1713
+ mutation_id, resource_id, publication_json, created_at
1714
+ ) VALUES (?, ?, ?, ?)
1715
+ `).run(entry.mutationId, entry.resourceId, stableJson({
1369
1716
  authorityEpoch: entry.authorityEpoch,
1370
1717
  baseVersion: entry.baseVersion,
1371
1718
  snapshotChecksum: entry.snapshotChecksum,
1372
1719
  snapshotJson: entry.snapshotJson,
1373
- }), new Date().toISOString());
1720
+ }), Date.now());
1374
1721
  }
1375
1722
  finally {
1376
1723
  database.close();
@@ -1379,7 +1726,7 @@ function appendSnapshotJournal(file, entry) {
1379
1726
  function deleteJournalEntry(file, mutationId) {
1380
1727
  const database = initializeDatabase(file);
1381
1728
  try {
1382
- database.prepare("DELETE FROM mutation_journal WHERE mutation_id = ?").run(mutationId);
1729
+ database.prepare("DELETE FROM snapshot_publications WHERE mutation_id = ?").run(mutationId);
1383
1730
  }
1384
1731
  finally {
1385
1732
  database.close();
@@ -1758,7 +2105,7 @@ async function reconcileGround(input) {
1758
2105
  durationMs: performance.now() - started,
1759
2106
  plannedRows: proposals.length,
1760
2107
  records: proposals.length,
1761
- journalBytes: proposals.reduce((bytes, proposal) => bytes + Buffer.byteLength(encodeMutationOperation({ proposal })), 0),
2108
+ recordBytes: proposals.reduce((bytes, proposal) => bytes + encodedRecordBytes(parameters.resourceId, proposal), 0),
1762
2109
  });
1763
2110
  // Each root commits its local projection before Detect yields. Native
1764
2111
  // callbacks can therefore preserve new suspicion between large roots.
@@ -1832,12 +2179,9 @@ async function reconcileRoot(input) {
1832
2179
  return [[local, row]];
1833
2180
  }));
1834
2181
  const registeredBoundaries = input.registeredBoundaries ?? new Map();
1835
- const completeRepositoryEvidence = existingRows.every((row) => row.type !== "repo.git"
1836
- || (Boolean(row.payloadVersion) && Boolean(row.transportVersion)));
1837
2182
  if (suspects !== null
1838
2183
  && suspects.length === 0
1839
- && existingRows.length > 0
1840
- && completeRepositoryEvidence) {
2184
+ && existingRows.length > 0) {
1841
2185
  const rows = existingRows.map((row) => ({
1842
2186
  record: portableRecord(row),
1843
2187
  relativePath: row.relativePath,