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,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")