cjm-capability-graph-sqlite 0.0.26__tar.gz → 0.0.29__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 (17) hide show
  1. {cjm_capability_graph_sqlite-0.0.26/cjm_capability_graph_sqlite.egg-info → cjm_capability_graph_sqlite-0.0.29}/PKG-INFO +5 -1
  2. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/README.md +4 -0
  3. cjm_capability_graph_sqlite-0.0.29/cjm_capability_graph_sqlite/__init__.py +1 -0
  4. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite/capability.py +125 -20
  5. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite/query_translation.py +31 -5
  6. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29/cjm_capability_graph_sqlite.egg-info}/PKG-INFO +5 -1
  7. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/tests/test_capability.py +108 -0
  8. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/tests/test_query_translation.py +33 -2
  9. cjm_capability_graph_sqlite-0.0.26/cjm_capability_graph_sqlite/__init__.py +0 -1
  10. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/LICENSE +0 -0
  11. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/MANIFEST.in +0 -0
  12. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite.egg-info/SOURCES.txt +0 -0
  13. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite.egg-info/dependency_links.txt +0 -0
  14. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite.egg-info/requires.txt +0 -0
  15. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite.egg-info/top_level.txt +0 -0
  16. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/pyproject.toml +0 -0
  17. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.29}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-capability-graph-sqlite
3
- Version: 0.0.26
3
+ Version: 0.0.29
4
4
  Summary: A local, file-backed context-graph storage capability for the cjm-substrate runtime that implements graph storage, traversal, and querying using SQLite.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -26,6 +26,7 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
26
26
 
27
27
  ## Modules
28
28
 
29
+ - **`cjm_capability_graph_sqlite.__init__`**
29
30
  - **`cjm_capability_graph_sqlite.capability`** — Capability implementation for Context Graph using SQLite.
30
31
  - **`cjm_capability_graph_sqlite.query_translation`** — The per-backend translation of the typed query expressions (pass-2 Thread 5; stage 4): NodeQuery/EdgeQuery -> parameterized SQLite SQL over the nodes/edges schema. THIS module is what makes the typed surface portable - the expression is domain- and backend-neutral; every backend tool owns a translation like this one (the ratified stage-4 split: backend owns translation; the adapter stays generic). Pure functions, unit-tested against an in-memory DB with the production schema - no capability runtime needed.
31
32
 
@@ -35,13 +36,16 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
35
36
 
36
37
  - `SQLiteGraphCapability` _class_ — Local, file-backed Context Graph implementation using SQLite.
37
38
  - `SQLiteGraphCapabilityConfig` _class_ — Configuration for SQLite Graph Capability.
39
+ - `ingest_sources` _function_ — The source HEADs this db was ingested from, on a fresh read-only connection. A db built
38
40
  - `integrity_check` _function_ — Backend self-check (stage 4; the G3 corruption find institutionalized):
39
41
  - `query_edges` _function_ — Execute a typed edge query (stage 4) — same contract as `query_nodes`.
40
42
  - `query_nodes` _function_ — Execute a typed node query (stage 4) — translated to parameterized SQL
41
43
  - `raw_query` _function_ — Execute the raw escape — refuses backend mismatches (non-portable by
44
+ - `record_ingest_sources` _function_ — Record the source HEADs this db was ingested from, REPLACING any earlier record
42
45
 
43
46
  ### `cjm_capability_graph_sqlite.query_translation`
44
47
 
48
+ - `decode_projected_row` _function_ — Zip a projected row, decoding every property column from its JSON representation.
45
49
  - `translate_edge_query` _function_ — Translate an `EdgeQuery` to parameterized SQLite SQL.
46
50
  - `translate_node_query` _function_ — Translate a `NodeQuery` to parameterized SQLite SQL.
47
51
 
@@ -6,6 +6,7 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
6
6
 
7
7
  ## Modules
8
8
 
9
+ - **`cjm_capability_graph_sqlite.__init__`**
9
10
  - **`cjm_capability_graph_sqlite.capability`** — Capability implementation for Context Graph using SQLite.
10
11
  - **`cjm_capability_graph_sqlite.query_translation`** — The per-backend translation of the typed query expressions (pass-2 Thread 5; stage 4): NodeQuery/EdgeQuery -> parameterized SQLite SQL over the nodes/edges schema. THIS module is what makes the typed surface portable - the expression is domain- and backend-neutral; every backend tool owns a translation like this one (the ratified stage-4 split: backend owns translation; the adapter stays generic). Pure functions, unit-tested against an in-memory DB with the production schema - no capability runtime needed.
11
12
 
@@ -15,13 +16,16 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
15
16
 
16
17
  - `SQLiteGraphCapability` _class_ — Local, file-backed Context Graph implementation using SQLite.
17
18
  - `SQLiteGraphCapabilityConfig` _class_ — Configuration for SQLite Graph Capability.
19
+ - `ingest_sources` _function_ — The source HEADs this db was ingested from, on a fresh read-only connection. A db built
18
20
  - `integrity_check` _function_ — Backend self-check (stage 4; the G3 corruption find institutionalized):
19
21
  - `query_edges` _function_ — Execute a typed edge query (stage 4) — same contract as `query_nodes`.
20
22
  - `query_nodes` _function_ — Execute a typed node query (stage 4) — translated to parameterized SQL
21
23
  - `raw_query` _function_ — Execute the raw escape — refuses backend mismatches (non-portable by
24
+ - `record_ingest_sources` _function_ — Record the source HEADs this db was ingested from, REPLACING any earlier record
22
25
 
23
26
  ### `cjm_capability_graph_sqlite.query_translation`
24
27
 
28
+ - `decode_projected_row` _function_ — Zip a projected row, decoding every property column from its JSON representation.
25
29
  - `translate_edge_query` _function_ — Translate an `EdgeQuery` to parameterized SQLite SQL.
26
30
  - `translate_node_query` _function_ — Translate a `NodeQuery` to parameterized SQLite SQL.
27
31
 
@@ -0,0 +1 @@
1
+ __version__ = "0.0.29"
@@ -22,7 +22,11 @@ import time
22
22
  from dataclasses import dataclass, field
23
23
  from typing import Any, Dict, List, Optional, Tuple, Union
24
24
 
25
- from cjm_capability_graph_sqlite.query_translation import translate_edge_query, translate_node_query
25
+ from cjm_capability_graph_sqlite.query_translation import (decode_projected_row,
26
+ EDGE_STRUCTURAL_KEYS,
27
+ NODE_STRUCTURAL_KEYS,
28
+ translate_edge_query,
29
+ translate_node_query)
26
30
  from cjm_context_graph_primitives.graph import GraphContext, GraphEdge, GraphNode
27
31
  from cjm_context_graph_primitives.provenance import SourceRef
28
32
  from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
@@ -78,10 +82,21 @@ class SQLiteGraphCapability(ToolCapability):
78
82
  FOREIGN KEY(source_id) REFERENCES nodes(id) ON DELETE CASCADE,
79
83
  FOREIGN KEY(target_id) REFERENCES nodes(id) ON DELETE CASCADE
80
84
  );
85
+
86
+ CREATE TABLE ingest_sources ( -- the source HEADs the rebuild ingested (DEC a9176261)
87
+ source TEXT PRIMARY KEY,
88
+ head TEXT NOT NULL
89
+ );
81
90
  """
82
91
 
83
92
  config_class = SQLiteGraphCapabilityConfig
84
93
 
94
+ # Substrate observability class (DEC 8bf656c0 as manifest DATA, finding 0d886ffe B):
95
+ # graph-storage ops are compute-light, re-derivable work — routine per-op journal
96
+ # rows and worker-lifecycle rows are noise (they filled 97% of journal.db); failures
97
+ # still journal. `cjm-ctl regenerate-manifest` records this on the manifest.
98
+ observability_class = "ambient"
99
+
85
100
  def __init__(self):
86
101
  self.logger = logging.getLogger(f"{__name__}.{type(self).__name__}")
87
102
  self.config: SQLiteGraphCapabilityConfig = None
@@ -201,6 +216,14 @@ class SQLiteGraphCapability(ToolCapability):
201
216
  # them up on the next initialize.
202
217
  con.execute("CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(source_id, relation_type);")
203
218
  con.execute("CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(target_id, relation_type);")
219
+ # The ingest record (DEC a9176261): the source HEADs this db was ingested from —
220
+ # provenance about the db itself, beside the graph and never in it.
221
+ con.execute("""
222
+ CREATE TABLE IF NOT EXISTS ingest_sources (
223
+ source TEXT PRIMARY KEY,
224
+ head TEXT NOT NULL
225
+ )
226
+ """)
204
227
  # H7 companion: planner statistics. Without sqlite_stat1 the
205
228
  # correlated-probe plans pick the WRONG composite index (the
206
229
  # target-side probe scans a whole document's PART_OF rows per
@@ -291,17 +314,23 @@ class SQLiteGraphCapability(ToolCapability):
291
314
  self,
292
315
  nodes: List[GraphNode] # Nodes to create
293
316
  ) -> List[str]: # Created node IDs
294
- """Bulk create nodes."""
317
+ """Bulk create nodes.
318
+
319
+ A node arriving with created_at/updated_at already set keeps them —
320
+ journal REPLAY restores temporal provenance this way (finding 0d50b921:
321
+ stamping now() here clamped every rebuilt node to rebuild time)."""
295
322
  ids = []
296
323
  now = time.time()
297
324
  with self._connect() as con:
298
325
  for n in nodes:
299
326
  sources_json = json.dumps([s.to_dict() for s in n.sources])
300
327
  props_json = json.dumps(n.properties)
328
+ created = n.created_at if n.created_at is not None else now
329
+ updated = n.updated_at if n.updated_at is not None else created
301
330
  try:
302
331
  con.execute(
303
332
  "INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
304
- (n.id, n.label, props_json, sources_json, now, now)
333
+ (n.id, n.label, props_json, sources_json, created, updated)
305
334
  )
306
335
  ids.append(n.id)
307
336
  except sqlite3.IntegrityError:
@@ -312,21 +341,40 @@ class SQLiteGraphCapability(ToolCapability):
312
341
  self,
313
342
  edges: List[GraphEdge] # Edges to create
314
343
  ) -> List[str]: # Created edge IDs
315
- """Bulk create edges."""
344
+ """Bulk create edges.
345
+
346
+ Carried created_at/updated_at are honored like add_nodes (0d50b921).
347
+
348
+ Missing-endpoint edges (FOREIGN KEY failures) are AGGREGATED into one warning
349
+ per call — count + a bounded sample — never one line per edge: a rebuild replays
350
+ every journaled edge whose endpoint is absent, and per-edge logging flooded
351
+ diagnostics.db to 1.5M rows / 633 MB (finding 0d886ffe). The skipped count is
352
+ recoverable by the caller as `len(edges) - len(ids)`."""
316
353
  ids = []
317
354
  now = time.time()
355
+ missing: List[Tuple[str, str, str]] = [] # (source_id, target_id, relation_type) of FK-skipped edges
318
356
  with self._connect() as con:
319
357
  con.execute("PRAGMA foreign_keys = ON;")
320
358
  for e in edges:
321
359
  props_json = json.dumps(e.properties)
360
+ created = e.created_at if e.created_at is not None else now
361
+ updated = e.updated_at if e.updated_at is not None else created
322
362
  try:
323
363
  con.execute(
324
364
  "INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
325
- (e.id, e.source_id, e.target_id, e.relation_type, props_json, now, now)
365
+ (e.id, e.source_id, e.target_id, e.relation_type, props_json, created, updated)
326
366
  )
327
367
  ids.append(e.id)
328
368
  except sqlite3.IntegrityError as err:
329
- self.logger.warning(f"Edge creation error (likely missing node): {err}")
369
+ if "FOREIGN KEY" in str(err):
370
+ missing.append((e.source_id, e.target_id, e.relation_type))
371
+ else:
372
+ self.logger.warning(f"Edge creation error ({e.id}): {err}")
373
+ if missing:
374
+ sample = "; ".join(f"{s[:8]}->{t[:8]} {r}" for s, t, r in missing[:3])
375
+ self.logger.warning(
376
+ f"add_edges: {len(missing)} of {len(edges)} edge(s) skipped — endpoint node missing "
377
+ f"(FOREIGN KEY); sample: {sample}")
330
378
  return ids
331
379
 
332
380
  # -------------------------------------------------------------------------
@@ -490,7 +538,14 @@ class SQLiteGraphCapability(ToolCapability):
490
538
  node_id: str, # UUID of node to update
491
539
  properties: Dict[str, Any] # Properties to merge/update
492
540
  ) -> bool: # True if successful
493
- """Partial update of node properties."""
541
+ """Partial update of node properties.
542
+
543
+ A reserved `updated_at` key in `properties` sets the COLUMN instead of
544
+ now() and never lands in the JSON blob — journal REPLAY restores a STATE
545
+ op's true time this way (0d50b921 residual: later-STATE nodes clamped
546
+ to rebuild time)."""
547
+ properties = dict(properties)
548
+ stamp = properties.pop("updated_at", None)
494
549
  with self._connect() as con:
495
550
  # Fetch existing to merge
496
551
  cur = con.execute("SELECT properties FROM nodes WHERE id = ?", (node_id,))
@@ -503,7 +558,7 @@ class SQLiteGraphCapability(ToolCapability):
503
558
 
504
559
  con.execute(
505
560
  "UPDATE nodes SET properties = ?, updated_at = ? WHERE id = ?",
506
- (json.dumps(existing), time.time(), node_id)
561
+ (json.dumps(existing), stamp if stamp is not None else time.time(), node_id)
507
562
  )
508
563
  return True
509
564
 
@@ -512,7 +567,13 @@ class SQLiteGraphCapability(ToolCapability):
512
567
  edge_id: str, # UUID of edge to update
513
568
  properties: Dict[str, Any] # Properties to merge/update
514
569
  ) -> bool: # True if successful
515
- """Partial update of edge properties."""
570
+ """Partial update of edge properties.
571
+
572
+ A reserved `updated_at` key in `properties` sets the COLUMN instead of now()
573
+ and never lands in the JSON blob — the op clock's stamp (design 8f6f2343),
574
+ exactly as `update_node` takes it."""
575
+ properties = dict(properties)
576
+ stamp = properties.pop("updated_at", None)
516
577
  with self._connect() as con:
517
578
  cur = con.execute("SELECT properties FROM edges WHERE id = ?", (edge_id,))
518
579
  row = cur.fetchone()
@@ -524,7 +585,7 @@ class SQLiteGraphCapability(ToolCapability):
524
585
 
525
586
  con.execute(
526
587
  "UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
527
- (json.dumps(existing), time.time(), edge_id)
588
+ (json.dumps(existing), stamp if stamp is not None else time.time(), edge_id)
528
589
  )
529
590
  return True
530
591
 
@@ -613,7 +674,8 @@ class SQLiteGraphCapability(ToolCapability):
613
674
  nodes: List[GraphNode], # Nodes to import
614
675
  merge_strategy: str # "overwrite" | "skip" | "merge"
615
676
  ) -> int: # Count of rows written (inserted or updated)
616
- """Import nodes honoring merge_strategy (see import_graph)."""
677
+ """Import nodes honoring merge_strategy (see import_graph). Carried
678
+ created_at / updated_at are honored like add_nodes (0d50b921, design 8f6f2343)."""
617
679
  now = time.time()
618
680
  written = 0
619
681
  with self._connect() as con:
@@ -624,7 +686,10 @@ class SQLiteGraphCapability(ToolCapability):
624
686
  if row is None:
625
687
  con.execute(
626
688
  "INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
627
- (node.id, node.label, props_json, sources_json, now, now),
689
+ (node.id, node.label, props_json, sources_json,
690
+ node.created_at if node.created_at is not None else now,
691
+ node.updated_at if node.updated_at is not None
692
+ else (node.created_at if node.created_at is not None else now)),
628
693
  )
629
694
  written += 1
630
695
  elif merge_strategy == "skip":
@@ -632,7 +697,8 @@ class SQLiteGraphCapability(ToolCapability):
632
697
  elif merge_strategy == "overwrite":
633
698
  con.execute(
634
699
  "UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
635
- (node.label, props_json, sources_json, now, node.id),
700
+ (node.label, props_json, sources_json,
701
+ node.updated_at if node.updated_at is not None else now, node.id),
636
702
  )
637
703
  written += 1
638
704
  else: # merge
@@ -648,7 +714,8 @@ class SQLiteGraphCapability(ToolCapability):
648
714
  seen.add(key)
649
715
  con.execute(
650
716
  "UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
651
- (node.label, json.dumps(merged_props), json.dumps(existing_sources), now, node.id),
717
+ (node.label, json.dumps(merged_props), json.dumps(existing_sources),
718
+ node.updated_at if node.updated_at is not None else now, node.id),
652
719
  )
653
720
  written += 1
654
721
  return written
@@ -658,7 +725,8 @@ class SQLiteGraphCapability(ToolCapability):
658
725
  edges: List[GraphEdge], # Edges to import
659
726
  merge_strategy: str # "overwrite" | "skip" | "merge"
660
727
  ) -> int: # Count of rows written (inserted or updated)
661
- """Import edges honoring merge_strategy (see import_graph)."""
728
+ """Import edges honoring merge_strategy (see import_graph). Carried
729
+ created_at / updated_at are honored like add_edges (0d50b921, design 8f6f2343)."""
662
730
  now = time.time()
663
731
  written = 0
664
732
  with self._connect() as con:
@@ -669,7 +737,10 @@ class SQLiteGraphCapability(ToolCapability):
669
737
  try:
670
738
  con.execute(
671
739
  "INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
672
- (edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json, now, now),
740
+ (edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json,
741
+ edge.created_at if edge.created_at is not None else now,
742
+ edge.updated_at if edge.updated_at is not None
743
+ else (edge.created_at if edge.created_at is not None else now)),
673
744
  )
674
745
  written += 1
675
746
  except sqlite3.IntegrityError as err:
@@ -679,7 +750,8 @@ class SQLiteGraphCapability(ToolCapability):
679
750
  elif merge_strategy == "overwrite":
680
751
  con.execute(
681
752
  "UPDATE edges SET source_id = ?, target_id = ?, relation_type = ?, properties = ?, updated_at = ? WHERE id = ?",
682
- (edge.source_id, edge.target_id, edge.relation_type, props_json, now, edge.id),
753
+ (edge.source_id, edge.target_id, edge.relation_type, props_json,
754
+ edge.updated_at if edge.updated_at is not None else now, edge.id),
683
755
  )
684
756
  written += 1
685
757
  else: # merge
@@ -687,7 +759,8 @@ class SQLiteGraphCapability(ToolCapability):
687
759
  merged.update(edge.properties)
688
760
  con.execute(
689
761
  "UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
690
- (json.dumps(merged), now, edge.id),
762
+ (json.dumps(merged), edge.updated_at if edge.updated_at is not None else now,
763
+ edge.id),
691
764
  )
692
765
  written += 1
693
766
  return written
@@ -782,7 +855,7 @@ def query_nodes(
782
855
  return NodeQueryResult(nodes=[self._row_to_node(r) for r in rows])
783
856
  out = []
784
857
  for r in rows:
785
- d = dict(zip(keys, r))
858
+ d = decode_projected_row(keys, r, NODE_STRUCTURAL_KEYS) # properties keep their JSON types
786
859
  if "sources" in d and d["sources"]:
787
860
  d["sources"] = json.loads(d["sources"]) # ref dicts (CR-19 shape)
788
861
  out.append(d)
@@ -805,7 +878,7 @@ def query_edges(
805
878
  con.close()
806
879
  if mode == "full":
807
880
  return EdgeQueryResult(edges=[self._row_to_edge(r) for r in rows])
808
- return EdgeQueryResult(rows=[dict(zip(keys, r)) for r in rows])
881
+ return EdgeQueryResult(rows=[decode_projected_row(keys, r, EDGE_STRUCTURAL_KEYS) for r in rows])
809
882
 
810
883
 
811
884
  def raw_query(
@@ -843,7 +916,39 @@ def integrity_check(self) -> Dict[str, Any]: # {"ok": bool, "errors": [...], "b
843
916
  return {"ok": ok, "errors": [] if ok else rows, "backend": "sqlite"}
844
917
 
845
918
 
919
+ def record_ingest_sources(
920
+ self,
921
+ sources: Dict[str, str], # source id -> the HEAD commit the ingest read
922
+ ) -> int: # Sources recorded
923
+ """Record the source HEADs this db was ingested from, REPLACING any earlier record
924
+ (DEC a9176261). The rebuild writes it into the fresh db it builds, so the record and the
925
+ rows it describes swap in together; it is provenance about the db, not a graph row, so
926
+ it is neither stamped by the op clock nor seen by the write observer."""
927
+ rows = sorted((str(k), str(v)) for k, v in dict(sources).items())
928
+ with self._connect() as con:
929
+ con.execute("DELETE FROM ingest_sources")
930
+ con.executemany("INSERT INTO ingest_sources (source, head) VALUES (?, ?)", rows)
931
+ return len(rows)
932
+
933
+
934
+ def ingest_sources(self) -> Dict[str, str]: # source id -> HEAD; {} when the db holds no record
935
+ """The source HEADs this db was ingested from, on a fresh read-only connection. A db built
936
+ before the record existed has no table: it reads as no record, never as an error."""
937
+ con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
938
+ try:
939
+ rows = con.execute("SELECT source, head FROM ingest_sources ORDER BY source").fetchall()
940
+ except sqlite3.OperationalError as e:
941
+ if "no such table" not in str(e):
942
+ raise
943
+ rows = []
944
+ finally:
945
+ con.close()
946
+ return {s: h for s, h in rows}
947
+
948
+
846
949
  SQLiteGraphCapability.query_nodes = query_nodes
847
950
  SQLiteGraphCapability.query_edges = query_edges
848
951
  SQLiteGraphCapability.raw_query = raw_query
849
952
  SQLiteGraphCapability.integrity_check = integrity_check
953
+ SQLiteGraphCapability.record_ingest_sources = record_ingest_sources
954
+ SQLiteGraphCapability.ingest_sources = ingest_sources
@@ -22,9 +22,14 @@ Translation rules:
22
22
  does Python-side locator equality; a recurring need here is promotion
23
23
  evidence).
24
24
  - **Modes**: `count` > `project` > full (mirrors the result DTO contract).
25
- Projected node rows always carry `id` (+ `label` projectable structurally;
26
- `sources` projectable as parsed ref dicts); edge rows always carry
27
- `id`/`source_id`/`target_id` (+ `relation_type` projectable)."""
25
+ Projected node rows always carry `id` (+ `label`/`created_at`/`updated_at`
26
+ projectable structurally — the storage stamps, never shadowed by a
27
+ same-named property; `sources` projectable as parsed ref dicts); edge rows
28
+ always carry `id`/`source_id`/`target_id` (+ `relation_type` projectable).
29
+ A projected PROPERTY is selected as its JSON representation (`->`) and decoded
30
+ by the capability, so it keeps its type — a list stays a list, an object an
31
+ object (`json_extract` returned arrays and objects as JSON TEXT: a projected
32
+ `calls` list read as a string, the live code fold's index keyed characters)."""
28
33
 
29
34
  import json
30
35
  import re
@@ -230,11 +235,14 @@ def translate_node_query(
230
235
  if name == "label":
231
236
  select_parts.append("n.label")
232
237
  keys.append("label")
238
+ elif name in ("created_at", "updated_at"):
239
+ select_parts.append("n." + name) # storage stamp, not a property
240
+ keys.append(name)
233
241
  elif name == "sources":
234
242
  select_parts.append("n.sources")
235
243
  keys.append("sources")
236
244
  else:
237
- select_parts.append(f"json_extract(n.properties, {_json_path(name)})")
245
+ select_parts.append(f"n.properties -> {_json_path(name)}")
238
246
  keys.append(name)
239
247
  tail = _order_limit_sql(q, "n.properties", params)
240
248
  return (f"SELECT {', '.join(select_parts)} FROM nodes AS n{where_sql}{tail}",
@@ -302,7 +310,7 @@ def translate_edge_query(
302
310
  select_parts.append("e.relation_type")
303
311
  keys.append("relation_type")
304
312
  else:
305
- select_parts.append(f"json_extract(e.properties, {_json_path(name)})")
313
+ select_parts.append(f"e.properties -> {_json_path(name)}")
306
314
  keys.append(name)
307
315
  tail = _order_limit_sql(q, "e.properties", params)
308
316
  return (f"SELECT {', '.join(select_parts)} FROM edges AS e{where_sql}{tail}",
@@ -311,3 +319,21 @@ def translate_edge_query(
311
319
  tail = _order_limit_sql(q, "e.properties", params)
312
320
  return (f"SELECT {EDGE_FULL_COLUMNS} FROM edges AS e{where_sql}{tail}",
313
321
  params, "full", None)
322
+
323
+
324
+ # The structural columns a projection selects as-is; every other projected key is a property,
325
+ # selected as JSON (`->`) and decoded by `decode_projected_row`.
326
+ NODE_STRUCTURAL_KEYS = frozenset({"id", "label", "created_at", "updated_at", "sources"})
327
+ EDGE_STRUCTURAL_KEYS = frozenset({"id", "source_id", "target_id", "relation_type"})
328
+
329
+
330
+ def decode_projected_row(
331
+ keys: List[str], # The projected keys, in select order
332
+ values: Any, # One cursor row
333
+ structural: frozenset, # The keys selected as plain columns
334
+ ) -> dict: # The row: structural columns as-is, each property decoded to its JSON type (absent -> None)
335
+ """Zip a projected row, decoding every property column from its JSON representation."""
336
+ out = {}
337
+ for k, v in zip(keys, values):
338
+ out[k] = v if k in structural or v is None else json.loads(v)
339
+ return out
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cjm-capability-graph-sqlite
3
- Version: 0.0.26
3
+ Version: 0.0.29
4
4
  Summary: A local, file-backed context-graph storage capability for the cjm-substrate runtime that implements graph storage, traversal, and querying using SQLite.
5
5
  Author-email: "Christian J. Mills" <9126128+cj-mills@users.noreply.github.com>
6
6
  License: Apache-2.0
@@ -26,6 +26,7 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
26
26
 
27
27
  ## Modules
28
28
 
29
+ - **`cjm_capability_graph_sqlite.__init__`**
29
30
  - **`cjm_capability_graph_sqlite.capability`** — Capability implementation for Context Graph using SQLite.
30
31
  - **`cjm_capability_graph_sqlite.query_translation`** — The per-backend translation of the typed query expressions (pass-2 Thread 5; stage 4): NodeQuery/EdgeQuery -> parameterized SQLite SQL over the nodes/edges schema. THIS module is what makes the typed surface portable - the expression is domain- and backend-neutral; every backend tool owns a translation like this one (the ratified stage-4 split: backend owns translation; the adapter stays generic). Pure functions, unit-tested against an in-memory DB with the production schema - no capability runtime needed.
31
32
 
@@ -35,13 +36,16 @@ A local, file-backed context-graph storage capability for the cjm-substrate runt
35
36
 
36
37
  - `SQLiteGraphCapability` _class_ — Local, file-backed Context Graph implementation using SQLite.
37
38
  - `SQLiteGraphCapabilityConfig` _class_ — Configuration for SQLite Graph Capability.
39
+ - `ingest_sources` _function_ — The source HEADs this db was ingested from, on a fresh read-only connection. A db built
38
40
  - `integrity_check` _function_ — Backend self-check (stage 4; the G3 corruption find institutionalized):
39
41
  - `query_edges` _function_ — Execute a typed edge query (stage 4) — same contract as `query_nodes`.
40
42
  - `query_nodes` _function_ — Execute a typed node query (stage 4) — translated to parameterized SQL
41
43
  - `raw_query` _function_ — Execute the raw escape — refuses backend mismatches (non-portable by
44
+ - `record_ingest_sources` _function_ — Record the source HEADs this db was ingested from, REPLACING any earlier record
42
45
 
43
46
  ### `cjm_capability_graph_sqlite.query_translation`
44
47
 
48
+ - `decode_projected_row` _function_ — Zip a projected row, decoding every property column from its JSON representation.
45
49
  - `translate_edge_query` _function_ — Translate an `EdgeQuery` to parameterized SQLite SQL.
46
50
  - `translate_node_query` _function_ — Translate a `NodeQuery` to parameterized SQLite SQL.
47
51
 
@@ -177,3 +177,111 @@ def test_typed_surface_smoke(cap):
177
177
  ctx = cap.export_graph(NodeQuery(label="Segment",
178
178
  related=RelationPredicate("PART_OF", node_id=doc)))
179
179
  assert len(ctx.nodes) == 3 and len(ctx.edges) == 2 # NEXT only (PART_OF targets the doc)
180
+
181
+
182
+ def test_update_node_reserved_updated_at_sets_column(cap):
183
+ # 0d50b921 residual: a reserved `updated_at` key in the update payload sets
184
+ # the COLUMN (journal replay restores a STATE op's true time) and never
185
+ # lands in the JSON properties blob; without it, now()-stamping stands.
186
+ import json
187
+
188
+ nid = str(uuid.uuid4())
189
+ cap.add_nodes([GraphNode(id=nid, label="Person", properties={"name": "Ada"})])
190
+ assert cap.update_node(nid, {"role": "host", "updated_at": 1234.5})
191
+ con = sqlite3.connect(cap._db_path)
192
+ row = con.execute("SELECT properties, updated_at FROM nodes WHERE id = ?",
193
+ (nid,)).fetchone()
194
+ con.close()
195
+ props = json.loads(row[0])
196
+ assert props["role"] == "host" and "updated_at" not in props
197
+ assert row[1] == 1234.5
198
+ # Without the reserved key the column re-stamps to now().
199
+ assert cap.update_node(nid, {"role": "guest"})
200
+ con = sqlite3.connect(cap._db_path)
201
+ row2 = con.execute("SELECT updated_at FROM nodes WHERE id = ?", (nid,)).fetchone()
202
+ con.close()
203
+ assert row2[0] > 1234.5
204
+
205
+
206
+ def test_add_edges_aggregates_missing_endpoint_warnings(cap, caplog):
207
+ """Finding 0d886ffe: FK-skipped edges log ONE warning per call (count + sample),
208
+ never one line per edge — per-edge logging flooded diagnostics.db during rebuilds."""
209
+ import logging
210
+ from cjm_context_graph_primitives.graph import GraphEdge, GraphNode
211
+ cap.add_nodes([GraphNode(id="n-live", label="T", properties={}, sources=[])])
212
+ edges = [GraphEdge(id=f"e{i}", source_id="n-live", target_id=f"n-missing-{i}",
213
+ relation_type="REL", properties={}) for i in range(25)]
214
+ edges.append(GraphEdge(id="e-ok", source_id="n-live", target_id="n-live",
215
+ relation_type="SELF", properties={}))
216
+ with caplog.at_level(logging.WARNING):
217
+ ids = cap.add_edges(edges)
218
+ assert ids == ["e-ok"] # skipped count recoverable as len(edges) - len(ids)
219
+ fk_lines = [r for r in caplog.records if "endpoint node missing" in r.getMessage()]
220
+ assert len(fk_lines) == 1
221
+ msg = fk_lines[0].getMessage()
222
+ assert "25 of 26" in msg and "sample:" in msg
223
+ assert not any("Edge creation error (likely missing node)" in r.getMessage() for r in caplog.records)
224
+
225
+
226
+ def test_update_edge_reserved_updated_at_sets_column(cap):
227
+ # The op clock (design 8f6f2343): update_edge takes the reserved `updated_at` exactly as
228
+ # update_node does — the COLUMN, never the JSON blob; without it, now()-stamping stands.
229
+ import json
230
+
231
+ a, b = str(uuid.uuid4()), str(uuid.uuid4())
232
+ cap.add_nodes([GraphNode(id=a, label="Person"), GraphNode(id=b, label="Person")])
233
+ eid = str(uuid.uuid4())
234
+ cap.add_edges([GraphEdge(id=eid, source_id=a, target_id=b, relation_type="KNOWS")])
235
+ assert cap.update_edge(eid, {"since": 1999, "updated_at": 1234.5})
236
+ con = sqlite3.connect(cap._db_path)
237
+ props, stamp = con.execute("SELECT properties, updated_at FROM edges WHERE id = ?",
238
+ (eid,)).fetchone()
239
+ con.close()
240
+ assert json.loads(props) == {"since": 1999} and stamp == 1234.5
241
+ assert cap.update_edge(eid, {"since": 2000})
242
+ con = sqlite3.connect(cap._db_path)
243
+ (stamp2,) = con.execute("SELECT updated_at FROM edges WHERE id = ?", (eid,)).fetchone()
244
+ con.close()
245
+ assert stamp2 > 1234.5
246
+
247
+
248
+ def test_import_honors_carried_times(cap):
249
+ # import_graph keeps carried created_at / updated_at like add_nodes / add_edges.
250
+ a, b = str(uuid.uuid4()), str(uuid.uuid4())
251
+ eid = str(uuid.uuid4())
252
+ cap.import_graph(GraphContext(
253
+ nodes=[GraphNode(id=a, label="P", created_at=10.0, updated_at=20.0),
254
+ GraphNode(id=b, label="P", created_at=30.0)],
255
+ edges=[GraphEdge(id=eid, source_id=a, target_id=b, relation_type="R", created_at=40.0)]))
256
+ con = sqlite3.connect(cap._db_path)
257
+ rows = dict((r[0], r[1:]) for r in con.execute("SELECT id, created_at, updated_at FROM nodes"))
258
+ erow = con.execute("SELECT created_at, updated_at FROM edges WHERE id = ?", (eid,)).fetchone()
259
+ con.close()
260
+ assert rows[a] == (10.0, 20.0) and rows[b] == (30.0, 30.0) and erow == (40.0, 40.0)
261
+
262
+
263
+ def test_ingest_record_replaces_reads_readonly_and_tolerates_a_legacy_db(cap, tmp_path):
264
+ """The ingest record (DEC a9176261): a replace, never a merge; beside the graph (no row,
265
+ no op-clock stamp); readable through a read-only open; a db built before the table
266
+ existed reads as no record."""
267
+ assert cap.ingest_sources() == {}
268
+ assert cap.record_ingest_sources({"archive:/site/posts": "a" * 40, "repo:/r/x": "b" * 40}) == 2
269
+ assert cap.record_ingest_sources({"repo:/r/x": "c" * 40}) == 1
270
+ assert cap.ingest_sources() == {"repo:/r/x": "c" * 40}
271
+ assert cap.get_schema()["counts"] == {} # beside the graph, never in it
272
+
273
+ ro = SQLiteGraphCapability()
274
+ ro.initialize({"db_path": cap._db_path, "readonly": True})
275
+ assert ro.ingest_sources() == {"repo:/r/x": "c" * 40}
276
+ ro.cleanup()
277
+
278
+ legacy = str(tmp_path / "legacy.db")
279
+ con = sqlite3.connect(legacy)
280
+ con.execute("CREATE TABLE nodes (id TEXT PRIMARY KEY, label TEXT NOT NULL, properties JSON, "
281
+ "sources JSON, created_at REAL, updated_at REAL)")
282
+ con.commit()
283
+ con.close()
284
+ old = SQLiteGraphCapability()
285
+ old.initialize({"db_path": legacy, "readonly": True})
286
+ assert old.ingest_sources() == {}
287
+ old.cleanup()
@@ -8,7 +8,10 @@ import sqlite3
8
8
 
9
9
  import pytest
10
10
 
11
- from cjm_capability_graph_sqlite.query_translation import (translate_edge_query,
11
+ from cjm_capability_graph_sqlite.query_translation import (EDGE_STRUCTURAL_KEYS,
12
+ NODE_STRUCTURAL_KEYS,
13
+ decode_projected_row,
14
+ translate_edge_query,
12
15
  translate_node_query)
13
16
  from cjm_context_graph_primitives.locators import FileRef
14
17
  from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy,
@@ -71,7 +74,8 @@ def run(con, q):
71
74
  return cur.fetchone()[0]
72
75
  rows = cur.fetchall()
73
76
  if mode == "rows":
74
- return [dict(zip(keys, r)) for r in rows]
77
+ structural = NODE_STRUCTURAL_KEYS if isinstance(q, NodeQuery) else EDGE_STRUCTURAL_KEYS
78
+ return [decode_projected_row(keys, r, structural) for r in rows]
75
79
  return rows
76
80
 
77
81
 
@@ -85,6 +89,33 @@ def test_spine_read_ordered_projection(con):
85
89
  assert json.loads(rows[1]["sources"])[0]["content_hash"] == "sha256:ab"
86
90
 
87
91
 
92
+ def test_projected_properties_keep_their_json_types(con):
93
+ # A projected object stays an object and a missing key is None (json_extract used to
94
+ # hand arrays and objects back as JSON TEXT — a projected list read as a string).
95
+ rows = run(con, NodeQuery(label="Correction", project=["payload", "correction_type"]))
96
+ by = {r["id"]: r for r in rows}
97
+ assert by["c1"]["payload"] == {"document_id": "doc-1", "segment_id": "s1"}
98
+ assert by["c2"]["correction_type"] == "text_content"
99
+ rows = run(con, NodeQuery(label="Segment", related=RelationPredicate("PART_OF", node_id="doc-1"),
100
+ order_by=OrderBy(prop="index"), project=["text", "start_time"]))
101
+ assert [r["text"] for r in rows] == ["It's 100% real_deal", "hello world", "", None]
102
+ assert rows[1]["start_time"] == 1.0
103
+ rows = run(con, EdgeQuery(relation_type="REVIEWED", project=["decision"]))
104
+ assert rows == [{"id": "rev", "source_id": "sess-1", "target_id": "s0", "decision": "corrected"}]
105
+
106
+
107
+ def test_structural_timestamp_projection(con):
108
+ # created_at/updated_at project from the storage COLUMNS (the stamps),
109
+ # not json_extract over properties — no Segment carries them as props.
110
+ rows = run(con, NodeQuery(label="Segment",
111
+ related=RelationPredicate("PART_OF", node_id="doc-1"),
112
+ order_by=OrderBy(prop="index"),
113
+ project=["index", "created_at", "updated_at"]))
114
+ assert [r["created_at"] for r in rows] == [1.0, 1.0, 1.0, 1.0]
115
+ assert rows[0]["updated_at"] == 1.0
116
+ assert rows[0]["id"] == "s0"
117
+
118
+
88
119
  def test_empty_filter_or_case_two_counts(con):
89
120
  empty_eq = run(con, NodeQuery(label="Segment", where=[PropertyPredicate("text", "eq", "")],
90
121
  related=RelationPredicate("PART_OF", node_id="doc-1"), count=True))
@@ -1 +0,0 @@
1
- __version__ = "0.0.26"