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,582 @@
1
+ """Overlay branches (design 6.11, AC-16; store schema notes).
2
+
3
+ An overlay branch stores only the materialized rows it changed and reads the rows of its base
4
+ branch as they were when it was forked:
5
+
6
+ - `refs.base_branch`, `refs.base_revision` and `refs.base_generation` name the base and the
7
+ fork point; they are set exactly when `materialization = 'overlay'`. Both references are
8
+ checked at commit time, like `refs.head` (revision `0004`), so deleting a Domain cascades in
9
+ any order; a base branch cannot be deleted while an overlay depends on it.
10
+ - `mat_records.revision_generation` and `mat_links.revision_generation` hold the generation of
11
+ the Revision that last wrote the row (`0` for rows written before this revision, when no
12
+ overlay existed); `deleted` marks the tombstones of rows an overlay removed.
13
+ - `lb_overlay_rows(domain, branch, table)` renders the resolved rows of an overlay as SQL
14
+ text (`lorebase_store.layout` renders the same text in Python), and the read functions of
15
+ revision `0005` get overlay variants (`lb_search_overlay`, `lb_links_in_overlay`,
16
+ `lb_neighborhood_overlay`, `lb_query_by_attribute_overlay`) that read through it. They
17
+ have the signatures, options and grants of the originals and the argument, scope and
18
+ search_path checks revision `0008` added to them (counts bounded by
19
+ `store.reads.definer_ceilings` as read when this revision runs); on a branch that is not (or
20
+ no longer) an overlay they read its own rows, so a caller that resolved the layout before a
21
+ promotion still gets correct rows. The originals stay unchanged for full branches.
22
+
23
+ Like revision `0008`, the upgrade refuses to run while `database.application_role` may create
24
+ objects in the schema of the tables or of pg_trgm.
25
+
26
+ Revision ID: 0010
27
+ Revises: 0009
28
+ Create Date: 2026-10-21 18:00:00.000000
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import importlib.util
34
+ from collections.abc import Sequence
35
+ from pathlib import Path
36
+ from types import ModuleType
37
+
38
+ import sqlalchemy as sa
39
+ from alembic import op
40
+
41
+ revision: str = "0010"
42
+ down_revision: str | Sequence[str] | None = "0009"
43
+ branch_labels: str | Sequence[str] | None = None
44
+ depends_on: str | Sequence[str] | None = None
45
+
46
+
47
+ def _revision_module(filename: str) -> ModuleType:
48
+ """An earlier revision file of this directory, loaded for its SQL templates and checks."""
49
+ path = Path(__file__).with_name(filename)
50
+ spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
51
+ if spec is None or spec.loader is None:
52
+ raise RuntimeError(f"cannot load migration {path}")
53
+ module = importlib.util.module_from_spec(spec)
54
+ spec.loader.exec_module(module)
55
+ return module
56
+
57
+
58
+ _checks = _revision_module("0008_definer_argument_checks.py")
59
+ _reads = _checks._reads
60
+
61
+ HELPER = "lb_overlay_rows(text, text, text, text)"
62
+ SIGNATURES = (
63
+ "lb_search_overlay(text, text, text[], regconfig, text, integer, integer, real, real)",
64
+ "lb_links_in_overlay(text, text, text, text, integer, text, text, text)",
65
+ "lb_neighborhood_overlay(text, text, jsonb, integer, jsonb, integer, integer, integer)",
66
+ "lb_query_by_attribute_overlay(text, text, text, text, text, text, text, integer, text, text)",
67
+ )
68
+
69
+ _RECORD_COLUMNS = (
70
+ "domain_id",
71
+ "branch",
72
+ "kind_id",
73
+ "key",
74
+ "values",
75
+ "derived",
76
+ "title",
77
+ "search",
78
+ "object_hash",
79
+ "lineage",
80
+ "updated_revision",
81
+ "updated_at",
82
+ "revision_generation",
83
+ "deleted",
84
+ )
85
+ _LINK_COLUMNS = (
86
+ "domain_id",
87
+ "branch",
88
+ "relation_id",
89
+ "from_key",
90
+ "to_key",
91
+ "values",
92
+ "object_hash",
93
+ "lineage",
94
+ "revision_generation",
95
+ "deleted",
96
+ )
97
+
98
+
99
+ def _array(columns: Sequence[str]) -> str:
100
+ return "ARRAY[" + ", ".join(f"'{c}'" for c in columns) + "]"
101
+
102
+
103
+ LB_OVERLAY_ROWS = f"""
104
+ CREATE FUNCTION lb_overlay_rows(
105
+ p_domain text, p_branch text, p_table text, p_tail text DEFAULT NULL)
106
+ RETURNS text
107
+ LANGUAGE plpgsql STABLE
108
+ SET search_path = {{search_path}}
109
+ AS $fn$
110
+ DECLARE
111
+ v_columns text[];
112
+ v_key text[];
113
+ v_base text;
114
+ v_generation bigint;
115
+ v_o text := CASE WHEN p_tail IS NULL THEN 'o' ELSE 'm' END;
116
+ v_s text := CASE WHEN p_tail IS NULL THEN 's' ELSE 'm' END;
117
+ v_own text;
118
+ v_from_base text;
119
+ v_shadow text;
120
+ BEGIN
121
+ IF p_table = 'mat_records' THEN
122
+ v_columns := {_array(_RECORD_COLUMNS)};
123
+ v_key := ARRAY['kind_id', 'key'];
124
+ ELSIF p_table = 'mat_links' THEN
125
+ v_columns := {_array(_LINK_COLUMNS)};
126
+ v_key := ARRAY['relation_id', 'from_key', 'to_key'];
127
+ ELSE
128
+ RAISE EXCEPTION 'unknown materialized table %', p_table USING ERRCODE = '22023';
129
+ END IF;
130
+ SELECT r.base_branch, r.base_generation INTO v_base, v_generation
131
+ FROM refs r
132
+ WHERE r.domain_id = p_domain AND r.name = p_branch AND r.materialization = 'overlay';
133
+ IF v_base IS NULL THEN
134
+ RETURN NULL;
135
+ END IF;
136
+ SELECT string_agg(v_o || '.' || quote_ident(c), ', ' ORDER BY n) INTO v_own
137
+ FROM unnest(v_columns) WITH ORDINALITY AS t(c, n);
138
+ SELECT string_agg(
139
+ CASE WHEN c = 'branch' THEN format('CAST(%L AS text)', p_branch)
140
+ ELSE v_s || '.' || quote_ident(c) END, ', ' ORDER BY n)
141
+ INTO v_from_base FROM unnest(v_columns) WITH ORDINALITY AS t(c, n);
142
+ SELECT string_agg(format('x.%1$I = %2$s.%1$I', c, v_s), ' AND ' ORDER BY n)
143
+ INTO v_shadow FROM unnest(v_key) WITH ORDINALITY AS t(c, n);
144
+ -- The same rows as lorebase_store.layout.BranchLayout: overlay rows that are not
145
+ -- tombstones, then base rows written at or before the fork that the overlay does not
146
+ -- shadow, while the ref is still that overlay. With `p_tail` (conditions on alias m,
147
+ -- ORDER BY, LIMIT; built by the read functions only) each half ends with it, as
148
+ -- BranchLayout.records_arms does.
149
+ v_own := format(
150
+ 'SELECT %1$s FROM %2$I %5$s WHERE %5$s.domain_id = %3$L AND %5$s.branch = %4$L '
151
+ 'AND NOT %5$s.deleted', v_own, p_table, p_domain, p_branch, v_o);
152
+ v_from_base := format(
153
+ 'SELECT %1$s FROM %2$I %7$s WHERE %7$s.domain_id = %3$L AND %7$s.branch = %4$L '
154
+ 'AND %7$s.revision_generation <= %5$s AND EXISTS (SELECT 1 FROM refs f '
155
+ 'WHERE f.domain_id = %3$L AND f.name = %6$L AND f.base_branch = %4$L '
156
+ 'AND f.base_generation = %5$s OFFSET 0) AND NOT EXISTS (SELECT 1 FROM %2$I x '
157
+ 'WHERE x.domain_id = %3$L AND x.branch = %6$L AND %8$s OFFSET 0)',
158
+ v_from_base, p_table, p_domain, v_base, v_generation, p_branch, v_s, v_shadow);
159
+ IF p_tail IS NULL THEN
160
+ RETURN '(' || v_own || ' UNION ALL ' || v_from_base || ')';
161
+ END IF;
162
+ RETURN '((' || v_own || p_tail || ') UNION ALL (' || v_from_base || p_tail || '))';
163
+ END
164
+ $fn$
165
+ """
166
+
167
+ _SOURCES = """
168
+ v_records text;
169
+ v_links text;
170
+ """
171
+
172
+ _RESOLVE = """
173
+ v_records := coalesce(lb_overlay_rows(p_domain, p_branch, 'mat_records'), 'mat_records');
174
+ v_links := coalesce(lb_overlay_rows(p_domain, p_branch, 'mat_links'), 'mat_links');
175
+ """
176
+ """Run after the checks of revision 0008: the branch's rows are resolved only for a Domain in
177
+ the transaction scope, through the checked search_path."""
178
+
179
+ LB_SEARCH_OVERLAY = """
180
+ CREATE FUNCTION lb_search_overlay(
181
+ p_domain text, p_branch text, p_kinds text[], p_config regconfig, p_query text,
182
+ p_candidates integer, p_limit integer,
183
+ p_trigram_threshold real DEFAULT NULL, p_trigram_weight real DEFAULT 0)
184
+ RETURNS TABLE (kind_id text, key text, title text, score real)
185
+ {options}
186
+ SET pg_trgm.similarity_threshold = '0.3'
187
+ AS $fn$
188
+ DECLARE
189
+ v_match text := 'm.search @@ q.ts';
190
+ v_score text := 'ts_rank_cd(c.search, q.ts)';
191
+ v_kinds text := '';
192
+ {sources}
193
+ BEGIN
194
+ IF p_candidates < 1 OR p_limit < 1 THEN
195
+ RAISE EXCEPTION 'candidates and limit must be positive' USING ERRCODE = '22023';
196
+ END IF;
197
+ IF p_kinds IS NOT NULL THEN
198
+ v_kinds := ' AND m.kind_id = ANY ($3)';
199
+ END IF;
200
+ IF p_trigram_threshold IS NOT NULL THEN
201
+ PERFORM set_config('pg_trgm.similarity_threshold', p_trigram_threshold::text, true);
202
+ v_match := '(m.search @@ q.ts OR m.title % $5)';
203
+ v_score := v_score || ' + $9 * similarity(coalesce(c.title, ''''), $5)';
204
+ END IF;
205
+ RETURN QUERY EXECUTE format(
206
+ 'SELECT c.kind_id, c.key, c.title, CAST(%s AS real) AS score '
207
+ 'FROM (SELECT m.kind_id, m.key, m.title, m.search '
208
+ ' FROM %s m, (SELECT websearch_to_tsquery($4, $5) AS ts) q '
209
+ ' WHERE m.domain_id = %L AND m.branch = %L%s AND %s LIMIT $6) AS c, '
210
+ ' (SELECT websearch_to_tsquery($4, $5) AS ts) q '
211
+ 'ORDER BY score DESC, c.kind_id, c.key LIMIT $7',
212
+ v_score, v_records, p_domain, p_branch, v_kinds, v_match)
213
+ USING p_domain, p_branch, p_kinds, p_config, p_query, p_candidates, p_limit,
214
+ p_trigram_threshold, p_trigram_weight;
215
+ END
216
+ $fn$
217
+ """
218
+
219
+ LB_LINKS_IN_OVERLAY = """
220
+ CREATE FUNCTION lb_links_in_overlay(
221
+ p_domain text, p_branch text, p_relation text, p_to_key text, p_limit integer,
222
+ p_after text DEFAULT NULL, p_from_kind text DEFAULT NULL, p_attribute text DEFAULT NULL)
223
+ RETURNS TABLE (relation_id text, from_key text, to_key text, "values" jsonb,
224
+ object_hash bytea)
225
+ {options}
226
+ AS $fn$
227
+ DECLARE
228
+ v_after text := '';
229
+ {sources}
230
+ BEGIN
231
+ IF p_limit < 1 THEN
232
+ RAISE EXCEPTION 'limit must be positive' USING ERRCODE = '22023';
233
+ END IF;
234
+ IF (p_from_kind IS NULL) <> (p_attribute IS NULL) THEN
235
+ RAISE EXCEPTION 'a foreign-key Relation needs both its from Kind and its Attribute'
236
+ USING ERRCODE = '22023';
237
+ END IF;
238
+ IF p_attribute IS NULL THEN
239
+ IF p_after IS NOT NULL THEN
240
+ v_after := ' AND l.from_key > $5';
241
+ END IF;
242
+ RETURN QUERY EXECUTE format(
243
+ 'SELECT l.relation_id, l.from_key, l.to_key, l."values", l.object_hash '
244
+ 'FROM %s l WHERE l.domain_id = %L AND l.branch = %L '
245
+ 'AND l.relation_id = $3 AND l.to_key = $4%s ORDER BY l.from_key LIMIT $6',
246
+ v_links, p_domain, p_branch, v_after)
247
+ USING p_domain, p_branch, p_relation, p_to_key, p_after, p_limit;
248
+ ELSE
249
+ IF p_after IS NOT NULL THEN
250
+ v_after := ' AND m.key > $5';
251
+ END IF;
252
+ RETURN QUERY EXECUTE format(
253
+ 'SELECT CAST(%L AS text), m.key, CAST($4 AS text), CAST(''{{}}'' AS jsonb), '
254
+ 'CAST(NULL AS bytea) FROM %s m '
255
+ 'WHERE m.domain_id = %L AND m.branch = %L AND m.kind_id = %L '
256
+ 'AND (m."values" ->> %L) = $4%s ORDER BY m.key LIMIT $6',
257
+ p_relation, v_records, p_domain, p_branch, p_from_kind, p_attribute, v_after)
258
+ USING p_domain, p_branch, p_relation, p_to_key, p_after, p_limit;
259
+ END IF;
260
+ END
261
+ $fn$
262
+ """
263
+
264
+ LB_QUERY_BY_ATTRIBUTE_OVERLAY = """
265
+ CREATE FUNCTION lb_query_by_attribute_overlay(
266
+ p_domain text, p_branch text, p_kind text, p_attribute text, p_type text, p_op text,
267
+ p_value text, p_limit integer, p_after_value text DEFAULT NULL,
268
+ p_after_key text DEFAULT NULL)
269
+ RETURNS TABLE ({record_columns})
270
+ {options}
271
+ AS $fn$
272
+ DECLARE
273
+ v_raw text := format('(m."values" ->> %L)', p_attribute);
274
+ v_expression text;
275
+ v_value text;
276
+ v_op text;
277
+ v_sql text;
278
+ v_tail text;
279
+ v_order text;
280
+ v_rows text;
281
+ BEGIN
282
+ IF p_limit < 1 THEN
283
+ RAISE EXCEPTION 'limit must be positive' USING ERRCODE = '22023';
284
+ END IF;
285
+ v_op := CASE p_op WHEN 'eq' THEN '=' WHEN 'lt' THEN '<' WHEN 'lte' THEN '<='
286
+ WHEN 'gt' THEN '>' WHEN 'gte' THEN '>=' END;
287
+ IF v_op IS NULL THEN
288
+ RAISE EXCEPTION 'unknown comparison %', p_op USING ERRCODE = '22023';
289
+ END IF;
290
+ IF p_type = 'text' THEN
291
+ v_expression := v_raw;
292
+ v_value := 'CAST(%s AS text)';
293
+ ELSIF p_type = 'md5' THEN
294
+ v_expression := 'md5' || v_raw;
295
+ v_value := 'md5(CAST(%s AS text))';
296
+ ELSIF p_type IN ('bigint', 'numeric', 'double precision', 'boolean') THEN
297
+ v_expression := format('(%s::%s)', v_raw, p_type);
298
+ v_value := 'CAST(CAST(%s AS text) AS ' || p_type || ')';
299
+ ELSE
300
+ RAISE EXCEPTION 'unknown index type %', p_type USING ERRCODE = '22023';
301
+ END IF;
302
+ -- Conditions, order and limit, applied inside each half of an overlay's rows (each
303
+ -- half reads at most one page, the base half through the base's partial index).
304
+ v_tail := format(' AND m.kind_id = %L AND %s %s %s', p_kind, v_expression, v_op,
305
+ format(v_value, '$1'));
306
+ IF p_op = 'eq' THEN
307
+ IF p_after_key IS NOT NULL THEN
308
+ v_tail := v_tail || ' AND m.key > $3';
309
+ END IF;
310
+ v_order := 'm.key';
311
+ ELSE
312
+ IF p_after_key IS NOT NULL THEN
313
+ v_tail := v_tail || format(
314
+ ' AND (%s, m.key) > (%s, $3)', v_expression, format(v_value, '$2'));
315
+ END IF;
316
+ v_order := v_expression || ', m.key';
317
+ END IF;
318
+ v_tail := v_tail || ' ORDER BY ' || v_order || ' LIMIT $4';
319
+ v_rows := lb_overlay_rows(p_domain, p_branch, 'mat_records', v_tail);
320
+ IF v_rows IS NULL THEN
321
+ v_rows := format('(SELECT * FROM mat_records m WHERE m.domain_id = %L '
322
+ 'AND m.branch = %L%s) AS m', p_domain, p_branch, v_tail);
323
+ ELSE
324
+ v_rows := v_rows || ' AS m';
325
+ END IF;
326
+ v_sql := 'SELECT m.kind_id, m.key, m."values", m.title, m.object_hash, m.lineage, '
327
+ 'm.updated_revision, m.updated_at FROM ' || v_rows
328
+ || ' ORDER BY ' || v_order || ' LIMIT $4';
329
+ RETURN QUERY EXECUTE v_sql USING p_value, p_after_value, p_after_key, p_limit;
330
+ END
331
+ $fn$
332
+ """
333
+
334
+ LB_NEIGHBORHOOD_OVERLAY = """
335
+ CREATE FUNCTION lb_neighborhood_overlay(
336
+ p_domain text, p_branch text, p_seeds jsonb, p_depth integer, p_relations jsonb,
337
+ p_fanout integer, p_max_steps integer, p_max_nodes integer)
338
+ RETURNS TABLE (item text, depth integer, {record_columns}, relation_id text, from_key text,
339
+ to_key text, link_values jsonb, link_hash bytea)
340
+ {options}
341
+ AS $fn$
342
+ DECLARE
343
+ {sources}
344
+ v_parts text[] := ARRAY[]::text[];
345
+ v_relation jsonb;
346
+ v_step text;
347
+ v_join text;
348
+ v_nulls text := 'CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS text), '
349
+ 'CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS jsonb), '
350
+ 'CAST(NULL AS bytea)';
351
+ BEGIN
352
+ IF p_depth < 1 OR p_fanout < 1 OR p_max_steps < 1 OR p_max_nodes < 1 THEN
353
+ RAISE EXCEPTION 'depth and caps must be positive' USING ERRCODE = '22023';
354
+ END IF;
355
+ IF jsonb_typeof(p_seeds) <> 'array' OR jsonb_typeof(p_relations) <> 'object' THEN
356
+ RAISE EXCEPTION 'seeds must be an array and relations an object'
357
+ USING ERRCODE = '22023';
358
+ END IF;
359
+ -- Lateral, so that the Relation and the frontier key reach both halves of an overlay's
360
+ -- Links as index conditions (OFFSET 0 keeps it a subquery).
361
+ v_join := format(
362
+ 'CROSS JOIN LATERAL (SELECT * FROM %s l WHERE l.domain_id = %L AND l.branch = %L '
363
+ 'AND l.relation_id = r.relation_id AND l.@near@ = w.key OFFSET 0) AS l',
364
+ v_links, p_domain, p_branch);
365
+ IF jsonb_array_length(coalesce(p_relations -> 'out', '[]')) > 0 THEN
366
+ v_parts := v_parts || (
367
+ 'SELECT r.to_kind, l.to_key, l.relation_id, r.from_kind, l.from_key, r.to_kind, '
368
+ 'l.to_key, l."values", l.object_hash FROM out_rels r '
369
+ || replace(v_join, '@near@', 'from_key')
370
+ || ' WHERE r.from_kind = w.kind_id LIMIT $3');
371
+ END IF;
372
+ IF jsonb_array_length(coalesce(p_relations -> 'in', '[]')) > 0 THEN
373
+ v_parts := v_parts || (
374
+ 'SELECT r.from_kind, l.from_key, l.relation_id, r.from_kind, l.from_key, '
375
+ 'r.to_kind, l.to_key, l."values", l.object_hash FROM in_rels r '
376
+ || replace(v_join, '@near@', 'to_key')
377
+ || ' WHERE r.to_kind = w.kind_id LIMIT $3');
378
+ END IF;
379
+ IF jsonb_array_length(coalesce(p_relations -> 'fk_out', '[]')) > 0 THEN
380
+ v_parts := v_parts || format(
381
+ 'SELECT f.to_kind, m."values" ->> f.attribute, f.relation_id, f.from_kind, '
382
+ 'm.key, f.to_kind, m."values" ->> f.attribute, CAST(''{{}}'' AS jsonb), '
383
+ 'CAST(NULL AS bytea) FROM fk_rels f JOIN %s m ON m.domain_id = %L '
384
+ 'AND m.branch = %L AND m.kind_id = w.kind_id AND m.key = w.key '
385
+ 'WHERE f.from_kind = w.kind_id AND m."values" ->> f.attribute IS NOT NULL '
386
+ 'LIMIT $3', v_records, p_domain, p_branch);
387
+ END IF;
388
+ FOR v_relation IN
389
+ SELECT e FROM jsonb_array_elements(coalesce(p_relations -> 'fk_in', '[]')) AS e
390
+ LOOP
391
+ IF (v_relation ->> 0) IS NULL OR (v_relation ->> 1) IS NULL
392
+ OR (v_relation ->> 2) IS NULL OR (v_relation ->> 3) IS NULL THEN
393
+ RAISE EXCEPTION 'a foreign-key Relation is [id, from Kind, to Kind, Attribute]'
394
+ USING ERRCODE = '22023';
395
+ END IF;
396
+ v_parts := v_parts || format(
397
+ 'SELECT CAST(%1$L AS text), key, CAST(%2$L AS text), CAST(%1$L AS text), key, '
398
+ 'CAST(%3$L AS text), w.key, CAST(''{{}}'' AS jsonb), CAST(NULL AS bytea) '
399
+ 'FROM %7$s mat_records WHERE w.kind_id = %3$L AND domain_id = %4$L '
400
+ 'AND branch = %5$L AND kind_id = %1$L AND ("values" ->> %6$L) = w.key LIMIT $3',
401
+ v_relation ->> 1, v_relation ->> 0, v_relation ->> 2, p_domain, p_branch,
402
+ v_relation ->> 3, v_records);
403
+ END LOOP;
404
+ IF cardinality(v_parts) = 0 THEN
405
+ v_step := 'SELECT w.depth + 1, w.kind_id, w.key, w.kind_id, w.key, ' || v_nulls
406
+ || ' FROM walk w WHERE false';
407
+ ELSE
408
+ v_step := 'SELECT w.depth + 1, w.kind_id, w.key, n.* FROM walk w '
409
+ 'CROSS JOIN LATERAL (SELECT * FROM ('
410
+ || (SELECT string_agg('(' || p.part || ')', ' UNION ALL ' ORDER BY p.n)
411
+ FROM unnest(v_parts) WITH ORDINALITY AS p(part, n))
412
+ || ') AS hop LIMIT $3) AS n WHERE w.depth < $2';
413
+ END IF;
414
+ RETURN QUERY EXECUTE format($sql$
415
+ WITH RECURSIVE
416
+ out_rels(relation_id, from_kind, to_kind) AS (
417
+ SELECT e ->> 0, e ->> 1, e ->> 2 FROM jsonb_array_elements($4 -> 'out') AS e),
418
+ in_rels(relation_id, from_kind, to_kind) AS (
419
+ SELECT e ->> 0, e ->> 1, e ->> 2 FROM jsonb_array_elements($4 -> 'in') AS e),
420
+ fk_rels(relation_id, from_kind, to_kind, attribute) AS (
421
+ SELECT e ->> 0, e ->> 1, e ->> 2, e ->> 3
422
+ FROM jsonb_array_elements($4 -> 'fk_out') AS e),
423
+ walk(depth, via_kind, via_key, kind_id, key, relation_id, from_kind, from_key, to_kind,
424
+ to_key, link_values, link_hash) AS (
425
+ SELECT 0, CAST(NULL AS text), CAST(NULL AS text), s ->> 0, s ->> 1, %1$s
426
+ FROM jsonb_array_elements($1) AS s
427
+ UNION ALL
428
+ %2$s
429
+ ) CYCLE kind_id, key SET is_cycle USING path,
430
+ walked AS MATERIALIZED (
431
+ SELECT depth, via_kind, via_key, kind_id, key, relation_id, from_kind, from_key,
432
+ to_kind, to_key, link_values, link_hash
433
+ FROM walk LIMIT $5),
434
+ nodes AS MATERIALIZED (
435
+ SELECT kind_id, key, min(depth) AS depth FROM walked GROUP BY kind_id, key
436
+ ORDER BY min(depth), kind_id, key LIMIT $6 + 1),
437
+ links AS (
438
+ SELECT DISTINCT ON (relation_id, from_key, to_key) relation_id, from_key, to_key,
439
+ link_values, link_hash
440
+ FROM walked w
441
+ WHERE relation_id IS NOT NULL
442
+ AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.from_kind AND n.key = w.from_key)
443
+ AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.to_kind AND n.key = w.to_key))
444
+ SELECT 'record', n.depth, m.kind_id, m.key, m."values", m.title, m.object_hash, m.lineage,
445
+ m.updated_revision, m.updated_at, CAST(NULL AS text), CAST(NULL AS text),
446
+ CAST(NULL AS text), CAST(NULL AS jsonb), CAST(NULL AS bytea)
447
+ FROM nodes n
448
+ CROSS JOIN LATERAL (
449
+ SELECT * FROM %5$s m WHERE m.domain_id = %3$L AND m.branch = %4$L
450
+ AND m.kind_id = n.kind_id AND m.key = n.key LIMIT 1) AS m
451
+ UNION ALL
452
+ SELECT 'link', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
453
+ relation_id, from_key, to_key, link_values, link_hash
454
+ FROM links
455
+ UNION ALL
456
+ SELECT 'steps', CAST((SELECT count(*) FROM walked) AS integer), NULL, NULL, NULL, NULL,
457
+ NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL
458
+ UNION ALL
459
+ SELECT 'fanout', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
460
+ NULL, NULL
461
+ WHERE EXISTS (
462
+ SELECT 1 FROM walked WHERE depth > 0 GROUP BY depth, via_kind, via_key
463
+ HAVING count(DISTINCT (relation_id, from_key, to_key)) >= $3)
464
+ $sql$, v_nulls, v_step, p_domain, p_branch, v_records)
465
+ USING p_seeds, p_depth, p_fanout, p_relations, p_max_steps, p_max_nodes;
466
+ END
467
+ $fn$
468
+ """
469
+
470
+
471
+ COUNTS = {
472
+ LB_SEARCH_OVERLAY: ("p_candidates", "p_limit"),
473
+ LB_LINKS_IN_OVERLAY: ("p_limit",),
474
+ LB_QUERY_BY_ATTRIBUTE_OVERLAY: ("p_limit",),
475
+ LB_NEIGHBORHOOD_OVERLAY: ("p_depth", "p_fanout", "p_max_steps", "p_max_nodes"),
476
+ }
477
+ """Each overlay template and its count arguments, as in revision 0008."""
478
+
479
+
480
+ def _columns() -> None:
481
+ op.add_column("refs", sa.Column("base_branch", sa.Text(), nullable=True))
482
+ op.add_column("refs", sa.Column("base_revision", sa.LargeBinary(), nullable=True))
483
+ op.add_column("refs", sa.Column("base_generation", sa.BigInteger(), nullable=True))
484
+ op.create_foreign_key(
485
+ op.f("fk_refs_domain_id_base_branch_refs"),
486
+ "refs",
487
+ "refs",
488
+ ["domain_id", "base_branch"],
489
+ ["domain_id", "name"],
490
+ deferrable=True,
491
+ initially="DEFERRED",
492
+ )
493
+ op.create_foreign_key(
494
+ op.f("fk_refs_domain_id_base_revision_revisions"),
495
+ "refs",
496
+ "revisions",
497
+ ["domain_id", "base_revision"],
498
+ ["domain_id", "id"],
499
+ deferrable=True,
500
+ initially="DEFERRED",
501
+ )
502
+ op.create_check_constraint(
503
+ op.f("ck_refs_overlay_base"),
504
+ "refs",
505
+ "(materialization = 'overlay') = (base_branch IS NOT NULL "
506
+ "AND base_revision IS NOT NULL AND base_generation IS NOT NULL)",
507
+ )
508
+ op.create_index(
509
+ "ix_refs_domain_id_base_branch",
510
+ "refs",
511
+ ["domain_id", "base_branch"],
512
+ unique=False,
513
+ postgresql_where=sa.text("base_branch IS NOT NULL"),
514
+ )
515
+ for table in ("mat_records", "mat_links"):
516
+ op.add_column(
517
+ table,
518
+ sa.Column(
519
+ "revision_generation", sa.BigInteger(), server_default=sa.text("0"), nullable=False
520
+ ),
521
+ )
522
+ op.add_column(
523
+ table,
524
+ sa.Column("deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False),
525
+ )
526
+
527
+
528
+ def upgrade() -> None:
529
+ role = _reads._application_role()
530
+ if role is not None:
531
+ exists = (
532
+ op.get_bind()
533
+ .execute(
534
+ sa.text("SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :r)"), {"r": role}
535
+ )
536
+ .scalar()
537
+ )
538
+ if not exists:
539
+ raise RuntimeError(
540
+ f"database.application_role names {role!r}, which does not exist; create the "
541
+ "role first or leave the setting empty"
542
+ )
543
+ _checks._refuse_writable_schemas(role)
544
+ _columns()
545
+ search_path = _reads._search_path()
546
+ op.execute(LB_OVERLAY_ROWS.format(search_path=search_path))
547
+ op.execute(f"REVOKE ALL ON FUNCTION {HELPER} FROM PUBLIC")
548
+ options = _reads._OPTIONS.format(search_path=search_path).strip()
549
+ for template, counts in COUNTS.items():
550
+ guards = _checks._guards(search_path, counts)
551
+ if "{sources}" in template:
552
+ guards += _RESOLVE
553
+ statement = _checks._create_or_replace(template, guards)
554
+ op.execute(
555
+ statement.format(
556
+ options=options, record_columns=_reads._RECORD_COLUMNS, sources=_SOURCES.strip()
557
+ )
558
+ )
559
+ for signature in SIGNATURES:
560
+ op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
561
+ if role is not None:
562
+ quoted = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar()
563
+ for signature in SIGNATURES:
564
+ op.execute(f"GRANT EXECUTE ON FUNCTION {signature} TO {quoted}")
565
+
566
+
567
+ def downgrade() -> None:
568
+ for signature in reversed(SIGNATURES):
569
+ op.execute(f"DROP FUNCTION IF EXISTS {signature}")
570
+ op.execute(f"DROP FUNCTION IF EXISTS {HELPER}")
571
+ for table in ("mat_links", "mat_records"):
572
+ op.drop_column(table, "deleted")
573
+ op.drop_column(table, "revision_generation")
574
+ op.drop_index("ix_refs_domain_id_base_branch", table_name="refs")
575
+ op.drop_constraint(op.f("ck_refs_overlay_base"), "refs", type_="check")
576
+ op.drop_constraint(
577
+ op.f("fk_refs_domain_id_base_revision_revisions"), "refs", type_="foreignkey"
578
+ )
579
+ op.drop_constraint(op.f("fk_refs_domain_id_base_branch_refs"), "refs", type_="foreignkey")
580
+ op.drop_column("refs", "base_generation")
581
+ op.drop_column("refs", "base_revision")
582
+ op.drop_column("refs", "base_branch")