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,325 @@
1
+ """Shared lineage maps and storage profile state (store schema notes, docs/performance.md).
2
+
3
+ A Record row of `mat_records` held two maps per Attribute, `lineage` (Attribute -> provenance
4
+ id) and `lineage_revisions` (Attribute -> Revision), and a Link row of `mat_links` its
5
+ `lineage`: about half of a Record row, and the same few hundred distinct maps repeated over
6
+ millions of rows. With shared maps a row holds `{"$map": "<hex id>"}` instead, and the map is
7
+ stored once per Domain in `mat_lineage_maps`, keyed by the first 16 bytes of the SHA-256 of
8
+ its canonical text.
9
+
10
+ - `mat_lineage_maps(domain_id, id, map)`: Domain-scoped with row-level security; rows go with
11
+ their Domain. Maps are immutable and never updated; a map no row names any more stays.
12
+ - `lb_lineage(domain, value)`: the map a stored value stands for (the value itself when it
13
+ is not a reference). Every reader of the columns goes through it; a reference whose map is
14
+ missing raises an error instead of reading as empty.
15
+ - `lb_lineage_share(domain, map)`: stores a map and returns its reference; maps smaller than
16
+ a reference, references and non-objects come back unchanged.
17
+ - Row triggers `lb_lineage_maps` (BEFORE INSERT or UPDATE of the map columns) on `mat_records`
18
+ and `mat_links` replace maps by references for every writer (commit pipeline, copies,
19
+ copy-on-write, rebuilds, backfills); a value that already is a reference passes through.
20
+ The storage profile (`store.storage.lineage_maps`) enables them or not; `python -m
21
+ lorebase_store.storage apply` switches them and rewrites the existing rows in batches, in
22
+ either direction.
23
+ - `storage_tasks(name, state)`: progress of the resumable steps of `storage apply`.
24
+
25
+ The triggers start enabled only on a database without materialized Records (a new install):
26
+ on an existing one, processes of the previous version may still run during a rolling upgrade
27
+ and read the columns as maps, so they stay disabled until `storage apply` runs once every
28
+ process has the code of this revision. Existing rows keep their maps until then; readers of
29
+ this revision accept both forms. The
30
+ downgrade writes every map back into its rows (one statement per table) before dropping the
31
+ objects.
32
+
33
+ Revision ID: 0036
34
+ Revises: 0035
35
+ Create Date: 2026-10-23 10:00:00.000000
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import time
41
+ from collections.abc import Sequence
42
+
43
+ import sqlalchemy as sa
44
+ from alembic import op
45
+ from sqlalchemy.dialects import postgresql
46
+ from sqlalchemy.exc import DBAPIError
47
+
48
+ revision: str = "0036"
49
+ down_revision: str | Sequence[str] | None = "0035"
50
+ branch_labels: str | Sequence[str] | None = None
51
+ depends_on: str | Sequence[str] | None = None
52
+
53
+ MAPS = "mat_lineage_maps"
54
+ TASKS = "storage_tasks"
55
+ TRIGGER = "lb_lineage_maps"
56
+ GRANTED = "SELECT, INSERT, UPDATE, DELETE"
57
+ REVOKED = "TRUNCATE, REFERENCES, TRIGGER"
58
+ LOCK_NOT_AVAILABLE = "55P03"
59
+ LOCK_RETRY_S = 0.2
60
+ """Pause before the downgrade asks again for the locks of both tables."""
61
+
62
+ DOMAIN_VISIBLE = (
63
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
64
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
65
+ )
66
+
67
+ MAP_KEY = "$map"
68
+ """Key of a reference; Attribute ids and `$created` never take this form."""
69
+
70
+ MIN_SHARED_BYTES = 64
71
+ """Maps of at most this many bytes stay in their row: a reference is about as large."""
72
+
73
+ LINEAGE_MISSING = """
74
+ CREATE FUNCTION lb_lineage_missing(p_domain text, p_id text) RETURNS jsonb
75
+ LANGUAGE plpgsql STABLE PARALLEL SAFE AS $fn$
76
+ BEGIN
77
+ RAISE EXCEPTION 'lineage map % of Domain % is missing', p_id, p_domain
78
+ USING ERRCODE = 'XX001';
79
+ END
80
+ $fn$
81
+ """
82
+
83
+ LINEAGE = f"""
84
+ CREATE FUNCTION lb_lineage(p_domain text, p_value jsonb) RETURNS jsonb
85
+ LANGUAGE sql STABLE PARALLEL SAFE AS $fn$
86
+ SELECT CASE
87
+ WHEN jsonb_typeof(p_value) = 'object' AND p_value ? '{MAP_KEY}' THEN coalesce(
88
+ (SELECT m.map FROM __MAPS__ m
89
+ WHERE m.domain_id = p_domain AND m.id = decode(p_value ->> '{MAP_KEY}', 'hex')),
90
+ __SCHEMA__.lb_lineage_missing(p_domain, p_value ->> '{MAP_KEY}'))
91
+ ELSE p_value
92
+ END
93
+ $fn$
94
+ """
95
+
96
+ LINEAGE_SHARE = f"""
97
+ CREATE FUNCTION lb_lineage_share(p_domain text, p_map jsonb) RETURNS jsonb
98
+ LANGUAGE plpgsql VOLATILE AS $fn$
99
+ DECLARE
100
+ v_id bytea;
101
+ BEGIN
102
+ IF p_map IS NULL OR jsonb_typeof(p_map) <> 'object' OR p_map ? '{MAP_KEY}'
103
+ OR pg_column_size(p_map) <= {MIN_SHARED_BYTES} THEN
104
+ RETURN p_map;
105
+ END IF;
106
+ v_id := substr(sha256(convert_to(p_map::text, 'UTF8')), 1, 16);
107
+ PERFORM 1 FROM __MAPS__ WHERE domain_id = p_domain AND id = v_id;
108
+ IF NOT FOUND THEN
109
+ INSERT INTO __MAPS__ (domain_id, id, map) VALUES (p_domain, v_id, p_map)
110
+ ON CONFLICT DO NOTHING;
111
+ END IF;
112
+ RETURN jsonb_build_object('{MAP_KEY}', encode(v_id, 'hex'));
113
+ END
114
+ $fn$
115
+ """
116
+
117
+ RECORD_TRIGGER = """
118
+ CREATE FUNCTION lb_lineage_maps_records() RETURNS trigger
119
+ LANGUAGE plpgsql AS $fn$
120
+ BEGIN
121
+ NEW.lineage := __SCHEMA__.lb_lineage_share(NEW.domain_id, NEW.lineage);
122
+ NEW.lineage_revisions := __SCHEMA__.lb_lineage_share(NEW.domain_id, NEW.lineage_revisions);
123
+ RETURN NEW;
124
+ END
125
+ $fn$
126
+ """
127
+
128
+ LINK_TRIGGER = """
129
+ CREATE FUNCTION lb_lineage_maps_links() RETURNS trigger
130
+ LANGUAGE plpgsql AS $fn$
131
+ BEGIN
132
+ NEW.lineage := __SCHEMA__.lb_lineage_share(NEW.domain_id, NEW.lineage);
133
+ RETURN NEW;
134
+ END
135
+ $fn$
136
+ """
137
+
138
+ TABLES = (
139
+ ("mat_records", "lb_lineage_maps_records", ("lineage", "lineage_revisions")),
140
+ ("mat_links", "lb_lineage_maps_links", ("lineage",)),
141
+ )
142
+ """(table, trigger function, map columns)."""
143
+
144
+ TRIGGER_FUNCTIONS = ("lb_lineage_maps_records()", "lb_lineage_maps_links()")
145
+ FUNCTIONS = (
146
+ *TRIGGER_FUNCTIONS,
147
+ "lb_lineage_share(text, jsonb)",
148
+ "lb_lineage(text, jsonb)",
149
+ "lb_lineage_missing(text, text)",
150
+ )
151
+
152
+
153
+ def _attribute(name: str) -> object:
154
+ config = op.get_context().config
155
+ if config is None or name not in config.attributes:
156
+ raise RuntimeError("run this migration through the Lorebase Alembic environment")
157
+ return config.attributes[name]
158
+
159
+
160
+ def _application_role() -> str | None:
161
+ role: str | None = _attribute("database_settings").application_role # type: ignore[attr-defined]
162
+ return role
163
+
164
+
165
+ def _shared() -> bool:
166
+ """Whether the triggers start enabled: on a database without materialized Records (a new
167
+ install, where no process of an earlier version can read the references) when the profile
168
+ shares lineage maps (`store.storage.lineage_maps`, else every profile but `fast`, as
169
+ `lorebase_store.storage.PROFILES` when this was written). An existing install keeps its
170
+ maps inline until `storage apply` runs, once every process reads references."""
171
+ storage = _attribute("store_settings").storage # type: ignore[attr-defined]
172
+ if storage.lineage_maps is not None:
173
+ wanted = bool(storage.lineage_maps == "shared")
174
+ else:
175
+ wanted = bool(storage.profile != "fast")
176
+ if not wanted:
177
+ return False
178
+ op.execute("SELECT set_config('lorebase.bypass_rls', 'on', true)")
179
+ found = op.get_bind().execute(sa.text("SELECT 1 FROM mat_records LIMIT 1")).first()
180
+ op.execute("SELECT set_config('lorebase.bypass_rls', '', true)")
181
+ return found is None
182
+
183
+
184
+ def _maps_name() -> str:
185
+ """`mat_lineage_maps` qualified with the schema the migration runs in: the functions run
186
+ with the caller's search_path (`lb_lineage` must stay inlinable, so it has no SET)."""
187
+ name: str = (
188
+ op.get_bind()
189
+ .execute(
190
+ sa.text("SELECT quote_ident(current_schema()) || '.' || quote_ident(:t)"), {"t": MAPS}
191
+ )
192
+ .scalar_one()
193
+ )
194
+ return name
195
+
196
+
197
+ def _quoted(role: str) -> str:
198
+ quoted: str = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar_one()
199
+ return quoted
200
+
201
+
202
+ def _grant(table: str) -> None:
203
+ op.execute(f"REVOKE ALL ON TABLE {table} FROM PUBLIC")
204
+ role = _application_role()
205
+ if role is not None:
206
+ quoted = _quoted(role)
207
+ op.execute(f"REVOKE {REVOKED} ON TABLE {table} FROM {quoted}")
208
+ op.execute(f"GRANT {GRANTED} ON TABLE {table} TO {quoted}")
209
+
210
+
211
+ def upgrade() -> None:
212
+ op.create_table(
213
+ MAPS,
214
+ sa.Column("domain_id", sa.Text(), nullable=False),
215
+ sa.Column("id", sa.LargeBinary(), nullable=False),
216
+ sa.Column("map", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
217
+ sa.CheckConstraint("octet_length(id) = 16", name=op.f("ck_mat_lineage_maps_id_h16")),
218
+ sa.ForeignKeyConstraint(
219
+ ["domain_id"],
220
+ ["domains.id"],
221
+ name=op.f("fk_mat_lineage_maps_domain_id_domains"),
222
+ ondelete="CASCADE",
223
+ ),
224
+ sa.PrimaryKeyConstraint("domain_id", "id", name=op.f("pk_mat_lineage_maps")),
225
+ )
226
+ op.execute(f"ALTER TABLE {MAPS} ENABLE ROW LEVEL SECURITY")
227
+ op.execute(f"ALTER TABLE {MAPS} FORCE ROW LEVEL SECURITY")
228
+ op.execute(
229
+ f"CREATE POLICY domain_isolation ON {MAPS} "
230
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
231
+ )
232
+ _grant(MAPS)
233
+ op.create_table(
234
+ TASKS,
235
+ sa.Column("name", sa.Text(), nullable=False),
236
+ sa.Column(
237
+ "state",
238
+ postgresql.JSONB(astext_type=sa.Text()),
239
+ server_default=sa.text("'{}'::jsonb"),
240
+ nullable=False,
241
+ ),
242
+ sa.Column(
243
+ "updated_at",
244
+ sa.DateTime(timezone=True),
245
+ server_default=sa.text("now()"),
246
+ nullable=False,
247
+ ),
248
+ sa.PrimaryKeyConstraint("name", name=op.f("pk_storage_tasks")),
249
+ )
250
+ _grant(TASKS)
251
+ maps = _maps_name()
252
+ schema = maps.rsplit(".", 1)[0]
253
+ for statement in (LINEAGE_MISSING, LINEAGE, LINEAGE_SHARE, RECORD_TRIGGER, LINK_TRIGGER):
254
+ op.execute(statement.replace("__MAPS__", maps).replace("__SCHEMA__", schema))
255
+ # Trigger functions are not called directly. The others run as their caller (no SECURITY
256
+ # DEFINER), under its table privileges and row-level security, so every role may call them.
257
+ for signature in TRIGGER_FUNCTIONS:
258
+ op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
259
+ shared = _shared()
260
+ for table, function, columns in TABLES:
261
+ op.execute(
262
+ f"CREATE TRIGGER {TRIGGER} BEFORE INSERT OR UPDATE OF {', '.join(columns)} "
263
+ f"ON {table} FOR EACH ROW EXECUTE FUNCTION {function}()"
264
+ )
265
+ if not shared:
266
+ op.execute(f"ALTER TABLE {table} DISABLE TRIGGER {TRIGGER}")
267
+
268
+
269
+ def _lock_tables(bind: sa.Connection, tables: Sequence[str]) -> None:
270
+ """Take the exclusive lock of every table up front (dropping the triggers needs it), in
271
+ the order commits write them (Records, then Links), each wait shorter than
272
+ `deadlock_timeout`: when a commit holding a later table waits for an earlier one, the
273
+ downgrade gives up what it holds before the deadlock check runs and asks again, so the
274
+ commit is never the victim, and neither is the downgrade."""
275
+ deadlock_ms = bind.execute(
276
+ sa.text("SELECT extract(epoch FROM current_setting('deadlock_timeout')::interval) * 1000")
277
+ ).scalar_one()
278
+ wait_ms = max(1, int(float(deadlock_ms) / 2))
279
+ previous = bind.execute(sa.text("SELECT current_setting('lock_timeout')")).scalar_one()
280
+ while True:
281
+ try:
282
+ with bind.begin_nested():
283
+ bind.execute(sa.text(f"SET LOCAL lock_timeout = '{wait_ms}ms'"))
284
+ for table in tables:
285
+ bind.execute(sa.text(f"LOCK TABLE {table} IN ACCESS EXCLUSIVE MODE"))
286
+ break
287
+ except DBAPIError as error:
288
+ cause: BaseException | None = error
289
+ while cause is not None and not isinstance(getattr(cause, "sqlstate", None), str):
290
+ cause = getattr(cause, "orig", None) or cause.__cause__
291
+ if cause is None or cause.sqlstate != LOCK_NOT_AVAILABLE: # type: ignore[attr-defined]
292
+ raise
293
+ time.sleep(LOCK_RETRY_S)
294
+ bind.execute(sa.text("SELECT set_config('lock_timeout', :v, true)"), {"v": previous})
295
+
296
+
297
+ def downgrade() -> None:
298
+ op.execute("SELECT set_config('lorebase.bypass_rls', 'on', true)")
299
+ bind = op.get_bind()
300
+ _lock_tables(bind, [table for table, _, _ in TABLES])
301
+ for table, _, columns in TABLES:
302
+ op.execute(f"DROP TRIGGER IF EXISTS {TRIGGER} ON {table}")
303
+ assignments = ", ".join(f"{c} = lb_lineage(domain_id, {c})" for c in columns)
304
+ references = " OR ".join(
305
+ f"(jsonb_typeof({c}) = 'object' AND {c} ? '{MAP_KEY}')" for c in columns
306
+ )
307
+ # Partition by partition: the statement-level triggers of the parent (lexicon) do not
308
+ # run for these rows, whose words do not change.
309
+ partitions = bind.execute(
310
+ sa.text(
311
+ "SELECT c.relname FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid "
312
+ "WHERE i.inhparent = CAST(:t AS regclass) ORDER BY c.relname"
313
+ ),
314
+ {"t": table},
315
+ ).scalars().all() or [table]
316
+ for partition in partitions:
317
+ op.execute(f"UPDATE {partition} SET {assignments} WHERE {references}")
318
+ left = bind.execute(sa.text(f"SELECT 1 FROM {table} WHERE {references} LIMIT 1")).first()
319
+ if left is not None:
320
+ raise RuntimeError(f"{table} still holds lineage map references; not dropping maps")
321
+ op.execute("SELECT set_config('lorebase.bypass_rls', '', true)")
322
+ for signature in FUNCTIONS:
323
+ op.execute(f"DROP FUNCTION IF EXISTS {signature}")
324
+ op.drop_table(TASKS)
325
+ op.drop_table(MAPS)
@@ -0,0 +1,346 @@
1
+ """Optional BM25 ranking of lexical search through ParadeDB `pg_search` (docs/search.md#bm25-
2
+ ranking; store schema notes).
3
+
4
+ Nothing here needs the extension: the functions below only name its types and operator inside
5
+ statements they build at run time, so this revision runs, and they exist, on any Postgres. The
6
+ BM25 index itself is built by `python -m lorebase_store.storage apply` with
7
+ `store.storage.bm25_index: true` (`lorebase_store.bm25`), and Keeper ranks with it only with
8
+ `search.backend: bm25` once the index is valid.
9
+
10
+ - `lb_search_terms(search)`: the lexemes of a search vector as whitespace-separated text, each
11
+ repeated at each of its positions and in position order (once when the vector has no
12
+ positions). The BM25 index tokenizes it on whitespace (`pdb.whitespace`, field `terms`), so
13
+ it holds exactly the lexemes full-text search matches (the text search configuration's
14
+ stemming, the searchable Attributes only, sensitive values left out as in the vector), with
15
+ their frequencies and order for phrases.
16
+ - `lb_bm25_query_valid(query)`: whether a JSON search query uses only what the store sends:
17
+ `term` and `phrase` on the field `terms`, `boolean` (`must`, `should`, `must_not`, none
18
+ empty) and `all`, at most 32 levels deep.
19
+ - `lb_search_bm25` and `lb_search_bm25_overlay`: security definer search, with the options,
20
+ checks and grants of revisions 0008 and 0018 (count ceilings, a Domain in the transaction
21
+ scope, a fixed search_path). The caller translates the query as written (`p_match`) and the
22
+ hits that match only through typo corrections (`p_fixed`) into BM25 queries; a query that is
23
+ not translated exactly (`p_*_exact` false: phrases, a negated phrase) is a superset, ranked
24
+ for its first `p_candidates` matches and rechecked against the search vector. A query that
25
+ matches everything but some words (`all`) leaves out rows without a search vector, as `@@`
26
+ does. Each scan is a top-k scan of the index ordered by BM25 score, ties by Kind and key
27
+ (bytewise); `score` is relative to the best row returned (1), so that it does not carry the
28
+ index-wide term statistics the raw BM25 score is built from. On an overlay
29
+ the overlay's own rows and the base's rows as of the fork point are scanned apart; the base's
30
+ scan reads as many more rows as the overlay holds of the searched Kinds (at most
31
+ `p_candidates` more), so the rows it shadows cannot push a match out. `truncated` is true on
32
+ every row when a scan that filled its limit may have left out a row that would rank among
33
+ those returned. When the index cannot serve the call (dropped meanwhile, the library not
34
+ loaded), the functions return one row without a Kind that holds the error (SQLSTATE in
35
+ `key`, message in `title`) instead of failing the caller's transaction; the store then ranks
36
+ the default way.
37
+
38
+ Revision ID: 0037
39
+ Revises: 0036
40
+ Create Date: 2026-10-30 09:00:00.000000
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import importlib.util
46
+ from collections.abc import Sequence
47
+ from pathlib import Path
48
+ from types import ModuleType
49
+
50
+ from alembic import op
51
+
52
+ revision: str = "0037"
53
+ down_revision: str | Sequence[str] | None = "0036"
54
+ branch_labels: str | Sequence[str] | None = None
55
+ depends_on: str | Sequence[str] | None = None
56
+
57
+
58
+ def _revision_module(filename: str) -> ModuleType:
59
+ """An earlier revision file of this directory, loaded for its SQL templates and checks."""
60
+ path = Path(__file__).with_name(filename)
61
+ spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
62
+ if spec is None or spec.loader is None:
63
+ raise RuntimeError(f"cannot load migration {path}")
64
+ module = importlib.util.module_from_spec(spec)
65
+ spec.loader.exec_module(module)
66
+ return module
67
+
68
+
69
+ _lexicon = _revision_module("0018_search_lexicon.py")
70
+ _checks = _lexicon._checks
71
+ _reads = _lexicon._reads
72
+
73
+ INDEX = "ix_mat_records_bm25"
74
+ """Built by `storage apply` (`lorebase_store.bm25.INDEX`); dropped with its functions."""
75
+
76
+ SEARCH_TERMS = """
77
+ CREATE FUNCTION lb_search_terms(p_search tsvector) RETURNS text
78
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
79
+ BEGIN ATOMIC
80
+ SELECT pg_catalog.string_agg(u.lexeme, ' ' ORDER BY p.position, u.lexeme COLLATE "C")
81
+ FROM pg_catalog.unnest(p_search) AS u(lexeme, positions, weights)
82
+ CROSS JOIN LATERAL pg_catalog.unnest(
83
+ CASE WHEN coalesce(pg_catalog.cardinality(u.positions), 0)
84
+ OPERATOR(pg_catalog.=) 0
85
+ THEN CAST(ARRAY[0] AS pg_catalog.int2[]) ELSE u.positions END) AS p(position);
86
+ END
87
+ """
88
+ """The indexed text of a search vector. A standard SQL body: names resolve when it is created,
89
+ never through a caller's path."""
90
+
91
+ QUERY_VALID = """
92
+ CREATE FUNCTION lb_bm25_query_valid(p_query jsonb, p_depth integer DEFAULT 0) RETURNS boolean
93
+ LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
94
+ SET search_path = {search_path}
95
+ AS $fn$
96
+ DECLARE
97
+ v_key text;
98
+ v_value jsonb;
99
+ v_clause text;
100
+ v_items jsonb;
101
+ BEGIN
102
+ IF p_query IS NULL OR p_depth > 32 OR jsonb_typeof(p_query) <> 'object'
103
+ OR (SELECT count(*) FROM jsonb_object_keys(p_query)) <> 1 THEN
104
+ RETURN false;
105
+ END IF;
106
+ SELECT e.key, e.value INTO v_key, v_value FROM jsonb_each(p_query) AS e;
107
+ IF v_key = 'all' THEN
108
+ RETURN v_value = 'null'::jsonb;
109
+ ELSIF v_key = 'term' THEN
110
+ RETURN jsonb_typeof(v_value) = 'object'
111
+ AND ARRAY(SELECT k FROM jsonb_object_keys(v_value) AS k ORDER BY 1)
112
+ = ARRAY['field', 'value']
113
+ AND v_value -> 'field' = '"terms"'::jsonb
114
+ AND jsonb_typeof(v_value -> 'value') = 'string';
115
+ ELSIF v_key = 'phrase' THEN
116
+ RETURN jsonb_typeof(v_value) = 'object'
117
+ AND ARRAY(SELECT k FROM jsonb_object_keys(v_value) AS k ORDER BY 1)
118
+ = ARRAY['field', 'phrases', 'slop']
119
+ AND v_value -> 'field' = '"terms"'::jsonb
120
+ AND jsonb_typeof(v_value -> 'phrases') = 'array'
121
+ AND jsonb_array_length(v_value -> 'phrases') BETWEEN 1 AND 64
122
+ AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements(v_value -> 'phrases') AS p(x)
123
+ WHERE jsonb_typeof(p.x) <> 'string')
124
+ AND v_value -> 'slop' IN ('0'::jsonb, '1'::jsonb);
125
+ ELSIF v_key <> 'boolean' OR jsonb_typeof(v_value) <> 'object'
126
+ OR NOT EXISTS (SELECT 1 FROM jsonb_object_keys(v_value)) THEN
127
+ RETURN false;
128
+ END IF;
129
+ FOR v_clause, v_items IN SELECT e.key, e.value FROM jsonb_each(v_value) AS e LOOP
130
+ IF v_clause NOT IN ('must', 'should', 'must_not') OR jsonb_typeof(v_items) <> 'array'
131
+ OR jsonb_array_length(v_items) NOT BETWEEN 1 AND 256 THEN
132
+ RETURN false;
133
+ END IF;
134
+ IF EXISTS (SELECT 1 FROM jsonb_array_elements(v_items) AS i(x)
135
+ WHERE NOT lb_bm25_query_valid(i.x, p_depth + 1)) THEN
136
+ RETURN false;
137
+ END IF;
138
+ END LOOP;
139
+ RETURN true;
140
+ END
141
+ $fn$
142
+ """
143
+ """The queries `lorebase_store.bm25` builds and nothing else: the definer functions refuse any
144
+ other (another field, another query type of the extension), whoever calls them."""
145
+
146
+ LB_SEARCH_BM25 = """
147
+ CREATE FUNCTION {name}(
148
+ p_domain text, p_branch text, p_kinds text[], p_config regconfig, p_query text,
149
+ p_candidates integer, p_limit integer, p_match jsonb, p_match_exact boolean,
150
+ p_words text[] DEFAULT NULL, p_replacements text[] DEFAULT NULL, p_weight real DEFAULT 1,
151
+ p_fixed jsonb DEFAULT NULL, p_fixed_exact boolean DEFAULT true)
152
+ RETURNS TABLE (kind_id text, key text, title text, score real, exact boolean,
153
+ truncated boolean)
154
+ {{options}}
155
+ AS $fn$
156
+ DECLARE
157
+ v_kinds text := '';
158
+ v_ts tsquery;
159
+ v_fix tsquery;
160
+ v_word text;
161
+ v_replacements text[];
162
+ v_base text;
163
+ v_generation bigint;
164
+ v_shadow bigint := 0;
165
+ v_halves text[] := CAST(ARRAY[] AS text[]);
166
+ v_rows text[] := CAST(ARRAY[] AS text[]);
167
+ v_stats text[] := CAST(ARRAY[] AS text[]);
168
+ v_arm integer;
169
+ v_half integer;
170
+ v_n integer := 0;
171
+ v_exact boolean;
172
+ v_label text;
173
+ v_param text;
174
+ v_recheck text;
175
+ v_branch text;
176
+ v_filter text;
177
+ v_limit bigint;
178
+ v_present text;
179
+ v_state text;
180
+ v_message text;
181
+ BEGIN
182
+ IF cardinality(p_words) IS DISTINCT FROM cardinality(p_replacements) THEN
183
+ RAISE EXCEPTION 'p_words and p_replacements must have the same length'
184
+ USING ERRCODE = '22023';
185
+ END IF;
186
+ IF p_weight IS NULL OR p_weight < 0 THEN
187
+ RAISE EXCEPTION 'p_weight must not be negative' USING ERRCODE = '22023';
188
+ END IF;
189
+ IF p_match_exact IS NULL OR p_fixed_exact IS NULL OR NOT lb_bm25_query_valid(p_match)
190
+ OR (p_fixed IS NOT NULL AND NOT lb_bm25_query_valid(p_fixed)) THEN
191
+ RAISE EXCEPTION 'p_match and p_fixed must be search term queries'
192
+ USING ERRCODE = '22023';
193
+ END IF;
194
+ IF p_kinds IS NOT NULL THEN
195
+ v_kinds := ' AND m.kind_id = ANY ($3)';
196
+ END IF;
197
+ v_ts := websearch_to_tsquery(p_config, p_query);
198
+ v_fix := v_ts;
199
+ -- As lb_search_corrected: each corrected lexeme becomes (lexeme | replacement | ...).
200
+ FOR v_word, v_replacements IN
201
+ SELECT t.w, array_agg(t.r ORDER BY t.n)
202
+ FROM unnest(p_words, p_replacements) WITH ORDINALITY AS t(w, r, n)
203
+ GROUP BY t.w ORDER BY t.w
204
+ LOOP
205
+ v_fix := ts_rewrite(v_fix, lb_lexeme_query(v_word),
206
+ lb_lexeme_query(v_word, v_replacements));
207
+ END LOOP;
208
+ {{resolve}}
209
+ -- Arm 1: the query as written; arm 2: matches only through a correction. Each arm scans
210
+ -- the branch (half 1), or on an overlay its own rows (half 1) and the base's (half 2).
211
+ FOR v_arm IN 1..2 LOOP
212
+ CONTINUE WHEN v_arm = 2 AND (p_fixed IS NULL OR v_fix = v_ts);
213
+ v_exact := CASE WHEN v_arm = 1 THEN p_match_exact ELSE p_fixed_exact END;
214
+ v_param := CASE WHEN v_arm = 1 THEN '$6' ELSE '$7' END;
215
+ v_label := CASE WHEN v_arm = 1 THEN 'true' ELSE 'false' END;
216
+ -- `all` matches rows without a search vector too; `search @@` never does.
217
+ v_present := CASE WHEN jsonb_path_exists(CASE WHEN v_arm = 1 THEN p_match
218
+ ELSE p_fixed END, '$.**."all"')
219
+ THEN ' AND m.search IS NOT NULL' ELSE '' END;
220
+ v_recheck := CASE WHEN v_exact THEN 'true'
221
+ WHEN v_arm = 1 THEN 'h.search @@ $4'
222
+ ELSE 'h.search @@ $5 AND NOT h.search @@ $4' END;
223
+ FOR v_half IN 1..CASE WHEN v_base IS NULL THEN 1 ELSE 2 END LOOP
224
+ v_n := v_n + 1;
225
+ v_limit := CASE WHEN v_exact THEN p_limit ELSE p_candidates END;
226
+ v_filter := v_recheck;
227
+ IF v_base IS NULL THEN
228
+ v_branch := format('m.branch = %L', p_branch);
229
+ ELSIF v_half = 1 THEN
230
+ v_branch := format('m.branch = %L AND NOT m.deleted', p_branch);
231
+ ELSE
232
+ -- Base rows written at or before the fork, while the ref is still that
233
+ -- overlay, that the overlay does not shadow (as lb_overlay_rows): the scan
234
+ -- reads one more row for each row the overlay holds of these Kinds.
235
+ v_branch := format('m.branch = %L AND m.revision_generation <= %s',
236
+ v_base, v_generation);
237
+ v_limit := v_limit + least(v_shadow, p_candidates);
238
+ v_filter := v_filter || format(
239
+ ' AND EXISTS (SELECT 1 FROM refs f WHERE f.domain_id = %1$L '
240
+ 'AND f.name = %2$L AND f.base_branch = %3$L AND f.base_generation = %4$s '
241
+ 'OFFSET 0) AND NOT EXISTS (SELECT 1 FROM mat_records x '
242
+ 'WHERE x.domain_id = %1$L AND x.branch = %2$L AND x.kind_id = h.kind_id '
243
+ 'AND x.key = h.key OFFSET 0)',
244
+ p_domain, p_branch, v_base, v_generation);
245
+ END IF;
246
+ v_halves := v_halves || format(
247
+ 'h%1$s AS MATERIALIZED (SELECT m.kind_id, m.key, m.title, %2$s AS search, '
248
+ 'CAST(pdb.score(m.key) AS real) AS raw FROM mat_records m '
249
+ 'WHERE m.domain_id = %3$L AND %4$s%5$s%8$s '
250
+ 'AND m.key OPERATOR(pg_catalog.@@@) CAST(%6$s AS paradedb.searchqueryinput) '
251
+ 'ORDER BY pdb.score(m.key) DESC, m.kind_id COLLATE "C", m.key COLLATE "C" '
252
+ 'LIMIT %7$s)',
253
+ v_n, CASE WHEN v_exact THEN 'CAST(NULL AS tsvector)' ELSE 'm.search' END,
254
+ p_domain, v_branch, v_kinds, v_param, v_limit, v_present);
255
+ v_rows := v_rows || format(
256
+ 'SELECT h.kind_id, h.key, h.title, h.raw, %1$s AS exact FROM h%2$s h '
257
+ 'WHERE %3$s', v_label, v_n, v_filter);
258
+ v_stats := v_stats || format(
259
+ 'SELECT %1$s AS exact, count(*) >= %2$s AS filled, min(h.raw) AS floor '
260
+ 'FROM h%3$s h', v_label, v_limit, v_n);
261
+ END LOOP;
262
+ END LOOP;
263
+ -- A scan that filled its limit may have left out rows scoring at most its last one: the
264
+ -- result is cut when such a row would rank among those returned (fewer rows than asked
265
+ -- at or above its arm, or a returned row of its arm scoring below that last one).
266
+ BEGIN
267
+ RETURN QUERY EXECUTE
268
+ 'WITH ' || array_to_string(v_halves, ', ')
269
+ || ', r AS (' || array_to_string(v_rows, ' UNION ALL ') || ')'
270
+ || ', s AS (' || array_to_string(v_stats, ' UNION ALL ') || ')'
271
+ || ', t AS (SELECT r.kind_id, r.key, r.title, r.raw, r.exact, '
272
+ || 'CAST(CASE WHEN r.exact THEN r.raw ELSE $8 * r.raw END AS real) AS score FROM r '
273
+ || 'ORDER BY r.exact DESC, score DESC, r.raw DESC, r.kind_id COLLATE "C", '
274
+ || 'r.key COLLATE "C" LIMIT $9) '
275
+ || 'SELECT t.kind_id, t.key, t.title, '
276
+ || 'CAST(coalesce(t.score / nullif(max(t.score) OVER (), 0), 0) AS real), t.exact, '
277
+ || 'EXISTS (SELECT 1 FROM s WHERE s.filled AND ((SELECT count(*) FROM t x '
278
+ || 'WHERE x.exact OR NOT s.exact) < $9 '
279
+ || 'OR (SELECT min(x.raw) FROM t x WHERE x.exact = s.exact) < s.floor)) '
280
+ || 'FROM t ORDER BY t.exact DESC, t.score DESC, t.raw DESC, t.kind_id COLLATE "C", '
281
+ || 't.key COLLATE "C"'
282
+ USING p_domain, p_branch, p_kinds, v_ts, v_fix, p_match, p_fixed, p_weight, p_limit;
283
+ EXCEPTION WHEN OTHERS THEN
284
+ -- The index cannot serve the call (dropped since the caller looked, the library not
285
+ -- loaded, a query the extension refuses): a row without a Kind, holding the error,
286
+ -- tells the caller to rank the default way. Cancellation is not caught (OTHERS).
287
+ GET STACKED DIAGNOSTICS v_state = RETURNED_SQLSTATE, v_message = MESSAGE_TEXT;
288
+ RETURN QUERY SELECT CAST(NULL AS text), v_state, v_message, CAST(NULL AS real),
289
+ CAST(NULL AS boolean), CAST(NULL AS boolean);
290
+ END;
291
+ END
292
+ $fn$
293
+ """
294
+
295
+ _RESOLVE_OVERLAY = """
296
+ SELECT r.base_branch, r.base_generation INTO v_base, v_generation FROM refs r
297
+ WHERE r.domain_id = p_domain AND r.name = p_branch AND r.materialization = 'overlay';
298
+ IF v_base IS NOT NULL THEN
299
+ EXECUTE 'SELECT count(*) FROM (SELECT 1 FROM mat_records x WHERE x.domain_id = $1 '
300
+ 'AND x.branch = $2 AND ($3 IS NULL OR x.kind_id = ANY ($3)) LIMIT $4) AS s'
301
+ INTO v_shadow USING p_domain, p_branch, p_kinds, p_candidates + 1;
302
+ END IF;"""
303
+ """The overlay variant finds the base and the generation it forked at (a branch that is no
304
+ longer an overlay is read as a full one) and counts the overlay's rows of the searched Kinds,
305
+ tombstones included: each can shadow one base row."""
306
+
307
+ PARALLEL = "\nSET max_parallel_workers_per_gather = 0"
308
+ """A top-k scan of a few rows costs less than starting workers: with them the planner picked a
309
+ parallel scan of the overlay's rows that took 19 ms instead of 1 (1M Records)."""
310
+
311
+ SEARCH = (
312
+ ("lb_search_bm25", ""),
313
+ ("lb_search_bm25_overlay", _RESOLVE_OVERLAY),
314
+ )
315
+ ARGUMENTS = (
316
+ "(text, text, text[], regconfig, text, integer, integer, jsonb, boolean, text[], text[], "
317
+ "real, jsonb, boolean)"
318
+ )
319
+ SIGNATURES = tuple(f"{name}{ARGUMENTS}" for name, _ in SEARCH)
320
+ HELPERS = ("lb_bm25_query_valid(jsonb, integer)", "lb_search_terms(tsvector)")
321
+
322
+
323
+ def upgrade() -> None:
324
+ role = _reads._application_role()
325
+ if role is not None:
326
+ _checks._refuse_writable_schemas(role)
327
+ search_path = _reads._search_path()
328
+ op.execute(SEARCH_TERMS)
329
+ op.execute(QUERY_VALID.format(search_path=search_path))
330
+ options = _reads._OPTIONS.format(search_path=search_path).strip() + PARALLEL
331
+ for name, resolve in SEARCH:
332
+ guards = _checks._guards(search_path, ("p_candidates", "p_limit"))
333
+ template = LB_SEARCH_BM25.format(name=name)
334
+ statement = _checks._create_or_replace(template, guards)
335
+ op.execute(statement.format(options=options, resolve=resolve.strip()))
336
+ _lexicon._grant(SIGNATURES)
337
+
338
+
339
+ def downgrade() -> None:
340
+ op.execute(f"DROP INDEX IF EXISTS {INDEX}")
341
+ for signature in reversed(SIGNATURES):
342
+ op.execute(f"DROP FUNCTION IF EXISTS {signature}")
343
+ op.execute(f"DROP FUNCTION IF EXISTS {HELPERS[0]}")
344
+ # CASCADE: partition indexes of an interrupted build, never attached to INDEX, use it too.
345
+ # The extension stays; an administrator drops it.
346
+ op.execute(f"DROP FUNCTION IF EXISTS {HELPERS[1]} CASCADE")