fgraph 1.0.4__tar.gz → 1.2.0__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.
- {fgraph-1.0.4 → fgraph-1.2.0}/PKG-INFO +1 -1
- {fgraph-1.0.4 → fgraph-1.2.0}/pyproject.toml +2 -2
- {fgraph-1.0.4 → fgraph-1.2.0}/pyproject.toml.orig +2 -2
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/__init__.py +1 -1
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/cli.py +130 -37
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/mcp_server.py +137 -9
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/query.py +37 -28
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/store.py +262 -107
- {fgraph-1.0.4 → fgraph-1.2.0}/LICENSE +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/README.md +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/__main__.py +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/_embed_runner.py +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/errors.py +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/jsonio.py +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/models.py +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/py.typed +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/search.py +0 -0
- {fgraph-1.0.4 → fgraph-1.2.0}/src/fgraph/values.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fgraph"
|
|
7
|
-
version = "1.0
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Embedded temporal fact store in a single SQLite file"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["uv_build>=0.12.
|
|
2
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
3
3
|
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fgraph"
|
|
7
|
-
version = "1.0
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "Embedded temporal fact store in a single SQLite file"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import os
|
|
6
7
|
import sys
|
|
7
8
|
from collections.abc import Iterator
|
|
8
9
|
from contextlib import contextmanager
|
|
@@ -14,6 +15,7 @@ from typing import Annotated, Any, TextIO
|
|
|
14
15
|
|
|
15
16
|
import click
|
|
16
17
|
import typer
|
|
18
|
+
from typer._click.core import ParameterSource
|
|
17
19
|
from typer._click.exceptions import Exit as TyperExit
|
|
18
20
|
from typer._click.exceptions import UsageError as TyperUsageError
|
|
19
21
|
|
|
@@ -22,11 +24,16 @@ from fgraph.errors import FGraphError
|
|
|
22
24
|
from fgraph.errors import TypeError as FGraphTypeError
|
|
23
25
|
from fgraph.jsonio import loads
|
|
24
26
|
from fgraph.store import DEFAULT_QUERY_BUDGET, GENESIS_TX, Db
|
|
25
|
-
from fgraph.values import _canonical_json_document
|
|
27
|
+
from fgraph.values import INT64_MAX, INT64_MIN, _canonical_json_document
|
|
26
28
|
|
|
27
29
|
app = typer.Typer(no_args_is_help=True, pretty_exceptions_enable=False, help="Temporal facts in one SQLite file.")
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
DEFAULT_DATABASE_PATH = "facts.fgraph"
|
|
32
|
+
LEGACY_DEFAULT_DATABASE_PATH = "fgraph.db"
|
|
33
|
+
|
|
34
|
+
# The callback alone reads FGRAPH_DB. Command-local --db remains available, but
|
|
35
|
+
# an absent local option must not override a global explicit value with the env.
|
|
36
|
+
DbOption = Annotated[str | None, typer.Option("--db", help="SQLite database path.")]
|
|
30
37
|
JsonOption = Annotated[bool, typer.Option("--json", help="Emit canonical machine-readable JSON.")]
|
|
31
38
|
FilterOption = Annotated[list[str] | None, typer.Option("--filter", help="JSON [attribute,value], repeatable.")]
|
|
32
39
|
EmbedOption = Annotated[str | None, typer.Option("--embed-cmd")]
|
|
@@ -35,7 +42,8 @@ ArgsOption = Annotated[str | None, typer.Option("--args", help="Canonical JSON o
|
|
|
35
42
|
|
|
36
43
|
@dataclass(frozen=True, slots=True)
|
|
37
44
|
class _Options:
|
|
38
|
-
db: str =
|
|
45
|
+
db: str = DEFAULT_DATABASE_PATH
|
|
46
|
+
db_is_implicit: bool = True
|
|
39
47
|
json_output: bool = False
|
|
40
48
|
query_budget: int = DEFAULT_QUERY_BUDGET
|
|
41
49
|
|
|
@@ -46,7 +54,7 @@ _CURRENT_OPTIONS: ContextVar[_Options | None] = ContextVar("fgraph_cli_options",
|
|
|
46
54
|
@app.callback()
|
|
47
55
|
def _common(
|
|
48
56
|
ctx: typer.Context,
|
|
49
|
-
db: Annotated[str, typer.Option("--db", envvar="FGRAPH_DB", help="SQLite database path.")] =
|
|
57
|
+
db: Annotated[str, typer.Option("--db", envvar="FGRAPH_DB", help="SQLite database path.")] = DEFAULT_DATABASE_PATH,
|
|
50
58
|
json_output: JsonOption = False,
|
|
51
59
|
query_budget: Annotated[
|
|
52
60
|
int,
|
|
@@ -58,7 +66,14 @@ def _common(
|
|
|
58
66
|
] = DEFAULT_QUERY_BUDGET,
|
|
59
67
|
) -> None:
|
|
60
68
|
"""Configure options shared by every command."""
|
|
61
|
-
|
|
69
|
+
db_source = ctx.get_parameter_source("db")
|
|
70
|
+
empty_environment = db_source is ParameterSource.DEFAULT and os.environ.get("FGRAPH_DB") == ""
|
|
71
|
+
options = _Options(
|
|
72
|
+
db="" if empty_environment else db,
|
|
73
|
+
db_is_implicit=db_source is ParameterSource.DEFAULT and not empty_environment,
|
|
74
|
+
json_output=json_output,
|
|
75
|
+
query_budget=query_budget,
|
|
76
|
+
)
|
|
62
77
|
ctx.obj = options
|
|
63
78
|
_CURRENT_OPTIONS.set(options)
|
|
64
79
|
|
|
@@ -68,13 +83,53 @@ def _options() -> _Options:
|
|
|
68
83
|
|
|
69
84
|
|
|
70
85
|
def _open(path: str | None, *, read_only: bool = False) -> Db:
|
|
86
|
+
options = _options()
|
|
87
|
+
selected_path = options.db if path is None else path
|
|
88
|
+
if selected_path == "":
|
|
89
|
+
raise fgraph.FormatError(
|
|
90
|
+
f"database path is empty; pass --db PATH or unset FGRAPH_DB to use {DEFAULT_DATABASE_PATH}"
|
|
91
|
+
)
|
|
92
|
+
if path is None and options.db_is_implicit:
|
|
93
|
+
selected_path = _resolve_implicit_database_path()
|
|
71
94
|
return fgraph.connect(
|
|
72
|
-
|
|
95
|
+
selected_path,
|
|
73
96
|
read_only=read_only,
|
|
74
|
-
query_budget=
|
|
97
|
+
query_budget=options.query_budget,
|
|
75
98
|
)
|
|
76
99
|
|
|
77
100
|
|
|
101
|
+
def _path_entry_exists(path: Path, *, description: str) -> bool:
|
|
102
|
+
try:
|
|
103
|
+
path.lstat()
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
return False
|
|
106
|
+
except OSError as exc:
|
|
107
|
+
raise fgraph.FormatError(
|
|
108
|
+
f"cannot inspect {description} database path {path!s}; check directory permissions"
|
|
109
|
+
) from exc
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_implicit_database_path() -> str:
|
|
114
|
+
legacy = Path(LEGACY_DEFAULT_DATABASE_PATH)
|
|
115
|
+
if not _path_entry_exists(legacy, description="legacy"):
|
|
116
|
+
return DEFAULT_DATABASE_PATH
|
|
117
|
+
|
|
118
|
+
current = Path(DEFAULT_DATABASE_PATH)
|
|
119
|
+
if not _path_entry_exists(current, description="default"):
|
|
120
|
+
return LEGACY_DEFAULT_DATABASE_PATH
|
|
121
|
+
try:
|
|
122
|
+
with fgraph.connect(current, read_only=True):
|
|
123
|
+
pass
|
|
124
|
+
except FGraphError as exc:
|
|
125
|
+
raise fgraph.FormatError(
|
|
126
|
+
f"legacy default database {legacy!s} exists and {current!s} is not an initialized "
|
|
127
|
+
f"fgraph database; use --db {legacy!s} to keep using the legacy file or explicitly "
|
|
128
|
+
f"pass --db {current!s} to select the new default"
|
|
129
|
+
) from exc
|
|
130
|
+
return DEFAULT_DATABASE_PATH
|
|
131
|
+
|
|
132
|
+
|
|
78
133
|
def run_mcp(graph: Any, *, read_only: bool, embed_cmd: str | None) -> None:
|
|
79
134
|
"""Load the optional MCP runtime only when the command is invoked."""
|
|
80
135
|
from fgraph.mcp_server import run
|
|
@@ -98,6 +153,28 @@ def _reference(value: str) -> str | int:
|
|
|
98
153
|
return value
|
|
99
154
|
|
|
100
155
|
|
|
156
|
+
def _transaction_integer(value: int, *, context: str) -> int:
|
|
157
|
+
if not INT64_MIN <= value <= INT64_MAX:
|
|
158
|
+
raise FGraphTypeError(f"{context} {value!r} is outside the signed 64-bit integer range")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _exclusive_flags(
|
|
163
|
+
positive: bool,
|
|
164
|
+
negative: bool,
|
|
165
|
+
*,
|
|
166
|
+
positive_name: str,
|
|
167
|
+
negative_name: str,
|
|
168
|
+
) -> bool | None:
|
|
169
|
+
if positive and negative:
|
|
170
|
+
raise TyperUsageError(f"{positive_name} and {negative_name} are mutually exclusive")
|
|
171
|
+
if positive:
|
|
172
|
+
return True
|
|
173
|
+
if negative:
|
|
174
|
+
return False
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
101
178
|
def _read_argument(value: str) -> str:
|
|
102
179
|
if value == "-":
|
|
103
180
|
return sys.stdin.read()
|
|
@@ -112,25 +189,14 @@ def _read_argument(value: str) -> str:
|
|
|
112
189
|
return value
|
|
113
190
|
|
|
114
191
|
|
|
115
|
-
def _input_text(source: str, *, context: str) -> str:
|
|
116
|
-
if source == "-":
|
|
117
|
-
return sys.stdin.read()
|
|
118
|
-
try:
|
|
119
|
-
return Path(source).read_text(encoding="utf-8")
|
|
120
|
-
except (OSError, UnicodeError) as exc:
|
|
121
|
-
raise fgraph.FormatError(
|
|
122
|
-
f"{context} file {source!r} cannot be read as UTF-8; check the path and permissions"
|
|
123
|
-
) from exc
|
|
124
|
-
|
|
125
|
-
|
|
126
192
|
@contextmanager
|
|
127
|
-
def _input_lines(source: str, *, context: str) -> Iterator[
|
|
193
|
+
def _input_lines(source: str, *, context: str) -> Iterator[TextIO]:
|
|
128
194
|
if source == "-":
|
|
129
|
-
yield
|
|
195
|
+
yield sys.stdin
|
|
130
196
|
return
|
|
131
197
|
try:
|
|
132
198
|
with Path(source).open(encoding="utf-8") as stream:
|
|
133
|
-
yield
|
|
199
|
+
yield stream
|
|
134
200
|
except (OSError, UnicodeError) as exc:
|
|
135
201
|
raise fgraph.FormatError(
|
|
136
202
|
f"{context} file {source!r} cannot be read as UTF-8; check the path and permissions"
|
|
@@ -216,9 +282,9 @@ def add(
|
|
|
216
282
|
) -> None:
|
|
217
283
|
"""Transact inline JSON, @file JSON, or stdin JSON/NDJSON with '-'."""
|
|
218
284
|
if operation_id is not None and operation_id_prefix is not None:
|
|
219
|
-
raise
|
|
285
|
+
raise TyperUsageError("choose --operation-id for one transaction or --operation-id-prefix for batches")
|
|
220
286
|
if operation_id_prefix is not None and batch_size is None:
|
|
221
|
-
raise
|
|
287
|
+
raise TyperUsageError("--operation-id-prefix requires --batch-size")
|
|
222
288
|
if batch_size is not None:
|
|
223
289
|
_add_batches(
|
|
224
290
|
data,
|
|
@@ -234,6 +300,8 @@ def add(
|
|
|
234
300
|
payloads = _payloads(data, context="add input")
|
|
235
301
|
if operation_id is not None and len(payloads) > 1:
|
|
236
302
|
raise FGraphTypeError("--operation-id requires one JSON transaction, not NDJSON")
|
|
303
|
+
if if_basis_tx is not None and len(payloads) > 1:
|
|
304
|
+
raise FGraphTypeError("--if-basis-tx cannot span multiple transactions; use idempotent operation ids")
|
|
237
305
|
with _open(db) as graph:
|
|
238
306
|
reports = [
|
|
239
307
|
graph.transact(
|
|
@@ -473,6 +541,7 @@ def transaction_receipt(
|
|
|
473
541
|
json_output: JsonOption = False,
|
|
474
542
|
) -> None:
|
|
475
543
|
"""Print one durable operation/event receipt."""
|
|
544
|
+
transaction = _transaction_integer(transaction, context="transaction id")
|
|
476
545
|
with _open(db, read_only=True) as graph:
|
|
477
546
|
_emit(graph.receipt(transaction), json_output)
|
|
478
547
|
|
|
@@ -480,6 +549,8 @@ def transaction_receipt(
|
|
|
480
549
|
@app.command()
|
|
481
550
|
def diff(t1: int, t2: int, db: DbOption = None, json_output: JsonOption = False) -> None:
|
|
482
551
|
"""Print facts asserted/retracted in a transaction window."""
|
|
552
|
+
t1 = _transaction_integer(t1, context="start transaction")
|
|
553
|
+
t2 = _transaction_integer(t2, context="end transaction")
|
|
483
554
|
with _open(db, read_only=True) as graph:
|
|
484
555
|
_emit(graph.diff(t1, t2), json_output)
|
|
485
556
|
|
|
@@ -489,9 +560,12 @@ def declare(
|
|
|
489
560
|
attr: str,
|
|
490
561
|
type: str | None = typer.Option(None, "--type"), # noqa: A002
|
|
491
562
|
ref: bool = False,
|
|
492
|
-
many: bool
|
|
493
|
-
|
|
494
|
-
|
|
563
|
+
many: bool = typer.Option(False, "--many"),
|
|
564
|
+
one: bool = typer.Option(False, "--one"),
|
|
565
|
+
unique: bool = typer.Option(False, "--unique"),
|
|
566
|
+
not_unique: bool = typer.Option(False, "--not-unique"),
|
|
567
|
+
nohistory: bool = typer.Option(False, "--nohistory"),
|
|
568
|
+
history: bool = typer.Option(False, "--history"),
|
|
495
569
|
dims: int | None = None,
|
|
496
570
|
doc: str | None = None,
|
|
497
571
|
vector_model: Annotated[str | None, typer.Option("--vector-model")] = None,
|
|
@@ -501,15 +575,28 @@ def declare(
|
|
|
501
575
|
json_output: JsonOption = False,
|
|
502
576
|
) -> None:
|
|
503
577
|
"""Declare optional attribute behavior."""
|
|
578
|
+
many_value = _exclusive_flags(many, one, positive_name="--many", negative_name="--one")
|
|
579
|
+
unique_value = _exclusive_flags(
|
|
580
|
+
unique,
|
|
581
|
+
not_unique,
|
|
582
|
+
positive_name="--unique",
|
|
583
|
+
negative_name="--not-unique",
|
|
584
|
+
)
|
|
585
|
+
nohistory_value = _exclusive_flags(
|
|
586
|
+
nohistory,
|
|
587
|
+
history,
|
|
588
|
+
positive_name="--nohistory",
|
|
589
|
+
negative_name="--history",
|
|
590
|
+
)
|
|
504
591
|
with _open(db) as graph:
|
|
505
592
|
_emit(
|
|
506
593
|
graph.declare(
|
|
507
594
|
attr,
|
|
508
595
|
type=type,
|
|
509
596
|
ref=ref,
|
|
510
|
-
many=
|
|
511
|
-
unique=
|
|
512
|
-
nohistory=
|
|
597
|
+
many=many_value,
|
|
598
|
+
unique=unique_value,
|
|
599
|
+
nohistory=nohistory_value,
|
|
513
600
|
dims=dims,
|
|
514
601
|
doc=doc,
|
|
515
602
|
vector_model=vector_model,
|
|
@@ -525,20 +612,27 @@ def shape_command(
|
|
|
525
612
|
name: str,
|
|
526
613
|
required: Annotated[list[str] | None, typer.Option("--required")] = None,
|
|
527
614
|
allowed: Annotated[list[str] | None, typer.Option("--allowed")] = None,
|
|
528
|
-
closed: Annotated[bool, typer.Option("--closed
|
|
615
|
+
closed: Annotated[bool, typer.Option("--closed")] = False,
|
|
616
|
+
open_shape: Annotated[bool, typer.Option("--open")] = False,
|
|
529
617
|
operation_id: Annotated[str | None, typer.Option("--operation-id")] = None,
|
|
530
618
|
if_basis_tx: Annotated[int | None, typer.Option("--if-basis-tx")] = None,
|
|
531
619
|
db: DbOption = None,
|
|
532
620
|
json_output: JsonOption = False,
|
|
533
621
|
) -> None:
|
|
534
622
|
"""Create or replace a required/allowed attribute shape."""
|
|
623
|
+
closed_value = _exclusive_flags(
|
|
624
|
+
closed,
|
|
625
|
+
open_shape,
|
|
626
|
+
positive_name="--closed",
|
|
627
|
+
negative_name="--open",
|
|
628
|
+
)
|
|
535
629
|
with _open(db) as graph:
|
|
536
630
|
_emit(
|
|
537
631
|
graph.declare_shape(
|
|
538
632
|
name,
|
|
539
633
|
required=required or (),
|
|
540
634
|
allowed=allowed or (),
|
|
541
|
-
closed=
|
|
635
|
+
closed=closed_value or False,
|
|
542
636
|
operation_id=operation_id,
|
|
543
637
|
if_basis_tx=if_basis_tx,
|
|
544
638
|
),
|
|
@@ -616,9 +710,7 @@ def apply_command(
|
|
|
616
710
|
def snapshot_command(db: DbOption = None) -> None:
|
|
617
711
|
"""Write a checksummed exact logical snapshot to stdout."""
|
|
618
712
|
with _open(db, read_only=True) as graph:
|
|
619
|
-
|
|
620
|
-
if output is not None:
|
|
621
|
-
typer.echo(output, nl=False)
|
|
713
|
+
graph.snapshot(sys.stdout)
|
|
622
714
|
|
|
623
715
|
|
|
624
716
|
@app.command("restore")
|
|
@@ -628,8 +720,8 @@ def restore_command(
|
|
|
628
720
|
json_output: JsonOption = False,
|
|
629
721
|
) -> None:
|
|
630
722
|
"""Restore a snapshot/1 stream into a pristine database."""
|
|
631
|
-
with _open(db) as graph:
|
|
632
|
-
graph.restore(
|
|
723
|
+
with _input_lines(source, context="snapshot") as lines, _open(db) as graph:
|
|
724
|
+
graph.restore(lines)
|
|
633
725
|
_emit({"ok": True, "basis_tx": graph.schema()["basis_tx"]}, json_output)
|
|
634
726
|
|
|
635
727
|
|
|
@@ -642,6 +734,7 @@ def undo(
|
|
|
642
734
|
json_output: JsonOption = False,
|
|
643
735
|
) -> None:
|
|
644
736
|
"""Apply an audited compensating transaction."""
|
|
737
|
+
tx = _transaction_integer(tx, context="transaction id")
|
|
645
738
|
with _open(db) as graph:
|
|
646
739
|
_emit(
|
|
647
740
|
graph.undo(
|
|
@@ -685,7 +778,7 @@ def tail(
|
|
|
685
778
|
for record in graph.follow(since):
|
|
686
779
|
typer.echo(_canonical_json_document(record))
|
|
687
780
|
else:
|
|
688
|
-
for record in graph.
|
|
781
|
+
for _transaction, record in graph._iter_event_records(since): # noqa: SLF001
|
|
689
782
|
typer.echo(_canonical_json_document(record))
|
|
690
783
|
|
|
691
784
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import base64
|
|
5
6
|
import os
|
|
6
7
|
import signal
|
|
7
8
|
import subprocess
|
|
@@ -22,7 +23,7 @@ from mcp.server.mcpserver.exceptions import ResourceError, ToolError
|
|
|
22
23
|
from mcp.types import ToolAnnotations
|
|
23
24
|
|
|
24
25
|
from fgraph._embed_runner import START_ERROR
|
|
25
|
-
from fgraph.errors import FGraphError, ReadOnly, TooLarge
|
|
26
|
+
from fgraph.errors import Conflict, FGraphError, ReadOnly, TooLarge
|
|
26
27
|
from fgraph.errors import TypeError as FGraphTypeError
|
|
27
28
|
from fgraph.models import TxReport
|
|
28
29
|
from fgraph.store import GENESIS_TX, Db
|
|
@@ -33,6 +34,8 @@ _EMBED_TIMEOUT_MESSAGE = "embed command timed out after 60 seconds; use a bounde
|
|
|
33
34
|
_MAX_TOOL_ITEMS = 100
|
|
34
35
|
_MAX_ENTITY_ATTRIBUTES = 32
|
|
35
36
|
_MAX_RESPONSE_BYTES = 256 * 1024
|
|
37
|
+
_CHANGES_PAGE_BYTES = 192 * 1024
|
|
38
|
+
_EVENT_CHUNK_BYTES = 128 * 1024
|
|
36
39
|
|
|
37
40
|
|
|
38
41
|
def _tool_errors[**P, R](handler: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
|
|
@@ -180,6 +183,62 @@ def _continued_resource_uri(authority: str, cursor: str, *, path: str | None = N
|
|
|
180
183
|
return f"fgraph://{authority}{suffix}?{urlencode(query)}"
|
|
181
184
|
|
|
182
185
|
|
|
186
|
+
def _resource_integer(value: Any, name: str, *, minimum: int, maximum: int) -> int:
|
|
187
|
+
if not isinstance(value, str) or not value.isascii() or not value.isdigit():
|
|
188
|
+
raise FGraphTypeError(f"event {name} must be a canonical decimal integer")
|
|
189
|
+
parsed = int(value)
|
|
190
|
+
if value != str(parsed) or not minimum <= parsed <= maximum:
|
|
191
|
+
raise FGraphTypeError(f"event {name} is outside its valid range")
|
|
192
|
+
return parsed
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _event_coordinates(db: Db, event: Any, basis: Any, offset: Any, digest: Any) -> tuple[str, int, int, str]:
|
|
196
|
+
if not isinstance(event, str):
|
|
197
|
+
raise FGraphTypeError("event id must be a canonical RFC 4122 UUID")
|
|
198
|
+
try:
|
|
199
|
+
parsed_event = uuid.UUID(event)
|
|
200
|
+
except ValueError as exc:
|
|
201
|
+
raise FGraphTypeError("event id must be a canonical RFC 4122 UUID") from exc
|
|
202
|
+
if event != str(parsed_event) or parsed_event.variant != uuid.RFC_4122:
|
|
203
|
+
raise FGraphTypeError("event id must be a canonical RFC 4122 UUID")
|
|
204
|
+
pinned_basis = _resource_integer(basis, "basis", minimum=GENESIS_TX, maximum=_basis(db))
|
|
205
|
+
basis_row = db._connection.execute( # noqa: SLF001
|
|
206
|
+
"SELECT 1 FROM fgraph_events WHERE tx=?",
|
|
207
|
+
(pinned_basis,),
|
|
208
|
+
).fetchone()
|
|
209
|
+
if basis_row is None:
|
|
210
|
+
raise FGraphTypeError("event basis must identify a transaction receipt")
|
|
211
|
+
chunk_offset = _resource_integer(offset, "offset", minimum=0, maximum=INT64_MAX)
|
|
212
|
+
if (
|
|
213
|
+
not isinstance(digest, str)
|
|
214
|
+
or len(digest) != 64
|
|
215
|
+
or any(character not in "0123456789abcdef" for character in digest)
|
|
216
|
+
):
|
|
217
|
+
raise FGraphTypeError("event digest must be 32-byte lowercase hexadecimal")
|
|
218
|
+
return event, pinned_basis, chunk_offset, digest
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _event_resource_uri(event: str, basis: int, offset: int, digest: str) -> str:
|
|
222
|
+
return f"fgraph://event/{quote(event, safe='')}?" + urlencode({"basis": basis, "offset": offset, "digest": digest})
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _event_payload(db: Db, event: str, basis: int, digest: str) -> bytes:
|
|
226
|
+
row = db._connection.execute( # noqa: SLF001
|
|
227
|
+
"SELECT ev.event_hash,ev.event_data FROM fgraph_events ev "
|
|
228
|
+
"JOIN fgraph_ids i ON i.id=ev.tx WHERE i.gid=? AND ev.tx<=?",
|
|
229
|
+
(uuid.UUID(event).bytes, basis),
|
|
230
|
+
).fetchone()
|
|
231
|
+
if row is None:
|
|
232
|
+
raise Conflict(f"event {event!r} is not visible at basis {basis}")
|
|
233
|
+
event_hash = bytes(row["event_hash"])
|
|
234
|
+
if len(event_hash) != 32 or digest != event_hash.hex():
|
|
235
|
+
raise Conflict("event digest does not match the pinned event receipt")
|
|
236
|
+
if row["event_data"] is None:
|
|
237
|
+
raise Conflict("event payload is unavailable because the event was excised")
|
|
238
|
+
db._decode_event_data(event, event_hash, row["event_data"]) # noqa: SLF001
|
|
239
|
+
return str(row["event_data"]).encode()
|
|
240
|
+
|
|
241
|
+
|
|
183
242
|
def _schema_page(
|
|
184
243
|
snapshot: dict[str, Any],
|
|
185
244
|
offset: int,
|
|
@@ -392,7 +451,7 @@ def create_server(
|
|
|
392
451
|
"pages, preserve returned basis_tx for follow-up reads, and supply stable operation_id plus "
|
|
393
452
|
"if_basis_tx for retry-safe writes. The server is read-only unless explicitly started with write access."
|
|
394
453
|
),
|
|
395
|
-
version="1.0
|
|
454
|
+
version="1.2.0",
|
|
396
455
|
)
|
|
397
456
|
read_annotations = ToolAnnotations(
|
|
398
457
|
read_only_hint=True,
|
|
@@ -756,6 +815,37 @@ def create_server(
|
|
|
756
815
|
receipt["truncated"] = len(facts) > _MAX_TOOL_ITEMS
|
|
757
816
|
return _resource_result(receipt)
|
|
758
817
|
|
|
818
|
+
@server.resource(
|
|
819
|
+
"fgraph://event/{event}{?basis,offset,digest}",
|
|
820
|
+
name="fgraph event",
|
|
821
|
+
description="One basis- and digest-pinned canonical event payload in bounded byte chunks.",
|
|
822
|
+
mime_type="application/json",
|
|
823
|
+
)
|
|
824
|
+
@_resource_errors
|
|
825
|
+
async def event_resource(
|
|
826
|
+
event: str,
|
|
827
|
+
basis: str = "",
|
|
828
|
+
offset: str = "0",
|
|
829
|
+
digest: str = "",
|
|
830
|
+
) -> dict[str, Any]:
|
|
831
|
+
event, pinned_basis, chunk_offset, digest = _event_coordinates(db, event, basis, offset, digest)
|
|
832
|
+
payload = _event_payload(db, event, pinned_basis, digest)
|
|
833
|
+
if chunk_offset >= len(payload):
|
|
834
|
+
raise FGraphTypeError("event offset is outside the canonical event payload")
|
|
835
|
+
chunk = payload[chunk_offset : chunk_offset + _EVENT_CHUNK_BYTES]
|
|
836
|
+
next_offset = chunk_offset + len(chunk)
|
|
837
|
+
result = {
|
|
838
|
+
"basis_tx": pinned_basis,
|
|
839
|
+
"event": event,
|
|
840
|
+
"event_hash": digest,
|
|
841
|
+
"offset": chunk_offset,
|
|
842
|
+
"encoding": "base64",
|
|
843
|
+
"data": base64.b64encode(chunk).decode("ascii"),
|
|
844
|
+
}
|
|
845
|
+
if next_offset < len(payload):
|
|
846
|
+
result["next_uri"] = _event_resource_uri(event, pinned_basis, next_offset, digest)
|
|
847
|
+
return _resource_result(result)
|
|
848
|
+
|
|
759
849
|
@server.resource(
|
|
760
850
|
"fgraph://changes{?since,cursor}",
|
|
761
851
|
name="fgraph changes",
|
|
@@ -776,16 +866,54 @@ def create_server(
|
|
|
776
866
|
if basis is None:
|
|
777
867
|
basis = _basis(db)
|
|
778
868
|
position = boundary
|
|
869
|
+
if position is None:
|
|
870
|
+
raise FGraphTypeError("changes cursor has no transaction position; restart pagination")
|
|
779
871
|
rows = db._connection.execute( # noqa: SLF001
|
|
780
872
|
"SELECT tx FROM fgraph_events WHERE tx>? AND tx<=? ORDER BY tx LIMIT ?",
|
|
781
873
|
(position, basis, _MAX_TOOL_ITEMS + 1),
|
|
782
|
-
)
|
|
783
|
-
records
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
874
|
+
)
|
|
875
|
+
records: list[dict[str, Any]] = []
|
|
876
|
+
last_position = position
|
|
877
|
+
next_position: int | None = None
|
|
878
|
+
oversized_event: dict[str, Any] | None = None
|
|
879
|
+
event_bytes = 0
|
|
880
|
+
for row in rows:
|
|
881
|
+
transaction = int(row["tx"])
|
|
882
|
+
if len(records) == _MAX_TOOL_ITEMS:
|
|
883
|
+
next_position = last_position
|
|
884
|
+
break
|
|
885
|
+
record = db._event_record_for_tx(transaction) # noqa: SLF001
|
|
886
|
+
raw = _canonical_json_document(record).encode()
|
|
887
|
+
if len(raw) <= _CHANGES_PAGE_BYTES and event_bytes + len(raw) <= _CHANGES_PAGE_BYTES:
|
|
888
|
+
records.append(record)
|
|
889
|
+
event_bytes += len(raw)
|
|
890
|
+
last_position = transaction
|
|
891
|
+
continue
|
|
892
|
+
if records:
|
|
893
|
+
next_position = last_position
|
|
894
|
+
break
|
|
895
|
+
event_hash_row = db._connection.execute( # noqa: SLF001
|
|
896
|
+
"SELECT event_hash FROM fgraph_events WHERE tx=?",
|
|
897
|
+
(transaction,),
|
|
898
|
+
).fetchone()
|
|
899
|
+
if event_hash_row is None or len(bytes(event_hash_row["event_hash"])) != 32:
|
|
900
|
+
raise FGraphTypeError("oversized event has no valid event hash")
|
|
901
|
+
event_hash = bytes(event_hash_row["event_hash"]).hex()
|
|
902
|
+
oversized_event = {
|
|
903
|
+
"event": record["event"],
|
|
904
|
+
"event_hash": event_hash,
|
|
905
|
+
"bytes": len(raw),
|
|
906
|
+
"uri": _event_resource_uri(str(record["event"]), basis, 0, event_hash),
|
|
907
|
+
}
|
|
908
|
+
last_position = transaction
|
|
909
|
+
if transaction < basis:
|
|
910
|
+
next_position = transaction
|
|
911
|
+
break
|
|
912
|
+
result = {"basis_tx": basis, "events": records}
|
|
913
|
+
if oversized_event is not None:
|
|
914
|
+
result["oversized_event"] = oversized_event
|
|
915
|
+
if next_position is not None:
|
|
916
|
+
next_cursor = _changes_cursor(db, basis, next_position, since)
|
|
789
917
|
result["next_uri"] = _continued_resource_uri("changes", next_cursor, since=since)
|
|
790
918
|
return _resource_result(result)
|
|
791
919
|
|