cjm-capability-graph-sqlite 0.0.26__tar.gz → 0.0.28__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.28}/PKG-INFO +1 -1
  2. cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite/__init__.py +1 -0
  3. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/cjm_capability_graph_sqlite/capability.py +45 -7
  4. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/cjm_capability_graph_sqlite/query_translation.py +7 -3
  5. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28/cjm_capability_graph_sqlite.egg-info}/PKG-INFO +1 -1
  6. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/tests/test_capability.py +44 -0
  7. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/tests/test_query_translation.py +12 -0
  8. cjm_capability_graph_sqlite-0.0.26/cjm_capability_graph_sqlite/__init__.py +0 -1
  9. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/LICENSE +0 -0
  10. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/MANIFEST.in +0 -0
  11. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/README.md +0 -0
  12. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/cjm_capability_graph_sqlite.egg-info/SOURCES.txt +0 -0
  13. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/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.28}/cjm_capability_graph_sqlite.egg-info/requires.txt +0 -0
  15. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/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.28}/pyproject.toml +0 -0
  17. {cjm_capability_graph_sqlite-0.0.26 → cjm_capability_graph_sqlite-0.0.28}/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.28
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
@@ -0,0 +1 @@
1
+ __version__ = "0.0.28"
@@ -82,6 +82,12 @@ class SQLiteGraphCapability(ToolCapability):
82
82
 
83
83
  config_class = SQLiteGraphCapabilityConfig
84
84
 
85
+ # Substrate observability class (DEC 8bf656c0 as manifest DATA, finding 0d886ffe B):
86
+ # graph-storage ops are compute-light, re-derivable work — routine per-op journal
87
+ # rows and worker-lifecycle rows are noise (they filled 97% of journal.db); failures
88
+ # still journal. `cjm-ctl regenerate-manifest` records this on the manifest.
89
+ observability_class = "ambient"
90
+
85
91
  def __init__(self):
86
92
  self.logger = logging.getLogger(f"{__name__}.{type(self).__name__}")
87
93
  self.config: SQLiteGraphCapabilityConfig = None
@@ -291,17 +297,23 @@ class SQLiteGraphCapability(ToolCapability):
291
297
  self,
292
298
  nodes: List[GraphNode] # Nodes to create
293
299
  ) -> List[str]: # Created node IDs
294
- """Bulk create nodes."""
300
+ """Bulk create nodes.
301
+
302
+ A node arriving with created_at/updated_at already set keeps them —
303
+ journal REPLAY restores temporal provenance this way (finding 0d50b921:
304
+ stamping now() here clamped every rebuilt node to rebuild time)."""
295
305
  ids = []
296
306
  now = time.time()
297
307
  with self._connect() as con:
298
308
  for n in nodes:
299
309
  sources_json = json.dumps([s.to_dict() for s in n.sources])
300
310
  props_json = json.dumps(n.properties)
311
+ created = n.created_at if n.created_at is not None else now
312
+ updated = n.updated_at if n.updated_at is not None else created
301
313
  try:
302
314
  con.execute(
303
315
  "INSERT INTO nodes (id, label, properties, sources, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
304
- (n.id, n.label, props_json, sources_json, now, now)
316
+ (n.id, n.label, props_json, sources_json, created, updated)
305
317
  )
306
318
  ids.append(n.id)
307
319
  except sqlite3.IntegrityError:
@@ -312,21 +324,40 @@ class SQLiteGraphCapability(ToolCapability):
312
324
  self,
313
325
  edges: List[GraphEdge] # Edges to create
314
326
  ) -> List[str]: # Created edge IDs
315
- """Bulk create edges."""
327
+ """Bulk create edges.
328
+
329
+ Carried created_at/updated_at are honored like add_nodes (0d50b921).
330
+
331
+ Missing-endpoint edges (FOREIGN KEY failures) are AGGREGATED into one warning
332
+ per call — count + a bounded sample — never one line per edge: a rebuild replays
333
+ every journaled edge whose endpoint is absent, and per-edge logging flooded
334
+ diagnostics.db to 1.5M rows / 633 MB (finding 0d886ffe). The skipped count is
335
+ recoverable by the caller as `len(edges) - len(ids)`."""
316
336
  ids = []
317
337
  now = time.time()
338
+ missing: List[Tuple[str, str, str]] = [] # (source_id, target_id, relation_type) of FK-skipped edges
318
339
  with self._connect() as con:
319
340
  con.execute("PRAGMA foreign_keys = ON;")
320
341
  for e in edges:
321
342
  props_json = json.dumps(e.properties)
343
+ created = e.created_at if e.created_at is not None else now
344
+ updated = e.updated_at if e.updated_at is not None else created
322
345
  try:
323
346
  con.execute(
324
347
  "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)
348
+ (e.id, e.source_id, e.target_id, e.relation_type, props_json, created, updated)
326
349
  )
327
350
  ids.append(e.id)
328
351
  except sqlite3.IntegrityError as err:
329
- self.logger.warning(f"Edge creation error (likely missing node): {err}")
352
+ if "FOREIGN KEY" in str(err):
353
+ missing.append((e.source_id, e.target_id, e.relation_type))
354
+ else:
355
+ self.logger.warning(f"Edge creation error ({e.id}): {err}")
356
+ if missing:
357
+ sample = "; ".join(f"{s[:8]}->{t[:8]} {r}" for s, t, r in missing[:3])
358
+ self.logger.warning(
359
+ f"add_edges: {len(missing)} of {len(edges)} edge(s) skipped — endpoint node missing "
360
+ f"(FOREIGN KEY); sample: {sample}")
330
361
  return ids
331
362
 
332
363
  # -------------------------------------------------------------------------
@@ -490,7 +521,14 @@ class SQLiteGraphCapability(ToolCapability):
490
521
  node_id: str, # UUID of node to update
491
522
  properties: Dict[str, Any] # Properties to merge/update
492
523
  ) -> bool: # True if successful
493
- """Partial update of node properties."""
524
+ """Partial update of node properties.
525
+
526
+ A reserved `updated_at` key in `properties` sets the COLUMN instead of
527
+ now() and never lands in the JSON blob — journal REPLAY restores a STATE
528
+ op's true time this way (0d50b921 residual: later-STATE nodes clamped
529
+ to rebuild time)."""
530
+ properties = dict(properties)
531
+ stamp = properties.pop("updated_at", None)
494
532
  with self._connect() as con:
495
533
  # Fetch existing to merge
496
534
  cur = con.execute("SELECT properties FROM nodes WHERE id = ?", (node_id,))
@@ -503,7 +541,7 @@ class SQLiteGraphCapability(ToolCapability):
503
541
 
504
542
  con.execute(
505
543
  "UPDATE nodes SET properties = ?, updated_at = ? WHERE id = ?",
506
- (json.dumps(existing), time.time(), node_id)
544
+ (json.dumps(existing), stamp if stamp is not None else time.time(), node_id)
507
545
  )
508
546
  return True
509
547
 
@@ -22,9 +22,10 @@ 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)."""
28
29
 
29
30
  import json
30
31
  import re
@@ -230,6 +231,9 @@ def translate_node_query(
230
231
  if name == "label":
231
232
  select_parts.append("n.label")
232
233
  keys.append("label")
234
+ elif name in ("created_at", "updated_at"):
235
+ select_parts.append("n." + name) # storage stamp, not a property
236
+ keys.append(name)
233
237
  elif name == "sources":
234
238
  select_parts.append("n.sources")
235
239
  keys.append("sources")
@@ -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.28
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
@@ -177,3 +177,47 @@ 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)
@@ -85,6 +85,18 @@ def test_spine_read_ordered_projection(con):
85
85
  assert json.loads(rows[1]["sources"])[0]["content_hash"] == "sha256:ab"
86
86
 
87
87
 
88
+ def test_structural_timestamp_projection(con):
89
+ # created_at/updated_at project from the storage COLUMNS (the stamps),
90
+ # not json_extract over properties — no Segment carries them as props.
91
+ rows = run(con, NodeQuery(label="Segment",
92
+ related=RelationPredicate("PART_OF", node_id="doc-1"),
93
+ order_by=OrderBy(prop="index"),
94
+ project=["index", "created_at", "updated_at"]))
95
+ assert [r["created_at"] for r in rows] == [1.0, 1.0, 1.0, 1.0]
96
+ assert rows[0]["updated_at"] == 1.0
97
+ assert rows[0]["id"] == "s0"
98
+
99
+
88
100
  def test_empty_filter_or_case_two_counts(con):
89
101
  empty_eq = run(con, NodeQuery(label="Segment", where=[PropertyPredicate("text", "eq", "")],
90
102
  related=RelationPredicate("PART_OF", node_id="doc-1"), count=True))
@@ -1 +0,0 @@
1
- __version__ = "0.0.26"