lorebase-keeper 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 (126) hide show
  1. lorebase_keeper/__init__.py +1 -0
  2. lorebase_keeper/__main__.py +456 -0
  3. lorebase_keeper/agents/__init__.py +1 -0
  4. lorebase_keeper/agents/budget.py +247 -0
  5. lorebase_keeper/agents/context.py +529 -0
  6. lorebase_keeper/agents/explain.py +582 -0
  7. lorebase_keeper/agents/feed.py +259 -0
  8. lorebase_keeper/agents/markdown.py +219 -0
  9. lorebase_keeper/agents/pack.py +403 -0
  10. lorebase_keeper/agents/settings.py +111 -0
  11. lorebase_keeper/app.py +373 -0
  12. lorebase_keeper/auth/__init__.py +4 -0
  13. lorebase_keeper/auth/api_keys.py +127 -0
  14. lorebase_keeper/auth/audit.py +356 -0
  15. lorebase_keeper/auth/cache.py +53 -0
  16. lorebase_keeper/auth/config.py +618 -0
  17. lorebase_keeper/auth/deps.py +183 -0
  18. lorebase_keeper/auth/keyring.py +14 -0
  19. lorebase_keeper/auth/oidc.py +242 -0
  20. lorebase_keeper/auth/permissions.py +142 -0
  21. lorebase_keeper/auth/ports.py +212 -0
  22. lorebase_keeper/auth/principals.py +377 -0
  23. lorebase_keeper/auth/provider.py +250 -0
  24. lorebase_keeper/auth/rbac.py +247 -0
  25. lorebase_keeper/auth/read_audit.py +357 -0
  26. lorebase_keeper/auth/runtime.py +113 -0
  27. lorebase_keeper/auth/sessions.py +452 -0
  28. lorebase_keeper/bootstrap.py +151 -0
  29. lorebase_keeper/client_address.py +129 -0
  30. lorebase_keeper/context.py +226 -0
  31. lorebase_keeper/db.py +93 -0
  32. lorebase_keeper/deps.py +47 -0
  33. lorebase_keeper/errors.py +257 -0
  34. lorebase_keeper/health.py +61 -0
  35. lorebase_keeper/kindhealth/__init__.py +6 -0
  36. lorebase_keeper/kindhealth/config.py +51 -0
  37. lorebase_keeper/kindhealth/markdown.py +163 -0
  38. lorebase_keeper/kindhealth/models.py +179 -0
  39. lorebase_keeper/kindhealth/report.py +154 -0
  40. lorebase_keeper/kindhealth/scan.py +512 -0
  41. lorebase_keeper/middleware.py +285 -0
  42. lorebase_keeper/pagination.py +87 -0
  43. lorebase_keeper/ratelimit/__init__.py +10 -0
  44. lorebase_keeper/ratelimit/bucket.py +118 -0
  45. lorebase_keeper/ratelimit/config.py +283 -0
  46. lorebase_keeper/ratelimit/enforce.py +393 -0
  47. lorebase_keeper/ratelimit/limiter.py +149 -0
  48. lorebase_keeper/ratelimit/quotas.py +204 -0
  49. lorebase_keeper/ratelimit/shared.py +242 -0
  50. lorebase_keeper/readiness.py +257 -0
  51. lorebase_keeper/reading/__init__.py +1 -0
  52. lorebase_keeper/reading/caching.py +251 -0
  53. lorebase_keeper/reading/compiler.py +678 -0
  54. lorebase_keeper/reading/cursors.py +56 -0
  55. lorebase_keeper/reading/diff.py +408 -0
  56. lorebase_keeper/reading/graph.py +268 -0
  57. lorebase_keeper/reading/history.py +152 -0
  58. lorebase_keeper/reading/points.py +410 -0
  59. lorebase_keeper/reading/problems.py +74 -0
  60. lorebase_keeper/reading/record_history.py +722 -0
  61. lorebase_keeper/reading/records.py +780 -0
  62. lorebase_keeper/reading/requests.py +107 -0
  63. lorebase_keeper/reading/runtime.py +145 -0
  64. lorebase_keeper/reading/search.py +708 -0
  65. lorebase_keeper/reading/sensitivity.py +882 -0
  66. lorebase_keeper/reading/settings.py +339 -0
  67. lorebase_keeper/reading/snapshots.py +148 -0
  68. lorebase_keeper/reading/tree.py +98 -0
  69. lorebase_keeper/reading/views.py +264 -0
  70. lorebase_keeper/reading/visibility.py +93 -0
  71. lorebase_keeper/routes/__init__.py +1 -0
  72. lorebase_keeper/routes/admin.py +597 -0
  73. lorebase_keeper/routes/agents.py +160 -0
  74. lorebase_keeper/routes/branches.py +438 -0
  75. lorebase_keeper/routes/derivations.py +215 -0
  76. lorebase_keeper/routes/documents.py +497 -0
  77. lorebase_keeper/routes/domain_api.py +59 -0
  78. lorebase_keeper/routes/domains.py +220 -0
  79. lorebase_keeper/routes/events.py +187 -0
  80. lorebase_keeper/routes/kind_health.py +166 -0
  81. lorebase_keeper/routes/operations.py +392 -0
  82. lorebase_keeper/routes/proposal_items.py +563 -0
  83. lorebase_keeper/routes/proposals.py +1107 -0
  84. lorebase_keeper/routes/read.py +493 -0
  85. lorebase_keeper/routes/revisions.py +523 -0
  86. lorebase_keeper/routes/schema.py +682 -0
  87. lorebase_keeper/routes/schema_inference.py +437 -0
  88. lorebase_keeper/routes/schema_suggestions.py +328 -0
  89. lorebase_keeper/routes/sessions.py +286 -0
  90. lorebase_keeper/routes/source_preview.py +232 -0
  91. lorebase_keeper/routes/sources.py +200 -0
  92. lorebase_keeper/routes/trace.py +506 -0
  93. lorebase_keeper/routes/vault.py +332 -0
  94. lorebase_keeper/routes/webhooks.py +298 -0
  95. lorebase_keeper/routes/whoami.py +88 -0
  96. lorebase_keeper/services/__init__.py +1 -0
  97. lorebase_keeper/services/agent_writes.py +1035 -0
  98. lorebase_keeper/services/branches.py +337 -0
  99. lorebase_keeper/services/config.py +431 -0
  100. lorebase_keeper/services/derivation_preview.py +279 -0
  101. lorebase_keeper/services/document_urls.py +114 -0
  102. lorebase_keeper/services/domains.py +118 -0
  103. lorebase_keeper/services/errors.py +196 -0
  104. lorebase_keeper/services/operation_catalog.py +806 -0
  105. lorebase_keeper/services/operation_events.py +199 -0
  106. lorebase_keeper/services/operations.py +1229 -0
  107. lorebase_keeper/services/pii_import.py +222 -0
  108. lorebase_keeper/services/proposal_reads.py +158 -0
  109. lorebase_keeper/services/proposals.py +850 -0
  110. lorebase_keeper/services/reads.py +286 -0
  111. lorebase_keeper/services/schema.py +601 -0
  112. lorebase_keeper/services/schema_inference.py +390 -0
  113. lorebase_keeper/services/schema_suggestions.py +418 -0
  114. lorebase_keeper/services/source_preview.py +127 -0
  115. lorebase_keeper/settings.py +313 -0
  116. lorebase_keeper/tracing/__init__.py +1 -0
  117. lorebase_keeper/tracing/capture.py +350 -0
  118. lorebase_keeper/tracing/config.py +157 -0
  119. lorebase_keeper/tracing/explain.py +97 -0
  120. lorebase_keeper/tracing/middleware.py +359 -0
  121. lorebase_keeper/tracing/notes.py +80 -0
  122. lorebase_keeper/tracing/recorder.py +180 -0
  123. lorebase_keeper-0.2.3.dist-info/METADATA +21 -0
  124. lorebase_keeper-0.2.3.dist-info/RECORD +126 -0
  125. lorebase_keeper-0.2.3.dist-info/WHEEL +4 -0
  126. lorebase_keeper-0.2.3.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,160 @@
1
+ """Agent-facing reads (design 11): context packs and Record explanations.
2
+
3
+ `POST /context` answers in one call with the seed Records, their neighborhood, the schema
4
+ fragment, recent changes and lineage, trimmed to a token budget (`agents.context`). It is
5
+ authorized like every read (`services.reads`, Kind-scoped grants included) and cached by
6
+ Revision like GET reads: the `ETag` names the Revision and a digest of the request, and a
7
+ matching `If-None-Match` answers 304.
8
+
9
+ `GET .../records/{key}/explain` tells why a Record exists and why its values are what they
10
+ are (`agents.explain`), as JSON or markdown, cached like every GET read.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated, Any, Literal, cast
16
+
17
+ from fastapi import APIRouter, Depends, Query, Request, Response
18
+
19
+ from lorebase_keeper.agents.context import JSON, MARKDOWN, PackContext, build_pack
20
+ from lorebase_keeper.agents.explain import ExplainOut, explain_markdown, explain_record
21
+ from lorebase_keeper.agents.pack import ContextPackOut, ContextRequest, dumps
22
+ from lorebase_keeper.agents.settings import AgentSettings, agent_settings
23
+ from lorebase_keeper.errors import Problem, ValidationProblem
24
+ from lorebase_keeper.ratelimit.enforce import charge_context_tokens, check_context_quota
25
+ from lorebase_keeper.reading.caching import CacheKey, cache_key, entity_tag, respond_with
26
+ from lorebase_keeper.reading.cursors import fingerprint
27
+ from lorebase_keeper.reading.points import REVISION_HEADER, open_revision, read_guard, resolve_point
28
+ from lorebase_keeper.reading.records import require_kind
29
+ from lorebase_keeper.reading.runtime import ReadRuntimeDep, ReadSessionDep
30
+ from lorebase_keeper.reading.sensitivity import deferred_markdown
31
+ from lorebase_keeper.services.reads import ReadGuard
32
+ from lorebase_platform.settings import Settings
33
+
34
+ router = APIRouter(tags=["agents"])
35
+
36
+ BASE = "/domains/{domain_id}"
37
+ _ERRORS: dict[int | str, dict[str, Any]] = {
38
+ 403: {"model": Problem, "description": "The caller lacks data.read for what is read."},
39
+ 404: {"model": Problem, "description": "Domain, branch, Revision, Kind or Record not found."},
40
+ 422: {"model": ValidationProblem, "description": "Invalid request."},
41
+ }
42
+
43
+ DataRead = Annotated[ReadGuard, Depends(read_guard())]
44
+
45
+
46
+ def get_agent_settings(request: Request) -> AgentSettings:
47
+ return agent_settings(cast(Settings, request.app.state.settings))
48
+
49
+
50
+ AgentSettingsDep = Annotated[AgentSettings, Depends(get_agent_settings)]
51
+
52
+
53
+ @router.post(
54
+ f"{BASE}/context",
55
+ summary="Context pack for an agent",
56
+ responses={
57
+ **_ERRORS,
58
+ 200: {
59
+ "model": ContextPackOut,
60
+ "content": {JSON: {}, MARKDOWN: {}},
61
+ "description": "The pack in JSON, or markdown with `format: markdown`.",
62
+ },
63
+ 304: {"description": "Not modified: If-None-Match names the current ETag."},
64
+ },
65
+ )
66
+ async def context_pack(
67
+ domain_id: str,
68
+ body: ContextRequest,
69
+ access: DataRead,
70
+ runtime: ReadRuntimeDep,
71
+ session: ReadSessionDep,
72
+ settings: AgentSettingsDep,
73
+ request: Request,
74
+ ) -> Response:
75
+ """Seed Records, their neighborhood, schema, recent changes and lineage within a budget."""
76
+ await check_context_quota(request, access.principal)
77
+ point = await resolve_point(session, runtime, access, branch=body.branch, at=body.at)
78
+ since: str | None = None
79
+ floor: int | None = None
80
+ if body.since is not None and body.include_recent_changes:
81
+ found = await open_revision(session, access, point.domain, body.since)
82
+ since, floor = found.id.hex(), found.generation
83
+ digest = fingerprint("context", body.model_dump(mode="json"), since)
84
+ key = CacheKey(
85
+ point.domain.id,
86
+ point.revision_hex,
87
+ request.url.path,
88
+ (("body", digest),),
89
+ point.variant,
90
+ )
91
+ context = PackContext(point=point, etag=entity_tag(key), since=since, floor=floor)
92
+
93
+ async def produce() -> tuple[bytes, str]:
94
+ return await build_pack(session, runtime, context, body, settings.context)
95
+
96
+ response = await respond_with(
97
+ request,
98
+ runtime.cache,
99
+ key,
100
+ produce,
101
+ time_travel=not point.materialized,
102
+ headers={REVISION_HEADER: point.revision_hex},
103
+ disclose=point.disclosure(session, runtime).send,
104
+ )
105
+ await charge_context_tokens(
106
+ request, access.principal, response, settings.context.chars_per_token
107
+ )
108
+ return response
109
+
110
+
111
+ @router.get(
112
+ f"{BASE}/kinds/{{kind}}/records/{{key}}/explain",
113
+ summary="Explain a Record",
114
+ responses={
115
+ **_ERRORS,
116
+ 200: {
117
+ "model": ExplainOut,
118
+ "content": {JSON: {}, MARKDOWN: {}},
119
+ "description": "The explanation in JSON, or markdown with `format=markdown`.",
120
+ },
121
+ 304: {"description": "Not modified: If-None-Match names the current ETag."},
122
+ },
123
+ )
124
+ async def explain(
125
+ domain_id: str,
126
+ kind: str,
127
+ key: str,
128
+ access: DataRead,
129
+ runtime: ReadRuntimeDep,
130
+ session: ReadSessionDep,
131
+ settings: AgentSettingsDep,
132
+ request: Request,
133
+ branch: Annotated[
134
+ str | None, Query(description="Branch to read; default: the Domain's default branch.")
135
+ ] = None,
136
+ at: Annotated[
137
+ str | None,
138
+ Query(description="Revision id or tag to read at (time travel, served from a snapshot)."),
139
+ ] = None,
140
+ format: Annotated[Literal["json", "markdown"], Query()] = "json",
141
+ ) -> Response:
142
+ """Creation, per-Attribute lineage, Links with their origin and Proposals of a Record."""
143
+ point = await resolve_point(session, runtime, access, branch=branch, at=at)
144
+ found = require_kind(point, kind)
145
+
146
+ async def produce() -> tuple[bytes, str]:
147
+ document = await explain_record(session, runtime, point, found, key, settings.explain)
148
+ if format == "markdown":
149
+ return deferred_markdown(document, explain_markdown), MARKDOWN
150
+ return dumps(document).encode(), JSON
151
+
152
+ return await respond_with(
153
+ request,
154
+ runtime.cache,
155
+ cache_key(request, point.domain.id, point.revision_hex, point.variant),
156
+ produce,
157
+ time_travel=not point.materialized,
158
+ headers={REVISION_HEADER: point.revision_hex},
159
+ disclose=point.disclosure(session, runtime).send,
160
+ )
@@ -0,0 +1,438 @@
1
+ """Branches and tags (design 6.5, 11.1).
2
+
3
+ Branch names are hierarchical (`agent/bot-1/task`); in paths they are written as they are or
4
+ URL-encoded. Grants narrowed to a branch pattern apply to the branch named in the path or,
5
+ on creation, in the body.
6
+
7
+ | Route | Permission |
8
+ |-------|------------|
9
+ | list, get branches and tags | `schema.read` |
10
+ | create or delete a branch, create a tag | `branch.create`, plus read of the start point |
11
+ | protect or unprotect a branch | `domain.settings` |
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from datetime import datetime
17
+ from typing import Annotated, Any
18
+ from urllib.parse import quote
19
+
20
+ from fastapi import APIRouter, Depends, Request, Response
21
+ from pydantic import BaseModel, Field
22
+
23
+ from lorebase_core.canonical import hex
24
+ from lorebase_keeper.auth.audit import annotate
25
+ from lorebase_keeper.auth.deps import Access, AuthDep, PrincipalDep, authorize, require
26
+ from lorebase_keeper.auth.permissions import Permission
27
+ from lorebase_keeper.auth.principals import Principal
28
+ from lorebase_keeper.auth.rbac import AccessContext
29
+ from lorebase_keeper.auth.runtime import AuthRuntime
30
+ from lorebase_keeper.context import StoreAccess, StoreDep, parse_revision_id
31
+ from lorebase_keeper.deps import IdempotencyKeyDep, KeeperSettingsDep
32
+ from lorebase_keeper.errors import Problem, ValidationProblem
33
+ from lorebase_keeper.pagination import Page, PageParamsDep, encode_cursor
34
+ from lorebase_keeper.routes.domains import REPLAYED_HEADER
35
+ from lorebase_keeper.services import branches as service
36
+ from lorebase_keeper.services.reads import resolve_readable
37
+ from lorebase_keeper.settings import KeeperSettings
38
+ from lorebase_store.materialize import BranchMode
39
+ from lorebase_store.models.enums import Materialization, RefKind
40
+ from lorebase_store.repositories import (
41
+ MaterializationPhase,
42
+ MaterializationState,
43
+ MaterializationStatus,
44
+ RefRow,
45
+ )
46
+
47
+ router = APIRouter(tags=["branches"])
48
+
49
+ BRANCH_PATH = "/domains/{domain_id}/branches/{branch:path}"
50
+
51
+ _ERRORS: dict[int | str, dict[str, Any]] = {
52
+ 403: {"model": Problem, "description": "The caller lacks the permission named in the body."},
53
+ 404: {"model": Problem, "description": "Domain, branch, tag or Revision not found."},
54
+ 409: {"model": Problem, "description": "Exists already, protected, or over a limit."},
55
+ 422: {"model": ValidationProblem, "description": "Invalid request."},
56
+ 503: {"model": Problem, "description": "The branch is locked by another writer."},
57
+ }
58
+
59
+
60
+ class MaterializationProgress(BaseModel):
61
+ """How far the background copy of a branch got."""
62
+
63
+ phase: MaterializationPhase = Field(
64
+ description="What the next batch copies: `records`, `links`, `history`, then `switch` "
65
+ "(the branch becomes a full copy); `done` at the end."
66
+ )
67
+ rows_done: int = Field(description="Rows of the source branch read so far.")
68
+ rows_total: int | None = Field(
69
+ description="Rows of the source branch to read (Records, Links and history entries "
70
+ "at the start of the copy); null until the job has started."
71
+ )
72
+ error: str | None = Field(
73
+ description="Last failure of the copy job, kept while it is retried and when it failed."
74
+ )
75
+ updated_at: datetime
76
+ finished_at: datetime | None
77
+
78
+
79
+ class BranchOut(BaseModel):
80
+ """A branch."""
81
+
82
+ name: str
83
+ head: str
84
+ protected: bool
85
+ default: bool
86
+ materialization: Materialization
87
+ version: int
88
+ created_at: datetime
89
+ created_by: str
90
+ materialization_status: MaterializationStatus | None = Field(
91
+ default=None,
92
+ description="Background copy of the branch: a large copy branch is created as an "
93
+ "overlay of its source (`materialization` is `overlay`, reads and writes work at once) "
94
+ "and a job copies its rows in batches; a large copy that cannot start as an overlay "
95
+ "(of an overlay, from an older Revision) is `building` until a job writes its rows "
96
+ "from its tree (its head reads like a past Revision, commits answer 409). `pending` "
97
+ "(queued, or waiting for a retry), `running`, `done` (`materialization` is then "
98
+ "`full`) or `failed` (a build that failed for good). Null for a branch that was never "
99
+ "copied in the background.",
100
+ )
101
+ materialization_progress: MaterializationProgress | None = Field(
102
+ default=None, description="Progress of the background copy; null without one."
103
+ )
104
+
105
+
106
+ class BranchDetail(BranchOut):
107
+ """A branch with its distance to the default branch."""
108
+
109
+ ahead: int = Field(description="Revisions on this branch that the default branch lacks.")
110
+ behind: int = Field(description="Revisions on the default branch that this branch lacks.")
111
+
112
+
113
+ class BranchCreate(BaseModel):
114
+ """A new branch, started at a Revision or at the head of a branch or tag."""
115
+
116
+ name: str = Field(min_length=1)
117
+ source: str | None = Field(
118
+ default=None, description="Branch or tag to start from; default: the default branch."
119
+ )
120
+ revision: str | None = Field(
121
+ default=None, description="Revision id to start at; default: the head of `source`."
122
+ )
123
+ mode: BranchMode | None = Field(
124
+ default=None,
125
+ description="`copy`: the branch gets its own materialized rows. `overlay`: it stores "
126
+ "only the rows it changes and reads the rest from `source` as of its head (only when "
127
+ "starting at the head of a fully materialized branch; otherwise a copy). Default: "
128
+ "`store.materialize.branch_mode`, overlay for names with an overlay prefix.",
129
+ )
130
+
131
+
132
+ class BranchUpdate(BaseModel):
133
+ """Branch flags."""
134
+
135
+ protected: bool = Field(
136
+ description="Protected branches accept changes only through Proposals (design 6.5)."
137
+ )
138
+
139
+
140
+ class TagOut(BaseModel):
141
+ """An immutable tag."""
142
+
143
+ name: str
144
+ revision: str
145
+ created_at: datetime
146
+ created_by: str
147
+
148
+
149
+ class TagCreate(BaseModel):
150
+ """A new tag at a Revision or at the head of a branch."""
151
+
152
+ name: str = Field(min_length=1)
153
+ source: str | None = Field(default=None, description="Branch whose head is tagged.")
154
+ revision: str | None = Field(default=None, description="Revision id to tag.")
155
+
156
+
157
+ def _branch(
158
+ ref: RefRow,
159
+ *,
160
+ default: bool,
161
+ state: MaterializationState | None = None,
162
+ limited: bool = False,
163
+ ) -> BranchOut:
164
+ """A branch; `limited` (a caller limited to Kinds, `Access.kinds`) leaves out the
165
+ progress of its copy, whose row counts and errors tell about every Kind."""
166
+ progress = None
167
+ if state is not None and not limited:
168
+ progress = MaterializationProgress(
169
+ phase=state.phase,
170
+ rows_done=state.rows_done,
171
+ rows_total=state.rows_total,
172
+ error=state.error,
173
+ updated_at=state.updated_at,
174
+ finished_at=state.finished_at,
175
+ )
176
+ return BranchOut(
177
+ name=ref.name,
178
+ head=hex(ref.head),
179
+ protected=ref.protected,
180
+ default=default,
181
+ materialization=ref.materialization,
182
+ version=ref.version,
183
+ created_at=ref.created_at,
184
+ created_by=ref.created_by,
185
+ materialization_status=None if state is None else state.status,
186
+ materialization_progress=progress,
187
+ )
188
+
189
+
190
+ def _tag(ref: RefRow) -> TagOut:
191
+ return TagOut(
192
+ name=ref.name, revision=hex(ref.head), created_at=ref.created_at, created_by=ref.created_by
193
+ )
194
+
195
+
196
+ async def _require_source_readable(
197
+ auth: AuthRuntime,
198
+ principal: Principal,
199
+ store: StoreAccess,
200
+ keeper: KeeperSettings,
201
+ domain_id: str,
202
+ *,
203
+ source: str | None,
204
+ revision: str | None,
205
+ ) -> None:
206
+ """A new branch or tag starts at content the caller must be able to read: the Revision
207
+ given, else the source branch or tag (default: the default branch)."""
208
+ await resolve_readable(
209
+ store,
210
+ auth.authorizer,
211
+ principal,
212
+ (Permission.SCHEMA_READ, Permission.DATA_READ),
213
+ domain_id,
214
+ branch=None,
215
+ at=revision or source,
216
+ max_depth=keeper.reads.reachability_max_depth,
217
+ )
218
+
219
+
220
+ def _after(params: Any) -> str | None:
221
+ return None if params.position is None else str(params.position.get("name", ""))
222
+
223
+
224
+ def _next(rows: list[RefRow], limit: int) -> str | None:
225
+ return encode_cursor({"name": rows[limit - 1].name}) if len(rows) > limit else None
226
+
227
+
228
+ @router.get("/domains/{domain_id}/branches", summary="List branches", responses=_ERRORS)
229
+ async def list_branches(
230
+ domain_id: str,
231
+ store: StoreDep,
232
+ params: PageParamsDep,
233
+ access: Annotated[Access, Depends(require(Permission.SCHEMA_READ, kind_limited=True))],
234
+ ) -> Page[BranchOut]:
235
+ """Branches ordered by name."""
236
+ found = await service.page(
237
+ store, domain_id, RefKind.BRANCH, limit=params.limit + 1, after=_after(params)
238
+ )
239
+ return Page[BranchOut](
240
+ items=[
241
+ _branch(
242
+ r,
243
+ default=r.name == found.default,
244
+ state=found.materializations.get(r.name),
245
+ limited=access.kinds is not None,
246
+ )
247
+ for r in found.refs[: params.limit]
248
+ ],
249
+ next_cursor=_next(found.refs, params.limit),
250
+ )
251
+
252
+
253
+ @router.post(
254
+ "/domains/{domain_id}/branches", status_code=201, summary="Create a branch", responses=_ERRORS
255
+ )
256
+ async def create_branch(
257
+ domain_id: str,
258
+ body: BranchCreate,
259
+ request: Request,
260
+ response: Response,
261
+ principal: PrincipalDep,
262
+ auth: AuthDep,
263
+ store: StoreDep,
264
+ keeper: KeeperSettingsDep,
265
+ idempotency_key: IdempotencyKeyDep,
266
+ ) -> BranchOut:
267
+ """Create a materialized branch (a copy, or an overlay on its source) from a branch, tag
268
+ or Revision.
269
+
270
+ A copy of a large source (above `store.materialize.async_copy_min_rows` rows) is created
271
+ as an overlay at once and copied by a background job: `materialization_status` is then
272
+ `pending`, and the branch becomes `full` when it is `done`. With an `Idempotency-Key`,
273
+ retrying a create that succeeded returns the branch with status 200 and
274
+ `Idempotent-Replayed: true`.
275
+ """
276
+ await authorize(
277
+ request,
278
+ auth,
279
+ principal,
280
+ Permission.BRANCH_CREATE,
281
+ domain_id,
282
+ AccessContext(branch=body.name),
283
+ )
284
+ revision = None if body.revision is None else parse_revision_id(body.revision, "/body/revision")
285
+ await _require_source_readable(
286
+ auth, principal, store, keeper, domain_id, source=body.source, revision=body.revision
287
+ )
288
+ created = await service.create(
289
+ store,
290
+ domain_id,
291
+ body.name,
292
+ principal,
293
+ keeper.branches,
294
+ source=body.source,
295
+ revision=revision,
296
+ idempotency_key=idempotency_key,
297
+ mode=body.mode,
298
+ )
299
+ annotate(
300
+ request,
301
+ action="branch.create",
302
+ target=body.name,
303
+ head=hex(created.ref.head),
304
+ source=body.source,
305
+ materialization=None if created.mode is None else created.mode.value,
306
+ )
307
+ if created.replayed:
308
+ response.status_code = 200
309
+ response.headers[REPLAYED_HEADER] = "true"
310
+ response.headers["Location"] = (
311
+ f"{keeper.api_prefix}/domains/{domain_id}/branches/{quote(body.name, safe='')}"
312
+ )
313
+ return _branch(created.ref, default=created.default, state=created.materialization)
314
+
315
+
316
+ @router.get("/domains/{domain_id}/tags", summary="List tags", responses=_ERRORS)
317
+ async def list_tags(
318
+ domain_id: str,
319
+ store: StoreDep,
320
+ params: PageParamsDep,
321
+ _: Annotated[Access, Depends(require(Permission.SCHEMA_READ, kind_limited=True))],
322
+ ) -> Page[TagOut]:
323
+ """Tags ordered by name."""
324
+ found = await service.page(
325
+ store, domain_id, RefKind.TAG, limit=params.limit + 1, after=_after(params)
326
+ )
327
+ return Page[TagOut](
328
+ items=[_tag(r) for r in found.refs[: params.limit]],
329
+ next_cursor=_next(found.refs, params.limit),
330
+ )
331
+
332
+
333
+ @router.post(
334
+ "/domains/{domain_id}/tags", status_code=201, summary="Create a tag", responses=_ERRORS
335
+ )
336
+ async def create_tag(
337
+ domain_id: str,
338
+ body: TagCreate,
339
+ request: Request,
340
+ response: Response,
341
+ principal: PrincipalDep,
342
+ auth: AuthDep,
343
+ store: StoreDep,
344
+ keeper: KeeperSettingsDep,
345
+ idempotency_key: IdempotencyKeyDep,
346
+ ) -> TagOut:
347
+ """Tag a Revision. Tags never move."""
348
+ await authorize(
349
+ request,
350
+ auth,
351
+ principal,
352
+ Permission.BRANCH_CREATE,
353
+ domain_id,
354
+ AccessContext(branch=body.name),
355
+ )
356
+ revision = None if body.revision is None else parse_revision_id(body.revision, "/body/revision")
357
+ await _require_source_readable(
358
+ auth, principal, store, keeper, domain_id, source=body.source, revision=body.revision
359
+ )
360
+ created = await service.create_tag(
361
+ store,
362
+ domain_id,
363
+ body.name,
364
+ principal,
365
+ keeper.branches,
366
+ source=body.source,
367
+ revision=revision,
368
+ idempotency_key=idempotency_key,
369
+ )
370
+ annotate(request, action="tag.create", target=body.name, revision=hex(created.ref.head))
371
+ if created.replayed:
372
+ response.status_code = 200
373
+ response.headers[REPLAYED_HEADER] = "true"
374
+ return _tag(created.ref)
375
+
376
+
377
+ # Routes with a hierarchical branch name come last: `{branch:path}` matches any suffix.
378
+
379
+
380
+ @router.get(BRANCH_PATH, summary="Get a branch", responses=_ERRORS)
381
+ async def get_branch(
382
+ domain_id: str,
383
+ branch: str,
384
+ store: StoreDep,
385
+ access: Annotated[
386
+ Access,
387
+ Depends(require(Permission.SCHEMA_READ, branch_param="branch", kind_limited=True)),
388
+ ],
389
+ ) -> BranchDetail:
390
+ """The branch head, protection flag and ahead/behind counts against the default branch."""
391
+ state = await service.get(store, domain_id, branch)
392
+ out = _branch(
393
+ state.ref,
394
+ default=state.default,
395
+ state=state.materialization,
396
+ limited=access.kinds is not None,
397
+ )
398
+ return BranchDetail(**out.model_dump(), ahead=state.ahead, behind=state.behind)
399
+
400
+
401
+ @router.patch(BRANCH_PATH, summary="Protect or unprotect a branch", responses=_ERRORS)
402
+ async def update_branch(
403
+ domain_id: str,
404
+ branch: str,
405
+ body: BranchUpdate,
406
+ request: Request,
407
+ store: StoreDep,
408
+ _: Annotated[Access, Depends(require(Permission.DOMAIN_SETTINGS, branch_param="branch"))],
409
+ ) -> BranchOut:
410
+ """Set the protection flag."""
411
+ state = await service.set_protected(store, domain_id, branch, body.protected)
412
+ annotate(
413
+ request,
414
+ action="branch.protect" if body.protected else "branch.unprotect",
415
+ target=branch,
416
+ )
417
+ return _branch(state.ref, default=state.default)
418
+
419
+
420
+ @router.delete(BRANCH_PATH, status_code=204, summary="Delete a branch", responses=_ERRORS)
421
+ async def delete_branch(
422
+ domain_id: str,
423
+ branch: str,
424
+ request: Request,
425
+ store: StoreDep,
426
+ keeper: KeeperSettingsDep,
427
+ _: Annotated[Access, Depends(require(Permission.BRANCH_CREATE, branch_param="branch"))],
428
+ ) -> Response:
429
+ """Delete a branch and its materialized rows; the default and protected branches stay.
430
+
431
+ A large branch (above `store.materialize.async_delete_min_rows` rows) disappears at once
432
+ and a background job deletes its rows; its name can be used again when that is done.
433
+ """
434
+ ref, deletion = await service.delete(store, domain_id, branch, keeper.branches)
435
+ annotate(
436
+ request, action="branch.delete", target=branch, head=hex(ref.head), deletion=deletion.value
437
+ )
438
+ return Response(status_code=204)