@amalgm/shell 0.1.41 → 0.1.43

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.
@@ -8,7 +8,8 @@ import Database from "better-sqlite3";
8
8
  import { atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
9
  import { ContentCacheDownload, captureContentFile, hashContentFile, } from "./content-cache-host.js";
10
10
  import { decodeContentWireBytes, encodeContentWireBytes } from "./content-wire-codec.js";
11
- import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, workspaceBindingDir, } from "./files-register-host.js";
11
+ import { planGroundDetection, reconcileGroundUUIDs, } from "./detection-host.js";
12
+ import { WORKSPACE_UUID as UUID, createFilesRegisterPorts, ensureWorkspaceBinding, ensureWorkspaceReference, pathExists, pathWithin, referenceWorkspaceId, selectKnownRegistrationId, workspaceBindingDir, } from "./files-register-host.js";
12
13
  import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, hasGitMarker, } from "./git-repository-host.js";
13
14
  import { inspectGitRegistration, } from "./git-registration-host.js";
14
15
  import { projectMaterializedGraph } from "./materialized-graph.js";
@@ -21,6 +22,12 @@ const PORTABLE_FIELDS = [
21
22
  const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
22
23
  const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
23
24
  const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
25
+ const GROUND_ROW_COLUMNS = [
26
+ "uuid", "resource_id", "root_uuid", "type", "parent_uuid", "name", "status", "version",
27
+ "payload_version", "transport_version", "relative_path", "absolute_path", "device_number",
28
+ "inode", "byte_size", "modified_time_ms", "changed_time_ms", "filesystem_mode",
29
+ "content_verified_at_ms",
30
+ ].join(", ");
24
31
  /** The production Files port injected into Core. Portable identity remains in
25
32
  * Live; this class owns only Node filesystem, SQLite, Watch, and wire effects. */
26
33
  export class UserGroundHost {
@@ -290,21 +297,21 @@ export class UserGroundHost {
290
297
  watch,
291
298
  };
292
299
  }
293
- /** Make every observed filesystem change durable in SQLite, immutable
294
- * content storage, and the official cloud head before returning. */
300
+ /** Prove one finite observation boundary in SQLite, immutable content
301
+ * storage, and the official cloud head. Rings received during that proof
302
+ * remain pending and already own their next debounced Detect pass. */
295
303
  async flush() {
296
304
  if (!this.converged || !this.activeIdentity || !this.cloudState)
297
305
  return;
298
306
  const identity = this.activeIdentity;
299
307
  this.watchHost.suspectAll();
300
- this.watchDirty = true;
308
+ // The complete suspicion above contains everything known at this cutoff.
309
+ // A native callback after this assignment sets watchDirty again and must
310
+ // remain a later generation; flush never waits for global Watch silence.
311
+ this.watchDirty = false;
301
312
  await this.syncNow();
302
313
  if (!this.closing)
303
314
  this.ensureWatchers(identity);
304
- await this.flushObservedChanges();
305
- if (!this.closing)
306
- this.ensureWatchers(identity);
307
- await this.flushObservedChanges();
308
315
  }
309
316
  async syncNow() {
310
317
  if (!this.activeIdentity)
@@ -317,20 +324,14 @@ export class UserGroundHost {
317
324
  await this.syncing;
318
325
  }
319
326
  async flushObservedChanges() {
320
- while (true) {
321
- const dirty = this.watchDirty
322
- || this.watchHost.hasPending;
323
- this.watchDirty = false;
324
- if (dirty) {
325
- await this.syncNow();
326
- continue;
327
- }
328
- if (this.syncing) {
329
- await this.syncing;
330
- continue;
331
- }
327
+ const dirty = this.watchDirty || this.watchHost.hasPending;
328
+ this.watchDirty = false;
329
+ if (dirty) {
330
+ await this.syncNow();
332
331
  return;
333
332
  }
333
+ if (this.syncing)
334
+ await this.syncing;
334
335
  }
335
336
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
336
337
  // Files convergence and Watch/Detect own ground currency. Advertising an
@@ -709,37 +710,62 @@ export class UserGroundHost {
709
710
  throw new Error("cloud state is unavailable for user-ground Watch");
710
711
  const observations = this.watchHost.observations();
711
712
  let localRecords;
713
+ let detection;
714
+ let acceptedNotebookRows;
715
+ let notebookRemovals;
712
716
  try {
713
- localRecords = await scanAndRegister({
717
+ const scanned = await scanAndRegister({
714
718
  identity,
715
719
  userRoot: this.userRoot(identity),
716
720
  database: this.databasePath(identity),
717
721
  cacheDir: this.cacheDir(identity),
718
722
  onScan: this.options.onDetectScan,
719
- suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion.paths])),
723
+ suspicions: new Map(observations.map((observation) => [observation.directory, observation.suspicion])),
720
724
  });
725
+ localRecords = [...scanned.records];
726
+ detection = scanned.detection;
727
+ acceptedNotebookRows = scanned.acceptedNotebookRows;
728
+ notebookRemovals = scanned.notebookRemovals;
721
729
  }
722
730
  catch (error) {
723
731
  throw pipelineStageError("entity registration", error);
724
732
  }
733
+ const retry = detection.find((plan) => plan.kind === "retry");
734
+ if (retry?.kind === "retry") {
735
+ throw pipelineStageError("detection", new Error(retry.reasons.join("; ")));
736
+ }
737
+ const detectedRecords = detection.flatMap((plan) => plan.kind === "ready"
738
+ ? plan.proposals.map((proposal) => ({ mutationId: randomUUID(), proposal }))
739
+ : []);
740
+ // The notebook is durable identity evidence, not evidence scoped to only
741
+ // this Watch generation. A later no-op flush must not erase ground that an
742
+ // earlier observation proved missing without lifecycle authority.
743
+ localRecords = recordsRetainingMissingEvidence(localRecords, readDetectionNotebook(this.databasePath(identity))
744
+ .filter((row) => !notebookRemovals.includes(row.uuid))
745
+ .map(portableRecord));
725
746
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
726
747
  const checksum = sha256Hex(stableJson(snapshot));
727
748
  if (checksum === state.checksum) {
749
+ commitDetectedState(this.databasePath(identity), {
750
+ acceptedNotebookRows,
751
+ notebookRemovals,
752
+ });
728
753
  this.watchHost.settle(observations);
729
754
  return;
730
755
  }
731
- const database = initializeDatabase(this.databasePath(identity));
732
- try {
733
- database.prepare(`
734
- INSERT INTO cloud_outbox(
735
- mutation_id, resource_id, authority_epoch, base_version,
736
- snapshot_checksum, snapshot_json, created_at
737
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
738
- `).run(randomUUID(), state.resourceId, state.authorityEpoch, state.headVersion, checksum, stableJson(snapshot), new Date().toISOString());
739
- }
740
- finally {
741
- database.close();
742
- }
756
+ commitDetectedState(this.databasePath(identity), {
757
+ acceptedNotebookRows,
758
+ notebookRemovals,
759
+ outbox: {
760
+ mutationId: randomUUID(),
761
+ resourceId: state.resourceId,
762
+ authorityEpoch: state.authorityEpoch,
763
+ baseVersion: state.headVersion,
764
+ snapshotChecksum: checksum,
765
+ snapshotJson: stableJson(snapshot),
766
+ detectedRecordsJson: stableJson(detectedRecords),
767
+ },
768
+ });
743
769
  await this.drainOutbox(identity);
744
770
  this.watchHost.settle(observations);
745
771
  }
@@ -937,6 +963,27 @@ function initializeDatabase(file) {
937
963
  content_verified_at_ms REAL,
938
964
  UNIQUE(resource_id, root_uuid, relative_path)
939
965
  );
966
+ CREATE TABLE IF NOT EXISTS detection_notebook (
967
+ uuid TEXT PRIMARY KEY,
968
+ resource_id TEXT NOT NULL,
969
+ root_uuid TEXT NOT NULL,
970
+ type TEXT NOT NULL,
971
+ parent_uuid TEXT,
972
+ name TEXT NOT NULL,
973
+ status TEXT NOT NULL,
974
+ version TEXT NOT NULL,
975
+ payload_version TEXT,
976
+ transport_version TEXT,
977
+ relative_path TEXT NOT NULL,
978
+ absolute_path TEXT NOT NULL,
979
+ device_number INTEGER NOT NULL,
980
+ inode INTEGER NOT NULL,
981
+ byte_size INTEGER,
982
+ modified_time_ms REAL,
983
+ changed_time_ms REAL,
984
+ filesystem_mode INTEGER,
985
+ content_verified_at_ms REAL
986
+ );
940
987
  CREATE TABLE IF NOT EXISTS cloud_outbox (
941
988
  sequence INTEGER PRIMARY KEY AUTOINCREMENT,
942
989
  mutation_id TEXT NOT NULL UNIQUE,
@@ -945,6 +992,7 @@ function initializeDatabase(file) {
945
992
  base_version INTEGER NOT NULL,
946
993
  snapshot_checksum TEXT NOT NULL,
947
994
  snapshot_json TEXT NOT NULL,
995
+ detected_records_json TEXT NOT NULL DEFAULT '[]',
948
996
  created_at TEXT NOT NULL
949
997
  );
950
998
  CREATE TABLE IF NOT EXISTS workspace_add_intents (
@@ -956,6 +1004,10 @@ function initializeDatabase(file) {
956
1004
  ON entities(absolute_path);
957
1005
  CREATE INDEX IF NOT EXISTS entities_by_physical_identity
958
1006
  ON entities(device_number, inode);
1007
+ CREATE INDEX IF NOT EXISTS detection_notebook_by_root_path
1008
+ ON detection_notebook(resource_id, root_uuid, relative_path);
1009
+ CREATE INDEX IF NOT EXISTS detection_notebook_by_physical_identity
1010
+ ON detection_notebook(device_number, inode);
959
1011
  `);
960
1012
  const currentEntityColumns = new Set(database.prepare("PRAGMA table_info(entities)").all()
961
1013
  .map(({ name }) => name));
@@ -970,9 +1022,20 @@ function initializeDatabase(file) {
970
1022
  if (!currentEntityColumns.has(name))
971
1023
  database.exec(`ALTER TABLE entities ADD COLUMN ${name} ${type}`);
972
1024
  }
1025
+ const outboxColumns = new Set(database.prepare("PRAGMA table_info(cloud_outbox)").all()
1026
+ .map(({ name }) => name));
1027
+ if (!outboxColumns.has("detected_records_json")) {
1028
+ database.exec("ALTER TABLE cloud_outbox ADD COLUMN detected_records_json TEXT NOT NULL DEFAULT '[]'");
1029
+ }
1030
+ const notebookCount = database.prepare("SELECT COUNT(*) AS count FROM detection_notebook").get().count;
1031
+ if (notebookCount === 0)
1032
+ database.exec(`
1033
+ INSERT INTO detection_notebook(${GROUND_ROW_COLUMNS})
1034
+ SELECT ${GROUND_ROW_COLUMNS} FROM entities
1035
+ `);
973
1036
  return database;
974
1037
  }
975
- function readRows(file) {
1038
+ function readGroundRows(file, table) {
976
1039
  if (!existsSync(file))
977
1040
  return [];
978
1041
  const database = initializeDatabase(file);
@@ -986,13 +1049,19 @@ function readRows(file) {
986
1049
  byte_size AS byteSize, modified_time_ms AS modifiedTimeMs,
987
1050
  changed_time_ms AS changedTimeMs, filesystem_mode AS filesystemMode,
988
1051
  content_verified_at_ms AS contentVerifiedAtMs
989
- FROM entities ORDER BY uuid
1052
+ FROM ${table} ORDER BY uuid
990
1053
  `).all();
991
1054
  }
992
1055
  finally {
993
1056
  database.close();
994
1057
  }
995
1058
  }
1059
+ function readRows(file) {
1060
+ return readGroundRows(file, "entities");
1061
+ }
1062
+ function readDetectionNotebook(file) {
1063
+ return readGroundRows(file, "detection_notebook");
1064
+ }
996
1065
  function workspaceAddStagingPath(destinationPath, workspaceId) {
997
1066
  return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
998
1067
  }
@@ -1107,12 +1176,14 @@ function findKnownEntityId(file, absolutePath, deviceNumber, inode) {
1107
1176
  const database = initializeDatabase(file);
1108
1177
  try {
1109
1178
  const exact = database.prepare("SELECT uuid FROM entities WHERE absolute_path = ? LIMIT 2").all(absolutePath);
1110
- const physical = exact.length > 0 ? exact : database.prepare("SELECT uuid FROM entities WHERE device_number = ? AND inode = ? LIMIT 2").all(deviceNumber, inode);
1111
- const ids = [...new Set(physical.map(({ uuid }) => uuid))];
1112
- if (ids.length > 1) {
1113
- throw new Error(`entity graph has ambiguous identity for ${absolutePath}`);
1179
+ if (exact.length > 0) {
1180
+ return selectKnownRegistrationId(absolutePath, exact.map(({ uuid }) => ({ uuid, absolutePath })));
1114
1181
  }
1115
- return ids[0] ?? null;
1182
+ const physical = database.prepare(`
1183
+ SELECT uuid, absolute_path AS absolutePath
1184
+ FROM entities WHERE device_number = ? AND inode = ?
1185
+ `).all(deviceNumber, inode);
1186
+ return selectKnownRegistrationId(absolutePath, physical);
1116
1187
  }
1117
1188
  finally {
1118
1189
  database.close();
@@ -1126,7 +1197,8 @@ function readOutbox(file) {
1126
1197
  return database.prepare(`
1127
1198
  SELECT mutation_id AS mutationId, resource_id AS resourceId,
1128
1199
  authority_epoch AS authorityEpoch, base_version AS baseVersion,
1129
- snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson
1200
+ snapshot_checksum AS snapshotChecksum, snapshot_json AS snapshotJson,
1201
+ detected_records_json AS detectedRecordsJson
1130
1202
  FROM cloud_outbox ORDER BY sequence
1131
1203
  `).all();
1132
1204
  }
@@ -1134,6 +1206,33 @@ function readOutbox(file) {
1134
1206
  database.close();
1135
1207
  }
1136
1208
  }
1209
+ /** Record is the SQLite transaction that accepts Detect's exact proposals and
1210
+ * advances the last-verified notebook. Watch may settle only after this
1211
+ * function returns. The existing outbox remains the one journal and queue. */
1212
+ function commitDetectedState(file, input) {
1213
+ const database = initializeDatabase(file);
1214
+ try {
1215
+ database.transaction(() => {
1216
+ if (input.outbox) {
1217
+ database.prepare(`
1218
+ INSERT INTO cloud_outbox(
1219
+ mutation_id, resource_id, authority_epoch, base_version,
1220
+ snapshot_checksum, snapshot_json, detected_records_json, created_at
1221
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1222
+ `).run(input.outbox.mutationId, input.outbox.resourceId, input.outbox.authorityEpoch, input.outbox.baseVersion, input.outbox.snapshotChecksum, input.outbox.snapshotJson, input.outbox.detectedRecordsJson, new Date().toISOString());
1223
+ }
1224
+ const remove = database.prepare("DELETE FROM detection_notebook WHERE uuid = ?");
1225
+ for (const uuid of input.notebookRemovals)
1226
+ remove.run(uuid);
1227
+ const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1228
+ for (const row of input.acceptedNotebookRows)
1229
+ upsertNotebook.run(...groundRowValues(row));
1230
+ })();
1231
+ }
1232
+ finally {
1233
+ database.close();
1234
+ }
1235
+ }
1137
1236
  function deleteOutbox(file, mutationId) {
1138
1237
  const database = initializeDatabase(file);
1139
1238
  try {
@@ -1149,7 +1248,85 @@ function portableRecord(row) {
1149
1248
  function portableRecords(file) {
1150
1249
  return readRows(file).map(portableRecord);
1151
1250
  }
1152
- function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = []) {
1251
+ function capturedGroundRow(resourceId, rootUUID, row) {
1252
+ return {
1253
+ resourceId,
1254
+ rootUUID,
1255
+ uuid: row.record.uuid,
1256
+ type: row.record.type,
1257
+ parentUUID: row.record.parentUUID,
1258
+ name: row.record.name,
1259
+ status: "active",
1260
+ version: row.record.version,
1261
+ payloadVersion: row.record.payloadVersion,
1262
+ transportVersion: row.record.transportVersion,
1263
+ relativePath: row.relativePath,
1264
+ absolutePath: row.absolutePath,
1265
+ deviceNumber: row.deviceNumber,
1266
+ inode: row.inode,
1267
+ byteSize: row.byteSize,
1268
+ modifiedTimeMs: row.modifiedTimeMs,
1269
+ changedTimeMs: row.changedTimeMs,
1270
+ filesystemMode: row.filesystemMode,
1271
+ contentVerifiedAtMs: row.contentVerifiedAtMs,
1272
+ };
1273
+ }
1274
+ function sameGroundRow(left, right) {
1275
+ return right !== undefined
1276
+ && left.resourceId === right.resourceId
1277
+ && left.rootUUID === right.rootUUID
1278
+ && left.uuid === right.uuid
1279
+ && left.type === right.type
1280
+ && left.parentUUID === right.parentUUID
1281
+ && left.name === right.name
1282
+ && left.status === right.status
1283
+ && left.version === right.version
1284
+ && left.payloadVersion === right.payloadVersion
1285
+ && left.transportVersion === right.transportVersion
1286
+ && left.relativePath === right.relativePath
1287
+ && left.absolutePath === right.absolutePath
1288
+ && left.deviceNumber === right.deviceNumber
1289
+ && left.inode === right.inode
1290
+ && left.byteSize === right.byteSize
1291
+ && left.modifiedTimeMs === right.modifiedTimeMs
1292
+ && left.changedTimeMs === right.changedTimeMs
1293
+ && left.filesystemMode === right.filesystemMode
1294
+ && left.contentVerifiedAtMs === right.contentVerifiedAtMs;
1295
+ }
1296
+ function groundRowValues(row) {
1297
+ return [
1298
+ row.uuid, row.resourceId, row.rootUUID, row.type, row.parentUUID, row.name, row.status,
1299
+ row.version, row.payloadVersion, row.transportVersion, row.relativePath, row.absolutePath,
1300
+ row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs,
1301
+ row.filesystemMode, row.contentVerifiedAtMs,
1302
+ ];
1303
+ }
1304
+ function prepareGroundUpsert(database, table) {
1305
+ return database.prepare(`
1306
+ INSERT INTO ${table}(${GROUND_ROW_COLUMNS})
1307
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1308
+ ON CONFLICT(uuid) DO UPDATE SET
1309
+ resource_id = excluded.resource_id,
1310
+ root_uuid = excluded.root_uuid,
1311
+ type = excluded.type,
1312
+ parent_uuid = excluded.parent_uuid,
1313
+ name = excluded.name,
1314
+ status = excluded.status,
1315
+ version = excluded.version,
1316
+ payload_version = excluded.payload_version,
1317
+ transport_version = excluded.transport_version,
1318
+ relative_path = excluded.relative_path,
1319
+ absolute_path = excluded.absolute_path,
1320
+ device_number = excluded.device_number,
1321
+ inode = excluded.inode,
1322
+ byte_size = excluded.byte_size,
1323
+ modified_time_ms = excluded.modified_time_ms,
1324
+ changed_time_ms = excluded.changed_time_ms,
1325
+ filesystem_mode = excluded.filesystem_mode,
1326
+ content_verified_at_ms = excluded.content_verified_at_ms
1327
+ `);
1328
+ }
1329
+ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = [], options = {}) {
1153
1330
  const database = initializeDatabase(file);
1154
1331
  try {
1155
1332
  const replaceIdentity = database.prepare(`
@@ -1160,37 +1337,15 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1160
1337
  user_email = excluded.user_email,
1161
1338
  device_id = excluded.device_id
1162
1339
  `);
1163
- const upsert = database.prepare(`
1164
- INSERT INTO entities(
1165
- uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
1166
- transport_version, relative_path, absolute_path, device_number, inode
1167
- , byte_size, modified_time_ms, changed_time_ms, filesystem_mode, content_verified_at_ms
1168
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1169
- ON CONFLICT(uuid) DO UPDATE SET
1170
- resource_id = excluded.resource_id,
1171
- root_uuid = excluded.root_uuid,
1172
- type = excluded.type,
1173
- parent_uuid = excluded.parent_uuid,
1174
- name = excluded.name,
1175
- status = excluded.status,
1176
- version = excluded.version,
1177
- payload_version = excluded.payload_version,
1178
- transport_version = excluded.transport_version,
1179
- relative_path = excluded.relative_path,
1180
- absolute_path = excluded.absolute_path,
1181
- device_number = excluded.device_number,
1182
- inode = excluded.inode,
1183
- byte_size = excluded.byte_size,
1184
- modified_time_ms = excluded.modified_time_ms,
1185
- changed_time_ms = excluded.changed_time_ms,
1186
- filesystem_mode = excluded.filesystem_mode,
1187
- content_verified_at_ms = excluded.content_verified_at_ms
1188
- `);
1340
+ const upsert = prepareGroundUpsert(database, "entities");
1341
+ const upsertNotebook = prepareGroundUpsert(database, "detection_notebook");
1189
1342
  const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
1343
+ const removeNotebook = database.prepare("DELETE FROM detection_notebook WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
1190
1344
  const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
1345
+ const materializedIds = new Set(database.prepare("SELECT uuid FROM entities").all()
1346
+ .map(({ uuid }) => uuid));
1191
1347
  const currentUuids = new Set(rows.map((row) => row.record.uuid));
1192
- const changed = rows.filter((row) => {
1193
- const old = previousByUuid.get(row.record.uuid);
1348
+ const rowChanged = (row, old) => {
1194
1349
  return !old
1195
1350
  || old.resourceId !== resourceId
1196
1351
  || old.rootUUID !== rootUUID
@@ -1210,15 +1365,27 @@ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows =
1210
1365
  || old.changedTimeMs !== row.changedTimeMs
1211
1366
  || old.filesystemMode !== row.filesystemMode
1212
1367
  || old.contentVerifiedAtMs !== row.contentVerifiedAtMs;
1213
- });
1368
+ };
1369
+ const changed = rows.filter((row) => !materializedIds.has(row.record.uuid)
1370
+ || rowChanged(row, previousByUuid.get(row.record.uuid)));
1371
+ const notebookChanged = rows.filter((row) => rowChanged(row, previousByUuid.get(row.record.uuid)));
1214
1372
  const replace = database.transaction(() => {
1215
1373
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
1216
1374
  for (const old of previousRows) {
1217
- if (!currentUuids.has(old.uuid))
1375
+ if (!currentUuids.has(old.uuid)) {
1218
1376
  remove.run(old.uuid, resourceId, old.rootUUID);
1377
+ if (options.updateNotebook !== false) {
1378
+ removeNotebook.run(old.uuid, resourceId, old.rootUUID);
1379
+ }
1380
+ }
1219
1381
  }
1220
1382
  for (const row of changed)
1221
- upsert.run(row.record.uuid, resourceId, rootUUID, row.record.type, row.record.parentUUID, row.record.name, row.record.status, row.record.version, row.record.payloadVersion, row.record.transportVersion, row.relativePath, row.absolutePath, row.deviceNumber, row.inode, row.byteSize, row.modifiedTimeMs, row.changedTimeMs, row.filesystemMode, row.contentVerifiedAtMs);
1383
+ upsert.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
1384
+ if (options.updateNotebook !== false) {
1385
+ for (const row of notebookChanged) {
1386
+ upsertNotebook.run(...groundRowValues(capturedGroundRow(resourceId, rootUUID, row)));
1387
+ }
1388
+ }
1222
1389
  });
1223
1390
  replace();
1224
1391
  }
@@ -1233,15 +1400,17 @@ function refreshRevealedRootEvidence(file, workspaceId, absolutePath) {
1233
1400
  const stats = statFingerprint(lstatSync(absolutePath));
1234
1401
  const database = initializeDatabase(file);
1235
1402
  try {
1236
- const result = database.prepare(`
1237
- UPDATE entities SET
1403
+ const refresh = (table) => database.prepare(`
1404
+ UPDATE ${table} SET
1238
1405
  device_number = ?, inode = ?, byte_size = ?, modified_time_ms = ?,
1239
1406
  changed_time_ms = ?, filesystem_mode = ?
1240
1407
  WHERE uuid = ? AND absolute_path = ?
1241
1408
  `).run(stats.deviceNumber, stats.inode, stats.byteSize, stats.modifiedTimeMs, stats.changedTimeMs, stats.filesystemMode, workspaceId, absolutePath);
1409
+ const result = refresh("entities");
1242
1410
  if (result.changes !== 1) {
1243
1411
  throw new Error(`revealed workspace ${workspaceId} has no committed root row`);
1244
1412
  }
1413
+ refresh("detection_notebook");
1245
1414
  }
1246
1415
  finally {
1247
1416
  database.close();
@@ -1364,7 +1533,10 @@ function materializedWorkspaceBindings(bindingDir) {
1364
1533
  async function scanAndRegister(input) {
1365
1534
  const { identity, userRoot, database, cacheDir, suspicions, onScan } = input;
1366
1535
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
1367
- const existing = readRows(database);
1536
+ const materialized = readRows(database);
1537
+ const notebook = readDetectionNotebook(database);
1538
+ const existing = suspicions ? notebook : materialized;
1539
+ const notebookByUuid = new Map(notebook.map((row) => [row.uuid, row]));
1368
1540
  const ignoreFile = join(userRoot, ".amalgmignore");
1369
1541
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
1370
1542
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
@@ -1378,8 +1550,9 @@ async function scanAndRegister(input) {
1378
1550
  const suspicionFor = (root) => {
1379
1551
  if (!suspicions || !suspicions.has(root))
1380
1552
  return suspicions ? [] : null;
1381
- return suspicions.get(root);
1553
+ return suspicions.get(root).paths;
1382
1554
  };
1555
+ const observationFor = (root) => suspicions?.get(root);
1383
1556
  const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
1384
1557
  const coreUUID = existingCore?.uuid || randomUUID();
1385
1558
  const existingReferenceIds = new Set(existing
@@ -1388,7 +1561,10 @@ async function scanAndRegister(input) {
1388
1561
  const coreSuspicion = suspicionFor(userRoot);
1389
1562
  const missingPortableReference = [...boundRoots.keys()]
1390
1563
  .some((workspaceId) => !existingReferenceIds.has(workspaceId));
1391
- const deferredRootIds = new Set();
1564
+ const acceptedRecords = [];
1565
+ const acceptedNotebookRows = [];
1566
+ const notebookRemovals = new Set();
1567
+ const detection = [];
1392
1568
  const scan = async (parameters) => {
1393
1569
  const counters = {
1394
1570
  entries: 0,
@@ -1399,6 +1575,22 @@ async function scanAndRegister(input) {
1399
1575
  };
1400
1576
  const started = performance.now();
1401
1577
  const result = await scanRoot({ ...parameters, evidence: counters });
1578
+ if (result.detection)
1579
+ detection.push(result.detection);
1580
+ for (const uuid of result.notebookRemovals) {
1581
+ notebookRemovals.add(uuid);
1582
+ notebookByUuid.delete(uuid);
1583
+ }
1584
+ if (result.transferable) {
1585
+ acceptedRecords.push(...result.records);
1586
+ for (const row of result.rows) {
1587
+ const captured = capturedGroundRow(parameters.resourceId, parameters.rootUUID, row);
1588
+ if (sameGroundRow(captured, notebookByUuid.get(captured.uuid)))
1589
+ continue;
1590
+ acceptedNotebookRows.push(captured);
1591
+ notebookByUuid.set(captured.uuid, captured);
1592
+ }
1593
+ }
1402
1594
  onScan?.({
1403
1595
  rootId: parameters.rootUUID,
1404
1596
  directory: parameters.rootPath,
@@ -1413,7 +1605,7 @@ async function scanAndRegister(input) {
1413
1605
  await new Promise((resolve) => setImmediate(resolve));
1414
1606
  return result;
1415
1607
  };
1416
- const core = await scan({
1608
+ await scan({
1417
1609
  identity,
1418
1610
  resourceId,
1419
1611
  rootUUID: coreUUID,
@@ -1428,13 +1620,12 @@ async function scanAndRegister(input) {
1428
1620
  ? [...new Set([...coreSuspicion, "workspaces"])]
1429
1621
  : coreSuspicion,
1430
1622
  existingRows: existing.filter((row) => row.rootUUID === coreUUID),
1623
+ suspicion: observationFor(userRoot),
1431
1624
  });
1432
- if (!core.transferable)
1433
- deferredRootIds.add(coreUUID);
1434
1625
  for (const { workspaceId, directory: rootPath } of outerBoundRoots) {
1435
1626
  const existingRoot = existing.find((row) => row.uuid === workspaceId);
1436
1627
  const existingWorkspaceRows = existing.filter((row) => row.resourceId === resourceId && pathWithin(rootPath, row.absolutePath));
1437
- const scanned = await scan({
1628
+ await scan({
1438
1629
  identity,
1439
1630
  resourceId,
1440
1631
  rootUUID: workspaceId,
@@ -1451,13 +1642,15 @@ async function scanAndRegister(input) {
1451
1642
  suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1452
1643
  existingRows: existingWorkspaceRows,
1453
1644
  registeredBoundaries,
1645
+ suspicion: observationFor(rootPath),
1454
1646
  });
1455
- if (!scanned.transferable)
1456
- deferredRootIds.add(workspaceId);
1457
1647
  }
1458
- return readRows(database)
1459
- .filter((row) => !deferredRootIds.has(row.rootUUID))
1460
- .map(portableRecord);
1648
+ return {
1649
+ records: acceptedRecords,
1650
+ detection,
1651
+ acceptedNotebookRows,
1652
+ notebookRemovals: [...notebookRemovals],
1653
+ };
1461
1654
  }
1462
1655
  async function scanRoot(input) {
1463
1656
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
@@ -1476,32 +1669,43 @@ async function scanRoot(input) {
1476
1669
  && suspects.length === 0
1477
1670
  && existingRows.length > 0
1478
1671
  && completeRepositoryEvidence) {
1672
+ const rows = existingRows.map((row) => ({
1673
+ record: portableRecord(row),
1674
+ relativePath: row.relativePath,
1675
+ absolutePath: row.absolutePath,
1676
+ deviceNumber: row.deviceNumber,
1677
+ inode: row.inode,
1678
+ byteSize: row.byteSize,
1679
+ modifiedTimeMs: row.modifiedTimeMs,
1680
+ changedTimeMs: row.changedTimeMs,
1681
+ filesystemMode: row.filesystemMode,
1682
+ contentVerifiedAtMs: row.contentVerifiedAtMs,
1683
+ }));
1479
1684
  return {
1480
1685
  records: existingRows.map(portableRecord),
1481
- rows: existingRows.map((row) => ({
1482
- record: portableRecord(row),
1483
- relativePath: row.relativePath,
1484
- absolutePath: row.absolutePath,
1485
- deviceNumber: row.deviceNumber,
1486
- inode: row.inode,
1487
- byteSize: row.byteSize,
1488
- modifiedTimeMs: row.modifiedTimeMs,
1489
- changedTimeMs: row.changedTimeMs,
1490
- filesystemMode: row.filesystemMode,
1491
- contentVerifiedAtMs: row.contentVerifiedAtMs,
1492
- })),
1686
+ rows,
1493
1687
  referenceWorkspaceIds: existingRows
1494
1688
  .filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
1495
1689
  .map((row) => row.payloadVersion),
1496
1690
  transferable: true,
1691
+ notebookRemovals: [],
1692
+ detection: input.suspicion ? planGroundDetection({
1693
+ rootId: rootUUID,
1694
+ suspicion: input.suspicion,
1695
+ before: existingRows.map(portableRecord),
1696
+ after: rows.map((row) => ({ record: row.record, relativePath: row.relativePath })),
1697
+ }) : null,
1497
1698
  };
1498
1699
  }
1499
1700
  const entries = [];
1500
1701
  const referenceWorkspaceIds = new Set();
1501
1702
  const rootStats = lstatSync(rootPath);
1703
+ const priorRootType = existingByPath.get("")?.type;
1704
+ const rootRailChanged = priorRootType !== undefined && priorRootType !== rootType;
1502
1705
  evidence && (evidence.entries += 1);
1503
1706
  entries.push({
1504
1707
  uuid: rootUUID,
1708
+ fixedUUID: rootUUID,
1505
1709
  type: rootType,
1506
1710
  parentUUID: input.rootParentUUID ?? null,
1507
1711
  name: rootName,
@@ -1538,7 +1742,7 @@ async function scanRoot(input) {
1538
1742
  };
1539
1743
  const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1540
1744
  ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1541
- : null) => {
1745
+ : null, forceObserve = rootRailChanged) => {
1542
1746
  const children = readdirSync(directory, { withFileTypes: true })
1543
1747
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
1544
1748
  for (const child of children) {
@@ -1561,7 +1765,7 @@ async function scanRoot(input) {
1561
1765
  const stableDuringCompleteCatchUp = suspects === null
1562
1766
  && existing !== undefined
1563
1767
  && reusableDuringCompleteCatchUp(existing, stats);
1564
- const observe = existing === undefined
1768
+ const observe = forceObserve || existing === undefined
1565
1769
  || (suspects === null ? !stableDuringCompleteCatchUp : touchesSuspicion(relativePath));
1566
1770
  if (!observe && suspects === null)
1567
1771
  evidence && (evidence.metadataReused += 1);
@@ -1585,6 +1789,7 @@ async function scanRoot(input) {
1585
1789
  : undefined;
1586
1790
  let type;
1587
1791
  let payloadVersion = null;
1792
+ let linkTarget;
1588
1793
  let contentVerifiedAtMs = existing?.contentVerifiedAtMs ?? null;
1589
1794
  if (!observe && existing && !stats.isDirectory()) {
1590
1795
  type = existing.type;
@@ -1607,7 +1812,8 @@ async function scanRoot(input) {
1607
1812
  }
1608
1813
  else {
1609
1814
  evidence && (evidence.contentReads += 1);
1610
- const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
1815
+ linkTarget = readlinkSync(absolutePath);
1816
+ const bytes = Buffer.from(linkTarget, "utf8");
1611
1817
  payloadVersion = sha256Hex(bytes);
1612
1818
  if (!activeRepository)
1613
1819
  immutableWriteArtifact(cacheDir, {
@@ -1641,6 +1847,7 @@ async function scanRoot(input) {
1641
1847
  contentVerifiedAtMs = Date.now();
1642
1848
  entries.push({
1643
1849
  uuid,
1850
+ ...(registeredBoundaryId ? { fixedUUID: registeredBoundaryId } : {}),
1644
1851
  type,
1645
1852
  parentUUID,
1646
1853
  name: child.name,
@@ -1649,20 +1856,47 @@ async function scanRoot(input) {
1649
1856
  payloadVersion,
1650
1857
  transportVersion: null,
1651
1858
  contentVerifiedAtMs,
1859
+ ...(linkTarget !== undefined ? { linkTarget } : {}),
1652
1860
  ...statFingerprint(stats),
1653
1861
  });
1654
1862
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
1863
+ const childRailChanged = existing !== undefined && existing.type !== type;
1655
1864
  await visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1656
1865
  ? {
1657
1866
  root: absolutePath,
1658
1867
  evidencePaths: repositoryEvidencePaths(absolutePath),
1659
1868
  evidence: null,
1660
1869
  }
1661
- : activeRepository);
1870
+ : activeRepository, forceObserve || childRailChanged);
1662
1871
  }
1663
1872
  }
1664
1873
  };
1665
1874
  await visit(rootPath, rootUUID, rootType);
1875
+ const resolvedIdentity = reconcileGroundUUIDs(existingRows.map((row) => ({
1876
+ uuid: row.uuid,
1877
+ type: row.type,
1878
+ relativePath: row.relativePath,
1879
+ deviceNumber: row.deviceNumber,
1880
+ inode: row.inode,
1881
+ })), entries.map((entry) => ({
1882
+ key: entry.relativePath,
1883
+ type: entry.type,
1884
+ relativePath: entry.relativePath,
1885
+ deviceNumber: entry.deviceNumber,
1886
+ inode: entry.inode,
1887
+ ...(entry.fixedUUID ? { fixedUUID: entry.fixedUUID } : {}),
1888
+ })));
1889
+ const resolvedByProvisional = new Map();
1890
+ for (const entry of entries) {
1891
+ const resolved = resolvedIdentity.get(entry.relativePath) ?? entry.uuid;
1892
+ resolvedByProvisional.set(entry.uuid, resolved);
1893
+ entry.uuid = resolved;
1894
+ }
1895
+ for (const entry of entries) {
1896
+ if (entry.parentUUID !== null) {
1897
+ entry.parentUUID = resolvedByProvisional.get(entry.parentUUID) ?? entry.parentUUID;
1898
+ }
1899
+ }
1666
1900
  const children = new Map();
1667
1901
  for (const entry of entries) {
1668
1902
  if (entry.parentUUID === null)
@@ -1706,10 +1940,13 @@ async function scanRoot(input) {
1706
1940
  });
1707
1941
  const persistCurrentRows = () => {
1708
1942
  const rows = rowsFromEntries();
1709
- persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1943
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows, {
1944
+ updateNotebook: input.suspicion === undefined,
1945
+ });
1710
1946
  return rows;
1711
1947
  };
1712
1948
  const repositories = entries.filter((entry) => entry.type === "repo.git");
1949
+ const replayByUuid = new Map();
1713
1950
  const properDescendantOf = (candidate, ancestor) => ancestor === "" ? candidate !== "" : candidate.startsWith(`${ancestor}/`);
1714
1951
  const repositoryOwner = (entry) => repositories
1715
1952
  .filter((repository) => repository.uuid !== entry.uuid
@@ -1734,12 +1971,14 @@ async function scanRoot(input) {
1734
1971
  repository.transportVersion = priorRow.transportVersion;
1735
1972
  }
1736
1973
  let prior = null;
1974
+ let priorLayout = null;
1737
1975
  if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
1738
1976
  const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
1739
1977
  if (existsSync(priorPath)) {
1740
1978
  try {
1741
1979
  const layout = inspectRepositoryTransportFile(priorPath);
1742
1980
  if (layout.stateId === priorRow.payloadVersion) {
1981
+ priorLayout = layout;
1743
1982
  prior = {
1744
1983
  stateId: priorRow.payloadVersion,
1745
1984
  transportVersion: priorRow.transportVersion,
@@ -1789,6 +2028,17 @@ async function scanRoot(input) {
1789
2028
  contentHash: captured.transportVersion,
1790
2029
  }, captured.bytes);
1791
2030
  }
2031
+ const currentLayout = inspectRepositoryTransportFile(join(cacheDir, `${captured.transportVersion}.bin`));
2032
+ replayByUuid.set(repository.uuid, {
2033
+ kind: "repo.git",
2034
+ stateId: currentLayout.stateId,
2035
+ cardId: currentLayout.cardId,
2036
+ checkpointId: currentLayout.checkpointId,
2037
+ cardChanged: priorLayout?.cardId !== currentLayout.cardId,
2038
+ checkpointChanged: priorLayout?.checkpointId !== currentLayout.checkpointId,
2039
+ parentTransportVersion: currentLayout.parentTransportVersion,
2040
+ transportVersion: captured.transportVersion,
2041
+ });
1792
2042
  }
1793
2043
  }
1794
2044
  catch {
@@ -1804,14 +2054,90 @@ async function scanRoot(input) {
1804
2054
  rows,
1805
2055
  referenceWorkspaceIds: [...referenceWorkspaceIds],
1806
2056
  transferable: false,
2057
+ notebookRemovals: [],
2058
+ detection: null,
1807
2059
  };
1808
2060
  }
1809
2061
  const rows = persistCurrentRows();
2062
+ const currentUUIDs = new Set(rows.map((row) => row.record.uuid));
2063
+ const notebookRemovals = input.suspicion === undefined ? [] : existingRows
2064
+ .filter((row) => !currentUUIDs.has(row.uuid) && existsSync(row.absolutePath))
2065
+ .map((row) => row.uuid);
2066
+ const before = existingRows.map(portableRecord);
2067
+ const beforeByUuid = new Map(before.map((record) => [record.uuid, record]));
2068
+ const detected = rows.map((row) => {
2069
+ const base = beforeByUuid.get(row.record.uuid) ?? null;
2070
+ const contentChanged = base === null
2071
+ || base.type !== row.record.type
2072
+ || base.payloadVersion !== row.record.payloadVersion
2073
+ || base.transportVersion !== row.record.transportVersion;
2074
+ let replay = { kind: "structure" };
2075
+ if (contentChanged) {
2076
+ switch (row.record.type) {
2077
+ case "file.text":
2078
+ if (!row.record.payloadVersion)
2079
+ throw new Error("detected text file has no content head");
2080
+ replay = {
2081
+ kind: "file.text",
2082
+ mode: "snapshot",
2083
+ basePayloadVersion: base?.payloadVersion && /^[0-9a-f]{64}$/.test(base.payloadVersion)
2084
+ ? base.payloadVersion
2085
+ : null,
2086
+ resultPayloadVersion: row.record.payloadVersion,
2087
+ delta: null,
2088
+ };
2089
+ break;
2090
+ case "file.binary": {
2091
+ if (!row.record.payloadVersion)
2092
+ throw new Error("detected binary file has no content head");
2093
+ const manifest = readCachedManifest(cacheDir, row.record.payloadVersion);
2094
+ if (manifest) {
2095
+ replay = {
2096
+ kind: "file.binary",
2097
+ resultPayloadVersion: row.record.payloadVersion,
2098
+ manifest,
2099
+ };
2100
+ }
2101
+ break;
2102
+ }
2103
+ case "link": {
2104
+ if (!row.record.payloadVersion)
2105
+ throw new Error("detected link has no content head");
2106
+ const target = entries.find((entry) => entry.uuid === row.record.uuid)?.linkTarget;
2107
+ if (target !== undefined) {
2108
+ replay = {
2109
+ kind: "link",
2110
+ resultPayloadVersion: row.record.payloadVersion,
2111
+ target,
2112
+ };
2113
+ }
2114
+ break;
2115
+ }
2116
+ case "repo.git": {
2117
+ const repositoryReplay = replayByUuid.get(row.record.uuid);
2118
+ if (!repositoryReplay)
2119
+ throw new Error("detected repository has no Card + Checkpoint replay");
2120
+ replay = repositoryReplay;
2121
+ break;
2122
+ }
2123
+ default:
2124
+ break;
2125
+ }
2126
+ }
2127
+ return { record: row.record, relativePath: row.relativePath, replay };
2128
+ });
1810
2129
  return {
1811
2130
  records: rows.map((row) => row.record),
1812
2131
  rows,
1813
2132
  referenceWorkspaceIds: [...referenceWorkspaceIds],
1814
2133
  transferable: true,
2134
+ notebookRemovals,
2135
+ detection: input.suspicion ? planGroundDetection({
2136
+ rootId: rootUUID,
2137
+ suspicion: input.suspicion,
2138
+ before,
2139
+ after: detected,
2140
+ }) : null,
1815
2141
  };
1816
2142
  }
1817
2143
  function portablePaths(records, boundaryUUIDs = new Set()) {
@@ -1878,6 +2204,40 @@ function mergeMaterializedRoots(cloudRecords, localRecords) {
1878
2204
  });
1879
2205
  return [...preserved, ...localRecords];
1880
2206
  }
2207
+ /** Filesystem disappearance is evidence, not cloud lifecycle authority. Keep
2208
+ * the last logical records in the outgoing graph while their materialized
2209
+ * rows remain absent, and derive container membership from that honest union. */
2210
+ function recordsRetainingMissingEvidence(present, missing) {
2211
+ const byUuid = new Map(present.map((record) => [record.uuid, record]));
2212
+ for (const record of missing) {
2213
+ if (!byUuid.has(record.uuid))
2214
+ byUuid.set(record.uuid, record);
2215
+ }
2216
+ const records = [...byUuid.values()];
2217
+ const activeChildren = new Map();
2218
+ for (const record of records) {
2219
+ if (record.status !== "active" || record.parentUUID === null)
2220
+ continue;
2221
+ const children = activeChildren.get(record.parentUUID) ?? [];
2222
+ children.push(record);
2223
+ activeChildren.set(record.parentUUID, children);
2224
+ }
2225
+ return records.map((record) => {
2226
+ if (record.type !== "workspace" && record.type !== "folder")
2227
+ return record;
2228
+ const payloadVersion = membershipHash(activeChildren.get(record.uuid) ?? [], sha256Hex);
2229
+ return {
2230
+ ...record,
2231
+ version: canonicalVersion({
2232
+ type: record.type,
2233
+ parentUUID: record.parentUUID,
2234
+ name: record.name,
2235
+ status: record.status,
2236
+ payloadVersion,
2237
+ }, sha256Hex),
2238
+ };
2239
+ });
2240
+ }
1881
2241
  function clearCoreMaterialization(userRoot) {
1882
2242
  ensurePrivateDir(userRoot);
1883
2243
  for (const entry of readdirSync(userRoot, { withFileTypes: true })) {
@@ -1889,8 +2249,12 @@ function clearCoreMaterialization(userRoot) {
1889
2249
  function deleteRootRows(databasePath, resourceId, rootUUID) {
1890
2250
  const database = initializeDatabase(databasePath);
1891
2251
  try {
1892
- database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
1893
- .run(resourceId, rootUUID);
2252
+ database.transaction(() => {
2253
+ database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
2254
+ .run(resourceId, rootUUID);
2255
+ database.prepare("DELETE FROM detection_notebook WHERE resource_id = ? AND root_uuid = ?")
2256
+ .run(resourceId, rootUUID);
2257
+ })();
1894
2258
  }
1895
2259
  finally {
1896
2260
  database.close();