synaptiq 2.0.2__tar.gz → 2.0.3__tar.gz

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 (69) hide show
  1. {synaptiq-2.0.2 → synaptiq-2.0.3}/PKG-INFO +1 -1
  2. {synaptiq-2.0.2 → synaptiq-2.0.3}/pyproject.toml +1 -1
  3. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/__init__.py +1 -1
  4. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/embeddings/lazy_worker.py +49 -9
  5. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/manifest.py +28 -3
  6. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/storage/ladybug_backend.py +123 -18
  7. {synaptiq-2.0.2 → synaptiq-2.0.3}/README.md +0 -0
  8. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/__main__.py +0 -0
  9. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/cli/__init__.py +0 -0
  10. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/cli/main.py +0 -0
  11. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/cli/update_check.py +0 -0
  12. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/config/__init__.py +0 -0
  13. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/config/ignore.py +0 -0
  14. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/config/languages.py +0 -0
  15. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/__init__.py +0 -0
  16. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/cypher_guard.py +0 -0
  17. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/daemon/__init__.py +0 -0
  18. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/daemon/lock.py +0 -0
  19. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/daemon/rwlock.py +0 -0
  20. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/daemon/socket_client.py +0 -0
  21. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/daemon/socket_server.py +0 -0
  22. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/diff.py +0 -0
  23. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/embeddings/__init__.py +0 -0
  24. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/embeddings/embedder.py +0 -0
  25. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/embeddings/text.py +0 -0
  26. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/graph/__init__.py +0 -0
  27. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/graph/graph.py +0 -0
  28. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/graph/model.py +0 -0
  29. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/__init__.py +0 -0
  30. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/calls.py +0 -0
  31. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/community.py +0 -0
  32. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/coupling.py +0 -0
  33. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/dead_code.py +0 -0
  34. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/heritage.py +0 -0
  35. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/imports.py +0 -0
  36. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/incremental.py +0 -0
  37. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/incremental_build.py +0 -0
  38. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/parser_phase.py +0 -0
  39. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/pipeline.py +0 -0
  40. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/processes.py +0 -0
  41. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/rest_linking.py +0 -0
  42. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/structure.py +0 -0
  43. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/symbol_lookup.py +0 -0
  44. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/types.py +0 -0
  45. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/walker.py +0 -0
  46. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/ingestion/watcher.py +0 -0
  47. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/memory.py +0 -0
  48. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/parsers/__init__.py +0 -0
  49. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/parsers/base.py +0 -0
  50. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/parsers/go_lang.py +0 -0
  51. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/parsers/python_lang.py +0 -0
  52. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/parsers/ruby_lang.py +0 -0
  53. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/parsers/typescript.py +0 -0
  54. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/resources.py +0 -0
  55. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/search/__init__.py +0 -0
  56. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/search/hybrid.py +0 -0
  57. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/search/pagerank.py +0 -0
  58. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/storage/__init__.py +0 -0
  59. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/core/storage/base.py +0 -0
  60. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/__init__.py +0 -0
  61. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/freshness.py +0 -0
  62. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/http_transport.py +0 -0
  63. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/resources.py +0 -0
  64. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/secret_scanner.py +0 -0
  65. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/server.py +0 -0
  66. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/suggest.py +0 -0
  67. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/token_budget.py +0 -0
  68. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/mcp/tools.py +0 -0
  69. {synaptiq-2.0.2 → synaptiq-2.0.3}/src/synaptiq/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synaptiq
3
- Version: 2.0.2
3
+ Version: 2.0.3
4
4
  Summary: Graph-powered code intelligence engine — indexes codebases into a knowledge graph, exposed via MCP tools for AI agents and a CLI for developers.
5
5
  Keywords: code-intelligence,knowledge-graph,mcp,tree-sitter,static-analysis,dead-code,claude-code
6
6
  Author: Stevica Canadi
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "synaptiq"
3
- version = "2.0.2"
3
+ version = "2.0.3"
4
4
  description = "Graph-powered code intelligence engine — indexes codebases into a knowledge graph, exposed via MCP tools for AI agents and a CLI for developers."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """Synaptiq — Graph-powered code intelligence engine."""
2
2
 
3
- __version__ = "2.0.2"
3
+ __version__ = "2.0.3"
@@ -342,6 +342,7 @@ def _encode_and_store(data_dir: Path, db_path: Path, started_at: str) -> None:
342
342
  from synaptiq.core.embeddings.embedder import (
343
343
  embed_graph,
344
344
  embeddable_node_count,
345
+ partition_embeddings,
345
346
  tier_from_meta,
346
347
  )
347
348
 
@@ -369,16 +370,53 @@ def _encode_and_store(data_dir: Path, db_path: Path, started_at: str) -> None:
369
370
  return
370
371
  graph, previous = loaded
371
372
 
372
- total = embeddable_node_count(graph)
373
- if total == 0:
373
+ if embeddable_node_count(graph) == 0:
374
374
  _update_meta_embeddings(data_dir, 0, expect_anchor=anchor)
375
375
  write_state(data_dir, "complete", done=0, total=0, started_at=started_at)
376
376
  return
377
377
 
378
- write_state(data_dir, "encoding", done=0, total=total, started_at=started_at)
378
+ # Only the PENDING delta is actually encoded — embed_graph reuses every
379
+ # unchanged vector and runs the model on just the new/changed symbols. So
380
+ # report progress against the pending count, not the full embeddable-node
381
+ # count: a one-file change must read "encoding 3/12", never
382
+ # "encoding 0/26,909". partition_embeddings runs the same split
383
+ # embed_graph does but never loads the model (generate-text cost), so this
384
+ # is cheap next to the encode it precedes.
385
+ reused_vecs, pending = partition_embeddings(graph, previous, tier=tier.name)
386
+ reused = len(reused_vecs)
387
+ if pending == 0:
388
+ # Nothing new to encode (everything reused). Still store the reused
389
+ # set so meta reflects the true vector count, but there is no work to
390
+ # show progress for.
391
+ embeddings = reused_vecs
392
+ if _read_meta_timestamp(data_dir) != anchor:
393
+ continue
394
+ if _store_with_retry(db_path, embeddings):
395
+ _update_meta_embeddings(data_dir, len(embeddings), expect_anchor=anchor)
396
+ write_state(data_dir, "complete", done=0, total=0, started_at=started_at)
397
+ else:
398
+ write_state(
399
+ data_dir,
400
+ "deferred",
401
+ done=0,
402
+ total=0,
403
+ started_at=started_at,
404
+ detail="index locked; re-run `synaptiq analyze` to encode",
405
+ )
406
+ return
379
407
 
380
- def _on_progress(done: int, tot: int) -> None:
381
- write_state(data_dir, "encoding", done=done, total=tot, started_at=started_at)
408
+ write_state(data_dir, "encoding", done=0, total=pending, started_at=started_at)
409
+
410
+ def _on_progress(done: int, _full: int) -> None:
411
+ # embed_graph reports done = reused + encoded-so-far against the full
412
+ # embeddable count; re-base onto the pending delta for honest status.
413
+ write_state(
414
+ data_dir,
415
+ "encoding",
416
+ done=max(0, min(done - reused, pending)),
417
+ total=pending,
418
+ started_at=started_at,
419
+ )
382
420
 
383
421
  embeddings = embed_graph(
384
422
  graph, tier=tier.name, previous=previous, progress_callback=_on_progress
@@ -393,16 +431,18 @@ def _encode_and_store(data_dir: Path, db_path: Path, started_at: str) -> None:
393
431
  )
394
432
  continue
395
433
 
396
- # (f) Store under the single-writer lock (retry → deferred).
434
+ # (f) Store under the single-writer lock (retry → deferred). Progress is
435
+ # reported against the pending delta (see above), so a completed encode
436
+ # reads "pending/pending".
397
437
  if _store_with_retry(db_path, embeddings):
398
438
  _update_meta_embeddings(data_dir, len(embeddings), expect_anchor=anchor)
399
- write_state(data_dir, "complete", done=total, total=total, started_at=started_at)
439
+ write_state(data_dir, "complete", done=pending, total=pending, started_at=started_at)
400
440
  else:
401
441
  write_state(
402
442
  data_dir,
403
443
  "deferred",
404
- done=total,
405
- total=total,
444
+ done=pending,
445
+ total=pending,
406
446
  started_at=started_at,
407
447
  detail="index locked; re-run `synaptiq analyze` to encode",
408
448
  )
@@ -29,6 +29,7 @@ from __future__ import annotations
29
29
 
30
30
  import hashlib
31
31
  import json
32
+ import logging
32
33
  from dataclasses import dataclass, field
33
34
  from datetime import datetime, timezone
34
35
  from typing import Any
@@ -36,6 +37,8 @@ from typing import Any
36
37
  from synaptiq.core.graph.graph import KnowledgeGraph
37
38
  from synaptiq.core.graph.model import GraphNode, NodeLabel, RelType
38
39
 
40
+ logger = logging.getLogger(__name__)
41
+
39
42
  # ---------------------------------------------------------------------------
40
43
  # Versioning
41
44
  # ---------------------------------------------------------------------------
@@ -494,9 +497,25 @@ def _parse_file_manifest_row(row: list[Any]) -> FileManifest | None:
494
497
  path = row[0]
495
498
  content = row[1] or ""
496
499
  language = row[2] or ""
497
- symbol_ids = json.loads(row[3]) if row[3] else []
498
- symbol_sigs = json.loads(row[4]) if row[4] else {}
499
- raw_edges = json.loads(row[5]) if row[5] else []
500
+ # ``symbol_ids`` / ``symbol_sigs`` / ``out_edges`` are ALWAYS serialized
501
+ # as non-empty JSON (``"[]"`` / ``"{}"``; see serialize_file_manifest), so
502
+ # a NULL/empty value in any of them is not "no symbols" — it means the
503
+ # storage layer silently DROPPED the field (the ladybug bug where large
504
+ # string columns are corrupted when written into a node table that
505
+ # previously had rows DETACH DELETEd, which 2.0.3 fixes at the source).
506
+ # Treating that as empty provenance is the worst outcome: invisible
507
+ # incremental blindness. So fail LOUD — a dropped field poisons the row
508
+ # (caller -> None -> full rebuild), never silent partial provenance.
509
+ if not row[3] or not row[4] or not row[5]:
510
+ logger.warning(
511
+ "manifest row for %r has empty symbol provenance (dropped by "
512
+ "storage); treating the whole manifest as corrupt -> full rebuild",
513
+ path,
514
+ )
515
+ return None
516
+ symbol_ids = json.loads(row[3])
517
+ symbol_sigs = json.loads(row[4])
518
+ raw_edges = json.loads(row[5])
500
519
  # ``unresolved_imports`` was added after the first manifest schema; a row
501
520
  # written before it (or a NULL from CSV COPY) has fewer than 7 cols —
502
521
  # tolerate that with an empty list rather than treating it as corrupt.
@@ -600,6 +619,12 @@ def load_manifest_from_rows(
600
619
  for row in file_rows:
601
620
  fm = _parse_file_manifest_row(row)
602
621
  if fm is None:
622
+ path = row[0] if row else "<unknown>"
623
+ logger.warning(
624
+ "corrupt manifest file row (%r); manifest is untrustworthy -> "
625
+ "forcing a full rebuild",
626
+ path,
627
+ )
603
628
  return None
604
629
  files[fm.path] = fm
605
630
  return Manifest(index=index, files=files)
@@ -48,6 +48,7 @@ except ImportError: # pragma: no cover - exercised in CSV-only environments
48
48
  from synaptiq.core.graph.graph import KnowledgeGraph
49
49
  from synaptiq.core.graph.model import GraphNode, GraphRelationship, NodeLabel
50
50
  from synaptiq.core.ingestion.manifest import (
51
+ FILE_MANIFEST_COLUMNS,
51
52
  Manifest,
52
53
  build_manifest,
53
54
  load_manifest_from_rows,
@@ -2290,21 +2291,87 @@ class LadybugBackend:
2290
2291
  except Exception:
2291
2292
  logger.debug("manifest table creation skipped", exc_info=True)
2292
2293
 
2294
+ def _reset_manifest_file_table(self) -> None:
2295
+ """DROP + re-CREATE the FileManifest table for FRESH string storage.
2296
+
2297
+ The crux of the 2.0.3 manifest-persistence fix. LadybugDB silently drops
2298
+ the values of large STRING columns when rows are written into a node
2299
+ table that previously had rows removed via ``DETACH DELETE`` — verified on
2300
+ a real 3,563-file repo where the SECOND manifest write lost 533 rows /
2301
+ 5,397 symbol ids, size-correlated (the files with the most symbols first),
2302
+ *identically* through CSV COPY, in-memory Arrow COPY, and parameterized
2303
+ MERGE. The corruption lives in the table's reused string storage, not in
2304
+ any one writer; only never-deleted-from storage round-trips losslessly.
2305
+ ``bulk_load`` stamps the first manifest into a pristine ``.rebuild`` table
2306
+ (always fine); the losses were in the SECOND write onward — the
2307
+ ``stamp_full_manifest`` after coupling and every incremental
2308
+ consolidation reused the populated live table. Dropping and recreating
2309
+ gives every write a pristine table, which round-trips losslessly.
2310
+ """
2311
+ assert self._conn is not None
2312
+ try:
2313
+ self._conn.execute(f"DROP TABLE IF EXISTS {_MANIFEST_FILE_TABLE}")
2314
+ except Exception:
2315
+ logger.debug("manifest file-table drop skipped", exc_info=True)
2316
+ self._conn.execute(_FILE_MANIFEST_DDL)
2317
+
2318
+ def _reset_manifest_index_table(self) -> None:
2319
+ """DROP + re-CREATE the IndexManifest table (drops the prior singleton)."""
2320
+ assert self._conn is not None
2321
+ try:
2322
+ self._conn.execute(f"DROP TABLE IF EXISTS {_MANIFEST_INDEX_TABLE}")
2323
+ except Exception:
2324
+ logger.debug("manifest index-table drop skipped", exc_info=True)
2325
+ self._conn.execute(_INDEX_MANIFEST_DDL)
2326
+
2327
+ def _refresh_write_conn(self) -> None:
2328
+ """Recreate the write connection to drop LadybugDB's stale caches after DDL.
2329
+
2330
+ LadybugDB caches prepared statements (keyed by query string) and table
2331
+ catalog entries per connection, and does NOT invalidate them when a table
2332
+ is ``DROP``ped and re-``CREATE``d underneath. Re-running a parameterized
2333
+ statement (the index-singleton ``CREATE``, the ``CREATE`` row fallback) or
2334
+ a ``MATCH``-based query against a just-reset table on the SAME connection
2335
+ then crashes with ``unordered_map::at: key not found`` /
2336
+ ``Cannot find table catalog entry``. Manifest writes reuse one write
2337
+ connection across incremental consolidations and the daemon's lifetime, so
2338
+ after resetting the manifest tables we swap in a fresh connection whose
2339
+ caches are empty — the light, targeted analogue of what the ``bulk_load``
2340
+ swap gets for free by calling :meth:`initialize`. The DB handle is
2341
+ untouched (cheap); pending manifest DDL already auto-committed, so nothing
2342
+ is lost. ``self._prepared`` (this backend's own per-connection cache) is
2343
+ cleared for the same reason it is in :meth:`initialize`.
2344
+ """
2345
+ assert self._db is not None
2346
+ old = self._conn
2347
+ self._conn = ladybug.Connection(self._db)
2348
+ self._prepared = {}
2349
+ if old is not None:
2350
+ try:
2351
+ old.close()
2352
+ except Exception:
2353
+ logger.debug("closing prior write connection failed", exc_info=True)
2354
+
2293
2355
  def write_manifest(self, manifest: Manifest) -> None:
2294
2356
  """Persist *manifest* into the current DB, replacing any prior manifest.
2295
2357
 
2296
- Full replace: clears both manifest tables, bulk-writes the per-file rows,
2297
- then writes the index singleton **last** as a completion marker (a
2298
- partial/crashed write leaves no singleton, so :meth:`read_manifest`
2299
- reports "no manifest" and the caller does a full rebuild). Not wrapped in
2300
- an explicit transaction it mirrors the COPY-based bulk path and is
2301
- crash-safe by riding :meth:`bulk_load`'s ``.rebuild`` swap. Uses the
2358
+ Full replace via DROP + re-CREATE of both manifest tables (see
2359
+ :meth:`_reset_manifest_file_table` for why fresh storage is mandatory),
2360
+ then bulk-writes the per-file rows, then writes the index singleton
2361
+ **last** as a completion marker: resetting the index table removes the
2362
+ prior singleton, so throughout the rewrite there is no completion marker
2363
+ a partial/crashed write leaves none, :meth:`read_manifest` reports "no
2364
+ manifest", and the caller does a full rebuild. Not wrapped in an explicit
2365
+ transaction (DDL auto-commits) — crash-safety rides the singleton-last
2366
+ marker and, for the ``bulk_load`` write, the ``.rebuild`` swap. Uses the
2302
2367
  write connection, so callers serialize via the external lock.
2303
2368
  """
2304
2369
  assert self._conn is not None
2305
- self._create_manifest_tables()
2306
- self._conn.execute(f"MATCH (m:{_MANIFEST_FILE_TABLE}) DETACH DELETE m")
2307
- self._conn.execute(f"MATCH (im:{_MANIFEST_INDEX_TABLE}) DETACH DELETE im")
2370
+ self._reset_manifest_file_table()
2371
+ self._reset_manifest_index_table()
2372
+ # Drop LadybugDB's per-connection caches staled by the resets above,
2373
+ # before any cached/parameterized DML runs on this connection.
2374
+ self._refresh_write_conn()
2308
2375
  rows = [serialize_file_manifest(fm) for fm in manifest.files.values()]
2309
2376
  if rows:
2310
2377
  self._write_manifest_file_rows(rows)
@@ -2315,22 +2382,60 @@ class LadybugBackend:
2315
2382
  )
2316
2383
 
2317
2384
  def _write_manifest_file_rows(self, rows: list[list[Any]]) -> None:
2318
- """Insert FileManifest rows via CSV COPY, falling back to row MERGE.
2319
-
2320
- Mirrors the node bulk path: COPY is fast at repo scale, and the MERGE
2321
- fallback keeps the write working in COPY-unavailable environments.
2385
+ """Bulk-write FileManifest rows into the freshly-reset (empty) table.
2386
+
2387
+ The table is pristine (``write_manifest`` reset it), so this only ever
2388
+ INSERTs. Prefers the in-memory Arrow COPY (pyarrow is a core dep; native
2389
+ arbitrary-size strings, no temp file), falls back to CSV COPY, then to
2390
+ row-by-row ``CREATE`` for COPY-unavailable environments. ``CREATE`` — not
2391
+ ``MERGE`` — because LadybugDB raises a stale-catalog error on a
2392
+ MATCH-based ``MERGE`` executed against a just-DROP+CREATEd table, and a
2393
+ fresh empty table never needs upsert semantics. Each fallback first
2394
+ re-resets the file table so a COPY that failed after partial progress
2395
+ cannot leave duplicate/torn rows behind.
2322
2396
  """
2323
2397
  assert self._conn is not None
2398
+ if _HAS_PYARROW:
2399
+ try:
2400
+ self._write_manifest_file_rows_arrow(rows)
2401
+ return
2402
+ except Exception:
2403
+ logger.debug("manifest Arrow COPY failed; trying CSV COPY", exc_info=True)
2404
+ self._reset_manifest_file_table()
2405
+ self._refresh_write_conn()
2324
2406
  try:
2325
2407
  self._csv_copy(_MANIFEST_FILE_TABLE, rows)
2326
2408
  return
2327
2409
  except Exception:
2328
- logger.debug("manifest CSV COPY failed; falling back to row inserts", exc_info=True)
2410
+ logger.debug("manifest CSV COPY failed; falling back to row CREATE", exc_info=True)
2411
+ self._reset_manifest_file_table()
2412
+ self._refresh_write_conn()
2413
+ self._write_manifest_file_rows_create(rows)
2414
+
2415
+ def _write_manifest_file_rows_arrow(self, rows: list[list[Any]]) -> None:
2416
+ """COPY FileManifest rows from an in-memory pyarrow table (native strings).
2417
+
2418
+ Columns are built in :data:`FILE_MANIFEST_COLUMNS` order — contractual
2419
+ with the DDL and the read query. Values are stored verbatim (no
2420
+ empty→NULL coercion): the JSON columns are never empty, and preserving
2421
+ them exactly keeps the reader's loud empty-provenance gate meaningful.
2422
+ """
2423
+ assert self._conn is not None
2424
+ arrow_tbl = pa.table(
2425
+ {
2426
+ name: pa.array([row[i] for row in rows], type=pa.string())
2427
+ for i, name in enumerate(FILE_MANIFEST_COLUMNS)
2428
+ }
2429
+ )
2430
+ self._arrow_copy(_MANIFEST_FILE_TABLE, arrow_tbl)
2431
+
2432
+ def _write_manifest_file_rows_create(self, rows: list[list[Any]]) -> None:
2433
+ """Row-by-row ``CREATE`` fallback for COPY-unavailable environments."""
2434
+ assert self._conn is not None
2329
2435
  stmt = (
2330
- f"MERGE (m:{_MANIFEST_FILE_TABLE} {{path: $path}}) "
2331
- "SET m.content_sha = $content_sha, m.language = $language, "
2332
- "m.symbol_ids = $symbol_ids, m.symbol_sigs = $symbol_sigs, "
2333
- "m.out_edges = $out_edges, m.unresolved_imports = $unresolved_imports"
2436
+ f"CREATE (m:{_MANIFEST_FILE_TABLE} {{path: $path, content_sha: $content_sha, "
2437
+ "language: $language, symbol_ids: $symbol_ids, symbol_sigs: $symbol_sigs, "
2438
+ "out_edges: $out_edges, unresolved_imports: $unresolved_imports}})"
2334
2439
  )
2335
2440
  for row in rows:
2336
2441
  self._conn.execute(
File without changes
File without changes