supython 0.5.0__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 (188) hide show
  1. supython/__init__.py +8 -0
  2. supython/admin/__init__.py +3 -0
  3. supython/admin/api/__init__.py +24 -0
  4. supython/admin/api/auth.py +118 -0
  5. supython/admin/api/auth_templates.py +67 -0
  6. supython/admin/api/auth_users.py +225 -0
  7. supython/admin/api/db.py +174 -0
  8. supython/admin/api/functions.py +92 -0
  9. supython/admin/api/jobs.py +192 -0
  10. supython/admin/api/ops.py +224 -0
  11. supython/admin/api/realtime.py +281 -0
  12. supython/admin/api/service_auth.py +49 -0
  13. supython/admin/api/service_auth_templates.py +83 -0
  14. supython/admin/api/service_auth_users.py +346 -0
  15. supython/admin/api/service_db.py +214 -0
  16. supython/admin/api/service_functions.py +287 -0
  17. supython/admin/api/service_jobs.py +282 -0
  18. supython/admin/api/service_ops.py +213 -0
  19. supython/admin/api/service_realtime.py +30 -0
  20. supython/admin/api/service_storage.py +220 -0
  21. supython/admin/api/storage.py +117 -0
  22. supython/admin/api/system.py +37 -0
  23. supython/admin/audit.py +29 -0
  24. supython/admin/deps.py +22 -0
  25. supython/admin/errors.py +16 -0
  26. supython/admin/schemas.py +310 -0
  27. supython/admin/session.py +52 -0
  28. supython/admin/spa.py +38 -0
  29. supython/admin/static/assets/Alert-dluGVkos.js +49 -0
  30. supython/admin/static/assets/Audit-Njung3HI.js +2 -0
  31. supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
  32. supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
  33. supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
  34. supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
  35. supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
  36. supython/admin/static/assets/Crons-B67vc39F.js +2 -0
  37. supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
  38. supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
  39. supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
  40. supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
  41. supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
  42. supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
  43. supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
  44. supython/admin/static/assets/Input-DppYTq9C.js +259 -0
  45. supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
  46. supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
  47. supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
  48. supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
  49. supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
  50. supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
  51. supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
  52. supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
  53. supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
  54. supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
  55. supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
  56. supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
  57. supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
  58. supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
  59. supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
  60. supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
  61. supython/admin/static/assets/Space-n5-XcguU.js +400 -0
  62. supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
  63. supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
  64. supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
  65. supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
  66. supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
  67. supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
  68. supython/admin/static/assets/Users-wzwajhlh.js +2 -0
  69. supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
  70. supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
  71. supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
  72. supython/admin/static/assets/get-Ca6unauB.js +2 -0
  73. supython/admin/static/assets/index-CeE6v959.js +951 -0
  74. supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
  75. supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
  76. supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
  77. supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
  78. supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
  79. supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
  80. supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
  81. supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
  82. supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
  83. supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
  84. supython/admin/static/favicon.svg +1 -0
  85. supython/admin/static/icons.svg +24 -0
  86. supython/admin/static/index.html +24 -0
  87. supython/app.py +149 -0
  88. supython/auth/__init__.py +3 -0
  89. supython/auth/_email_job.py +11 -0
  90. supython/auth/providers/__init__.py +34 -0
  91. supython/auth/providers/github.py +22 -0
  92. supython/auth/providers/google.py +19 -0
  93. supython/auth/providers/oauth.py +56 -0
  94. supython/auth/providers/registry.py +16 -0
  95. supython/auth/ratelimit.py +39 -0
  96. supython/auth/router.py +282 -0
  97. supython/auth/schemas.py +79 -0
  98. supython/auth/service.py +587 -0
  99. supython/body_size.py +184 -0
  100. supython/cli.py +1653 -0
  101. supython/client/__init__.py +67 -0
  102. supython/client/_auth.py +249 -0
  103. supython/client/_client.py +145 -0
  104. supython/client/_config.py +92 -0
  105. supython/client/_functions.py +69 -0
  106. supython/client/_storage.py +255 -0
  107. supython/client/py.typed +0 -0
  108. supython/db.py +151 -0
  109. supython/db_admin.py +8 -0
  110. supython/functions/__init__.py +19 -0
  111. supython/functions/context.py +262 -0
  112. supython/functions/loader.py +307 -0
  113. supython/functions/router.py +228 -0
  114. supython/functions/schemas.py +50 -0
  115. supython/gen/__init__.py +5 -0
  116. supython/gen/_introspect.py +137 -0
  117. supython/gen/types_py.py +270 -0
  118. supython/gen/types_ts.py +365 -0
  119. supython/health.py +229 -0
  120. supython/hooks.py +117 -0
  121. supython/jobs/__init__.py +31 -0
  122. supython/jobs/backends.py +97 -0
  123. supython/jobs/context.py +58 -0
  124. supython/jobs/cron.py +152 -0
  125. supython/jobs/cron_inproc.py +118 -0
  126. supython/jobs/decorators.py +76 -0
  127. supython/jobs/registry.py +79 -0
  128. supython/jobs/router.py +136 -0
  129. supython/jobs/schemas.py +92 -0
  130. supython/jobs/service.py +311 -0
  131. supython/jobs/worker.py +219 -0
  132. supython/jwks.py +257 -0
  133. supython/keyset.py +279 -0
  134. supython/logging_config.py +291 -0
  135. supython/mail.py +33 -0
  136. supython/mailer.py +65 -0
  137. supython/migrate.py +81 -0
  138. supython/migrations/0001_extensions_and_roles.sql +46 -0
  139. supython/migrations/0002_auth_schema.sql +66 -0
  140. supython/migrations/0003_demo_todos.sql +42 -0
  141. supython/migrations/0004_auth_v0_2.sql +47 -0
  142. supython/migrations/0005_storage_schema.sql +117 -0
  143. supython/migrations/0006_realtime_schema.sql +206 -0
  144. supython/migrations/0007_jobs_schema.sql +254 -0
  145. supython/migrations/0008_jobs_last_error.sql +56 -0
  146. supython/migrations/0009_auth_rate_limits.sql +33 -0
  147. supython/migrations/0010_worker_heartbeat.sql +14 -0
  148. supython/migrations/0011_admin_schema.sql +45 -0
  149. supython/migrations/0012_auth_banned_until.sql +10 -0
  150. supython/migrations/0013_email_templates.sql +19 -0
  151. supython/migrations/0014_realtime_payload_warning.sql +96 -0
  152. supython/migrations/0015_backups_schema.sql +14 -0
  153. supython/passwords.py +15 -0
  154. supython/realtime/__init__.py +6 -0
  155. supython/realtime/broker.py +814 -0
  156. supython/realtime/protocol.py +234 -0
  157. supython/realtime/router.py +184 -0
  158. supython/realtime/schemas.py +207 -0
  159. supython/realtime/service.py +261 -0
  160. supython/realtime/topics.py +175 -0
  161. supython/realtime/websocket.py +586 -0
  162. supython/scaffold/__init__.py +5 -0
  163. supython/scaffold/init_project.py +133 -0
  164. supython/scaffold/templates/Caddyfile.tmpl +4 -0
  165. supython/scaffold/templates/README.md.tmpl +22 -0
  166. supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
  167. supython/scaffold/templates/docker-compose.yml.tmpl +41 -0
  168. supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
  169. supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
  170. supython/scaffold/templates/env.example.tmpl +149 -0
  171. supython/scaffold/templates/functions_README.md.tmpl +21 -0
  172. supython/scaffold/templates/gitignore.tmpl +14 -0
  173. supython/scaffold/templates/migrations/.gitkeep +0 -0
  174. supython/secretset.py +347 -0
  175. supython/security_headers.py +78 -0
  176. supython/settings.py +198 -0
  177. supython/storage/__init__.py +5 -0
  178. supython/storage/backends.py +392 -0
  179. supython/storage/router.py +341 -0
  180. supython/storage/schemas.py +50 -0
  181. supython/storage/service.py +445 -0
  182. supython/storage/signing.py +119 -0
  183. supython/tokens.py +85 -0
  184. supython-0.5.0.dist-info/METADATA +714 -0
  185. supython-0.5.0.dist-info/RECORD +188 -0
  186. supython-0.5.0.dist-info/WHEEL +4 -0
  187. supython-0.5.0.dist-info/entry_points.txt +2 -0
  188. supython-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,287 @@
1
+ """Admin service for the functions surface.
2
+
3
+ Three responsibilities:
4
+
5
+ 1. Enumerate routes the running registry has discovered.
6
+ 2. Read a function's source from disk (only for files registered as routes).
7
+ 3. Invoke a function under ``service_role`` on behalf of the operator —
8
+ never minting an end-user JWT (Story 9.2 v1.1.3 contract).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ import time
17
+ from typing import Any
18
+
19
+ from fastapi import Response, status
20
+ from fastapi.responses import JSONResponse
21
+ from pydantic import BaseModel
22
+ from starlette.requests import Request
23
+
24
+ from ... import db
25
+ from ...functions.loader import get_registry
26
+ from ...functions.schemas import FunctionMeta
27
+ from ...settings import get_settings
28
+ from ..errors import AdminError
29
+ from ..schemas import (
30
+ FunctionInvokeRequest,
31
+ FunctionInvokeResponse,
32
+ FunctionRoute,
33
+ FunctionSourceResponse,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ _MAX_SOURCE_BYTES = 1_000_000
40
+
41
+
42
+ def list_routes() -> list[FunctionRoute]:
43
+ return [
44
+ FunctionRoute(
45
+ name=meta.name,
46
+ path=str(meta.path),
47
+ methods=list(meta.methods),
48
+ auth=meta.auth,
49
+ )
50
+ for meta in get_registry().list()
51
+ ]
52
+
53
+
54
+ def _resolve_meta(name: str) -> FunctionMeta:
55
+ meta = get_registry().get(name)
56
+ if meta is None:
57
+ raise AdminError("function_not_found", f"function {name!r} not found", 404)
58
+ return meta
59
+
60
+
61
+ def read_source(name: str) -> FunctionSourceResponse:
62
+ meta = _resolve_meta(name)
63
+ try:
64
+ size = meta.path.stat().st_size
65
+ except OSError as exc:
66
+ raise AdminError(
67
+ "function_source_unreadable",
68
+ f"could not stat function source: {exc}",
69
+ 500,
70
+ ) from exc
71
+ if size > _MAX_SOURCE_BYTES:
72
+ raise AdminError(
73
+ "function_source_too_large",
74
+ f"source exceeds {_MAX_SOURCE_BYTES} bytes",
75
+ 413,
76
+ )
77
+ try:
78
+ text = meta.path.read_text(encoding="utf-8")
79
+ except OSError as exc:
80
+ raise AdminError(
81
+ "function_source_unreadable",
82
+ f"could not read function source: {exc}",
83
+ 500,
84
+ ) from exc
85
+ return FunctionSourceResponse(
86
+ name=meta.name,
87
+ path=str(meta.path),
88
+ source=text,
89
+ size=size,
90
+ )
91
+
92
+
93
+ def _build_request(
94
+ name: str,
95
+ method: str,
96
+ headers: dict[str, str],
97
+ body: bytes,
98
+ query: str | None,
99
+ ) -> Request:
100
+ """Build a Starlette Request that mirrors a real /functions/{name} call."""
101
+ raw_headers: list[tuple[bytes, bytes]] = [
102
+ (k.lower().encode("latin-1"), v.encode("latin-1"))
103
+ for k, v in headers.items()
104
+ ]
105
+ has_content_length = any(k == b"content-length" for k, _ in raw_headers)
106
+ if not has_content_length:
107
+ raw_headers.append((b"content-length", str(len(body)).encode("latin-1")))
108
+
109
+ path = f"/functions/{name}"
110
+ scope = {
111
+ "type": "http",
112
+ "asgi": {"version": "3.0", "spec_version": "2.3"},
113
+ "http_version": "1.1",
114
+ "method": method,
115
+ "scheme": "http",
116
+ "server": ("admin-invoke", 80),
117
+ "client": ("127.0.0.1", 0),
118
+ "root_path": "",
119
+ "path": path,
120
+ "raw_path": path.encode("ascii"),
121
+ "query_string": (query or "").encode("latin-1"),
122
+ "headers": raw_headers,
123
+ }
124
+
125
+ sent = False
126
+
127
+ async def receive() -> dict[str, Any]:
128
+ nonlocal sent
129
+ if not sent:
130
+ sent = True
131
+ return {"type": "http.request", "body": body, "more_body": False}
132
+ return {"type": "http.disconnect"}
133
+
134
+ return Request(scope, receive=receive)
135
+
136
+
137
+ def _translate(result: Any) -> Response:
138
+ """Same translation contract as functions/router.py — kept local to avoid
139
+ importing a private symbol across modules."""
140
+ if isinstance(result, Response):
141
+ return result
142
+ if isinstance(result, tuple) and len(result) == 2 and isinstance(result[0], int):
143
+ status_code, payload = result
144
+ inner = _translate(payload)
145
+ inner.status_code = status_code
146
+ return inner
147
+ if isinstance(result, BaseModel):
148
+ return JSONResponse(content=result.model_dump(mode="json"))
149
+ if isinstance(result, (dict, list)) or result is None:
150
+ return JSONResponse(content=result)
151
+ if isinstance(result, (str, int, bool)):
152
+ return JSONResponse(content=result)
153
+ if isinstance(result, bytes):
154
+ return Response(content=result, media_type="application/octet-stream")
155
+ raise AdminError(
156
+ "function_invalid_return",
157
+ f"handler returned unsupported type {type(result).__name__!r}",
158
+ 500,
159
+ )
160
+
161
+
162
+ def _serialize_response(response: Response, elapsed_ms: float) -> FunctionInvokeResponse:
163
+ raw_body = getattr(response, "body", b"") or b""
164
+ if isinstance(raw_body, memoryview):
165
+ raw_body = bytes(raw_body)
166
+ try:
167
+ body_text = raw_body.decode("utf-8")
168
+ except UnicodeDecodeError:
169
+ body_text = ""
170
+ parsed: Any | None = None
171
+ if body_text:
172
+ try:
173
+ parsed = json.loads(body_text)
174
+ except (json.JSONDecodeError, ValueError):
175
+ parsed = None
176
+ headers: dict[str, str] = {}
177
+ for key, value in response.headers.items():
178
+ headers[key] = value
179
+ return FunctionInvokeResponse(
180
+ status=response.status_code,
181
+ headers=headers,
182
+ body=parsed,
183
+ body_text=body_text,
184
+ elapsed_ms=elapsed_ms,
185
+ )
186
+
187
+
188
+ async def invoke_function(
189
+ name: str,
190
+ payload: FunctionInvokeRequest,
191
+ ) -> FunctionInvokeResponse:
192
+ """Invoke a discovered function under ``service_role``.
193
+
194
+ The admin session never becomes an end-user JWT — claims set on the
195
+ connection are informational (``role=service_role``) so audit/stamping
196
+ helpers behave, but RLS is bypassed by ``service_role`` regardless.
197
+ """
198
+ meta = _resolve_meta(name)
199
+ method = payload.method.upper()
200
+ if method not in meta.methods:
201
+ raise AdminError(
202
+ "method_not_allowed",
203
+ f"function {name!r} does not allow {method}",
204
+ 405,
205
+ )
206
+
207
+ if payload.body is None:
208
+ body_bytes = b""
209
+ elif isinstance(payload.body, (dict, list)):
210
+ body_bytes = json.dumps(payload.body).encode("utf-8")
211
+ elif isinstance(payload.body, str):
212
+ body_bytes = payload.body.encode("utf-8")
213
+ else:
214
+ body_bytes = json.dumps(payload.body).encode("utf-8")
215
+
216
+ headers = dict(payload.headers or {})
217
+ has_content_type = any(k.lower() == "content-type" for k in headers)
218
+ if body_bytes and not has_content_type and isinstance(payload.body, (dict, list)):
219
+ headers["content-type"] = "application/json"
220
+
221
+ request = _build_request(meta.name, method, headers, body_bytes, payload.query)
222
+
223
+ settings = get_settings()
224
+ handler = meta.handler
225
+ assert handler is not None # validated at load
226
+
227
+ from ...functions.context import build_ctx # local import: avoid cycles
228
+
229
+ started = time.monotonic()
230
+ try:
231
+ async with db.as_service_role(claims={"role": "service_role"}) as conn:
232
+ ctx = build_ctx(
233
+ conn=conn,
234
+ user=None,
235
+ request=request,
236
+ raw_jwt=None,
237
+ settings=settings,
238
+ )
239
+ try:
240
+ result = await asyncio.wait_for(
241
+ handler(request, ctx),
242
+ timeout=settings.functions_max_handler_seconds,
243
+ )
244
+ finally:
245
+ try:
246
+ await ctx.postgrest.aclose()
247
+ except Exception:
248
+ logger.warning(
249
+ "admin.functions: postgrest close failed for %s",
250
+ meta.name,
251
+ exc_info=True,
252
+ )
253
+ except TimeoutError:
254
+ elapsed = (time.monotonic() - started) * 1000.0
255
+ return FunctionInvokeResponse(
256
+ status=status.HTTP_504_GATEWAY_TIMEOUT,
257
+ headers={"content-type": "application/json"},
258
+ body={
259
+ "code": "function_timeout",
260
+ "message": (
261
+ f"function exceeded {settings.functions_max_handler_seconds}s"
262
+ ),
263
+ },
264
+ body_text="",
265
+ elapsed_ms=elapsed,
266
+ )
267
+ except AdminError:
268
+ raise
269
+ except Exception as exc:
270
+ logger.warning(
271
+ "admin.functions: handler %s raised", meta.name, exc_info=True
272
+ )
273
+ elapsed = (time.monotonic() - started) * 1000.0
274
+ return FunctionInvokeResponse(
275
+ status=status.HTTP_500_INTERNAL_SERVER_ERROR,
276
+ headers={"content-type": "application/json"},
277
+ body={
278
+ "code": "function_error",
279
+ "message": str(exc) or "function raised an unhandled exception",
280
+ },
281
+ body_text="",
282
+ elapsed_ms=elapsed,
283
+ )
284
+
285
+ elapsed = (time.monotonic() - started) * 1000.0
286
+ response = _translate(result)
287
+ return _serialize_response(response, elapsed)
@@ -0,0 +1,282 @@
1
+ """Admin service layer for the jobs queue surface.
2
+
3
+ Pure async functions over ``asyncpg.Connection``. No FastAPI imports.
4
+ """
5
+
6
+ import json
7
+ import logging
8
+ from uuid import UUID
9
+
10
+ import asyncpg
11
+
12
+ from ...jobs import service as jobs_service
13
+ from ...jobs.schemas import JobRecord
14
+ from ..errors import AdminError
15
+ from ..schemas import AdminCronRow, AdminJobRow, AdminJobsPage, PgCronHealth
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _row_to_admin_job(row: asyncpg.Record) -> AdminJobRow:
21
+ raw_payload = row["payload"]
22
+ if isinstance(raw_payload, str):
23
+ raw_payload = json.loads(raw_payload) if raw_payload else {}
24
+ return AdminJobRow(
25
+ id=row["id"],
26
+ name=row["name"],
27
+ version=row["version"],
28
+ status=row["status"],
29
+ payload=raw_payload,
30
+ queue=row["queue"],
31
+ user_id=row["user_id"],
32
+ attempts=row["attempts"],
33
+ max_attempts=row["max_attempts"],
34
+ run_at=row["run_at"],
35
+ locked_at=row["locked_at"],
36
+ locked_by=row["locked_by"],
37
+ role=row["role"],
38
+ finished_at=row["finished_at"],
39
+ created_at=row["created_at"],
40
+ last_error=row.get("last_error"),
41
+ )
42
+
43
+
44
+ def _job_record_to_admin_job(rec: JobRecord) -> AdminJobRow:
45
+ return AdminJobRow(
46
+ id=rec.id,
47
+ name=rec.name,
48
+ version=rec.version,
49
+ status=rec.status,
50
+ payload=rec.payload,
51
+ queue=rec.queue,
52
+ user_id=rec.user_id,
53
+ attempts=rec.attempts,
54
+ max_attempts=rec.max_attempts,
55
+ run_at=rec.run_at,
56
+ locked_at=rec.locked_at,
57
+ locked_by=rec.locked_by,
58
+ role=rec.role,
59
+ finished_at=rec.finished_at,
60
+ created_at=rec.created_at,
61
+ last_error=rec.last_error,
62
+ )
63
+
64
+
65
+ def _row_to_admin_cron(row: asyncpg.Record) -> AdminCronRow:
66
+ raw_payload = row["payload"]
67
+ if isinstance(raw_payload, str):
68
+ raw_payload = json.loads(raw_payload) if raw_payload else {}
69
+ return AdminCronRow(
70
+ id=row["id"],
71
+ name=row["name"],
72
+ cron_expr=row["cron_expr"],
73
+ job_name=row["job_name"],
74
+ job_version=row["job_version"],
75
+ payload=raw_payload,
76
+ queue=row["queue"],
77
+ enabled=row["enabled"],
78
+ last_fire_at=row["last_fire_at"],
79
+ created_at=row["created_at"],
80
+ pg_cron_active=row.get("pg_cron_active"),
81
+ )
82
+
83
+
84
+ async def _fetch_admin_job(conn: asyncpg.Connection, job_id: UUID) -> AdminJobRow:
85
+ row = await conn.fetchrow(
86
+ """
87
+ select id, name, version, status, payload, queue, user_id,
88
+ attempts, max_attempts, run_at, locked_at, locked_by,
89
+ role, finished_at, created_at, last_error
90
+ from jobs.jobs
91
+ where id = $1
92
+ """,
93
+ job_id,
94
+ )
95
+ if row is None:
96
+ raise AdminError("job_not_found", f"job {job_id} not found", 404)
97
+ return _row_to_admin_job(row)
98
+
99
+
100
+ async def list_queue(
101
+ conn: asyncpg.Connection,
102
+ *,
103
+ status: str | None = None,
104
+ queue: str | None = None,
105
+ limit: int = 50,
106
+ offset: int = 0,
107
+ ) -> AdminJobsPage:
108
+ clauses: list[str] = []
109
+ args: list = []
110
+ idx = 1
111
+
112
+ if status is not None:
113
+ clauses.append(f"status = ${idx}")
114
+ args.append(status)
115
+ idx += 1
116
+ if queue is not None:
117
+ clauses.append(f"queue = ${idx}")
118
+ args.append(queue)
119
+ idx += 1
120
+
121
+ where = f"where {' and '.join(clauses)}" if clauses else ""
122
+
123
+ rows = await conn.fetch(
124
+ f"""
125
+ select id, name, version, status, payload, queue, user_id,
126
+ attempts, max_attempts, run_at, locked_at, locked_by,
127
+ role, finished_at, created_at, last_error
128
+ from jobs.jobs
129
+ {where}
130
+ order by created_at desc
131
+ limit ${idx} offset ${idx + 1}
132
+ """,
133
+ *args,
134
+ limit,
135
+ offset,
136
+ )
137
+
138
+ total = await conn.fetchval(
139
+ f"select count(*) from jobs.jobs {where}",
140
+ *args,
141
+ )
142
+
143
+ counts = await count_by_status(conn)
144
+
145
+ return AdminJobsPage(
146
+ rows=[_row_to_admin_job(r) for r in rows],
147
+ total=total or 0,
148
+ counts=counts,
149
+ )
150
+
151
+
152
+ async def count_by_status(conn: asyncpg.Connection) -> dict[str, int]:
153
+ rows = await conn.fetch(
154
+ """
155
+ select status, count(*) as cnt
156
+ from jobs.jobs
157
+ group by status
158
+ """
159
+ )
160
+ return {r["status"]: r["cnt"] for r in rows}
161
+
162
+
163
+ async def retry_job(conn: asyncpg.Connection, job_id: UUID) -> AdminJobRow:
164
+ try:
165
+ await jobs_service.retry(conn, job_id)
166
+ except jobs_service.JobError as exc:
167
+ raise AdminError(exc.code, exc.message, exc.status) from exc
168
+ return await _fetch_admin_job(conn, job_id)
169
+
170
+
171
+ async def cancel_job(conn: asyncpg.Connection, job_id: UUID) -> AdminJobRow:
172
+ try:
173
+ await jobs_service.cancel(conn, job_id)
174
+ except jobs_service.JobError as exc:
175
+ raise AdminError(exc.code, exc.message, exc.status) from exc
176
+ return await _fetch_admin_job(conn, job_id)
177
+
178
+
179
+ async def list_crons(conn: asyncpg.Connection) -> list[AdminCronRow]:
180
+ pg_cron_installed = await _pg_cron_installed(conn)
181
+
182
+ rows = await conn.fetch(
183
+ """
184
+ select id, name, cron_expr, job_name, job_version, payload,
185
+ queue, enabled, last_fire_at, created_at
186
+ from jobs.cron_schedules
187
+ order by name
188
+ """
189
+ )
190
+
191
+ pg_cron_map: dict[str, bool] = {}
192
+ if pg_cron_installed:
193
+ cron_rows = await conn.fetch(
194
+ """
195
+ select jobname, active
196
+ from cron.job
197
+ """
198
+ )
199
+ pg_cron_map = {r["jobname"]: r["active"] for r in cron_rows}
200
+
201
+ results: list[AdminCronRow] = []
202
+ for r in rows:
203
+ cron = _row_to_admin_cron(r)
204
+ if pg_cron_installed:
205
+ cron.pg_cron_active = pg_cron_map.get(r["name"])
206
+ results.append(cron)
207
+
208
+ return results
209
+
210
+
211
+ async def pg_cron_health(conn: asyncpg.Connection) -> PgCronHealth:
212
+ installed = await _pg_cron_installed(conn)
213
+ if not installed:
214
+ return PgCronHealth(installed=False)
215
+
216
+ ext_version = await conn.fetchval(
217
+ """
218
+ select extversion from pg_extension where extname = 'pg_cron'
219
+ """
220
+ )
221
+
222
+ try:
223
+ active_jobs = (
224
+ await conn.fetchval(
225
+ """
226
+ select count(*) from cron.job where active
227
+ """
228
+ )
229
+ or 0
230
+ )
231
+ except Exception:
232
+ logger.warning("pg_cron_health: cron.job not accessible", exc_info=True)
233
+ active_jobs = 0
234
+
235
+ return PgCronHealth(
236
+ installed=True,
237
+ active_jobs=active_jobs,
238
+ extension_version=ext_version,
239
+ )
240
+
241
+
242
+ async def run_cron_now(conn: asyncpg.Connection, cron_name: str) -> AdminJobRow:
243
+ row = await conn.fetchrow(
244
+ """
245
+ select id, name, job_name, job_version, payload, queue, enabled
246
+ from jobs.cron_schedules
247
+ where name = $1
248
+ """,
249
+ cron_name,
250
+ )
251
+ if row is None:
252
+ raise AdminError("cron_not_found", f"cron schedule {cron_name!r} not found", 404)
253
+ if not row["enabled"]:
254
+ raise AdminError("cron_disabled", f"cron schedule {cron_name!r} is disabled", 409)
255
+
256
+ raw_payload = row["payload"]
257
+ if isinstance(raw_payload, str):
258
+ raw_payload = json.loads(raw_payload) if raw_payload else {}
259
+
260
+ try:
261
+ result = await jobs_service.enqueue(
262
+ conn,
263
+ name=row["job_name"],
264
+ payload=raw_payload,
265
+ queue=row["queue"],
266
+ version=row["job_version"],
267
+ )
268
+ except jobs_service.JobError as exc:
269
+ raise AdminError(exc.code, exc.message, exc.status) from exc
270
+
271
+ return _job_record_to_admin_job(result.job)
272
+
273
+
274
+ async def _pg_cron_installed(conn: asyncpg.Connection) -> bool:
275
+ val = await conn.fetchval(
276
+ """
277
+ select exists(
278
+ select 1 from pg_extension where extname = 'pg_cron'
279
+ )
280
+ """
281
+ )
282
+ return bool(val)