okfgraph 0.2.4__py3-none-any.whl
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.
- okfgraph/__init__.py +7 -0
- okfgraph/cli.py +1364 -0
- okfgraph/components/__init__.py +61 -0
- okfgraph/components/converters.py +128 -0
- okfgraph/components/delta.py +271 -0
- okfgraph/components/diff.py +172 -0
- okfgraph/components/doctor.py +216 -0
- okfgraph/components/embedding.py +323 -0
- okfgraph/components/export.py +354 -0
- okfgraph/components/image_assets.py +319 -0
- okfgraph/components/import_.py +1110 -0
- okfgraph/components/ingest.py +495 -0
- okfgraph/components/links.py +133 -0
- okfgraph/components/lint.py +118 -0
- okfgraph/components/purge.py +342 -0
- okfgraph/components/ranking.py +168 -0
- okfgraph/components/schema.py +443 -0
- okfgraph/components/search.py +1006 -0
- okfgraph/config.py +393 -0
- okfgraph/images.py +435 -0
- okfgraph/mcp_server.py +461 -0
- okfgraph/models.py +128 -0
- okfgraph/router.py +485 -0
- okfgraph/security.py +269 -0
- okfgraph/tools.py +460 -0
- okfgraph-0.2.4.dist-info/METADATA +25 -0
- okfgraph-0.2.4.dist-info/RECORD +31 -0
- okfgraph-0.2.4.dist-info/WHEEL +5 -0
- okfgraph-0.2.4.dist-info/entry_points.txt +3 -0
- okfgraph-0.2.4.dist-info/licenses/LICENSE +6 -0
- okfgraph-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""Schema / metadata / index orchestration extracted during the OKFRouter Phase 1 refactor.
|
|
2
|
+
|
|
3
|
+
Bodies are verbatim from okfgraph/router.py; the facade (OKFRouter) owns
|
|
4
|
+
the shared resources (conn, embedder, tokenizer, ...) and injects them
|
|
5
|
+
here. Public callers reach these via router.<method> (component bridge).
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class SchemaManager:
|
|
13
|
+
"""Owns schema migrations, meta KV store, and search-index rebuild."""
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 5 # bumped when the on-disk schema changes
|
|
16
|
+
|
|
17
|
+
def __init__(self, conn, embedding_dim, write_lock_ctx):
|
|
18
|
+
self.conn = conn
|
|
19
|
+
self.embedding_dim = embedding_dim
|
|
20
|
+
self._write_lock_ctx = write_lock_ctx
|
|
21
|
+
self._search_available = False
|
|
22
|
+
|
|
23
|
+
def _migrate_v1_to_v2(self) -> None:
|
|
24
|
+
"""v1 → v2: Add Chunk table, PART_OF rel, Chunk vector/FTS indexes.
|
|
25
|
+
|
|
26
|
+
This corresponds to the v4.0 → v5.0 transition (graph-aware chunking).
|
|
27
|
+
"""
|
|
28
|
+
dim = self.embedding_dim
|
|
29
|
+
self.conn.execute(f"""
|
|
30
|
+
CREATE NODE TABLE IF NOT EXISTS Chunk (
|
|
31
|
+
id STRING PRIMARY KEY,
|
|
32
|
+
parent_doc_id STRING,
|
|
33
|
+
chunk_index INT64,
|
|
34
|
+
chunk_text STRING,
|
|
35
|
+
block_type STRING,
|
|
36
|
+
start_offset INT64,
|
|
37
|
+
end_offset INT64,
|
|
38
|
+
embedding FLOAT[{dim}]
|
|
39
|
+
)
|
|
40
|
+
""")
|
|
41
|
+
self.conn.execute("""
|
|
42
|
+
CREATE REL TABLE IF NOT EXISTS PART_OF (
|
|
43
|
+
FROM Concept TO Chunk
|
|
44
|
+
)
|
|
45
|
+
""")
|
|
46
|
+
# Create Chunk indexes (idempotent — Ladybug skips if present)
|
|
47
|
+
for table, name, create_sql in self._index_specs():
|
|
48
|
+
if table == "Chunk":
|
|
49
|
+
try:
|
|
50
|
+
self.conn.execute(create_sql)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.debug("chunk index %s skipped during migration: %s", name, e)
|
|
53
|
+
logger.info("Schema migrated: v1 → v2 (Chunk + PART_OF)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _migrate_v2_to_v3(self) -> None:
|
|
57
|
+
"""v2 → v3: Add FileHash table with concept_id column.
|
|
58
|
+
|
|
59
|
+
This corresponds to the v5.1 transition (delta detection & purge).
|
|
60
|
+
"""
|
|
61
|
+
self.conn.execute("""
|
|
62
|
+
CREATE NODE TABLE IF NOT EXISTS FileHash (
|
|
63
|
+
path STRING PRIMARY KEY,
|
|
64
|
+
hash STRING,
|
|
65
|
+
concept_id STRING
|
|
66
|
+
)
|
|
67
|
+
""")
|
|
68
|
+
logger.info("Schema migrated: v2 → v3 (FileHash + concept_id)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _migrate_v3_to_v4(self) -> None:
|
|
72
|
+
"""v3 → v4: Add DirHash table for directory-level hash aggregation.
|
|
73
|
+
|
|
74
|
+
This corresponds to the v5.5 transition (directory-level delta detection).
|
|
75
|
+
"""
|
|
76
|
+
self.conn.execute("""
|
|
77
|
+
CREATE NODE TABLE IF NOT EXISTS DirHash (
|
|
78
|
+
path STRING PRIMARY KEY,
|
|
79
|
+
hash STRING,
|
|
80
|
+
files STRING
|
|
81
|
+
)
|
|
82
|
+
""")
|
|
83
|
+
logger.info("Schema migrated: v3 → v4 (DirHash)")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _migrate_v4_to_v5(self) -> None:
|
|
87
|
+
"""v4 → v5: Add DeletedConcept table for soft-delete recovery.
|
|
88
|
+
|
|
89
|
+
This corresponds to the v5.8 transition (soft-delete with recovery window).
|
|
90
|
+
"""
|
|
91
|
+
self.conn.execute("""
|
|
92
|
+
CREATE NODE TABLE IF NOT EXISTS DeletedConcept (
|
|
93
|
+
id STRING PRIMARY KEY,
|
|
94
|
+
original_id STRING,
|
|
95
|
+
title STRING,
|
|
96
|
+
body STRING,
|
|
97
|
+
deleted_at STRING,
|
|
98
|
+
type STRING,
|
|
99
|
+
tags STRING
|
|
100
|
+
)
|
|
101
|
+
""")
|
|
102
|
+
logger.info("Schema migrated: v4 → v5 (DeletedConcept)")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_MIGRATIONS = {}
|
|
106
|
+
_MIGRATIONS[1] = _migrate_v1_to_v2
|
|
107
|
+
_MIGRATIONS[2] = _migrate_v2_to_v3
|
|
108
|
+
_MIGRATIONS[3] = _migrate_v3_to_v4
|
|
109
|
+
_MIGRATIONS[4] = _migrate_v4_to_v5
|
|
110
|
+
|
|
111
|
+
def _ensure_schema(self) -> None:
|
|
112
|
+
"""Create schema, extensions, and indexes if they don't exist."""
|
|
113
|
+
# Search extensions are optional at construction time. Text-only
|
|
114
|
+
# ingestion, graph traversal, list_directory and get_by_id all work
|
|
115
|
+
# without them; only hybrid/image *search* needs vector + fts. If the
|
|
116
|
+
# extensions can't be installed/loaded (e.g. the extension repository
|
|
117
|
+
# is unreachable), degrade gracefully rather than making the whole
|
|
118
|
+
# router unusable — a search call will then raise a clear error.
|
|
119
|
+
self._search_available = True
|
|
120
|
+
for ext in ("vector", "fts"):
|
|
121
|
+
try:
|
|
122
|
+
self.conn.execute(f"INSTALL {ext};")
|
|
123
|
+
self.conn.execute(f"LOAD {ext};")
|
|
124
|
+
except Exception as e:
|
|
125
|
+
self._search_available = False
|
|
126
|
+
logger.warning(
|
|
127
|
+
"Could not load the '%s' extension (%s). Vector/FTS search "
|
|
128
|
+
"will be unavailable; ingestion and graph queries still work.",
|
|
129
|
+
ext, e,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self.conn.execute(f"""
|
|
133
|
+
CREATE NODE TABLE IF NOT EXISTS Concept (
|
|
134
|
+
id STRING PRIMARY KEY,
|
|
135
|
+
type STRING,
|
|
136
|
+
title STRING,
|
|
137
|
+
description STRING,
|
|
138
|
+
resource STRING,
|
|
139
|
+
tags STRING[],
|
|
140
|
+
timestamp TIMESTAMP,
|
|
141
|
+
body STRING,
|
|
142
|
+
embedding FLOAT[{self.embedding_dim}],
|
|
143
|
+
extra MAP(STRING, STRING)
|
|
144
|
+
)
|
|
145
|
+
""")
|
|
146
|
+
self.conn.execute("""
|
|
147
|
+
CREATE NODE TABLE IF NOT EXISTS Directory (id STRING PRIMARY KEY)
|
|
148
|
+
""")
|
|
149
|
+
|
|
150
|
+
# ImageAsset: unified embedding column (text-model alt-text vectors and
|
|
151
|
+
# omni-model image vectors share this single space / index).
|
|
152
|
+
self.conn.execute(f"""
|
|
153
|
+
CREATE NODE TABLE IF NOT EXISTS ImageAsset (
|
|
154
|
+
id STRING PRIMARY KEY,
|
|
155
|
+
file_name STRING,
|
|
156
|
+
mime_type STRING,
|
|
157
|
+
alt_text STRING,
|
|
158
|
+
caption STRING,
|
|
159
|
+
embed_route STRING,
|
|
160
|
+
content_hash STRING,
|
|
161
|
+
data BLOB,
|
|
162
|
+
embedding FLOAT[{self.embedding_dim}]
|
|
163
|
+
)
|
|
164
|
+
""")
|
|
165
|
+
|
|
166
|
+
self.conn.execute("""
|
|
167
|
+
CREATE REL TABLE IF NOT EXISTS CONTAINS (
|
|
168
|
+
FROM Directory TO Directory,
|
|
169
|
+
FROM Directory TO Concept
|
|
170
|
+
)
|
|
171
|
+
""")
|
|
172
|
+
self.conn.execute("""
|
|
173
|
+
CREATE REL TABLE IF NOT EXISTS LINKS_TO (FROM Concept TO Concept)
|
|
174
|
+
""")
|
|
175
|
+
self.conn.execute("""
|
|
176
|
+
CREATE REL TABLE IF NOT EXISTS INCLUDES_ASSET (FROM Concept TO ImageAsset)
|
|
177
|
+
""")
|
|
178
|
+
|
|
179
|
+
self.conn.execute(f"""
|
|
180
|
+
CREATE NODE TABLE IF NOT EXISTS Chunk (
|
|
181
|
+
id STRING PRIMARY KEY,
|
|
182
|
+
parent_doc_id STRING,
|
|
183
|
+
chunk_index INT64,
|
|
184
|
+
chunk_text STRING,
|
|
185
|
+
block_type STRING,
|
|
186
|
+
start_offset INT64,
|
|
187
|
+
end_offset INT64,
|
|
188
|
+
embedding FLOAT[{self.embedding_dim}]
|
|
189
|
+
)
|
|
190
|
+
""")
|
|
191
|
+
|
|
192
|
+
self.conn.execute("""
|
|
193
|
+
CREATE REL TABLE IF NOT EXISTS PART_OF (
|
|
194
|
+
FROM Concept TO Chunk
|
|
195
|
+
)
|
|
196
|
+
""")
|
|
197
|
+
|
|
198
|
+
# BrokenLink table — tracks links to concepts not yet imported
|
|
199
|
+
self.conn.execute("""
|
|
200
|
+
CREATE NODE TABLE IF NOT EXISTS BrokenLink (
|
|
201
|
+
id STRING PRIMARY KEY,
|
|
202
|
+
source_id STRING,
|
|
203
|
+
target_id STRING,
|
|
204
|
+
timestamp TIMESTAMP
|
|
205
|
+
)
|
|
206
|
+
""")
|
|
207
|
+
|
|
208
|
+
# Meta — small key/value store. Used for index dirty-tracking:
|
|
209
|
+
# 'write_epoch' bumps on every index-affecting write (concept/image
|
|
210
|
+
# upsert or delete); 'indexed_epoch' records the write_epoch at which the
|
|
211
|
+
# search indexes were last (re)built. Indexes are dirty when
|
|
212
|
+
# write_epoch > indexed_epoch, which drives change-driven rebuilds.
|
|
213
|
+
self.conn.execute("""
|
|
214
|
+
CREATE NODE TABLE IF NOT EXISTS Meta (key STRING PRIMARY KEY, value INT64)
|
|
215
|
+
""")
|
|
216
|
+
|
|
217
|
+
# FileHash — per-file SHA-256 mapping for delta detection.
|
|
218
|
+
# Stores which files changed since the last import_bundle() call.
|
|
219
|
+
# concept_id maps the file path to its Concept node for safe purge.
|
|
220
|
+
self.conn.execute("""
|
|
221
|
+
CREATE NODE TABLE IF NOT EXISTS FileHash (
|
|
222
|
+
path STRING PRIMARY KEY,
|
|
223
|
+
hash STRING,
|
|
224
|
+
concept_id STRING
|
|
225
|
+
)
|
|
226
|
+
""")
|
|
227
|
+
|
|
228
|
+
# DirHash — per-directory combined hash for subtree-level delta detection.
|
|
229
|
+
# Stores a SHA-256 hash of all files in a directory subtree. When the
|
|
230
|
+
# hash hasn't changed, the entire subtree is skipped on re-import.
|
|
231
|
+
# The 'files' column stores a JSON string of relative file paths for purge tracking.
|
|
232
|
+
self.conn.execute("""
|
|
233
|
+
CREATE NODE TABLE IF NOT EXISTS DirHash (
|
|
234
|
+
path STRING PRIMARY KEY,
|
|
235
|
+
hash STRING,
|
|
236
|
+
files STRING
|
|
237
|
+
)
|
|
238
|
+
""")
|
|
239
|
+
|
|
240
|
+
# Run schema migrations if the on-disk version is behind.
|
|
241
|
+
self._run_schema_migrations()
|
|
242
|
+
|
|
243
|
+
# When opening a pre-existing DB, the stored embedding column dimension
|
|
244
|
+
# is authoritative (CREATE TABLE IF NOT EXISTS won't have changed it).
|
|
245
|
+
# Adopt it so query vectors match — otherwise a caller that opened the
|
|
246
|
+
# DB with the wrong --dim would silently produce dimension-mismatch
|
|
247
|
+
# errors on every vector search (a common search-browser footgun).
|
|
248
|
+
self._adopt_existing_embedding_dim()
|
|
249
|
+
|
|
250
|
+
# Create the search indexes if they don't yet exist. On construction we
|
|
251
|
+
# do NOT drop/rebuild (that would be costly every time a large DB is
|
|
252
|
+
# merely opened for reading); imports rebuild them explicitly so newly
|
|
253
|
+
# written rows become searchable — see _build_search_indexes().
|
|
254
|
+
self._build_search_indexes(rebuild=False)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _adopt_existing_embedding_dim(self) -> None:
|
|
258
|
+
"""If the Concept.embedding column already exists, honour its dimension.
|
|
259
|
+
|
|
260
|
+
Parses ``FLOAT[N]`` from the stored schema and, if it differs from the
|
|
261
|
+
requested ``embedding_dim``, warns and adopts the stored value so query
|
|
262
|
+
encoding and vector indexes stay consistent with what's on disk.
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
rows = self.conn.execute(
|
|
266
|
+
"CALL TABLE_INFO('Concept') RETURN *"
|
|
267
|
+
).rows_as_dict().get_all()
|
|
268
|
+
except Exception:
|
|
269
|
+
return
|
|
270
|
+
for r in rows:
|
|
271
|
+
if r.get("name") == "embedding":
|
|
272
|
+
m = re.search(r"\[(\d+)\]", str(r.get("type") or ""))
|
|
273
|
+
if m:
|
|
274
|
+
stored = int(m.group(1))
|
|
275
|
+
if stored != self.embedding_dim:
|
|
276
|
+
logger.warning(
|
|
277
|
+
"Opened a DB whose embedding dimension is %d, but "
|
|
278
|
+
"embedding_dim=%d was requested. Using the stored "
|
|
279
|
+
"dimension (%d) to stay consistent with the data.",
|
|
280
|
+
stored, self.embedding_dim, stored,
|
|
281
|
+
)
|
|
282
|
+
self.embedding_dim = stored
|
|
283
|
+
break
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _run_schema_migrations(self) -> None:
|
|
287
|
+
"""Run any pending schema migrations.
|
|
288
|
+
|
|
289
|
+
Reads the stored schema version from the Meta table. If it is behind
|
|
290
|
+
``SCHEMA_VERSION``, runs each migration function in order and bumps
|
|
291
|
+
the version. New databases start at version 0 (no migrations needed
|
|
292
|
+
because ``_ensure_schema`` creates the full schema).
|
|
293
|
+
"""
|
|
294
|
+
current = self._get_meta("schema_version", 0)
|
|
295
|
+
target = self.SCHEMA_VERSION
|
|
296
|
+
|
|
297
|
+
if current >= target:
|
|
298
|
+
return # already up to date
|
|
299
|
+
|
|
300
|
+
if current == 0:
|
|
301
|
+
# Fresh database — schema was created by _ensure_schema above.
|
|
302
|
+
# Just stamp the version.
|
|
303
|
+
self._set_meta("schema_version", target)
|
|
304
|
+
logger.debug("New database — schema version set to %d", target)
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
logger.info(
|
|
308
|
+
"Schema migration: current=%d, target=%d — running %d migration(s)",
|
|
309
|
+
current, target, target - current,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
for v in range(current, target):
|
|
313
|
+
fn = self._MIGRATIONS.get(v)
|
|
314
|
+
if fn is None:
|
|
315
|
+
logger.error(
|
|
316
|
+
"No migration function for version %d → %d. "
|
|
317
|
+
"Database may be in an inconsistent state.",
|
|
318
|
+
v, v + 1,
|
|
319
|
+
)
|
|
320
|
+
break
|
|
321
|
+
try:
|
|
322
|
+
fn(self)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
logger.error(
|
|
325
|
+
"Migration v%d → v%d failed: %s. Database may be unusable.",
|
|
326
|
+
v, v + 1, e,
|
|
327
|
+
)
|
|
328
|
+
raise
|
|
329
|
+
self._set_meta("schema_version", v + 1)
|
|
330
|
+
|
|
331
|
+
logger.info("Schema migrations complete — version %d", target)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _index_specs(self):
|
|
335
|
+
vec = (
|
|
336
|
+
"CALL CREATE_VECTOR_INDEX('{table}', '{name}', 'embedding', "
|
|
337
|
+
"mu := 30, ml := 60, metric := 'cosine', efc := 200)"
|
|
338
|
+
)
|
|
339
|
+
return [
|
|
340
|
+
("Concept", "concept_embedding", vec.format(table="Concept", name="concept_embedding")),
|
|
341
|
+
("Concept", "concept_fts",
|
|
342
|
+
"CALL CREATE_FTS_INDEX('Concept', 'concept_fts', ['title', 'description', 'body'])"),
|
|
343
|
+
("ImageAsset", "image_omni_idx", vec.format(table="ImageAsset", name="image_omni_idx")),
|
|
344
|
+
("Chunk", "chunk_embedding", vec.format(table="Chunk", name="chunk_embedding")),
|
|
345
|
+
("Chunk", "chunk_fts",
|
|
346
|
+
"CALL CREATE_FTS_INDEX('Chunk', 'chunk_fts', ['chunk_text'])"),
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _build_search_indexes(self, rebuild: bool, force: bool = False) -> bool:
|
|
351
|
+
"""Create (or rebuild) the vector + FTS indexes.
|
|
352
|
+
|
|
353
|
+
Ladybug's vector/FTS indexes are built over a table's *current*
|
|
354
|
+
contents; rows inserted after an index is created are not returned by
|
|
355
|
+
search until the index is rebuilt. Import paths therefore call this with
|
|
356
|
+
``rebuild=True`` after data is written.
|
|
357
|
+
|
|
358
|
+
Rebuilds are *change-driven*: when ``rebuild`` is requested we skip the
|
|
359
|
+
work unless the indexes are actually dirty (``write_epoch >
|
|
360
|
+
indexed_epoch``), so a no-op import or a redundant call costs nothing.
|
|
361
|
+
Pass ``force=True`` (the manual ``reindex`` path) to rebuild regardless —
|
|
362
|
+
e.g. to repair a DB written by an older build whose markers don't exist.
|
|
363
|
+
|
|
364
|
+
``rebuild=False`` (construction) only creates missing indexes; it never
|
|
365
|
+
drops, never checks dirty, and never stamps — merely opening a DB should
|
|
366
|
+
not trigger an O(N) rebuild.
|
|
367
|
+
|
|
368
|
+
Returns True if indexes were (re)built, False if skipped.
|
|
369
|
+
"""
|
|
370
|
+
if not getattr(self, "_search_available", False):
|
|
371
|
+
return False
|
|
372
|
+
if rebuild and not force and not self._indexes_dirty():
|
|
373
|
+
logger.debug("Search indexes already up to date; skipping rebuild.")
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
# Capture the epoch we're about to satisfy *before* building, so the
|
|
377
|
+
# stamp reflects the data the index was built over.
|
|
378
|
+
target_epoch = self._get_meta("write_epoch")
|
|
379
|
+
built = False
|
|
380
|
+
for table, name, create_sql in self._index_specs():
|
|
381
|
+
# Ladybug: DROP INDEX leaves stale internal state that prevents
|
|
382
|
+
# recreation with the same name. Instead, rely on CREATE ... IF
|
|
383
|
+
# NOT EXISTS semantics (Ladybug silently skips if present).
|
|
384
|
+
try:
|
|
385
|
+
self.conn.execute(create_sql)
|
|
386
|
+
built = True
|
|
387
|
+
except Exception as e:
|
|
388
|
+
# Already exists (rebuild=False) or table empty — both benign.
|
|
389
|
+
logger.debug("create index %s.%s skipped: %s", table, name, e)
|
|
390
|
+
if rebuild:
|
|
391
|
+
self._set_meta("indexed_epoch", target_epoch)
|
|
392
|
+
return built
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def reindex(self, force: bool = True) -> bool:
|
|
396
|
+
"""Rebuild the search indexes on demand (recovery / deferred workflows).
|
|
397
|
+
|
|
398
|
+
Use for a DB built by an older version with stale indexes, after a crash
|
|
399
|
+
between the data commit and the index build, or when single-file imports
|
|
400
|
+
deferred rebuilding. Returns True if a rebuild ran.
|
|
401
|
+
"""
|
|
402
|
+
# Acquire write lock (Gap #7b)
|
|
403
|
+
with self._write_lock_ctx():
|
|
404
|
+
return self._build_search_indexes(rebuild=True, force=force)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _get_meta(self, key: str, default: int = 0) -> int:
|
|
408
|
+
try:
|
|
409
|
+
rows = self.conn.execute(
|
|
410
|
+
"MATCH (m:Meta {key: $k}) RETURN m.value AS v", {"k": key}
|
|
411
|
+
).rows_as_dict().get_all()
|
|
412
|
+
except Exception:
|
|
413
|
+
return default
|
|
414
|
+
return rows[0]["v"] if rows else default
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _set_meta(self, key: str, value: int) -> None:
|
|
418
|
+
try:
|
|
419
|
+
self.conn.execute(
|
|
420
|
+
"MERGE (m:Meta {key: $k}) SET m.value = $v", {"k": key, "v": int(value)}
|
|
421
|
+
)
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.debug("could not set meta %s=%s: %s", key, value, e)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _bump_write_epoch(self) -> None:
|
|
427
|
+
"""Mark the search indexes dirty after an index-affecting write."""
|
|
428
|
+
try:
|
|
429
|
+
self.conn.execute(
|
|
430
|
+
"""
|
|
431
|
+
MERGE (m:Meta {key: 'write_epoch'})
|
|
432
|
+
ON CREATE SET m.value = 1
|
|
433
|
+
ON MATCH SET m.value = m.value + 1
|
|
434
|
+
"""
|
|
435
|
+
)
|
|
436
|
+
except Exception as e:
|
|
437
|
+
logger.debug("could not bump write_epoch: %s", e)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _indexes_dirty(self) -> bool:
|
|
441
|
+
return self._get_meta("write_epoch") > self._get_meta("indexed_epoch")
|
|
442
|
+
|
|
443
|
+
|