supython 0.1.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.
- supython/__init__.py +24 -0
- supython/admin/__init__.py +3 -0
- supython/admin/api/__init__.py +24 -0
- supython/admin/api/auth.py +118 -0
- supython/admin/api/auth_templates.py +67 -0
- supython/admin/api/auth_users.py +225 -0
- supython/admin/api/db.py +174 -0
- supython/admin/api/functions.py +92 -0
- supython/admin/api/jobs.py +192 -0
- supython/admin/api/ops.py +224 -0
- supython/admin/api/realtime.py +281 -0
- supython/admin/api/service_auth.py +49 -0
- supython/admin/api/service_auth_templates.py +83 -0
- supython/admin/api/service_auth_users.py +346 -0
- supython/admin/api/service_db.py +214 -0
- supython/admin/api/service_functions.py +287 -0
- supython/admin/api/service_jobs.py +282 -0
- supython/admin/api/service_ops.py +213 -0
- supython/admin/api/service_realtime.py +30 -0
- supython/admin/api/service_storage.py +220 -0
- supython/admin/api/storage.py +117 -0
- supython/admin/api/system.py +37 -0
- supython/admin/audit.py +29 -0
- supython/admin/deps.py +22 -0
- supython/admin/errors.py +16 -0
- supython/admin/schemas.py +310 -0
- supython/admin/session.py +52 -0
- supython/admin/spa.py +38 -0
- supython/admin/static/assets/Alert-dluGVkos.js +49 -0
- supython/admin/static/assets/Audit-Njung3HI.js +2 -0
- supython/admin/static/assets/Backups-DzPlFgrm.js +2 -0
- supython/admin/static/assets/Buckets-ByacGkU1.js +2 -0
- supython/admin/static/assets/Channels-BoIuTtam.js +353 -0
- supython/admin/static/assets/ChevronRight-CtQH1EQ1.js +2 -0
- supython/admin/static/assets/CodeViewer-Bqy7-wvH.js +2 -0
- supython/admin/static/assets/Crons-B67vc39F.js +2 -0
- supython/admin/static/assets/DashboardView-CUTFVL6k.js +2 -0
- supython/admin/static/assets/DataTable-COAAWEft.js +747 -0
- supython/admin/static/assets/DescriptionsItem-P8JUDaBs.js +75 -0
- supython/admin/static/assets/DrawerContent-TpYTFgF1.js +139 -0
- supython/admin/static/assets/Empty-cr2r7e2u.js +25 -0
- supython/admin/static/assets/EmptyState-DeDck-OL.js +2 -0
- supython/admin/static/assets/Grid-hFkp9F4P.js +2 -0
- supython/admin/static/assets/Input-DppYTq9C.js +259 -0
- supython/admin/static/assets/Invoke-DW3Nveeh.js +2 -0
- supython/admin/static/assets/JsonField-DibyJgun.js +2 -0
- supython/admin/static/assets/LoginView-BjLyE3Ds.css +1 -0
- supython/admin/static/assets/LoginView-CoOjECT_.js +111 -0
- supython/admin/static/assets/Logs-D9WYrnIT.js +2 -0
- supython/admin/static/assets/Logs-DS1XPa0h.css +1 -0
- supython/admin/static/assets/Migrations-DOSC2ddQ.js +2 -0
- supython/admin/static/assets/ObjectBrowser-_5w8vOX8.js +2 -0
- supython/admin/static/assets/Queue-CywZs6vI.js +2 -0
- supython/admin/static/assets/RefreshTokens-Ccjr53jg.js +2 -0
- supython/admin/static/assets/RlsEditor-BSlH9vSc.js +2 -0
- supython/admin/static/assets/Routes-BiLXE49D.js +2 -0
- supython/admin/static/assets/Routes-C-ianIGD.css +1 -0
- supython/admin/static/assets/SchemaBrowser-DKy2_KQi.css +1 -0
- supython/admin/static/assets/SchemaBrowser-XFvFbtDB.js +2 -0
- supython/admin/static/assets/Select-DIzZyRZb.js +434 -0
- supython/admin/static/assets/Space-n5-XcguU.js +400 -0
- supython/admin/static/assets/SqlEditor-b8pTsILY.js +3 -0
- supython/admin/static/assets/SqlWorkspace-BUS7IntH.js +104 -0
- supython/admin/static/assets/TableData-CQIagLKn.js +2 -0
- supython/admin/static/assets/Tag-D1fOKpTH.js +72 -0
- supython/admin/static/assets/Templates-BS-ugkdq.js +2 -0
- supython/admin/static/assets/Thing-CEAniuMg.js +107 -0
- supython/admin/static/assets/Users-wzwajhlh.js +2 -0
- supython/admin/static/assets/_plugin-vue_export-helper-DGA9ry_j.js +1 -0
- supython/admin/static/assets/dist-VXIJLCYq.js +13 -0
- supython/admin/static/assets/format-length-CGCY1rMh.js +2 -0
- supython/admin/static/assets/get-Ca6unauB.js +2 -0
- supython/admin/static/assets/index-CeE6v959.js +951 -0
- supython/admin/static/assets/pinia-COXwfrOX.js +2 -0
- supython/admin/static/assets/resources-Bt6thQCD.js +44 -0
- supython/admin/static/assets/use-locale-mtgM0a3a.js +2 -0
- supython/admin/static/assets/use-merged-state-BvhkaHNX.js +2 -0
- supython/admin/static/assets/useConfirm-tMjvBFXR.js +2 -0
- supython/admin/static/assets/useResource-C_rJCY8C.js +2 -0
- supython/admin/static/assets/useTable-CnZc5zhi.js +363 -0
- supython/admin/static/assets/useTable-Dg0XlRlq.css +1 -0
- supython/admin/static/assets/useToast-DsZKx0IX.js +2 -0
- supython/admin/static/assets/utils-sbXoq7Ir.js +2 -0
- supython/admin/static/favicon.svg +1 -0
- supython/admin/static/icons.svg +24 -0
- supython/admin/static/index.html +24 -0
- supython/app.py +162 -0
- supython/auth/__init__.py +3 -0
- supython/auth/_email_job.py +11 -0
- supython/auth/providers/__init__.py +34 -0
- supython/auth/providers/github.py +22 -0
- supython/auth/providers/google.py +19 -0
- supython/auth/providers/oauth.py +56 -0
- supython/auth/providers/registry.py +16 -0
- supython/auth/ratelimit.py +39 -0
- supython/auth/router.py +282 -0
- supython/auth/schemas.py +79 -0
- supython/auth/service.py +587 -0
- supython/backups/__init__.py +24 -0
- supython/backups/_backup_job.py +170 -0
- supython/backups/schemas.py +18 -0
- supython/backups/service.py +217 -0
- supython/body_size.py +184 -0
- supython/cli.py +1663 -0
- supython/client/__init__.py +67 -0
- supython/client/_auth.py +249 -0
- supython/client/_client.py +145 -0
- supython/client/_config.py +92 -0
- supython/client/_functions.py +69 -0
- supython/client/_storage.py +255 -0
- supython/client/py.typed +0 -0
- supython/db.py +151 -0
- supython/db_admin.py +8 -0
- supython/extensions.py +36 -0
- supython/functions/__init__.py +19 -0
- supython/functions/context.py +262 -0
- supython/functions/loader.py +307 -0
- supython/functions/router.py +228 -0
- supython/functions/schemas.py +50 -0
- supython/gen/__init__.py +5 -0
- supython/gen/_introspect.py +137 -0
- supython/gen/types_py.py +270 -0
- supython/gen/types_ts.py +365 -0
- supython/health.py +229 -0
- supython/hooks.py +117 -0
- supython/jobs/__init__.py +31 -0
- supython/jobs/backends.py +97 -0
- supython/jobs/context.py +58 -0
- supython/jobs/cron.py +152 -0
- supython/jobs/cron_inproc.py +119 -0
- supython/jobs/decorators.py +76 -0
- supython/jobs/registry.py +79 -0
- supython/jobs/router.py +136 -0
- supython/jobs/schemas.py +92 -0
- supython/jobs/service.py +311 -0
- supython/jobs/worker.py +219 -0
- supython/jwks.py +257 -0
- supython/keyset.py +279 -0
- supython/logging_config.py +291 -0
- supython/mail.py +33 -0
- supython/mailer.py +65 -0
- supython/migrate.py +81 -0
- supython/migrations/0001_extensions_and_roles.sql +46 -0
- supython/migrations/0002_auth_schema.sql +66 -0
- supython/migrations/0003_demo_todos.sql +42 -0
- supython/migrations/0004_auth_v0_2.sql +47 -0
- supython/migrations/0005_storage_schema.sql +117 -0
- supython/migrations/0006_realtime_schema.sql +206 -0
- supython/migrations/0007_jobs_schema.sql +254 -0
- supython/migrations/0008_jobs_last_error.sql +56 -0
- supython/migrations/0009_auth_rate_limits.sql +33 -0
- supython/migrations/0010_worker_heartbeat.sql +14 -0
- supython/migrations/0011_admin_schema.sql +45 -0
- supython/migrations/0012_auth_banned_until.sql +10 -0
- supython/migrations/0013_email_templates.sql +19 -0
- supython/migrations/0014_realtime_payload_warning.sql +96 -0
- supython/migrations/0015_backups_schema.sql +14 -0
- supython/passwords.py +15 -0
- supython/realtime/__init__.py +6 -0
- supython/realtime/broker.py +814 -0
- supython/realtime/protocol.py +234 -0
- supython/realtime/router.py +184 -0
- supython/realtime/schemas.py +207 -0
- supython/realtime/service.py +261 -0
- supython/realtime/topics.py +175 -0
- supython/realtime/websocket.py +586 -0
- supython/scaffold/__init__.py +5 -0
- supython/scaffold/init_project.py +144 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/apps_hooks.py.tmpl +11 -0
- supython/scaffold/templates/apps_jobs.py.tmpl +8 -0
- supython/scaffold/templates/asgi.py.tmpl +14 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +45 -0
- supython/scaffold/templates/docker_postgres_Dockerfile.tmpl +9 -0
- supython/scaffold/templates/docker_postgres_postgresql.conf.tmpl +3 -0
- supython/scaffold/templates/env.example.tmpl +168 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/manage.py.tmpl +11 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/scaffold/templates/package_init.py.tmpl +1 -0
- supython/scaffold/templates/settings.py.tmpl +31 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +244 -0
- supython/settings_module.py +117 -0
- supython/storage/__init__.py +5 -0
- supython/storage/backends.py +392 -0
- supython/storage/router.py +341 -0
- supython/storage/schemas.py +50 -0
- supython/storage/service.py +445 -0
- supython/storage/signing.py +119 -0
- supython/tokens.py +85 -0
- supython-0.1.0.dist-info/METADATA +756 -0
- supython-0.1.0.dist-info/RECORD +200 -0
- supython-0.1.0.dist-info/WHEEL +4 -0
- supython-0.1.0.dist-info/entry_points.txt +2 -0
- supython-0.1.0.dist-info/licenses/LICENSE +21 -0
supython/gen/types_ts.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Render a TypeScript ``Database`` interface from Postgres schema introspection.
|
|
2
|
+
|
|
3
|
+
Public entry point: :func:`render_types_ts`. The returned string is a full
|
|
4
|
+
TypeScript module exporting a ``Database`` interface compatible with
|
|
5
|
+
``@supabase/postgrest-js`` and ``@supython/sdk``.
|
|
6
|
+
|
|
7
|
+
The generator opens its own asyncpg connection and closes it before returning;
|
|
8
|
+
it does not depend on the supython service running.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
import asyncpg
|
|
14
|
+
|
|
15
|
+
from ..settings import get_settings
|
|
16
|
+
from ._introspect import (
|
|
17
|
+
_fetch_columns,
|
|
18
|
+
_fetch_enums,
|
|
19
|
+
_fetch_relationships,
|
|
20
|
+
_fetch_tables,
|
|
21
|
+
)
|
|
22
|
+
from .types_py import _class_name
|
|
23
|
+
|
|
24
|
+
_TS_TYPE_MAP: dict[str, str] = {
|
|
25
|
+
# strings
|
|
26
|
+
"text": "string",
|
|
27
|
+
"varchar": "string",
|
|
28
|
+
"bpchar": "string",
|
|
29
|
+
"char": "string",
|
|
30
|
+
"citext": "string",
|
|
31
|
+
"name": "string",
|
|
32
|
+
"inet": "string",
|
|
33
|
+
"cidr": "string",
|
|
34
|
+
"macaddr": "string",
|
|
35
|
+
"tsvector": "string",
|
|
36
|
+
"tsquery": "string",
|
|
37
|
+
"bit": "string",
|
|
38
|
+
"varbit": "string",
|
|
39
|
+
"uuid": "string",
|
|
40
|
+
"bytea": "string",
|
|
41
|
+
"oid": "string",
|
|
42
|
+
# numbers
|
|
43
|
+
"int2": "number",
|
|
44
|
+
"int4": "number",
|
|
45
|
+
"int8": "number",
|
|
46
|
+
"float4": "number",
|
|
47
|
+
"float8": "number",
|
|
48
|
+
"numeric": "number",
|
|
49
|
+
"money": "number",
|
|
50
|
+
# boolean
|
|
51
|
+
"bool": "boolean",
|
|
52
|
+
# dates/times as ISO strings
|
|
53
|
+
"timestamptz": "string",
|
|
54
|
+
"timestamp": "string",
|
|
55
|
+
"date": "string",
|
|
56
|
+
"time": "string",
|
|
57
|
+
"timetz": "string",
|
|
58
|
+
"interval": "string",
|
|
59
|
+
# json
|
|
60
|
+
"json": "Record<string, unknown>",
|
|
61
|
+
"jsonb": "Record<string, unknown>",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# TypeScript reserved words that cannot be used as bare identifiers
|
|
65
|
+
_TS_KEYWORDS: set[str] = {
|
|
66
|
+
# strict mode reserved
|
|
67
|
+
"break",
|
|
68
|
+
"case",
|
|
69
|
+
"catch",
|
|
70
|
+
"class",
|
|
71
|
+
"const",
|
|
72
|
+
"continue",
|
|
73
|
+
"debugger",
|
|
74
|
+
"default",
|
|
75
|
+
"delete",
|
|
76
|
+
"do",
|
|
77
|
+
"else",
|
|
78
|
+
"enum",
|
|
79
|
+
"export",
|
|
80
|
+
"extends",
|
|
81
|
+
"false",
|
|
82
|
+
"finally",
|
|
83
|
+
"for",
|
|
84
|
+
"function",
|
|
85
|
+
"if",
|
|
86
|
+
"import",
|
|
87
|
+
"in",
|
|
88
|
+
"instanceof",
|
|
89
|
+
"new",
|
|
90
|
+
"null",
|
|
91
|
+
"return",
|
|
92
|
+
"super",
|
|
93
|
+
"switch",
|
|
94
|
+
"this",
|
|
95
|
+
"throw",
|
|
96
|
+
"true",
|
|
97
|
+
"try",
|
|
98
|
+
"typeof",
|
|
99
|
+
"var",
|
|
100
|
+
"void",
|
|
101
|
+
"while",
|
|
102
|
+
"with",
|
|
103
|
+
"implements",
|
|
104
|
+
"interface",
|
|
105
|
+
"let",
|
|
106
|
+
"package",
|
|
107
|
+
"private",
|
|
108
|
+
"protected",
|
|
109
|
+
"public",
|
|
110
|
+
"static",
|
|
111
|
+
"yield",
|
|
112
|
+
"await",
|
|
113
|
+
"abstract",
|
|
114
|
+
"as",
|
|
115
|
+
"asserts",
|
|
116
|
+
"async",
|
|
117
|
+
"constructor",
|
|
118
|
+
"declare",
|
|
119
|
+
"from",
|
|
120
|
+
"get",
|
|
121
|
+
"infer",
|
|
122
|
+
"intrinsic",
|
|
123
|
+
"is",
|
|
124
|
+
"keyof",
|
|
125
|
+
"module",
|
|
126
|
+
"namespace",
|
|
127
|
+
"never",
|
|
128
|
+
"of",
|
|
129
|
+
"readonly",
|
|
130
|
+
"require",
|
|
131
|
+
"set",
|
|
132
|
+
"type",
|
|
133
|
+
"unique",
|
|
134
|
+
"unknown",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _safe_ts_col_name(name: str) -> str:
|
|
139
|
+
"""Append _ if column name is a TS reserved word."""
|
|
140
|
+
if name in _TS_KEYWORDS:
|
|
141
|
+
return f"{name}_"
|
|
142
|
+
return name
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pg_to_ts(
|
|
146
|
+
udt_schema: str,
|
|
147
|
+
udt_name: str,
|
|
148
|
+
data_type: str,
|
|
149
|
+
element: tuple[str, str, str] | None,
|
|
150
|
+
enum_types: set[tuple[str, str]],
|
|
151
|
+
) -> str:
|
|
152
|
+
"""Return TypeScript type annotation string."""
|
|
153
|
+
if data_type == "ARRAY" and element is not None:
|
|
154
|
+
elem_type = _pg_to_ts(element[1], element[2], element[0], None, enum_types)
|
|
155
|
+
return f"{elem_type}[]"
|
|
156
|
+
|
|
157
|
+
if data_type == "USER-DEFINED" and (udt_schema, udt_name) in enum_types:
|
|
158
|
+
# Enum type - will be rendered as "schema.name" union type
|
|
159
|
+
return f"{udt_schema}.{udt_name}"
|
|
160
|
+
|
|
161
|
+
if udt_name in _TS_TYPE_MAP:
|
|
162
|
+
return _TS_TYPE_MAP[udt_name]
|
|
163
|
+
|
|
164
|
+
return "unknown"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _has_default(col: asyncpg.Record) -> bool:
|
|
168
|
+
"""Return True if column has a default value, is generated, or is identity."""
|
|
169
|
+
return (
|
|
170
|
+
col["column_default"] is not None
|
|
171
|
+
or col["is_generated"] != "NEVER"
|
|
172
|
+
or col["is_identity"] == "YES"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _render_table_shape(
|
|
177
|
+
cols: list[asyncpg.Record],
|
|
178
|
+
enum_types: set[tuple[str, str]],
|
|
179
|
+
relationships: list[dict],
|
|
180
|
+
) -> tuple[list[str], list[str], list[str], list[str]]:
|
|
181
|
+
"""Render Row, Insert, Update, and Relationships lines for a table.
|
|
182
|
+
|
|
183
|
+
Returns (row_lines, insert_lines, update_lines, relationships_lines).
|
|
184
|
+
"""
|
|
185
|
+
row_lines: list[str] = []
|
|
186
|
+
insert_lines: list[str] = []
|
|
187
|
+
update_lines: list[str] = []
|
|
188
|
+
|
|
189
|
+
for c in cols:
|
|
190
|
+
element = None
|
|
191
|
+
if c["element_data_type"]:
|
|
192
|
+
element = (
|
|
193
|
+
c["element_data_type"],
|
|
194
|
+
c["element_udt_schema"],
|
|
195
|
+
c["element_udt_name"],
|
|
196
|
+
)
|
|
197
|
+
ts_type = _pg_to_ts(
|
|
198
|
+
c["udt_schema"],
|
|
199
|
+
c["udt_name"],
|
|
200
|
+
c["data_type"],
|
|
201
|
+
element,
|
|
202
|
+
enum_types,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
col_name = _safe_ts_col_name(c["column_name"])
|
|
206
|
+
nullable = c["is_nullable"] == "YES"
|
|
207
|
+
has_default = _has_default(c)
|
|
208
|
+
|
|
209
|
+
# Row: required if NOT NULL
|
|
210
|
+
row_optional = nullable
|
|
211
|
+
row_lines.append(
|
|
212
|
+
f" {col_name}{'?' if row_optional else ''}: {ts_type};"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Insert: required if NOT NULL and no default
|
|
216
|
+
insert_optional = nullable or has_default
|
|
217
|
+
insert_lines.append(
|
|
218
|
+
f" {col_name}{'?' if insert_optional else ''}: {ts_type};"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Update: always optional
|
|
222
|
+
update_lines.append(f" {col_name}?: {ts_type};")
|
|
223
|
+
|
|
224
|
+
# Relationships
|
|
225
|
+
rel_lines: list[str] = []
|
|
226
|
+
if relationships:
|
|
227
|
+
for rel in relationships:
|
|
228
|
+
columns = rel["columns"]
|
|
229
|
+
referenced = rel["referencedRelation"]
|
|
230
|
+
ref_cols = rel["referencedColumns"]
|
|
231
|
+
rel_lines.append(" {")
|
|
232
|
+
fk_name = rel["foreignKeyName"]
|
|
233
|
+
rel_lines.append(f' foreignKeyName: "{fk_name}";')
|
|
234
|
+
cols_str = ", ".join(repr(c) for c in columns)
|
|
235
|
+
rel_lines.append(f" columns: [{cols_str}];")
|
|
236
|
+
rel_lines.append(f' referencedRelation: "{referenced}";')
|
|
237
|
+
ref_str = ", ".join(repr(c) for c in ref_cols)
|
|
238
|
+
rel_lines.append(f" referencedColumns: [{ref_str}];")
|
|
239
|
+
rel_lines.append(" },")
|
|
240
|
+
|
|
241
|
+
return row_lines, insert_lines, update_lines, rel_lines
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render(
|
|
245
|
+
schemas: list[str],
|
|
246
|
+
enums: dict[tuple[str, str], list[str]],
|
|
247
|
+
tables: list[tuple[str, str]],
|
|
248
|
+
columns: dict[tuple[str, str], list[asyncpg.Record]],
|
|
249
|
+
relationships: dict[tuple[str, str], list[dict]],
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Render the full TypeScript Database interface."""
|
|
252
|
+
# Build enum type names set
|
|
253
|
+
enum_types: set[tuple[str, str]] = set(enums.keys())
|
|
254
|
+
|
|
255
|
+
# Group tables by schema
|
|
256
|
+
by_schema: dict[str, list[tuple[str, str]]] = {}
|
|
257
|
+
for schema, table in tables:
|
|
258
|
+
by_schema.setdefault(schema, []).append((schema, table))
|
|
259
|
+
|
|
260
|
+
body: list[str] = []
|
|
261
|
+
|
|
262
|
+
# Emit enum type aliases at top level
|
|
263
|
+
for (schema, name), labels in sorted(enums.items()):
|
|
264
|
+
enum_name = _class_name(schema, name, schemas)
|
|
265
|
+
label_union = " | ".join(f'"{lbl}"' for lbl in labels)
|
|
266
|
+
body.append(f"export type {enum_name} = {label_union};")
|
|
267
|
+
body.append("")
|
|
268
|
+
|
|
269
|
+
# Start Database interface
|
|
270
|
+
body.append("export interface Database {")
|
|
271
|
+
|
|
272
|
+
for schema in sorted(schemas):
|
|
273
|
+
schema_tables = by_schema.get(schema, [])
|
|
274
|
+
|
|
275
|
+
body.append(f" {schema}: {{")
|
|
276
|
+
|
|
277
|
+
# Tables section
|
|
278
|
+
body.append(" Tables: {")
|
|
279
|
+
for s, table in schema_tables:
|
|
280
|
+
cols = columns.get((s, table), [])
|
|
281
|
+
if not cols:
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
rels = relationships.get((s, table), [])
|
|
285
|
+
|
|
286
|
+
row_lines, insert_lines, update_lines, rel_lines = _render_table_shape(
|
|
287
|
+
cols, enum_types, rels
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
body.append(f" {table}: {{")
|
|
291
|
+
body.append(" Row: {")
|
|
292
|
+
body.extend(row_lines)
|
|
293
|
+
body.append(" };")
|
|
294
|
+
body.append(" Insert: {")
|
|
295
|
+
body.extend(insert_lines)
|
|
296
|
+
body.append(" };")
|
|
297
|
+
body.append(" Update: {")
|
|
298
|
+
body.extend(update_lines)
|
|
299
|
+
body.append(" };")
|
|
300
|
+
|
|
301
|
+
if rel_lines:
|
|
302
|
+
body.append(" Relationships: [")
|
|
303
|
+
body.extend(rel_lines)
|
|
304
|
+
body.append(" ];")
|
|
305
|
+
|
|
306
|
+
body.append(" };")
|
|
307
|
+
body.append(" };")
|
|
308
|
+
|
|
309
|
+
# Views section - deferred (needs table_type tracking to separate
|
|
310
|
+
# from BASE TABLE entries already rendered in Tables above)
|
|
311
|
+
body.append(" Views: {")
|
|
312
|
+
body.append(" };")
|
|
313
|
+
|
|
314
|
+
# Functions section (RPC) - deferred
|
|
315
|
+
body.append(" Functions: {")
|
|
316
|
+
body.append(" };")
|
|
317
|
+
|
|
318
|
+
# Enums section - export as type aliases at schema level
|
|
319
|
+
schema_enums = [
|
|
320
|
+
(s, n) for s, n in enums if s == schema
|
|
321
|
+
]
|
|
322
|
+
if schema_enums:
|
|
323
|
+
body.append(" Enums: {")
|
|
324
|
+
for s, name in sorted(schema_enums):
|
|
325
|
+
enum_name = _class_name(s, name, schemas)
|
|
326
|
+
labels = enums[(s, name)]
|
|
327
|
+
label_union = " | ".join(f'"{lbl}"' for lbl in labels)
|
|
328
|
+
body.append(f" {name}: {label_union};")
|
|
329
|
+
body.append(" };")
|
|
330
|
+
else:
|
|
331
|
+
body.append(" Enums: {")
|
|
332
|
+
body.append(" };")
|
|
333
|
+
|
|
334
|
+
body.append(" };")
|
|
335
|
+
|
|
336
|
+
body.append("}")
|
|
337
|
+
|
|
338
|
+
# Build final output
|
|
339
|
+
lines: list[str] = []
|
|
340
|
+
lines.append("// Generated by `supython gen types --lang ts`. Do not edit.")
|
|
341
|
+
lines.append("//")
|
|
342
|
+
lines.append(f"// Schemas: {', '.join(schemas)}")
|
|
343
|
+
lines.append(f"// Generated at: {datetime.now(UTC).isoformat()}")
|
|
344
|
+
lines.append("")
|
|
345
|
+
lines.extend(body)
|
|
346
|
+
lines.append("")
|
|
347
|
+
|
|
348
|
+
return "\n".join(lines)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def render_types_ts(schemas: list[str]) -> str:
|
|
352
|
+
"""Connect to ``DATABASE_URL`` and return a rendered TypeScript ``Database`` interface."""
|
|
353
|
+
s = get_settings()
|
|
354
|
+
conn = await asyncpg.connect(s.database_url)
|
|
355
|
+
try:
|
|
356
|
+
enums = await _fetch_enums(conn, schemas)
|
|
357
|
+
tables = await _fetch_tables(conn, schemas)
|
|
358
|
+
columns = await _fetch_columns(conn, schemas)
|
|
359
|
+
relationships = await _fetch_relationships(conn, schemas)
|
|
360
|
+
finally:
|
|
361
|
+
await conn.close()
|
|
362
|
+
return _render(schemas, enums, tables, columns, relationships)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
__all__ = ["render_types_ts"]
|
supython/health.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Deep health probes: /livez, /readyz, /health."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from fastapi import APIRouter, Request
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from . import __version__, db
|
|
15
|
+
from .settings import get_settings
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
router = APIRouter(tags=["meta"])
|
|
20
|
+
CHECK_TIMEOUT_S = 2.0
|
|
21
|
+
WORKER_HEARTBEAT_MAX_AGE_S = 30.0
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Response models
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CheckResult(BaseModel):
|
|
29
|
+
status: str # "ok" | "fail"
|
|
30
|
+
detail: str | None = None
|
|
31
|
+
latency_ms: float | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ReadyzResponse(BaseModel):
|
|
35
|
+
status: str # "ok" | "fail"
|
|
36
|
+
checks: dict[str, CheckResult]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HealthResponse(BaseModel):
|
|
40
|
+
status: str
|
|
41
|
+
version: str
|
|
42
|
+
checks: dict[str, CheckResult]
|
|
43
|
+
postgrest_url: str
|
|
44
|
+
timestamp: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LivezResponse(BaseModel):
|
|
48
|
+
status: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Check functions (pure async, no FastAPI deps)
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _check_database() -> CheckResult:
|
|
57
|
+
"""Run select 1 with one total timeout."""
|
|
58
|
+
started = perf_counter()
|
|
59
|
+
try:
|
|
60
|
+
async with asyncio.timeout(CHECK_TIMEOUT_S):
|
|
61
|
+
async with db.acquire() as conn:
|
|
62
|
+
await conn.fetchval("select 1")
|
|
63
|
+
except TimeoutError:
|
|
64
|
+
return CheckResult(status="fail", detail="timeout")
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return CheckResult(status="fail", detail=str(exc))
|
|
67
|
+
elapsed = (perf_counter() - started) * 1000
|
|
68
|
+
return CheckResult(status="ok", latency_ms=round(elapsed, 1))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _check_postgrest() -> CheckResult:
|
|
72
|
+
"""HEAD / with one total timeout."""
|
|
73
|
+
settings = get_settings()
|
|
74
|
+
started = perf_counter()
|
|
75
|
+
try:
|
|
76
|
+
async with asyncio.timeout(CHECK_TIMEOUT_S):
|
|
77
|
+
async with httpx.AsyncClient(timeout=CHECK_TIMEOUT_S) as client:
|
|
78
|
+
resp = await client.head(f"{settings.postgrest_url.rstrip('/')}/")
|
|
79
|
+
if resp.status_code >= 500:
|
|
80
|
+
return CheckResult(
|
|
81
|
+
status="fail",
|
|
82
|
+
detail=f"postgrest_status={resp.status_code}",
|
|
83
|
+
)
|
|
84
|
+
except TimeoutError:
|
|
85
|
+
return CheckResult(status="fail", detail="timeout")
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
return CheckResult(status="fail", detail=str(exc))
|
|
88
|
+
elapsed = (perf_counter() - started) * 1000
|
|
89
|
+
return CheckResult(status="ok", latency_ms=round(elapsed, 1))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _check_broker(broker: Any | None) -> CheckResult:
|
|
93
|
+
"""Broker listener task alive and connection open."""
|
|
94
|
+
if broker is None:
|
|
95
|
+
return CheckResult(status="fail", detail="broker_not_started")
|
|
96
|
+
try:
|
|
97
|
+
async with asyncio.timeout(CHECK_TIMEOUT_S):
|
|
98
|
+
if not broker.is_healthy:
|
|
99
|
+
return CheckResult(status="fail", detail="listener_not_ready")
|
|
100
|
+
connection_count = broker.connection_count
|
|
101
|
+
except TimeoutError:
|
|
102
|
+
return CheckResult(status="fail", detail="timeout")
|
|
103
|
+
except Exception as exc:
|
|
104
|
+
return CheckResult(status="fail", detail=str(exc))
|
|
105
|
+
return CheckResult(status="ok", detail=f"connections={connection_count}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _check_worker() -> CheckResult:
|
|
109
|
+
"""Check freshness of the newest worker heartbeat row."""
|
|
110
|
+
started = perf_counter()
|
|
111
|
+
try:
|
|
112
|
+
async with asyncio.timeout(CHECK_TIMEOUT_S):
|
|
113
|
+
async with db.as_service_role() as conn:
|
|
114
|
+
row = await conn.fetchrow(
|
|
115
|
+
"""
|
|
116
|
+
select
|
|
117
|
+
count(*)::int as workers,
|
|
118
|
+
coalesce(sum(inflight), 0)::int as inflight,
|
|
119
|
+
extract(epoch from now() - max(last_heartbeat))::float as age_s
|
|
120
|
+
from jobs.worker_heartbeats
|
|
121
|
+
"""
|
|
122
|
+
)
|
|
123
|
+
except TimeoutError:
|
|
124
|
+
return CheckResult(status="fail", detail="timeout")
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
return CheckResult(status="fail", detail=str(exc))
|
|
127
|
+
|
|
128
|
+
if row is None or row["workers"] == 0 or row["age_s"] is None:
|
|
129
|
+
return CheckResult(status="fail", detail="no_worker_heartbeat")
|
|
130
|
+
|
|
131
|
+
age_s = float(row["age_s"])
|
|
132
|
+
if age_s > WORKER_HEARTBEAT_MAX_AGE_S:
|
|
133
|
+
return CheckResult(status="fail", detail=f"heartbeat_stale age_s={age_s:.1f}")
|
|
134
|
+
|
|
135
|
+
elapsed = (perf_counter() - started) * 1000
|
|
136
|
+
return CheckResult(
|
|
137
|
+
status="ok",
|
|
138
|
+
detail=f"workers={row['workers']} inflight={row['inflight']} age_s={age_s:.1f}",
|
|
139
|
+
latency_ms=round(elapsed, 1),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _check_pg_cron() -> CheckResult:
|
|
144
|
+
"""pg_cron extension present and cron.job readable."""
|
|
145
|
+
started = perf_counter()
|
|
146
|
+
try:
|
|
147
|
+
async with asyncio.timeout(CHECK_TIMEOUT_S):
|
|
148
|
+
async with db.as_service_role() as conn:
|
|
149
|
+
has_ext = await conn.fetchval(
|
|
150
|
+
"select exists(select 1 from pg_extension where extname = 'pg_cron')"
|
|
151
|
+
)
|
|
152
|
+
if not has_ext:
|
|
153
|
+
return CheckResult(status="fail", detail="pg_cron_extension_missing")
|
|
154
|
+
count = await conn.fetchval("select count(*) from cron.job")
|
|
155
|
+
except TimeoutError:
|
|
156
|
+
return CheckResult(status="fail", detail="timeout")
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
return CheckResult(status="fail", detail=str(exc))
|
|
159
|
+
elapsed = (perf_counter() - started) * 1000
|
|
160
|
+
return CheckResult(
|
|
161
|
+
status="ok",
|
|
162
|
+
detail=f"scheduled_jobs={count}",
|
|
163
|
+
latency_ms=round(elapsed, 1),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Orchestrator
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def run_checks(request: Request) -> dict[str, CheckResult]:
|
|
173
|
+
"""Run all applicable checks concurrently and return results."""
|
|
174
|
+
settings = get_settings()
|
|
175
|
+
app_state = request.app.state
|
|
176
|
+
|
|
177
|
+
coros: dict[str, Any] = {
|
|
178
|
+
"database": _check_database(),
|
|
179
|
+
"postgrest": _check_postgrest(),
|
|
180
|
+
}
|
|
181
|
+
if settings.realtime_enabled:
|
|
182
|
+
coros["broker"] = _check_broker(getattr(app_state, "broker", None))
|
|
183
|
+
if settings.jobs_enabled:
|
|
184
|
+
coros["worker"] = _check_worker()
|
|
185
|
+
if settings.jobs_cron_backend == "pg_cron":
|
|
186
|
+
coros["pg_cron"] = _check_pg_cron()
|
|
187
|
+
|
|
188
|
+
# Gather all checks concurrently
|
|
189
|
+
names = list(coros.keys())
|
|
190
|
+
results = await asyncio.gather(*coros.values())
|
|
191
|
+
return dict(zip(names, results, strict=True))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Endpoints
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@router.get("/livez", response_model=LivezResponse)
|
|
200
|
+
async def livez() -> LivezResponse:
|
|
201
|
+
return LivezResponse(status="ok")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.get("/readyz")
|
|
205
|
+
async def readyz(request: Request) -> JSONResponse:
|
|
206
|
+
checks = await run_checks(request)
|
|
207
|
+
all_ok = all(c.status == "ok" for c in checks.values())
|
|
208
|
+
body = ReadyzResponse(
|
|
209
|
+
status="ok" if all_ok else "fail",
|
|
210
|
+
checks=checks,
|
|
211
|
+
)
|
|
212
|
+
return JSONResponse(
|
|
213
|
+
content=body.model_dump(mode="json"),
|
|
214
|
+
status_code=200 if all_ok else 503,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.get("/health", response_model=HealthResponse)
|
|
219
|
+
async def health(request: Request) -> HealthResponse:
|
|
220
|
+
settings = get_settings()
|
|
221
|
+
checks = await run_checks(request)
|
|
222
|
+
all_ok = all(c.status == "ok" for c in checks.values())
|
|
223
|
+
return HealthResponse(
|
|
224
|
+
status="ok" if all_ok else "degraded",
|
|
225
|
+
version=__version__,
|
|
226
|
+
checks=checks,
|
|
227
|
+
postgrest_url=settings.postgrest_url,
|
|
228
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
229
|
+
)
|
supython/hooks.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Generic hook system for supython lifecycle events.
|
|
2
|
+
|
|
3
|
+
Provides ``on(event, fn)`` to register a handler and ``fire(event, *args)``
|
|
4
|
+
to invoke all registered handlers for that event in registration order.
|
|
5
|
+
|
|
6
|
+
The ``fn=None`` default on ``on()`` makes both ``@app.on_signup`` (bare)
|
|
7
|
+
and ``@app.on_signup()`` (called) work correctly.
|
|
8
|
+
|
|
9
|
+
``HookCtx`` / ``build_hook_ctx`` live here rather than in ``jobs/context.py``
|
|
10
|
+
so feature modules (auth, storage, realtime, jobs, ...) share one hook
|
|
11
|
+
contract. Keeping the type in ``hooks`` also breaks the ``auth → jobs``
|
|
12
|
+
import edge that would otherwise couple auth to the jobs package.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
24
|
+
import asyncpg
|
|
25
|
+
|
|
26
|
+
from .mailer import EmailBackend
|
|
27
|
+
from .settings import Settings
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_handlers: dict[str, list[Callable[..., Coroutine[Any, Any, None]]]] = defaultdict(list)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def on(
|
|
35
|
+
event: str,
|
|
36
|
+
fn: Callable[..., Coroutine[Any, Any, None]] | None = None,
|
|
37
|
+
) -> Callable[..., Coroutine[Any, Any, None]]:
|
|
38
|
+
"""Register an async handler for *event*.
|
|
39
|
+
|
|
40
|
+
Usage::
|
|
41
|
+
|
|
42
|
+
@hooks.on("signup")
|
|
43
|
+
async def on_signup(user, ctx):
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
Or imperatively::
|
|
47
|
+
|
|
48
|
+
hooks.on("signup", my_handler)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def _register(
|
|
52
|
+
f: Callable[..., Coroutine[Any, Any, None]],
|
|
53
|
+
) -> Callable[..., Coroutine[Any, Any, None]]:
|
|
54
|
+
_handlers[event].append(f)
|
|
55
|
+
return f
|
|
56
|
+
|
|
57
|
+
if fn is not None:
|
|
58
|
+
return _register(fn)
|
|
59
|
+
return _register
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def fire(event: str, *args: Any, **kwargs: Any) -> None:
|
|
63
|
+
"""Invoke all registered handlers for *event* in order.
|
|
64
|
+
|
|
65
|
+
Exceptions are logged but do not prevent subsequent handlers from running.
|
|
66
|
+
"""
|
|
67
|
+
for handler in _handlers.get(event, []):
|
|
68
|
+
try:
|
|
69
|
+
await handler(*args, **kwargs)
|
|
70
|
+
except Exception:
|
|
71
|
+
logger.exception("hooks.fire error in %r handler %r", event, handler)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def reset() -> None:
|
|
75
|
+
_handlers.clear()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class HookCtx:
|
|
80
|
+
"""Value passed to hook handlers (e.g. ``on_signup``).
|
|
81
|
+
|
|
82
|
+
Smaller than ``jobs.JobCtx`` and ``functions.Ctx`` on purpose: a hook
|
|
83
|
+
fires inline on the request/worker path and should only reach for the
|
|
84
|
+
things every lifecycle event is expected to need — the role-scoped DB
|
|
85
|
+
connection, settings, a mailer, and a logger. Anything heavier belongs
|
|
86
|
+
in an enqueued job.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
db: asyncpg.Connection
|
|
90
|
+
settings: Settings
|
|
91
|
+
send_email: Callable[..., Awaitable[None]]
|
|
92
|
+
logger: logging.Logger
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_hook_ctx(
|
|
96
|
+
*,
|
|
97
|
+
conn: asyncpg.Connection,
|
|
98
|
+
settings: Settings | None = None,
|
|
99
|
+
mailer: EmailBackend | None = None,
|
|
100
|
+
) -> HookCtx:
|
|
101
|
+
"""Assemble a ``HookCtx`` from a role-scoped connection.
|
|
102
|
+
|
|
103
|
+
The caller is expected to have entered ``db.as_role(...)`` already so
|
|
104
|
+
that ``conn`` carries the intended RLS posture; this factory does not
|
|
105
|
+
switch roles.
|
|
106
|
+
"""
|
|
107
|
+
from .functions.context import _make_send_email
|
|
108
|
+
from .mailer import get_mailer
|
|
109
|
+
from .settings import get_settings
|
|
110
|
+
|
|
111
|
+
s = settings or get_settings()
|
|
112
|
+
return HookCtx(
|
|
113
|
+
db=conn,
|
|
114
|
+
settings=s,
|
|
115
|
+
send_email=_make_send_email(mailer or get_mailer()),
|
|
116
|
+
logger=logging.getLogger("supython.hooks"),
|
|
117
|
+
)
|