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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fgraph
3
- Version: 1.0.4
3
+ Version: 1.2.0
4
4
  Summary: Embedded temporal fact store in a single SQLite file
5
5
  Author: Médéric Hurier
6
6
  License-Expression: MIT
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["uv_build>=0.12.0,<0.13.0"]
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.4"
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.0,<0.13.0"]
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.4"
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"
@@ -34,4 +34,4 @@ __all__ = [
34
34
  "restore_backup",
35
35
  ]
36
36
 
37
- __version__ = "1.0.4"
37
+ __version__ = "1.2.0"
@@ -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
- DbOption = Annotated[str | None, typer.Option("--db", envvar="FGRAPH_DB", help="SQLite database path.")]
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 = "fgraph.db"
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.")] = "fgraph.db",
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
- options = _Options(db=db, json_output=json_output, query_budget=query_budget)
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
- path or _options().db,
95
+ selected_path,
73
96
  read_only=read_only,
74
- query_budget=_options().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[Iterator[str]]:
193
+ def _input_lines(source: str, *, context: str) -> Iterator[TextIO]:
128
194
  if source == "-":
129
- yield iter(sys.stdin)
195
+ yield sys.stdin
130
196
  return
131
197
  try:
132
198
  with Path(source).open(encoding="utf-8") as stream:
133
- yield iter(stream)
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 FGraphTypeError("choose --operation-id for one transaction or --operation-id-prefix for batches")
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 FGraphTypeError("--operation-id-prefix requires --batch-size")
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 | None = typer.Option(None, "--many/--one"),
493
- unique: bool | None = typer.Option(None, "--unique/--not-unique"),
494
- nohistory: bool | None = typer.Option(None, "--nohistory/--history"),
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=many,
511
- unique=unique,
512
- nohistory=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/--open")] = False,
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=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
- output = graph.snapshot()
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(_input_text(source, context="snapshot"))
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.event_records(since):
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.4",
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
- ).fetchall()
783
- records = [db._event_record_for_tx(int(row["tx"])) for row in rows] # noqa: SLF001
784
- next_cursor = None
785
- if len(records) > _MAX_TOOL_ITEMS:
786
- next_cursor = _changes_cursor(db, basis, int(rows[_MAX_TOOL_ITEMS - 1]["tx"]), since)
787
- result = {"basis_tx": basis, "events": records[:_MAX_TOOL_ITEMS]}
788
- if next_cursor is not None:
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