cjm-capability-graph-sqlite 0.0.28__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.28/cjm_capability_graph_sqlite.egg-info → cjm_capability_graph_sqlite-0.0.29}/PKG-INFO +5 -1
  2. {cjm_capability_graph_sqlite-0.0.28 → 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.28 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite/capability.py +80 -13
  5. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite/query_translation.py +25 -3
  6. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29/cjm_capability_graph_sqlite.egg-info}/PKG-INFO +5 -1
  7. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29}/tests/test_capability.py +64 -0
  8. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29}/tests/test_query_translation.py +21 -2
  9. cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite/__init__.py +0 -1
  10. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29}/LICENSE +0 -0
  11. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29}/MANIFEST.in +0 -0
  12. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite.egg-info/SOURCES.txt +0 -0
  13. {cjm_capability_graph_sqlite-0.0.28 → 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.28 → cjm_capability_graph_sqlite-0.0.29}/cjm_capability_graph_sqlite.egg-info/requires.txt +0 -0
  15. {cjm_capability_graph_sqlite-0.0.28 → 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.28 → cjm_capability_graph_sqlite-0.0.29}/pyproject.toml +0 -0
  17. {cjm_capability_graph_sqlite-0.0.28 → 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.28
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,6 +82,11 @@ 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
@@ -207,6 +216,14 @@ class SQLiteGraphCapability(ToolCapability):
207
216
  # them up on the next initialize.
208
217
  con.execute("CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(source_id, relation_type);")
209
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
+ """)
210
227
  # H7 companion: planner statistics. Without sqlite_stat1 the
211
228
  # correlated-probe plans pick the WRONG composite index (the
212
229
  # target-side probe scans a whole document's PART_OF rows per
@@ -550,7 +567,13 @@ class SQLiteGraphCapability(ToolCapability):
550
567
  edge_id: str, # UUID of edge to update
551
568
  properties: Dict[str, Any] # Properties to merge/update
552
569
  ) -> bool: # True if successful
553
- """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)
554
577
  with self._connect() as con:
555
578
  cur = con.execute("SELECT properties FROM edges WHERE id = ?", (edge_id,))
556
579
  row = cur.fetchone()
@@ -562,7 +585,7 @@ class SQLiteGraphCapability(ToolCapability):
562
585
 
563
586
  con.execute(
564
587
  "UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
565
- (json.dumps(existing), time.time(), edge_id)
588
+ (json.dumps(existing), stamp if stamp is not None else time.time(), edge_id)
566
589
  )
567
590
  return True
568
591
 
@@ -651,7 +674,8 @@ class SQLiteGraphCapability(ToolCapability):
651
674
  nodes: List[GraphNode], # Nodes to import
652
675
  merge_strategy: str # "overwrite" | "skip" | "merge"
653
676
  ) -> int: # Count of rows written (inserted or updated)
654
- """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)."""
655
679
  now = time.time()
656
680
  written = 0
657
681
  with self._connect() as con:
@@ -662,7 +686,10 @@ class SQLiteGraphCapability(ToolCapability):
662
686
  if row is None:
663
687
  con.execute(
664
688
  "INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
665
- (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)),
666
693
  )
667
694
  written += 1
668
695
  elif merge_strategy == "skip":
@@ -670,7 +697,8 @@ class SQLiteGraphCapability(ToolCapability):
670
697
  elif merge_strategy == "overwrite":
671
698
  con.execute(
672
699
  "UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
673
- (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),
674
702
  )
675
703
  written += 1
676
704
  else: # merge
@@ -686,7 +714,8 @@ class SQLiteGraphCapability(ToolCapability):
686
714
  seen.add(key)
687
715
  con.execute(
688
716
  "UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
689
- (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),
690
719
  )
691
720
  written += 1
692
721
  return written
@@ -696,7 +725,8 @@ class SQLiteGraphCapability(ToolCapability):
696
725
  edges: List[GraphEdge], # Edges to import
697
726
  merge_strategy: str # "overwrite" | "skip" | "merge"
698
727
  ) -> int: # Count of rows written (inserted or updated)
699
- """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)."""
700
730
  now = time.time()
701
731
  written = 0
702
732
  with self._connect() as con:
@@ -707,7 +737,10 @@ class SQLiteGraphCapability(ToolCapability):
707
737
  try:
708
738
  con.execute(
709
739
  "INSERT INTO edges (id, source_id, target_id, relation_type, properties, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
710
- (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)),
711
744
  )
712
745
  written += 1
713
746
  except sqlite3.IntegrityError as err:
@@ -717,7 +750,8 @@ class SQLiteGraphCapability(ToolCapability):
717
750
  elif merge_strategy == "overwrite":
718
751
  con.execute(
719
752
  "UPDATE edges SET source_id = ?, target_id = ?, relation_type = ?, properties = ?, updated_at = ? WHERE id = ?",
720
- (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),
721
755
  )
722
756
  written += 1
723
757
  else: # merge
@@ -725,7 +759,8 @@ class SQLiteGraphCapability(ToolCapability):
725
759
  merged.update(edge.properties)
726
760
  con.execute(
727
761
  "UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
728
- (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),
729
764
  )
730
765
  written += 1
731
766
  return written
@@ -820,7 +855,7 @@ def query_nodes(
820
855
  return NodeQueryResult(nodes=[self._row_to_node(r) for r in rows])
821
856
  out = []
822
857
  for r in rows:
823
- d = dict(zip(keys, r))
858
+ d = decode_projected_row(keys, r, NODE_STRUCTURAL_KEYS) # properties keep their JSON types
824
859
  if "sources" in d and d["sources"]:
825
860
  d["sources"] = json.loads(d["sources"]) # ref dicts (CR-19 shape)
826
861
  out.append(d)
@@ -843,7 +878,7 @@ def query_edges(
843
878
  con.close()
844
879
  if mode == "full":
845
880
  return EdgeQueryResult(edges=[self._row_to_edge(r) for r in rows])
846
- 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])
847
882
 
848
883
 
849
884
  def raw_query(
@@ -881,7 +916,39 @@ def integrity_check(self) -> Dict[str, Any]: # {"ok": bool, "errors": [...], "b
881
916
  return {"ok": ok, "errors": [] if ok else rows, "backend": "sqlite"}
882
917
 
883
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
+
884
949
  SQLiteGraphCapability.query_nodes = query_nodes
885
950
  SQLiteGraphCapability.query_edges = query_edges
886
951
  SQLiteGraphCapability.raw_query = raw_query
887
952
  SQLiteGraphCapability.integrity_check = integrity_check
953
+ SQLiteGraphCapability.record_ingest_sources = record_ingest_sources
954
+ SQLiteGraphCapability.ingest_sources = ingest_sources
@@ -25,7 +25,11 @@ Translation rules:
25
25
  Projected node rows always carry `id` (+ `label`/`created_at`/`updated_at`
26
26
  projectable structurally — the storage stamps, never shadowed by a
27
27
  same-named property; `sources` projectable as parsed ref dicts); edge rows
28
- always carry `id`/`source_id`/`target_id` (+ `relation_type` projectable)."""
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)."""
29
33
 
30
34
  import json
31
35
  import re
@@ -238,7 +242,7 @@ def translate_node_query(
238
242
  select_parts.append("n.sources")
239
243
  keys.append("sources")
240
244
  else:
241
- select_parts.append(f"json_extract(n.properties, {_json_path(name)})")
245
+ select_parts.append(f"n.properties -> {_json_path(name)}")
242
246
  keys.append(name)
243
247
  tail = _order_limit_sql(q, "n.properties", params)
244
248
  return (f"SELECT {', '.join(select_parts)} FROM nodes AS n{where_sql}{tail}",
@@ -306,7 +310,7 @@ def translate_edge_query(
306
310
  select_parts.append("e.relation_type")
307
311
  keys.append("relation_type")
308
312
  else:
309
- select_parts.append(f"json_extract(e.properties, {_json_path(name)})")
313
+ select_parts.append(f"e.properties -> {_json_path(name)}")
310
314
  keys.append(name)
311
315
  tail = _order_limit_sql(q, "e.properties", params)
312
316
  return (f"SELECT {', '.join(select_parts)} FROM edges AS e{where_sql}{tail}",
@@ -315,3 +319,21 @@ def translate_edge_query(
315
319
  tail = _order_limit_sql(q, "e.properties", params)
316
320
  return (f"SELECT {EDGE_FULL_COLUMNS} FROM edges AS e{where_sql}{tail}",
317
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.28
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
 
@@ -221,3 +221,67 @@ def test_add_edges_aggregates_missing_endpoint_warnings(cap, caplog):
221
221
  msg = fk_lines[0].getMessage()
222
222
  assert "25 of 26" in msg and "sample:" in msg
223
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,21 @@ 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
+
88
107
  def test_structural_timestamp_projection(con):
89
108
  # created_at/updated_at project from the storage COLUMNS (the stamps),
90
109
  # not json_extract over properties — no Segment carries them as props.
@@ -1 +0,0 @@
1
- __version__ = "0.0.28"