lorebase-store 0.2.3__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 (163) hide show
  1. lorebase_store/__init__.py +1 -0
  2. lorebase_store/_migrations/env.py +106 -0
  3. lorebase_store/_migrations/script.py.mako +27 -0
  4. lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
  5. lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
  6. lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
  7. lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
  8. lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
  9. lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
  10. lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
  11. lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
  12. lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
  13. lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
  14. lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
  15. lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
  16. lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
  17. lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
  18. lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
  19. lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
  20. lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
  21. lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
  22. lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
  23. lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
  24. lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
  25. lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
  26. lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
  27. lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
  28. lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
  29. lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
  30. lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
  31. lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
  32. lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
  33. lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
  34. lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
  35. lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
  36. lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
  37. lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
  38. lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
  39. lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
  40. lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
  41. lorebase_store/auth/__init__.py +52 -0
  42. lorebase_store/auth/api_keys.py +161 -0
  43. lorebase_store/auth/audit.py +382 -0
  44. lorebase_store/auth/checkpoints.py +185 -0
  45. lorebase_store/auth/grants.py +161 -0
  46. lorebase_store/auth/principals.py +158 -0
  47. lorebase_store/auth/scope.py +70 -0
  48. lorebase_store/auth/sessions.py +153 -0
  49. lorebase_store/auth/unit.py +53 -0
  50. lorebase_store/auth/usage.py +70 -0
  51. lorebase_store/blobs.py +73 -0
  52. lorebase_store/bm25.py +346 -0
  53. lorebase_store/bulk.py +147 -0
  54. lorebase_store/bundle.py +1830 -0
  55. lorebase_store/changefeed.py +261 -0
  56. lorebase_store/commit/__init__.py +42 -0
  57. lorebase_store/commit/changes.py +67 -0
  58. lorebase_store/commit/errors.py +60 -0
  59. lorebase_store/commit/migrations.py +335 -0
  60. lorebase_store/commit/pipeline.py +754 -0
  61. lorebase_store/commit/schema.py +123 -0
  62. lorebase_store/db.py +58 -0
  63. lorebase_store/definer.py +50 -0
  64. lorebase_store/digests.py +99 -0
  65. lorebase_store/embeddings.py +444 -0
  66. lorebase_store/endpoint_modes.py +59 -0
  67. lorebase_store/extraction/__init__.py +61 -0
  68. lorebase_store/extraction/items.py +187 -0
  69. lorebase_store/extraction/materialize.py +364 -0
  70. lorebase_store/extraction/proposal.py +65 -0
  71. lorebase_store/extraction/review.py +168 -0
  72. lorebase_store/gc.py +970 -0
  73. lorebase_store/indexes.py +1033 -0
  74. lorebase_store/jobs.py +651 -0
  75. lorebase_store/keyring.py +72 -0
  76. lorebase_store/layout.py +335 -0
  77. lorebase_store/materialize/__init__.py +103 -0
  78. lorebase_store/materialize/apply.py +436 -0
  79. lorebase_store/materialize/background.py +1184 -0
  80. lorebase_store/materialize/branch.py +268 -0
  81. lorebase_store/materialize/build.py +536 -0
  82. lorebase_store/materialize/derived.py +540 -0
  83. lorebase_store/materialize/lineage_backfill.py +502 -0
  84. lorebase_store/materialize/links.py +266 -0
  85. lorebase_store/materialize/overlay.py +292 -0
  86. lorebase_store/materialize/paths.py +315 -0
  87. lorebase_store/materialize/rebuild.py +326 -0
  88. lorebase_store/materialize/rows.py +153 -0
  89. lorebase_store/materialize/sensitivity.py +66 -0
  90. lorebase_store/materialize/snapshot.py +205 -0
  91. lorebase_store/materialize/stale.py +444 -0
  92. lorebase_store/materialize/verify.py +321 -0
  93. lorebase_store/merging/__init__.py +39 -0
  94. lorebase_store/merging/attempts.py +334 -0
  95. lorebase_store/merging/lineage.py +123 -0
  96. lorebase_store/merging/service.py +652 -0
  97. lorebase_store/migrations.py +133 -0
  98. lorebase_store/models/__init__.py +136 -0
  99. lorebase_store/models/base.py +64 -0
  100. lorebase_store/models/enums.py +132 -0
  101. lorebase_store/models/governance.py +268 -0
  102. lorebase_store/models/ingestion.py +288 -0
  103. lorebase_store/models/materialized.py +277 -0
  104. lorebase_store/models/proposals.py +195 -0
  105. lorebase_store/models/queues.py +87 -0
  106. lorebase_store/models/tracing.py +116 -0
  107. lorebase_store/models/versioning.py +222 -0
  108. lorebase_store/models/webhooks.py +96 -0
  109. lorebase_store/nodestore.py +384 -0
  110. lorebase_store/objectstore.py +162 -0
  111. lorebase_store/outbox_events.py +166 -0
  112. lorebase_store/profiling/__init__.py +58 -0
  113. lorebase_store/profiling/delimited.py +109 -0
  114. lorebase_store/profiling/postgres.py +211 -0
  115. lorebase_store/profiling/record_files.py +127 -0
  116. lorebase_store/profiling/records.py +92 -0
  117. lorebase_store/profiling/sql_databases.py +298 -0
  118. lorebase_store/ratelimit.py +129 -0
  119. lorebase_store/reads.py +1491 -0
  120. lorebase_store/replicas.py +434 -0
  121. lorebase_store/repositories/__init__.py +152 -0
  122. lorebase_store/repositories/changesets.py +59 -0
  123. lorebase_store/repositories/deferred_events.py +142 -0
  124. lorebase_store/repositories/documents.py +305 -0
  125. lorebase_store/repositories/domains.py +97 -0
  126. lorebase_store/repositories/events.py +205 -0
  127. lorebase_store/repositories/index_view.py +489 -0
  128. lorebase_store/repositories/invocations.py +68 -0
  129. lorebase_store/repositories/materializations.py +276 -0
  130. lorebase_store/repositories/materialized.py +1129 -0
  131. lorebase_store/repositories/merges.py +417 -0
  132. lorebase_store/repositories/proposal_items.py +199 -0
  133. lorebase_store/repositories/proposals.py +184 -0
  134. lorebase_store/repositories/provenance.py +77 -0
  135. lorebase_store/repositories/refs.py +452 -0
  136. lorebase_store/repositories/revisions.py +273 -0
  137. lorebase_store/repositories/scope.py +33 -0
  138. lorebase_store/repositories/source_runs.py +123 -0
  139. lorebase_store/repositories/suggestions.py +638 -0
  140. lorebase_store/runtime.py +87 -0
  141. lorebase_store/schema_cache.py +50 -0
  142. lorebase_store/settings.py +879 -0
  143. lorebase_store/storage.py +1478 -0
  144. lorebase_store/telemetry.py +142 -0
  145. lorebase_store/testing.py +108 -0
  146. lorebase_store/tracing.py +505 -0
  147. lorebase_store/traverse.py +578 -0
  148. lorebase_store/vault/__init__.py +50 -0
  149. lorebase_store/vault/keys.py +216 -0
  150. lorebase_store/vault/quotes.py +133 -0
  151. lorebase_store/vault/rotation.py +209 -0
  152. lorebase_store/vault/settings.py +152 -0
  153. lorebase_store/vault/store.py +987 -0
  154. lorebase_store/vault/transfer.py +558 -0
  155. lorebase_store/webhooks/__init__.py +24 -0
  156. lorebase_store/webhooks/delivery.py +637 -0
  157. lorebase_store/webhooks/keys.py +126 -0
  158. lorebase_store/webhooks/repository.py +142 -0
  159. lorebase_store/webhooks/settings.py +138 -0
  160. lorebase_store/webhooks/signing.py +50 -0
  161. lorebase_store-0.2.3.dist-info/METADATA +13 -0
  162. lorebase_store-0.2.3.dist-info/RECORD +163 -0
  163. lorebase_store-0.2.3.dist-info/WHEEL +4 -0
@@ -0,0 +1,229 @@
1
+ """Search indexes scoped by Domain and branch (design 8.7; store schema notes).
2
+
3
+ The GIN indexes of revision 0001 on `search` (`ix_mat_records_search`) and on `title`
4
+ (trigrams, `ix_mat_records_title`) index that column alone, so each is shared by every Domain
5
+ and branch of a hash partition: a search reads the matches of all of them and keeps its own
6
+ when it rechecks the rows, and a search on an overlay branch does so once per half. This
7
+ revision replaces them with `ix_mat_records_branch_search` on `(domain_id, branch, search)`
8
+ and `ix_mat_records_branch_title` on `(domain_id, branch, title gin_trgm_ops)`; the
9
+ `btree_gin` extension (contrib) provides the GIN operator classes for the two text columns,
10
+ so a search's Domain and branch are index conditions and the bitmap holds the branch's
11
+ matches only. The extension is created if missing and never dropped on downgrade, like the
12
+ extensions of revision 0001.
13
+
14
+ Building and swapping each index does not block writes for long:
15
+
16
+ - `CREATE INDEX ... ON ONLY mat_records` makes the parent index (invalid, no partition yet);
17
+ - outside the migration transaction, each partition gets its index with `CREATE INDEX
18
+ CONCURRENTLY` (an invalid leftover of an interrupted build is dropped and built again) and
19
+ is attached to the parent, which becomes valid when every partition is attached;
20
+ - the old index is dropped only once the new one is valid; dropping a partitioned index takes
21
+ a short exclusive lock on `mat_records` (catalog work, no scan).
22
+
23
+ Search on an overlay also stops probing the overlay's primary key once per base match: the
24
+ anti-join of `lb_overlay_rows` gets the argument `p_probe` (default true: `OFFSET 0` keeps it a
25
+ probe per base row, right for lookups by key and short pages); `lb_search_overlay` passes
26
+ false and lets the planner hash the overlay's keys when the matches outnumber them. Its
27
+ signature, checks, owner and grants stay those of revision 0010.
28
+
29
+ Revision ID: 0011
30
+ Revises: 0010
31
+ Create Date: 2026-10-21 20:00:00.000000
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import importlib.util
37
+ from collections.abc import Sequence
38
+ from pathlib import Path
39
+ from types import ModuleType
40
+ from typing import NamedTuple
41
+
42
+ import sqlalchemy as sa
43
+ from alembic import op
44
+
45
+ revision: str = "0011"
46
+ down_revision: str | Sequence[str] | None = "0010"
47
+ branch_labels: str | Sequence[str] | None = None
48
+ depends_on: str | Sequence[str] | None = None
49
+
50
+
51
+ def _revision_module(filename: str) -> ModuleType:
52
+ """An earlier revision file of this directory, loaded for its SQL templates and checks."""
53
+ path = Path(__file__).with_name(filename)
54
+ spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
55
+ if spec is None or spec.loader is None:
56
+ raise RuntimeError(f"cannot load migration {path}")
57
+ module = importlib.util.module_from_spec(spec)
58
+ spec.loader.exec_module(module)
59
+ return module
60
+
61
+
62
+ _overlays = _revision_module("0010_overlay_branches.py")
63
+ _checks = _overlays._checks
64
+ _reads = _overlays._reads
65
+
66
+ PARENT = "mat_records"
67
+
68
+
69
+ class GinIndex(NamedTuple):
70
+ name: str
71
+ columns: str
72
+ suffix: str
73
+ """Partition indexes are named `<partition>_<suffix>`, as Postgres names those it creates
74
+ for an index on the parent."""
75
+
76
+
77
+ SWAPS = (
78
+ (
79
+ GinIndex("ix_mat_records_search", "search", "search_idx"),
80
+ GinIndex(
81
+ "ix_mat_records_branch_search",
82
+ "domain_id, branch, search",
83
+ "domain_id_branch_search_idx",
84
+ ),
85
+ ),
86
+ (
87
+ GinIndex("ix_mat_records_title", "title gin_trgm_ops", "title_idx"),
88
+ GinIndex(
89
+ "ix_mat_records_branch_title",
90
+ "domain_id, branch, title gin_trgm_ops",
91
+ "domain_id_branch_title_idx",
92
+ ),
93
+ ),
94
+ )
95
+ """(index of revision 0001, its branch-scoped replacement)."""
96
+
97
+ OLD_HELPER = "lb_overlay_rows(text, text, text, text)"
98
+ HELPER = "lb_overlay_rows(text, text, text, text, boolean)"
99
+ SEARCH = _overlays.SIGNATURES[0]
100
+
101
+
102
+ def _replaced(text: str, old: str, new: str) -> str:
103
+ if text.count(old) != 1:
104
+ raise RuntimeError(f"revision 0010 changed: {old!r} not found once")
105
+ return text.replace(old, new)
106
+
107
+
108
+ LB_OVERLAY_ROWS = _replaced(
109
+ _replaced(
110
+ _replaced(
111
+ _overlays.LB_OVERLAY_ROWS,
112
+ "p_tail text DEFAULT NULL)",
113
+ "p_tail text DEFAULT NULL,\n p_probe boolean DEFAULT true)",
114
+ ),
115
+ "AND %8$s OFFSET 0)',",
116
+ "AND %8$s%9$s)',",
117
+ ),
118
+ "v_from_base, p_table, p_domain, v_base, v_generation, p_branch, v_s, v_shadow);",
119
+ "v_from_base, p_table, p_domain, v_base, v_generation, p_branch, v_s, v_shadow,\n"
120
+ " CASE WHEN p_probe THEN ' OFFSET 0' ELSE '' END);",
121
+ )
122
+ """Revision 0010's helper with `p_probe`: false renders the anti-join without `OFFSET 0`."""
123
+
124
+ _SEARCH_SOURCES = "v_records text;"
125
+ _SEARCH_RESOLVE = """
126
+ v_records := coalesce(
127
+ lb_overlay_rows(p_domain, p_branch, 'mat_records', NULL, false), 'mat_records');
128
+ """
129
+ """Run after the checks of revision 0008, as in revision 0010; search reads no Links."""
130
+
131
+
132
+ def _overlay_search(sources: str, resolve: str) -> None:
133
+ search_path = _reads._search_path()
134
+ options = _reads._OPTIONS.format(search_path=search_path).strip()
135
+ counts = _overlays.COUNTS[_overlays.LB_SEARCH_OVERLAY]
136
+ guards = _checks._guards(search_path, counts) + resolve
137
+ statement = _checks._create_or_replace(_overlays.LB_SEARCH_OVERLAY, guards)
138
+ op.execute(
139
+ statement.format(options=options, record_columns=_reads._RECORD_COLUMNS, sources=sources)
140
+ )
141
+ # CREATE OR REPLACE keeps the owner and the grants; restate them all the same.
142
+ op.execute(f"REVOKE ALL ON FUNCTION {SEARCH} FROM PUBLIC")
143
+ role = _reads._application_role()
144
+ if role is not None:
145
+ quoted = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar()
146
+ op.execute(f"GRANT EXECUTE ON FUNCTION {SEARCH} TO {quoted}")
147
+
148
+
149
+ def _helper(drop: str, create: str, signature: str) -> None:
150
+ """Swap the helper; the read functions resolve it by name when they run."""
151
+ op.execute(f"DROP FUNCTION IF EXISTS {drop}")
152
+ op.execute(create.format(search_path=_reads._search_path()))
153
+ op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
154
+
155
+
156
+ def _partitions() -> list[str]:
157
+ found = op.get_bind().execute(
158
+ sa.text(
159
+ "SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid "
160
+ "WHERE i.inhparent = CAST(:parent AS regclass) ORDER BY c.relname"
161
+ ),
162
+ {"parent": PARENT},
163
+ )
164
+ return list(found.scalars())
165
+
166
+
167
+ def _state(name: str) -> tuple[bool, bool] | None:
168
+ """`(valid, attached)` of an index, `None` when it does not exist."""
169
+ row = (
170
+ op.get_bind()
171
+ .execute(
172
+ sa.text(
173
+ "SELECT x.indisvalid, EXISTS (SELECT 1 FROM pg_inherits i "
174
+ "WHERE i.inhrelid = c.oid) FROM pg_class c JOIN pg_index x "
175
+ "ON x.indexrelid = c.oid WHERE c.relname = :name "
176
+ "AND c.relnamespace = (SELECT relnamespace FROM pg_class "
177
+ "WHERE oid = CAST(:parent AS regclass))"
178
+ ),
179
+ {"name": name, "parent": PARENT},
180
+ )
181
+ .first()
182
+ )
183
+ return None if row is None else (bool(row[0]), bool(row[1]))
184
+
185
+
186
+ def _build(index: GinIndex) -> None:
187
+ """Build the GIN index on every partition without blocking writes."""
188
+ op.execute(
189
+ f"CREATE INDEX IF NOT EXISTS {index.name} ON ONLY {PARENT} USING gin ({index.columns})"
190
+ )
191
+ with op.get_context().autocommit_block():
192
+ for partition in _partitions():
193
+ name = f"{partition}_{index.suffix}"
194
+ state = _state(name)
195
+ if state is not None and not state[0]:
196
+ op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
197
+ state = None
198
+ if state is None:
199
+ op.execute(
200
+ f"CREATE INDEX CONCURRENTLY {name} ON {partition} USING gin ({index.columns})"
201
+ )
202
+ if state is None or not state[1]:
203
+ op.execute(f"ALTER INDEX {index.name} ATTACH PARTITION {name}")
204
+ if _state(index.name) != (True, False):
205
+ raise RuntimeError(f"index {index.name} is not valid after attaching every partition")
206
+
207
+
208
+ def _swap(old: GinIndex, new: GinIndex) -> None:
209
+ """Build `new`, then drop `old` once `new` is valid."""
210
+ _build(new)
211
+ op.execute(f"DROP INDEX IF EXISTS {old.name}")
212
+
213
+
214
+ def upgrade() -> None:
215
+ role = _reads._application_role()
216
+ if role is not None:
217
+ _checks._refuse_writable_schemas(role)
218
+ op.execute("CREATE EXTENSION IF NOT EXISTS btree_gin")
219
+ _helper(OLD_HELPER, LB_OVERLAY_ROWS, HELPER)
220
+ _overlay_search(_SEARCH_SOURCES, _SEARCH_RESOLVE)
221
+ for old, new in SWAPS:
222
+ _swap(old, new)
223
+
224
+
225
+ def downgrade() -> None:
226
+ for old, new in reversed(SWAPS):
227
+ _swap(new, old)
228
+ _helper(HELPER, _overlays.LB_OVERLAY_ROWS, OLD_HELPER)
229
+ _overlay_search(_overlays._SOURCES.strip(), _overlays._RESOLVE)
@@ -0,0 +1,185 @@
1
+ """One lookup per hop in the overlay neighborhood (store schema notes).
2
+
3
+ `lb_neighborhood_overlay` (revision 0010) looked Links and Records up by key through the
4
+ resolved rows of the overlay, `UNION ALL` of its rows and its base's: two index scans per
5
+ lookup, a primary-key probe of the overlay per base row found, and a larger statement to
6
+ plan for every hop and side. This revision adds `lb_overlay_lookup(domain, branch, table,
7
+ where)`, which renders a lookup of the resolved rows matching `where` as one index scan over
8
+ both branches (`branch IN (overlay, base)`, base rows up to the fork generation while the ref
9
+ is still that overlay), where the overlay's row of an identity, tombstone included, wins over
10
+ the base's (`DISTINCT ON`, overlay first); `lorebase_store.layout.BranchLayout.records_lookup`
11
+ renders the same text. The function's stored Link hops, foreign-key hops out of a Record and
12
+ final hydration use it; inbound foreign-key hops still read both halves through their
13
+ per-Attribute indexes. Signature, checks, owner and grants are those of revision 0010.
14
+
15
+ `where` is SQL text: only the read functions call the helper, with conditions they build
16
+ from constants; EXECUTE is revoked from PUBLIC.
17
+
18
+ Revision ID: 0012
19
+ Revises: 0011
20
+ Create Date: 2026-10-21 21:00:00.000000
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.util
26
+ from collections.abc import Sequence
27
+ from pathlib import Path
28
+ from types import ModuleType
29
+
30
+ import sqlalchemy as sa
31
+ from alembic import op
32
+
33
+ revision: str = "0012"
34
+ down_revision: str | Sequence[str] | None = "0011"
35
+ branch_labels: str | Sequence[str] | None = None
36
+ depends_on: str | Sequence[str] | None = None
37
+
38
+
39
+ def _revision_module(filename: str) -> ModuleType:
40
+ """An earlier revision file of this directory, loaded for its SQL templates and checks."""
41
+ path = Path(__file__).with_name(filename)
42
+ spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
43
+ if spec is None or spec.loader is None:
44
+ raise RuntimeError(f"cannot load migration {path}")
45
+ module = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(module)
47
+ return module
48
+
49
+
50
+ _overlays = _revision_module("0010_overlay_branches.py")
51
+ _checks = _overlays._checks
52
+ _reads = _overlays._reads
53
+
54
+ HELPER = "lb_overlay_lookup(text, text, text, text)"
55
+ NEIGHBORHOOD = _overlays.SIGNATURES[2]
56
+
57
+ LB_OVERLAY_LOOKUP = f"""
58
+ CREATE FUNCTION lb_overlay_lookup(p_domain text, p_branch text, p_table text, p_where text)
59
+ RETURNS text
60
+ LANGUAGE plpgsql STABLE
61
+ SET search_path = {{search_path}}
62
+ AS $fn$
63
+ DECLARE
64
+ v_columns text[];
65
+ v_key text[];
66
+ v_base text;
67
+ v_generation bigint;
68
+ v_select text;
69
+ v_identity text;
70
+ BEGIN
71
+ IF p_table = 'mat_records' THEN
72
+ v_columns := {_overlays._array(_overlays._RECORD_COLUMNS)};
73
+ v_key := ARRAY['kind_id', 'key'];
74
+ ELSIF p_table = 'mat_links' THEN
75
+ v_columns := {_overlays._array(_overlays._LINK_COLUMNS)};
76
+ v_key := ARRAY['relation_id', 'from_key', 'to_key'];
77
+ ELSE
78
+ RAISE EXCEPTION 'unknown materialized table %', p_table USING ERRCODE = '22023';
79
+ END IF;
80
+ SELECT string_agg(
81
+ CASE WHEN c = 'branch' THEN format('CAST(%L AS text) AS branch', p_branch)
82
+ ELSE 'x.' || quote_ident(c) END, ', ' ORDER BY n)
83
+ INTO v_select FROM unnest(v_columns) WITH ORDINALITY AS t(c, n);
84
+ SELECT r.base_branch, r.base_generation INTO v_base, v_generation
85
+ FROM refs r
86
+ WHERE r.domain_id = p_domain AND r.name = p_branch AND r.materialization = 'overlay';
87
+ IF v_base IS NULL THEN
88
+ -- Not (or no longer) an overlay: its own rows.
89
+ RETURN format('(SELECT %s FROM %I x WHERE x.domain_id = %L AND x.branch = %L AND %s)',
90
+ v_select, p_table, p_domain, p_branch, p_where);
91
+ END IF;
92
+ SELECT string_agg('x.' || quote_ident(c), ', ' ORDER BY n) INTO v_identity
93
+ FROM unnest(v_key) WITH ORDINALITY AS t(c, n);
94
+ -- The rows of lorebase_store.layout.BranchLayout.records_lookup: the overlay's and the
95
+ -- base's (up to the fork, while the ref is still that overlay) in one scan, the
96
+ -- overlay's row of an identity first, tombstones dropped last.
97
+ RETURN format(
98
+ '(SELECT %1$s FROM (SELECT DISTINCT ON (%2$s) * FROM %3$I x '
99
+ 'WHERE x.domain_id = %4$L AND x.branch IN (%5$L, %6$L) '
100
+ 'AND (x.branch = %5$L OR (x.revision_generation <= %7$s '
101
+ 'AND EXISTS (SELECT 1 FROM refs f WHERE f.domain_id = %4$L AND f.name = %5$L '
102
+ 'AND f.base_branch = %6$L AND f.base_generation = %7$s OFFSET 0))) AND %8$s '
103
+ 'ORDER BY %2$s, x.branch = %5$L DESC) x WHERE NOT x.deleted)',
104
+ v_select, v_identity, p_table, p_domain, p_branch, v_base, v_generation, p_where);
105
+ END
106
+ $fn$
107
+ """
108
+
109
+
110
+ def _replaced(text: str, old: str, new: str) -> str:
111
+ if text.count(old) != 1:
112
+ raise RuntimeError(f"revision 0010 changed: {old!r} not found once")
113
+ return text.replace(old, new)
114
+
115
+
116
+ _LINK_JOIN = """ v_join := format(
117
+ 'CROSS JOIN LATERAL (SELECT * FROM %s l WHERE l.domain_id = %L AND l.branch = %L '
118
+ 'AND l.relation_id = r.relation_id AND l.@near@ = w.key OFFSET 0) AS l',
119
+ v_links, p_domain, p_branch);"""
120
+ _LINK_LOOKUP = """ v_join := 'CROSS JOIN LATERAL ' || lb_overlay_lookup(
121
+ p_domain, p_branch, 'mat_links', 'x.relation_id = r.relation_id AND x.@near@ = w.key')
122
+ || ' AS l';"""
123
+ _RECORD_JOIN = """'CAST(NULL AS bytea) FROM fk_rels f JOIN %s m ON m.domain_id = %L '
124
+ 'AND m.branch = %L AND m.kind_id = w.kind_id AND m.key = w.key '
125
+ 'WHERE f.from_kind = w.kind_id AND m."values" ->> f.attribute IS NOT NULL '
126
+ 'LIMIT $3', v_records, p_domain, p_branch);"""
127
+ _RECORD_LOOKUP = """'CAST(NULL AS bytea) FROM fk_rels f CROSS JOIN LATERAL %s m '
128
+ 'WHERE f.from_kind = w.kind_id AND m."values" ->> f.attribute IS NOT NULL '
129
+ 'LIMIT $3', lb_overlay_lookup(p_domain, p_branch, 'mat_records',
130
+ 'x.kind_id = w.kind_id AND x.key = w.key'));"""
131
+ _HYDRATE_JOIN = """ CROSS JOIN LATERAL (
132
+ SELECT * FROM %5$s m WHERE m.domain_id = %3$L AND m.branch = %4$L
133
+ AND m.kind_id = n.kind_id AND m.key = n.key LIMIT 1) AS m"""
134
+ _HYDRATE_LOOKUP = " CROSS JOIN LATERAL %6$s AS m"
135
+ _FORMAT_ARGUMENTS = "$sql$, v_nulls, v_step, p_domain, p_branch, v_records)"
136
+ _LOOKUP_ARGUMENTS = """$sql$, v_nulls, v_step, p_domain, p_branch, v_records,
137
+ lb_overlay_lookup(p_domain, p_branch, 'mat_records',
138
+ 'x.kind_id = n.kind_id AND x.key = n.key'))"""
139
+
140
+ LB_NEIGHBORHOOD_OVERLAY = _overlays.LB_NEIGHBORHOOD_OVERLAY
141
+ for _old, _new in (
142
+ (_LINK_JOIN, _LINK_LOOKUP),
143
+ (_RECORD_JOIN, _RECORD_LOOKUP),
144
+ (_HYDRATE_JOIN, _HYDRATE_LOOKUP),
145
+ (_FORMAT_ARGUMENTS, _LOOKUP_ARGUMENTS),
146
+ ):
147
+ LB_NEIGHBORHOOD_OVERLAY = _replaced(LB_NEIGHBORHOOD_OVERLAY, _old, _new)
148
+ """Revision 0010's function with the lookups; inbound foreign-key hops still read
149
+ `v_records`, the resolved rows."""
150
+
151
+ _SOURCES = "v_records text;"
152
+ _RESOLVE = """
153
+ v_records := coalesce(lb_overlay_rows(p_domain, p_branch, 'mat_records'), 'mat_records');
154
+ """
155
+ """Run after the checks of revision 0008, as in revision 0010; Links are read by lookup."""
156
+
157
+
158
+ def _neighborhood(template: str, sources: str, resolve: str) -> None:
159
+ search_path = _reads._search_path()
160
+ options = _reads._OPTIONS.format(search_path=search_path).strip()
161
+ guards = _checks._guards(search_path, _overlays.COUNTS[_overlays.LB_NEIGHBORHOOD_OVERLAY])
162
+ statement = _checks._create_or_replace(template, guards + resolve)
163
+ op.execute(
164
+ statement.format(options=options, record_columns=_reads._RECORD_COLUMNS, sources=sources)
165
+ )
166
+ # CREATE OR REPLACE keeps the owner and the grants; restate them all the same.
167
+ op.execute(f"REVOKE ALL ON FUNCTION {NEIGHBORHOOD} FROM PUBLIC")
168
+ role = _reads._application_role()
169
+ if role is not None:
170
+ quoted = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar()
171
+ op.execute(f"GRANT EXECUTE ON FUNCTION {NEIGHBORHOOD} TO {quoted}")
172
+
173
+
174
+ def upgrade() -> None:
175
+ role = _reads._application_role()
176
+ if role is not None:
177
+ _checks._refuse_writable_schemas(role)
178
+ op.execute(LB_OVERLAY_LOOKUP.format(search_path=_reads._search_path()))
179
+ op.execute(f"REVOKE ALL ON FUNCTION {HELPER} FROM PUBLIC")
180
+ _neighborhood(LB_NEIGHBORHOOD_OVERLAY, _SOURCES, _RESOLVE)
181
+
182
+
183
+ def downgrade() -> None:
184
+ _neighborhood(_overlays.LB_NEIGHBORHOOD_OVERLAY, _overlays._SOURCES.strip(), _overlays._RESOLVE)
185
+ op.execute(f"DROP FUNCTION IF EXISTS {HELPER}")
@@ -0,0 +1,72 @@
1
+ """Browser sessions of the web app, Keeper acting as backend-for-frontend (design 13.2).
2
+
3
+ One row per signed-in session. The cookie carries an opaque id; the row is found by the
4
+ SHA-256 of that id, which is never stored itself. `tokens` holds the provider's refresh and
5
+ ID tokens, encrypted by Keeper with a key from its configuration. The table is
6
+ installation-wide like `api_keys` (no `domain_id`, no policy); rows go with their principal.
7
+ Created by the owner role like every table: the application role receives row rights through
8
+ the owner's default privileges (deployment docs).
9
+
10
+ Revision ID: 0013
11
+ Revises: 0012
12
+ Create Date: 2026-10-22 09:00:00.000000
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Sequence
18
+
19
+ import sqlalchemy as sa
20
+ from alembic import op
21
+ from sqlalchemy.dialects import postgresql
22
+
23
+ revision: str = "0013"
24
+ down_revision: str | Sequence[str] | None = "0012"
25
+ branch_labels: str | Sequence[str] | None = None
26
+ depends_on: str | Sequence[str] | None = None
27
+
28
+
29
+ def upgrade() -> None:
30
+ op.create_table(
31
+ "web_sessions",
32
+ sa.Column("id_hash", sa.LargeBinary(), nullable=False),
33
+ sa.Column("principal_id", sa.Text(), nullable=False),
34
+ sa.Column("subject", sa.Text(), nullable=False),
35
+ sa.Column(
36
+ "external_groups",
37
+ postgresql.JSONB(astext_type=sa.Text()),
38
+ server_default=sa.text("'[]'::jsonb"),
39
+ nullable=False,
40
+ ),
41
+ sa.Column("tokens", sa.LargeBinary(), nullable=True),
42
+ sa.Column(
43
+ "created_at",
44
+ sa.DateTime(timezone=True),
45
+ server_default=sa.text("now()"),
46
+ nullable=False,
47
+ ),
48
+ sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
49
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
50
+ sa.Column("refresh_at", sa.DateTime(timezone=True), nullable=True),
51
+ sa.CheckConstraint("octet_length(id_hash) = 32", name=op.f("ck_web_sessions_id_hash_h32")),
52
+ sa.ForeignKeyConstraint(
53
+ ["principal_id"],
54
+ ["principals.id"],
55
+ name=op.f("fk_web_sessions_principal_id_principals"),
56
+ ondelete="CASCADE",
57
+ ),
58
+ sa.PrimaryKeyConstraint("id_hash", name=op.f("pk_web_sessions")),
59
+ )
60
+ op.create_index(
61
+ op.f("ix_web_sessions_principal_id"), "web_sessions", ["principal_id"], unique=False
62
+ )
63
+ op.create_index(
64
+ op.f("ix_web_sessions_last_seen_at"), "web_sessions", ["last_seen_at"], unique=False
65
+ )
66
+ op.create_index(
67
+ op.f("ix_web_sessions_expires_at"), "web_sessions", ["expires_at"], unique=False
68
+ )
69
+
70
+
71
+ def downgrade() -> None:
72
+ op.drop_table("web_sessions")
@@ -0,0 +1,70 @@
1
+ """Embedding jobs (design 9.3): text hashes of vectors and per-branch progress.
2
+
3
+ - `embeddings.text_hash`: SHA-256 of the text a vector was computed from. A Record whose text
4
+ did not change keeps its vector, and an object whose text equals another's reuses that
5
+ vector without a model call (index on `(domain_id, model, text_hash)`). Null for vectors
6
+ written before this revision.
7
+ - `embedding_cursors`: per branch and model, the head and the template fingerprints of the
8
+ last complete pass of the embedding job. Domain-scoped with row-level security like the
9
+ tables of 0001; rows go with their branch.
10
+
11
+ Revision ID: 0014
12
+ Revises: 0013
13
+ Create Date: 2026-10-22 18:00:00.000000
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Sequence
19
+
20
+ import sqlalchemy as sa
21
+ from alembic import op
22
+ from sqlalchemy.dialects import postgresql
23
+
24
+ revision: str = "0014"
25
+ down_revision: str | Sequence[str] | None = "0013"
26
+ branch_labels: str | Sequence[str] | None = None
27
+ depends_on: str | Sequence[str] | None = None
28
+
29
+ DOMAIN_VISIBLE = (
30
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
31
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
32
+ )
33
+
34
+
35
+ def upgrade() -> None:
36
+ op.add_column("embeddings", sa.Column("text_hash", sa.LargeBinary(), nullable=True))
37
+ op.create_index(
38
+ op.f("ix_embeddings_domain_id_model_text_hash"),
39
+ "embeddings",
40
+ ["domain_id", "model", "text_hash"],
41
+ unique=False,
42
+ )
43
+ op.create_table(
44
+ "embedding_cursors",
45
+ sa.Column("domain_id", sa.Text(), nullable=False),
46
+ sa.Column("branch", sa.Text(), nullable=False),
47
+ sa.Column("model", sa.Text(), nullable=False),
48
+ sa.Column("head", sa.LargeBinary(), nullable=False),
49
+ sa.Column("fingerprints", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
50
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
51
+ sa.ForeignKeyConstraint(
52
+ ["domain_id", "branch"],
53
+ ["refs.domain_id", "refs.name"],
54
+ name=op.f("fk_embedding_cursors_domain_id_branch_refs"),
55
+ ondelete="CASCADE",
56
+ ),
57
+ sa.PrimaryKeyConstraint("domain_id", "branch", "model", name=op.f("pk_embedding_cursors")),
58
+ )
59
+ op.execute("ALTER TABLE embedding_cursors ENABLE ROW LEVEL SECURITY")
60
+ op.execute("ALTER TABLE embedding_cursors FORCE ROW LEVEL SECURITY")
61
+ op.execute(
62
+ "CREATE POLICY domain_isolation ON embedding_cursors "
63
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
64
+ )
65
+
66
+
67
+ def downgrade() -> None:
68
+ op.drop_table("embedding_cursors")
69
+ op.drop_index(op.f("ix_embeddings_domain_id_model_text_hash"), table_name="embeddings")
70
+ op.drop_column("embeddings", "text_hash")
@@ -0,0 +1,83 @@
1
+ """The audit log is append-only for every role but its owner.
2
+
3
+ The application role received `SELECT, INSERT, UPDATE, DELETE` on `audit_log` like on every
4
+ table (default privileges of the owner, deployment docs), though no code updates or deletes
5
+ audit entries: the hash chains are only extended. With those rights a compromised service
6
+ could rewrite or drop the newest entries of a chain without the verification noticing.
7
+
8
+ `UPDATE`, `DELETE` and `TRUNCATE` (and `REFERENCES`, `TRIGGER`, which nothing needs) are
9
+ revoked on `audit_log` from `PUBLIC`, from `database.application_role` when set and from
10
+ every other role holding them except the table owner, which runs the migrations and the
11
+ maintenance jobs. `SELECT` and `INSERT` stay. Nothing in Lorebase prunes the audit log; an
12
+ operator who needs to (retention, erasure) does it as the owner.
13
+
14
+ Revision ID: 0015
15
+ Revises: 0014
16
+ Create Date: 2026-10-23 09:00:00.000000
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Sequence
22
+
23
+ import sqlalchemy as sa
24
+ from alembic import op
25
+
26
+ revision: str = "0015"
27
+ down_revision: str | Sequence[str] | None = "0014"
28
+ branch_labels: str | Sequence[str] | None = None
29
+ depends_on: str | Sequence[str] | None = None
30
+
31
+ TABLE = "audit_log"
32
+ REVOKED = "UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER"
33
+ RESTORED = "UPDATE, DELETE"
34
+ """What the downgrade gives back to the application role: its rights before this revision."""
35
+
36
+
37
+ def _application_role() -> str | None:
38
+ config = op.get_context().config
39
+ if config is None or "database_settings" not in config.attributes:
40
+ raise RuntimeError("run this migration through the Lorebase Alembic environment")
41
+ role: str | None = config.attributes["database_settings"].application_role
42
+ return role
43
+
44
+
45
+ def _quoted(role: str) -> str:
46
+ quoted: str = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar_one()
47
+ return quoted
48
+
49
+
50
+ def _holders() -> list[str]:
51
+ """Roles other than the owner holding a revoked privilege on the table."""
52
+ return list(
53
+ op.get_bind()
54
+ .execute(
55
+ sa.text(
56
+ "SELECT DISTINCT quote_ident(r.rolname) FROM pg_class c "
57
+ "CROSS JOIN LATERAL aclexplode(c.relacl) a "
58
+ "JOIN pg_roles r ON r.oid = a.grantee "
59
+ "WHERE c.oid = to_regclass(:t) AND a.grantee <> c.relowner "
60
+ "AND a.privilege_type IN "
61
+ "('UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER') ORDER BY 1"
62
+ ),
63
+ {"t": TABLE},
64
+ )
65
+ .scalars()
66
+ .all()
67
+ )
68
+
69
+
70
+ def upgrade() -> None:
71
+ op.execute(f"REVOKE {REVOKED} ON TABLE {TABLE} FROM PUBLIC")
72
+ roles = set(_holders())
73
+ role = _application_role()
74
+ if role is not None:
75
+ roles.add(_quoted(role))
76
+ for quoted in sorted(roles):
77
+ op.execute(f"REVOKE {REVOKED} ON TABLE {TABLE} FROM {quoted}")
78
+
79
+
80
+ def downgrade() -> None:
81
+ role = _application_role()
82
+ if role is not None:
83
+ op.execute(f"GRANT {RESTORED} ON TABLE {TABLE} TO {_quoted(role)}")