@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
package/dist/config.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { DELETED, REGISTRY } from "./config.generated.js";
|
|
2
|
+
/**
|
|
3
|
+
* The boot validator — 08 §4, and the ONLY module in the server that may read
|
|
4
|
+
* `process.env`.
|
|
5
|
+
*
|
|
6
|
+
* LOCK L7 and the scaffold's import lint make that mechanical rather than
|
|
7
|
+
* conventional: a route handler that reaches for `process.env` fails the
|
|
8
|
+
* lint. Every handler takes its config from `createApp(deps)`, which is what
|
|
9
|
+
* lets a test construct one instead of mutating the process.
|
|
10
|
+
*
|
|
11
|
+
* The REGISTRY is generated from 08 §4 — see `scripts/gen-config.mjs` and the
|
|
12
|
+
* rule it quotes. Nothing here is hand-maintained; this file is the rules,
|
|
13
|
+
* not the list.
|
|
14
|
+
*
|
|
15
|
+
* §4.1's rules, as implemented:
|
|
16
|
+
*
|
|
17
|
+
* 1. `DATABASE_URL` is the one required variable, in the platform's own
|
|
18
|
+
* spelling — the deliberate exception to the `SNAGSET_*` shape, because
|
|
19
|
+
* the Vercel marketplace mints it in the deploy click and Railway/Render
|
|
20
|
+
* inject it. A9's promise stands: one deployable unit, one required
|
|
21
|
+
* datastore, and everything else defaults to something that works.
|
|
22
|
+
* 2. An unknown `SNAGSET_*` is FATAL at boot, "with the offending name, the
|
|
23
|
+
* closest registered name by edit distance, and a link to this section" —
|
|
24
|
+
* because a transposed letter in a retention variable that silently
|
|
25
|
+
* reverts to its default is a data-loss bug discovered at restore time.
|
|
26
|
+
* 7. Customer-owned variables are not ours: `*_SNAGSET_ENABLED` and
|
|
27
|
+
* `SNAGSET_PRODUCTION_TOKEN_MODE` are set on the CUSTOMER's build (06
|
|
28
|
+
* §4), and the server "never reads them and must not reject them,
|
|
29
|
+
* because it never sees them" — so the prefix test is anchored.
|
|
30
|
+
* 8. Booleans are `true|false|1|0` case-insensitively; anything else fatal.
|
|
31
|
+
*/
|
|
32
|
+
export class ConfigError extends Error {
|
|
33
|
+
/** What the operator should do about it — printed under the message. */
|
|
34
|
+
fix;
|
|
35
|
+
constructor(message, fix) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "ConfigError";
|
|
38
|
+
this.fix = fix;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** §4.1 rule 7's carve-out: names a customer sets on THEIR build. */
|
|
42
|
+
const NOT_OURS = /^.+_SNAGSET_ENABLED$|^SNAGSET_PRODUCTION_TOKEN_MODE$/;
|
|
43
|
+
/** Levenshtein, for rule 2's "closest registered name". */
|
|
44
|
+
function distance(a, b) {
|
|
45
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
46
|
+
for (let i = 1; i <= a.length; i++) {
|
|
47
|
+
for (let j = 1; j <= b.length; j++) {
|
|
48
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return d[a.length][b.length];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The near-miss suggestion, and it is TOKEN-AWARE for a measured reason.
|
|
55
|
+
*
|
|
56
|
+
* Plain edit distance failed the exact mistake a first-time operator makes.
|
|
57
|
+
* `SNAGSET_OWNER_EMAIL` → `SNAGSET_BOOTSTRAP_OWNER_EMAIL` is 10 edits away —
|
|
58
|
+
* over any sane threshold — so the error said "unknown variable" and suggested
|
|
59
|
+
* nothing, while the right answer was the same name with one word inserted.
|
|
60
|
+
* Caught by mistyping it on a cold install and getting a dead end.
|
|
61
|
+
*
|
|
62
|
+
* So: if the typed name's underscore-separated words appear IN ORDER inside a
|
|
63
|
+
* registered name, that is a dropped or added segment rather than a typo, and
|
|
64
|
+
* it is a far stronger signal than character distance. Edit distance stays as
|
|
65
|
+
* the fallback for genuine typos (`SNAGSET_PROT`).
|
|
66
|
+
*/
|
|
67
|
+
function closest(name) {
|
|
68
|
+
const words = name.toUpperCase().split("_").filter(Boolean);
|
|
69
|
+
// Subsequence match first, shortest winner — `SNAGSET_OWNER_EMAIL` should
|
|
70
|
+
// reach `SNAGSET_BOOTSTRAP_OWNER_EMAIL` rather than some longer name that
|
|
71
|
+
// also happens to contain those words.
|
|
72
|
+
let bySegment = null;
|
|
73
|
+
for (const row of REGISTRY) {
|
|
74
|
+
const target = row.name.split("_");
|
|
75
|
+
let i = 0;
|
|
76
|
+
for (const part of target)
|
|
77
|
+
if (i < words.length && part === words[i])
|
|
78
|
+
i++;
|
|
79
|
+
if (i === words.length && (bySegment === null || row.name.length < bySegment.length)) {
|
|
80
|
+
bySegment = row.name;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (bySegment)
|
|
84
|
+
return bySegment;
|
|
85
|
+
let best = null;
|
|
86
|
+
let score = Infinity;
|
|
87
|
+
for (const row of REGISTRY) {
|
|
88
|
+
const n = distance(name, row.name);
|
|
89
|
+
if (n < score) {
|
|
90
|
+
score = n;
|
|
91
|
+
best = row.name;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Beyond a few edits it is not a typo, it is a different idea — and a
|
|
95
|
+
// confident wrong suggestion is worse than none.
|
|
96
|
+
return score <= 6 ? best : null;
|
|
97
|
+
}
|
|
98
|
+
export function loadConfig(env) {
|
|
99
|
+
const rows = new Map(REGISTRY.map((r) => [r.name, r]));
|
|
100
|
+
const retired = new Map(DELETED.map((d) => [d.name, d.to]));
|
|
101
|
+
// Rule 2, FIRST — before any value is read. An operator who typo'd a flag
|
|
102
|
+
// wants to hear about the typo, not about the consequence of it being
|
|
103
|
+
// unset: they turned something off, it stayed on, and nothing said so.
|
|
104
|
+
for (const name of Object.keys(env)) {
|
|
105
|
+
if (!name.startsWith("SNAGSET_") || rows.has(name) || NOT_OURS.test(name))
|
|
106
|
+
continue;
|
|
107
|
+
// §4.11 exists precisely so an upgrade says what a name BECAME.
|
|
108
|
+
if (retired.has(name)) {
|
|
109
|
+
const to = retired.get(name);
|
|
110
|
+
throw new ConfigError(`${name} is no longer a Snagset variable`, to ? `it was renamed — set ${to} instead (08 §4.11)` : `it was deleted; see 08 §4.11 for what replaced it`);
|
|
111
|
+
}
|
|
112
|
+
const near = closest(name);
|
|
113
|
+
throw new ConfigError(`unknown environment variable ${name}`,
|
|
114
|
+
// `specs/08-deployment.md §4` is the registry and is NOT in the npm
|
|
115
|
+
// tarball — an operator who installed the package has no `specs/`
|
|
116
|
+
// directory, so the one pointer this error gave was to a file they could
|
|
117
|
+
// not open. `snagset env` is the same registry, printed by the binary
|
|
118
|
+
// they already have in front of them.
|
|
119
|
+
`${near ? `did you mean ${near}? ` : ""}run \`snagset env\` for the full list`);
|
|
120
|
+
}
|
|
121
|
+
const resolved = new Map();
|
|
122
|
+
for (const row of REGISTRY) {
|
|
123
|
+
const raw = env[row.name];
|
|
124
|
+
if (raw !== undefined && raw !== "") {
|
|
125
|
+
validate(row, raw);
|
|
126
|
+
resolved.set(row.name, { value: raw, source: "env" });
|
|
127
|
+
}
|
|
128
|
+
else if (row.required) {
|
|
129
|
+
throw new ConfigError(`${row.name} is required`, `set ${row.name} — a Postgres connection string (08 §${row.section})`);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
resolved.set(row.name, { value: row.default, source: "default" });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const get = (name) => resolved.get(name)?.value ?? undefined;
|
|
136
|
+
return {
|
|
137
|
+
get,
|
|
138
|
+
int: (name) => Number(get(name)),
|
|
139
|
+
bool: (name) => /^(true|1)$/i.test(get(name) ?? ""),
|
|
140
|
+
required: (name) => {
|
|
141
|
+
const v = get(name);
|
|
142
|
+
if (v === undefined)
|
|
143
|
+
throw new ConfigError(`${name} has no value`, `set ${name}`);
|
|
144
|
+
return v;
|
|
145
|
+
},
|
|
146
|
+
describe: () => REGISTRY.map((r) => ({
|
|
147
|
+
name: r.name,
|
|
148
|
+
value: resolved.get(r.name).value,
|
|
149
|
+
source: resolved.get(r.name).source,
|
|
150
|
+
secret: r.secret,
|
|
151
|
+
})),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function validate(row, raw) {
|
|
155
|
+
const bad = (why, fix) => {
|
|
156
|
+
throw new ConfigError(`${row.name} ${why}`, fix);
|
|
157
|
+
};
|
|
158
|
+
if (row.oneOf && !row.oneOf.includes(raw)) {
|
|
159
|
+
bad(`must be one of ${row.oneOf.join(" | ")}, got ${JSON.stringify(raw)}`, `set ${row.name} to one of: ${row.oneOf.join(", ")}`);
|
|
160
|
+
}
|
|
161
|
+
// Rule 8: byte counts are integers, never "5MB" strings — a value the
|
|
162
|
+
// operator believes is 5 MB and the program reads as NaN is the worst of
|
|
163
|
+
// both, so it is refused rather than coerced.
|
|
164
|
+
if (row.type === "int" && !/^\d+$/.test(raw)) {
|
|
165
|
+
bad(`must be a whole number, got ${JSON.stringify(raw)}`, `set ${row.name} to digits only (durations are seconds/days by their suffix; byte counts are bytes)`);
|
|
166
|
+
}
|
|
167
|
+
if (row.type === "bool" && !/^(true|false|1|0)$/i.test(raw)) {
|
|
168
|
+
bad(`must be true, false, 1 or 0 — got ${JSON.stringify(raw)}`, `set ${row.name} to true or false`);
|
|
169
|
+
}
|
|
170
|
+
// The `url` type covers three families and the allowlist has to name all of
|
|
171
|
+
// them: http(s) for base URLs and webhooks, postgres(ql) for DATABASE_URL,
|
|
172
|
+
// and smtp(s) for the mail transport (08 §4.8). Found live: a correct
|
|
173
|
+
// `smtp://host:2525` was refused by a validator that only knew the first
|
|
174
|
+
// two, so the SMTP adapter could not be configured at all — a boot failure
|
|
175
|
+
// for a variable whose entire purpose is to hold an SMTP URL.
|
|
176
|
+
if (row.type === "url" && !/^(https?|postgres(ql)?|smtps?):\/\//.test(raw)) {
|
|
177
|
+
bad(`must be a URL, got ${JSON.stringify(raw)}`, `set ${row.name} to a full URL including the scheme (http://, https://, postgres://, smtp:// or smtps://)`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
export function seal(keys, plaintext) {
|
|
3
|
+
// 12 bytes is GCM's canonical nonce length — the one at which the counter
|
|
4
|
+
// construction needs no extra hashing step.
|
|
5
|
+
const iv = randomBytes(12);
|
|
6
|
+
const c = createCipheriv("aes-256-gcm", keys.derive("credential-enc"), iv);
|
|
7
|
+
const body = Buffer.concat([c.update(plaintext, "utf8"), c.final()]);
|
|
8
|
+
return { ciphertext: Buffer.concat([body, c.getAuthTag()]), iv };
|
|
9
|
+
}
|
|
10
|
+
export function open(keys, sealed) {
|
|
11
|
+
try {
|
|
12
|
+
const tag = sealed.ciphertext.subarray(sealed.ciphertext.length - 16);
|
|
13
|
+
const body = sealed.ciphertext.subarray(0, sealed.ciphertext.length - 16);
|
|
14
|
+
const d = createDecipheriv("aes-256-gcm", keys.derive("credential-enc"), sealed.iv);
|
|
15
|
+
d.setAuthTag(tag);
|
|
16
|
+
return Buffer.concat([d.update(body), d.final()]).toString("utf8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// The overwhelmingly likely cause is a changed `SNAGSET_SECRET_KEY` —
|
|
20
|
+
// which 08 §4.7 warns makes stored credentials "unrecoverable
|
|
21
|
+
// ciphertext". Null rather than throwing, so a boot with a lost key
|
|
22
|
+
// degrades to "storage disconnected" instead of failing to start.
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=crypto.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AA6B5E,MAAM,UAAU,IAAI,CAAC,IAAa,EAAE,SAAiB;IACnD,0EAA0E;IAC1E,4CAA4C;IAC5C,MAAM,EAAE,GAAG,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,MAAM,CAAC,GAAG,cAAc,CAAC,aAAa,EAAE,IAAI,CAAC,MAAM,CAAC,gBAAgB,CAAC,EAAE,EAAE,CAAC,CAAC;IAC3E,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IACrE,OAAO,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC;AACnE,CAAC;AAED,MAAM,UAAU,IAAI,CAAC,IAAa,EAAE,MAAc;IAChD,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;QACtE,MAAM,IAAI,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,GAAG,EAAE,CAAC,CAAC;QAC1E,MAAM,CAAC,GAAG,gBAAgB,CAAC,aAAa,EAAE,IAAI,CAAC,MAAM,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC;QACpF,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QAClB,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IACrE,CAAC;IAAC,MAAM,CAAC;QACP,sEAAsE;QACtE,8DAA8D;QAC9D,oEAAoE;QACpE,kEAAkE;QAClE,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
/**
|
|
3
|
+
* The one place a connection is opened — and the one place `prepare: false`
|
|
4
|
+
* is explained, so it does not get "optimised" back on.
|
|
5
|
+
*
|
|
6
|
+
* 08 §3.2 describes the blessed path as *"marketplace Postgres attached in the
|
|
7
|
+
* click, handing the app a **pooled** `DATABASE_URL`"*. A pooled endpoint is
|
|
8
|
+
* PgBouncer in transaction mode: a connection is handed to one transaction and
|
|
9
|
+
* then to somebody else, so a prepared statement created on it may be executed
|
|
10
|
+
* later against a different backend that never saw the PREPARE.
|
|
11
|
+
*
|
|
12
|
+
* The failure is not a clean error at the point of the mistake. It surfaces
|
|
13
|
+
* as `cached plan must not change result type` on some later query, usually
|
|
14
|
+
* after a migration has changed a table the plan referenced — which reads as
|
|
15
|
+
* database corruption and is nothing of the kind. It was found here by running
|
|
16
|
+
* the share-link suite twice against one pooled Neon endpoint: the second run
|
|
17
|
+
* dropped and recreated the schema, and every query afterwards failed.
|
|
18
|
+
*
|
|
19
|
+
* THE COST IS ONE EXTRA ROUND TRIP PER PARAMETERISED STATEMENT, EVERY TIME —
|
|
20
|
+
* not per unique query shape, which is what this comment used to claim.
|
|
21
|
+
* postgres.js sets `describeFirst` whenever a query has parameters and is not
|
|
22
|
+
* prepared (connection.js: `parameters.length && !q.prepared`), so it sends
|
|
23
|
+
* Describe+Flush, waits, then Bind+Execute+Sync. Measured against London from
|
|
24
|
+
* Seoul, 4 runs each: a parameterised `SELECT $1::int` costs **520 / 521 / 509
|
|
25
|
+
* / 519 ms** unprepared against **539 / 268 / 270 / 264 ms** prepared, on a
|
|
26
|
+
* link whose one-way is ~260 ms. Every statement in a request pays it.
|
|
27
|
+
*
|
|
28
|
+
* It is still the right default, and the reason is deployment rather than
|
|
29
|
+
* performance: 08 §3.2's blessed path hands out a pooled URL, and this is the
|
|
30
|
+
* setting that makes that URL work. But the cost is now written down, because
|
|
31
|
+
* it is the largest single term in the create path's latency and the next
|
|
32
|
+
* person to look at that number should not have to measure it again.
|
|
33
|
+
*
|
|
34
|
+
* (For the record, postgres.js DOES retry both `RevalidateCachedQuery` and
|
|
35
|
+
* `FetchPreparedStatement` — so re-enabling `prepare` is a real option to
|
|
36
|
+
* evaluate, not a settled impossibility. It wants its own test against a
|
|
37
|
+
* schema change on a pooled endpoint, which is the scenario that produced the
|
|
38
|
+
* original failure, and it should not be flipped without one.)
|
|
39
|
+
*/
|
|
40
|
+
export function connect(url, opts = {}) {
|
|
41
|
+
return postgres(url, {
|
|
42
|
+
max: opts.max ?? 10,
|
|
43
|
+
prepare: false,
|
|
44
|
+
// The server has its own logging rules (app.ts); postgres.js NOTICE output
|
|
45
|
+
// would bypass them.
|
|
46
|
+
onnotice: () => { },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=connect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connect.js","sourceRoot":"","sources":["../../src/db/connect.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,UAAU,CAAC;AAEhC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,MAAM,UAAU,OAAO,CAAC,GAAW,EAAE,OAAyB,EAAE;IAC9D,OAAO,QAAQ,CAAC,GAAG,EAAE;QACnB,GAAG,EAAE,IAAI,CAAC,GAAG,IAAI,EAAE;QACnB,OAAO,EAAE,KAAK;QACd,2EAA2E;QAC3E,qBAAqB;QACrB,QAAQ,EAAE,GAAG,EAAE,GAAE,CAAC;KACnB,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import postgres from "postgres";
|
|
6
|
+
/**
|
|
7
|
+
* The migrator — 08 §5.
|
|
8
|
+
*
|
|
9
|
+
* Hand-written, and deliberately not `drizzle-kit migrate`. A9 promises `npx
|
|
10
|
+
* snagset` against one `DATABASE_URL` works, which means migrations run at BOOT
|
|
11
|
+
* on a machine nobody is watching, on a platform that may start several
|
|
12
|
+
* instances of the process at once. That is a concurrency problem before it is
|
|
13
|
+
* a schema problem, and it is the half a migration runner bolted onto a CLI
|
|
14
|
+
* does not solve.
|
|
15
|
+
*
|
|
16
|
+
* The four rules that make an unattended boot safe:
|
|
17
|
+
*
|
|
18
|
+
* 1. **One advisory lock, taken first.** Vercel starts N lambdas; a Railway
|
|
19
|
+
* deploy overlaps old and new containers. Without the lock, two processes
|
|
20
|
+
* run `CREATE TABLE` concurrently and the loser dies on a duplicate — which
|
|
21
|
+
* looks exactly like a corrupt database to whoever reads the logs. With it,
|
|
22
|
+
* the loser waits and then finds there is nothing to do.
|
|
23
|
+
* 2. **Checksums, compared every boot.** A migration file that changed after
|
|
24
|
+
* it was applied is the single most expensive thing to discover late: every
|
|
25
|
+
* environment is now a different schema wearing the same version number.
|
|
26
|
+
* Refused loudly, exit 4, before a single statement runs.
|
|
27
|
+
* 3. **Each file in ONE transaction.** Postgres does transactional DDL, so a
|
|
28
|
+
* migration either happened or did not. The `.no-txn.sql` suffix opts out
|
|
29
|
+
* for the statements that cannot run inside one (`CREATE INDEX
|
|
30
|
+
* CONCURRENTLY`); nothing needs it at 0001, and the mechanism exists so
|
|
31
|
+
* that the first thing that does is not also the first time this code is
|
|
32
|
+
* asked to handle it.
|
|
33
|
+
* 4. **A short `lock_timeout`, per file.** A migration blocked behind a long
|
|
34
|
+
* read holds the advisory lock while every other instance queues behind it.
|
|
35
|
+
* Three seconds, then fail cleanly and let the platform retry — a failed
|
|
36
|
+
* deploy is recoverable, a deploy wedged for an hour is an outage.
|
|
37
|
+
*
|
|
38
|
+
* Exit codes, because a boot script has to be able to tell these apart:
|
|
39
|
+
* 0 up to date (or applied cleanly)
|
|
40
|
+
* 3 could not connect / could not take the lock
|
|
41
|
+
* 4 refused: a checksum changed, or an applied migration is missing
|
|
42
|
+
* 1 a migration failed to apply
|
|
43
|
+
*/
|
|
44
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const DIR = join(HERE, "migrations");
|
|
46
|
+
/**
|
|
47
|
+
* The advisory lock id. A constant, because every instance has to pick the
|
|
48
|
+
* SAME number without coordinating — that is the entire mechanism.
|
|
49
|
+
*
|
|
50
|
+
* 0x63644D47 = 1667517767, the ASCII for "cdMG". Kept rather than re-derived
|
|
51
|
+
* from the new name: the number is meaningless by design, and changing it in a
|
|
52
|
+
* fleet where an old instance still holds the old one is precisely the
|
|
53
|
+
* split-brain the lock exists to prevent. Rename residue elsewhere is a bug;
|
|
54
|
+
* here it is the point.
|
|
55
|
+
*/
|
|
56
|
+
export const MIGRATION_LOCK_ID = 0x63644d47;
|
|
57
|
+
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
|
|
58
|
+
/** Files in lexical order — `0001_…` before `0002_…`, which is why they are numbered. */
|
|
59
|
+
function files() {
|
|
60
|
+
return readdirSync(DIR)
|
|
61
|
+
.filter((f) => f.endsWith(".sql"))
|
|
62
|
+
.sort()
|
|
63
|
+
.map((name) => {
|
|
64
|
+
const sql = readFileSync(join(DIR, name), "utf8");
|
|
65
|
+
return { name, sql, checksum: sha256(sql), txn: !name.endsWith(".no-txn.sql") };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export class MigrationError extends Error {
|
|
69
|
+
exitCode;
|
|
70
|
+
constructor(message, exitCode) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "MigrationError";
|
|
73
|
+
this.exitCode = exitCode;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function migrate(sql, log = console.log) {
|
|
77
|
+
// The ledger itself, first and outside the lock: it is `IF NOT EXISTS`, it is
|
|
78
|
+
// the only DDL that can safely race, and every later step reads it.
|
|
79
|
+
await sql `
|
|
80
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
81
|
+
name text PRIMARY KEY,
|
|
82
|
+
checksum text NOT NULL,
|
|
83
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
84
|
+
duration_ms integer NOT NULL
|
|
85
|
+
)
|
|
86
|
+
`;
|
|
87
|
+
// Rule 1. Session-scoped, released in the `finally` — not `pg_advisory_xact_lock`,
|
|
88
|
+
// because each migration wants its own transaction inside the held lock.
|
|
89
|
+
const lock = await sql `
|
|
90
|
+
SELECT pg_try_advisory_lock(${MIGRATION_LOCK_ID})
|
|
91
|
+
`;
|
|
92
|
+
if (!lock[0]?.pg_try_advisory_lock) {
|
|
93
|
+
// Another instance is mid-migration. Waiting is correct: by the time the
|
|
94
|
+
// lock frees, the work is done and this process finds nothing to apply.
|
|
95
|
+
log("migrate: another instance holds the lock — waiting");
|
|
96
|
+
await sql `SELECT pg_advisory_lock(${MIGRATION_LOCK_ID})`;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const done = new Map((await sql `SELECT name, checksum FROM _migrations`).map((r) => [r.name, r.checksum]));
|
|
100
|
+
const pending = [];
|
|
101
|
+
// Rule 2, and it runs over EVERY file before any of them is applied. A
|
|
102
|
+
// half-migrated database whose next file is the corrupt one is worse than
|
|
103
|
+
// one that refused at the door.
|
|
104
|
+
/*
|
|
105
|
+
* WHEN 0001 STOPS BEING REGENERABLE, and why it still is today.
|
|
106
|
+
*
|
|
107
|
+
* 0001_init.sql is GENERATED from 05 §3 (gen-migration.mjs), so §3 is the
|
|
108
|
+
* single description of the schema and the file is its rendering. That
|
|
109
|
+
* works right up to the moment a second person has applied it, and then it
|
|
110
|
+
* cannot: an applied migration is history, and history that regenerates is
|
|
111
|
+
* a checksum mismatch on somebody else's database.
|
|
112
|
+
*
|
|
113
|
+
* The switch point is M2-35, where schemaVersion freezes for publish
|
|
114
|
+
* (spine §0.2). Before it there is exactly one real install -- the studio's
|
|
115
|
+
* own dogfood -- and correcting §3 plus bringing that one database along by
|
|
116
|
+
* hand is both cheaper and more honest than a numbered ALTER for a schema
|
|
117
|
+
* nobody else has. After it, §3 describes the CURRENT schema, 0001 is
|
|
118
|
+
* frozen at what publish shipped, and every change is a new numbered file.
|
|
119
|
+
* The generator's check has to change shape with it: from "0001 renders §3"
|
|
120
|
+
* to "0001 plus every later file EQUALS §3", which needs a scratch database
|
|
121
|
+
* to answer and is therefore a gate rather than a pure function.
|
|
122
|
+
*
|
|
123
|
+
* The rule below does not soften either way. It is what makes that switch
|
|
124
|
+
* point real rather than aspirational.
|
|
125
|
+
*/
|
|
126
|
+
for (const f of files()) {
|
|
127
|
+
const seen = done.get(f.name);
|
|
128
|
+
if (seen === undefined) {
|
|
129
|
+
pending.push(f);
|
|
130
|
+
}
|
|
131
|
+
else if (seen !== f.checksum) {
|
|
132
|
+
throw new MigrationError(`${f.name} has changed since it was applied (recorded ${seen.slice(0, 12)}…, ` +
|
|
133
|
+
`file is ${f.checksum.slice(0, 12)}…). An applied migration is history: ` +
|
|
134
|
+
`add a new file instead of editing this one.`, 4);
|
|
135
|
+
}
|
|
136
|
+
done.delete(f.name);
|
|
137
|
+
}
|
|
138
|
+
// Whatever is left was applied and no longer exists on disk — a rollback to
|
|
139
|
+
// an older build against a newer database. Refused for the same reason:
|
|
140
|
+
// the code is about to assume a schema that is not the one present.
|
|
141
|
+
if (done.size) {
|
|
142
|
+
throw new MigrationError(`the database has migrations this build does not ship: ${[...done.keys()].join(", ")}. ` +
|
|
143
|
+
`This is an older release running against a newer database.`, 4);
|
|
144
|
+
}
|
|
145
|
+
if (!pending.length) {
|
|
146
|
+
log(`migrate: up to date (${files().length} applied)`);
|
|
147
|
+
return { applied: [], alreadyApplied: files().length };
|
|
148
|
+
}
|
|
149
|
+
const applied = [];
|
|
150
|
+
for (const f of pending) {
|
|
151
|
+
const started = Date.now();
|
|
152
|
+
log(`migrate: applying ${f.name}…`);
|
|
153
|
+
const run = async (tx) => {
|
|
154
|
+
// Rule 4, per file and inside the transaction so it reverts with it.
|
|
155
|
+
await tx `SET LOCAL lock_timeout = '3s'`;
|
|
156
|
+
await tx.unsafe(f.sql);
|
|
157
|
+
await tx `
|
|
158
|
+
INSERT INTO _migrations (name, checksum, duration_ms)
|
|
159
|
+
VALUES (${f.name}, ${f.checksum}, ${Date.now() - started})
|
|
160
|
+
`;
|
|
161
|
+
};
|
|
162
|
+
// Rule 3. `.no-txn.sql` runs on the connection directly — its statements
|
|
163
|
+
// are the ones Postgres refuses inside a transaction block.
|
|
164
|
+
if (f.txn)
|
|
165
|
+
await sql.begin(run);
|
|
166
|
+
else
|
|
167
|
+
await run(sql);
|
|
168
|
+
log(`migrate: applied ${f.name} in ${Date.now() - started} ms`);
|
|
169
|
+
applied.push(f.name);
|
|
170
|
+
}
|
|
171
|
+
return { applied, alreadyApplied: files().length - applied.length };
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await sql `SELECT pg_advisory_unlock(${MIGRATION_LOCK_ID})`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* `pnpm migrate` / `snagset migrate`. The library above takes a connection so a
|
|
179
|
+
* test can hand it one; only this half reads the environment and exits.
|
|
180
|
+
*/
|
|
181
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
182
|
+
const { loadConfig } = await import("../config.js");
|
|
183
|
+
let code = 0;
|
|
184
|
+
let sql;
|
|
185
|
+
try {
|
|
186
|
+
const cfg = loadConfig(process.env);
|
|
187
|
+
sql = (await import("./connect.js")).connect(cfg.required("DATABASE_URL"), { max: 1 });
|
|
188
|
+
await migrate(sql);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
if (e instanceof MigrationError) {
|
|
192
|
+
console.error(`✗ migrate: ${e.message}`);
|
|
193
|
+
code = e.exitCode;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
197
|
+
// A connection failure and a bad migration are different operator
|
|
198
|
+
// problems; 3 means "the database was not reachable", 1 means "it was,
|
|
199
|
+
// and it said no".
|
|
200
|
+
const connectionish = /ECONNREFUSED|ENOTFOUND|ETIMEDOUT|password|SASL|SSL|does not exist/i.test(msg);
|
|
201
|
+
console.error(`✗ migrate: ${msg}`);
|
|
202
|
+
code = connectionish ? 3 : 1;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await sql?.end({ timeout: 5 });
|
|
207
|
+
}
|
|
208
|
+
process.exit(code);
|
|
209
|
+
}
|
|
210
|
+
//# sourceMappingURL=migrate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrate.js","sourceRoot":"","sources":["../../src/db/migrate.ts"],"names":[],"mappings":"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"}
|