cjm-capability-graph-sqlite 0.0.28__tar.gz → 0.0.30__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 (18) hide show
  1. {cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite.egg-info → cjm_capability_graph_sqlite-0.0.30}/PKG-INFO +6 -2
  2. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/README.md +4 -0
  3. cjm_capability_graph_sqlite-0.0.30/cjm_capability_graph_sqlite/__init__.py +1 -0
  4. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite/capability.py +129 -36
  5. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite/query_translation.py +44 -10
  6. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30/cjm_capability_graph_sqlite.egg-info}/PKG-INFO +6 -2
  7. cjm_capability_graph_sqlite-0.0.30/cjm_capability_graph_sqlite.egg-info/requires.txt +2 -0
  8. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/pyproject.toml +1 -1
  9. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/tests/test_capability.py +95 -4
  10. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/tests/test_query_translation.py +45 -6
  11. cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite/__init__.py +0 -1
  12. cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite.egg-info/requires.txt +0 -2
  13. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/LICENSE +0 -0
  14. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/MANIFEST.in +0 -0
  15. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite.egg-info/SOURCES.txt +0 -0
  16. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite.egg-info/dependency_links.txt +0 -0
  17. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/cjm_capability_graph_sqlite.egg-info/top_level.txt +0 -0
  18. {cjm_capability_graph_sqlite-0.0.28 → cjm_capability_graph_sqlite-0.0.30}/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.30
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
@@ -15,7 +15,7 @@ Requires-Python: >=3.12
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: cjm-substrate>=0.0.51
18
- Requires-Dist: cjm-context-graph-primitives>=0.0.12
18
+ Requires-Dist: cjm-context-graph-primitives>=0.0.18
19
19
  Dynamic: license-file
20
20
 
21
21
  # cjm-capability-graph-sqlite
@@ -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.30"
@@ -19,10 +19,15 @@ import logging
19
19
  import os
20
20
  import sqlite3
21
21
  import time
22
+ from contextlib import contextmanager
22
23
  from dataclasses import dataclass, field
23
- from typing import Any, Dict, List, Optional, Tuple, Union
24
+ from typing import Any, Dict, Iterator, List, Optional, Tuple, Union
24
25
 
25
- from cjm_capability_graph_sqlite.query_translation import translate_edge_query, translate_node_query
26
+ from cjm_capability_graph_sqlite.query_translation import (decode_projected_row,
27
+ EDGE_STRUCTURAL_KEYS,
28
+ NODE_STRUCTURAL_KEYS,
29
+ translate_edge_query,
30
+ translate_node_query)
26
31
  from cjm_context_graph_primitives.graph import GraphContext, GraphEdge, GraphNode
27
32
  from cjm_context_graph_primitives.provenance import SourceRef
28
33
  from cjm_context_graph_primitives.query import (EdgeQuery, EdgeQueryResult, NodeQuery,
@@ -36,6 +41,7 @@ from cjm_substrate.utils.validation import (config_to_dict, dataclass_to_jsonsch
36
41
  @dataclass
37
42
  class SQLiteGraphCapabilityConfig:
38
43
  """Configuration for SQLite Graph Capability."""
44
+
39
45
  db_path: Optional[str] = field(
40
46
  default=None,
41
47
  metadata={
@@ -78,6 +84,11 @@ class SQLiteGraphCapability(ToolCapability):
78
84
  FOREIGN KEY(source_id) REFERENCES nodes(id) ON DELETE CASCADE,
79
85
  FOREIGN KEY(target_id) REFERENCES nodes(id) ON DELETE CASCADE
80
86
  );
87
+
88
+ CREATE TABLE ingest_sources ( -- the source HEADs the rebuild ingested (DEC a9176261)
89
+ source TEXT PRIMARY KEY,
90
+ head TEXT NOT NULL
91
+ );
81
92
  """
82
93
 
83
94
  config_class = SQLiteGraphCapabilityConfig
@@ -164,8 +175,9 @@ class SQLiteGraphCapability(ToolCapability):
164
175
  return os.path.join(data_dir, "context_graph.db")
165
176
 
166
177
  def _init_db(self) -> None:
167
- """Create tables and indices."""
168
- with self._connect() as con:
178
+ """Create tables and indices -- the schema only. Statistics are written by the
179
+ paths that change data (`_writing`), never by an open (finding ff3ede3d)."""
180
+ with self._writing() as con:
169
181
  # Enable Foreign Keys
170
182
  con.execute("PRAGMA foreign_keys = ON;")
171
183
 
@@ -180,7 +192,11 @@ class SQLiteGraphCapability(ToolCapability):
180
192
  updated_at REAL
181
193
  )
182
194
  """)
183
- con.execute("CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(label);")
195
+ # (label, id): a label read in id order -- a keyset page, a bounded read -- is a
196
+ # range scan, never a sort of the whole label (findings a107e2f3 / ae2b196e); it
197
+ # serves a label-only lookup too, so the label-only index it replaces is dropped.
198
+ con.execute("CREATE INDEX IF NOT EXISTS idx_nodes_label_id ON nodes(label, id);")
199
+ con.execute("DROP INDEX IF EXISTS idx_nodes_label;")
184
200
 
185
201
  # Edges Table
186
202
  con.execute("""
@@ -207,18 +223,21 @@ class SQLiteGraphCapability(ToolCapability):
207
223
  # them up on the next initialize.
208
224
  con.execute("CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(source_id, relation_type);")
209
225
  con.execute("CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(target_id, relation_type);")
226
+ # The ingest record (DEC a9176261): the source HEADs this db was ingested from —
227
+ # provenance about the db itself, beside the graph and never in it.
228
+ con.execute("""
229
+ CREATE TABLE IF NOT EXISTS ingest_sources (
230
+ source TEXT PRIMARY KEY,
231
+ head TEXT NOT NULL
232
+ )
233
+ """)
210
234
  # H7 companion: planner statistics. Without sqlite_stat1 the
211
235
  # correlated-probe plans pick the WRONG composite index (the
212
236
  # target-side probe scans a whole document's PART_OF rows per
213
- # outer row: 5s; source-side probe: 8ms at corpus scale).
214
- # Cheap (~40ms at 13k segments); writable opens only. Guarded:
215
- # stats are an OPTIMIZATION — a corrupted DB must still LOAD so
216
- # integrity_check (the designed G3 gate) can diagnose it, never
217
- # fail at initialize via a stats side-effect.
218
- try:
219
- con.execute("ANALYZE;")
220
- except sqlite3.DatabaseError as e:
221
- logging.warning(f"ANALYZE skipped (run integrity_check): {e}")
237
+ # outer row: 5s; source-side probe: 8ms at corpus scale). They are
238
+ # kept by `_writing`'s PRAGMA optimize at the close of every write,
239
+ # this one included (a new index is analyzed there) -- no longer a
240
+ # full ANALYZE per open, O(db) on every read (finding ff3ede3d).
222
241
 
223
242
  def _connect(self) -> sqlite3.Connection: # Open DB connection (honors readonly; sets WAL + FKs)
224
243
  """Open a SQLite connection honoring the `readonly` config (SG-41).
@@ -236,6 +255,28 @@ class SQLiteGraphCapability(ToolCapability):
236
255
  con.execute("PRAGMA foreign_keys = ON;")
237
256
  return con
238
257
 
258
+ @contextmanager
259
+ def _writing(self) -> Iterator[sqlite3.Connection]: # One unit of write work: commit, planner upkeep, close
260
+ """Open a connection for one unit of write work: commit on success (roll back on
261
+ error), then keep the planner's statistics current, then close.
262
+
263
+ Statistics are written on the paths that change data, never on an open (finding
264
+ ff3ede3d: a read paid a full ANALYZE and took the write lock). PRAGMA optimize
265
+ analyzes a table only when it was never analyzed, gained an index, or moved ~10x
266
+ in size since, so a steady-state write pays a no-op check and a fresh rebuild db
267
+ gains statistics as it fills. Guarded: statistics are an OPTIMIZATION — a corrupted
268
+ DB must still answer integrity_check (the designed G3 gate)."""
269
+ con = self._connect()
270
+ try:
271
+ with con:
272
+ yield con
273
+ try:
274
+ con.execute("PRAGMA optimize=0x10002;")
275
+ except sqlite3.DatabaseError as e:
276
+ logging.warning(f"PRAGMA optimize skipped (run integrity_check): {e}")
277
+ finally:
278
+ con.close()
279
+
239
280
  # -------------------------------------------------------------------------
240
281
  # Helpers
241
282
  # -------------------------------------------------------------------------
@@ -304,7 +345,7 @@ class SQLiteGraphCapability(ToolCapability):
304
345
  stamping now() here clamped every rebuilt node to rebuild time)."""
305
346
  ids = []
306
347
  now = time.time()
307
- with self._connect() as con:
348
+ with self._writing() as con:
308
349
  for n in nodes:
309
350
  sources_json = json.dumps([s.to_dict() for s in n.sources])
310
351
  props_json = json.dumps(n.properties)
@@ -336,7 +377,7 @@ class SQLiteGraphCapability(ToolCapability):
336
377
  ids = []
337
378
  now = time.time()
338
379
  missing: List[Tuple[str, str, str]] = [] # (source_id, target_id, relation_type) of FK-skipped edges
339
- with self._connect() as con:
380
+ with self._writing() as con:
340
381
  con.execute("PRAGMA foreign_keys = ON;")
341
382
  for e in edges:
342
383
  props_json = json.dumps(e.properties)
@@ -419,11 +460,13 @@ class SQLiteGraphCapability(ToolCapability):
419
460
  label: str, # Node label to search for
420
461
  limit: int = 100 # Max results
421
462
  ) -> List[GraphNode]: # Matching nodes
422
- """Find nodes by label."""
463
+ """Find nodes by label, in id order: a bounded read sorts before it limits, so a
464
+ page is never an arbitrary subset (finding a107e2f3)."""
423
465
  results = []
424
466
  with self._connect() as con:
425
467
  cur = con.execute(
426
- "SELECT id, label, properties, sources, created_at, updated_at FROM nodes WHERE label = ? LIMIT ?",
468
+ "SELECT id, label, properties, sources, created_at, updated_at FROM nodes "
469
+ "WHERE label = ? ORDER BY id LIMIT ?",
427
470
  (label, limit)
428
471
  )
429
472
  for row in cur:
@@ -529,7 +572,7 @@ class SQLiteGraphCapability(ToolCapability):
529
572
  to rebuild time)."""
530
573
  properties = dict(properties)
531
574
  stamp = properties.pop("updated_at", None)
532
- with self._connect() as con:
575
+ with self._writing() as con:
533
576
  # Fetch existing to merge
534
577
  cur = con.execute("SELECT properties FROM nodes WHERE id = ?", (node_id,))
535
578
  row = cur.fetchone()
@@ -550,8 +593,14 @@ class SQLiteGraphCapability(ToolCapability):
550
593
  edge_id: str, # UUID of edge to update
551
594
  properties: Dict[str, Any] # Properties to merge/update
552
595
  ) -> bool: # True if successful
553
- """Partial update of edge properties."""
554
- with self._connect() as con:
596
+ """Partial update of edge properties.
597
+
598
+ A reserved `updated_at` key in `properties` sets the COLUMN instead of now()
599
+ and never lands in the JSON blob — the op clock's stamp (design 8f6f2343),
600
+ exactly as `update_node` takes it."""
601
+ properties = dict(properties)
602
+ stamp = properties.pop("updated_at", None)
603
+ with self._writing() as con:
555
604
  cur = con.execute("SELECT properties FROM edges WHERE id = ?", (edge_id,))
556
605
  row = cur.fetchone()
557
606
  if not row:
@@ -562,7 +611,7 @@ class SQLiteGraphCapability(ToolCapability):
562
611
 
563
612
  con.execute(
564
613
  "UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
565
- (json.dumps(existing), time.time(), edge_id)
614
+ (json.dumps(existing), stamp if stamp is not None else time.time(), edge_id)
566
615
  )
567
616
  return True
568
617
 
@@ -576,7 +625,7 @@ class SQLiteGraphCapability(ToolCapability):
576
625
  cascade: bool = True # Also delete connected edges
577
626
  ) -> int: # Number of nodes deleted
578
627
  """Delete nodes (and optionally connected edges)."""
579
- with self._connect() as con:
628
+ with self._writing() as con:
580
629
  if cascade:
581
630
  con.execute("PRAGMA foreign_keys = ON;") # Ensures cascade works
582
631
  else:
@@ -594,7 +643,7 @@ class SQLiteGraphCapability(ToolCapability):
594
643
  edge_ids: List[str] # UUIDs of edges to delete
595
644
  ) -> int: # Number of edges deleted
596
645
  """Delete edges."""
597
- with self._connect() as con:
646
+ with self._writing() as con:
598
647
  placeholders = ','.join('?' for _ in edge_ids)
599
648
  cur = con.execute(
600
649
  f"DELETE FROM edges WHERE id IN ({placeholders})",
@@ -651,10 +700,11 @@ class SQLiteGraphCapability(ToolCapability):
651
700
  nodes: List[GraphNode], # Nodes to import
652
701
  merge_strategy: str # "overwrite" | "skip" | "merge"
653
702
  ) -> int: # Count of rows written (inserted or updated)
654
- """Import nodes honoring merge_strategy (see import_graph)."""
703
+ """Import nodes honoring merge_strategy (see import_graph). Carried
704
+ created_at / updated_at are honored like add_nodes (0d50b921, design 8f6f2343)."""
655
705
  now = time.time()
656
706
  written = 0
657
- with self._connect() as con:
707
+ with self._writing() as con:
658
708
  for node in nodes:
659
709
  props_json = json.dumps(node.properties)
660
710
  sources_json = json.dumps([s.to_dict() for s in node.sources])
@@ -662,7 +712,10 @@ class SQLiteGraphCapability(ToolCapability):
662
712
  if row is None:
663
713
  con.execute(
664
714
  "INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
665
- (node.id, node.label, props_json, sources_json, now, now),
715
+ (node.id, node.label, props_json, sources_json,
716
+ node.created_at if node.created_at is not None else now,
717
+ node.updated_at if node.updated_at is not None
718
+ else (node.created_at if node.created_at is not None else now)),
666
719
  )
667
720
  written += 1
668
721
  elif merge_strategy == "skip":
@@ -670,7 +723,8 @@ class SQLiteGraphCapability(ToolCapability):
670
723
  elif merge_strategy == "overwrite":
671
724
  con.execute(
672
725
  "UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
673
- (node.label, props_json, sources_json, now, node.id),
726
+ (node.label, props_json, sources_json,
727
+ node.updated_at if node.updated_at is not None else now, node.id),
674
728
  )
675
729
  written += 1
676
730
  else: # merge
@@ -686,7 +740,8 @@ class SQLiteGraphCapability(ToolCapability):
686
740
  seen.add(key)
687
741
  con.execute(
688
742
  "UPDATE nodes SET label = ?, properties = ?, sources = ?, updated_at = ? WHERE id = ?",
689
- (node.label, json.dumps(merged_props), json.dumps(existing_sources), now, node.id),
743
+ (node.label, json.dumps(merged_props), json.dumps(existing_sources),
744
+ node.updated_at if node.updated_at is not None else now, node.id),
690
745
  )
691
746
  written += 1
692
747
  return written
@@ -696,10 +751,11 @@ class SQLiteGraphCapability(ToolCapability):
696
751
  edges: List[GraphEdge], # Edges to import
697
752
  merge_strategy: str # "overwrite" | "skip" | "merge"
698
753
  ) -> int: # Count of rows written (inserted or updated)
699
- """Import edges honoring merge_strategy (see import_graph)."""
754
+ """Import edges honoring merge_strategy (see import_graph). Carried
755
+ created_at / updated_at are honored like add_edges (0d50b921, design 8f6f2343)."""
700
756
  now = time.time()
701
757
  written = 0
702
- with self._connect() as con:
758
+ with self._writing() as con:
703
759
  for edge in edges:
704
760
  props_json = json.dumps(edge.properties)
705
761
  row = con.execute("SELECT properties FROM edges WHERE id = ?", (edge.id,)).fetchone()
@@ -707,7 +763,10 @@ class SQLiteGraphCapability(ToolCapability):
707
763
  try:
708
764
  con.execute(
709
765
  "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),
766
+ (edge.id, edge.source_id, edge.target_id, edge.relation_type, props_json,
767
+ edge.created_at if edge.created_at is not None else now,
768
+ edge.updated_at if edge.updated_at is not None
769
+ else (edge.created_at if edge.created_at is not None else now)),
711
770
  )
712
771
  written += 1
713
772
  except sqlite3.IntegrityError as err:
@@ -717,7 +776,8 @@ class SQLiteGraphCapability(ToolCapability):
717
776
  elif merge_strategy == "overwrite":
718
777
  con.execute(
719
778
  "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),
779
+ (edge.source_id, edge.target_id, edge.relation_type, props_json,
780
+ edge.updated_at if edge.updated_at is not None else now, edge.id),
721
781
  )
722
782
  written += 1
723
783
  else: # merge
@@ -725,7 +785,8 @@ class SQLiteGraphCapability(ToolCapability):
725
785
  merged.update(edge.properties)
726
786
  con.execute(
727
787
  "UPDATE edges SET properties = ?, updated_at = ? WHERE id = ?",
728
- (json.dumps(merged), now, edge.id),
788
+ (json.dumps(merged), edge.updated_at if edge.updated_at is not None else now,
789
+ edge.id),
729
790
  )
730
791
  written += 1
731
792
  return written
@@ -820,7 +881,7 @@ def query_nodes(
820
881
  return NodeQueryResult(nodes=[self._row_to_node(r) for r in rows])
821
882
  out = []
822
883
  for r in rows:
823
- d = dict(zip(keys, r))
884
+ d = decode_projected_row(keys, r, NODE_STRUCTURAL_KEYS) # properties keep their JSON types
824
885
  if "sources" in d and d["sources"]:
825
886
  d["sources"] = json.loads(d["sources"]) # ref dicts (CR-19 shape)
826
887
  out.append(d)
@@ -843,7 +904,7 @@ def query_edges(
843
904
  con.close()
844
905
  if mode == "full":
845
906
  return EdgeQueryResult(edges=[self._row_to_edge(r) for r in rows])
846
- return EdgeQueryResult(rows=[dict(zip(keys, r)) for r in rows])
907
+ return EdgeQueryResult(rows=[decode_projected_row(keys, r, EDGE_STRUCTURAL_KEYS) for r in rows])
847
908
 
848
909
 
849
910
  def raw_query(
@@ -881,7 +942,39 @@ def integrity_check(self) -> Dict[str, Any]: # {"ok": bool, "errors": [...], "b
881
942
  return {"ok": ok, "errors": [] if ok else rows, "backend": "sqlite"}
882
943
 
883
944
 
945
+ def record_ingest_sources(
946
+ self,
947
+ sources: Dict[str, str], # source id -> the HEAD commit the ingest read
948
+ ) -> int: # Sources recorded
949
+ """Record the source HEADs this db was ingested from, REPLACING any earlier record
950
+ (DEC a9176261). The rebuild writes it into the fresh db it builds, so the record and the
951
+ rows it describes swap in together; it is provenance about the db, not a graph row, so
952
+ it is neither stamped by the op clock nor seen by the write observer."""
953
+ rows = sorted((str(k), str(v)) for k, v in dict(sources).items())
954
+ with self._writing() as con:
955
+ con.execute("DELETE FROM ingest_sources")
956
+ con.executemany("INSERT INTO ingest_sources (source, head) VALUES (?, ?)", rows)
957
+ return len(rows)
958
+
959
+
960
+ def ingest_sources(self) -> Dict[str, str]: # source id -> HEAD; {} when the db holds no record
961
+ """The source HEADs this db was ingested from, on a fresh read-only connection. A db built
962
+ before the record existed has no table: it reads as no record, never as an error."""
963
+ con = sqlite3.connect(f"file:{self._db_path}?mode=ro", uri=True)
964
+ try:
965
+ rows = con.execute("SELECT source, head FROM ingest_sources ORDER BY source").fetchall()
966
+ except sqlite3.OperationalError as e:
967
+ if "no such table" not in str(e):
968
+ raise
969
+ rows = []
970
+ finally:
971
+ con.close()
972
+ return {s: h for s, h in rows}
973
+
974
+
884
975
  SQLiteGraphCapability.query_nodes = query_nodes
885
976
  SQLiteGraphCapability.query_edges = query_edges
886
977
  SQLiteGraphCapability.raw_query = raw_query
887
978
  SQLiteGraphCapability.integrity_check = integrity_check
979
+ SQLiteGraphCapability.record_ingest_sources = record_ingest_sources
980
+ 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
@@ -167,16 +171,22 @@ EDGE_FULL_COLUMNS = "e.id, e.source_id, e.target_id, e.relation_type, e.properti
167
171
 
168
172
 
169
173
  def _order_limit_sql(
170
- query, # NodeQuery or EdgeQuery (order_by / limit / offset fields)
174
+ query, # NodeQuery or EdgeQuery (order_by / limit / offset / after fields)
171
175
  props_col: str, # Properties column expression for ORDER BY paths
176
+ id_col: str, # Row id column: the keyset key and the tie-break
172
177
  params: List[Any], # Bound-params list (appended in place)
173
178
  ) -> str: # ORDER BY / LIMIT / OFFSET tail
174
- """Compile the shared ordering + paging tail."""
179
+ """Compile the shared ordering + paging tail. A property ordering breaks ties on the
180
+ id, and a bounded or keyset read with no ordering is ordered by the id, so a page is
181
+ never an arbitrary subset (findings a107e2f3 / ae2b196e)."""
175
182
  tail = ""
176
183
  if query.order_by is not None:
177
184
  tail += (f" ORDER BY json_extract({props_col}, "
178
185
  f"{_json_path(query.order_by.prop)})"
179
- + (" DESC" if query.order_by.descending else " ASC"))
186
+ + (" DESC" if query.order_by.descending else " ASC")
187
+ + f", {id_col}")
188
+ elif query.limit is not None or query.offset or query.after is not None:
189
+ tail += f" ORDER BY {id_col}"
180
190
  if query.limit is not None or query.offset:
181
191
  tail += " LIMIT ?"
182
192
  params.append(query.limit if query.limit is not None else -1)
@@ -202,6 +212,9 @@ def translate_node_query(
202
212
  if q.ids is not None:
203
213
  where.append("n.id IN (SELECT value FROM json_each(?))")
204
214
  params.append(json.dumps(list(q.ids)))
215
+ if q.after is not None:
216
+ where.append("n.id > ?")
217
+ params.append(q.after)
205
218
  if q.label is not None:
206
219
  where.append("n.label = ?")
207
220
  params.append(q.label)
@@ -238,13 +251,13 @@ def translate_node_query(
238
251
  select_parts.append("n.sources")
239
252
  keys.append("sources")
240
253
  else:
241
- select_parts.append(f"json_extract(n.properties, {_json_path(name)})")
254
+ select_parts.append(f"n.properties -> {_json_path(name)}")
242
255
  keys.append(name)
243
- tail = _order_limit_sql(q, "n.properties", params)
256
+ tail = _order_limit_sql(q, "n.properties", "n.id", params)
244
257
  return (f"SELECT {', '.join(select_parts)} FROM nodes AS n{where_sql}{tail}",
245
258
  params, "rows", keys)
246
259
 
247
- tail = _order_limit_sql(q, "n.properties", params)
260
+ tail = _order_limit_sql(q, "n.properties", "n.id", params)
248
261
  return (f"SELECT {NODE_FULL_COLUMNS} FROM nodes AS n{where_sql}{tail}",
249
262
  params, "full", None)
250
263
 
@@ -264,6 +277,9 @@ def translate_edge_query(
264
277
  if q.ids is not None:
265
278
  where.append("e.id IN (SELECT value FROM json_each(?))")
266
279
  params.append(json.dumps(list(q.ids)))
280
+ if q.after is not None:
281
+ where.append("e.id > ?")
282
+ params.append(q.after)
267
283
  if q.relation_type is not None:
268
284
  where.append("e.relation_type = ?")
269
285
  params.append(q.relation_type)
@@ -306,12 +322,30 @@ def translate_edge_query(
306
322
  select_parts.append("e.relation_type")
307
323
  keys.append("relation_type")
308
324
  else:
309
- select_parts.append(f"json_extract(e.properties, {_json_path(name)})")
325
+ select_parts.append(f"e.properties -> {_json_path(name)}")
310
326
  keys.append(name)
311
- tail = _order_limit_sql(q, "e.properties", params)
327
+ tail = _order_limit_sql(q, "e.properties", "e.id", params)
312
328
  return (f"SELECT {', '.join(select_parts)} FROM edges AS e{where_sql}{tail}",
313
329
  params, "rows", keys)
314
330
 
315
- tail = _order_limit_sql(q, "e.properties", params)
331
+ tail = _order_limit_sql(q, "e.properties", "e.id", params)
316
332
  return (f"SELECT {EDGE_FULL_COLUMNS} FROM edges AS e{where_sql}{tail}",
317
333
  params, "full", None)
334
+
335
+
336
+ # The structural columns a projection selects as-is; every other projected key is a property,
337
+ # selected as JSON (`->`) and decoded by `decode_projected_row`.
338
+ NODE_STRUCTURAL_KEYS = frozenset({"id", "label", "created_at", "updated_at", "sources"})
339
+ EDGE_STRUCTURAL_KEYS = frozenset({"id", "source_id", "target_id", "relation_type"})
340
+
341
+
342
+ def decode_projected_row(
343
+ keys: List[str], # The projected keys, in select order
344
+ values: Any, # One cursor row
345
+ structural: frozenset, # The keys selected as plain columns
346
+ ) -> dict: # The row: structural columns as-is, each property decoded to its JSON type (absent -> None)
347
+ """Zip a projected row, decoding every property column from its JSON representation."""
348
+ out = {}
349
+ for k, v in zip(keys, values):
350
+ out[k] = v if k in structural or v is None else json.loads(v)
351
+ 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.30
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
@@ -15,7 +15,7 @@ Requires-Python: >=3.12
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
17
  Requires-Dist: cjm-substrate>=0.0.51
18
- Requires-Dist: cjm-context-graph-primitives>=0.0.12
18
+ Requires-Dist: cjm-context-graph-primitives>=0.0.18
19
19
  Dynamic: license-file
20
20
 
21
21
  # cjm-capability-graph-sqlite
@@ -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
 
@@ -0,0 +1,2 @@
1
+ cjm-substrate>=0.0.51
2
+ cjm-context-graph-primitives>=0.0.18
@@ -12,7 +12,7 @@ license = {text = "Apache-2.0"}
12
12
  authors = [{name = "Christian J. Mills", email = "9126128+cj-mills@users.noreply.github.com"}]
13
13
  keywords = ['python']
14
14
  classifiers = ["Natural Language :: English", "Intended Audience :: Developers", "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
15
- dependencies = ['cjm-substrate>=0.0.51', 'cjm-context-graph-primitives>=0.0.12']
15
+ dependencies = ['cjm-substrate>=0.0.51', 'cjm-context-graph-primitives>=0.0.18']
16
16
 
17
17
  [project.urls]
18
18
  Repository = "https://github.com/cj-mills/cjm-capability-graph-sqlite"
@@ -3,18 +3,17 @@
3
3
  Projected from the capability notebook's demo/test cells at the c25780e8 flip:
4
4
  the CRUD/round-trip walk, the SG-41 smells (WAL / readonly / guarded query /
5
5
  merge_strategy), and the stage-4 typed-surface smoke test."""
6
+
6
7
  import sqlite3
7
8
  import uuid
8
9
 
9
10
  import pytest
10
-
11
11
  from cjm_capability_graph_sqlite.capability import SQLiteGraphCapability
12
12
  from cjm_context_graph_primitives.graph import GraphContext, GraphEdge, GraphNode
13
13
  from cjm_context_graph_primitives.locators import FileRef
14
14
  from cjm_context_graph_primitives.provenance import SourceRef
15
- from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy,
16
- PropertyPredicate, RawQuery,
17
- RelationPredicate)
15
+ from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy, PropertyPredicate,
16
+ RawQuery, RelationPredicate)
18
17
  from cjm_context_graph_primitives.slices import FullContent
19
18
  from cjm_substrate.core.errors import CapabilityInputError
20
19
 
@@ -140,6 +139,34 @@ def test_sg41_wal_query_guard_merge_readonly(tmp_path):
140
139
  ro.cleanup()
141
140
 
142
141
 
142
+ def test_reads_and_opens_never_write_writes_keep_statistics(tmp_path):
143
+ # Finding ff3ede3d: statistics are written by the paths that change data (PRAGMA
144
+ # optimize at the close of each write), so an open or a read commits nothing -- an
145
+ # observer's data_version moves only when ANOTHER connection commits.
146
+ db = str(tmp_path / "stats.db")
147
+ w = SQLiteGraphCapability()
148
+ w.initialize({"db_path": db})
149
+ indexes = {r[0] for r in sqlite3.connect(db).execute(
150
+ "SELECT name FROM sqlite_master WHERE type = 'index'")}
151
+ assert "idx_nodes_label_id" in indexes and "idx_nodes_label" not in indexes
152
+ w.add_nodes([GraphNode(id=f"n{i:03d}", label="Doc", properties={"i": i}) for i in range(50)])
153
+ stats = {r[0] for r in sqlite3.connect(db).execute(
154
+ "SELECT idx FROM sqlite_stat1 WHERE tbl = 'nodes'")}
155
+ assert "idx_nodes_label_id" in stats
156
+ w.cleanup()
157
+
158
+ observer = sqlite3.connect(db)
159
+ before = observer.execute("PRAGMA data_version").fetchone()[0]
160
+ for readonly in (True, False):
161
+ r = SQLiteGraphCapability()
162
+ r.initialize({"db_path": db, "readonly": readonly})
163
+ assert [n.id for n in r.find_nodes_by_label("Doc", limit=3)] == ["n000", "n001", "n002"]
164
+ assert r.query_nodes(NodeQuery(label="Doc", count=True)).count == 50
165
+ r.cleanup()
166
+ assert observer.execute("PRAGMA data_version").fetchone()[0] == before
167
+ observer.close()
168
+
169
+
143
170
  def test_typed_surface_smoke(cap):
144
171
  doc = str(uuid.uuid4())
145
172
  segs = [str(uuid.uuid4()) for _ in range(3)]
@@ -221,3 +248,67 @@ def test_add_edges_aggregates_missing_endpoint_warnings(cap, caplog):
221
248
  msg = fk_lines[0].getMessage()
222
249
  assert "25 of 26" in msg and "sample:" in msg
223
250
  assert not any("Edge creation error (likely missing node)" in r.getMessage() for r in caplog.records)
251
+
252
+
253
+ def test_update_edge_reserved_updated_at_sets_column(cap):
254
+ # The op clock (design 8f6f2343): update_edge takes the reserved `updated_at` exactly as
255
+ # update_node does — the COLUMN, never the JSON blob; without it, now()-stamping stands.
256
+ import json
257
+
258
+ a, b = str(uuid.uuid4()), str(uuid.uuid4())
259
+ cap.add_nodes([GraphNode(id=a, label="Person"), GraphNode(id=b, label="Person")])
260
+ eid = str(uuid.uuid4())
261
+ cap.add_edges([GraphEdge(id=eid, source_id=a, target_id=b, relation_type="KNOWS")])
262
+ assert cap.update_edge(eid, {"since": 1999, "updated_at": 1234.5})
263
+ con = sqlite3.connect(cap._db_path)
264
+ props, stamp = con.execute("SELECT properties, updated_at FROM edges WHERE id = ?",
265
+ (eid,)).fetchone()
266
+ con.close()
267
+ assert json.loads(props) == {"since": 1999} and stamp == 1234.5
268
+ assert cap.update_edge(eid, {"since": 2000})
269
+ con = sqlite3.connect(cap._db_path)
270
+ (stamp2,) = con.execute("SELECT updated_at FROM edges WHERE id = ?", (eid,)).fetchone()
271
+ con.close()
272
+ assert stamp2 > 1234.5
273
+
274
+
275
+ def test_import_honors_carried_times(cap):
276
+ # import_graph keeps carried created_at / updated_at like add_nodes / add_edges.
277
+ a, b = str(uuid.uuid4()), str(uuid.uuid4())
278
+ eid = str(uuid.uuid4())
279
+ cap.import_graph(GraphContext(
280
+ nodes=[GraphNode(id=a, label="P", created_at=10.0, updated_at=20.0),
281
+ GraphNode(id=b, label="P", created_at=30.0)],
282
+ edges=[GraphEdge(id=eid, source_id=a, target_id=b, relation_type="R", created_at=40.0)]))
283
+ con = sqlite3.connect(cap._db_path)
284
+ rows = dict((r[0], r[1:]) for r in con.execute("SELECT id, created_at, updated_at FROM nodes"))
285
+ erow = con.execute("SELECT created_at, updated_at FROM edges WHERE id = ?", (eid,)).fetchone()
286
+ con.close()
287
+ assert rows[a] == (10.0, 20.0) and rows[b] == (30.0, 30.0) and erow == (40.0, 40.0)
288
+
289
+
290
+ def test_ingest_record_replaces_reads_readonly_and_tolerates_a_legacy_db(cap, tmp_path):
291
+ """The ingest record (DEC a9176261): a replace, never a merge; beside the graph (no row,
292
+ no op-clock stamp); readable through a read-only open; a db built before the table
293
+ existed reads as no record."""
294
+ assert cap.ingest_sources() == {}
295
+ assert cap.record_ingest_sources({"archive:/site/posts": "a" * 40, "repo:/r/x": "b" * 40}) == 2
296
+ assert cap.record_ingest_sources({"repo:/r/x": "c" * 40}) == 1
297
+ assert cap.ingest_sources() == {"repo:/r/x": "c" * 40}
298
+ assert cap.get_schema()["counts"] == {} # beside the graph, never in it
299
+
300
+ ro = SQLiteGraphCapability()
301
+ ro.initialize({"db_path": cap._db_path, "readonly": True})
302
+ assert ro.ingest_sources() == {"repo:/r/x": "c" * 40}
303
+ ro.cleanup()
304
+
305
+ legacy = str(tmp_path / "legacy.db")
306
+ con = sqlite3.connect(legacy)
307
+ con.execute("CREATE TABLE nodes (id TEXT PRIMARY KEY, label TEXT NOT NULL, properties JSON, "
308
+ "sources JSON, created_at REAL, updated_at REAL)")
309
+ con.commit()
310
+ con.close()
311
+ old = SQLiteGraphCapability()
312
+ old.initialize({"db_path": legacy, "readonly": True})
313
+ assert old.ingest_sources() == {}
314
+ old.cleanup()
@@ -3,17 +3,19 @@
3
3
  Projected from the query_translation notebook's behavior-test cell at the
4
4
  c25780e8 flip: the translation contract is verified against the PRODUCTION
5
5
  schema in an in-memory DB — by what it returns, not by SQL strings."""
6
+
6
7
  import json
7
8
  import sqlite3
8
9
 
9
10
  import pytest
10
-
11
- from cjm_capability_graph_sqlite.query_translation import (translate_edge_query,
11
+ from cjm_capability_graph_sqlite.query_translation import (decode_projected_row,
12
+ EDGE_STRUCTURAL_KEYS,
13
+ NODE_STRUCTURAL_KEYS,
14
+ translate_edge_query,
12
15
  translate_node_query)
13
16
  from cjm_context_graph_primitives.locators import FileRef
14
- from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy,
15
- PropertyPredicate, RelationPredicate,
16
- SourcePredicate)
17
+ from cjm_context_graph_primitives.query import (EdgeQuery, NodeQuery, OrderBy, PropertyPredicate,
18
+ RelationPredicate, SourcePredicate)
17
19
  from cjm_substrate.core.errors import CapabilityInputError
18
20
 
19
21
 
@@ -71,7 +73,8 @@ def run(con, q):
71
73
  return cur.fetchone()[0]
72
74
  rows = cur.fetchall()
73
75
  if mode == "rows":
74
- return [dict(zip(keys, r)) for r in rows]
76
+ structural = NODE_STRUCTURAL_KEYS if isinstance(q, NodeQuery) else EDGE_STRUCTURAL_KEYS
77
+ return [decode_projected_row(keys, r, structural) for r in rows]
75
78
  return rows
76
79
 
77
80
 
@@ -85,6 +88,21 @@ def test_spine_read_ordered_projection(con):
85
88
  assert json.loads(rows[1]["sources"])[0]["content_hash"] == "sha256:ab"
86
89
 
87
90
 
91
+ def test_projected_properties_keep_their_json_types(con):
92
+ # A projected object stays an object and a missing key is None (json_extract used to
93
+ # hand arrays and objects back as JSON TEXT — a projected list read as a string).
94
+ rows = run(con, NodeQuery(label="Correction", project=["payload", "correction_type"]))
95
+ by = {r["id"]: r for r in rows}
96
+ assert by["c1"]["payload"] == {"document_id": "doc-1", "segment_id": "s1"}
97
+ assert by["c2"]["correction_type"] == "text_content"
98
+ rows = run(con, NodeQuery(label="Segment", related=RelationPredicate("PART_OF", node_id="doc-1"),
99
+ order_by=OrderBy(prop="index"), project=["text", "start_time"]))
100
+ assert [r["text"] for r in rows] == ["It's 100% real_deal", "hello world", "", None]
101
+ assert rows[1]["start_time"] == 1.0
102
+ rows = run(con, EdgeQuery(relation_type="REVIEWED", project=["decision"]))
103
+ assert rows == [{"id": "rev", "source_id": "sess-1", "target_id": "s0", "decision": "corrected"}]
104
+
105
+
88
106
  def test_structural_timestamp_projection(con):
89
107
  # created_at/updated_at project from the storage COLUMNS (the stamps),
90
108
  # not json_extract over properties — no Segment carries them as props.
@@ -163,6 +181,27 @@ def test_ne_order_desc_limit_and_paging(con):
163
181
  assert [r[0] for r in rows] == ["s1", "s2"]
164
182
 
165
183
 
184
+ def test_keyset_pages_and_deterministic_bounded_order(con):
185
+ # The keyset cursor (finding ae2b196e): a page is the rows after the last id seen, in
186
+ # id order, so a pass over the whole answer visits every row exactly once.
187
+ every = sorted(r["id"] for r in run(con, NodeQuery(label="Segment", project=[])))
188
+ seen, after = [], None
189
+ while True:
190
+ rows = run(con, NodeQuery(label="Segment", project=[], limit=2, after=after))
191
+ seen.extend(r["id"] for r in rows)
192
+ if len(rows) < 2:
193
+ break
194
+ after = rows[-1]["id"]
195
+ assert seen == every
196
+ # A bounded read with no ordering is ordered by id, never an arbitrary subset
197
+ # (finding a107e2f3); a property ordering breaks its ties on the id.
198
+ assert [r[0] for r in run(con, NodeQuery(label="Segment", limit=3))] == every[:3]
199
+ tied = run(con, NodeQuery(label="Segment", where=[PropertyPredicate("index", "eq", 0)],
200
+ order_by=OrderBy(prop="index")))
201
+ assert [r[0] for r in tied] == ["other", "s0"]
202
+ assert [r[0] for r in run(con, EdgeQuery(relation_type="NEXT", after="n0"))] == ["n1", "nx"]
203
+
204
+
166
205
  def test_in_op_ids_batch_and_top_level_source(con):
167
206
  rows = run(con, NodeQuery(label="Segment", where=[PropertyPredicate("index", "in", [0, 2])],
168
207
  related=RelationPredicate("PART_OF", node_id="doc-1")))
@@ -1 +0,0 @@
1
- __version__ = "0.0.28"
@@ -1,2 +0,0 @@
1
- cjm-substrate>=0.0.51
2
- cjm-context-graph-primitives>=0.0.12