@snagset/server 0.0.1
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.
- package/README.md +83 -0
- package/dist/app.js +453 -0
- package/dist/app.js.map +1 -0
- package/dist/bin.js +951 -0
- package/dist/bin.js.map +1 -0
- package/dist/boot.js +321 -0
- package/dist/boot.js.map +1 -0
- package/dist/config.generated.js +116 -0
- package/dist/config.generated.js.map +1 -0
- package/dist/config.js +180 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.js +26 -0
- package/dist/crypto.js.map +1 -0
- package/dist/db/connect.js +49 -0
- package/dist/db/connect.js.map +1 -0
- package/dist/db/migrate.js +210 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/migrations/0001_init.sql +1060 -0
- package/dist/db/migrations/0002_maintenance_claim.sql +109 -0
- package/dist/db/migrations/0003_email_provider.sql +35 -0
- package/dist/db/migrations/0004_unsubscribe.sql +24 -0
- package/dist/db/rls.js +58 -0
- package/dist/db/rls.js.map +1 -0
- package/dist/jobs/index.js +171 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/reaper.js +182 -0
- package/dist/jobs/reaper.js.map +1 -0
- package/dist/keys.js +77 -0
- package/dist/keys.js.map +1 -0
- package/dist/mail/index.js +75 -0
- package/dist/mail/index.js.map +1 -0
- package/dist/mail/mailer.js +27 -0
- package/dist/mail/mailer.js.map +1 -0
- package/dist/mail/notify.js +405 -0
- package/dist/mail/notify.js.map +1 -0
- package/dist/mail/resend.js +95 -0
- package/dist/mail/resend.js.map +1 -0
- package/dist/mail/smtp.js +315 -0
- package/dist/mail/smtp.js.map +1 -0
- package/dist/mail/unsubscribe.js +134 -0
- package/dist/mail/unsubscribe.js.map +1 -0
- package/dist/projectCache.js +50 -0
- package/dist/projectCache.js.map +1 -0
- package/dist/projects.js +101 -0
- package/dist/projects.js.map +1 -0
- package/dist/routes/attachments.js +159 -0
- package/dist/routes/attachments.js.map +1 -0
- package/dist/routes/auth.js +55 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/commentEdit.js +201 -0
- package/dist/routes/commentEdit.js.map +1 -0
- package/dist/routes/comments.js +313 -0
- package/dist/routes/comments.js.map +1 -0
- package/dist/routes/cors.js +174 -0
- package/dist/routes/cors.js.map +1 -0
- package/dist/routes/jobs.js +56 -0
- package/dist/routes/jobs.js.map +1 -0
- package/dist/routes/mcpAuth.js +187 -0
- package/dist/routes/mcpAuth.js.map +1 -0
- package/dist/routes/mcpToken.js +94 -0
- package/dist/routes/mcpToken.js.map +1 -0
- package/dist/routes/owner.js +288 -0
- package/dist/routes/owner.js.map +1 -0
- package/dist/routes/reviewLinks.js +169 -0
- package/dist/routes/reviewLinks.js.map +1 -0
- package/dist/routes/rounds.js +137 -0
- package/dist/routes/rounds.js.map +1 -0
- package/dist/routes/sessions.js +349 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/sites.js +192 -0
- package/dist/routes/sites.js.map +1 -0
- package/dist/routes/threads.js +1080 -0
- package/dist/routes/threads.js.map +1 -0
- package/dist/routes/widget.js +132 -0
- package/dist/routes/widget.js.map +1 -0
- package/dist/serverless.js +97 -0
- package/dist/serverless.js.map +1 -0
- package/dist/session.js +152 -0
- package/dist/session.js.map +1 -0
- package/dist/storage/bucket.js +116 -0
- package/dist/storage/bucket.js.map +1 -0
- package/dist/storage/settings.js +132 -0
- package/dist/storage/settings.js.map +1 -0
- package/dist/vendor/adapters/index.js +1 -0
- package/dist/vendor/adapters/outbound/fence.js +44 -0
- package/dist/vendor/adapters/outbound/sanitise.js +233 -0
- package/dist/vendor/types/index.js +4 -0
- package/dist/vendor/types/names.js +254 -0
- package/dist/vendor/widget/object.js +38 -0
- package/dist/vendor/widget/predicates.js +377 -0
- package/package.json +53 -0
- package/static/dashboard/assets/index-CaPfKhFP.css +1 -0
- package/static/dashboard/assets/index-DfTNjHCK.js +1 -0
- package/static/dashboard/index.html +14 -0
- package/static/widget/snag.anchor-ext.js +1 -0
- package/static/widget/snag.anchor-fuzzy.js +1 -0
- package/static/widget/snag.capture.js +4 -0
- package/static/widget/snag.crumbs.js +4 -0
- package/static/widget/snag.dock.js +5 -0
- package/static/widget/snag.js +5 -0
- package/static/widget/snag.panels.js +2 -0
- package/static/widget/snag.runtime.js +2 -0
- package/static/widget/snag.screenshot.js +1 -0
- package/static/widget/snag.text.js +2 -0
- package/vercel.json +6 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- 08 §9.2's MAINTENANCE CLAIM (D56) — the second disjunct every org policy
|
|
3
|
+
-- needs before scheduled work can see a single row.
|
|
4
|
+
--
|
|
5
|
+
-- THE BUG THIS FIXES, measured rather than reasoned:
|
|
6
|
+
--
|
|
7
|
+
-- 0001 writes USING (org_id = current_setting('app.org_id', true)::uuid)
|
|
8
|
+
--
|
|
9
|
+
-- The reaper (05 §3.11) is cross-tenant by definition: it expires every org's
|
|
10
|
+
-- nonces, downgrades every project past its grace window, reaps every orphaned
|
|
11
|
+
-- blob. It therefore runs with no app.org_id set — and with nothing set,
|
|
12
|
+
-- current_setting(..., true) returns NULL, org_id = NULL evaluates to NULL,
|
|
13
|
+
-- NULL is not true, and every row is filtered out. The job commits, reports
|
|
14
|
+
-- success, and has done nothing.
|
|
15
|
+
--
|
|
16
|
+
-- On the test database: as the owner, one project. As a role without
|
|
17
|
+
-- BYPASSRLS and no app.org_id, zero. The failure is INVISIBLE wherever the
|
|
18
|
+
-- connecting role bypasses RLS, which includes every Neon connection string
|
|
19
|
+
-- (D50) — so it works in development and silently stops working on every
|
|
20
|
+
-- correctly configured self-host. That is the worst shape a bug can have.
|
|
21
|
+
--
|
|
22
|
+
-- WHY THIS IS A SEPARATE FILE AND NOT A REGENERATED 0001.
|
|
23
|
+
--
|
|
24
|
+
-- The policies are not in 05 §3 — the generator adds them (see its header), so
|
|
25
|
+
-- changing them means changing the generator, which changes 0001, which
|
|
26
|
+
-- changes its checksum, which makes migrate.ts refuse to start against any
|
|
27
|
+
-- database that already applied it. By the time this was written that included
|
|
28
|
+
-- a dogfood instance holding real review data. A migration is exactly the tool
|
|
29
|
+
-- for "the schema needs to change and the old one is already out there", and
|
|
30
|
+
-- reaching for the generator instead would have traded somebody's data for a
|
|
31
|
+
-- tidier diff.
|
|
32
|
+
--
|
|
33
|
+
-- THE SECOND BUG IN THE SAME PREDICATE, found while testing the first.
|
|
34
|
+
--
|
|
35
|
+
-- After a transaction that did set_config('app.org_id', ..., true) commits,
|
|
36
|
+
-- current_setting('app.org_id', true) does NOT go back to NULL — it returns
|
|
37
|
+
-- the EMPTY STRING. So the next query on that pooled connection evaluates
|
|
38
|
+
-- ''::uuid and raises "invalid input syntax for type uuid". Not zero rows: an
|
|
39
|
+
-- error. Every request the server serves sets that variable, and every
|
|
40
|
+
-- connection is reused, so on a deployment where the policy is actually
|
|
41
|
+
-- evaluated this is not an edge case — it is the second request onwards.
|
|
42
|
+
--
|
|
43
|
+
-- Invisible for the same reason as the first bug: a role with BYPASSRLS never
|
|
44
|
+
-- evaluates the predicate, so the whole class of defect is undetectable on the
|
|
45
|
+
-- database this was developed against. Both are fixed here, because both are
|
|
46
|
+
-- the same sentence written wrong, and a migration that fixed only the one we
|
|
47
|
+
-- went looking for would leave the worse one in place.
|
|
48
|
+
--
|
|
49
|
+
-- WHY A LOOP AND NOT 31 HAND-WRITTEN STATEMENTS.
|
|
50
|
+
--
|
|
51
|
+
-- The predicate differs per table (orgs matches on id, users through
|
|
52
|
+
-- memberships, everything else on org_id), so hand-writing them means
|
|
53
|
+
-- transcribing three variants across 31 tables — the transcription error this
|
|
54
|
+
-- repo generates its migration to avoid. Reading each policy's CURRENT
|
|
55
|
+
-- expression back out of the catalogue and appending one disjunct is both
|
|
56
|
+
-- shorter and incapable of getting a predicate wrong.
|
|
57
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
DO $$
|
|
60
|
+
DECLARE
|
|
61
|
+
p record;
|
|
62
|
+
existing text;
|
|
63
|
+
BEGIN
|
|
64
|
+
FOR p IN
|
|
65
|
+
SELECT c.relname AS table_name, pol.polname AS policy_name,
|
|
66
|
+
pg_get_expr(pol.polqual, pol.polrelid) AS qual
|
|
67
|
+
FROM pg_policy pol
|
|
68
|
+
JOIN pg_class c ON c.oid = pol.polrelid
|
|
69
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
70
|
+
WHERE n.nspname = 'public'
|
|
71
|
+
AND pol.polname = c.relname || '_org'
|
|
72
|
+
ORDER BY c.relname
|
|
73
|
+
LOOP
|
|
74
|
+
existing := p.qual;
|
|
75
|
+
|
|
76
|
+
-- Fix one: an unset variable reads as '' rather than NULL, and ''::uuid
|
|
77
|
+
-- raises. NULLIF turns it back into the NULL the predicate was written
|
|
78
|
+
-- assuming, which compares as unknown and filters the row out quietly —
|
|
79
|
+
-- which is what "no tenant context" should do.
|
|
80
|
+
existing := replace(
|
|
81
|
+
existing,
|
|
82
|
+
'current_setting(''app.org_id''::text, true)',
|
|
83
|
+
'NULLIF(current_setting(''app.org_id''::text, true), ''''::text)'
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
-- Fix two: the maintenance claim. Idempotent by inspection rather than by
|
|
87
|
+
-- IF NOT EXISTS: re-running this migration is not the case that matters
|
|
88
|
+
-- (the ledger stops that), but a database that reached this shape another
|
|
89
|
+
-- way must not end up with the disjunct twice.
|
|
90
|
+
IF existing NOT LIKE '%app.maintenance%' THEN
|
|
91
|
+
existing := existing ||
|
|
92
|
+
' OR current_setting(''app.maintenance'', true) = ''on''';
|
|
93
|
+
END IF;
|
|
94
|
+
|
|
95
|
+
IF existing = p.qual THEN
|
|
96
|
+
CONTINUE;
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
EXECUTE format(
|
|
100
|
+
'DROP POLICY %I ON public.%I',
|
|
101
|
+
p.policy_name, p.table_name
|
|
102
|
+
);
|
|
103
|
+
EXECUTE format(
|
|
104
|
+
'CREATE POLICY %I ON public.%I USING (%s)',
|
|
105
|
+
p.policy_name, p.table_name, existing
|
|
106
|
+
);
|
|
107
|
+
END LOOP;
|
|
108
|
+
END
|
|
109
|
+
$$;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- `email` joins snag_provider, so notification mail can ride the outbox
|
|
3
|
+
-- (L1.4, plan/03 §M2-32: "Delivered through M2-31's outbox, so a retry never
|
|
4
|
+
-- double-sends").
|
|
5
|
+
--
|
|
6
|
+
-- The outbox already carries exactly the guarantee email needs and nothing
|
|
7
|
+
-- else in this system does: UNIQUE (provider, dedupe_key) makes re-enqueueing
|
|
8
|
+
-- a no-op, and the status machine makes a delivered row un-redeliverable. A
|
|
9
|
+
-- second table beside it would be that mechanism written twice, and the second
|
|
10
|
+
-- copy is the one that gets the retry semantics subtly wrong.
|
|
11
|
+
--
|
|
12
|
+
-- WHY 05 §3's ENUM BLOCK IS NOT EDITED, for the second time.
|
|
13
|
+
--
|
|
14
|
+
-- §3 is the sole schema source and 0001 is generated from it — so editing the
|
|
15
|
+
-- enum there regenerates 0001, changes its checksum, and makes migrate.ts
|
|
16
|
+
-- refuse to start against every database that already applied it. The
|
|
17
|
+
-- convention `migrate.ts` documents is that 0001 stays regenerable UNTIL M2-35
|
|
18
|
+
-- freezes schemaVersion, after which §3 describes the current schema and the
|
|
19
|
+
-- generator's check becomes "0001 plus every later file EQUALS §3".
|
|
20
|
+
--
|
|
21
|
+
-- This is the SECOND migration to work around that boundary (0002 was the
|
|
22
|
+
-- first), and two is the point at which it stops being an exception. The
|
|
23
|
+
-- freeze is now overdue: until it happens, §3 describes the schema a FRESH
|
|
24
|
+
-- database gets before its later migrations run, which is a subtly different
|
|
25
|
+
-- thing from the schema, and a reader has no way to tell from §3 alone. A
|
|
26
|
+
-- prose note beside the enum in §3 points here; it sits outside the fenced
|
|
27
|
+
-- block, so the generator does not see it and 0001 is unchanged.
|
|
28
|
+
--
|
|
29
|
+
-- ADD VALUE IF NOT EXISTS is transaction-safe on PostgreSQL 12+ (08 §2.2
|
|
30
|
+
-- targets 14+) provided the new value is not USED in the same transaction.
|
|
31
|
+
-- Nothing here uses it; the first use is the first notification, which is a
|
|
32
|
+
-- different transaction entirely.
|
|
33
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
ALTER TYPE snag_provider ADD VALUE IF NOT EXISTS 'email';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- Reviewer notifications need an off switch — B2, and the FIRST migration
|
|
3
|
+
-- written under the frozen schema (D71).
|
|
4
|
+
--
|
|
5
|
+
-- Until now the only notification was `comment.created` → the project owner,
|
|
6
|
+
-- whose address we verified at claim time. Growing that to reviewers changes
|
|
7
|
+
-- the character of the system: their addresses are self-declared, so a
|
|
8
|
+
-- product that mails them on a stranger's trigger and offers no way out is an
|
|
9
|
+
-- open relay with better manners. The subscription rule is "created or
|
|
10
|
+
-- replied" (derivable, no schema) and the exit is this column.
|
|
11
|
+
--
|
|
12
|
+
-- WHY IT IS A TIMESTAMP. Every other state on `reviewers` is one —
|
|
13
|
+
-- `blocked_at`, `erased_at`, `identity_valid_after` — and a deliverability
|
|
14
|
+
-- complaint asks "when did they opt out", which a boolean cannot answer.
|
|
15
|
+
--
|
|
16
|
+
-- WHY NO INDEX. It is read one row at a time, always beside the reviewer the
|
|
17
|
+
-- notification is about, on a table already keyed by id. An index here would
|
|
18
|
+
-- be write cost for a read that never scans.
|
|
19
|
+
--
|
|
20
|
+
-- Under D71, §3 describes the CURRENT schema, so the column is added there in
|
|
21
|
+
-- the same commit as this file and `pnpm schema:diff:check` asserts the two
|
|
22
|
+
-- agree. That check is what makes this the last migration anybody has to
|
|
23
|
+
-- reason about by reading two places at once.
|
|
24
|
+
ALTER TABLE reviewers ADD COLUMN IF NOT EXISTS notifications_off_at timestamptz;
|
package/dist/db/rls.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export async function rlsStatus(sql) {
|
|
2
|
+
const [role] = await sql `
|
|
3
|
+
SELECT rolname AS name, rolbypassrls AS bypass
|
|
4
|
+
FROM pg_roles WHERE rolname = current_user
|
|
5
|
+
`;
|
|
6
|
+
// `_migrations` is ours and holds no tenant data; the pre-tenant
|
|
7
|
+
// `setup_tokens` has no org_id to scope by (05 §3.2). Everything else with an
|
|
8
|
+
// org_id column is expected to be protected, which is the check foundations
|
|
9
|
+
// §14.2 asks for stated as a query rather than as a convention.
|
|
10
|
+
const tables = await sql `
|
|
11
|
+
SELECT c.relname AS name,
|
|
12
|
+
c.relforcerowsecurity AS forced,
|
|
13
|
+
(SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policies
|
|
14
|
+
FROM pg_class c
|
|
15
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
16
|
+
WHERE n.nspname = 'public'
|
|
17
|
+
AND c.relkind = 'r'
|
|
18
|
+
AND EXISTS (
|
|
19
|
+
SELECT 1 FROM pg_attribute a
|
|
20
|
+
WHERE a.attrelid = c.oid AND a.attname = 'org_id' AND NOT a.attisdropped
|
|
21
|
+
)
|
|
22
|
+
ORDER BY c.relname
|
|
23
|
+
`;
|
|
24
|
+
const unprotected = tables.filter((t) => t.policies === 0).map((t) => t.name);
|
|
25
|
+
const notForced = tables.filter((t) => t.policies > 0 && !t.forced).map((t) => t.name);
|
|
26
|
+
const bypass = role?.bypass ?? false;
|
|
27
|
+
const effective = !bypass && !unprotected.length && !notForced.length && tables.length > 0;
|
|
28
|
+
let warning = null;
|
|
29
|
+
if (bypass) {
|
|
30
|
+
warning =
|
|
31
|
+
`row-level security is NOT in force: the role "${role?.name}" has BYPASSRLS, ` +
|
|
32
|
+
`which overrides every policy migration 0001 created. Managed Postgres often ` +
|
|
33
|
+
`grants it to the owner role in the connection string it hands you (Neon's ` +
|
|
34
|
+
`neondb_owner does). Requests are still scoped by org_id in the application, ` +
|
|
35
|
+
`so this is a lost safety net rather than an open door — to get it back, create ` +
|
|
36
|
+
`a role without BYPASSRLS, grant it the tables, and point DATABASE_URL at it.`;
|
|
37
|
+
}
|
|
38
|
+
else if (unprotected.length) {
|
|
39
|
+
warning =
|
|
40
|
+
`row-level security is incomplete: ${unprotected.length} table(s) carry org_id ` +
|
|
41
|
+
`with no policy (${unprotected.slice(0, 5).join(", ")}). Re-run migrations.`;
|
|
42
|
+
}
|
|
43
|
+
else if (notForced.length) {
|
|
44
|
+
warning =
|
|
45
|
+
`row-level security does not apply to the table owner on ${notForced.length} ` +
|
|
46
|
+
`table(s) (${notForced.slice(0, 5).join(", ")}), and a self-host connects as ` +
|
|
47
|
+
`the owner. Re-run migrations.`;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
effective,
|
|
51
|
+
role: role?.name ?? "unknown",
|
|
52
|
+
roleBypassesRls: bypass,
|
|
53
|
+
notForced,
|
|
54
|
+
unprotected,
|
|
55
|
+
warning,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=rls.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rls.js","sourceRoot":"","sources":["../../src/db/rls.ts"],"names":[],"mappings":"AAgDA,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,GAAiB;IAC/C,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,GAAG,CAAqC;;;GAG5D,CAAC;IAEF,iEAAiE;IACjE,8EAA8E;IAC9E,4EAA4E;IAC5E,gEAAgE;IAChE,MAAM,MAAM,GAAG,MAAM,GAAG,CAAuD;;;;;;;;;;;;;GAa9E,CAAC;IAEF,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC9E,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IACvF,MAAM,MAAM,GAAG,IAAI,EAAE,MAAM,IAAI,KAAK,CAAC;IACrC,MAAM,SAAS,GAAG,CAAC,MAAM,IAAI,CAAC,WAAW,CAAC,MAAM,IAAI,CAAC,SAAS,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;IAE3F,IAAI,OAAO,GAAkB,IAAI,CAAC;IAClC,IAAI,MAAM,EAAE,CAAC;QACX,OAAO;YACL,iDAAiD,IAAI,EAAE,IAAI,mBAAmB;gBAC9E,8EAA8E;gBAC9E,4EAA4E;gBAC5E,8EAA8E;gBAC9E,iFAAiF;gBACjF,8EAA8E,CAAC;IACnF,CAAC;SAAM,IAAI,WAAW,CAAC,MAAM,EAAE,CAAC;QAC9B,OAAO;YACL,qCAAqC,WAAW,CAAC,MAAM,yBAAyB;gBAChF,mBAAmB,WAAW,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,uBAAuB,CAAC;IACjF,CAAC;SAAM,IAAI,SAAS,CAAC,MAAM,EAAE,CAAC;QAC5B,OAAO;YACL,2DAA2D,SAAS,CAAC,MAAM,GAAG;gBAC9E,aAAa,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,iCAAiC;gBAC9E,+BAA+B,CAAC;IACpC,CAAC;IAED,OAAO;QACL,SAAS;QACT,IAAI,EAAE,IAAI,EAAE,IAAI,IAAI,SAAS;QAC7B,eAAe,EAAE,MAAM;QACvB,SAAS;QACT,WAAW;QACX,OAAO;KACR,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The advisory lock namespace — the first half of the two-int lock id.
|
|
3
|
+
*
|
|
4
|
+
* 0x63644A42 is the ASCII for "cdJB", picked the same way `MIGRATION_LOCK_ID`
|
|
5
|
+
* picks "cdMG": the number is meaningless, and what matters is only that every
|
|
6
|
+
* instance computes the SAME one without coordinating.
|
|
7
|
+
*/
|
|
8
|
+
export const JOB_LOCK_NAMESPACE = 0x63644a42;
|
|
9
|
+
/**
|
|
10
|
+
* Job name → the second half of the lock id, as a SIGNED 32-bit int.
|
|
11
|
+
*
|
|
12
|
+
* FNV-1a rather than a hash import: the requirement is that two processes
|
|
13
|
+
* agree, not that the result is hard to reverse. `| 0` at the end is what
|
|
14
|
+
* makes it signed — Postgres takes `int4`, and handing it 3 000 000 000 is an
|
|
15
|
+
* out-of-range error rather than a silent wrap.
|
|
16
|
+
*/
|
|
17
|
+
export function jobLockKey(name) {
|
|
18
|
+
let h = 0x811c9dc5;
|
|
19
|
+
for (let i = 0; i < name.length; i++) {
|
|
20
|
+
h ^= name.charCodeAt(i);
|
|
21
|
+
h = Math.imul(h, 0x01000193);
|
|
22
|
+
}
|
|
23
|
+
return h | 0;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The engine's own bookkeeping table.
|
|
27
|
+
*
|
|
28
|
+
* Created here rather than in a migration, and the precedent is `_migrations`
|
|
29
|
+
* itself: 05 §3 owns the DOMAIN schema — every table a tenant's data lives in —
|
|
30
|
+
* and the runners that operate on it keep their own state beside it. Both are
|
|
31
|
+
* `_`-prefixed, both hold no tenant data, both have no `org_id` and therefore
|
|
32
|
+
* no RLS policy to write (`rls.ts` only inspects tables that carry one).
|
|
33
|
+
*
|
|
34
|
+
* `last_run_at` is written whether the job SUCCEEDED OR FAILED. A job that
|
|
35
|
+
* throws every time would otherwise be due on every tick forever, and a
|
|
36
|
+
* failing 15-minute reaper hammering the database once a second is a worse
|
|
37
|
+
* outage than the one it started with. The error is kept beside it so the
|
|
38
|
+
* operator can see which, and how long it has been that way.
|
|
39
|
+
*/
|
|
40
|
+
async function ensureTable(sql) {
|
|
41
|
+
await sql `
|
|
42
|
+
CREATE TABLE IF NOT EXISTS _jobs (
|
|
43
|
+
name text PRIMARY KEY,
|
|
44
|
+
last_run_at timestamptz NOT NULL,
|
|
45
|
+
last_ms integer NOT NULL DEFAULT 0,
|
|
46
|
+
last_did integer NOT NULL DEFAULT 0,
|
|
47
|
+
last_error text
|
|
48
|
+
)
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
export async function runDueJobs(sql, jobs, opts) {
|
|
52
|
+
const started = Date.now();
|
|
53
|
+
const clock = opts.now ?? (() => new Date());
|
|
54
|
+
const log = opts.log ?? (() => { });
|
|
55
|
+
const maxPasses = opts.maxPasses ?? 20;
|
|
56
|
+
const spent = () => Date.now() - started;
|
|
57
|
+
const ran = [];
|
|
58
|
+
const skipped = [];
|
|
59
|
+
const failed = [];
|
|
60
|
+
await ensureTable(sql);
|
|
61
|
+
const last = new Map((await sql `SELECT name, last_run_at FROM _jobs`).map((r) => [r.name, r.last_run_at]));
|
|
62
|
+
for (const job of jobs) {
|
|
63
|
+
if (spent() >= opts.budgetMs) {
|
|
64
|
+
skipped.push({ name: job.name, why: "budget" });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const now = clock();
|
|
68
|
+
const previous = last.get(job.name);
|
|
69
|
+
// No row at all means never run, which is due. `>=` rather than `>` so a
|
|
70
|
+
// job with everyMs 0 — "every tick" — actually runs every tick.
|
|
71
|
+
if (previous && now.getTime() - previous.getTime() < job.everyMs) {
|
|
72
|
+
skipped.push({ name: job.name, why: "not-due" });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const at = Date.now();
|
|
76
|
+
let did = 0;
|
|
77
|
+
let passes = 0;
|
|
78
|
+
let error = null;
|
|
79
|
+
let lockedOut = false;
|
|
80
|
+
try {
|
|
81
|
+
for (;;) {
|
|
82
|
+
const outcome = await runOnce(sql, job, clock(), log);
|
|
83
|
+
if (outcome === "locked") {
|
|
84
|
+
lockedOut = true;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
passes++;
|
|
88
|
+
did += outcome.did;
|
|
89
|
+
if (outcome.note)
|
|
90
|
+
log(`jobs: ${job.name}: ${outcome.note}`);
|
|
91
|
+
if (!outcome.more)
|
|
92
|
+
break;
|
|
93
|
+
if (passes >= maxPasses) {
|
|
94
|
+
log(`jobs: ${job.name}: stopping at ${maxPasses} passes with work left`);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
if (spent() >= opts.budgetMs) {
|
|
98
|
+
log(`jobs: ${job.name}: out of budget with work left`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
error = e instanceof Error ? e.message : String(e);
|
|
105
|
+
}
|
|
106
|
+
if (lockedOut) {
|
|
107
|
+
// Another instance has it. Deliberately NOT recorded as a run: that
|
|
108
|
+
// instance will write `last_run_at` when it finishes, and claiming the
|
|
109
|
+
// run here would make this one think the work was done.
|
|
110
|
+
skipped.push({ name: job.name, why: "locked" });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const ms = Date.now() - at;
|
|
114
|
+
if (error)
|
|
115
|
+
failed.push({ name: job.name, error });
|
|
116
|
+
else
|
|
117
|
+
ran.push({ name: job.name, did, ms, passes });
|
|
118
|
+
await record(sql, job.name, clock(), ms, did, error);
|
|
119
|
+
}
|
|
120
|
+
return { ran, skipped, failed, ms: spent() };
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* One pass of one job: take the lock, claim maintenance, do the work — all in
|
|
124
|
+
* ONE transaction, so all three end together however it ends.
|
|
125
|
+
*
|
|
126
|
+
* `SET LOCAL app.maintenance` is 08 §9.2's maintenance claim (D56) and without
|
|
127
|
+
* it every statement in every job matches zero rows on any deployment whose
|
|
128
|
+
* role does not bypass RLS. `LOCAL` is the half that makes it an exemption
|
|
129
|
+
* rather than a hole: it cannot outlive this transaction, so nothing that runs
|
|
130
|
+
* after it inherits cross-tenant reach.
|
|
131
|
+
*/
|
|
132
|
+
async function runOnce(sql, job, now, log) {
|
|
133
|
+
return sql.begin(async (tx) => {
|
|
134
|
+
const [lock] = await tx `
|
|
135
|
+
SELECT pg_try_advisory_xact_lock(${JOB_LOCK_NAMESPACE}, ${jobLockKey(job.name)}) AS ok
|
|
136
|
+
`;
|
|
137
|
+
if (!lock?.ok)
|
|
138
|
+
return "locked";
|
|
139
|
+
await tx `SET LOCAL app.maintenance = 'on'`;
|
|
140
|
+
return job.run({ sql: tx, now, log });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/** Bookkeeping, in its own transaction so a rolled-back job still records the
|
|
144
|
+
* attempt. Every job is idempotent, so the worst case if this write is lost
|
|
145
|
+
* to a crash is that the work is repeated — which is the direction to fail
|
|
146
|
+
* in. */
|
|
147
|
+
async function record(sql, name, at, ms, did, error) {
|
|
148
|
+
await sql `
|
|
149
|
+
INSERT INTO _jobs (name, last_run_at, last_ms, last_did, last_error)
|
|
150
|
+
VALUES (${name}, ${at}, ${ms}, ${did}, ${error})
|
|
151
|
+
ON CONFLICT (name) DO UPDATE
|
|
152
|
+
SET last_run_at = EXCLUDED.last_run_at,
|
|
153
|
+
last_ms = EXCLUDED.last_ms,
|
|
154
|
+
last_did = EXCLUDED.last_did,
|
|
155
|
+
last_error = EXCLUDED.last_error
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
/** One line an operator can read in a log without a dashboard. */
|
|
159
|
+
export function summarise(s) {
|
|
160
|
+
const ran = s.ran.filter((r) => r.did > 0).map((r) => `${r.name}:${r.did}`);
|
|
161
|
+
const parts = [`${s.ms}ms`];
|
|
162
|
+
if (ran.length)
|
|
163
|
+
parts.push(ran.join(" "));
|
|
164
|
+
if (s.failed.length)
|
|
165
|
+
parts.push(`FAILED ${s.failed.map((f) => f.name).join(" ")}`);
|
|
166
|
+
const locked = s.skipped.filter((x) => x.why === "locked");
|
|
167
|
+
if (locked.length)
|
|
168
|
+
parts.push(`locked ${locked.map((l) => l.name).join(" ")}`);
|
|
169
|
+
return `jobs: ${parts.join(" · ")}`;
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/jobs/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 05 §3.11's reaper, cut to the three jobs L1 needs (L1.2).
|
|
3
|
+
*
|
|
4
|
+
* §3.11 lists eighteen steps. The other fifteen land with L2, and the split is
|
|
5
|
+
* not arbitrary — these three are the ones whose ABSENCE is already costing
|
|
6
|
+
* something today:
|
|
7
|
+
*
|
|
8
|
+
* · `write-mode-downgrade` (§3.11 step 12) makes the read-time downgrade in
|
|
9
|
+
* `routes/sessions.ts` real. Until now `effectiveWriteMode()` computed the
|
|
10
|
+
* right answer on every request and no row ever changed, so the dashboard
|
|
11
|
+
* showed `open` on a project that had behaved as `link-required` for days.
|
|
12
|
+
* One of those two is lying to the operator.
|
|
13
|
+
* · `expire-rows` (steps 1, 2, 4) deletes rows that are already expired by
|
|
14
|
+
* their own column. Nothing reads them — every consumer checks
|
|
15
|
+
* `expires_at` — so this is purely a table that grows without bound. A
|
|
16
|
+
* nonce table nobody sweeps is the one that eventually makes an INSERT
|
|
17
|
+
* slow at three in the morning.
|
|
18
|
+
* · `reap-pending-attachments` (step 5) is the one that costs actual money:
|
|
19
|
+
* an upload that was presigned and never bound leaves a blob in the bucket
|
|
20
|
+
* that no row will ever reference and no user will ever see.
|
|
21
|
+
*
|
|
22
|
+
* Every statement here is idempotent and bounded by a `WHERE` clause on time.
|
|
23
|
+
* That is what makes DoD 3's "two ticks do not double-run" a belt-and-braces
|
|
24
|
+
* guarantee rather than the only thing standing between us and corruption:
|
|
25
|
+
* running any of these twice does exactly what running it once does.
|
|
26
|
+
*/
|
|
27
|
+
/** How many rows one pass will touch. Small enough that the transaction is
|
|
28
|
+
* short and the advisory lock is not held across a long DELETE; large enough
|
|
29
|
+
* that a normal backlog clears in one pass. `more: true` is what makes the
|
|
30
|
+
* size a batching decision rather than a cap on how much ever gets done. */
|
|
31
|
+
const BATCH = 500;
|
|
32
|
+
/**
|
|
33
|
+
* §3.11 step 12 — the first-run auto-downgrade.
|
|
34
|
+
*
|
|
35
|
+
* 05 §4.2 gives a new project 24 hours of `open` writes so that `npx snagset`
|
|
36
|
+
* reaches a first pin with no dashboard round trip. This is the other half:
|
|
37
|
+
* the clock actually running out.
|
|
38
|
+
*
|
|
39
|
+
* `write_mode_downgrade_at = NULL` in the same statement, because the column
|
|
40
|
+
* means "a downgrade is pending" and leaving a past timestamp on a project
|
|
41
|
+
* that is already `link-required` makes the dashboard's countdown render a
|
|
42
|
+
* negative number.
|
|
43
|
+
*/
|
|
44
|
+
export const writeModeDowngrade = {
|
|
45
|
+
name: "write-mode-downgrade",
|
|
46
|
+
everyMs: 60_000,
|
|
47
|
+
async run({ sql, now }) {
|
|
48
|
+
const rows = await sql `
|
|
49
|
+
UPDATE projects
|
|
50
|
+
SET write_mode = 'link-required',
|
|
51
|
+
write_mode_downgrade_at = NULL
|
|
52
|
+
WHERE write_mode = 'open'
|
|
53
|
+
AND write_mode_downgrade_at IS NOT NULL
|
|
54
|
+
AND write_mode_downgrade_at <= ${now}
|
|
55
|
+
AND deleted_at IS NULL
|
|
56
|
+
RETURNING id
|
|
57
|
+
`;
|
|
58
|
+
return rows.length
|
|
59
|
+
? { did: rows.length, note: `${rows.length} project(s) downgraded to link-required` }
|
|
60
|
+
: { did: 0 };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* §3.11 steps 1, 2 and 4 — rows whose own column says they are expired.
|
|
65
|
+
*
|
|
66
|
+
* One job rather than three because they share a shape, a cadence and a
|
|
67
|
+
* failure mode, and three jobs would mean three advisory locks and three
|
|
68
|
+
* `_jobs` rows to describe one sentence: "delete what has expired".
|
|
69
|
+
*
|
|
70
|
+
* The `stream_tickets` case (step 3) is deliberately absent — 08 §9.4 deleted
|
|
71
|
+
* SSE outright, so there is no such table to sweep and a statement against one
|
|
72
|
+
* would be dead code that reads as coverage.
|
|
73
|
+
*/
|
|
74
|
+
export const expireRows = {
|
|
75
|
+
name: "expire-rows",
|
|
76
|
+
everyMs: 15 * 60_000,
|
|
77
|
+
async run({ sql, now }) {
|
|
78
|
+
// Three separate statements, counted together. A single statement with
|
|
79
|
+
// UNION would need three different row shapes to line up and would tell
|
|
80
|
+
// the operator nothing about WHICH table was growing.
|
|
81
|
+
const idem = await sql `
|
|
82
|
+
DELETE FROM idempotency_keys
|
|
83
|
+
WHERE ctid IN (SELECT ctid FROM idempotency_keys WHERE expires_at < ${now} LIMIT ${BATCH})
|
|
84
|
+
`;
|
|
85
|
+
const nonces = await sql `
|
|
86
|
+
DELETE FROM identity_nonces
|
|
87
|
+
WHERE ctid IN (SELECT ctid FROM identity_nonces WHERE expires_at < ${now} LIMIT ${BATCH})
|
|
88
|
+
`;
|
|
89
|
+
const counters = await sql `
|
|
90
|
+
DELETE FROM rate_counters
|
|
91
|
+
WHERE ctid IN (SELECT ctid FROM rate_counters WHERE expires_at < ${now} LIMIT ${BATCH})
|
|
92
|
+
`;
|
|
93
|
+
const did = idem.count + nonces.count + counters.count;
|
|
94
|
+
// Any one of them filling its batch means there is more of that one left.
|
|
95
|
+
const more = idem.count === BATCH || nonces.count === BATCH || counters.count === BATCH;
|
|
96
|
+
return did
|
|
97
|
+
? {
|
|
98
|
+
did,
|
|
99
|
+
more,
|
|
100
|
+
note: `expired ${idem.count} idempotency · ${nonces.count} nonces · ${counters.count} counters`,
|
|
101
|
+
}
|
|
102
|
+
: { did: 0, more };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* §3.11 step 5 — presigned uploads that were never bound to a comment.
|
|
107
|
+
*
|
|
108
|
+
* The two windows are LOCK L4's, and the difference between them is not a
|
|
109
|
+
* rounding preference. An IMAGE is captured, resized and uploaded by our own
|
|
110
|
+
* code inside one composer submission, so two hours is enormous slack for a
|
|
111
|
+
* slow phone on hotel wifi. A FILE is a browser PUT straight to the bucket
|
|
112
|
+
* against a 5-minute presign, so anything older than 15 minutes cannot still
|
|
113
|
+
* be in flight — the URL it would use has already expired.
|
|
114
|
+
*
|
|
115
|
+
* `DeleteObject` is attempted unconditionally and its failure is swallowed,
|
|
116
|
+
* because both outcomes are fine and neither is worth a retry: the browser may
|
|
117
|
+
* never have PUT anything (delete on a missing key is a no-op in S3), or the
|
|
118
|
+
* bucket may be misconfigured (in which case the row still has to leave
|
|
119
|
+
* `pending`, or it is re-tried on every tick forever). What must NOT happen is
|
|
120
|
+
* the row being marked orphaned while the bytes stay — so the delete is
|
|
121
|
+
* attempted first and the row is updated after.
|
|
122
|
+
*
|
|
123
|
+
* With no bucket configured the job does nothing and says so. That is the
|
|
124
|
+
* annotation-mode deployment (08 §8.5), where no blob was ever written and
|
|
125
|
+
* there is nothing to reap.
|
|
126
|
+
*/
|
|
127
|
+
export function reapPendingAttachments(bucket) {
|
|
128
|
+
return {
|
|
129
|
+
name: "reap-pending-attachments",
|
|
130
|
+
everyMs: 5 * 60_000,
|
|
131
|
+
async run({ sql, now, log }) {
|
|
132
|
+
const imageCutoff = new Date(now.getTime() - 2 * 60 * 60_000);
|
|
133
|
+
const fileCutoff = new Date(now.getTime() - 15 * 60_000);
|
|
134
|
+
const rows = await sql `
|
|
135
|
+
SELECT id, project_id, storage_key, bytes, kind
|
|
136
|
+
FROM attachments
|
|
137
|
+
WHERE state = 'pending'
|
|
138
|
+
AND ((kind = 'file' AND created_at < ${fileCutoff})
|
|
139
|
+
OR (kind <> 'file' AND created_at < ${imageCutoff}))
|
|
140
|
+
ORDER BY created_at
|
|
141
|
+
LIMIT ${BATCH}
|
|
142
|
+
`;
|
|
143
|
+
if (!rows.length)
|
|
144
|
+
return { did: 0 };
|
|
145
|
+
if (!bucket) {
|
|
146
|
+
return { did: 0, note: `${rows.length} pending attachment(s) and no bucket to reap from` };
|
|
147
|
+
}
|
|
148
|
+
let reaped = 0;
|
|
149
|
+
for (const row of rows) {
|
|
150
|
+
try {
|
|
151
|
+
await bucket.del(row.storage_key);
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
// Named, not silent: a bucket that refuses every delete is a bill
|
|
155
|
+
// that grows, and the only place it shows up is here.
|
|
156
|
+
log(`jobs: reap-pending-attachments: delete ${row.storage_key} failed: ${String(e)}`);
|
|
157
|
+
}
|
|
158
|
+
await sql `UPDATE attachments SET state = 'orphaned' WHERE id = ${row.id}`;
|
|
159
|
+
// §12.6's counter. Decremented per row rather than in one statement at
|
|
160
|
+
// the end so a mid-batch failure cannot leave the counter describing
|
|
161
|
+
// rows that were already reaped.
|
|
162
|
+
await sql `
|
|
163
|
+
UPDATE projects
|
|
164
|
+
SET storage_bytes_used = GREATEST(0, storage_bytes_used - ${Number(row.bytes)})
|
|
165
|
+
WHERE id = ${row.project_id}
|
|
166
|
+
`;
|
|
167
|
+
reaped++;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
did: reaped,
|
|
171
|
+
more: rows.length === BATCH,
|
|
172
|
+
note: `${reaped} orphaned upload(s) reaped`,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/** The L1 set, in the order a tick runs them: cheapest and most
|
|
178
|
+
* user-visible first, so a budget that runs out costs the least. */
|
|
179
|
+
export function l1Jobs(bucket) {
|
|
180
|
+
return [writeModeDowngrade, expireRows, reapPendingAttachments(bucket)];
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=reaper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reaper.js","sourceRoot":"","sources":["../../src/jobs/reaper.ts"],"names":[],"mappings":"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"}
|