backdraft 0.2.0__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.
Files changed (50) hide show
  1. backdraft/__init__.py +13 -0
  2. backdraft/bind/__init__.py +37 -0
  3. backdraft/bind/binder.py +653 -0
  4. backdraft/bind/cli.py +133 -0
  5. backdraft/bind/evidence.py +232 -0
  6. backdraft/bind/verify/__init__.py +30 -0
  7. backdraft/bind/verify/base.py +70 -0
  8. backdraft/bind/verify/entail.py +156 -0
  9. backdraft/bind/verify/overlap.py +114 -0
  10. backdraft/bind/verify/recompute.py +45 -0
  11. backdraft/bind/verify/value_trace.py +352 -0
  12. backdraft/cli.py +341 -0
  13. backdraft/cli_context.py +152 -0
  14. backdraft/credentials.py +106 -0
  15. backdraft/extract/__init__.py +37 -0
  16. backdraft/extract/base.py +184 -0
  17. backdraft/extract/pdf_text.py +124 -0
  18. backdraft/extract/text.py +44 -0
  19. backdraft/extract/vlm.py +175 -0
  20. backdraft/extract/vlm_settings.py +197 -0
  21. backdraft/extract/xlsx.py +244 -0
  22. backdraft/gate/__init__.py +39 -0
  23. backdraft/gate/cli.py +131 -0
  24. backdraft/gate/reader.py +592 -0
  25. backdraft/gate/searcher.py +123 -0
  26. backdraft/kernel/__init__.py +17 -0
  27. backdraft/kernel/artifact.py +234 -0
  28. backdraft/kernel/chunking.py +162 -0
  29. backdraft/kernel/claims.py +142 -0
  30. backdraft/kernel/errors.py +35 -0
  31. backdraft/kernel/hashing.py +89 -0
  32. backdraft/kernel/model.py +336 -0
  33. backdraft/kernel/tokens.py +291 -0
  34. backdraft/registry/__init__.py +39 -0
  35. backdraft/registry/ledger.py +82 -0
  36. backdraft/registry/schema.sql +90 -0
  37. backdraft/registry/store.py +915 -0
  38. backdraft/render/__init__.py +31 -0
  39. backdraft/render/_text.py +69 -0
  40. backdraft/render/cli.py +107 -0
  41. backdraft/render/footnotes.py +172 -0
  42. backdraft/render/html.py +1211 -0
  43. backdraft/render/markdown.py +313 -0
  44. backdraft/render/placement.py +105 -0
  45. backdraft/render/sidecar.py +164 -0
  46. backdraft-0.2.0.dist-info/METADATA +253 -0
  47. backdraft-0.2.0.dist-info/RECORD +50 -0
  48. backdraft-0.2.0.dist-info/WHEEL +4 -0
  49. backdraft-0.2.0.dist-info/entry_points.txt +2 -0
  50. backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,291 @@
1
+ """The citation token grammar: parse, format, validate.
2
+
3
+ A token is the textual name of an anchor — the thing a model transcribes into
4
+ authored text as a markdown link href: `[claim text](bd:t12-audit:p8.c3:a7f3)`.
5
+
6
+ EBNF (normative; prose copy in spec/tokens.md)::
7
+
8
+ token = "bd:" slug ":" locator ":" hash
9
+ slug = alnum-lower (alnum-lower | "-"){1,31} ; unique per registry
10
+ locator = page-loc | chunk-loc | cell-loc
11
+ page-loc = "p" int ; whole page
12
+ chunk-loc = "p" int "." "c" int ; chunk ordinal, 1-based
13
+ cell-loc = sheetref "!" cell [":" cell] ; cell or rectangular range
14
+ sheetref = slug-sanitized sheet name (no ":" "!" whitespace)
15
+ cell = column-letters row-int ; A1 notation, uppercase
16
+ hash = lowercase-hex{4,8} ; prefix of snippet sha256
17
+
18
+ Examples: ``bd:t12-audit:p8.c3:a7f3`` · ``bd:model:rent-roll!B10:9e2f``
19
+ · ``bd:t12-audit:p8:c114`` · ``bd:model:rent-roll!B10:C12:9e2f``
20
+
21
+ Wire form: one markdown link per claim span; multiple citations are `;`-separated
22
+ inside one href (`[claim](bd:...;bd:...)`). One grammar, no alternates.
23
+
24
+ Reserved and not implemented in v0: the derivation form ``bd:calc(<expr>)``.
25
+ `parse` raises `UnsupportedTokenError` for it — recognized, not supported.
26
+
27
+ Parsing is strict: no surrounding whitespace is tolerated and no canonicalization
28
+ is performed, so `format(parse(t)) == t` for every token this module accepts.
29
+ Callers that read from documents strip first (see `kernel.claims`).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import re
35
+ from dataclasses import dataclass
36
+
37
+ from .errors import MalformedTokenError, UnsupportedTokenError
38
+
39
+ __all__ = [
40
+ "PREFIX",
41
+ "SEPARATOR",
42
+ "Cell",
43
+ "PageLocator",
44
+ "ChunkLocator",
45
+ "CellLocator",
46
+ "Locator",
47
+ "Token",
48
+ "parse",
49
+ "parse_locator",
50
+ "format_token",
51
+ "format_locator",
52
+ "validate",
53
+ "split_href",
54
+ "is_token_href",
55
+ ]
56
+
57
+ PREFIX = "bd:"
58
+ """Every token starts with this."""
59
+
60
+ SEPARATOR = ";"
61
+ """Separates multiple citations inside one markdown href."""
62
+
63
+ _RESERVED_DERIVATION = "calc("
64
+ """Reserved derivation form: `bd:calc(<expr over tokens>)`. Grammar TBD."""
65
+
66
+ _SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{1,31}")
67
+ _HASH_RE = re.compile(r"[0-9a-f]{4,8}")
68
+ # NOTE: the spec constrains sheetrefs to exclude ":" "!" and whitespace. We also
69
+ # exclude ";" "(" ")" because those terminate a citation or a markdown link — a
70
+ # sheet name containing them could not survive the wire form.
71
+ _SHEETREF_RE = re.compile(r"[^:!;()\s]+")
72
+ # NOTE: integers are canonical — no leading zeros — so parse/format round-trips.
73
+ _INT_RE = r"(?:0|[1-9][0-9]*)"
74
+ _PAGE_RE = re.compile(rf"p(?P<page>{_INT_RE})")
75
+ _CHUNK_RE = re.compile(rf"p(?P<page>{_INT_RE})\.c(?P<ordinal>{_INT_RE})")
76
+ _CELL_RE = re.compile(
77
+ rf"(?P<sheet>[^:!;()\s]+)!"
78
+ rf"(?P<col>[A-Z]+)(?P<row>{_INT_RE})"
79
+ rf"(?::(?P<col2>[A-Z]+)(?P<row2>{_INT_RE}))?"
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class Cell:
85
+ """An A1-notation cell reference: uppercase column letters + 1-based row."""
86
+
87
+ column: str
88
+ row: int
89
+
90
+ def __post_init__(self) -> None:
91
+ if not re.fullmatch(r"[A-Z]+", self.column):
92
+ raise MalformedTokenError(f"cell column must be uppercase letters: {self.column!r}")
93
+ if self.row < 1:
94
+ raise MalformedTokenError(f"cell row must be 1-based: {self.row!r}")
95
+
96
+ def format(self) -> str:
97
+ return f"{self.column}{self.row}"
98
+
99
+ def __str__(self) -> str:
100
+ return self.format()
101
+
102
+
103
+ @dataclass(frozen=True, slots=True)
104
+ class PageLocator:
105
+ """A whole page or sheet: `p8`."""
106
+
107
+ page: int
108
+
109
+ def __post_init__(self) -> None:
110
+ if self.page < 1:
111
+ raise MalformedTokenError(f"page numbers are 1-based: {self.page!r}")
112
+
113
+ @property
114
+ def kind(self) -> str:
115
+ return "page"
116
+
117
+ def format(self) -> str:
118
+ return f"p{self.page}"
119
+
120
+ def __str__(self) -> str:
121
+ return self.format()
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class ChunkLocator:
126
+ """A chunk within a page: `p8.c3`. Both numbers are 1-based."""
127
+
128
+ page: int
129
+ ordinal: int
130
+
131
+ def __post_init__(self) -> None:
132
+ if self.page < 1:
133
+ raise MalformedTokenError(f"page numbers are 1-based: {self.page!r}")
134
+ if self.ordinal < 1:
135
+ raise MalformedTokenError(f"chunk ordinals are 1-based: {self.ordinal!r}")
136
+
137
+ @property
138
+ def kind(self) -> str:
139
+ return "chunk"
140
+
141
+ def format(self) -> str:
142
+ return f"p{self.page}.c{self.ordinal}"
143
+
144
+ def __str__(self) -> str:
145
+ return self.format()
146
+
147
+
148
+ @dataclass(frozen=True, slots=True)
149
+ class CellLocator:
150
+ """A cell or rectangular range within a sheet: `rent-roll!B10`, `rent-roll!B10:C12`.
151
+
152
+ `kind` is `"cell"` when `end` is absent and `"range"` when it is present,
153
+ matching the `anchors.kind` column.
154
+ """
155
+
156
+ sheet: str
157
+ cell: Cell
158
+ end: Cell | None = None
159
+
160
+ def __post_init__(self) -> None:
161
+ if not _SHEETREF_RE.fullmatch(self.sheet):
162
+ raise MalformedTokenError(f"invalid sheet reference: {self.sheet!r}")
163
+
164
+ @property
165
+ def kind(self) -> str:
166
+ return "range" if self.end is not None else "cell"
167
+
168
+ def format(self) -> str:
169
+ span = self.cell.format()
170
+ if self.end is not None:
171
+ span = f"{span}:{self.end.format()}"
172
+ return f"{self.sheet}!{span}"
173
+
174
+ def __str__(self) -> str:
175
+ return self.format()
176
+
177
+
178
+ type Locator = PageLocator | ChunkLocator | CellLocator
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class Token:
183
+ """A parsed citation token: `bd:<slug>:<locator>:<hash>`."""
184
+
185
+ slug: str
186
+ locator: Locator
187
+ hash: str
188
+
189
+ def __post_init__(self) -> None:
190
+ if not _SLUG_RE.fullmatch(self.slug):
191
+ raise MalformedTokenError(f"invalid slug: {self.slug!r}")
192
+ if not _HASH_RE.fullmatch(self.hash):
193
+ raise MalformedTokenError(f"invalid hash: {self.hash!r}")
194
+
195
+ @property
196
+ def kind(self) -> str:
197
+ """The anchor kind this token names: `page` | `chunk` | `cell` | `range`."""
198
+ return self.locator.kind
199
+
200
+ def format(self) -> str:
201
+ return f"{PREFIX}{self.slug}:{self.locator.format()}:{self.hash}"
202
+
203
+ def __str__(self) -> str:
204
+ return self.format()
205
+
206
+
207
+ def parse(text: str) -> Token:
208
+ """Parse a token. Strict: no whitespace tolerance, no canonicalization.
209
+
210
+ Raises `UnsupportedTokenError` for the reserved `bd:calc(...)` derivation
211
+ form and `MalformedTokenError` for everything else that is not a token.
212
+ """
213
+ if not isinstance(text, str):
214
+ raise MalformedTokenError(f"token must be a string: {text!r}")
215
+ if not text.startswith(PREFIX):
216
+ raise MalformedTokenError(f"token must start with {PREFIX!r}: {text!r}")
217
+ body = text[len(PREFIX) :]
218
+ if body.startswith(_RESERVED_DERIVATION):
219
+ raise UnsupportedTokenError(
220
+ f"reserved derivation form 'bd:calc(...)' is not supported in v0: {text!r}"
221
+ )
222
+ slug, sep, rest = body.partition(":")
223
+ if not sep:
224
+ raise MalformedTokenError(f"token needs slug, locator and hash segments: {text!r}")
225
+ locator_text, sep, hash_text = rest.rpartition(":")
226
+ if not sep:
227
+ raise MalformedTokenError(f"token is missing its hash segment: {text!r}")
228
+ if not _SLUG_RE.fullmatch(slug):
229
+ raise MalformedTokenError(f"invalid slug {slug!r} in token: {text!r}")
230
+ if not _HASH_RE.fullmatch(hash_text):
231
+ raise MalformedTokenError(f"invalid hash {hash_text!r} in token: {text!r}")
232
+ return Token(slug=slug, locator=parse_locator(locator_text), hash=hash_text)
233
+
234
+
235
+ def parse_locator(text: str) -> Locator:
236
+ """Parse the locator segment of a token into a typed locator."""
237
+ if match := _CHUNK_RE.fullmatch(text):
238
+ return ChunkLocator(page=int(match["page"]), ordinal=int(match["ordinal"]))
239
+ if match := _PAGE_RE.fullmatch(text):
240
+ return PageLocator(page=int(match["page"]))
241
+ if match := _CELL_RE.fullmatch(text):
242
+ end = None
243
+ if match["col2"] is not None:
244
+ end = Cell(column=match["col2"], row=int(match["row2"]))
245
+ return CellLocator(
246
+ sheet=match["sheet"],
247
+ cell=Cell(column=match["col"], row=int(match["row"])),
248
+ end=end,
249
+ )
250
+ raise MalformedTokenError(f"invalid locator: {text!r}")
251
+
252
+
253
+ def format_token(slug: str, locator: Locator, hash: str) -> str: # noqa: A002 - spec vocabulary
254
+ """Format a token from its parts, validating them."""
255
+ return Token(slug=slug, locator=locator, hash=hash).format()
256
+
257
+
258
+ def format_locator(locator: Locator) -> str:
259
+ """Format a locator exactly as it appears in a token."""
260
+ return locator.format()
261
+
262
+
263
+ def validate(text: str) -> bool:
264
+ """True iff `text` is a token this version can parse.
265
+
266
+ The reserved `bd:calc(...)` form is *not* valid: it is recognized but
267
+ unsupported, and callers that must distinguish should catch the errors.
268
+ """
269
+ try:
270
+ parse(text)
271
+ except (MalformedTokenError, UnsupportedTokenError):
272
+ return False
273
+ return True
274
+
275
+
276
+ def split_href(href: str) -> list[str]:
277
+ """Split a markdown href into its citation candidates.
278
+
279
+ Multiple citations are `;`-separated. Each piece is stripped; empty pieces
280
+ (a stray or trailing `;`) carry no token and are dropped.
281
+ """
282
+ return [piece for piece in (raw.strip() for raw in href.split(SEPARATOR)) if piece]
283
+
284
+
285
+ def is_token_href(href: str) -> bool:
286
+ """True if any `;`-separated piece of `href` claims to be a backdraft token.
287
+
288
+ Deliberately shallow: an href with a `bd:` piece is a citation href even if
289
+ that piece is malformed, so bind reports it instead of ignoring it.
290
+ """
291
+ return any(piece.startswith(PREFIX) for piece in split_href(href))
@@ -0,0 +1,39 @@
1
+ """The registry: SQLite + FTS5 in `.backdraft/`, behind one object.
2
+
3
+ `Registry` is the only stateful thing in backdraft. The gate, bind and render
4
+ layers consume the surface in SPEC.md § Addendum A and implement none of it.
5
+
6
+ Export style (every package above the kernel uses it): this file re-exports
7
+ **names**, never submodules, and only the ones a consumer outside the package
8
+ imports — `store`'s own `__all__` is wider and is its internal contract. Reaching
9
+ past this surface (`registry.store.something`) is a signal that the something
10
+ belongs here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .store import (
16
+ DIRECTORY,
17
+ EXPORT_FORMAT,
18
+ Registry,
19
+ RegistryError,
20
+ Resolution,
21
+ SearchHit,
22
+ SearchResults,
23
+ media_type_for,
24
+ sanitize_sheet_name,
25
+ slug_for,
26
+ )
27
+
28
+ __all__ = [
29
+ "DIRECTORY",
30
+ "EXPORT_FORMAT",
31
+ "Registry",
32
+ "RegistryError",
33
+ "Resolution",
34
+ "SearchHit",
35
+ "SearchResults",
36
+ "media_type_for",
37
+ "sanitize_sheet_name",
38
+ "slug_for",
39
+ ]
@@ -0,0 +1,82 @@
1
+ """The ledger: every token minted into a writer's context, per session.
2
+
3
+ This is what lets bind distinguish "cited what you were shown" from "cited a
4
+ valid token you were never shown" — the hallucination class the app could not
5
+ express. The gate writes here on every emitted token; bind reads.
6
+
7
+ Plain functions over a connection rather than a second stateful object: the
8
+ Registry is the only stateful thing in the system, and it delegates here.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sqlite3
14
+ import uuid
15
+ from typing import Sequence
16
+
17
+ __all__ = [
18
+ "ensure_session",
19
+ "record_shown",
20
+ "sessions",
21
+ "was_shown",
22
+ ]
23
+
24
+
25
+ def ensure_session(
26
+ connection: sqlite3.Connection, session_id: str | None, label: str | None, now: str
27
+ ) -> str:
28
+ """Return an existing or freshly created session id.
29
+
30
+ A `None` id mints one. NOTE: the spec says "caller-supplied or generated"
31
+ without fixing the generated form; a uuid4 hex is opaque, collision-free and
32
+ short enough to paste into `--session`.
33
+ """
34
+ resolved = session_id or uuid.uuid4().hex
35
+ connection.execute(
36
+ "INSERT INTO sessions (id, label, started_at) VALUES (?, ?, ?) "
37
+ "ON CONFLICT(id) DO NOTHING",
38
+ (resolved, label, now),
39
+ )
40
+ return resolved
41
+
42
+
43
+ def record_shown(
44
+ connection: sqlite3.Connection,
45
+ session_id: str,
46
+ anchor_ids: Sequence[int],
47
+ now: str,
48
+ ) -> None:
49
+ """Record that these anchors were shown in this session.
50
+
51
+ Idempotent: showing the same anchor twice keeps the first `shown_at`, because
52
+ what bind asks is whether the writer ever saw it.
53
+ """
54
+ connection.executemany(
55
+ "INSERT INTO ledger (session_id, anchor_id, shown_at) VALUES (?, ?, ?) "
56
+ "ON CONFLICT(session_id, anchor_id) DO NOTHING",
57
+ [(session_id, int(anchor_id), now) for anchor_id in anchor_ids],
58
+ )
59
+
60
+
61
+ def was_shown(connection: sqlite3.Connection, session_id: str, token: str) -> bool:
62
+ """True if any anchor named by `token` was shown in this session.
63
+
64
+ Matched by token, not by anchor id: a token that survived a re-ingest names a
65
+ new anchor row with the same name, and the writer did see it.
66
+ """
67
+ row = connection.execute(
68
+ "SELECT 1 FROM ledger JOIN anchors ON anchors.id = ledger.anchor_id "
69
+ "WHERE ledger.session_id = ? AND anchors.token = ? LIMIT 1",
70
+ (session_id, token),
71
+ ).fetchone()
72
+ return row is not None
73
+
74
+
75
+ def sessions(connection: sqlite3.Connection) -> list[dict]:
76
+ """Every session, oldest first."""
77
+ return [
78
+ {"id": row["id"], "label": row["label"], "started_at": row["started_at"]}
79
+ for row in connection.execute(
80
+ "SELECT id, label, started_at FROM sessions ORDER BY started_at, id"
81
+ )
82
+ ]
@@ -0,0 +1,90 @@
1
+ -- The DDL. Single source of truth for registry shape (SPEC.md § Registry).
2
+ --
3
+ -- Applied by `Registry.open` against a fresh `.backdraft/registry.db`; every
4
+ -- statement is idempotent so opening an existing registry is a no-op.
5
+
6
+ CREATE TABLE IF NOT EXISTS documents (
7
+ id INTEGER PRIMARY KEY,
8
+ slug TEXT NOT NULL UNIQUE,
9
+ sha256 TEXT NOT NULL UNIQUE,
10
+ path TEXT NOT NULL, -- as given at ingest; informational
11
+ filename TEXT NOT NULL,
12
+ media_type TEXT NOT NULL, -- 'pdf' | 'xlsx' | 'text'
13
+ created_at TEXT NOT NULL -- ISO-8601 UTC, everywhere
14
+ );
15
+
16
+ CREATE TABLE IF NOT EXISTS extractions (
17
+ id INTEGER PRIMARY KEY,
18
+ document_id INTEGER NOT NULL REFERENCES documents(id),
19
+ extractor TEXT NOT NULL, -- 'pdf-text' | 'xlsx' | 'text' | 'vlm' | ...
20
+ extractor_version TEXT NOT NULL,
21
+ config_hash TEXT NOT NULL,
22
+ deterministic INTEGER NOT NULL,
23
+ is_current INTEGER NOT NULL DEFAULT 1, -- one current per document (partial unique index)
24
+ created_at TEXT NOT NULL
25
+ );
26
+
27
+ -- The "one current generation per document" rule, enforced by the database
28
+ -- rather than by the code that flips the flag.
29
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_extractions_current
30
+ ON extractions(document_id) WHERE is_current = 1;
31
+
32
+ CREATE TABLE IF NOT EXISTS pages (
33
+ id INTEGER PRIMARY KEY,
34
+ extraction_id INTEGER NOT NULL REFERENCES extractions(id),
35
+ number INTEGER NOT NULL, -- 1-based
36
+ kind TEXT NOT NULL, -- 'page' | 'sheet'
37
+ name TEXT, -- sheet name for kind='sheet'
38
+ text TEXT NOT NULL, -- the snapshot; receipts quote THIS
39
+ summary TEXT, -- optional, for TOC
40
+ UNIQUE (extraction_id, number)
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS page_images (
44
+ extraction_id INTEGER NOT NULL REFERENCES extractions(id),
45
+ number INTEGER NOT NULL, -- matches pages.number
46
+ format TEXT NOT NULL, -- 'webp' | 'png' | 'jpeg'
47
+ width INTEGER NOT NULL,
48
+ height INTEGER NOT NULL,
49
+ data BLOB NOT NULL, -- the page as the extractor saw it
50
+ PRIMARY KEY (extraction_id, number)
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS anchors (
54
+ id INTEGER PRIMARY KEY,
55
+ extraction_id INTEGER NOT NULL REFERENCES extractions(id),
56
+ page_number INTEGER NOT NULL,
57
+ kind TEXT NOT NULL, -- 'chunk' | 'cell' | 'range' | 'page'
58
+ locator TEXT NOT NULL, -- exactly the token's locator segment
59
+ snippet TEXT NOT NULL, -- verbatim from the snapshot (the receipt)
60
+ snippet_sha256 TEXT NOT NULL,
61
+ token TEXT NOT NULL, -- full bd:... string
62
+ start_off INTEGER, end_off INTEGER,
63
+ created_at TEXT NOT NULL,
64
+ UNIQUE (extraction_id, locator)
65
+ );
66
+ CREATE INDEX IF NOT EXISTS idx_anchors_token ON anchors(token); -- token repeats across generations when content unchanged
67
+
68
+ CREATE TABLE IF NOT EXISTS sessions (
69
+ id TEXT PRIMARY KEY, -- caller-supplied or generated
70
+ label TEXT,
71
+ started_at TEXT NOT NULL
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS ledger (
75
+ session_id TEXT NOT NULL REFERENCES sessions(id),
76
+ anchor_id INTEGER NOT NULL REFERENCES anchors(id),
77
+ shown_at TEXT NOT NULL,
78
+ PRIMARY KEY (session_id, anchor_id)
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS bindings (
82
+ id INTEGER PRIMARY KEY,
83
+ doc_path TEXT NOT NULL,
84
+ session_id TEXT,
85
+ mode TEXT NOT NULL, -- 'frontwalk' | 'backfill'
86
+ report_json TEXT NOT NULL, -- full BindReport
87
+ bound_at TEXT NOT NULL
88
+ );
89
+
90
+ CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(snippet, token UNINDEXED, slug UNINDEXED, page UNINDEXED);