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,55 @@
1
+ """Webhook signing keys may be stored sealed.
2
+
3
+ `webhooks.sealed_key` holds the signing key (`sha256(secret)`) encrypted under
4
+ `webhooks.encryption_key` (AES-256-GCM, `lorebase_store.webhooks.keys`); `secret_hash`, which
5
+ held it usable, becomes nullable and exactly one of the two is set. Existing rows keep their
6
+ `secret_hash` and keep working: the encryption key lives in the services' configuration, not
7
+ here, so the delivery worker seals them once a key is configured.
8
+
9
+ The downgrade refuses to run while a row is sealed: only the services can open it. Unset
10
+ `webhooks.encryption_key` is not enough; delete those webhooks (or downgrade a copy without
11
+ them) first.
12
+
13
+ Revision ID: 0016
14
+ Revises: 0015
15
+ Create Date: 2026-10-23 10:00:00.000000
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from collections.abc import Sequence
21
+
22
+ import sqlalchemy as sa
23
+ from alembic import op
24
+
25
+ revision: str = "0016"
26
+ down_revision: str | Sequence[str] | None = "0015"
27
+ branch_labels: str | Sequence[str] | None = None
28
+ depends_on: str | Sequence[str] | None = None
29
+
30
+
31
+ def upgrade() -> None:
32
+ op.add_column("webhooks", sa.Column("sealed_key", sa.LargeBinary(), nullable=True))
33
+ op.alter_column("webhooks", "secret_hash", existing_type=sa.LargeBinary(), nullable=True)
34
+ op.create_check_constraint(
35
+ op.f("ck_webhooks_one_signing_key"),
36
+ "webhooks",
37
+ "(secret_hash IS NULL) <> (sealed_key IS NULL)",
38
+ )
39
+
40
+
41
+ def downgrade() -> None:
42
+ bind = op.get_bind()
43
+ # Row-level security is forced on the owner too: count the rows of every Domain.
44
+ bind.execute(sa.text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
45
+ sealed = bind.execute(
46
+ sa.text("SELECT count(*) FROM webhooks WHERE sealed_key IS NOT NULL")
47
+ ).scalar_one()
48
+ if sealed:
49
+ raise RuntimeError(
50
+ f"{sealed} webhook(s) have a sealed signing key, which revision 0015 cannot hold; "
51
+ "delete them before downgrading"
52
+ )
53
+ op.drop_constraint(op.f("ck_webhooks_one_signing_key"), "webhooks", type_="check")
54
+ op.alter_column("webhooks", "secret_hash", existing_type=sa.LargeBinary(), nullable=False)
55
+ op.drop_column("webhooks", "sealed_key")
@@ -0,0 +1,77 @@
1
+ """Background branch copies and deletions (store schema notes, "Large branches").
2
+
3
+ - `refs.deleted_at`: set on a branch whose deletion is in progress. The ref is hidden from
4
+ every read and write, its rows are deleted in batches by a `store.branch_delete` job, and
5
+ the job deletes the ref last (its foreign keys cascade what is left).
6
+ - `branch_materializations`: per branch, the progress of a `store.materialize` job that turns
7
+ an overlay into a full copy in batches (status, phase, cursor, rows done and total, error).
8
+ Domain-scoped with row-level security like the tables of 0001; rows go with their branch.
9
+
10
+ Revision ID: 0017
11
+ Revises: 0016
12
+ Create Date: 2026-10-22 21: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 = "0017"
24
+ down_revision: str | Sequence[str] | None = "0016"
25
+ branch_labels: str | Sequence[str] | None = None
26
+ depends_on: str | Sequence[str] | None = None
27
+
28
+ DOMAIN_VISIBLE = (
29
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
30
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
31
+ )
32
+
33
+
34
+ def upgrade() -> None:
35
+ op.add_column("refs", sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
36
+ op.create_table(
37
+ "branch_materializations",
38
+ sa.Column("domain_id", sa.Text(), nullable=False),
39
+ sa.Column("branch", sa.Text(), nullable=False),
40
+ sa.Column("status", sa.Text(), nullable=False),
41
+ sa.Column("phase", sa.Text(), nullable=False),
42
+ sa.Column("base_branch", sa.Text(), nullable=False),
43
+ sa.Column("base_generation", sa.BigInteger(), nullable=False),
44
+ sa.Column("cursor", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
45
+ sa.Column("rows_done", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
46
+ sa.Column("rows_total", sa.BigInteger(), nullable=True),
47
+ sa.Column("error", sa.Text(), nullable=True),
48
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
49
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
50
+ sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
51
+ sa.CheckConstraint(
52
+ "status IN ('pending', 'running', 'done', 'failed')",
53
+ name=op.f("ck_branch_materializations_status"),
54
+ ),
55
+ sa.CheckConstraint(
56
+ "phase IN ('records', 'links', 'history', 'switch', 'done')",
57
+ name=op.f("ck_branch_materializations_phase"),
58
+ ),
59
+ sa.ForeignKeyConstraint(
60
+ ["domain_id", "branch"],
61
+ ["refs.domain_id", "refs.name"],
62
+ name=op.f("fk_branch_materializations_domain_id_branch_refs"),
63
+ ondelete="CASCADE",
64
+ ),
65
+ sa.PrimaryKeyConstraint("domain_id", "branch", name=op.f("pk_branch_materializations")),
66
+ )
67
+ op.execute("ALTER TABLE branch_materializations ENABLE ROW LEVEL SECURITY")
68
+ op.execute("ALTER TABLE branch_materializations FORCE ROW LEVEL SECURITY")
69
+ op.execute(
70
+ "CREATE POLICY domain_isolation ON branch_materializations "
71
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
72
+ )
73
+
74
+
75
+ def downgrade() -> None:
76
+ op.drop_table("branch_materializations")
77
+ op.drop_column("refs", "deleted_at")
@@ -0,0 +1,477 @@
1
+ """Typo tolerance through a per-branch word lexicon (design 8.4; store schema notes).
2
+
3
+ Typo-tolerant search matched whole titles by trigram similarity (`m.title % query` on
4
+ `ix_mat_records_branch_title`). The index is lossy: a common word appears in long titles that
5
+ are never similar to it, so at 1M Records a search read and rechecked about 150,000 rows. This
6
+ revision replaces it with the approach of the pg_trgm documentation for unique words: trigram
7
+ matching runs on the distinct words of a branch, and full-text search runs with the corrected
8
+ query.
9
+
10
+ - `mat_lexicon(domain_id, branch, kind_id, word, records)`: per Kind, the lexemes of the
11
+ branch's own search vectors (title and searchable Attributes,
12
+ `store.materialize.text_search_config`) and the number of its rows that hold each, so
13
+ corrections come only from the Kinds a caller may read. Words with a digit or longer than
14
+ 64 characters are left out (`lb_lexicon_word`): codes and keys are not typo-corrected.
15
+ Domain-scoped with row-level security like the tables of 0001; rows go with their branch.
16
+ An overlay holds the words of its own rows; its readers add the base's (a superset of what
17
+ it reads, which only costs a candidate that matches nothing).
18
+ - Statement-level triggers on `mat_records` (`lb_lexicon_maintain`, transition tables) keep
19
+ it in step with every write of the commit pipeline, copy-on-write, promotion and deletion,
20
+ in the writing transaction: per statement, one aggregate over the rows whose search vector
21
+ changed. Rows of a branch whose ref is gone (a cascade) are skipped. Spent words (count 0)
22
+ are deleted through a small partial index.
23
+ - `lb_search_corrections(domain, branch, kinds, config, query, threshold, candidates,
24
+ min_length)`: for each query lexeme that is long enough, has no digit, is not negated and
25
+ is not in the lexicon of the branch's `kinds` (and of its base, for an overlay), its
26
+ `candidates` closest words of those Kinds at least `threshold` similar (trigram GIN index
27
+ on `(domain_id, branch, word)`), best first.
28
+ - `lb_search_corrected` and `lb_search_corrected_overlay`: full-text search where each
29
+ corrected lexeme is replaced by itself or its replacements (`ts_rewrite`); matches of the
30
+ query as written come first (`exact`), the others are ranked by `weight` times their rank.
31
+ - `lb_lexeme_query(word, alternatives)` builds the tsquery of a lexeme or of its
32
+ alternatives without normalizing them; plain SQL (`store.reads.use_definer_functions:
33
+ false`) uses it too.
34
+
35
+ The definer functions have the options, checks and grants of revisions 0008 and 0010. The
36
+ functions of revision 0005 stay for processes that still call them; `ix_mat_records_branch_
37
+ title` is dropped, so their trigram branch no longer has an index.
38
+
39
+ The backfill reads every search vector once, under the lock that creating the triggers takes
40
+ on `mat_records` (writes wait for it).
41
+
42
+ Revision ID: 0018
43
+ Revises: 0017
44
+ Create Date: 2026-10-22 21:00:00.000000
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import importlib.util
50
+ from collections.abc import Sequence
51
+ from pathlib import Path
52
+ from types import ModuleType
53
+
54
+ import sqlalchemy as sa
55
+ from alembic import op
56
+
57
+ revision: str = "0018"
58
+ down_revision: str | Sequence[str] | None = "0017"
59
+ branch_labels: str | Sequence[str] | None = None
60
+ depends_on: str | Sequence[str] | None = None
61
+
62
+
63
+ def _revision_module(filename: str) -> ModuleType:
64
+ """An earlier revision file of this directory, loaded for its SQL templates and checks."""
65
+ path = Path(__file__).with_name(filename)
66
+ spec = importlib.util.spec_from_file_location(f"_lorebase_revision_{path.stem}", path)
67
+ if spec is None or spec.loader is None:
68
+ raise RuntimeError(f"cannot load migration {path}")
69
+ module = importlib.util.module_from_spec(spec)
70
+ spec.loader.exec_module(module)
71
+ return module
72
+
73
+
74
+ _indexes = _revision_module("0011_branch_scoped_search_index.py")
75
+ _overlays = _indexes._overlays
76
+ _checks = _indexes._checks
77
+ _reads = _indexes._reads
78
+
79
+ DOMAIN_VISIBLE = (
80
+ "current_setting('lorebase.bypass_rls', true) = 'on' "
81
+ "OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
82
+ )
83
+
84
+ TITLE_INDEX = _indexes.SWAPS[1][1]
85
+ """`ix_mat_records_branch_title` of revision 0011, dropped here and rebuilt on downgrade."""
86
+
87
+ LEXICON_WORD = """
88
+ CREATE FUNCTION lb_lexicon_word(p_word text) RETURNS boolean
89
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
90
+ RETURN p_word OPERATOR(pg_catalog.!~) '[0-9]'
91
+ AND pg_catalog.char_length(p_word) OPERATOR(pg_catalog.<=) 64
92
+ """
93
+ """Whether a lexeme belongs in the lexicon. Qualified names, no SET clause: it is inlined
94
+ into the trigger and the read functions, and resolves nothing through a caller's path."""
95
+
96
+ LEXEME_QUERY = """
97
+ CREATE FUNCTION lb_lexeme_query(p_word text, p_alternatives text[] DEFAULT NULL)
98
+ RETURNS tsquery
99
+ LANGUAGE sql IMMUTABLE PARALLEL SAFE
100
+ RETURN CAST(pg_catalog.array_to_string(ARRAY(
101
+ SELECT '''' OPERATOR(pg_catalog.||) pg_catalog.replace(pg_catalog.replace(
102
+ t.w, '\\', '\\\\'), '''', '''''') OPERATOR(pg_catalog.||) ''''
103
+ FROM pg_catalog.unnest(ARRAY[p_word] OPERATOR(pg_catalog.||)
104
+ coalesce(p_alternatives, CAST('{}' AS pg_catalog.text[])))
105
+ WITH ORDINALITY AS t(w, n)
106
+ ORDER BY t.n), ' | ') AS pg_catalog.tsquery)
107
+ """
108
+ """`'word'`, or `'word' | 'alternative' | ...`: lexemes quoted as tsquery input (quotes and
109
+ backslashes doubled), so they are taken as they are, without a dictionary."""
110
+
111
+ _ROWS = """
112
+ SELECT {row}.domain_id, {row}.branch, {row}.kind_id, w.lexeme AS word, {sign} AS records
113
+ FROM {table} {row}{live}
114
+ CROSS JOIN LATERAL unnest({row}.search) AS w
115
+ WHERE {row}.search IS NOT NULL AND NOT {row}.deleted AND lb_lexicon_word(w.lexeme){unchanged}
116
+ """
117
+ _LIVE = " JOIN refs r ON r.domain_id = {row}.domain_id AND r.name = {row}.branch"
118
+ """Rows of a branch whose ref is gone (deleted, the delete cascading) leave no words."""
119
+ _UNCHANGED = (
120
+ " AND NOT EXISTS (SELECT 1 FROM {other} x WHERE x.domain_id = {row}.domain_id "
121
+ "AND x.branch = {row}.branch AND x.kind_id = {row}.kind_id AND x.key = {row}.key "
122
+ "AND x.deleted = {row}.deleted AND x.search IS NOT DISTINCT FROM {row}.search)"
123
+ )
124
+ """An UPDATE that keeps a row's search vector (most of them) changes none of its words."""
125
+
126
+
127
+ def _rows(table: str, row: str, sign: int, *, live: bool, other: str | None) -> str:
128
+ return _ROWS.format(
129
+ row=row,
130
+ table=table,
131
+ sign=sign,
132
+ live=_LIVE.format(row=row) if live else "",
133
+ unchanged=_UNCHANGED.format(other=other, row=row) if other else "",
134
+ ).strip()
135
+
136
+
137
+ SMALL_STATEMENT_ROWS = 64
138
+ """Statements that change at most this many rows sum their word changes with a cached plan;
139
+ larger ones plan the sum for their own row counts (see `_delta`)."""
140
+
141
+
142
+ def _delta(table: str, *arms: str) -> str:
143
+ """The per-word count changes of one statement, into the function's arrays.
144
+
145
+ The commit pipeline writes many one-row statements (`executemany`), where planning each
146
+ call would cost more than the work; a cached plan of a small statement, on the other
147
+ hand, would compare every pair of rows of a 10,000-row one. So small statements use the
148
+ plan plpgsql caches and large ones `EXECUTE`, planned for their row counts.
149
+ """
150
+ order = "ORDER BY d.domain_id, d.branch, d.kind_id, d.word"
151
+ select = (
152
+ f"SELECT array_agg(d.domain_id {order}), array_agg(d.branch {order}), "
153
+ f"array_agg(d.kind_id {order}), array_agg(d.word {order}), "
154
+ f"array_agg(d.records {order}) {{into}}"
155
+ "FROM (SELECT x.domain_id, x.branch, x.kind_id, x.word, sum(x.records) AS records "
156
+ "FROM ("
157
+ + " UNION ALL ".join(arms)
158
+ + ") AS x GROUP BY x.domain_id, x.branch, x.kind_id, x.word "
159
+ "HAVING sum(x.records) <> 0) AS d"
160
+ )
161
+ into = "INTO v_domains, v_branches, v_kind_ids, v_words, v_counts"
162
+ return (
163
+ f"SELECT count(*) INTO v_rows FROM {table};\n"
164
+ f" IF v_rows <= {SMALL_STATEMENT_ROWS} THEN\n"
165
+ f" {select.format(into=into + ' ')};\n"
166
+ " ELSE\n"
167
+ f" EXECUTE $delta${select.format(into='')}$delta$ {into};\n"
168
+ " END IF;"
169
+ )
170
+
171
+
172
+ INSERTED = _delta("lb_new", _rows("lb_new", "n", 1, live=False, other=None))
173
+ UPDATED = _delta(
174
+ "lb_new",
175
+ _rows("lb_new", "n", 1, live=False, other="lb_old"),
176
+ _rows("lb_old", "o", -1, live=True, other="lb_new"),
177
+ )
178
+ DELETED = _delta("lb_old", _rows("lb_old", "o", -1, live=True, other=None))
179
+
180
+ LEXICON_MAINTAIN = f"""
181
+ CREATE FUNCTION lb_lexicon_maintain() RETURNS trigger
182
+ LANGUAGE plpgsql
183
+ SET search_path = {{search_path}}
184
+ AS $fn$
185
+ DECLARE
186
+ v_domains text[];
187
+ v_branches text[];
188
+ v_kind_ids text[];
189
+ v_words text[];
190
+ v_counts bigint[];
191
+ v_rows bigint;
192
+ BEGIN
193
+ IF TG_OP = 'INSERT' THEN
194
+ {INSERTED}
195
+ ELSIF TG_OP = 'UPDATE' THEN
196
+ {UPDATED}
197
+ ELSE
198
+ {DELETED}
199
+ END IF;
200
+ IF v_words IS NULL THEN
201
+ RETURN NULL;
202
+ END IF;
203
+ -- One upsert in key order for gains and losses alike (the losses of a branch whose ref
204
+ -- is gone were left out above); writers of one branch hold its lock anyway.
205
+ INSERT INTO mat_lexicon AS l (domain_id, branch, kind_id, word, records)
206
+ SELECT d.domain_id, d.branch, d.kind_id, d.word, d.records
207
+ FROM unnest(v_domains, v_branches, v_kind_ids, v_words, v_counts)
208
+ WITH ORDINALITY AS d(domain_id, branch, kind_id, word, records, n)
209
+ ORDER BY d.n
210
+ ON CONFLICT (domain_id, branch, kind_id, word)
211
+ DO UPDATE SET records = l.records + EXCLUDED.records;
212
+ IF EXISTS (SELECT 1 FROM unnest(v_counts) AS c(records) WHERE c.records < 0) THEN
213
+ DELETE FROM mat_lexicon AS l
214
+ USING (SELECT DISTINCT b.domain_id, b.branch
215
+ FROM unnest(v_domains, v_branches) AS b(domain_id, branch)) AS b
216
+ WHERE l.domain_id = b.domain_id AND l.branch = b.branch AND l.records <= 0;
217
+ END IF;
218
+ RETURN NULL;
219
+ END
220
+ $fn$
221
+ """
222
+
223
+ TRIGGERS = (
224
+ ("lb_lexicon_insert", "INSERT", "NEW TABLE AS lb_new"),
225
+ ("lb_lexicon_update", "UPDATE", "OLD TABLE AS lb_old NEW TABLE AS lb_new"),
226
+ ("lb_lexicon_delete", "DELETE", "OLD TABLE AS lb_old"),
227
+ )
228
+
229
+ BACKFILL = """
230
+ INSERT INTO mat_lexicon (domain_id, branch, kind_id, word, records)
231
+ SELECT m.domain_id, m.branch, m.kind_id, w.lexeme, count(*)
232
+ FROM mat_records m CROSS JOIN LATERAL unnest(m.search) AS w
233
+ WHERE m.search IS NOT NULL AND NOT m.deleted AND lb_lexicon_word(w.lexeme)
234
+ GROUP BY m.domain_id, m.branch, m.kind_id, w.lexeme
235
+ """
236
+
237
+ LB_SEARCH_CORRECTIONS = """
238
+ CREATE FUNCTION lb_search_corrections(
239
+ p_domain text, p_branch text, p_kinds text[], p_config regconfig, p_query text,
240
+ p_threshold real, p_candidates integer, p_min_length integer)
241
+ RETURNS TABLE (word text, replacement text, similarity real)
242
+ {options}
243
+ SET pg_trgm.similarity_threshold = '0.3'
244
+ AS $fn$
245
+ DECLARE
246
+ v_base text;
247
+ v_word text;
248
+ v_lookup text;
249
+ v_kinds text := '';
250
+ BEGIN
251
+ IF p_threshold IS NULL OR NOT (p_threshold > 0 AND p_threshold <= 1) THEN
252
+ RAISE EXCEPTION 'p_threshold must be in (0, 1]' USING ERRCODE = '22023';
253
+ END IF;
254
+ IF p_min_length IS NULL OR p_min_length < 1 THEN
255
+ RAISE EXCEPTION 'p_min_length must be positive' USING ERRCODE = '22023';
256
+ END IF;
257
+ -- Local to this call: the SET clause above restores the setting on exit.
258
+ PERFORM set_config('pg_trgm.similarity_threshold', p_threshold::text, true);
259
+ SELECT r.base_branch INTO v_base FROM refs r
260
+ WHERE r.domain_id = p_domain AND r.name = p_branch AND r.materialization = 'overlay';
261
+ IF p_kinds IS NOT NULL THEN
262
+ v_kinds := ' AND l.kind_id = ANY ($3)';
263
+ END IF;
264
+ -- Domain and branch are literals: index conditions of the trigram index.
265
+ v_lookup := format(
266
+ 'SELECT l.word, l.records FROM mat_lexicon l WHERE l.domain_id = %L '
267
+ 'AND l.branch = %L AND l.records > 0%s AND l.word %% $1', p_domain, p_branch, v_kinds);
268
+ IF v_base IS NOT NULL THEN
269
+ v_lookup := v_lookup || ' UNION ALL ' || format(
270
+ 'SELECT l.word, l.records FROM mat_lexicon l WHERE l.domain_id = %L '
271
+ 'AND l.branch = %L AND l.records > 0%s AND l.word %% $1', p_domain, v_base, v_kinds);
272
+ END IF;
273
+ -- The lexemes of the query as full-text search reads it (tsquery output doubles quotes
274
+ -- and backslashes), but those it negates.
275
+ FOR v_word IN
276
+ SELECT DISTINCT replace(replace(t.m[2], '''''', ''''), '\\\\', '\\')
277
+ FROM regexp_matches(CAST(websearch_to_tsquery(p_config, p_query) AS text),
278
+ '(!?)''((?:[^'']|'''')*)''', 'g') AS t(m)
279
+ WHERE t.m[1] = ''
280
+ ORDER BY 1
281
+ LOOP
282
+ CONTINUE WHEN char_length(v_word) < p_min_length OR NOT lb_lexicon_word(v_word);
283
+ CONTINUE WHEN EXISTS (
284
+ SELECT 1 FROM mat_lexicon l
285
+ WHERE l.domain_id = p_domain AND l.branch IN (p_branch, v_base)
286
+ AND l.word = v_word AND l.records > 0
287
+ AND (p_kinds IS NULL OR l.kind_id = ANY (p_kinds)));
288
+ RETURN QUERY EXECUTE
289
+ 'SELECT CAST($1 AS text), c.word, CAST(max(similarity(c.word, $1)) AS real) '
290
+ 'FROM (' || v_lookup || ') AS c GROUP BY c.word '
291
+ 'ORDER BY 3 DESC, sum(c.records) DESC, c.word LIMIT $2'
292
+ USING v_word, p_candidates, p_kinds;
293
+ END LOOP;
294
+ END
295
+ $fn$
296
+ """
297
+
298
+ LB_SEARCH_CORRECTED = """
299
+ CREATE FUNCTION {name}(
300
+ p_domain text, p_branch text, p_kinds text[], p_config regconfig, p_query text,
301
+ p_candidates integer, p_limit integer, p_words text[] DEFAULT NULL,
302
+ p_replacements text[] DEFAULT NULL, p_weight real DEFAULT 1)
303
+ RETURNS TABLE (kind_id text, key text, title text, score real, exact boolean)
304
+ {{options}}
305
+ AS $fn$
306
+ DECLARE
307
+ v_kinds text := '';
308
+ v_ts tsquery;
309
+ v_fix tsquery;
310
+ v_word text;
311
+ v_replacements text[];
312
+ v_rows text;
313
+ {{sources}}
314
+ BEGIN
315
+ IF cardinality(p_words) IS DISTINCT FROM cardinality(p_replacements) THEN
316
+ RAISE EXCEPTION 'p_words and p_replacements must have the same length'
317
+ USING ERRCODE = '22023';
318
+ END IF;
319
+ IF p_weight IS NULL OR p_weight < 0 THEN
320
+ RAISE EXCEPTION 'p_weight must not be negative' USING ERRCODE = '22023';
321
+ END IF;
322
+ IF p_kinds IS NOT NULL THEN
323
+ v_kinds := ' AND m.kind_id = ANY ($3)';
324
+ END IF;
325
+ v_ts := websearch_to_tsquery(p_config, p_query);
326
+ v_fix := v_ts;
327
+ -- Each corrected lexeme becomes (lexeme | replacement | ...), wherever the query has it.
328
+ FOR v_word, v_replacements IN
329
+ SELECT t.w, array_agg(t.r ORDER BY t.n)
330
+ FROM unnest(p_words, p_replacements) WITH ORDINALITY AS t(w, r, n)
331
+ GROUP BY t.w ORDER BY t.w
332
+ LOOP
333
+ v_fix := ts_rewrite(v_fix, lb_lexeme_query(v_word),
334
+ lb_lexeme_query(v_word, v_replacements));
335
+ END LOOP;
336
+ v_rows := 'SELECT m.kind_id, m.key, m.title, m.search, %5$s AS exact FROM %1$s m '
337
+ 'WHERE m.domain_id = %2$L AND m.branch = %3$L%4$s AND %6$s LIMIT $6';
338
+ IF v_fix = v_ts THEN
339
+ RETURN QUERY EXECUTE format(
340
+ 'SELECT c.kind_id, c.key, c.title, CAST(ts_rank_cd(c.search, $8) AS real) AS score, '
341
+ 'c.exact FROM (' || v_rows || ') AS c '
342
+ 'ORDER BY score DESC, c.kind_id, c.key LIMIT $7',
343
+ v_records, p_domain, p_branch, v_kinds, 'true', 'm.search @@ $8')
344
+ USING p_domain, p_branch, p_kinds, p_config, p_query, p_candidates, p_limit, v_ts;
345
+ RETURN;
346
+ END IF;
347
+ RETURN QUERY EXECUTE format(
348
+ 'SELECT c.kind_id, c.key, c.title, CAST(CASE WHEN c.exact '
349
+ 'THEN ts_rank_cd(c.search, $8) ELSE $10 * ts_rank_cd(c.search, $9) END AS real) '
350
+ 'AS score, c.exact FROM ((' || v_rows || ') UNION ALL ('
351
+ || replace(replace(v_rows, '%5$s', '%7$s'), '%6$s', '%8$s') || ')) AS c '
352
+ 'ORDER BY c.exact DESC, score DESC, c.kind_id, c.key LIMIT $7',
353
+ v_records, p_domain, p_branch, v_kinds, 'true', 'm.search @@ $8', 'false',
354
+ 'm.search @@ $9 AND NOT m.search @@ $8')
355
+ USING p_domain, p_branch, p_kinds, p_config, p_query, p_candidates, p_limit, v_ts, v_fix,
356
+ p_weight;
357
+ END
358
+ $fn$
359
+ """
360
+
361
+ CORRECTIONS = "lb_search_corrections(text, text, text[], regconfig, text, real, integer, integer)"
362
+ _CORRECTED_ARGUMENTS = (
363
+ "(text, text, text[], regconfig, text, integer, integer, text[], text[], real)"
364
+ )
365
+ SEARCH = (
366
+ ("lb_search_corrected", "v_records text := 'mat_records';", ""),
367
+ (
368
+ "lb_search_corrected_overlay",
369
+ "v_records text;",
370
+ "\n v_records := coalesce(\n"
371
+ " lb_overlay_rows(p_domain, p_branch, 'mat_records', NULL, false), "
372
+ "'mat_records');\n",
373
+ ),
374
+ )
375
+ """(name, declarations, statements run after the checks): the overlay variant reads the
376
+ resolved rows (the anti-join hashed, as `lb_search_overlay` since revision 0011)."""
377
+ SIGNATURES = (CORRECTIONS, *(f"{name}{_CORRECTED_ARGUMENTS}" for name, _, _ in SEARCH))
378
+ HELPERS = ("lb_lexicon_word(text)", "lb_lexeme_query(text, text[])")
379
+
380
+
381
+ def _grant(signatures: Sequence[str]) -> None:
382
+ for signature in signatures:
383
+ op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
384
+ role = _reads._application_role()
385
+ if role is not None:
386
+ quoted = op.get_bind().execute(sa.text("SELECT quote_ident(:r)"), {"r": role}).scalar()
387
+ for signature in signatures:
388
+ op.execute(f"GRANT EXECUTE ON FUNCTION {signature} TO {quoted}")
389
+
390
+
391
+ def _read_functions(search_path: str) -> None:
392
+ options = _reads._OPTIONS.format(search_path=search_path).strip()
393
+ guards = _checks._guards(search_path, ("p_candidates",))
394
+ statement = _checks._create_or_replace(LB_SEARCH_CORRECTIONS, guards)
395
+ op.execute(statement.format(options=options))
396
+ for name, sources, resolve in SEARCH:
397
+ guards = _checks._guards(search_path, ("p_candidates", "p_limit")) + resolve
398
+ template = LB_SEARCH_CORRECTED.format(name=name)
399
+ statement = _checks._create_or_replace(template, guards)
400
+ op.execute(statement.format(options=options, sources=sources))
401
+ _grant(SIGNATURES)
402
+
403
+
404
+ def _lexicon() -> None:
405
+ op.create_table(
406
+ "mat_lexicon",
407
+ sa.Column("domain_id", sa.Text(), nullable=False),
408
+ sa.Column("branch", sa.Text(), nullable=False),
409
+ sa.Column("kind_id", sa.Text(), nullable=False),
410
+ sa.Column("word", sa.Text(), nullable=False),
411
+ sa.Column("records", sa.BigInteger(), nullable=False),
412
+ sa.ForeignKeyConstraint(
413
+ ["domain_id", "branch"],
414
+ ["refs.domain_id", "refs.name"],
415
+ name=op.f("fk_mat_lexicon_domain_id_branch_refs"),
416
+ ondelete="CASCADE",
417
+ ),
418
+ sa.PrimaryKeyConstraint(
419
+ "domain_id", "branch", "kind_id", "word", name=op.f("pk_mat_lexicon")
420
+ ),
421
+ )
422
+ op.create_index(
423
+ "ix_mat_lexicon_branch_word",
424
+ "mat_lexicon",
425
+ ["domain_id", "branch", "word"],
426
+ unique=False,
427
+ postgresql_using="gin",
428
+ postgresql_ops={"word": "gin_trgm_ops"},
429
+ )
430
+ op.create_index(
431
+ "ix_mat_lexicon_spent",
432
+ "mat_lexicon",
433
+ ["domain_id", "branch"],
434
+ unique=False,
435
+ postgresql_where=sa.text("records <= 0"),
436
+ )
437
+ op.execute("ALTER TABLE mat_lexicon ENABLE ROW LEVEL SECURITY")
438
+ op.execute("ALTER TABLE mat_lexicon FORCE ROW LEVEL SECURITY")
439
+ op.execute(
440
+ "CREATE POLICY domain_isolation ON mat_lexicon "
441
+ f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
442
+ )
443
+
444
+
445
+ def upgrade() -> None:
446
+ role = _reads._application_role()
447
+ if role is not None:
448
+ _checks._refuse_writable_schemas(role)
449
+ op.execute("CREATE EXTENSION IF NOT EXISTS btree_gin")
450
+ search_path = _reads._search_path()
451
+ op.execute(LEXICON_WORD)
452
+ op.execute(LEXEME_QUERY)
453
+ _lexicon()
454
+ op.execute(LEXICON_MAINTAIN.format(search_path=search_path))
455
+ op.execute("REVOKE ALL ON FUNCTION lb_lexicon_maintain() FROM PUBLIC")
456
+ for name, event, tables in TRIGGERS:
457
+ op.execute(
458
+ f"CREATE TRIGGER {name} AFTER {event} ON mat_records REFERENCING {tables} "
459
+ "FOR EACH STATEMENT EXECUTE FUNCTION lb_lexicon_maintain()"
460
+ )
461
+ op.execute("SELECT set_config('lorebase.bypass_rls', 'on', true)")
462
+ op.execute(BACKFILL)
463
+ op.execute("SELECT set_config('lorebase.bypass_rls', '', true)")
464
+ _read_functions(search_path)
465
+ op.execute(f"DROP INDEX IF EXISTS {TITLE_INDEX.name}")
466
+
467
+
468
+ def downgrade() -> None:
469
+ for signature in reversed(SIGNATURES):
470
+ op.execute(f"DROP FUNCTION IF EXISTS {signature}")
471
+ for name, _, _ in reversed(TRIGGERS):
472
+ op.execute(f"DROP TRIGGER IF EXISTS {name} ON mat_records")
473
+ op.execute("DROP FUNCTION IF EXISTS lb_lexicon_maintain()")
474
+ op.drop_table("mat_lexicon")
475
+ for signature in reversed(HELPERS):
476
+ op.execute(f"DROP FUNCTION IF EXISTS {signature}")
477
+ _indexes._build(TITLE_INDEX)
@@ -0,0 +1,56 @@
1
+ """Vault rows name the value they hold (design 13.5).
2
+
3
+ The `vault` table of revision 0001 had no writer. A row now binds its token to the Kind,
4
+ Record key and Attribute whose value it holds, and keeps a keyed fingerprint of the value for
5
+ erasure by value (`lorebase_store.vault`). Rows are found by Record (`domain_id, kind_id,
6
+ record_key`) and by value (`domain_id, kind_id, attribute_id, fingerprint`). The new columns
7
+ are required; the table must be empty, as nothing wrote to it before this revision.
8
+
9
+ Revision ID: 0019
10
+ Revises: 0018
11
+ Create Date: 2026-10-24 10:00:00.000000
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Sequence
17
+
18
+ import sqlalchemy as sa
19
+ from alembic import op
20
+
21
+ revision: str = "0019"
22
+ down_revision: str | Sequence[str] | None = "0018"
23
+ branch_labels: str | Sequence[str] | None = None
24
+ depends_on: str | Sequence[str] | None = None
25
+
26
+
27
+ def upgrade() -> None:
28
+ for column in ("kind_id", "attribute_id", "record_key"):
29
+ op.add_column("vault", sa.Column(column, sa.Text(), nullable=False))
30
+ op.add_column("vault", sa.Column("fingerprint", sa.LargeBinary(), nullable=True))
31
+ op.create_index(
32
+ op.f("ix_vault_domain_id_kind_id_record_key"),
33
+ "vault",
34
+ ["domain_id", "kind_id", "record_key"],
35
+ )
36
+ op.create_index(
37
+ op.f("ix_vault_domain_id_kind_id_attribute_id_fingerprint"),
38
+ "vault",
39
+ ["domain_id", "kind_id", "attribute_id", "fingerprint"],
40
+ )
41
+
42
+
43
+ def downgrade() -> None:
44
+ bind = op.get_bind()
45
+ # Row-level security is forced on the owner too: count the rows of every Domain.
46
+ bind.execute(sa.text("SELECT set_config('lorebase.bypass_rls', 'on', true)"))
47
+ stored = bind.execute(sa.text("SELECT count(*) FROM vault")).scalar_one()
48
+ if stored:
49
+ raise RuntimeError(
50
+ f"{stored} vault row(s) exist, which revision 0018 cannot bind to their Records; "
51
+ "erase or delete them before downgrading"
52
+ )
53
+ op.drop_index(op.f("ix_vault_domain_id_kind_id_attribute_id_fingerprint"), table_name="vault")
54
+ op.drop_index(op.f("ix_vault_domain_id_kind_id_record_key"), table_name="vault")
55
+ for column in ("fingerprint", "record_key", "attribute_id", "kind_id"):
56
+ op.drop_column("vault", column)