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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
-- v0.5 jobs: durable job queue with idempotency, bounded zombie reclaim, and
|
|
2
|
+
-- cron scheduling metadata.
|
|
3
|
+
--
|
|
4
|
+
-- Creates:
|
|
5
|
+
-- jobs schema (owned by service_role)
|
|
6
|
+
-- jobs.jobs — main queue table
|
|
7
|
+
-- jobs.cron_schedules — cron schedule metadata
|
|
8
|
+
-- jobs.enqueue() — idempotent insert, returns (job, is_new)
|
|
9
|
+
-- jobs.claim_next() — SKIP LOCKED poll + bounded zombie reclaim
|
|
10
|
+
-- Partial unique index on idempotency_key
|
|
11
|
+
-- 4-policy RLS on jobs.jobs
|
|
12
|
+
-- pg_cron grants for service_role (when the extension is installed)
|
|
13
|
+
--
|
|
14
|
+
-- Per B5: execute grant on jobs.enqueue is service_role only.
|
|
15
|
+
-- Per B9: claim_next reclaims zombies older than visibility_timeout_ms.
|
|
16
|
+
-- Per B6: enqueue returns TABLE(job jobs.jobs, is_new boolean).
|
|
17
|
+
|
|
18
|
+
create schema if not exists jobs;
|
|
19
|
+
|
|
20
|
+
grant usage on schema jobs to anon, authenticated, service_role;
|
|
21
|
+
|
|
22
|
+
-- ─── pg_cron grants ──────────────────────────────────────────────────────────
|
|
23
|
+
--
|
|
24
|
+
-- ``sync_pg_cron`` runs as ``service_role`` and needs to call
|
|
25
|
+
-- ``cron.schedule`` / ``cron.unschedule``. Without these grants the pg_cron
|
|
26
|
+
-- path raises ``InsufficientPrivilegeError``. The block is conditional so
|
|
27
|
+
-- this migration still applies cleanly on managed Postgres without pg_cron.
|
|
28
|
+
|
|
29
|
+
do $$
|
|
30
|
+
begin
|
|
31
|
+
if exists (select 1 from pg_extension where extname = 'pg_cron') then
|
|
32
|
+
execute 'grant usage on schema cron to service_role';
|
|
33
|
+
execute 'grant select on all tables in schema cron to service_role';
|
|
34
|
+
execute 'grant execute on function cron.schedule(text, text, text) to service_role';
|
|
35
|
+
execute 'grant execute on function cron.unschedule(text) to service_role';
|
|
36
|
+
end if;
|
|
37
|
+
end $$;
|
|
38
|
+
|
|
39
|
+
-- ─── jobs.jobs ────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
create table if not exists jobs.jobs (
|
|
42
|
+
id uuid primary key default gen_random_uuid(),
|
|
43
|
+
name text not null,
|
|
44
|
+
version int not null default 1,
|
|
45
|
+
payload jsonb not null default '{}',
|
|
46
|
+
queue text not null default 'default',
|
|
47
|
+
idempotency_key text,
|
|
48
|
+
user_id uuid references auth.users (id) on delete cascade,
|
|
49
|
+
status text not null default 'queued'
|
|
50
|
+
check (status in ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
|
|
51
|
+
attempts int not null default 0,
|
|
52
|
+
max_attempts int not null default 3,
|
|
53
|
+
backoff text not null default 'exponential'
|
|
54
|
+
check (backoff in ('exponential', 'linear', 'constant')),
|
|
55
|
+
backoff_base_s float not null default 5.0,
|
|
56
|
+
backoff_max_s float not null default 300.0,
|
|
57
|
+
run_at timestamptz not null default now(),
|
|
58
|
+
locked_at timestamptz,
|
|
59
|
+
locked_by text,
|
|
60
|
+
role text not null default 'service_role',
|
|
61
|
+
claims_from text,
|
|
62
|
+
finished_at timestamptz,
|
|
63
|
+
created_at timestamptz not null default now()
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
alter table jobs.jobs owner to service_role;
|
|
67
|
+
|
|
68
|
+
-- idempotency: at most one queued/running job per key
|
|
69
|
+
create unique index if not exists jobs_jobs_idempotency_key_idx
|
|
70
|
+
on jobs.jobs (idempotency_key)
|
|
71
|
+
where idempotency_key is not null
|
|
72
|
+
and status in ('queued', 'running');
|
|
73
|
+
|
|
74
|
+
-- index for user-scoped queries
|
|
75
|
+
create index if not exists jobs_jobs_user_id_idx
|
|
76
|
+
on jobs.jobs (user_id)
|
|
77
|
+
where user_id is not null;
|
|
78
|
+
|
|
79
|
+
-- ─── RLS ─────────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
alter table jobs.jobs enable row level security;
|
|
82
|
+
|
|
83
|
+
drop policy if exists "jobs: owner can read" on jobs.jobs;
|
|
84
|
+
drop policy if exists "jobs: owner can insert" on jobs.jobs;
|
|
85
|
+
drop policy if exists "jobs: owner can update" on jobs.jobs;
|
|
86
|
+
drop policy if exists "jobs: owner can delete" on jobs.jobs;
|
|
87
|
+
|
|
88
|
+
create policy "jobs: owner can read"
|
|
89
|
+
on jobs.jobs for select to authenticated
|
|
90
|
+
using (user_id = auth.uid());
|
|
91
|
+
|
|
92
|
+
create policy "jobs: owner can insert"
|
|
93
|
+
on jobs.jobs for insert to authenticated
|
|
94
|
+
with check (user_id = auth.uid());
|
|
95
|
+
|
|
96
|
+
create policy "jobs: owner can update"
|
|
97
|
+
on jobs.jobs for update to authenticated
|
|
98
|
+
using (user_id = auth.uid())
|
|
99
|
+
with check (user_id = auth.uid());
|
|
100
|
+
|
|
101
|
+
create policy "jobs: owner can delete"
|
|
102
|
+
on jobs.jobs for delete to authenticated
|
|
103
|
+
using (user_id = auth.uid());
|
|
104
|
+
|
|
105
|
+
grant select, insert, update, delete on jobs.jobs to authenticated;
|
|
106
|
+
|
|
107
|
+
-- ─── jobs.cron_schedules ─────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
create table if not exists jobs.cron_schedules (
|
|
110
|
+
id uuid primary key default gen_random_uuid(),
|
|
111
|
+
name text not null unique,
|
|
112
|
+
cron_expr text not null,
|
|
113
|
+
job_name text not null,
|
|
114
|
+
job_version int not null default 1,
|
|
115
|
+
payload jsonb not null default '{}',
|
|
116
|
+
queue text not null default 'default',
|
|
117
|
+
enabled boolean not null default true,
|
|
118
|
+
last_fire_at timestamptz,
|
|
119
|
+
created_at timestamptz not null default now()
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
alter table jobs.cron_schedules owner to service_role;
|
|
123
|
+
|
|
124
|
+
-- ─── jobs.enqueue ────────────────────────────────────────────────────────────
|
|
125
|
+
--
|
|
126
|
+
-- Idempotent enqueue. If an idempotency_key is provided and a queued/running
|
|
127
|
+
-- row already exists with that key, the existing row is returned with
|
|
128
|
+
-- is_new = false. Otherwise a new row is inserted and is_new = true.
|
|
129
|
+
--
|
|
130
|
+
-- The "fresh insert" path is split into a CTE so we never need to reference
|
|
131
|
+
-- a system column (``xmax = 0``) after moving the row into a local variable —
|
|
132
|
+
-- the idempotency short-circuit above returns before the INSERT, so anything
|
|
133
|
+
-- that makes it past the guard is definitively new.
|
|
134
|
+
|
|
135
|
+
create or replace function jobs.enqueue(
|
|
136
|
+
p_name text,
|
|
137
|
+
p_payload jsonb default '{}',
|
|
138
|
+
p_queue text default 'default',
|
|
139
|
+
p_idempotency_key text default null,
|
|
140
|
+
p_user_id uuid default null,
|
|
141
|
+
p_max_attempts int default 3,
|
|
142
|
+
p_backoff text default 'exponential',
|
|
143
|
+
p_backoff_base_s float default 5.0,
|
|
144
|
+
p_backoff_max_s float default 300.0,
|
|
145
|
+
p_run_at timestamptz default now(),
|
|
146
|
+
p_version int default 1,
|
|
147
|
+
p_role text default 'service_role',
|
|
148
|
+
p_claims_from text default null
|
|
149
|
+
)
|
|
150
|
+
returns table (job jobs.jobs, is_new boolean)
|
|
151
|
+
language plpgsql
|
|
152
|
+
security definer
|
|
153
|
+
as $$
|
|
154
|
+
declare
|
|
155
|
+
existing_id uuid;
|
|
156
|
+
begin
|
|
157
|
+
if p_idempotency_key is not null then
|
|
158
|
+
select j.id into existing_id
|
|
159
|
+
from jobs.jobs j
|
|
160
|
+
where j.idempotency_key = p_idempotency_key
|
|
161
|
+
and j.status in ('queued', 'running')
|
|
162
|
+
limit 1;
|
|
163
|
+
|
|
164
|
+
if existing_id is not null then
|
|
165
|
+
return query
|
|
166
|
+
select j, false
|
|
167
|
+
from jobs.jobs j
|
|
168
|
+
where j.id = existing_id;
|
|
169
|
+
return;
|
|
170
|
+
end if;
|
|
171
|
+
end if;
|
|
172
|
+
|
|
173
|
+
return query
|
|
174
|
+
with ins as (
|
|
175
|
+
insert into jobs.jobs (
|
|
176
|
+
name, version, payload, queue, idempotency_key, user_id,
|
|
177
|
+
max_attempts, backoff, backoff_base_s, backoff_max_s,
|
|
178
|
+
run_at, role, claims_from
|
|
179
|
+
)
|
|
180
|
+
values (
|
|
181
|
+
p_name, p_version, coalesce(p_payload, '{}'::jsonb), p_queue,
|
|
182
|
+
p_idempotency_key, p_user_id,
|
|
183
|
+
p_max_attempts, p_backoff, p_backoff_base_s, p_backoff_max_s,
|
|
184
|
+
coalesce(p_run_at, now()), p_role, p_claims_from
|
|
185
|
+
)
|
|
186
|
+
returning *
|
|
187
|
+
)
|
|
188
|
+
select ins::jobs.jobs, true from ins;
|
|
189
|
+
end;
|
|
190
|
+
$$;
|
|
191
|
+
|
|
192
|
+
grant execute on function jobs.enqueue(
|
|
193
|
+
text, jsonb, text, text, uuid, int, text, float, float, timestamptz, int, text, text
|
|
194
|
+
) to service_role;
|
|
195
|
+
|
|
196
|
+
-- ─── jobs.claim_next ─────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
-- Poll the queue for the next available job. Uses SKIP LOCKED so multiple
|
|
199
|
+
-- workers never claim the same row. Reclaims "zombie" rows whose
|
|
200
|
+
-- locked_at is older than p_visibility_timeout_ms ago (bounded by
|
|
201
|
+
-- p_zombie_batch to avoid thundering herd).
|
|
202
|
+
create or replace function jobs.claim_next(
|
|
203
|
+
p_queue text default 'default',
|
|
204
|
+
p_worker_id text default null,
|
|
205
|
+
p_visibility_timeout_ms int default 300000,
|
|
206
|
+
p_zombie_batch int default 10
|
|
207
|
+
)
|
|
208
|
+
returns setof jobs.jobs
|
|
209
|
+
language plpgsql
|
|
210
|
+
security definer
|
|
211
|
+
as $$
|
|
212
|
+
declare
|
|
213
|
+
reclaimed int;
|
|
214
|
+
begin
|
|
215
|
+
-- bounded zombie reclaim
|
|
216
|
+
update jobs.jobs
|
|
217
|
+
set status = 'queued',
|
|
218
|
+
locked_at = null,
|
|
219
|
+
locked_by = null
|
|
220
|
+
where id in (
|
|
221
|
+
select id
|
|
222
|
+
from jobs.jobs
|
|
223
|
+
where status = 'running'
|
|
224
|
+
and queue = p_queue
|
|
225
|
+
and locked_at < now() - (p_visibility_timeout_ms || ' milliseconds')::interval
|
|
226
|
+
order by locked_at
|
|
227
|
+
limit p_zombie_batch
|
|
228
|
+
for update skip locked
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
get diagnostics reclaimed = row_count;
|
|
232
|
+
|
|
233
|
+
-- claim the next queued job
|
|
234
|
+
return query
|
|
235
|
+
update jobs.jobs j
|
|
236
|
+
set status = 'running',
|
|
237
|
+
attempts = j.attempts + 1,
|
|
238
|
+
locked_at = now(),
|
|
239
|
+
locked_by = p_worker_id
|
|
240
|
+
where j.id in (
|
|
241
|
+
select c.id
|
|
242
|
+
from jobs.jobs c
|
|
243
|
+
where c.status = 'queued'
|
|
244
|
+
and c.queue = p_queue
|
|
245
|
+
and c.run_at <= now()
|
|
246
|
+
order by c.run_at, c.id
|
|
247
|
+
limit 1
|
|
248
|
+
for update skip locked
|
|
249
|
+
)
|
|
250
|
+
returning j.*;
|
|
251
|
+
end;
|
|
252
|
+
$$;
|
|
253
|
+
|
|
254
|
+
grant execute on function jobs.claim_next(text, text, int, int) to service_role;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
-- v0.6 grooming: add last_error column to jobs.jobs so the failure reason
|
|
2
|
+
-- lands on the row instead of only in logs.
|
|
3
|
+
|
|
4
|
+
alter table jobs.jobs add column if not exists last_error text;
|
|
5
|
+
|
|
6
|
+
-- clear last_error on reclaim so zombie rows don't carry stale errors
|
|
7
|
+
create or replace function jobs.claim_next(
|
|
8
|
+
p_queue text default 'default',
|
|
9
|
+
p_worker_id text default null,
|
|
10
|
+
p_visibility_timeout_ms int default 300000,
|
|
11
|
+
p_zombie_batch int default 10
|
|
12
|
+
)
|
|
13
|
+
returns setof jobs.jobs
|
|
14
|
+
language plpgsql
|
|
15
|
+
security definer
|
|
16
|
+
as $$
|
|
17
|
+
declare
|
|
18
|
+
reclaimed int;
|
|
19
|
+
begin
|
|
20
|
+
update jobs.jobs
|
|
21
|
+
set status = 'queued',
|
|
22
|
+
locked_at = null,
|
|
23
|
+
locked_by = null,
|
|
24
|
+
last_error = null
|
|
25
|
+
where id in (
|
|
26
|
+
select id
|
|
27
|
+
from jobs.jobs
|
|
28
|
+
where status = 'running'
|
|
29
|
+
and queue = p_queue
|
|
30
|
+
and locked_at < now() - (p_visibility_timeout_ms || ' milliseconds')::interval
|
|
31
|
+
order by locked_at
|
|
32
|
+
limit p_zombie_batch
|
|
33
|
+
for update skip locked
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
get diagnostics reclaimed = row_count;
|
|
37
|
+
|
|
38
|
+
return query
|
|
39
|
+
update jobs.jobs j
|
|
40
|
+
set status = 'running',
|
|
41
|
+
attempts = j.attempts + 1,
|
|
42
|
+
locked_at = now(),
|
|
43
|
+
locked_by = p_worker_id
|
|
44
|
+
where j.id in (
|
|
45
|
+
select c.id
|
|
46
|
+
from jobs.jobs c
|
|
47
|
+
where c.status = 'queued'
|
|
48
|
+
and c.queue = p_queue
|
|
49
|
+
and c.run_at <= now()
|
|
50
|
+
order by c.run_at, c.id
|
|
51
|
+
limit 1
|
|
52
|
+
for update skip locked
|
|
53
|
+
)
|
|
54
|
+
returning j.*;
|
|
55
|
+
end;
|
|
56
|
+
$$;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- v0.6: brute-force protection for auth endpoints with a Postgres-backed
|
|
2
|
+
-- fixed-window counter (no Redis dependency).
|
|
3
|
+
--
|
|
4
|
+
-- Uses an UNLOGGED table because counters are ephemeral; a crash truncation
|
|
5
|
+
-- only resets buckets, which is preferable to keeping stale windows.
|
|
6
|
+
|
|
7
|
+
create unlogged table if not exists auth.rate_limit_buckets (
|
|
8
|
+
bucket text not null,
|
|
9
|
+
window_start timestamptz not null,
|
|
10
|
+
count int not null default 0,
|
|
11
|
+
primary key (bucket, window_start)
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
alter table auth.rate_limit_buckets owner to service_role;
|
|
15
|
+
|
|
16
|
+
grant select, insert, update, delete
|
|
17
|
+
on auth.rate_limit_buckets to service_role;
|
|
18
|
+
|
|
19
|
+
-- prune stale buckets every 5 minutes when pg_cron is available.
|
|
20
|
+
-- conditional so migration applies cleanly on managed Postgres without pg_cron.
|
|
21
|
+
do $$
|
|
22
|
+
begin
|
|
23
|
+
if exists (select 1 from pg_extension where extname = 'pg_cron') then
|
|
24
|
+
perform cron.unschedule('auth_rate_limit_prune')
|
|
25
|
+
where exists (select 1 from cron.job where jobname = 'auth_rate_limit_prune');
|
|
26
|
+
perform cron.schedule(
|
|
27
|
+
'auth_rate_limit_prune',
|
|
28
|
+
'*/5 * * * *',
|
|
29
|
+
$cron$delete from auth.rate_limit_buckets
|
|
30
|
+
where window_start < now() - interval '1 hour'$cron$
|
|
31
|
+
);
|
|
32
|
+
end if;
|
|
33
|
+
end $$;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- Worker heartbeat table for /readyz health probing.
|
|
2
|
+
-- Each running worker upserts a row on every poll tick;
|
|
3
|
+
-- /readyz checks the age of the most recent heartbeat.
|
|
4
|
+
create table if not exists jobs.worker_heartbeats (
|
|
5
|
+
worker_id text primary key,
|
|
6
|
+
last_heartbeat timestamptz not null default now(),
|
|
7
|
+
inflight int not null default 0
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
alter table jobs.worker_heartbeats enable row level security;
|
|
11
|
+
alter table jobs.worker_heartbeats owner to service_role;
|
|
12
|
+
|
|
13
|
+
-- service_role owns the table; workers run as service_role.
|
|
14
|
+
-- No grant to authenticated -- this is internal infrastructure.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
create schema if not exists admin authorization service_role;
|
|
2
|
+
|
|
3
|
+
create table if not exists admin.admin_users (
|
|
4
|
+
id uuid primary key default gen_random_uuid(),
|
|
5
|
+
email citext unique not null,
|
|
6
|
+
password_hash text not null,
|
|
7
|
+
is_root boolean not null default false,
|
|
8
|
+
created_at timestamptz not null default now(),
|
|
9
|
+
last_login_at timestamptz
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
create table if not exists admin.admin_sessions (
|
|
13
|
+
id uuid primary key default gen_random_uuid(),
|
|
14
|
+
admin_id uuid not null references admin.admin_users(id) on delete cascade,
|
|
15
|
+
token_hash bytea not null unique,
|
|
16
|
+
issued_at timestamptz not null default now(),
|
|
17
|
+
expires_at timestamptz not null,
|
|
18
|
+
revoked_at timestamptz,
|
|
19
|
+
ip inet,
|
|
20
|
+
user_agent text
|
|
21
|
+
);
|
|
22
|
+
create index if not exists admin_sessions_admin_id_idx on admin.admin_sessions (admin_id);
|
|
23
|
+
|
|
24
|
+
create table if not exists admin.admin_audit (
|
|
25
|
+
id bigserial primary key,
|
|
26
|
+
admin_id uuid references admin.admin_users(id) on delete set null,
|
|
27
|
+
action text not null,
|
|
28
|
+
target text,
|
|
29
|
+
payload jsonb not null default '{}'::jsonb,
|
|
30
|
+
ip inet,
|
|
31
|
+
user_agent text,
|
|
32
|
+
at timestamptz not null default now()
|
|
33
|
+
);
|
|
34
|
+
create index if not exists admin_audit_at_idx on admin.admin_audit (at desc);
|
|
35
|
+
|
|
36
|
+
revoke all on schema admin from public;
|
|
37
|
+
revoke all on all tables in schema admin from public;
|
|
38
|
+
|
|
39
|
+
-- Schema is owned by service_role, but tables created here by the migration
|
|
40
|
+
-- runner (e.g. `supython` in dev) are not. Grant explicitly so the login
|
|
41
|
+
-- handler — which runs as service_role via `db.as_service_role()` — can read
|
|
42
|
+
-- and write admin tables. Mirrors the pattern in 0002_auth_schema.sql.
|
|
43
|
+
grant usage on schema admin to service_role;
|
|
44
|
+
grant all on all tables in schema admin to service_role;
|
|
45
|
+
grant all on all sequences in schema admin to service_role;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- Add a per-user ban window so the admin surface can ban / unban accounts
|
|
2
|
+
-- without deleting rows. `auth.users.banned_until` is null when the user is
|
|
3
|
+
-- not banned, otherwise the timestamp at which the ban lifts.
|
|
4
|
+
|
|
5
|
+
alter table auth.users
|
|
6
|
+
add column if not exists banned_until timestamptz;
|
|
7
|
+
|
|
8
|
+
create index if not exists users_banned_until_idx
|
|
9
|
+
on auth.users (banned_until)
|
|
10
|
+
where banned_until is not null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Store editable email templates so operators can customise the auth emails
|
|
2
|
+
-- without touching source files. The auth module looks up templates by name;
|
|
3
|
+
-- if a row exists, its subject + text_body are used instead of the built-in
|
|
4
|
+
-- default.
|
|
5
|
+
|
|
6
|
+
create table if not exists admin.email_templates (
|
|
7
|
+
name text primary key,
|
|
8
|
+
subject text not null,
|
|
9
|
+
text_body text not null,
|
|
10
|
+
updated_at timestamptz not null default now()
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
-- Seed the three templates the auth module currently ships as hard-coded
|
|
14
|
+
-- strings, so the operator sees the current defaults on first open.
|
|
15
|
+
insert into admin.email_templates (name, subject, text_body) values
|
|
16
|
+
('recover', 'Reset your password', 'Use this token to reset your password: {{ token }}'),
|
|
17
|
+
('magic_link', 'Sign in to your account', 'Click the link to sign in: {{ url }}'),
|
|
18
|
+
('otp', 'Your one-time password', 'Your OTP is: {{ token }}')
|
|
19
|
+
on conflict (name) do nothing;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
-- v0.4 follow-up: do not let realtime.fire_notify() abort user writes
|
|
2
|
+
-- when the rendered NOTIFY payload exceeds Postgres's 8000-byte ceiling.
|
|
3
|
+
--
|
|
4
|
+
-- Background: pg_notify(channel, payload) raises `payload string too
|
|
5
|
+
-- long` if `payload` is larger than 8000 bytes (a hard server-side
|
|
6
|
+
-- limit). Before this migration the trigger called pg_notify
|
|
7
|
+
-- unconditionally, so a single oversize row aborted the user's write
|
|
8
|
+
-- transaction. That is a correctness bug: a realtime subscription must
|
|
9
|
+
-- never break user writes.
|
|
10
|
+
--
|
|
11
|
+
-- New behavior: pre-check the rendered payload size before NOTIFY.
|
|
12
|
+
-- * Under the threshold (default 7900 bytes — ~100-byte headroom):
|
|
13
|
+
-- identical to migration 0006.
|
|
14
|
+
-- * Over the threshold:
|
|
15
|
+
-- 1. RAISE WARNING with schema, table, op, and rendered byte size
|
|
16
|
+
-- so the operator sees the event in the Postgres log. The v0.8
|
|
17
|
+
-- benchmark counts these as the second motivator for the
|
|
18
|
+
-- deferred logical-replication source (§19, 2026-05-04).
|
|
19
|
+
-- 2. Skip pg_notify. The write succeeds; subscribers receive no
|
|
20
|
+
-- event for this row. (Skipping is the conservative choice:
|
|
21
|
+
-- emitting a metadata-only notify would put `record: null` on
|
|
22
|
+
-- the wire for INSERT/UPDATE, which today only happens for
|
|
23
|
+
-- DELETE — a wire-shape change the broker and SDKs would have
|
|
24
|
+
-- to learn about.)
|
|
25
|
+
--
|
|
26
|
+
-- Operator escape hatch: realtime v2 (logical replication, Post v1.1)
|
|
27
|
+
-- has no payload-size cap. Until that ships, oversize rows are not
|
|
28
|
+
-- delivered via realtime; clients can refetch via REST/PostgREST.
|
|
29
|
+
--
|
|
30
|
+
-- Idempotent: `create or replace function` — fresh installs apply 0006
|
|
31
|
+
-- and then this; existing installs apply only this and pick up the new
|
|
32
|
+
-- body in place.
|
|
33
|
+
|
|
34
|
+
create or replace function realtime.fire_notify()
|
|
35
|
+
returns trigger language plpgsql security definer as $$
|
|
36
|
+
declare
|
|
37
|
+
v_columns jsonb;
|
|
38
|
+
v_payload jsonb;
|
|
39
|
+
v_payload_text text;
|
|
40
|
+
v_payload_bytes int;
|
|
41
|
+
-- 8000-byte NOTIFY cap minus ~100 bytes of headroom for asyncpg /
|
|
42
|
+
-- channel-name overhead. Hard-coded rather than a setting because
|
|
43
|
+
-- the 8000 limit is a Postgres compile-time constant — operators
|
|
44
|
+
-- cannot raise it without recompiling.
|
|
45
|
+
v_payload_max int := 7900;
|
|
46
|
+
begin
|
|
47
|
+
select jsonb_agg(
|
|
48
|
+
jsonb_build_object(
|
|
49
|
+
'name', a.attname,
|
|
50
|
+
'type', format_type(a.atttypid, a.atttypmod)
|
|
51
|
+
)
|
|
52
|
+
order by a.attnum
|
|
53
|
+
)
|
|
54
|
+
into v_columns
|
|
55
|
+
from pg_attribute a
|
|
56
|
+
where a.attrelid = tg_relid
|
|
57
|
+
and a.attnum > 0
|
|
58
|
+
and not a.attisdropped;
|
|
59
|
+
|
|
60
|
+
v_payload := jsonb_build_object(
|
|
61
|
+
'schema', tg_table_schema,
|
|
62
|
+
'table', tg_table_name,
|
|
63
|
+
'type', tg_op,
|
|
64
|
+
'commit_timestamp', to_char(
|
|
65
|
+
now() at time zone 'utc',
|
|
66
|
+
'YYYY-MM-DD"T"HH24:MI:SS"Z"'
|
|
67
|
+
),
|
|
68
|
+
'columns', coalesce(v_columns, '[]'::jsonb),
|
|
69
|
+
'record', case
|
|
70
|
+
when tg_op in ('INSERT', 'UPDATE') then to_jsonb(new)
|
|
71
|
+
else null
|
|
72
|
+
end,
|
|
73
|
+
'old_record', case
|
|
74
|
+
when tg_op in ('UPDATE', 'DELETE') then to_jsonb(old)
|
|
75
|
+
else null
|
|
76
|
+
end
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
v_payload_text := v_payload::text;
|
|
80
|
+
v_payload_bytes := octet_length(v_payload_text);
|
|
81
|
+
|
|
82
|
+
if v_payload_bytes > v_payload_max then
|
|
83
|
+
raise warning
|
|
84
|
+
'realtime.fire_notify: dropping % event for %.%; payload is % bytes (>%-byte NOTIFY ceiling)',
|
|
85
|
+
tg_op,
|
|
86
|
+
tg_table_schema,
|
|
87
|
+
tg_table_name,
|
|
88
|
+
v_payload_bytes,
|
|
89
|
+
v_payload_max;
|
|
90
|
+
return null;
|
|
91
|
+
end if;
|
|
92
|
+
|
|
93
|
+
perform pg_notify('realtime:changes', v_payload_text);
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
end $$;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
create table if not exists admin.backups (
|
|
2
|
+
id uuid primary key default gen_random_uuid(),
|
|
3
|
+
kind text not null check (kind in ('full', 'schema-only')),
|
|
4
|
+
status text not null default 'running'
|
|
5
|
+
check (status in ('running', 'completed', 'failed')),
|
|
6
|
+
size bigint,
|
|
7
|
+
file_path text,
|
|
8
|
+
error_message text,
|
|
9
|
+
started_at timestamptz not null default now(),
|
|
10
|
+
finished_at timestamptz,
|
|
11
|
+
created_at timestamptz not null default now()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
grant all on admin.backups to service_role;
|
supython/passwords.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from argon2 import PasswordHasher
|
|
2
|
+
from argon2.exceptions import VerificationError, VerifyMismatchError
|
|
3
|
+
|
|
4
|
+
_hasher = PasswordHasher()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def hash_password(password: str) -> str:
|
|
8
|
+
return _hasher.hash(password)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def verify_password(hashed: str, password: str) -> bool:
|
|
12
|
+
try:
|
|
13
|
+
return _hasher.verify(hashed, password)
|
|
14
|
+
except (VerifyMismatchError, VerificationError):
|
|
15
|
+
return False
|