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,74 @@
|
|
|
1
|
+
"""Proposal and merge attempt details (design 6.10, 7.3).
|
|
2
|
+
|
|
3
|
+
- `proposals`: `idempotency_key` (unique per Domain and author when set) and `updated_at`;
|
|
4
|
+
- `merge_attempts`: `base` becomes nullable (no common ancestor, or a virtual base), plus
|
|
5
|
+
`virtual_base`, `result`, `policy`, `created_by`, `outcome`, `revision` and
|
|
6
|
+
`idempotency_key`.
|
|
7
|
+
|
|
8
|
+
Revision ID: 0002
|
|
9
|
+
Revises: 0001
|
|
10
|
+
Create Date: 2026-10-21 09:00:00.000000
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
import sqlalchemy as sa
|
|
18
|
+
from alembic import op
|
|
19
|
+
from sqlalchemy.dialects import postgresql
|
|
20
|
+
|
|
21
|
+
revision: str = "0002"
|
|
22
|
+
down_revision: str | Sequence[str] | None = "0001"
|
|
23
|
+
branch_labels: str | Sequence[str] | None = None
|
|
24
|
+
depends_on: str | Sequence[str] | None = None
|
|
25
|
+
|
|
26
|
+
_ATTEMPT_COLUMNS = (
|
|
27
|
+
"virtual_base",
|
|
28
|
+
"result",
|
|
29
|
+
"policy",
|
|
30
|
+
"created_by",
|
|
31
|
+
"outcome",
|
|
32
|
+
"revision",
|
|
33
|
+
"idempotency_key",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def upgrade() -> None:
|
|
38
|
+
op.add_column("proposals", sa.Column("idempotency_key", sa.Text(), nullable=True))
|
|
39
|
+
op.add_column("proposals", sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True))
|
|
40
|
+
op.create_index(
|
|
41
|
+
"uq_proposals_idempotency",
|
|
42
|
+
"proposals",
|
|
43
|
+
["domain_id", "author", "idempotency_key"],
|
|
44
|
+
unique=True,
|
|
45
|
+
postgresql_where=sa.text("idempotency_key IS NOT NULL"),
|
|
46
|
+
)
|
|
47
|
+
op.alter_column("merge_attempts", "base", existing_type=sa.LargeBinary(), nullable=True)
|
|
48
|
+
op.add_column(
|
|
49
|
+
"merge_attempts",
|
|
50
|
+
sa.Column("virtual_base", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
|
51
|
+
)
|
|
52
|
+
op.add_column("merge_attempts", sa.Column("result", sa.Text(), nullable=True))
|
|
53
|
+
op.add_column(
|
|
54
|
+
"merge_attempts",
|
|
55
|
+
sa.Column("policy", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
56
|
+
)
|
|
57
|
+
op.add_column("merge_attempts", sa.Column("created_by", sa.Text(), nullable=True))
|
|
58
|
+
op.add_column(
|
|
59
|
+
"merge_attempts",
|
|
60
|
+
sa.Column("outcome", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
61
|
+
)
|
|
62
|
+
op.add_column("merge_attempts", sa.Column("revision", sa.LargeBinary(), nullable=True))
|
|
63
|
+
op.add_column("merge_attempts", sa.Column("idempotency_key", sa.Text(), nullable=True))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def downgrade() -> None:
|
|
67
|
+
for column in reversed(_ATTEMPT_COLUMNS):
|
|
68
|
+
op.drop_column("merge_attempts", column)
|
|
69
|
+
op.execute("SET LOCAL lorebase.bypass_rls = 'on'")
|
|
70
|
+
op.execute("DELETE FROM merge_attempts WHERE base IS NULL")
|
|
71
|
+
op.alter_column("merge_attempts", "base", existing_type=sa.LargeBinary(), nullable=False)
|
|
72
|
+
op.drop_index("uq_proposals_idempotency", table_name="proposals")
|
|
73
|
+
op.drop_column("proposals", "updated_at")
|
|
74
|
+
op.drop_column("proposals", "idempotency_key")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Webhooks and their deliveries (design 10.5).
|
|
2
|
+
|
|
3
|
+
Both tables are Domain-scoped: row-level security on `domain_id` like every such table of
|
|
4
|
+
0001. The delivery worker reads across Domains with `lorebase.bypass_rls`.
|
|
5
|
+
|
|
6
|
+
Revision ID: 0003
|
|
7
|
+
Revises: 0002
|
|
8
|
+
Create Date: 2026-10-21 09:00:00.000000
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
import sqlalchemy as sa
|
|
16
|
+
from alembic import op
|
|
17
|
+
from sqlalchemy.dialects import postgresql
|
|
18
|
+
|
|
19
|
+
revision: str = "0003"
|
|
20
|
+
down_revision: str | Sequence[str] | None = "0002"
|
|
21
|
+
branch_labels: str | Sequence[str] | None = None
|
|
22
|
+
depends_on: str | Sequence[str] | None = None
|
|
23
|
+
|
|
24
|
+
DOMAIN_SCOPED = ("webhooks", "webhook_deliveries")
|
|
25
|
+
DOMAIN_VISIBLE = (
|
|
26
|
+
"current_setting('lorebase.bypass_rls', true) = 'on' "
|
|
27
|
+
"OR domain_id = ANY (string_to_array(current_setting('lorebase.domain_ids', true), ','))"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def upgrade() -> None:
|
|
32
|
+
op.create_table(
|
|
33
|
+
"webhooks",
|
|
34
|
+
sa.Column("domain_id", sa.Text(), nullable=False),
|
|
35
|
+
sa.Column("id", sa.Text(), nullable=False),
|
|
36
|
+
sa.Column("url", sa.Text(), nullable=False),
|
|
37
|
+
sa.Column("description", sa.Text(), nullable=True),
|
|
38
|
+
sa.Column("event_types", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
39
|
+
sa.Column("branches", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
|
40
|
+
sa.Column("secret_hash", sa.LargeBinary(), nullable=False),
|
|
41
|
+
sa.Column("created_by", sa.Text(), nullable=False),
|
|
42
|
+
sa.Column(
|
|
43
|
+
"created_at",
|
|
44
|
+
sa.DateTime(timezone=True),
|
|
45
|
+
server_default=sa.text("now()"),
|
|
46
|
+
nullable=False,
|
|
47
|
+
),
|
|
48
|
+
sa.CheckConstraint(
|
|
49
|
+
"octet_length(secret_hash) = 32", name=op.f("ck_webhooks_secret_hash_h32")
|
|
50
|
+
),
|
|
51
|
+
sa.ForeignKeyConstraint(
|
|
52
|
+
["domain_id"],
|
|
53
|
+
["domains.id"],
|
|
54
|
+
name=op.f("fk_webhooks_domain_id_domains"),
|
|
55
|
+
ondelete="CASCADE",
|
|
56
|
+
),
|
|
57
|
+
sa.PrimaryKeyConstraint("domain_id", "id", name=op.f("pk_webhooks")),
|
|
58
|
+
)
|
|
59
|
+
op.create_table(
|
|
60
|
+
"webhook_deliveries",
|
|
61
|
+
sa.Column("domain_id", sa.Text(), nullable=False),
|
|
62
|
+
sa.Column("id", sa.BigInteger(), sa.Identity(always=True), nullable=False),
|
|
63
|
+
sa.Column("webhook_id", sa.Text(), nullable=False),
|
|
64
|
+
sa.Column("event_id", sa.BigInteger(), nullable=False),
|
|
65
|
+
sa.Column("event_type", sa.Text(), nullable=False),
|
|
66
|
+
sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
|
67
|
+
sa.Column("status", sa.Text(), server_default=sa.text("'pending'"), nullable=False),
|
|
68
|
+
sa.Column("attempts", sa.Integer(), server_default=sa.text("0"), nullable=False),
|
|
69
|
+
sa.Column(
|
|
70
|
+
"next_attempt_at",
|
|
71
|
+
sa.DateTime(timezone=True),
|
|
72
|
+
server_default=sa.text("now()"),
|
|
73
|
+
nullable=False,
|
|
74
|
+
),
|
|
75
|
+
sa.Column("last_status", sa.Integer(), nullable=True),
|
|
76
|
+
sa.Column("last_error", sa.Text(), nullable=True),
|
|
77
|
+
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
|
|
78
|
+
sa.Column(
|
|
79
|
+
"created_at",
|
|
80
|
+
sa.DateTime(timezone=True),
|
|
81
|
+
server_default=sa.text("now()"),
|
|
82
|
+
nullable=False,
|
|
83
|
+
),
|
|
84
|
+
sa.Column(
|
|
85
|
+
"updated_at",
|
|
86
|
+
sa.DateTime(timezone=True),
|
|
87
|
+
server_default=sa.text("now()"),
|
|
88
|
+
nullable=False,
|
|
89
|
+
),
|
|
90
|
+
sa.CheckConstraint(
|
|
91
|
+
"status IN ('pending', 'delivered', 'dead')",
|
|
92
|
+
name=op.f("ck_webhook_deliveries_status"),
|
|
93
|
+
),
|
|
94
|
+
sa.ForeignKeyConstraint(
|
|
95
|
+
["domain_id", "webhook_id"],
|
|
96
|
+
["webhooks.domain_id", "webhooks.id"],
|
|
97
|
+
name=op.f("fk_webhook_deliveries_domain_id_webhook_id_webhooks"),
|
|
98
|
+
ondelete="CASCADE",
|
|
99
|
+
),
|
|
100
|
+
sa.PrimaryKeyConstraint("domain_id", "id", name=op.f("pk_webhook_deliveries")),
|
|
101
|
+
sa.UniqueConstraint(
|
|
102
|
+
"domain_id",
|
|
103
|
+
"webhook_id",
|
|
104
|
+
"event_id",
|
|
105
|
+
name=op.f("uq_webhook_deliveries_domain_id_webhook_id_event_id"),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
op.create_index(
|
|
109
|
+
"ix_webhook_deliveries_due",
|
|
110
|
+
"webhook_deliveries",
|
|
111
|
+
["next_attempt_at"],
|
|
112
|
+
unique=False,
|
|
113
|
+
postgresql_where=sa.text("status = 'pending'"),
|
|
114
|
+
)
|
|
115
|
+
for table in DOMAIN_SCOPED:
|
|
116
|
+
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
|
117
|
+
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
|
118
|
+
op.execute(
|
|
119
|
+
f"CREATE POLICY domain_isolation ON {table} "
|
|
120
|
+
f"USING ({DOMAIN_VISIBLE}) WITH CHECK ({DOMAIN_VISIBLE})"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def downgrade() -> None:
|
|
125
|
+
# Indexes and policies go with their tables.
|
|
126
|
+
op.drop_table("webhook_deliveries")
|
|
127
|
+
op.drop_table("webhooks")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Check the foreign keys that point at `revisions` at commit time.
|
|
2
|
+
|
|
3
|
+
Deleting a Domain cascades to its Revisions, parent edges and refs in one statement, in no
|
|
4
|
+
fixed order. `revision_parents.parent_id` and `refs.head` reference `revisions` without a
|
|
5
|
+
cascade (a Revision still referenced must not be deleted on its own), so an immediate check
|
|
6
|
+
could see an edge or a ref that the same cascade removes a moment later. Deferring the check to
|
|
7
|
+
the end of the transaction keeps the protection and makes the cascade order irrelevant.
|
|
8
|
+
|
|
9
|
+
Revision ID: 0004
|
|
10
|
+
Revises: 0003
|
|
11
|
+
Create Date: 2026-10-21 12:00:00.000000
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
|
|
18
|
+
from alembic import op
|
|
19
|
+
|
|
20
|
+
revision: str = "0004"
|
|
21
|
+
down_revision: str | Sequence[str] | None = "0003"
|
|
22
|
+
branch_labels: str | Sequence[str] | None = None
|
|
23
|
+
depends_on: str | Sequence[str] | None = None
|
|
24
|
+
|
|
25
|
+
_CONSTRAINTS = (
|
|
26
|
+
("revision_parents", "fk_revision_parents_domain_id_parent_id_revisions"),
|
|
27
|
+
("refs", "fk_refs_domain_id_head_revisions"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def upgrade() -> None:
|
|
32
|
+
for table, name in _CONSTRAINTS:
|
|
33
|
+
op.execute(f"ALTER TABLE {table} ALTER CONSTRAINT {name} DEFERRABLE INITIALLY DEFERRED")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def downgrade() -> None:
|
|
37
|
+
for table, name in _CONSTRAINTS:
|
|
38
|
+
op.execute(f"ALTER TABLE {table} ALTER CONSTRAINT {name} NOT DEFERRABLE")
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Security definer read functions (design 8.7, store schema notes).
|
|
2
|
+
|
|
3
|
+
Under row-level security Postgres evaluates the `domain_isolation` policy before any condition
|
|
4
|
+
built from functions it does not mark leakproof (`->>`, casts, `@@`, `%`), so those conditions
|
|
5
|
+
cannot drive an index scan. The four read paths that depend on them get a function each that
|
|
6
|
+
runs as its owner (the migration role) and pins the Domain it is given in every predicate:
|
|
7
|
+
|
|
8
|
+
- `lb_search`: full-text search over a bounded candidate set, with optional trigram matching
|
|
9
|
+
of titles;
|
|
10
|
+
- `lb_links_in`: Links pointing at one Record along one Relation, stored or foreign-key backed;
|
|
11
|
+
- `lb_neighborhood`: the recursive neighborhood walk of `lorebase_store.traverse`;
|
|
12
|
+
- `lb_query_by_attribute`: comparisons served by the per-Attribute partial indexes.
|
|
13
|
+
|
|
14
|
+
Each function is `STABLE`, `SECURITY DEFINER`, has a fixed `search_path` and runs without
|
|
15
|
+
JIT compilation. Statements are
|
|
16
|
+
built with `format()` (`%L` literals, whitelisted keywords) and run with `EXECUTE ... USING`,
|
|
17
|
+
so every call is planned with its Domain, branch and Kind literals and matches the partial
|
|
18
|
+
index predicates. The functions never read `lorebase.domain_ids`: the caller (a store
|
|
19
|
+
primitive) passes the Domain it already authorized. EXECUTE is revoked from PUBLIC and granted
|
|
20
|
+
to `database.application_role` when set.
|
|
21
|
+
|
|
22
|
+
The functions skip row-level security only when their owner is exempt from it (a superuser,
|
|
23
|
+
or a role with BYPASSRLS); otherwise they return the same rows with the policy applied.
|
|
24
|
+
|
|
25
|
+
Revision ID: 0005
|
|
26
|
+
Revises: 0004
|
|
27
|
+
Create Date: 2026-10-21 12:00:00.000000
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Sequence
|
|
34
|
+
|
|
35
|
+
import sqlalchemy as sa
|
|
36
|
+
from alembic import op
|
|
37
|
+
|
|
38
|
+
revision: str = "0005"
|
|
39
|
+
down_revision: str | Sequence[str] | None = "0004"
|
|
40
|
+
branch_labels: str | Sequence[str] | None = None
|
|
41
|
+
depends_on: str | Sequence[str] | None = None
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
SIGNATURES = (
|
|
46
|
+
"lb_search(text, text, text[], regconfig, text, integer, integer, real, real)",
|
|
47
|
+
"lb_links_in(text, text, text, text, integer, text, text, text)",
|
|
48
|
+
"lb_neighborhood(text, text, jsonb, integer, jsonb, integer, integer, integer)",
|
|
49
|
+
"lb_query_by_attribute(text, text, text, text, text, text, text, integer, text, text)",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
_OPTIONS = """
|
|
53
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
54
|
+
SET search_path = {search_path}
|
|
55
|
+
SET jit = off
|
|
56
|
+
"""
|
|
57
|
+
"""JIT compilation costs more than these short reads (tens of milliseconds for the recursive
|
|
58
|
+
neighborhood statement, whose row estimates are inflated by `jsonb_array_elements`)."""
|
|
59
|
+
|
|
60
|
+
_RECORD_COLUMNS = (
|
|
61
|
+
'kind_id text, key text, "values" jsonb, title text, object_hash bytea, lineage jsonb, '
|
|
62
|
+
"updated_revision bytea, updated_at timestamptz"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
LB_SEARCH = """
|
|
66
|
+
CREATE FUNCTION lb_search(
|
|
67
|
+
p_domain text, p_branch text, p_kinds text[], p_config regconfig, p_query text,
|
|
68
|
+
p_candidates integer, p_limit integer,
|
|
69
|
+
p_trigram_threshold real DEFAULT NULL, p_trigram_weight real DEFAULT 0)
|
|
70
|
+
RETURNS TABLE (kind_id text, key text, title text, score real)
|
|
71
|
+
{options}
|
|
72
|
+
SET pg_trgm.similarity_threshold = '0.3'
|
|
73
|
+
AS $fn$
|
|
74
|
+
DECLARE
|
|
75
|
+
v_match text := 'm.search @@ q.ts';
|
|
76
|
+
v_score text := 'ts_rank_cd(c.search, q.ts)';
|
|
77
|
+
v_kinds text := '';
|
|
78
|
+
BEGIN
|
|
79
|
+
IF p_candidates < 1 OR p_limit < 1 THEN
|
|
80
|
+
RAISE EXCEPTION 'candidates and limit must be positive' USING ERRCODE = '22023';
|
|
81
|
+
END IF;
|
|
82
|
+
IF p_kinds IS NOT NULL THEN
|
|
83
|
+
v_kinds := ' AND m.kind_id = ANY ($3)';
|
|
84
|
+
END IF;
|
|
85
|
+
IF p_trigram_threshold IS NOT NULL THEN
|
|
86
|
+
-- Local to this call: the SET clause above restores the setting on exit.
|
|
87
|
+
PERFORM set_config('pg_trgm.similarity_threshold', p_trigram_threshold::text, true);
|
|
88
|
+
v_match := '(m.search @@ q.ts OR m.title % $5)';
|
|
89
|
+
v_score := v_score || ' + $9 * similarity(coalesce(c.title, ''''), $5)';
|
|
90
|
+
END IF;
|
|
91
|
+
RETURN QUERY EXECUTE format(
|
|
92
|
+
'SELECT c.kind_id, c.key, c.title, CAST(%s AS real) AS score '
|
|
93
|
+
'FROM (SELECT m.kind_id, m.key, m.title, m.search '
|
|
94
|
+
' FROM mat_records m, (SELECT websearch_to_tsquery($4, $5) AS ts) q '
|
|
95
|
+
' WHERE m.domain_id = %L AND m.branch = %L%s AND %s LIMIT $6) AS c, '
|
|
96
|
+
' (SELECT websearch_to_tsquery($4, $5) AS ts) q '
|
|
97
|
+
'ORDER BY score DESC, c.kind_id, c.key LIMIT $7',
|
|
98
|
+
v_score, p_domain, p_branch, v_kinds, v_match)
|
|
99
|
+
USING p_domain, p_branch, p_kinds, p_config, p_query, p_candidates, p_limit,
|
|
100
|
+
p_trigram_threshold, p_trigram_weight;
|
|
101
|
+
END
|
|
102
|
+
$fn$
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
LB_LINKS_IN = """
|
|
106
|
+
CREATE FUNCTION lb_links_in(
|
|
107
|
+
p_domain text, p_branch text, p_relation text, p_to_key text, p_limit integer,
|
|
108
|
+
p_after text DEFAULT NULL, p_from_kind text DEFAULT NULL, p_attribute text DEFAULT NULL)
|
|
109
|
+
RETURNS TABLE (relation_id text, from_key text, to_key text, "values" jsonb,
|
|
110
|
+
object_hash bytea)
|
|
111
|
+
{options}
|
|
112
|
+
AS $fn$
|
|
113
|
+
DECLARE
|
|
114
|
+
v_after text := '';
|
|
115
|
+
BEGIN
|
|
116
|
+
IF p_limit < 1 THEN
|
|
117
|
+
RAISE EXCEPTION 'limit must be positive' USING ERRCODE = '22023';
|
|
118
|
+
END IF;
|
|
119
|
+
IF (p_from_kind IS NULL) <> (p_attribute IS NULL) THEN
|
|
120
|
+
RAISE EXCEPTION 'a foreign-key Relation needs both its from Kind and its Attribute'
|
|
121
|
+
USING ERRCODE = '22023';
|
|
122
|
+
END IF;
|
|
123
|
+
IF p_attribute IS NULL THEN
|
|
124
|
+
IF p_after IS NOT NULL THEN
|
|
125
|
+
v_after := ' AND l.from_key > $5';
|
|
126
|
+
END IF;
|
|
127
|
+
RETURN QUERY EXECUTE format(
|
|
128
|
+
'SELECT l.relation_id, l.from_key, l.to_key, l."values", l.object_hash '
|
|
129
|
+
'FROM mat_links l WHERE l.domain_id = %L AND l.branch = %L '
|
|
130
|
+
'AND l.relation_id = $3 AND l.to_key = $4%s ORDER BY l.from_key LIMIT $6',
|
|
131
|
+
p_domain, p_branch, v_after)
|
|
132
|
+
USING p_domain, p_branch, p_relation, p_to_key, p_after, p_limit;
|
|
133
|
+
ELSE
|
|
134
|
+
IF p_after IS NOT NULL THEN
|
|
135
|
+
v_after := ' AND m.key > $5';
|
|
136
|
+
END IF;
|
|
137
|
+
-- Domain, branch, Kind and Attribute are literals: they match the partial index.
|
|
138
|
+
RETURN QUERY EXECUTE format(
|
|
139
|
+
'SELECT CAST(%L AS text), m.key, CAST($4 AS text), CAST(''{{}}'' AS jsonb), '
|
|
140
|
+
'CAST(NULL AS bytea) FROM mat_records m '
|
|
141
|
+
'WHERE m.domain_id = %L AND m.branch = %L AND m.kind_id = %L '
|
|
142
|
+
'AND (m."values" ->> %L) = $4%s ORDER BY m.key LIMIT $6',
|
|
143
|
+
p_relation, p_domain, p_branch, p_from_kind, p_attribute, v_after)
|
|
144
|
+
USING p_domain, p_branch, p_relation, p_to_key, p_after, p_limit;
|
|
145
|
+
END IF;
|
|
146
|
+
END
|
|
147
|
+
$fn$
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
LB_QUERY_BY_ATTRIBUTE = """
|
|
151
|
+
CREATE FUNCTION lb_query_by_attribute(
|
|
152
|
+
p_domain text, p_branch text, p_kind text, p_attribute text, p_type text, p_op text,
|
|
153
|
+
p_value text, p_limit integer, p_after_value text DEFAULT NULL,
|
|
154
|
+
p_after_key text DEFAULT NULL)
|
|
155
|
+
RETURNS TABLE ({record_columns})
|
|
156
|
+
{options}
|
|
157
|
+
AS $fn$
|
|
158
|
+
DECLARE
|
|
159
|
+
v_raw text := format('(m."values" ->> %L)', p_attribute);
|
|
160
|
+
v_expression text;
|
|
161
|
+
v_value text;
|
|
162
|
+
v_op text;
|
|
163
|
+
v_sql text;
|
|
164
|
+
BEGIN
|
|
165
|
+
IF p_limit < 1 THEN
|
|
166
|
+
RAISE EXCEPTION 'limit must be positive' USING ERRCODE = '22023';
|
|
167
|
+
END IF;
|
|
168
|
+
v_op := CASE p_op WHEN 'eq' THEN '=' WHEN 'lt' THEN '<' WHEN 'lte' THEN '<='
|
|
169
|
+
WHEN 'gt' THEN '>' WHEN 'gte' THEN '>=' END;
|
|
170
|
+
IF v_op IS NULL THEN
|
|
171
|
+
RAISE EXCEPTION 'unknown comparison %', p_op USING ERRCODE = '22023';
|
|
172
|
+
END IF;
|
|
173
|
+
-- The expressions repeat lorebase_store.indexes.IndexSpec.expression and value_sql.
|
|
174
|
+
IF p_type = 'text' THEN
|
|
175
|
+
v_expression := v_raw;
|
|
176
|
+
v_value := 'CAST(%s AS text)';
|
|
177
|
+
ELSIF p_type = 'md5' THEN
|
|
178
|
+
v_expression := 'md5' || v_raw;
|
|
179
|
+
v_value := 'md5(CAST(%s AS text))';
|
|
180
|
+
ELSIF p_type IN ('bigint', 'numeric', 'double precision', 'boolean') THEN
|
|
181
|
+
v_expression := format('(%s::%s)', v_raw, p_type);
|
|
182
|
+
v_value := 'CAST(CAST(%s AS text) AS ' || p_type || ')';
|
|
183
|
+
ELSE
|
|
184
|
+
RAISE EXCEPTION 'unknown index type %', p_type USING ERRCODE = '22023';
|
|
185
|
+
END IF;
|
|
186
|
+
v_sql := format(
|
|
187
|
+
'SELECT m.kind_id, m.key, m."values", m.title, m.object_hash, m.lineage, '
|
|
188
|
+
'm.updated_revision, m.updated_at FROM mat_records m '
|
|
189
|
+
'WHERE m.domain_id = %L AND m.branch = %L AND m.kind_id = %L AND %s %s %s',
|
|
190
|
+
p_domain, p_branch, p_kind, v_expression, v_op, format(v_value, '$1'));
|
|
191
|
+
IF p_op = 'eq' THEN
|
|
192
|
+
IF p_after_key IS NOT NULL THEN
|
|
193
|
+
v_sql := v_sql || ' AND m.key > $3';
|
|
194
|
+
END IF;
|
|
195
|
+
v_sql := v_sql || ' ORDER BY m.key LIMIT $4';
|
|
196
|
+
ELSE
|
|
197
|
+
IF p_after_key IS NOT NULL THEN
|
|
198
|
+
v_sql := v_sql || format(
|
|
199
|
+
' AND (%s, m.key) > (%s, $3)', v_expression, format(v_value, '$2'));
|
|
200
|
+
END IF;
|
|
201
|
+
v_sql := v_sql || format(' ORDER BY %s, m.key LIMIT $4', v_expression);
|
|
202
|
+
END IF;
|
|
203
|
+
RETURN QUERY EXECUTE v_sql USING p_value, p_after_value, p_after_key, p_limit;
|
|
204
|
+
END
|
|
205
|
+
$fn$
|
|
206
|
+
"""
|
|
207
|
+
|
|
208
|
+
LB_NEIGHBORHOOD = """
|
|
209
|
+
CREATE FUNCTION lb_neighborhood(
|
|
210
|
+
p_domain text, p_branch text, p_seeds jsonb, p_depth integer, p_relations jsonb,
|
|
211
|
+
p_fanout integer, p_max_steps integer, p_max_nodes integer)
|
|
212
|
+
RETURNS TABLE (item text, depth integer, {record_columns}, relation_id text, from_key text,
|
|
213
|
+
to_key text, link_values jsonb, link_hash bytea)
|
|
214
|
+
{options}
|
|
215
|
+
AS $fn$
|
|
216
|
+
DECLARE
|
|
217
|
+
v_parts text[] := ARRAY[]::text[];
|
|
218
|
+
v_relation jsonb;
|
|
219
|
+
v_step text;
|
|
220
|
+
v_links text := format(
|
|
221
|
+
'JOIN mat_links l ON l.domain_id = %L AND l.branch = %L '
|
|
222
|
+
'AND l.relation_id = r.relation_id', p_domain, p_branch);
|
|
223
|
+
v_nulls text := 'CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS text), '
|
|
224
|
+
'CAST(NULL AS text), CAST(NULL AS text), CAST(NULL AS jsonb), '
|
|
225
|
+
'CAST(NULL AS bytea)';
|
|
226
|
+
BEGIN
|
|
227
|
+
IF p_depth < 1 OR p_fanout < 1 OR p_max_steps < 1 OR p_max_nodes < 1 THEN
|
|
228
|
+
RAISE EXCEPTION 'depth and caps must be positive' USING ERRCODE = '22023';
|
|
229
|
+
END IF;
|
|
230
|
+
IF jsonb_typeof(p_seeds) <> 'array' OR jsonb_typeof(p_relations) <> 'object' THEN
|
|
231
|
+
RAISE EXCEPTION 'seeds must be an array and relations an object'
|
|
232
|
+
USING ERRCODE = '22023';
|
|
233
|
+
END IF;
|
|
234
|
+
-- One lateral source per kind of hop, as in lorebase_store.traverse.
|
|
235
|
+
IF jsonb_array_length(coalesce(p_relations -> 'out', '[]')) > 0 THEN
|
|
236
|
+
v_parts := v_parts || (
|
|
237
|
+
'SELECT r.to_kind, l.to_key, l.relation_id, r.from_kind, l.from_key, r.to_kind, '
|
|
238
|
+
'l.to_key, l."values", l.object_hash FROM out_rels r ' || v_links ||
|
|
239
|
+
' AND l.from_key = w.key WHERE r.from_kind = w.kind_id LIMIT $3');
|
|
240
|
+
END IF;
|
|
241
|
+
IF jsonb_array_length(coalesce(p_relations -> 'in', '[]')) > 0 THEN
|
|
242
|
+
v_parts := v_parts || (
|
|
243
|
+
'SELECT r.from_kind, l.from_key, l.relation_id, r.from_kind, l.from_key, '
|
|
244
|
+
'r.to_kind, l.to_key, l."values", l.object_hash FROM in_rels r ' || v_links ||
|
|
245
|
+
' AND l.to_key = w.key WHERE r.to_kind = w.kind_id LIMIT $3');
|
|
246
|
+
END IF;
|
|
247
|
+
IF jsonb_array_length(coalesce(p_relations -> 'fk_out', '[]')) > 0 THEN
|
|
248
|
+
v_parts := v_parts || format(
|
|
249
|
+
'SELECT f.to_kind, m."values" ->> f.attribute, f.relation_id, f.from_kind, '
|
|
250
|
+
'm.key, f.to_kind, m."values" ->> f.attribute, CAST(''{{}}'' AS jsonb), '
|
|
251
|
+
'CAST(NULL AS bytea) FROM fk_rels f JOIN mat_records m ON m.domain_id = %L '
|
|
252
|
+
'AND m.branch = %L AND m.kind_id = w.kind_id AND m.key = w.key '
|
|
253
|
+
'WHERE f.from_kind = w.kind_id AND m."values" ->> f.attribute IS NOT NULL '
|
|
254
|
+
'LIMIT $3', p_domain, p_branch);
|
|
255
|
+
END IF;
|
|
256
|
+
FOR v_relation IN
|
|
257
|
+
SELECT e FROM jsonb_array_elements(coalesce(p_relations -> 'fk_in', '[]')) AS e
|
|
258
|
+
LOOP
|
|
259
|
+
IF (v_relation ->> 0) IS NULL OR (v_relation ->> 1) IS NULL
|
|
260
|
+
OR (v_relation ->> 2) IS NULL OR (v_relation ->> 3) IS NULL THEN
|
|
261
|
+
RAISE EXCEPTION 'a foreign-key Relation is [id, from Kind, to Kind, Attribute]'
|
|
262
|
+
USING ERRCODE = '22023';
|
|
263
|
+
END IF;
|
|
264
|
+
-- Literal predicate and expression: the per-Attribute index of the from Kind.
|
|
265
|
+
v_parts := v_parts || format(
|
|
266
|
+
'SELECT CAST(%1$L AS text), key, CAST(%2$L AS text), CAST(%1$L AS text), key, '
|
|
267
|
+
'CAST(%3$L AS text), w.key, CAST(''{{}}'' AS jsonb), CAST(NULL AS bytea) '
|
|
268
|
+
'FROM mat_records WHERE w.kind_id = %3$L AND domain_id = %4$L '
|
|
269
|
+
'AND branch = %5$L AND kind_id = %1$L AND ("values" ->> %6$L) = w.key LIMIT $3',
|
|
270
|
+
v_relation ->> 1, v_relation ->> 0, v_relation ->> 2, p_domain, p_branch,
|
|
271
|
+
v_relation ->> 3);
|
|
272
|
+
END LOOP;
|
|
273
|
+
IF cardinality(v_parts) = 0 THEN
|
|
274
|
+
v_step := 'SELECT w.depth + 1, w.kind_id, w.key, w.kind_id, w.key, ' || v_nulls
|
|
275
|
+
|| ' FROM walk w WHERE false';
|
|
276
|
+
ELSE
|
|
277
|
+
v_step := 'SELECT w.depth + 1, w.kind_id, w.key, n.* FROM walk w '
|
|
278
|
+
'CROSS JOIN LATERAL (SELECT * FROM ('
|
|
279
|
+
|| (SELECT string_agg('(' || p.part || ')', ' UNION ALL ' ORDER BY p.n)
|
|
280
|
+
FROM unnest(v_parts) WITH ORDINALITY AS p(part, n))
|
|
281
|
+
|| ') AS hop LIMIT $3) AS n WHERE w.depth < $2';
|
|
282
|
+
END IF;
|
|
283
|
+
RETURN QUERY EXECUTE format($sql$
|
|
284
|
+
WITH RECURSIVE
|
|
285
|
+
out_rels(relation_id, from_kind, to_kind) AS (
|
|
286
|
+
SELECT e ->> 0, e ->> 1, e ->> 2 FROM jsonb_array_elements($4 -> 'out') AS e),
|
|
287
|
+
in_rels(relation_id, from_kind, to_kind) AS (
|
|
288
|
+
SELECT e ->> 0, e ->> 1, e ->> 2 FROM jsonb_array_elements($4 -> 'in') AS e),
|
|
289
|
+
fk_rels(relation_id, from_kind, to_kind, attribute) AS (
|
|
290
|
+
SELECT e ->> 0, e ->> 1, e ->> 2, e ->> 3
|
|
291
|
+
FROM jsonb_array_elements($4 -> 'fk_out') AS e),
|
|
292
|
+
walk(depth, via_kind, via_key, kind_id, key, relation_id, from_kind, from_key, to_kind,
|
|
293
|
+
to_key, link_values, link_hash) AS (
|
|
294
|
+
SELECT 0, CAST(NULL AS text), CAST(NULL AS text), s ->> 0, s ->> 1, %1$s
|
|
295
|
+
FROM jsonb_array_elements($1) AS s
|
|
296
|
+
UNION ALL
|
|
297
|
+
%2$s
|
|
298
|
+
) CYCLE kind_id, key SET is_cycle USING path,
|
|
299
|
+
walked AS MATERIALIZED (
|
|
300
|
+
SELECT depth, via_kind, via_key, kind_id, key, relation_id, from_kind, from_key,
|
|
301
|
+
to_kind, to_key, link_values, link_hash
|
|
302
|
+
FROM walk LIMIT $5),
|
|
303
|
+
nodes AS MATERIALIZED (
|
|
304
|
+
SELECT kind_id, key, min(depth) AS depth FROM walked GROUP BY kind_id, key
|
|
305
|
+
ORDER BY min(depth), kind_id, key LIMIT $6 + 1),
|
|
306
|
+
links AS (
|
|
307
|
+
SELECT DISTINCT ON (relation_id, from_key, to_key) relation_id, from_key, to_key,
|
|
308
|
+
link_values, link_hash
|
|
309
|
+
FROM walked w
|
|
310
|
+
WHERE relation_id IS NOT NULL
|
|
311
|
+
AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.from_kind AND n.key = w.from_key)
|
|
312
|
+
AND EXISTS (SELECT 1 FROM nodes n WHERE n.kind_id = w.to_kind AND n.key = w.to_key))
|
|
313
|
+
SELECT 'record', n.depth, m.kind_id, m.key, m."values", m.title, m.object_hash, m.lineage,
|
|
314
|
+
m.updated_revision, m.updated_at, CAST(NULL AS text), CAST(NULL AS text),
|
|
315
|
+
CAST(NULL AS text), CAST(NULL AS jsonb), CAST(NULL AS bytea)
|
|
316
|
+
FROM nodes n
|
|
317
|
+
JOIN mat_records m ON m.domain_id = %3$L AND m.branch = %4$L
|
|
318
|
+
AND m.kind_id = n.kind_id AND m.key = n.key
|
|
319
|
+
UNION ALL
|
|
320
|
+
SELECT 'link', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
321
|
+
relation_id, from_key, to_key, link_values, link_hash
|
|
322
|
+
FROM links
|
|
323
|
+
UNION ALL
|
|
324
|
+
SELECT 'steps', CAST((SELECT count(*) FROM walked) AS integer), NULL, NULL, NULL, NULL,
|
|
325
|
+
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL
|
|
326
|
+
UNION ALL
|
|
327
|
+
SELECT 'fanout', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
|
328
|
+
NULL, NULL
|
|
329
|
+
WHERE EXISTS (
|
|
330
|
+
SELECT 1 FROM walked WHERE depth > 0 GROUP BY depth, via_kind, via_key
|
|
331
|
+
HAVING count(DISTINCT (relation_id, from_key, to_key)) >= $3)
|
|
332
|
+
$sql$, v_nulls, v_step, p_domain, p_branch)
|
|
333
|
+
USING p_seeds, p_depth, p_fanout, p_relations, p_max_steps, p_max_nodes;
|
|
334
|
+
END
|
|
335
|
+
$fn$
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _search_path() -> str:
|
|
340
|
+
"""The migration's schema (where the tables are) and pg_trgm's, pg_temp last."""
|
|
341
|
+
bind = op.get_bind()
|
|
342
|
+
schema = bind.execute(sa.text("SELECT quote_ident(current_schema())")).scalar_one()
|
|
343
|
+
trgm = bind.execute(
|
|
344
|
+
sa.text(
|
|
345
|
+
"SELECT quote_ident(n.nspname) FROM pg_extension e "
|
|
346
|
+
"JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'pg_trgm'"
|
|
347
|
+
)
|
|
348
|
+
).scalar_one_or_none()
|
|
349
|
+
schemas = ["pg_catalog", schema]
|
|
350
|
+
if trgm is not None and trgm not in schemas:
|
|
351
|
+
schemas.append(trgm)
|
|
352
|
+
return ", ".join([*schemas, "pg_temp"])
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _application_role() -> str | None:
|
|
356
|
+
config = op.get_context().config
|
|
357
|
+
if config is None or "database_settings" not in config.attributes:
|
|
358
|
+
raise RuntimeError("run this migration through the Lorebase Alembic environment")
|
|
359
|
+
role: str | None = config.attributes["database_settings"].application_role
|
|
360
|
+
return role
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def upgrade() -> None:
|
|
364
|
+
options = _OPTIONS.format(search_path=_search_path()).strip()
|
|
365
|
+
for template in (LB_SEARCH, LB_LINKS_IN, LB_QUERY_BY_ATTRIBUTE, LB_NEIGHBORHOOD):
|
|
366
|
+
op.execute(template.format(options=options, record_columns=_RECORD_COLUMNS))
|
|
367
|
+
for signature in SIGNATURES:
|
|
368
|
+
op.execute(f"REVOKE ALL ON FUNCTION {signature} FROM PUBLIC")
|
|
369
|
+
bind = op.get_bind()
|
|
370
|
+
role = _application_role()
|
|
371
|
+
if role is not None:
|
|
372
|
+
quoted, exists = bind.execute(
|
|
373
|
+
sa.text("SELECT quote_ident(:r), EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :r)"),
|
|
374
|
+
{"r": role},
|
|
375
|
+
).one()
|
|
376
|
+
if not exists:
|
|
377
|
+
raise RuntimeError(
|
|
378
|
+
f"database.application_role names {role!r}, which does not exist; create the "
|
|
379
|
+
"role first or leave the setting empty"
|
|
380
|
+
)
|
|
381
|
+
for signature in SIGNATURES:
|
|
382
|
+
op.execute(f"GRANT EXECUTE ON FUNCTION {signature} TO {quoted}")
|
|
383
|
+
exempt = bind.execute(
|
|
384
|
+
sa.text("SELECT rolsuper OR rolbypassrls FROM pg_roles WHERE rolname = current_user")
|
|
385
|
+
).scalar_one()
|
|
386
|
+
if not exempt:
|
|
387
|
+
log.warning(
|
|
388
|
+
"the read functions are owned by %s, which is subject to row-level security: they "
|
|
389
|
+
"return correct rows but keep the slower plans (see docs/store-schema-notes.md)",
|
|
390
|
+
bind.execute(sa.text("SELECT current_user")).scalar_one(),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def downgrade() -> None:
|
|
395
|
+
for signature in reversed(SIGNATURES):
|
|
396
|
+
op.execute(f"DROP FUNCTION IF EXISTS {signature}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Daily usage of API keys, for per-key quotas (design 13.6).
|
|
2
|
+
|
|
3
|
+
One row per key and UTC day holds the requests served and the context-pack tokens delivered.
|
|
4
|
+
The table is installation-wide like `api_keys`; rows go with their key.
|
|
5
|
+
|
|
6
|
+
Revision ID: 0006
|
|
7
|
+
Revises: 0005
|
|
8
|
+
Create Date: 2026-10-21 15:00:00.000000
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
import sqlalchemy as sa
|
|
16
|
+
from alembic import op
|
|
17
|
+
|
|
18
|
+
revision: str = "0006"
|
|
19
|
+
down_revision: str | Sequence[str] | None = "0005"
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
op.create_table(
|
|
26
|
+
"api_key_usage",
|
|
27
|
+
sa.Column("api_key_id", sa.Text(), nullable=False),
|
|
28
|
+
sa.Column("day", sa.Date(), nullable=False),
|
|
29
|
+
sa.Column("requests", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
|
|
30
|
+
sa.Column("context_tokens", sa.BigInteger(), server_default=sa.text("0"), nullable=False),
|
|
31
|
+
sa.Column(
|
|
32
|
+
"updated_at",
|
|
33
|
+
sa.DateTime(timezone=True),
|
|
34
|
+
server_default=sa.text("now()"),
|
|
35
|
+
nullable=False,
|
|
36
|
+
),
|
|
37
|
+
sa.ForeignKeyConstraint(
|
|
38
|
+
["api_key_id"],
|
|
39
|
+
["api_keys.id"],
|
|
40
|
+
name=op.f("fk_api_key_usage_api_key_id_api_keys"),
|
|
41
|
+
ondelete="CASCADE",
|
|
42
|
+
),
|
|
43
|
+
sa.PrimaryKeyConstraint("api_key_id", "day", name=op.f("pk_api_key_usage")),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def downgrade() -> None:
|
|
48
|
+
op.drop_table("api_key_usage")
|