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.
- lorebase_store/__init__.py +1 -0
- lorebase_store/_migrations/env.py +106 -0
- lorebase_store/_migrations/script.py.mako +27 -0
- lorebase_store/_migrations/versions/0001_initial_schema.py +1071 -0
- lorebase_store/_migrations/versions/0002_merge_attempt_details.py +74 -0
- lorebase_store/_migrations/versions/0003_webhooks.py +127 -0
- lorebase_store/_migrations/versions/0004_defer_revision_references.py +38 -0
- lorebase_store/_migrations/versions/0005_security_definer_reads.py +396 -0
- lorebase_store/_migrations/versions/0006_api_key_usage.py +48 -0
- lorebase_store/_migrations/versions/0007_document_extraction.py +154 -0
- lorebase_store/_migrations/versions/0008_definer_argument_checks.py +178 -0
- lorebase_store/_migrations/versions/0009_api_key_networks.py +33 -0
- lorebase_store/_migrations/versions/0010_overlay_branches.py +582 -0
- lorebase_store/_migrations/versions/0011_branch_scoped_search_index.py +229 -0
- lorebase_store/_migrations/versions/0012_overlay_neighborhood_lookups.py +185 -0
- lorebase_store/_migrations/versions/0013_web_sessions.py +72 -0
- lorebase_store/_migrations/versions/0014_embedding_jobs.py +70 -0
- lorebase_store/_migrations/versions/0015_append_only_audit_log.py +83 -0
- lorebase_store/_migrations/versions/0016_sealed_webhook_keys.py +55 -0
- lorebase_store/_migrations/versions/0017_background_branch_copies.py +77 -0
- lorebase_store/_migrations/versions/0018_search_lexicon.py +477 -0
- lorebase_store/_migrations/versions/0019_vault_bindings.py +56 -0
- lorebase_store/_migrations/versions/0020_outbox_consumers.py +150 -0
- lorebase_store/_migrations/versions/0021_schema_suggestions.py +114 -0
- lorebase_store/_migrations/versions/0022_read_audit_and_shared_rate_limits.py +133 -0
- lorebase_store/_migrations/versions/0023_agent_trace.py +96 -0
- lorebase_store/_migrations/versions/0024_embedding_template_keys.py +205 -0
- lorebase_store/_migrations/versions/0025_snapshot_refs.py +35 -0
- lorebase_store/_migrations/versions/0026_plain_reverse_link_index.py +112 -0
- lorebase_store/_migrations/versions/0027_vault_rotation_and_tombstones.py +145 -0
- lorebase_store/_migrations/versions/0028_operation_deferred_events.py +71 -0
- lorebase_store/_migrations/versions/0029_agent_trace_sessions.py +108 -0
- lorebase_store/_migrations/versions/0030_path_fanout.py +31 -0
- lorebase_store/_migrations/versions/0031_branch_builds.py +91 -0
- lorebase_store/_migrations/versions/0032_lineage_revisions.py +35 -0
- lorebase_store/_migrations/versions/0033_vault_moved_tokens.py +30 -0
- lorebase_store/_migrations/versions/0034_inference_endpoint_modes.py +73 -0
- lorebase_store/_migrations/versions/0035_search_truncation.py +104 -0
- lorebase_store/_migrations/versions/0036_lineage_maps.py +325 -0
- lorebase_store/_migrations/versions/0037_bm25_search.py +346 -0
- lorebase_store/auth/__init__.py +52 -0
- lorebase_store/auth/api_keys.py +161 -0
- lorebase_store/auth/audit.py +382 -0
- lorebase_store/auth/checkpoints.py +185 -0
- lorebase_store/auth/grants.py +161 -0
- lorebase_store/auth/principals.py +158 -0
- lorebase_store/auth/scope.py +70 -0
- lorebase_store/auth/sessions.py +153 -0
- lorebase_store/auth/unit.py +53 -0
- lorebase_store/auth/usage.py +70 -0
- lorebase_store/blobs.py +73 -0
- lorebase_store/bm25.py +346 -0
- lorebase_store/bulk.py +147 -0
- lorebase_store/bundle.py +1830 -0
- lorebase_store/changefeed.py +261 -0
- lorebase_store/commit/__init__.py +42 -0
- lorebase_store/commit/changes.py +67 -0
- lorebase_store/commit/errors.py +60 -0
- lorebase_store/commit/migrations.py +335 -0
- lorebase_store/commit/pipeline.py +754 -0
- lorebase_store/commit/schema.py +123 -0
- lorebase_store/db.py +58 -0
- lorebase_store/definer.py +50 -0
- lorebase_store/digests.py +99 -0
- lorebase_store/embeddings.py +444 -0
- lorebase_store/endpoint_modes.py +59 -0
- lorebase_store/extraction/__init__.py +61 -0
- lorebase_store/extraction/items.py +187 -0
- lorebase_store/extraction/materialize.py +364 -0
- lorebase_store/extraction/proposal.py +65 -0
- lorebase_store/extraction/review.py +168 -0
- lorebase_store/gc.py +970 -0
- lorebase_store/indexes.py +1033 -0
- lorebase_store/jobs.py +651 -0
- lorebase_store/keyring.py +72 -0
- lorebase_store/layout.py +335 -0
- lorebase_store/materialize/__init__.py +103 -0
- lorebase_store/materialize/apply.py +436 -0
- lorebase_store/materialize/background.py +1184 -0
- lorebase_store/materialize/branch.py +268 -0
- lorebase_store/materialize/build.py +536 -0
- lorebase_store/materialize/derived.py +540 -0
- lorebase_store/materialize/lineage_backfill.py +502 -0
- lorebase_store/materialize/links.py +266 -0
- lorebase_store/materialize/overlay.py +292 -0
- lorebase_store/materialize/paths.py +315 -0
- lorebase_store/materialize/rebuild.py +326 -0
- lorebase_store/materialize/rows.py +153 -0
- lorebase_store/materialize/sensitivity.py +66 -0
- lorebase_store/materialize/snapshot.py +205 -0
- lorebase_store/materialize/stale.py +444 -0
- lorebase_store/materialize/verify.py +321 -0
- lorebase_store/merging/__init__.py +39 -0
- lorebase_store/merging/attempts.py +334 -0
- lorebase_store/merging/lineage.py +123 -0
- lorebase_store/merging/service.py +652 -0
- lorebase_store/migrations.py +133 -0
- lorebase_store/models/__init__.py +136 -0
- lorebase_store/models/base.py +64 -0
- lorebase_store/models/enums.py +132 -0
- lorebase_store/models/governance.py +268 -0
- lorebase_store/models/ingestion.py +288 -0
- lorebase_store/models/materialized.py +277 -0
- lorebase_store/models/proposals.py +195 -0
- lorebase_store/models/queues.py +87 -0
- lorebase_store/models/tracing.py +116 -0
- lorebase_store/models/versioning.py +222 -0
- lorebase_store/models/webhooks.py +96 -0
- lorebase_store/nodestore.py +384 -0
- lorebase_store/objectstore.py +162 -0
- lorebase_store/outbox_events.py +166 -0
- lorebase_store/profiling/__init__.py +58 -0
- lorebase_store/profiling/delimited.py +109 -0
- lorebase_store/profiling/postgres.py +211 -0
- lorebase_store/profiling/record_files.py +127 -0
- lorebase_store/profiling/records.py +92 -0
- lorebase_store/profiling/sql_databases.py +298 -0
- lorebase_store/ratelimit.py +129 -0
- lorebase_store/reads.py +1491 -0
- lorebase_store/replicas.py +434 -0
- lorebase_store/repositories/__init__.py +152 -0
- lorebase_store/repositories/changesets.py +59 -0
- lorebase_store/repositories/deferred_events.py +142 -0
- lorebase_store/repositories/documents.py +305 -0
- lorebase_store/repositories/domains.py +97 -0
- lorebase_store/repositories/events.py +205 -0
- lorebase_store/repositories/index_view.py +489 -0
- lorebase_store/repositories/invocations.py +68 -0
- lorebase_store/repositories/materializations.py +276 -0
- lorebase_store/repositories/materialized.py +1129 -0
- lorebase_store/repositories/merges.py +417 -0
- lorebase_store/repositories/proposal_items.py +199 -0
- lorebase_store/repositories/proposals.py +184 -0
- lorebase_store/repositories/provenance.py +77 -0
- lorebase_store/repositories/refs.py +452 -0
- lorebase_store/repositories/revisions.py +273 -0
- lorebase_store/repositories/scope.py +33 -0
- lorebase_store/repositories/source_runs.py +123 -0
- lorebase_store/repositories/suggestions.py +638 -0
- lorebase_store/runtime.py +87 -0
- lorebase_store/schema_cache.py +50 -0
- lorebase_store/settings.py +879 -0
- lorebase_store/storage.py +1478 -0
- lorebase_store/telemetry.py +142 -0
- lorebase_store/testing.py +108 -0
- lorebase_store/tracing.py +505 -0
- lorebase_store/traverse.py +578 -0
- lorebase_store/vault/__init__.py +50 -0
- lorebase_store/vault/keys.py +216 -0
- lorebase_store/vault/quotes.py +133 -0
- lorebase_store/vault/rotation.py +209 -0
- lorebase_store/vault/settings.py +152 -0
- lorebase_store/vault/store.py +987 -0
- lorebase_store/vault/transfer.py +558 -0
- lorebase_store/webhooks/__init__.py +24 -0
- lorebase_store/webhooks/delivery.py +637 -0
- lorebase_store/webhooks/keys.py +126 -0
- lorebase_store/webhooks/repository.py +142 -0
- lorebase_store/webhooks/settings.py +138 -0
- lorebase_store/webhooks/signing.py +50 -0
- lorebase_store-0.2.3.dist-info/METADATA +13 -0
- lorebase_store-0.2.3.dist-info/RECORD +163 -0
- 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)
|