Skip to content

Commit 8077e85

Browse files
authored
Fix server type errors after dual-driver; swap postgres.js → node-postgres (#32)
Background: the dual-driver commit (#29) typed the exported `db` as `NeonHttpDatabase | PostgresJsDatabase`, which degraded every `.returning()` / `.execute().rows` call site via builder-chain signature intersection — 60+ ghost type errors across the modules. Fixes: - db.ts: narrow `db` to `NeonHttpDatabase<typeof schema>` (the production driver and the shape the codebase was written against). Type-level also enforces the no-transactions rule documented in apps/server/CLAUDE.md. - db.ts: swap the local-Postgres branch from `postgres.js` to `pg` via `drizzle-orm/node-postgres`. `pg`'s `{ rows, fields, rowCount }` result matches `NeonHttpQueryResult` natively, dropping the `.rows` shim, timestamp OID serializers, and `prepare: false` workarounds. Pin UTC at connection startup via libpq `options: "-c TimeZone=UTC"`. - env-bindings.d.ts: augment `Cloudflare.Env` with the 8 secrets wrangler can't infer (DATABASE_URL, BETTER_AUTH_*, UPSTASH_*, TINYBIRD_*). - env.ts + require-client-credential.ts: add `ClientCredentialContext` and set `c.var.clientCredential` in the middleware, previously referenced but not wired. - middleware/session.ts: normalize `activeOrganizationId: undefined → null` to match `InferredSession` (Better Auth types it as optional). - task/client-routes.ts: pass `credential.publishableKey` to `verifyRequest` — previously passed the whole object, which didn't match the `(publishableKey: string, ...)` signature. - item/validators.ts: `UseItemResponseSchema.costItems`/`rewardItems` take the polymorphic `RewardEntry` shape, matching what `PullResult` actually returns since the currency refactor (#30). - middleware/request-log.ts: guard `c.executionCtx` — it's workerd-only and throws under vitest/Node, which was silently breaking every route-layer test since the Tinybird commit (#31). ESLint cleanup: - Disable `no-unused-vars` (both variants) in the shared base + re-apply in react-internal.js (tseslint preset re-enables it after base). - Disable `react/prop-types` — TypeScript handles prop validation. - Give `*.config.{js,mjs,cjs,ts}` the Node globals so `process` isn't `no-undef` in tinybird.config.mjs. - Declare TINYBIRD_* in turbo.json globalEnv. - `eslint --fix` picked up 4 prefer-const and 3 stale eslint-disable directives across services and tests. Verified: `pnpm --filter=server check-types` / `lint` / `test` all green (618/618 tests, up from 591 before the request-log guard).
1 parent 605beee commit 8077e85

21 files changed

Lines changed: 257 additions & 168 deletions

apps/server/eslint.config.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import { config } from "@repo/eslint-config/base";
33
/** @type {import("eslint").Linter.Config[]} */
44
export default [
55
...config,
6+
{
7+
// Tool config files run in Node at build time (tinybird, drizzle-kit,
8+
// vitest, etc.), not inside the Worker bundle — give them the Node
9+
// globals so `process.env` etc don't trip `no-undef`.
10+
files: ["*.config.{js,mjs,cjs,ts}"],
11+
languageOptions: {
12+
globals: { process: "readonly" },
13+
},
14+
},
615
{
716
ignores: [
817
"worker-configuration.d.ts",

apps/server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@
3232
"drizzle-orm": "^0.45.2",
3333
"filtrex": "^3.1.0",
3434
"hono": "^4.12.12",
35-
"postgres": "^3.4.9",
35+
"pg": "^8.20.0",
3636
"zod": "^4.3.6"
3737
},
3838
"devDependencies": {
3939
"@better-auth/cli": "^1.4.21",
4040
"@repo/eslint-config": "workspace:^",
4141
"@repo/typescript-config": "workspace:^",
4242
"@types/node": "^22.15.3",
43+
"@types/pg": "^8.20.0",
4344
"dotenv": "^17.4.1",
4445
"dotenv-cli": "^11.0.0",
4546
"drizzle-kit": "^0.31.10",

apps/server/src/db.ts

Lines changed: 38 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { env } from "cloudflare:workers";
22
import { neon } from "@neondatabase/serverless";
33
import { upstashCache } from "drizzle-orm/cache/upstash";
4-
import { drizzle as drizzleNeon } from "drizzle-orm/neon-http";
4+
import { drizzle as drizzleNeon, type NeonHttpDatabase } from "drizzle-orm/neon-http";
55

66
import * as schema from "./schema";
77

@@ -17,53 +17,44 @@ const cache =
1717
})
1818
: undefined;
1919

20-
export const db = isNeon
20+
// `db` is typed as `NeonHttpDatabase` — the production driver, and the
21+
// shape the entire codebase was written against (`.execute(...).rows`,
22+
// `.returning(...)`, no multi-statement transactions). Typing this as the
23+
// true `NeonHttp | NodePg` union would degrade every `.returning()` and
24+
// `.execute().rows` callsite via builder-chain signature intersection, so
25+
// we cast the local-Postgres branch to the neon-http type.
26+
//
27+
// The local branch uses `drizzle-orm/node-postgres` (not `postgres.js`)
28+
// because `pg`'s wire result is already `{ rows, fields, rowCount }` —
29+
// the same shape as `NeonHttpQueryResult`. `postgres.js` would need a
30+
// `.rows` shim over `RowList`, prepared-statement Bind tweaks, and
31+
// per-OID timestamp serializers to match.
32+
//
33+
// Narrowing to `NeonHttpDatabase` also enforces the codebase's
34+
// no-transaction rule at compile time — neon-http rejects
35+
// `db.transaction(cb)` at runtime, and all writes go through single
36+
// atomic `INSERT ... ON CONFLICT DO UPDATE WHERE ... RETURNING`
37+
// statements (see apps/server/CLAUDE.md → "`neon-http` has no
38+
// transactions", and modules/check-in/service.ts for the canonical
39+
// pattern).
40+
export const db: NeonHttpDatabase<typeof schema> = isNeon
2141
? drizzleNeon({ client: neon(url), schema, cache })
2242
: await (async () => {
23-
const { drizzle } = await import("drizzle-orm/postgres-js");
24-
const { default: postgres } = await import("postgres");
25-
const client = postgres(url, {
26-
// prepare: false → avoid the prepared-statement Bind codec that
27-
// chokes on Date → timestamptz. With prepare:false Drizzle sends
28-
// ISO strings that Postgres parses natively.
29-
prepare: false,
30-
// Pin the session to UTC so `timestamp`-without-tz columns behave
31-
// the same on local PG as on Neon (Neon's server runs UTC by
32-
// default, but a local Postgres.app on +08 would otherwise store
33-
// NOW() as local time and break any `col >= ${jsDate}` filter —
34-
// the ::timestamp cast of an ISO-with-Z string *strips* the Z
35-
// instead of converting.
36-
connection: { TimeZone: "UTC" },
43+
const { drizzle } = await import("drizzle-orm/node-postgres");
44+
const { default: pg } = await import("pg");
45+
// Pin every new physical connection to UTC so `timestamp`-without-tz
46+
// columns behave the same as on Neon (which defaults to UTC server-side).
47+
// Without this, a local Postgres on +08 stores NOW() as local time and
48+
// `col >= ${jsDate}` filters drift by the TZ offset.
49+
//
50+
// `options: "-c TimeZone=UTC"` passes the setting as a libpq startup
51+
// parameter — set before any query runs, avoiding the pg deprecation
52+
// warning we'd hit by firing a `SET TIME ZONE` on the `connect` event
53+
// (that path calls `client.query()` while the client is still mid-startup).
54+
const pool = new pg.Pool({
55+
connectionString: url,
56+
options: "-c TimeZone=UTC",
3757
});
38-
const drz = drizzle({ client, schema, cache });
39-
// Drizzle's postgres-js driver installs a `val => val` serializer
40-
// for all timestamp OIDs (see drizzle-orm/postgres-js/driver.js)
41-
// so it can format Dates itself. That works for `gte(col, date)`
42-
// where Drizzle pre-stringifies, but breaks `sql\`${col} <= ${date}\``
43-
// raw templates — the Date slips through to postgres-js's `b.str()`
44-
// and throws `ERR_INVALID_ARG_TYPE`. Reinstall a Date→ISO fallback.
45-
const toIso = (v: unknown) =>
46-
v instanceof Date ? v.toISOString() : v;
47-
for (const oid of ["1184", "1114", "1082", "1083", "1182", "1185", "1115", "1231"]) {
48-
(client as unknown as { options: { serializers: Record<string, (v: unknown) => unknown> } })
49-
.options.serializers[oid] = toIso;
50-
}
51-
// Shape compat: `db.execute(sql\`...\`)` returns `{ rows }` on neon-http
52-
// and a bare array on postgres-js. The codebase reads `.rows.length`
53-
// (e.g. friend-gift, check-in's ON CONFLICT ... RETURNING). Attach a
54-
// non-enumerable `rows` pointing back to the result array so both
55-
// drivers quack the same.
56-
const origExecute = drz.execute.bind(drz);
57-
drz.execute = (async (query: Parameters<typeof origExecute>[0]) => {
58-
const result = await origExecute(query);
59-
if (Array.isArray(result) && !("rows" in result)) {
60-
Object.defineProperty(result, "rows", {
61-
value: result,
62-
enumerable: false,
63-
configurable: true,
64-
});
65-
}
66-
return result;
67-
}) as typeof drz.execute;
68-
return drz;
58+
const drz = drizzle({ client: pool, schema, cache });
59+
return drz as unknown as NeonHttpDatabase<typeof schema>;
6960
})();

apps/server/src/env-bindings.d.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Augments `Cloudflare.Env` with worker secrets that `wrangler types`
3+
* can't infer from `wrangler.jsonc`. Keep in sync with the secret list
4+
* documented in `wrangler.jsonc` and with `testing/cloudflare-workers-shim.ts`.
5+
*/
6+
declare namespace Cloudflare {
7+
interface Env {
8+
DATABASE_URL: string;
9+
BETTER_AUTH_SECRET: string;
10+
BETTER_AUTH_URL: string;
11+
UPSTASH_REDIS_REST_URL: string;
12+
UPSTASH_REDIS_REST_TOKEN: string;
13+
TINYBIRD_TOKEN: string;
14+
TINYBIRD_URL: string;
15+
TINYBIRD_WORKSPACE_ID: string;
16+
}
17+
}

apps/server/src/env.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,27 @@ type InferredUser = typeof auth.$Infer.Session.user;
3030

3131
export type AuthMethod = "session" | "admin-api-key" | "client-credential";
3232

33+
/**
34+
* Client-credential context placed by `require-client-credential` middleware.
35+
* Keep this field-for-field compatible with the row selection the middleware
36+
* does — adding a column here requires adding it to the `.select()` shape
37+
* too, otherwise downstream routes reading `c.var.clientCredential.<field>`
38+
* will type-check but be `undefined` at runtime.
39+
*/
40+
export type ClientCredentialContext = {
41+
id: string;
42+
organizationId: string;
43+
publishableKey: string;
44+
enabled: boolean;
45+
expiresAt: Date | null;
46+
devMode: boolean;
47+
};
48+
3349
export type HonoEnv = {
3450
Variables: RequestIdVariables & {
3551
user: InferredUser | null;
3652
session: InferredSession | null;
3753
authMethod: AuthMethod | null;
54+
clientCredential: ClientCredentialContext | null;
3855
};
3956
};

apps/server/src/lib/storage/s3-compatible.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async function bodyToBuffer(
6565
const reader = body.getReader();
6666
const chunks: Uint8Array[] = [];
6767
let total = 0;
68-
// eslint-disable-next-line no-constant-condition
68+
6969
while (true) {
7070
const { done, value } = await reader.read();
7171
if (done) break;

apps/server/src/middleware/request-log.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,18 @@ export const requestLog = createMiddleware<HonoEnv>(async (c, next) => {
3838

3939
const path = new URL(c.req.url).pathname;
4040

41-
c.executionCtx.waitUntil(
41+
// `c.executionCtx` is a workerd-only API — it THROWS under vitest/Node
42+
// (where `app.request(...)` doesn't inject an ExecutionContext). Skip
43+
// analytics ingest in that case; tests don't need Tinybird and we don't
44+
// want to spam the real dataset from test runs anyway.
45+
let ec: ExecutionContext;
46+
try {
47+
ec = c.executionCtx;
48+
} catch {
49+
return;
50+
}
51+
52+
ec.waitUntil(
4253
deps.analytics.writer.logHttp({
4354
ts: new Date(start),
4455
orgId,

apps/server/src/middleware/require-client-credential.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const requireClientCredential = createMiddleware<HonoEnv>(
3737
.select({
3838
id: clientCredentials.id,
3939
organizationId: clientCredentials.organizationId,
40+
publishableKey: clientCredentials.publishableKey,
4041
enabled: clientCredentials.enabled,
4142
expiresAt: clientCredentials.expiresAt,
4243
devMode: clientCredentials.devMode,
@@ -69,6 +70,7 @@ export const requireClientCredential = createMiddleware<HonoEnv>(
6970
} as NonNullable<typeof c.var.session>);
7071
c.set("user", null);
7172
c.set("authMethod", "client-credential");
73+
c.set("clientCredential", cred);
7274

7375
return next();
7476
},

apps/server/src/middleware/session.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,21 @@ import type { HonoEnv } from "../env";
66
export const session = createMiddleware<HonoEnv>(async (c, next) => {
77
const data = await auth.api.getSession({ headers: c.req.raw.headers });
88
c.set("user", data?.user ?? null);
9-
c.set("session", data?.session ?? null);
9+
// Better Auth types `activeOrganizationId` as optional (`string | null |
10+
// undefined`) because the organization plugin doesn't teach the session
11+
// inferrer about the column it adds. At runtime the `session.create.before`
12+
// hook in `src/auth.ts` always populates it, so normalize `undefined → null`
13+
// here to match the `InferredSession` shape declared in `env.ts`.
14+
const rawSession = data?.session ?? null;
15+
c.set(
16+
"session",
17+
rawSession
18+
? {
19+
...rawSession,
20+
activeOrganizationId: rawSession.activeOrganizationId ?? null,
21+
}
22+
: null,
23+
);
1024
c.set("authMethod", data?.user ? "session" : null);
1125
await next();
1226
});

apps/server/src/modules/collection/service.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { collectionMilestones } from "../../schema/collection";
2525
import { createTestOrg, deleteTestOrg } from "../../testing/fixtures";
2626
import { createItemService } from "../item/service";
2727
import type { MailService } from "../mail/service";
28-
import type { ItemEntry } from "../item/types";
28+
import type { RewardEntry } from "../../lib/rewards";
2929
import { createCollectionService } from "./service";
3030

3131
type CapturedMail = {
@@ -34,7 +34,7 @@ type CapturedMail = {
3434
input: {
3535
title: string;
3636
content: string;
37-
rewards: ItemEntry[];
37+
rewards: RewardEntry[];
3838
originSource: string;
3939
originSourceId: string;
4040
requireRead?: boolean;

0 commit comments

Comments
 (0)