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.
- supython/__init__.py +8 -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 +149 -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/body_size.py +184 -0
- supython/cli.py +1653 -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/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 +118 -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 +133 -0
- supython/scaffold/templates/Caddyfile.tmpl +4 -0
- supython/scaffold/templates/README.md.tmpl +22 -0
- supython/scaffold/templates/docker-compose.prod.yml.tmpl +84 -0
- supython/scaffold/templates/docker-compose.yml.tmpl +41 -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 +149 -0
- supython/scaffold/templates/functions_README.md.tmpl +21 -0
- supython/scaffold/templates/gitignore.tmpl +14 -0
- supython/scaffold/templates/migrations/.gitkeep +0 -0
- supython/secretset.py +347 -0
- supython/security_headers.py +78 -0
- supython/settings.py +198 -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.5.0.dist-info/METADATA +714 -0
- supython-0.5.0.dist-info/RECORD +188 -0
- supython-0.5.0.dist-info/WHEEL +4 -0
- supython-0.5.0.dist-info/entry_points.txt +2 -0
- supython-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Realtime service layer.
|
|
2
|
+
|
|
3
|
+
Pure async functions over ``asyncpg.Connection``. No FastAPI imports.
|
|
4
|
+
|
|
5
|
+
There are three public entry points used by the rest of the realtime
|
|
6
|
+
module:
|
|
7
|
+
|
|
8
|
+
``enable_table``
|
|
9
|
+
Service-role only. Wraps the SQL helper ``realtime.enable(regclass,
|
|
10
|
+
text)`` defined in :file:`migrations/0006_realtime_schema.sql`. This
|
|
11
|
+
upserts ``realtime.enabled_tables`` and (re-)attaches the
|
|
12
|
+
``realtime_notify`` trigger.
|
|
13
|
+
|
|
14
|
+
``list_enabled``
|
|
15
|
+
Reads the registry under the caller's role so RLS still applies.
|
|
16
|
+
Returns one :class:`~.schemas.EnabledTable` per row.
|
|
17
|
+
|
|
18
|
+
``rls_check``
|
|
19
|
+
Per-event per-subscriber visibility probe. Runs ``select 1 from
|
|
20
|
+
<schema>.<table> where <pk> = $n`` under the subscriber's role +
|
|
21
|
+
claims. Used by the broker to decide whether a particular row change
|
|
22
|
+
should be forwarded to a particular WebSocket.
|
|
23
|
+
|
|
24
|
+
The DELETE-visibility short-circuit (matching ``old_record.<owner_column>``
|
|
25
|
+
against ``auth.uid()``) is implemented in the broker, not here, because
|
|
26
|
+
it does not require a database round-trip — the registry row already
|
|
27
|
+
tells us the owner column.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import asyncpg
|
|
34
|
+
|
|
35
|
+
from .. import db
|
|
36
|
+
from .schemas import EnabledTable, EnableTableRequest
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RealtimeError(Exception):
|
|
42
|
+
"""Domain error raised by the realtime service layer."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, code: str, message: str, status: int = 400) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.code = code
|
|
47
|
+
self.message = message
|
|
48
|
+
self.status = status
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Row mappers
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _row_to_enabled_table(row: asyncpg.Record) -> EnabledTable:
|
|
57
|
+
return EnabledTable(
|
|
58
|
+
schema_name=row["schema_name"],
|
|
59
|
+
table_name=row["table_name"],
|
|
60
|
+
pk_columns=list(row["pk_columns"]),
|
|
61
|
+
owner_column=row["owner_column"],
|
|
62
|
+
created_at=row["created_at"],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# enable_table — service-role only
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def enable_table(
|
|
72
|
+
conn: asyncpg.Connection,
|
|
73
|
+
payload: EnableTableRequest,
|
|
74
|
+
) -> EnabledTable:
|
|
75
|
+
"""Opt a table into realtime by attaching the notify trigger.
|
|
76
|
+
|
|
77
|
+
*conn* must be a service-role connection (or one whose effective
|
|
78
|
+
privileges include ``execute`` on ``realtime.enable``). The router
|
|
79
|
+
is responsible for that gating; the service trusts the connection.
|
|
80
|
+
|
|
81
|
+
Returns the freshly written :class:`EnabledTable` row so the caller
|
|
82
|
+
can immediately echo it back to the client.
|
|
83
|
+
"""
|
|
84
|
+
qualified = payload.table
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
await conn.execute(
|
|
88
|
+
"select realtime.enable($1::regclass, $2)",
|
|
89
|
+
qualified,
|
|
90
|
+
payload.owner_column,
|
|
91
|
+
)
|
|
92
|
+
except asyncpg.UndefinedTableError as exc:
|
|
93
|
+
raise RealtimeError(
|
|
94
|
+
"table_not_found",
|
|
95
|
+
f"table {qualified!r} does not exist",
|
|
96
|
+
status=404,
|
|
97
|
+
) from exc
|
|
98
|
+
except asyncpg.InsufficientPrivilegeError as exc:
|
|
99
|
+
raise RealtimeError(
|
|
100
|
+
"forbidden",
|
|
101
|
+
"service_role required to enable a table for realtime",
|
|
102
|
+
status=403,
|
|
103
|
+
) from exc
|
|
104
|
+
except asyncpg.RaiseError as exc:
|
|
105
|
+
raise RealtimeError("invalid_table", str(exc), status=400) from exc
|
|
106
|
+
|
|
107
|
+
schema_name, _, table_name = qualified.partition(".")
|
|
108
|
+
row = await conn.fetchrow(
|
|
109
|
+
"""
|
|
110
|
+
select schema_name, table_name, pk_columns, owner_column, created_at
|
|
111
|
+
from realtime.enabled_tables
|
|
112
|
+
where schema_name = $1 and table_name = $2
|
|
113
|
+
""",
|
|
114
|
+
schema_name,
|
|
115
|
+
table_name,
|
|
116
|
+
)
|
|
117
|
+
if row is None:
|
|
118
|
+
# The SQL helper returned without raising, but the registry row is
|
|
119
|
+
# missing — should never happen, but treat it as a server fault.
|
|
120
|
+
raise RealtimeError(
|
|
121
|
+
"registry_missing",
|
|
122
|
+
f"realtime.enable({qualified!r}) succeeded but the registry row is missing",
|
|
123
|
+
status=500,
|
|
124
|
+
)
|
|
125
|
+
return _row_to_enabled_table(row)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# list_enabled
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def list_enabled(conn: asyncpg.Connection) -> list[EnabledTable]:
|
|
134
|
+
"""Return every row from ``realtime.enabled_tables`` visible to *conn*.
|
|
135
|
+
|
|
136
|
+
Visibility is governed by the registry's RLS policies; in the v0.4
|
|
137
|
+
schema both ``anon`` and ``authenticated`` may read every row, so the
|
|
138
|
+
list is effectively the same regardless of the caller's role.
|
|
139
|
+
"""
|
|
140
|
+
rows = await conn.fetch(
|
|
141
|
+
"""
|
|
142
|
+
select schema_name, table_name, pk_columns, owner_column, created_at
|
|
143
|
+
from realtime.enabled_tables
|
|
144
|
+
order by schema_name, table_name
|
|
145
|
+
"""
|
|
146
|
+
)
|
|
147
|
+
return [_row_to_enabled_table(r) for r in rows]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def get_enabled(
|
|
151
|
+
conn: asyncpg.Connection,
|
|
152
|
+
schema_name: str,
|
|
153
|
+
table_name: str,
|
|
154
|
+
) -> EnabledTable | None:
|
|
155
|
+
"""Return one registry row, or ``None`` if the table is not enabled."""
|
|
156
|
+
row = await conn.fetchrow(
|
|
157
|
+
"""
|
|
158
|
+
select schema_name, table_name, pk_columns, owner_column, created_at
|
|
159
|
+
from realtime.enabled_tables
|
|
160
|
+
where schema_name = $1 and table_name = $2
|
|
161
|
+
""",
|
|
162
|
+
schema_name,
|
|
163
|
+
table_name,
|
|
164
|
+
)
|
|
165
|
+
return _row_to_enabled_table(row) if row else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# rls_check — per-subscriber visibility probe
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Quote an SQL identifier defensively. Schema, table, and PK column names
|
|
174
|
+
# all originate from the catalog (via realtime.enable's regclass /
|
|
175
|
+
# pg_attribute lookup), so they are already safe. We still double-quote
|
|
176
|
+
# them to handle reserved words and embedded uppercase, and we reject any
|
|
177
|
+
# identifier containing a literal double quote since asyncpg / psql will
|
|
178
|
+
# happily mis-interpret one.
|
|
179
|
+
def _quote_ident(name: str) -> str:
|
|
180
|
+
if '"' in name:
|
|
181
|
+
raise ValueError(f"identifier {name!r} contains a double quote")
|
|
182
|
+
return '"' + name + '"'
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def rls_check(
|
|
186
|
+
conn: asyncpg.Connection,
|
|
187
|
+
*,
|
|
188
|
+
schema_name: str,
|
|
189
|
+
table_name: str,
|
|
190
|
+
pk_columns: list[str],
|
|
191
|
+
pk_values: list[Any],
|
|
192
|
+
timeout: float | None = None,
|
|
193
|
+
) -> bool:
|
|
194
|
+
"""Probe whether *conn* (already role-scoped) can ``select`` the row.
|
|
195
|
+
|
|
196
|
+
``conn`` MUST come from :func:`db.as_role` so that ``set local role``
|
|
197
|
+
and ``request.jwt.claims`` are in effect; this function does not
|
|
198
|
+
re-scope the connection itself.
|
|
199
|
+
|
|
200
|
+
Returns ``True`` if the role's RLS policies expose the row identified
|
|
201
|
+
by ``pk_values`` (in the same order as ``pk_columns``), ``False``
|
|
202
|
+
otherwise. A timeout or transient error returns ``False`` and is
|
|
203
|
+
logged — never propagated — so a misbehaving subscriber connection
|
|
204
|
+
cannot stall the broker fan-out loop.
|
|
205
|
+
"""
|
|
206
|
+
if len(pk_columns) != len(pk_values):
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"pk_columns/pk_values length mismatch: "
|
|
209
|
+
f"{len(pk_columns)} vs {len(pk_values)}"
|
|
210
|
+
)
|
|
211
|
+
if not pk_columns:
|
|
212
|
+
raise ValueError("at least one pk column is required")
|
|
213
|
+
|
|
214
|
+
where_clauses = [
|
|
215
|
+
f"{_quote_ident(col)} = ${i + 1}"
|
|
216
|
+
for i, col in enumerate(pk_columns)
|
|
217
|
+
]
|
|
218
|
+
sql = (
|
|
219
|
+
f"select 1 from {_quote_ident(schema_name)}.{_quote_ident(table_name)} "
|
|
220
|
+
f"where {' and '.join(where_clauses)} limit 1"
|
|
221
|
+
)
|
|
222
|
+
try:
|
|
223
|
+
row = await conn.fetchrow(sql, *pk_values, timeout=timeout)
|
|
224
|
+
except asyncpg.PostgresError as exc:
|
|
225
|
+
logger.debug(
|
|
226
|
+
"realtime rls_check failed for %s.%s pk=%s: %s",
|
|
227
|
+
schema_name,
|
|
228
|
+
table_name,
|
|
229
|
+
pk_values,
|
|
230
|
+
exc,
|
|
231
|
+
)
|
|
232
|
+
return False
|
|
233
|
+
return row is not None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def rls_check_with_role(
|
|
237
|
+
role: str,
|
|
238
|
+
claims: dict[str, Any],
|
|
239
|
+
*,
|
|
240
|
+
schema_name: str,
|
|
241
|
+
table_name: str,
|
|
242
|
+
pk_columns: list[str],
|
|
243
|
+
pk_values: list[Any],
|
|
244
|
+
timeout: float | None = None,
|
|
245
|
+
) -> bool:
|
|
246
|
+
"""Convenience wrapper that opens a role-scoped connection per check.
|
|
247
|
+
|
|
248
|
+
The broker reuses an already-acquired connection per subscriber when
|
|
249
|
+
fanning out a single notification across many topics, but for one-off
|
|
250
|
+
checks (tests, REST control plane) this single-shot helper is
|
|
251
|
+
simpler.
|
|
252
|
+
"""
|
|
253
|
+
async with db.as_role(role, claims) as conn:
|
|
254
|
+
return await rls_check(
|
|
255
|
+
conn,
|
|
256
|
+
schema_name=schema_name,
|
|
257
|
+
table_name=table_name,
|
|
258
|
+
pk_columns=pk_columns,
|
|
259
|
+
pk_values=pk_values,
|
|
260
|
+
timeout=timeout,
|
|
261
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Topic validation and postgres_changes filter parsing for the realtime module.
|
|
2
|
+
|
|
3
|
+
Topic grammar (A2 in the plan):
|
|
4
|
+
topic := "realtime:" name
|
|
5
|
+
name := [a-zA-Z0-9][a-zA-Z0-9_-]{0,63}
|
|
6
|
+
|
|
7
|
+
The phoenix system topic ("phoenix") is also legal and is handled separately
|
|
8
|
+
by the WebSocket layer. This module only deals with "realtime:*" topics.
|
|
9
|
+
|
|
10
|
+
Filter grammar (v0.4 — eq / in only):
|
|
11
|
+
filter := col "=" op "." value
|
|
12
|
+
op := "eq" | "in"
|
|
13
|
+
value := any-string (for eq)
|
|
14
|
+
| "(" v1 "," v2 … ")" (for in)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import itertools
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
from .schemas import JoinConfig, PostgresChangesFilter, PostgresChangesSubscription
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# Topic validation
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_TOPIC_RE = re.compile(r"^realtime:([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})$")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TopicError(ValueError):
|
|
32
|
+
"""Raised when a topic string does not conform to the grammar."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_topic(topic: str) -> str:
|
|
36
|
+
"""Return the *name* portion of a valid ``realtime:<name>`` topic.
|
|
37
|
+
|
|
38
|
+
Raises :class:`TopicError` for any topic that does not match the grammar.
|
|
39
|
+
The reserved ``phoenix`` topic used for connection-level heartbeats is
|
|
40
|
+
intentionally excluded — the WebSocket handler manages it directly.
|
|
41
|
+
"""
|
|
42
|
+
m = _TOPIC_RE.fullmatch(topic)
|
|
43
|
+
if m is None:
|
|
44
|
+
raise TopicError(
|
|
45
|
+
f"Invalid topic {topic!r}. "
|
|
46
|
+
"Expected 'realtime:<name>' where <name> matches [a-zA-Z0-9][a-zA-Z0-9_-]{0,63}."
|
|
47
|
+
)
|
|
48
|
+
return m.group(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# postgres_changes filter parsing
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
_FILTER_RE = re.compile(r"^([a-zA-Z_][a-zA-Z0-9_]*)=(eq|in)\.(.+)$")
|
|
56
|
+
_IN_BODY_RE = re.compile(r"^\((.+)\)$")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FilterError(ValueError):
|
|
60
|
+
"""Raised when a filter string cannot be parsed."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(slots=True, frozen=True)
|
|
64
|
+
class EqFilter:
|
|
65
|
+
column: str
|
|
66
|
+
value: str
|
|
67
|
+
op: Literal["eq"] = field(default="eq", init=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True, frozen=True)
|
|
71
|
+
class InFilter:
|
|
72
|
+
column: str
|
|
73
|
+
values: tuple[str, ...]
|
|
74
|
+
op: Literal["in"] = field(default="in", init=False)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
ParsedFilter = EqFilter | InFilter
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def parse_filter(filter_str: str) -> ParsedFilter:
|
|
81
|
+
"""Parse a PostgREST-style filter string into a typed value.
|
|
82
|
+
|
|
83
|
+
Supported operators for v0.4:
|
|
84
|
+
- ``col=eq.<value>``
|
|
85
|
+
- ``col=in.(<v1>,<v2>,…)``
|
|
86
|
+
|
|
87
|
+
Raises :class:`FilterError` for unsupported or malformed inputs.
|
|
88
|
+
"""
|
|
89
|
+
m = _FILTER_RE.fullmatch(filter_str)
|
|
90
|
+
if m is None:
|
|
91
|
+
raise FilterError(
|
|
92
|
+
f"Cannot parse filter {filter_str!r}. "
|
|
93
|
+
"Expected 'col=eq.<val>' or 'col=in.(<v1>,<v2>,…)'."
|
|
94
|
+
)
|
|
95
|
+
column, op, raw_value = m.group(1), m.group(2), m.group(3)
|
|
96
|
+
|
|
97
|
+
if op == "eq":
|
|
98
|
+
return EqFilter(column=column, value=raw_value)
|
|
99
|
+
|
|
100
|
+
# op == "in"
|
|
101
|
+
body_m = _IN_BODY_RE.fullmatch(raw_value)
|
|
102
|
+
if body_m is None:
|
|
103
|
+
raise FilterError(
|
|
104
|
+
f"'in' filter value must be wrapped in parentheses, got {raw_value!r}."
|
|
105
|
+
)
|
|
106
|
+
values = tuple(v.strip() for v in body_m.group(1).split(",") if v.strip())
|
|
107
|
+
if not values:
|
|
108
|
+
raise FilterError(f"'in' filter has an empty value list in {filter_str!r}.")
|
|
109
|
+
return InFilter(column=column, values=values)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Subscription-id assignment
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
# Module-level monotonic counter. Single-process; each assigned id is unique
|
|
117
|
+
# for the lifetime of the server. The broker uses these as stable handles for
|
|
118
|
+
# the duration of a channel join; a rejoin produces new ids.
|
|
119
|
+
_sub_id_counter: "itertools.count[int]" = itertools.count(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _next_sub_id() -> int:
|
|
123
|
+
return next(_sub_id_counter)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(slots=True)
|
|
127
|
+
class ResolvedSubscription:
|
|
128
|
+
"""A ``postgres_changes`` filter with its server-assigned id and parsed filter."""
|
|
129
|
+
|
|
130
|
+
id: int
|
|
131
|
+
filter_spec: PostgresChangesFilter
|
|
132
|
+
parsed_filter: ParsedFilter | None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def assign_subscription_ids(
|
|
136
|
+
config: JoinConfig,
|
|
137
|
+
) -> list[ResolvedSubscription]:
|
|
138
|
+
"""Assign a server-local integer id to every entry in *config.postgres_changes*.
|
|
139
|
+
|
|
140
|
+
Also eagerly parses each filter string so callers catch :class:`FilterError`
|
|
141
|
+
before sending the join ack. Entries with ``filter=None`` have
|
|
142
|
+
``parsed_filter=None``.
|
|
143
|
+
|
|
144
|
+
Returns one :class:`ResolvedSubscription` per entry, **in the same order**
|
|
145
|
+
they appear in *config.postgres_changes* (required by A3).
|
|
146
|
+
"""
|
|
147
|
+
result: list[ResolvedSubscription] = []
|
|
148
|
+
for spec in config.postgres_changes:
|
|
149
|
+
parsed: ParsedFilter | None = None
|
|
150
|
+
if spec.filter is not None:
|
|
151
|
+
parsed = parse_filter(spec.filter)
|
|
152
|
+
result.append(
|
|
153
|
+
ResolvedSubscription(
|
|
154
|
+
id=_next_sub_id(),
|
|
155
|
+
filter_spec=spec,
|
|
156
|
+
parsed_filter=parsed,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolved_to_subscription_schema(
|
|
163
|
+
resolved: ResolvedSubscription,
|
|
164
|
+
) -> PostgresChangesSubscription:
|
|
165
|
+
"""Convert a :class:`ResolvedSubscription` to the Pydantic reply schema."""
|
|
166
|
+
spec = resolved.filter_spec
|
|
167
|
+
return PostgresChangesSubscription.model_validate(
|
|
168
|
+
{
|
|
169
|
+
"id": resolved.id,
|
|
170
|
+
"event": spec.event,
|
|
171
|
+
"schema": spec.schema_name,
|
|
172
|
+
"table": spec.table,
|
|
173
|
+
"filter": spec.filter,
|
|
174
|
+
}
|
|
175
|
+
)
|