From b8be4af77b3e5cd7db262992768a2583c3ef14b3 Mon Sep 17 00:00:00 2001 From: lobbystack <236289573+lobbystack@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:26:55 -0400 Subject: [PATCH] Add startup readiness retries and patch better-auth int8 type matching --- apps/server/src/dependency-patches.test.ts | 23 ++++++ apps/server/src/main.ts | 12 ++- apps/server/src/startup.test.ts | 92 ++++++++++++++++++++++ apps/server/src/startup.ts | 53 +++++++++++++ apps/server/src/store.ts | 9 ++- bun.lock | 17 +++- docs/provenance/better-auth-int8-patch.md | 28 +++++++ package.json | 3 + patches/better-auth@1.7.2.patch | 23 ++++++ 9 files changed, 254 insertions(+), 6 deletions(-) create mode 100644 apps/server/src/dependency-patches.test.ts create mode 100644 apps/server/src/startup.test.ts create mode 100644 apps/server/src/startup.ts create mode 100644 docs/provenance/better-auth-int8-patch.md create mode 100644 patches/better-auth@1.7.2.patch diff --git a/apps/server/src/dependency-patches.test.ts b/apps/server/src/dependency-patches.test.ts new file mode 100644 index 0000000..dcc2770 --- /dev/null +++ b/apps/server/src/dependency-patches.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test'; + +const patchUrl = new URL( + '../../../patches/better-auth@1.7.2.patch', + import.meta.url, +); +const manifestUrl = new URL('../../../package.json', import.meta.url); + +test('the better-auth int8 patch is present and wired into patchedDependencies', async () => { + const patch = await Bun.file(patchUrl).text(); + // The Postgres number allowlist must accept the internal Postgres type names, + // otherwise a bigint column is reported as int8 and warns as a mismatch. + expect(patch).toContain('"int8"'); + expect(patch).toContain('"int2"'); + expect(patch).toContain('"float8"'); + + const manifest = (await Bun.file(manifestUrl).json()) as { + patchedDependencies?: Record; + }; + expect(manifest.patchedDependencies?.['better-auth@1.7.2']).toBe( + 'patches/better-auth@1.7.2.patch', + ); +}); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 8af5173..2f17707 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -3,6 +3,7 @@ import { createAuth } from './auth'; import { createApp } from './app'; import { SyncStore } from './store'; import { BlobService } from './blobs'; +import { retryReady } from './startup'; import { fileURLToPath } from 'node:url'; import { websocket } from 'hono/bun'; @@ -10,7 +11,16 @@ const settings = config(); const store = new SyncStore(settings.databaseUrl); const identity = createAuth(settings); // Migrations are an explicit deployment step, never raced at application startup. -await store.ready(); +// The database may still be waking when this process starts, so wait for the +// schema probe with bounded backoff instead of crashing into a restart loop. +await retryReady(() => store.ready(), { + onRetry: (error, attempt, delayMs) => + console.warn( + `Database not ready (attempt ${attempt}): ${ + error instanceof Error ? error.message : 'unknown error' + }; retrying in ${delayMs}ms`, + ), +}); let s3: Bun.S3Client | undefined; if (process.env.S3_BUCKET) { const accessKeyId = process.env.S3_ACCESS_KEY_ID; diff --git a/apps/server/src/startup.test.ts b/apps/server/src/startup.test.ts new file mode 100644 index 0000000..9f3ba00 --- /dev/null +++ b/apps/server/src/startup.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from 'bun:test'; +import { retryReady } from './startup'; + +test('retries a waking database until the probe succeeds', async () => { + const delays: number[] = []; + let attempts = 0; + await retryReady( + async () => { + attempts += 1; + if (attempts < 3) throw new Error('connect ECONNREFUSED'); + }, + { + attempts: 5, + baseDelayMs: 100, + maxDelayMs: 400, + sleep: async (ms) => { + delays.push(ms); + }, + }, + ); + expect(attempts).toBe(3); + expect(delays).toEqual([100, 200]); +}); + +test('gives up after the attempt budget and surfaces the last error', async () => { + let attempts = 0; + await expect( + retryReady( + async () => { + attempts += 1; + throw new Error(`failure ${attempts}`); + }, + { + attempts: 4, + baseDelayMs: 10, + maxDelayMs: 20, + sleep: async () => {}, + }, + ), + ).rejects.toThrow('failure 4'); + expect(attempts).toBe(4); +}); + +test('caps the delay and reports each retry', async () => { + const delays: number[] = []; + const retries: Array<[number, number]> = []; + await expect( + retryReady( + async () => { + throw new Error('down'); + }, + { + attempts: 4, + baseDelayMs: 100, + maxDelayMs: 250, + sleep: async (ms) => { + delays.push(ms); + }, + onRetry: (_error, attempt, delayMs) => { + retries.push([attempt, delayMs]); + }, + }, + ), + ).rejects.toThrow('down'); + expect(delays).toEqual([100, 200, 250]); + expect(retries).toEqual([ + [1, 100], + [2, 200], + [3, 250], + ]); +}); + +test('a single attempt does not retry', async () => { + let attempts = 0; + let slept = 0; + await expect( + retryReady( + async () => { + attempts += 1; + throw new Error('once'); + }, + { + attempts: 1, + sleep: async () => { + slept += 1; + }, + }, + ), + ).rejects.toThrow('once'); + expect(attempts).toBe(1); + expect(slept).toBe(0); +}); diff --git a/apps/server/src/startup.ts b/apps/server/src/startup.ts new file mode 100644 index 0000000..313e9f2 --- /dev/null +++ b/apps/server/src/startup.ts @@ -0,0 +1,53 @@ +/** + * Startup readiness helpers. + * + * The sync service may start while the managed database is still waking (for + * example after scale-to-zero). A single failed probe must not crash the + * process into a restart loop, so {@link retryReady} waits with bounded + * exponential backoff and only surfaces the final error. + */ + +export interface RetryReadyOptions { + /** Total probe attempts before giving up. Defaults to 60. */ + attempts?: number; + /** Delay before the second attempt, doubled each retry. Defaults to 1000ms. */ + baseDelayMs?: number; + /** Upper bound for a single delay. Defaults to 5000ms. */ + maxDelayMs?: number; + /** Injectable sleep for tests. Defaults to `setTimeout`. */ + sleep?: (ms: number) => Promise; + /** Receives each failed attempt and the delay before the next one. */ + onRetry?: (error: unknown, attempt: number, delayMs: number) => void; +} + +const defaultSleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Run `probe` until it resolves. Retries a failed probe with bounded backoff and + * rejects with the last error once the attempt budget is exhausted. A + * non-positive attempt count is treated as a single attempt. + */ +export async function retryReady( + probe: () => Promise, + options: RetryReadyOptions = {}, +): Promise { + const attempts = Math.max(1, options.attempts ?? 60); + const baseDelayMs = Math.max(0, options.baseDelayMs ?? 1000); + const maxDelayMs = Math.max(0, options.maxDelayMs ?? 5000); + const sleep = options.sleep ?? defaultSleep; + let lastError: unknown; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await probe(); + return; + } catch (error) { + lastError = error; + if (attempt === attempts) break; + const delayMs = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs); + options.onRetry?.(error, attempt, delayMs); + await sleep(delayMs); + } + } + throw lastError; +} diff --git a/apps/server/src/store.ts b/apps/server/src/store.ts index d4f4dbd..3a71ac7 100644 --- a/apps/server/src/store.ts +++ b/apps/server/src/store.ts @@ -32,7 +32,14 @@ export class SyncStore { return this.activeWatches; } constructor(url: string) { - this.db = postgres(url, { max: 10, onnotice: () => {} }); + // `connect_timeout` bounds each TCP attempt (seconds) so a sleeping or + // unreachable database fails fast and the startup retry can move on + // instead of blocking on a long OS-level connect timeout. + this.db = postgres(url, { + max: 10, + onnotice: () => {}, + connect_timeout: 10, + }); } async transaction(run: (tx: Tx) => T | Promise): Promise { // postgres.js 3.4.9 can skip its BEGIN reservation hook at a pipeline diff --git a/bun.lock b/bun.lock index 974b3df..579cd51 100644 --- a/bun.lock +++ b/bun.lock @@ -262,6 +262,9 @@ }, }, }, + "patchedDependencies": { + "better-auth@1.7.2": "patches/better-auth@1.7.2.patch", + }, "packages": { "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], @@ -1551,7 +1554,7 @@ "vaul-svelte": ["vaul-svelte@1.0.0-next.7", "", { "dependencies": { "runed": "^0.23.2", "svelte-toolbelt": "^0.7.1" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-7zN7Bi3dFQixvvbUJY9uGDe7Ws/dGZeBQR2pXdXmzQiakjrxBvWo0QrmsX3HK+VH+SZOltz378cmgmCS9f9rSg=="], - "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], + "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], @@ -1597,7 +1600,7 @@ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], "zod-v3-to-json-schema": ["zod-v3-to-json-schema@4.0.0", "", { "peerDependencies": { "zod": "^3.25 || ^4.0.14" } }, "sha512-KixLrhX/uPmRFnDgsZrzrk4x5SSJA+PmaE5adbfID9+3KPJcdxqRobaHU397EfWBqfQircrjKqvEqZ/mW5QH6w=="], @@ -1607,12 +1610,14 @@ "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg=="], + "@better-auth/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@better-auth/passkey/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "@earendil-works/pi-agent-core/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - "@noura/workspace-schema/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@npmcli/arborist/nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="], "@npmcli/git/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], @@ -1641,6 +1646,8 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], + "better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], @@ -1689,6 +1696,8 @@ "vaul-svelte/svelte-toolbelt": ["svelte-toolbelt@0.7.1", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.23.2", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ=="], + "vite/postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + "@npmcli/arborist/nopt/abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], "@npmcli/git/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="], diff --git a/docs/provenance/better-auth-int8-patch.md b/docs/provenance/better-auth-int8-patch.md new file mode 100644 index 0000000..00ffc58 --- /dev/null +++ b/docs/provenance/better-auth-int8-patch.md @@ -0,0 +1,28 @@ +# Better Auth `int8` type-match patch + +- Repository: https://github.com/better-auth/better-auth +- Package: `better-auth@1.7.2`, pinned in `bun.lock` +- Source file: `dist/db/get-migration.mjs` +- Applicable license: MIT. Better Auth is consumed as a dependency; this is a + minimal patch to a published build artifact, not a copied implementation. +- Destination: `patches/better-auth@1.7.2.patch`, applied by Bun + `patchedDependencies` in the root `package.json`. +- Modifications: `matchType` compares a database column's `dataType` against a + per-driver allowlist of type names. The Postgres `number` allowlist contains + `integer` and `bigint` but not the internal Postgres type names `int2`/`int8` + (nor `float4`/`float8`), so a `bigint` column is reported as `int8` and fails + the match. The patch adds those internal aliases to the Postgres `number` + allowlist. No behavior changes beyond suppressing the false type-mismatch + warning; the declared field is already `bigint: true`. +- Why the patch instead of a config change: `rateLimit.lastRequest` stores + `Date.now()` epoch milliseconds. Better Auth's schema already marks the field + `bigint: true`, so the database column is correctly `int8`; an `integer` + column would overflow. The warning came only from the alias gap above. +- Upstream basis: the get-tables definition at + `@better-auth/core@1.7.2` `src/db/schema/rate-limit.ts` and + `src/db/get-tables.ts`, which declare `lastRequest` as + `{ type: "number", bigint: true }`. +- Notice obligations: none beyond the dependency's MIT terms already tracked for + `better-auth` in the server license report. +- Reviewer: Codex +- Date: 2026-09-16 diff --git a/package.json b/package.json index 36c4722..0233dc2 100644 --- a/package.json +++ b/package.json @@ -34,5 +34,8 @@ "railway": "^3.11.0", "typescript": "^5.9.3", "typescript-eslint": "latest" + }, + "patchedDependencies": { + "better-auth@1.7.2": "patches/better-auth@1.7.2.patch" } } diff --git a/patches/better-auth@1.7.2.patch b/patches/better-auth@1.7.2.patch new file mode 100644 index 0000000..61da5aa --- /dev/null +++ b/patches/better-auth@1.7.2.patch @@ -0,0 +1,23 @@ +diff --git a/dist/db/get-migration.mjs b/dist/db/get-migration.mjs +index b4599dc9a368b9e7ba47e7d8eb6b6607ce4bde3e..45e96d0de8c7cd1747699dc3b86a2122a9c07af2 100644 +--- a/dist/db/get-migration.mjs ++++ b/dist/db/get-migration.mjs +@@ -16,13 +16,17 @@ const map = { + "uuid" + ], + number: [ ++ "int2", + "int4", ++ "int8", + "integer", + "bigint", + "smallint", + "numeric", + "real", +- "double precision" ++ "float4", ++ "double precision", ++ "float8" + ], + boolean: ["bool", "boolean"], + date: [