@gmickel/gno 1.34.5 → 1.34.6

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 (33) hide show
  1. package/README.md +10 -0
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.zip → gno-browser-clipper-v1.34.6.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +4 -1
  6. package/spec/cli.md +6 -0
  7. package/src/ingestion/sync.ts +368 -84
  8. package/src/serve/public/globals.built.css +1 -1
  9. package/src/serve/watch-reconciliation-fallback-disk.ts +220 -0
  10. package/src/serve/watch-reconciliation-fallback.ts +404 -0
  11. package/src/serve/watch-reconciliation-shared.ts +343 -0
  12. package/src/serve/watch-reconciliation.ts +122 -0
  13. package/src/serve/watch-service-events.ts +261 -0
  14. package/src/serve/watch-service-flush-generation.ts +140 -0
  15. package/src/serve/watch-service-flush-helpers.ts +147 -0
  16. package/src/serve/watch-service-flush.ts +433 -0
  17. package/src/serve/watch-service-hosts.ts +109 -0
  18. package/src/serve/watch-service-lifecycle.ts +219 -0
  19. package/src/serve/watch-service-run-flush.ts +236 -0
  20. package/src/serve/watch-service-snapshot.ts +101 -0
  21. package/src/serve/watch-service-state.ts +146 -0
  22. package/src/serve/watch-service.ts +265 -306
  23. package/src/serve/watch-snapshot-handles.ts +285 -0
  24. package/src/serve/watch-snapshot-libc.ts +391 -0
  25. package/src/serve/watch-snapshot-ops.ts +399 -0
  26. package/src/serve/watch-snapshot-resolve.ts +246 -0
  27. package/src/serve/watch-snapshot-scan.ts +297 -0
  28. package/src/serve/watch-snapshot-types.ts +350 -0
  29. package/src/serve/watch-snapshot.ts +51 -0
  30. package/src/store/index.ts +1 -1
  31. package/src/store/sqlite/adapter.ts +191 -0
  32. package/src/store/types.ts +66 -0
  33. package/browser-extension/artifacts/gno-browser-clipper-v1.34.5.zip.sha256 +0 -1
package/README.md CHANGED
@@ -332,6 +332,11 @@ gno daemon --detach # background (macOS/Linux); use --status / --stop to manag
332
332
  detached child and exits 0; `gno daemon --status` and `gno daemon --stop` give
333
333
  you lifecycle control without `nohup`, `launchd`, or `systemd` units.
334
334
 
335
+ Exact eligible file events always go through content hashing. Atomic-save temp
336
+ files, directory events, missing filenames, and recursive deletions are
337
+ reconciled from bounded filesystem/index evidence, so final files appear and
338
+ proven removals disappear without routinely resyncing untouched siblings.
339
+
335
340
  See also: [docs/DAEMON.md](./docs/DAEMON.md)
336
341
 
337
342
  ### Connect to AI Agents
@@ -396,6 +401,11 @@ headless. `--detach` / `--status` / `--stop` give you symmetric lifecycle
396
401
  controls so you don't need `nohup`, `launchd`, or `systemd` units. The same
397
402
  flag set is available on `gno serve`.
398
403
 
404
+ The shared watcher preserves exact content-hash decisions while reconciling
405
+ ambiguous atomic replacements and directory changes with bounded work and
406
+ durable retries. Tested guarantees cover supported local filesystems; network,
407
+ removable, and coarse-timestamp filesystems are not universally claimed.
408
+
399
409
  [Daemon guide →](https://gno.sh/features/daemon-mode)
400
410
 
401
411
  ---
@@ -0,0 +1 @@
1
+ 76e1e560efa69f780e5e61130008e863cf8288a8ffe5cd82e16b48608c6c8578 gno-browser-clipper-v1.34.6.zip
@@ -21,5 +21,5 @@
21
21
  "content_security_policy": {
22
22
  "extension_pages": "script-src 'self'; object-src 'none'; connect-src http://127.0.0.1:*"
23
23
  },
24
- "version": "1.34.5"
24
+ "version": "1.34.6"
25
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.34.5",
3
+ "version": "1.34.6",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -82,6 +82,9 @@
82
82
  "eval:hybrid:delta": "bun scripts/hybrid-benchmark.ts --delta",
83
83
  "bench:ast-chunking": "bun scripts/ast-chunking-benchmark.ts",
84
84
  "smoke:serve-shutdown": "bun scripts/serve-shutdown-smoke.ts",
85
+ "test:watcher:filesystem": "bun test test/serve/watch-service-filesystem.test.ts test/serve/resident-runtime.test.ts",
86
+ "bench:watcher-reconciliation": "bun scripts/watcher-reconciliation-benchmark.ts",
87
+ "smoke:watcher-reconciliation": "bun scripts/watcher-reconciliation-smoke.ts",
85
88
  "bench:code-embeddings": "bun scripts/code-embedding-benchmark.ts",
86
89
  "bench:code-embeddings:write": "bun scripts/code-embedding-benchmark.ts --write",
87
90
  "bench:general-embeddings": "bun scripts/general-embedding-benchmark.ts",
package/spec/cli.md CHANGED
@@ -3369,6 +3369,12 @@ is blocked.
3369
3369
  - Opens DB once at startup
3370
3370
  - Loads config and requires at least one configured collection
3371
3371
  - Starts the same watcher + embed scheduler used by `gno serve`
3372
+ - Exact contained eligible paths always use targeted content-hash sync.
3373
+ Ambiguous temp/directory/missing-name/recursive-delete events use bounded,
3374
+ failure-safe filesystem/index reconciliation with durable retry.
3375
+ - Bounded overflow or unavailable native anchored handles escalates to full
3376
+ collection sync. This does not claim universal network/removable filesystem
3377
+ watcher behavior.
3372
3378
  - Runs an initial sync by default
3373
3379
  - Triggers embedding after initial sync completes
3374
3380
  - Runs in the foreground until `SIGINT` / `SIGTERM`
@@ -616,6 +616,31 @@ class Semaphore {
616
616
  }
617
617
  }
618
618
 
619
+ function summarizePathResults(
620
+ collection: string,
621
+ results: FileSyncResult[],
622
+ markedInactive: number,
623
+ startedAt: number,
624
+ errors: CollectionSyncResult["errors"]
625
+ ): CollectionSyncResult {
626
+ return {
627
+ collection,
628
+ filesProcessed: results.length,
629
+ filesAdded: results.filter((result) => result.status === "added").length,
630
+ filesUpdated: results.filter((result) => result.status === "updated")
631
+ .length,
632
+ filesUnchanged: results.filter((result) => result.status === "unchanged")
633
+ .length,
634
+ filesErrored: results.filter((result) => result.status === "error").length,
635
+ filesSkipped: results.filter((result) => result.status === "skipped")
636
+ .length,
637
+ filesMarkedInactive: markedInactive,
638
+ durationMs: Date.now() - startedAt,
639
+ files: results,
640
+ errors,
641
+ };
642
+ }
643
+
619
644
  /**
620
645
  * Sync service implementation.
621
646
  */
@@ -1161,6 +1186,54 @@ export class SyncService {
1161
1186
  return result.files ?? [];
1162
1187
  }
1163
1188
 
1189
+ /**
1190
+ * Inactivate proven-absent index sources without requiring disk absence.
1191
+ * Used when classification proves a path is no longer an eligible file
1192
+ * source even if a directory/FIFO/device now occupies the path.
1193
+ * Preserves record-container fan-out and typed-edge projection.
1194
+ */
1195
+ async inactivateAbsentSources(
1196
+ collection: Collection,
1197
+ store: StorePort,
1198
+ relPaths: string[],
1199
+ options: SyncOptions = {}
1200
+ ): Promise<CollectionSyncResult> {
1201
+ const startedAt = Date.now();
1202
+ const syncOptions: SyncOptions = {
1203
+ ...options,
1204
+ contentTypeRules: options.contentTypeRules ?? [],
1205
+ contentTypeRulesFingerprint:
1206
+ options.contentTypeRulesFingerprint ??
1207
+ fingerprintContentTypeMetadataRules(options.contentTypeRules ?? []),
1208
+ };
1209
+ const results: FileSyncResult[] = [];
1210
+ const projectionSourceIds = new Set<number>();
1211
+ let markedInactive = 0;
1212
+
1213
+ for (const relPath of relPaths) {
1214
+ const outcome = await this.inactivateOneAbsentSource(
1215
+ collection,
1216
+ store,
1217
+ relPath,
1218
+ projectionSourceIds
1219
+ );
1220
+ results.push(outcome.result);
1221
+ markedInactive += outcome.markedInactive;
1222
+ }
1223
+
1224
+ const errors =
1225
+ syncOptions.projectTypedEdges === false
1226
+ ? []
1227
+ : await this.projectTypedEdges(store, syncOptions, projectionSourceIds);
1228
+ return summarizePathResults(
1229
+ collection.name,
1230
+ results,
1231
+ markedInactive,
1232
+ startedAt,
1233
+ errors
1234
+ );
1235
+ }
1236
+
1164
1237
  async syncPaths(
1165
1238
  collection: Collection,
1166
1239
  store: StorePort,
@@ -1195,20 +1268,52 @@ export class SyncService {
1195
1268
  }
1196
1269
  const recordDocuments = recordDocumentsResult.value;
1197
1270
  const existingResult = await store.getDocument(collection.name, relPath);
1198
- const existingDoc = existingResult.ok ? existingResult.value : null;
1271
+ if (!existingResult.ok) {
1272
+ results.push({
1273
+ relPath,
1274
+ status: "error",
1275
+ errorCode: existingResult.error.code,
1276
+ errorMessage: existingResult.error.message,
1277
+ });
1278
+ continue;
1279
+ }
1280
+ const existingDoc = existingResult.value;
1199
1281
  if (existingDoc) {
1200
- await this.collectProjectionSourceIds(
1282
+ const collectError = await this.collectProjectionSourceIds(
1201
1283
  store,
1202
1284
  existingDoc.id,
1203
1285
  projectionSourceIds
1204
1286
  );
1287
+ if (collectError) {
1288
+ results.push({
1289
+ relPath,
1290
+ status: "error",
1291
+ errorCode: collectError.code,
1292
+ errorMessage: collectError.message,
1293
+ });
1294
+ continue;
1295
+ }
1205
1296
  }
1297
+ let recordCollectFailed: FileSyncResult | null = null;
1206
1298
  for (const recordDocument of recordDocuments) {
1207
- await this.collectProjectionSourceIds(
1299
+ const collectError = await this.collectProjectionSourceIds(
1208
1300
  store,
1209
1301
  recordDocument.id,
1210
1302
  projectionSourceIds
1211
1303
  );
1304
+ if (collectError) {
1305
+ recordCollectFailed = {
1306
+ relPath,
1307
+ status: "error",
1308
+ errorCode: collectError.code,
1309
+ errorMessage: collectError.message,
1310
+ };
1311
+ break;
1312
+ }
1313
+ }
1314
+ if (recordCollectFailed) {
1315
+ results.push(recordCollectFailed);
1316
+ continue;
1212
1317
  }
1213
1318
 
1214
1319
  const absPath = join(collection.path, relPath);
@@ -1230,39 +1335,15 @@ export class SyncService {
1230
1335
  });
1231
1336
  continue;
1232
1337
  }
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({
1338
+ const inactive = await this.inactivateOneAbsentSource(
1339
+ collection,
1340
+ store,
1262
1341
  relPath,
1263
- status: existingDoc ? "unchanged" : "skipped",
1264
- docid: existingDoc?.docid,
1265
- });
1342
+ projectionSourceIds,
1343
+ { existingDoc, recordDocuments }
1344
+ );
1345
+ results.push(inactive.result);
1346
+ markedInactive += inactive.markedInactive;
1266
1347
  continue;
1267
1348
  }
1268
1349
 
@@ -1322,28 +1403,67 @@ export class SyncService {
1322
1403
  syncOptions
1323
1404
  );
1324
1405
  results.push(result);
1406
+ if (result.status === "error" || result.status === "skipped") {
1407
+ continue;
1408
+ }
1409
+ // Post-write projection identity must succeed so typed-edge cleanup can run.
1325
1410
  const currentResult = await store.getDocument(collection.name, relPath);
1326
- const currentDoc = currentResult.ok ? currentResult.value : null;
1411
+ if (!currentResult.ok) {
1412
+ results[results.length - 1] = {
1413
+ relPath,
1414
+ status: "error",
1415
+ errorCode: currentResult.error.code,
1416
+ errorMessage: currentResult.error.message,
1417
+ };
1418
+ continue;
1419
+ }
1420
+ const currentDoc = currentResult.value;
1327
1421
  if (currentDoc?.active) {
1328
- await this.collectProjectionSourceIds(
1422
+ const collectError = await this.collectProjectionSourceIds(
1329
1423
  store,
1330
1424
  currentDoc.id,
1331
1425
  projectionSourceIds
1332
1426
  );
1427
+ if (collectError) {
1428
+ results[results.length - 1] = {
1429
+ relPath,
1430
+ status: "error",
1431
+ errorCode: collectError.code,
1432
+ errorMessage: collectError.message,
1433
+ };
1434
+ continue;
1435
+ }
1333
1436
  }
1334
1437
  const currentDocuments = await store.listRecordDocuments(
1335
1438
  collection.name,
1336
1439
  relPath
1337
1440
  );
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
- }
1441
+ if (!currentDocuments.ok) {
1442
+ results[results.length - 1] = {
1443
+ relPath,
1444
+ status: "error",
1445
+ errorCode: currentDocuments.error.code,
1446
+ errorMessage: currentDocuments.error.message,
1447
+ };
1448
+ continue;
1449
+ }
1450
+ for (const recordDocument of currentDocuments.value) {
1451
+ if (!recordDocument.active) {
1452
+ continue;
1453
+ }
1454
+ const collectError = await this.collectProjectionSourceIds(
1455
+ store,
1456
+ recordDocument.id,
1457
+ projectionSourceIds
1458
+ );
1459
+ if (collectError) {
1460
+ results[results.length - 1] = {
1461
+ relPath,
1462
+ status: "error",
1463
+ errorCode: collectError.code,
1464
+ errorMessage: collectError.message,
1465
+ };
1466
+ break;
1347
1467
  }
1348
1468
  }
1349
1469
  }
@@ -1352,55 +1472,184 @@ export class SyncService {
1352
1472
  syncOptions.projectTypedEdges === false
1353
1473
  ? []
1354
1474
  : 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;
1475
+ return summarizePathResults(
1476
+ collection.name,
1477
+ results,
1478
+ markedInactive,
1479
+ startedAt,
1480
+ errors
1481
+ );
1482
+ }
1483
+
1484
+ /**
1485
+ * Soft-delete active docs/record-container children for a proven-absent source.
1486
+ * Does not inspect disk — callers prove absence of an indexable file source.
1487
+ * Store lookup / backlink / mark failures surface as per-file errors (never
1488
+ * silent null/skip) so callers cannot advance snapshot authority.
1489
+ */
1490
+ private async inactivateOneAbsentSource(
1491
+ collection: Collection,
1492
+ store: StorePort,
1493
+ relPath: string,
1494
+ projectionSourceIds: Set<number>,
1495
+ preloaded?: {
1496
+ existingDoc: { id: number; active: boolean; docid?: string } | null;
1497
+ recordDocuments: Array<{ id: number; active: boolean; relPath: string }>;
1498
+ }
1499
+ ): Promise<{ result: FileSyncResult; markedInactive: number }> {
1500
+ let existingDoc = preloaded?.existingDoc ?? null;
1501
+ let recordDocuments = preloaded?.recordDocuments;
1502
+
1503
+ if (!preloaded) {
1504
+ const recordDocumentsResult = await store.listRecordDocuments(
1505
+ collection.name,
1506
+ relPath
1507
+ );
1508
+ if (!recordDocumentsResult.ok) {
1509
+ return {
1510
+ result: {
1511
+ relPath,
1512
+ status: "error",
1513
+ errorCode: recordDocumentsResult.error.code,
1514
+ errorMessage: recordDocumentsResult.error.message,
1515
+ },
1516
+ markedInactive: 0,
1517
+ };
1518
+ }
1519
+ recordDocuments = recordDocumentsResult.value;
1520
+ const existingResult = await store.getDocument(collection.name, relPath);
1521
+ if (!existingResult.ok) {
1522
+ // ok:false is a hard per-file error — never treat as missing/skip.
1523
+ return {
1524
+ result: {
1525
+ relPath,
1526
+ status: "error",
1527
+ errorCode: existingResult.error.code,
1528
+ errorMessage: existingResult.error.message,
1529
+ },
1530
+ markedInactive: 0,
1531
+ };
1532
+ }
1533
+ existingDoc = existingResult.value;
1534
+ if (existingDoc) {
1535
+ const collectError = await this.collectProjectionSourceIds(
1536
+ store,
1537
+ existingDoc.id,
1538
+ projectionSourceIds
1539
+ );
1540
+ if (collectError) {
1541
+ return {
1542
+ result: {
1543
+ relPath,
1544
+ status: "error",
1545
+ errorCode: collectError.code,
1546
+ errorMessage: collectError.message,
1547
+ },
1548
+ markedInactive: 0,
1549
+ };
1550
+ }
1551
+ }
1552
+ for (const recordDocument of recordDocuments) {
1553
+ const collectError = await this.collectProjectionSourceIds(
1554
+ store,
1555
+ recordDocument.id,
1556
+ projectionSourceIds
1557
+ );
1558
+ if (collectError) {
1559
+ return {
1560
+ result: {
1561
+ relPath,
1562
+ status: "error",
1563
+ errorCode: collectError.code,
1564
+ errorMessage: collectError.message,
1565
+ },
1566
+ markedInactive: 0,
1567
+ };
1568
+ }
1569
+ }
1570
+ }
1368
1571
 
1572
+ const docs = recordDocuments ?? [];
1573
+ // Physical source path may own multiple logical docs (record containers).
1574
+ // Deduplicate while preserving the primary source path first.
1575
+ const activePathSet = new Set<string>();
1576
+ if (existingDoc?.active) {
1577
+ activePathSet.add(relPath);
1578
+ }
1579
+ for (const document of docs) {
1580
+ if (document.active) {
1581
+ activePathSet.add(document.relPath);
1582
+ }
1583
+ }
1584
+ const activePaths = [...activePathSet];
1585
+ if (activePaths.length > 0) {
1586
+ const inactiveResult = await store.markInactive(
1587
+ collection.name,
1588
+ activePaths
1589
+ );
1590
+ if (!inactiveResult.ok) {
1591
+ return {
1592
+ result: {
1593
+ relPath,
1594
+ status: "error",
1595
+ errorCode: inactiveResult.error.code,
1596
+ errorMessage: inactiveResult.error.message,
1597
+ },
1598
+ markedInactive: 0,
1599
+ };
1600
+ }
1601
+ return {
1602
+ result: {
1603
+ relPath,
1604
+ status: "updated",
1605
+ docid: existingDoc?.docid,
1606
+ },
1607
+ markedInactive: inactiveResult.value,
1608
+ };
1609
+ }
1369
1610
  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,
1611
+ result: {
1612
+ relPath,
1613
+ status: existingDoc ? "unchanged" : "skipped",
1614
+ docid: existingDoc?.docid,
1615
+ },
1616
+ markedInactive: 0,
1381
1617
  };
1382
1618
  }
1383
1619
 
1620
+ /**
1621
+ * Collect source doc ids that need typed-edge re-projection after a change.
1622
+ * Backlink lookup failures are fatal for the caller (not silently ignored).
1623
+ */
1384
1624
  private async collectProjectionSourceIds(
1385
1625
  store: StorePort,
1386
1626
  documentId: number,
1387
1627
  sourceIds: Set<number>
1388
- ): Promise<void> {
1628
+ ): Promise<{ code: string; message: string } | null> {
1389
1629
  sourceIds.add(documentId);
1390
1630
  const [linkBacklinks, edgeBacklinks] = await Promise.all([
1391
1631
  store.getBacklinksForDoc(documentId),
1392
1632
  store.getEdgeBacklinksForDoc(documentId),
1393
1633
  ]);
1394
- if (linkBacklinks.ok) {
1395
- for (const backlink of linkBacklinks.value) {
1396
- sourceIds.add(backlink.sourceDocId);
1397
- }
1634
+ if (!linkBacklinks.ok) {
1635
+ return {
1636
+ code: linkBacklinks.error.code,
1637
+ message: linkBacklinks.error.message,
1638
+ };
1398
1639
  }
1399
- if (edgeBacklinks.ok) {
1400
- for (const backlink of edgeBacklinks.value) {
1401
- sourceIds.add(backlink.sourceDocId);
1402
- }
1640
+ if (!edgeBacklinks.ok) {
1641
+ return {
1642
+ code: edgeBacklinks.error.code,
1643
+ message: edgeBacklinks.error.message,
1644
+ };
1645
+ }
1646
+ for (const backlink of linkBacklinks.value) {
1647
+ sourceIds.add(backlink.sourceDocId);
1648
+ }
1649
+ for (const backlink of edgeBacklinks.value) {
1650
+ sourceIds.add(backlink.sourceDocId);
1403
1651
  }
1652
+ return null;
1404
1653
  }
1405
1654
 
1406
1655
  private async projectTypedEdges(
@@ -1441,7 +1690,19 @@ export class SyncService {
1441
1690
  if (selectedSourceIds) {
1442
1691
  for (const documentId of selectedSourceIds) {
1443
1692
  if (!activeIds.has(documentId)) {
1444
- await store.setDocEdges(documentId, [], "frontmatter-relation");
1693
+ // Clear typed edges for inactivated projection sources.
1694
+ const clearResult = await store.setDocEdges(
1695
+ documentId,
1696
+ [],
1697
+ "frontmatter-relation"
1698
+ );
1699
+ if (!clearResult.ok) {
1700
+ errors.push({
1701
+ relPath: `(doc:${documentId})`,
1702
+ code: clearResult.error.code,
1703
+ message: clearResult.error.message,
1704
+ });
1705
+ }
1445
1706
  }
1446
1707
  }
1447
1708
  }
@@ -1798,10 +2059,18 @@ export class SyncService {
1798
2059
  }
1799
2060
  }
1800
2061
 
1801
- // 5. Mark missing files as inactive
2062
+ // 5. Mark missing files as inactive (inventory failures are hard errors).
1802
2063
  let markedInactive = 0;
2064
+ let inventoryErrored = 0;
1803
2065
  const existingDocsResult = await store.listDocuments(collection.name);
1804
- if (existingDocsResult.ok) {
2066
+ if (!existingDocsResult.ok) {
2067
+ inventoryErrored += 1;
2068
+ errors.push({
2069
+ relPath: "",
2070
+ code: existingDocsResult.error.code,
2071
+ message: existingDocsResult.error.message,
2072
+ });
2073
+ } else {
1805
2074
  const missingPaths = existingDocsResult.value
1806
2075
  .filter(
1807
2076
  (document) =>
@@ -1817,6 +2086,21 @@ export class SyncService {
1817
2086
  );
1818
2087
  if (markResult.ok) {
1819
2088
  markedInactive = markResult.value;
2089
+ } else {
2090
+ inventoryErrored += missingPaths.length;
2091
+ errors.push({
2092
+ relPath: missingPaths[0] ?? "",
2093
+ code: markResult.error.code,
2094
+ message: markResult.error.message,
2095
+ });
2096
+ for (const missingPath of missingPaths) {
2097
+ fileResults.push({
2098
+ relPath: missingPath,
2099
+ status: "error",
2100
+ errorCode: markResult.error.code,
2101
+ errorMessage: markResult.error.message,
2102
+ });
2103
+ }
1820
2104
  }
1821
2105
  }
1822
2106
  }
@@ -1827,11 +2111,11 @@ export class SyncService {
1827
2111
 
1828
2112
  return {
1829
2113
  collection: collection.name,
1830
- filesProcessed: entries.length,
2114
+ filesProcessed: entries.length + inventoryErrored,
1831
2115
  filesAdded: added,
1832
2116
  filesUpdated: updated,
1833
2117
  filesUnchanged: unchanged,
1834
- filesErrored: errored,
2118
+ filesErrored: errored + inventoryErrored,
1835
2119
  filesSkipped: skipped.length + dynamicSkipped,
1836
2120
  filesMarkedInactive: markedInactive,
1837
2121
  durationMs: Date.now() - startTime,