@gmickel/gno 1.34.5 → 1.35.0

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.
Files changed (49) hide show
  1. package/README.md +22 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.zip → gno-browser-clipper-v1.35.0.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +4 -1
  6. package/spec/cli.md +43 -0
  7. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  8. package/src/config/index.ts +4 -0
  9. package/src/config/types.ts +14 -0
  10. package/src/core/path-rules.ts +34 -0
  11. package/src/ingestion/index.ts +21 -0
  12. package/src/ingestion/record-container.ts +23 -1
  13. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  14. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  15. package/src/ingestion/source-availability/directory.ts +402 -0
  16. package/src/ingestion/source-availability/index.ts +74 -0
  17. package/src/ingestion/source-availability/readers.ts +360 -0
  18. package/src/ingestion/source-availability/resolve.ts +28 -0
  19. package/src/ingestion/source-availability/types.ts +170 -0
  20. package/src/ingestion/sync.ts +565 -108
  21. package/src/ingestion/types.ts +45 -3
  22. package/src/ingestion/walker.ts +263 -5
  23. package/src/serve/public/globals.built.css +1 -1
  24. package/src/serve/watch-reconciliation-fallback-disk.ts +359 -0
  25. package/src/serve/watch-reconciliation-fallback.ts +434 -0
  26. package/src/serve/watch-reconciliation-shared.ts +348 -0
  27. package/src/serve/watch-reconciliation.ts +129 -0
  28. package/src/serve/watch-service-events.ts +261 -0
  29. package/src/serve/watch-service-flush-generation.ts +140 -0
  30. package/src/serve/watch-service-flush-helpers.ts +147 -0
  31. package/src/serve/watch-service-flush.ts +443 -0
  32. package/src/serve/watch-service-hosts.ts +109 -0
  33. package/src/serve/watch-service-lifecycle.ts +221 -0
  34. package/src/serve/watch-service-run-flush.ts +236 -0
  35. package/src/serve/watch-service-snapshot.ts +125 -0
  36. package/src/serve/watch-service-state.ts +146 -0
  37. package/src/serve/watch-service.ts +266 -306
  38. package/src/serve/watch-snapshot-availability.ts +51 -0
  39. package/src/serve/watch-snapshot-handles.ts +365 -0
  40. package/src/serve/watch-snapshot-libc.ts +510 -0
  41. package/src/serve/watch-snapshot-ops.ts +541 -0
  42. package/src/serve/watch-snapshot-resolve.ts +246 -0
  43. package/src/serve/watch-snapshot-scan.ts +300 -0
  44. package/src/serve/watch-snapshot-types.ts +392 -0
  45. package/src/serve/watch-snapshot.ts +51 -0
  46. package/src/store/index.ts +1 -1
  47. package/src/store/sqlite/adapter.ts +191 -0
  48. package/src/store/types.ts +66 -0
  49. package/browser-extension/artifacts/gno-browser-clipper-v1.34.5.zip.sha256 +0 -1
@@ -69,6 +69,19 @@ import {
69
69
  } from "./frontmatter";
70
70
  import { buildLineOffsets } from "./position";
71
71
  import { processRecordContainer } from "./record-container";
72
+ import {
73
+ createDirectoryAvailability,
74
+ createSourceContentReader,
75
+ findUnprovenAvailabilityPrefix,
76
+ isSourceAvailabilitySkip,
77
+ isUnprovenAbsenceCode,
78
+ memoizeDirectoryAvailability,
79
+ relPathUnderAnyPrefix,
80
+ resolveSourceAvailability,
81
+ type DirectoryAvailabilityPort,
82
+ type SourceContentReaderPort,
83
+ type SourceReadFailure,
84
+ } from "./source-availability";
72
85
  import { getExcludedRanges } from "./strip";
73
86
  import { collectionToWalkConfig, DEFAULT_CHUNK_PARAMS } from "./types";
74
87
  import { defaultWalker } from "./walker";
@@ -616,6 +629,31 @@ class Semaphore {
616
629
  }
617
630
  }
618
631
 
632
+ function summarizePathResults(
633
+ collection: string,
634
+ results: FileSyncResult[],
635
+ markedInactive: number,
636
+ startedAt: number,
637
+ errors: CollectionSyncResult["errors"]
638
+ ): CollectionSyncResult {
639
+ return {
640
+ collection,
641
+ filesProcessed: results.length,
642
+ filesAdded: results.filter((result) => result.status === "added").length,
643
+ filesUpdated: results.filter((result) => result.status === "updated")
644
+ .length,
645
+ filesUnchanged: results.filter((result) => result.status === "unchanged")
646
+ .length,
647
+ filesErrored: results.filter((result) => result.status === "error").length,
648
+ filesSkipped: results.filter((result) => result.status === "skipped")
649
+ .length,
650
+ filesMarkedInactive: markedInactive,
651
+ durationMs: Date.now() - startedAt,
652
+ files: results,
653
+ errors,
654
+ };
655
+ }
656
+
619
657
  /**
620
658
  * Sync service implementation.
621
659
  */
@@ -624,17 +662,32 @@ export class SyncService {
624
662
  private readonly chunker: ChunkerPort;
625
663
  private readonly mimeDetector: MimeDetector;
626
664
  private readonly pipeline: ConversionPipeline;
665
+ private readonly sourceReaderFactory: (
666
+ mode: "any" | "local"
667
+ ) => SourceContentReaderPort;
668
+ private readonly directoryAvailabilityFactory: (
669
+ mode: "any" | "local"
670
+ ) => DirectoryAvailabilityPort;
627
671
 
628
672
  constructor(
629
673
  walker?: WalkerPort,
630
674
  chunker?: ChunkerPort,
631
675
  mimeDetector?: MimeDetector,
632
- pipeline?: ConversionPipeline
676
+ pipeline?: ConversionPipeline,
677
+ sourceReaderFactory?: (mode: "any" | "local") => SourceContentReaderPort,
678
+ directoryAvailabilityFactory?: (
679
+ mode: "any" | "local"
680
+ ) => DirectoryAvailabilityPort
633
681
  ) {
634
682
  this.walker = walker ?? defaultWalker;
635
683
  this.chunker = chunker ?? defaultChunker;
636
684
  this.mimeDetector = mimeDetector ?? getDefaultMimeDetector();
637
685
  this.pipeline = pipeline ?? getDefaultPipeline();
686
+ this.sourceReaderFactory =
687
+ sourceReaderFactory ?? ((mode) => createSourceContentReader(mode));
688
+ this.directoryAvailabilityFactory =
689
+ directoryAvailabilityFactory ??
690
+ ((mode) => createDirectoryAvailability(mode));
638
691
  }
639
692
 
640
693
  private async selectRecordAdapter(
@@ -741,6 +794,28 @@ export class SyncService {
741
794
  const contentTypeRulesFingerprint =
742
795
  options.contentTypeRulesFingerprint ??
743
796
  fingerprintContentTypeMetadataRules(contentTypeRules);
797
+
798
+ // 2. Local mode performs one guarded content-boundary read. `any` keeps
799
+ // the legacy record-stream and sniff/read paths byte-for-byte unchanged.
800
+ // No availability syscall is added during discovery.
801
+ const availabilityMode = resolveSourceAvailability(collection, options);
802
+ let guardedBytes: Uint8Array | undefined;
803
+ if (availabilityMode === "local") {
804
+ const sourceRead = await this.sourceReaderFactory("local").readAll(
805
+ entry.absPath,
806
+ sourceSize
807
+ );
808
+ if (!sourceRead.ok) {
809
+ return await this.finishSourceAvailabilityFailure(
810
+ collection,
811
+ entry,
812
+ store,
813
+ sourceRead
814
+ );
815
+ }
816
+ guardedBytes = sourceRead.bytes;
817
+ }
818
+
744
819
  if (recordAdapter) {
745
820
  return await processRecordContainer({
746
821
  adapter: recordAdapter,
@@ -757,13 +832,16 @@ export class SyncService {
757
832
  sourceCtime,
758
833
  sourceMtime,
759
834
  sourceSize,
835
+ sourceBytes: guardedBytes,
760
836
  store,
761
837
  });
762
838
  }
763
839
 
764
- const sniffBytes = new Uint8Array(
765
- await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
766
- );
840
+ const sniffBytes = guardedBytes
841
+ ? guardedBytes.subarray(0, Math.min(512, guardedBytes.byteLength))
842
+ : new Uint8Array(
843
+ await Bun.file(entry.absPath).slice(0, 512).arrayBuffer()
844
+ );
767
845
  const mime = this.mimeDetector.detect(entry.relPath, sniffBytes);
768
846
 
769
847
  const priorRecordDocuments = mustOk(
@@ -772,10 +850,9 @@ export class SyncService {
772
850
  { collection: collection.name, relPath: entry.relPath }
773
851
  );
774
852
 
775
- // 2. Read byte-oriented files only after record-adapter selection.
776
- const bytes = await Bun.file(entry.absPath).bytes();
853
+ const bytes = guardedBytes ?? (await Bun.file(entry.absPath).bytes());
777
854
 
778
- // 3. Compute sourceHash
855
+ // 3. Compute sourceHash from source bytes. Local mode cannot reopen.
779
856
  const hasher = new Bun.CryptoHasher("sha256");
780
857
  hasher.update(bytes);
781
858
  const sourceHash = hasher.digest("hex");
@@ -1127,6 +1204,38 @@ export class SyncService {
1127
1204
  }
1128
1205
  }
1129
1206
 
1207
+ /**
1208
+ * Translate guarded-read failures into skip (cloud placeholder/partial) or
1209
+ * fail-closed error outcomes. Never surfaces materialization refusal as a
1210
+ * conversion error.
1211
+ */
1212
+ private async finishSourceAvailabilityFailure(
1213
+ collection: Collection,
1214
+ entry: WalkEntry,
1215
+ store: StorePort,
1216
+ failure: SourceReadFailure
1217
+ ): Promise<FileSyncResult> {
1218
+ const status = isSourceAvailabilitySkip(failure.code) ? "skipped" : "error";
1219
+ await store
1220
+ .recordError({
1221
+ collection: collection.name,
1222
+ relPath: entry.relPath,
1223
+ code: failure.code,
1224
+ message: failure.message,
1225
+ details:
1226
+ failure.errno === undefined || failure.errno === null
1227
+ ? undefined
1228
+ : { errno: failure.errno },
1229
+ })
1230
+ .catch(() => undefined);
1231
+ return {
1232
+ relPath: entry.relPath,
1233
+ status,
1234
+ errorCode: failure.code,
1235
+ errorMessage: failure.message,
1236
+ };
1237
+ }
1238
+
1130
1239
  private async readPreviousStructure(
1131
1240
  store: StorePort,
1132
1241
  existing: DocumentRow | null
@@ -1161,6 +1270,54 @@ export class SyncService {
1161
1270
  return result.files ?? [];
1162
1271
  }
1163
1272
 
1273
+ /**
1274
+ * Inactivate proven-absent index sources without requiring disk absence.
1275
+ * Used when classification proves a path is no longer an eligible file
1276
+ * source even if a directory/FIFO/device now occupies the path.
1277
+ * Preserves record-container fan-out and typed-edge projection.
1278
+ */
1279
+ async inactivateAbsentSources(
1280
+ collection: Collection,
1281
+ store: StorePort,
1282
+ relPaths: string[],
1283
+ options: SyncOptions = {}
1284
+ ): Promise<CollectionSyncResult> {
1285
+ const startedAt = Date.now();
1286
+ const syncOptions: SyncOptions = {
1287
+ ...options,
1288
+ contentTypeRules: options.contentTypeRules ?? [],
1289
+ contentTypeRulesFingerprint:
1290
+ options.contentTypeRulesFingerprint ??
1291
+ fingerprintContentTypeMetadataRules(options.contentTypeRules ?? []),
1292
+ };
1293
+ const results: FileSyncResult[] = [];
1294
+ const projectionSourceIds = new Set<number>();
1295
+ let markedInactive = 0;
1296
+
1297
+ for (const relPath of relPaths) {
1298
+ const outcome = await this.inactivateOneAbsentSource(
1299
+ collection,
1300
+ store,
1301
+ relPath,
1302
+ projectionSourceIds
1303
+ );
1304
+ results.push(outcome.result);
1305
+ markedInactive += outcome.markedInactive;
1306
+ }
1307
+
1308
+ const errors =
1309
+ syncOptions.projectTypedEdges === false
1310
+ ? []
1311
+ : await this.projectTypedEdges(store, syncOptions, projectionSourceIds);
1312
+ return summarizePathResults(
1313
+ collection.name,
1314
+ results,
1315
+ markedInactive,
1316
+ startedAt,
1317
+ errors
1318
+ );
1319
+ }
1320
+
1164
1321
  async syncPaths(
1165
1322
  collection: Collection,
1166
1323
  store: StorePort,
@@ -1178,6 +1335,10 @@ export class SyncService {
1178
1335
  const results: FileSyncResult[] = [];
1179
1336
  const projectionSourceIds = new Set<number>();
1180
1337
  let markedInactive = 0;
1338
+ const availabilityMode = resolveSourceAvailability(collection, syncOptions);
1339
+ const directoryAvailability = memoizeDirectoryAvailability(
1340
+ this.directoryAvailabilityFactory(availabilityMode)
1341
+ );
1181
1342
 
1182
1343
  for (const relPath of relPaths) {
1183
1344
  const recordDocumentsResult = await store.listRecordDocuments(
@@ -1195,20 +1356,79 @@ export class SyncService {
1195
1356
  }
1196
1357
  const recordDocuments = recordDocumentsResult.value;
1197
1358
  const existingResult = await store.getDocument(collection.name, relPath);
1198
- const existingDoc = existingResult.ok ? existingResult.value : null;
1359
+ if (!existingResult.ok) {
1360
+ results.push({
1361
+ relPath,
1362
+ status: "error",
1363
+ errorCode: existingResult.error.code,
1364
+ errorMessage: existingResult.error.message,
1365
+ });
1366
+ continue;
1367
+ }
1368
+ const existingDoc = existingResult.value;
1199
1369
  if (existingDoc) {
1200
- await this.collectProjectionSourceIds(
1370
+ const collectError = await this.collectProjectionSourceIds(
1201
1371
  store,
1202
1372
  existingDoc.id,
1203
1373
  projectionSourceIds
1204
1374
  );
1375
+ if (collectError) {
1376
+ results.push({
1377
+ relPath,
1378
+ status: "error",
1379
+ errorCode: collectError.code,
1380
+ errorMessage: collectError.message,
1381
+ });
1382
+ continue;
1383
+ }
1205
1384
  }
1385
+ let recordCollectFailed: FileSyncResult | null = null;
1206
1386
  for (const recordDocument of recordDocuments) {
1207
- await this.collectProjectionSourceIds(
1387
+ const collectError = await this.collectProjectionSourceIds(
1208
1388
  store,
1209
1389
  recordDocument.id,
1210
1390
  projectionSourceIds
1211
1391
  );
1392
+ if (collectError) {
1393
+ recordCollectFailed = {
1394
+ relPath,
1395
+ status: "error",
1396
+ errorCode: collectError.code,
1397
+ errorMessage: collectError.message,
1398
+ };
1399
+ break;
1400
+ }
1401
+ }
1402
+ if (recordCollectFailed) {
1403
+ results.push(recordCollectFailed);
1404
+ continue;
1405
+ }
1406
+
1407
+ // Direct syncPaths must not bypass directory-availability guards.
1408
+ const unprovenPrefix = await findUnprovenAvailabilityPrefix(
1409
+ collection.path,
1410
+ relPath,
1411
+ directoryAvailability
1412
+ );
1413
+ if (unprovenPrefix) {
1414
+ const status = isSourceAvailabilitySkip(unprovenPrefix.code)
1415
+ ? "skipped"
1416
+ : "error";
1417
+ await store
1418
+ .recordError({
1419
+ collection: collection.name,
1420
+ relPath: unprovenPrefix.relPath || relPath,
1421
+ code: unprovenPrefix.code,
1422
+ message: unprovenPrefix.message,
1423
+ })
1424
+ .catch(() => undefined);
1425
+ results.push({
1426
+ relPath,
1427
+ status,
1428
+ errorCode: unprovenPrefix.code,
1429
+ errorMessage: unprovenPrefix.message,
1430
+ });
1431
+ continue;
1212
1432
  }
1213
1433
 
1214
1434
  const absPath = join(collection.path, relPath);
@@ -1230,39 +1450,15 @@ export class SyncService {
1230
1450
  });
1231
1451
  continue;
1232
1452
  }
1233
- const activePaths = [
1234
- ...(existingDoc?.active ? [relPath] : []),
1235
- ...recordDocuments
1236
- .filter((document) => document.active)
1237
- .map((document) => document.relPath),
1238
- ];
1239
- if (activePaths.length > 0) {
1240
- const inactiveResult = await store.markInactive(
1241
- collection.name,
1242
- activePaths
1243
- );
1244
- if (!inactiveResult.ok) {
1245
- results.push({
1246
- relPath,
1247
- status: "error",
1248
- errorCode: inactiveResult.error.code,
1249
- errorMessage: inactiveResult.error.message,
1250
- });
1251
- continue;
1252
- }
1253
- markedInactive += inactiveResult.value;
1254
- results.push({
1255
- relPath,
1256
- status: "updated",
1257
- docid: existingDoc?.docid,
1258
- });
1259
- continue;
1260
- }
1261
- results.push({
1453
+ const inactive = await this.inactivateOneAbsentSource(
1454
+ collection,
1455
+ store,
1262
1456
  relPath,
1263
- status: existingDoc ? "unchanged" : "skipped",
1264
- docid: existingDoc?.docid,
1265
- });
1457
+ projectionSourceIds,
1458
+ { existingDoc, recordDocuments }
1459
+ );
1460
+ results.push(inactive.result);
1461
+ markedInactive += inactive.markedInactive;
1266
1462
  continue;
1267
1463
  }
1268
1464
 
@@ -1322,28 +1518,67 @@ export class SyncService {
1322
1518
  syncOptions
1323
1519
  );
1324
1520
  results.push(result);
1521
+ if (result.status === "error" || result.status === "skipped") {
1522
+ continue;
1523
+ }
1524
+ // Post-write projection identity must succeed so typed-edge cleanup can run.
1325
1525
  const currentResult = await store.getDocument(collection.name, relPath);
1326
- const currentDoc = currentResult.ok ? currentResult.value : null;
1526
+ if (!currentResult.ok) {
1527
+ results[results.length - 1] = {
1528
+ relPath,
1529
+ status: "error",
1530
+ errorCode: currentResult.error.code,
1531
+ errorMessage: currentResult.error.message,
1532
+ };
1533
+ continue;
1534
+ }
1535
+ const currentDoc = currentResult.value;
1327
1536
  if (currentDoc?.active) {
1328
- await this.collectProjectionSourceIds(
1537
+ const collectError = await this.collectProjectionSourceIds(
1329
1538
  store,
1330
1539
  currentDoc.id,
1331
1540
  projectionSourceIds
1332
1541
  );
1542
+ if (collectError) {
1543
+ results[results.length - 1] = {
1544
+ relPath,
1545
+ status: "error",
1546
+ errorCode: collectError.code,
1547
+ errorMessage: collectError.message,
1548
+ };
1549
+ continue;
1550
+ }
1333
1551
  }
1334
1552
  const currentDocuments = await store.listRecordDocuments(
1335
1553
  collection.name,
1336
1554
  relPath
1337
1555
  );
1338
- if (currentDocuments.ok) {
1339
- for (const recordDocument of currentDocuments.value) {
1340
- if (recordDocument.active) {
1341
- await this.collectProjectionSourceIds(
1342
- store,
1343
- recordDocument.id,
1344
- projectionSourceIds
1345
- );
1346
- }
1556
+ if (!currentDocuments.ok) {
1557
+ results[results.length - 1] = {
1558
+ relPath,
1559
+ status: "error",
1560
+ errorCode: currentDocuments.error.code,
1561
+ errorMessage: currentDocuments.error.message,
1562
+ };
1563
+ continue;
1564
+ }
1565
+ for (const recordDocument of currentDocuments.value) {
1566
+ if (!recordDocument.active) {
1567
+ continue;
1568
+ }
1569
+ const collectError = await this.collectProjectionSourceIds(
1570
+ store,
1571
+ recordDocument.id,
1572
+ projectionSourceIds
1573
+ );
1574
+ if (collectError) {
1575
+ results[results.length - 1] = {
1576
+ relPath,
1577
+ status: "error",
1578
+ errorCode: collectError.code,
1579
+ errorMessage: collectError.message,
1580
+ };
1581
+ break;
1347
1582
  }
1348
1583
  }
1349
1584
  }
@@ -1352,55 +1587,184 @@ export class SyncService {
1352
1587
  syncOptions.projectTypedEdges === false
1353
1588
  ? []
1354
1589
  : await this.projectTypedEdges(store, syncOptions, projectionSourceIds);
1355
- const added = results.filter((result) => result.status === "added").length;
1356
- const updated = results.filter(
1357
- (result) => result.status === "updated"
1358
- ).length;
1359
- const unchanged = results.filter(
1360
- (result) => result.status === "unchanged"
1361
- ).length;
1362
- const errored = results.filter(
1363
- (result) => result.status === "error"
1364
- ).length;
1365
- const skipped = results.filter(
1366
- (result) => result.status === "skipped"
1367
- ).length;
1590
+ return summarizePathResults(
1591
+ collection.name,
1592
+ results,
1593
+ markedInactive,
1594
+ startedAt,
1595
+ errors
1596
+ );
1597
+ }
1598
+
1599
+ /**
1600
+ * Soft-delete active docs/record-container children for a proven-absent source.
1601
+ * Does not inspect disk — callers prove absence of an indexable file source.
1602
+ * Store lookup / backlink / mark failures surface as per-file errors (never
1603
+ * silent null/skip) so callers cannot advance snapshot authority.
1604
+ */
1605
+ private async inactivateOneAbsentSource(
1606
+ collection: Collection,
1607
+ store: StorePort,
1608
+ relPath: string,
1609
+ projectionSourceIds: Set<number>,
1610
+ preloaded?: {
1611
+ existingDoc: { id: number; active: boolean; docid?: string } | null;
1612
+ recordDocuments: Array<{ id: number; active: boolean; relPath: string }>;
1613
+ }
1614
+ ): Promise<{ result: FileSyncResult; markedInactive: number }> {
1615
+ let existingDoc = preloaded?.existingDoc ?? null;
1616
+ let recordDocuments = preloaded?.recordDocuments;
1368
1617
 
1618
+ if (!preloaded) {
1619
+ const recordDocumentsResult = await store.listRecordDocuments(
1620
+ collection.name,
1621
+ relPath
1622
+ );
1623
+ if (!recordDocumentsResult.ok) {
1624
+ return {
1625
+ result: {
1626
+ relPath,
1627
+ status: "error",
1628
+ errorCode: recordDocumentsResult.error.code,
1629
+ errorMessage: recordDocumentsResult.error.message,
1630
+ },
1631
+ markedInactive: 0,
1632
+ };
1633
+ }
1634
+ recordDocuments = recordDocumentsResult.value;
1635
+ const existingResult = await store.getDocument(collection.name, relPath);
1636
+ if (!existingResult.ok) {
1637
+ // ok:false is a hard per-file error — never treat as missing/skip.
1638
+ return {
1639
+ result: {
1640
+ relPath,
1641
+ status: "error",
1642
+ errorCode: existingResult.error.code,
1643
+ errorMessage: existingResult.error.message,
1644
+ },
1645
+ markedInactive: 0,
1646
+ };
1647
+ }
1648
+ existingDoc = existingResult.value;
1649
+ if (existingDoc) {
1650
+ const collectError = await this.collectProjectionSourceIds(
1651
+ store,
1652
+ existingDoc.id,
1653
+ projectionSourceIds
1654
+ );
1655
+ if (collectError) {
1656
+ return {
1657
+ result: {
1658
+ relPath,
1659
+ status: "error",
1660
+ errorCode: collectError.code,
1661
+ errorMessage: collectError.message,
1662
+ },
1663
+ markedInactive: 0,
1664
+ };
1665
+ }
1666
+ }
1667
+ for (const recordDocument of recordDocuments) {
1668
+ const collectError = await this.collectProjectionSourceIds(
1669
+ store,
1670
+ recordDocument.id,
1671
+ projectionSourceIds
1672
+ );
1673
+ if (collectError) {
1674
+ return {
1675
+ result: {
1676
+ relPath,
1677
+ status: "error",
1678
+ errorCode: collectError.code,
1679
+ errorMessage: collectError.message,
1680
+ },
1681
+ markedInactive: 0,
1682
+ };
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ const docs = recordDocuments ?? [];
1688
+ // Physical source path may own multiple logical docs (record containers).
1689
+ // Deduplicate while preserving the primary source path first.
1690
+ const activePathSet = new Set<string>();
1691
+ if (existingDoc?.active) {
1692
+ activePathSet.add(relPath);
1693
+ }
1694
+ for (const document of docs) {
1695
+ if (document.active) {
1696
+ activePathSet.add(document.relPath);
1697
+ }
1698
+ }
1699
+ const activePaths = [...activePathSet];
1700
+ if (activePaths.length > 0) {
1701
+ const inactiveResult = await store.markInactive(
1702
+ collection.name,
1703
+ activePaths
1704
+ );
1705
+ if (!inactiveResult.ok) {
1706
+ return {
1707
+ result: {
1708
+ relPath,
1709
+ status: "error",
1710
+ errorCode: inactiveResult.error.code,
1711
+ errorMessage: inactiveResult.error.message,
1712
+ },
1713
+ markedInactive: 0,
1714
+ };
1715
+ }
1716
+ return {
1717
+ result: {
1718
+ relPath,
1719
+ status: "updated",
1720
+ docid: existingDoc?.docid,
1721
+ },
1722
+ markedInactive: inactiveResult.value,
1723
+ };
1724
+ }
1369
1725
  return {
1370
- collection: collection.name,
1371
- filesProcessed: results.length,
1372
- filesAdded: added,
1373
- filesUpdated: updated,
1374
- filesUnchanged: unchanged,
1375
- filesErrored: errored,
1376
- filesSkipped: skipped,
1377
- filesMarkedInactive: markedInactive,
1378
- durationMs: Date.now() - startedAt,
1379
- files: results,
1380
- errors,
1726
+ result: {
1727
+ relPath,
1728
+ status: existingDoc ? "unchanged" : "skipped",
1729
+ docid: existingDoc?.docid,
1730
+ },
1731
+ markedInactive: 0,
1381
1732
  };
1382
1733
  }
1383
1734
 
1735
+ /**
1736
+ * Collect source doc ids that need typed-edge re-projection after a change.
1737
+ * Backlink lookup failures are fatal for the caller (not silently ignored).
1738
+ */
1384
1739
  private async collectProjectionSourceIds(
1385
1740
  store: StorePort,
1386
1741
  documentId: number,
1387
1742
  sourceIds: Set<number>
1388
- ): Promise<void> {
1743
+ ): Promise<{ code: string; message: string } | null> {
1389
1744
  sourceIds.add(documentId);
1390
1745
  const [linkBacklinks, edgeBacklinks] = await Promise.all([
1391
1746
  store.getBacklinksForDoc(documentId),
1392
1747
  store.getEdgeBacklinksForDoc(documentId),
1393
1748
  ]);
1394
- if (linkBacklinks.ok) {
1395
- for (const backlink of linkBacklinks.value) {
1396
- sourceIds.add(backlink.sourceDocId);
1397
- }
1749
+ if (!linkBacklinks.ok) {
1750
+ return {
1751
+ code: linkBacklinks.error.code,
1752
+ message: linkBacklinks.error.message,
1753
+ };
1398
1754
  }
1399
- if (edgeBacklinks.ok) {
1400
- for (const backlink of edgeBacklinks.value) {
1401
- sourceIds.add(backlink.sourceDocId);
1402
- }
1755
+ if (!edgeBacklinks.ok) {
1756
+ return {
1757
+ code: edgeBacklinks.error.code,
1758
+ message: edgeBacklinks.error.message,
1759
+ };
1760
+ }
1761
+ for (const backlink of linkBacklinks.value) {
1762
+ sourceIds.add(backlink.sourceDocId);
1403
1763
  }
1764
+ for (const backlink of edgeBacklinks.value) {
1765
+ sourceIds.add(backlink.sourceDocId);
1766
+ }
1767
+ return null;
1404
1768
  }
1405
1769
 
1406
1770
  private async projectTypedEdges(
@@ -1441,7 +1805,19 @@ export class SyncService {
1441
1805
  if (selectedSourceIds) {
1442
1806
  for (const documentId of selectedSourceIds) {
1443
1807
  if (!activeIds.has(documentId)) {
1444
- await store.setDocEdges(documentId, [], "frontmatter-relation");
1808
+ // Clear typed edges for inactivated projection sources.
1809
+ const clearResult = await store.setDocEdges(
1810
+ documentId,
1811
+ [],
1812
+ "frontmatter-relation"
1813
+ );
1814
+ if (!clearResult.ok) {
1815
+ errors.push({
1816
+ relPath: `(doc:${documentId})`,
1817
+ code: clearResult.error.code,
1818
+ message: clearResult.error.message,
1819
+ });
1820
+ }
1445
1821
  }
1446
1822
  }
1447
1823
  }
@@ -1620,23 +1996,44 @@ export class SyncService {
1620
1996
  await gitPull(collection.path);
1621
1997
  }
1622
1998
 
1623
- // 2. Walk collection
1999
+ // 2. Walk collection (local mode refuses dataless/unproven directory descent)
1624
2000
  const maxBytes = options.limits?.maxBytes ?? DEFAULT_LIMITS.maxBytes;
1625
- const walkConfig = collectionToWalkConfig(collection, maxBytes);
2001
+ const availabilityMode = resolveSourceAvailability(collection, syncOptions);
2002
+ const directoryAvailability = memoizeDirectoryAvailability(
2003
+ this.directoryAvailabilityFactory(availabilityMode)
2004
+ );
2005
+ const walkConfig = {
2006
+ ...collectionToWalkConfig(collection, maxBytes, syncOptions),
2007
+ sourceAvailability: availabilityMode,
2008
+ directoryAvailability,
2009
+ };
1626
2010
  const { entries, skipped } = await this.walker.walk(walkConfig);
1627
2011
 
1628
2012
  // Track seen paths for marking inactive
1629
2013
  // Only include TOO_LARGE files (they exist but are unprocessable)
1630
2014
  // EXCLUDED files should NOT be in seenPaths - if config changes to exclude
1631
2015
  // a previously-included file, that doc SHOULD be marked inactive
2016
+ // Unproven prefixes (dataless / availability-unknown dirs) preserve
2017
+ // previously indexed descendants — never treat them as proven deletions.
1632
2018
  const seenPaths = new Set<string>();
2019
+ const unprovenPrefixes: string[] = [];
1633
2020
  for (const skip of skipped) {
1634
2021
  if (skip.reason === "TOO_LARGE") {
1635
2022
  seenPaths.add(skip.relPath);
1636
2023
  }
2024
+ if (skip.unprovenPrefix || isUnprovenAbsenceCode(skip.reason)) {
2025
+ unprovenPrefixes.push(skip.relPath);
2026
+ }
1637
2027
  }
1638
2028
 
1639
- // 3. Record TOO_LARGE errors and track in seenPaths
2029
+ let added = 0;
2030
+ let updated = 0;
2031
+ let unchanged = 0;
2032
+ let errored = 0;
2033
+ let dynamicSkipped = 0;
2034
+ const fileResults: FileSyncResult[] = [];
2035
+
2036
+ // 3. Record TOO_LARGE / availability-prefix outcomes for receipts
1640
2037
  for (const skip of skipped) {
1641
2038
  if (skip.reason === "TOO_LARGE") {
1642
2039
  const recordResult = await store.recordError({
@@ -1658,6 +2055,35 @@ export class SyncService {
1658
2055
  code: "TOO_LARGE",
1659
2056
  message: `File size ${skip.size} exceeds limit ${maxBytes}`,
1660
2057
  });
2058
+ continue;
2059
+ }
2060
+ if (skip.unprovenPrefix || isUnprovenAbsenceCode(skip.reason)) {
2061
+ const message =
2062
+ skip.message ??
2063
+ `Directory availability refused descent (${skip.reason})`;
2064
+ await store
2065
+ .recordError({
2066
+ collection: collection.name,
2067
+ relPath: skip.relPath,
2068
+ code: skip.reason,
2069
+ message,
2070
+ })
2071
+ .catch(() => undefined);
2072
+ errors.push({
2073
+ relPath: skip.relPath,
2074
+ code: skip.reason,
2075
+ message,
2076
+ });
2077
+ const isSkip = isSourceAvailabilitySkip(skip.reason);
2078
+ fileResults.push({
2079
+ relPath: skip.relPath === "" ? "." : skip.relPath,
2080
+ status: isSkip ? "skipped" : "error",
2081
+ errorCode: skip.reason,
2082
+ errorMessage: message,
2083
+ });
2084
+ if (!isSkip) {
2085
+ errored += 1;
2086
+ }
1661
2087
  }
1662
2088
  }
1663
2089
 
@@ -1666,14 +2092,6 @@ export class SyncService {
1666
2092
  1,
1667
2093
  Math.min(MAX_CONCURRENCY, options.concurrency ?? DEFAULT_CONCURRENCY)
1668
2094
  );
1669
-
1670
- let added = 0;
1671
- let updated = 0;
1672
- let unchanged = 0;
1673
- let errored = 0;
1674
- let dynamicSkipped = 0;
1675
- const fileResults: FileSyncResult[] = [];
1676
-
1677
2095
  if (concurrency === 1) {
1678
2096
  // Sequential processing with batched transactions (Windows perf)
1679
2097
  for (let i = 0; i < entries.length; i += TX_BATCH_SIZE) {
@@ -1798,16 +2216,33 @@ export class SyncService {
1798
2216
  }
1799
2217
  }
1800
2218
 
1801
- // 5. Mark missing files as inactive
2219
+ // 5. Mark missing files as inactive (inventory failures are hard errors).
1802
2220
  let markedInactive = 0;
2221
+ let inventoryErrored = 0;
1803
2222
  const existingDocsResult = await store.listDocuments(collection.name);
1804
- if (existingDocsResult.ok) {
2223
+ if (!existingDocsResult.ok) {
2224
+ inventoryErrored += 1;
2225
+ errors.push({
2226
+ relPath: "",
2227
+ code: existingDocsResult.error.code,
2228
+ message: existingDocsResult.error.message,
2229
+ });
2230
+ } else {
1805
2231
  const missingPaths = existingDocsResult.value
1806
- .filter(
1807
- (document) =>
1808
- document.active &&
1809
- !seenPaths.has(document.recordSourcePath ?? document.relPath)
1810
- )
2232
+ .filter((document) => {
2233
+ if (!document.active) {
2234
+ return false;
2235
+ }
2236
+ const sourcePath = document.recordSourcePath ?? document.relPath;
2237
+ if (seenPaths.has(sourcePath)) {
2238
+ return false;
2239
+ }
2240
+ // Absence under an unenumerated/unproven prefix is not proven deletion.
2241
+ if (relPathUnderAnyPrefix(sourcePath, unprovenPrefixes)) {
2242
+ return false;
2243
+ }
2244
+ return true;
2245
+ })
1811
2246
  .map((d) => d.relPath);
1812
2247
 
1813
2248
  if (missingPaths.length > 0) {
@@ -1817,6 +2252,21 @@ export class SyncService {
1817
2252
  );
1818
2253
  if (markResult.ok) {
1819
2254
  markedInactive = markResult.value;
2255
+ } else {
2256
+ inventoryErrored += missingPaths.length;
2257
+ errors.push({
2258
+ relPath: missingPaths[0] ?? "",
2259
+ code: markResult.error.code,
2260
+ message: markResult.error.message,
2261
+ });
2262
+ for (const missingPath of missingPaths) {
2263
+ fileResults.push({
2264
+ relPath: missingPath,
2265
+ status: "error",
2266
+ errorCode: markResult.error.code,
2267
+ errorMessage: markResult.error.message,
2268
+ });
2269
+ }
1820
2270
  }
1821
2271
  }
1822
2272
  }
@@ -1825,14 +2275,21 @@ export class SyncService {
1825
2275
  errors.push(...(await this.projectTypedEdges(store, syncOptions)));
1826
2276
  }
1827
2277
 
2278
+ const walkerSkippedCount = skipped.filter(
2279
+ (skip) =>
2280
+ skip.reason === "TOO_LARGE" ||
2281
+ skip.reason === "EXCLUDED" ||
2282
+ isSourceAvailabilitySkip(skip.reason)
2283
+ ).length;
2284
+
1828
2285
  return {
1829
2286
  collection: collection.name,
1830
- filesProcessed: entries.length,
2287
+ filesProcessed: entries.length + inventoryErrored,
1831
2288
  filesAdded: added,
1832
2289
  filesUpdated: updated,
1833
2290
  filesUnchanged: unchanged,
1834
- filesErrored: errored,
1835
- filesSkipped: skipped.length + dynamicSkipped,
2291
+ filesErrored: errored + inventoryErrored,
2292
+ filesSkipped: walkerSkippedCount + dynamicSkipped,
1836
2293
  filesMarkedInactive: markedInactive,
1837
2294
  durationMs: Date.now() - startTime,
1838
2295
  files: fileResults,