cloudflare-next-intl 0.7.8 → 0.8.0

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 CHANGED
@@ -13,6 +13,10 @@ and Cloudflare environment.
13
13
  - **Tree-shaking**: Properly architected for optimal tree-shaking.
14
14
  - **Error handling**: shared, opt-in `console.error` override and
15
15
  `reportError`/`withErrorHandling` helpers, GDPR-aware (consent-gated).
16
+ - **Database**: optional Postgres/Drizzle data-access layer, reachable either
17
+ directly (Cloudflare Hyperdrive or a connection string) or through the
18
+ Supabase Data API (project URL + anon key only), with request-scoped
19
+ public/user contexts and RLS wiring in both modes.
16
20
 
17
21
  ## Installation
18
22
 
@@ -384,6 +388,143 @@ server-side resolution) on `ErrorHandlingParams` — reporting is skipped
384
388
  whenever `consent` is set and not `true`, since sending error reports to a
385
389
  third party without consent can itself be GDPR-relevant.
386
390
 
391
+ ### Database (`db`)
392
+
393
+ Thin Postgres/Drizzle data-access layer over a Cloudflare Hyperdrive binding
394
+ (or a plain connection string). `pg` and `drizzle-orm` ship as dependencies of
395
+ this package, so there is nothing extra to install. They are loaded through
396
+ dynamic `import()` inside the `db` exports, so an app that never calls a `db`
397
+ export never pulls them into its bundle. Enable it by setting `db` on your
398
+ `RoutingConfig`:
399
+
400
+ ```typescript
401
+ // src/i18n/intl_config.ts
402
+ import { getCloudflareContext } from "@opennextjs/cloudflare";
403
+
404
+ export default setIntlConfig({
405
+ locales: ["en", "uk"] as const,
406
+ defaultLocale: "en",
407
+ generate: { getCloudflareContext },
408
+ db: { hyperdriveBinding: "HYPERDRIVE" },
409
+ });
410
+ ```
411
+
412
+ `db` fields (all optional):
413
+
414
+ - `connectionString` — a Postgres connection string. Omit to resolve it from
415
+ the Hyperdrive binding named by `hyperdriveBinding` instead (the normal
416
+ production setup); a value here always wins over the binding, which is
417
+ what makes local dev / build-time evaluation work.
418
+ - `hyperdriveBinding` — name of the Hyperdrive binding on `env` to read a
419
+ connection string from when `connectionString` is not set. Defaults to
420
+ `'HYPERDRIVE'`. Requires `generate.getCloudflareContext` to be configured.
421
+ - `disconnectAfterRequest` — whether the pooled client is closed once the
422
+ last in-flight `withPublicDb`/`withUserDb` call of the request
423
+ finishes. Defaults to `true` (one connection per request, released to
424
+ Hyperdrive immediately). Set `false` to keep the connection open for the
425
+ lifetime of the isolate — faster for a long-lived server, but it holds a
426
+ Hyperdrive connection slot between requests.
427
+ - `authenticatedRole` — Postgres role assumed inside `withUserDb`'s
428
+ transaction. Defaults to `'authenticated'` (the Supabase RLS convention).
429
+ - `getUserId` — resolves the user id injected as
430
+ `request.jwt.claims->>'sub'` inside `withUserDb`. Omit when
431
+ `firebaseAuth` is configured — the uid then comes from this package's own
432
+ `getAuthUser()` automatically. Provide it to use a different auth source
433
+ (or when `firebaseAuth` is absent).
434
+ - `disconnectTimeoutMs` — milliseconds `disconnectPostgres` waits for
435
+ `client.end()` before giving up. Defaults to `2000`.
436
+
437
+ #### Choosing a transport
438
+
439
+ `db` reaches Postgres one of two ways, decided by which fields you set. The
440
+ query code is identical either way — switching is a config change only.
441
+
442
+ | Config | Transport | Use when |
443
+ |---|---|---|
444
+ | `connectionString` or `hyperdriveBinding` | Direct Postgres via `pg` | You have a Postgres password or a Hyperdrive binding. |
445
+ | `supabase` | Supabase Data API (PostgREST) | You only have `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY`. |
446
+
447
+ A direct connection always wins if both are configured, so adding a `supabase`
448
+ block cannot silently reroute live traffic.
449
+
450
+ ```typescript
451
+ export default setIntlConfig({
452
+ locales: ["en", "uk"] as const,
453
+ defaultLocale: "en",
454
+ // reads NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY
455
+ db: { supabase: {} },
456
+ });
457
+ ```
458
+
459
+ ```typescript
460
+ // unchanged in both modes
461
+ const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
462
+ ```
463
+
464
+ Supabase mode requires one function in your database, shipped at
465
+ `node_modules/cloudflare-next-intl/supabase/cfni_exec.sql`. Run it once (via
466
+ `supabase db push`, a migration, or the SQL editor). It is `security invoker`,
467
+ so statements execute with the caller's own privileges and RLS applies exactly
468
+ as it does over the REST API. `@supabase/supabase-js` ships as a dependency of
469
+ this package and is loaded through dynamic `import()` inside the `db` exports,
470
+ same as `pg`/`drizzle-orm` — an app that never calls a `db` export never
471
+ bundles any of them.
472
+
473
+ `db.supabase` fields (all optional):
474
+
475
+ - `url` — project URL. Defaults to `NEXT_PUBLIC_SUPABASE_URL`.
476
+ - `anonKey` — anon/publishable key. Defaults to `NEXT_PUBLIC_SUPABASE_ANON_KEY`.
477
+ Never put a service-role key here.
478
+ - `execFunction` — name of the exec function. Defaults to `'cfni_exec'`.
479
+
480
+ `db.getAccessToken` resolves the JWT `withUserDb` sends as
481
+ `Authorization: Bearer`, which is what makes PostgREST resolve the caller as
482
+ `authenticated`. Omit it when `firebaseAuth` is configured — the signed-in
483
+ user's Firebase ID token is used automatically.
484
+
485
+ **Two differences to know about in Supabase mode:**
486
+
487
+ - **Per-statement transactions.** Each statement in a `withUserDb` callback is
488
+ its own round-trip, so it is its own implicit transaction. Multi-statement
489
+ atomicity is available in connection-string mode only.
490
+ - **Wider SQL surface.** `cfni_exec` runs statements your app generates, so any
491
+ role that can execute it can run arbitrary SQL *within that role's own
492
+ privileges* — a broader surface than PostgREST's normal verbs, though still
493
+ bounded by RLS and your grants. If your app only uses `withUserDb`, drop the
494
+ anon grant: `revoke execute on function public.cfni_exec(text, jsonb) from anon;`
495
+
496
+ Two query wrappers, both from `cloudflare-next-intl/db`. Choose by who is
497
+ allowed to see the rows:
498
+
499
+ - `withPublicDb(fn)` — runs `fn` as the anonymous role: a pooled connection
500
+ with no transaction/role switch in connection-string mode, or the anon key
501
+ as the PostgREST bearer token in Supabase mode. No user id is attached
502
+ either way, so RLS policies that test `auth.jwt()->>'sub'` will deny access
503
+ — use `withUserDb` for user-owned rows.
504
+ - `withUserDb(fn, uid?)` — runs `fn` as the signed-in user. In
505
+ connection-string mode this opens a transaction where Postgres sees the
506
+ resolved user id as `auth.jwt()->>'sub'` under `db.authenticatedRole`; in
507
+ Supabase mode identity instead rides on the JWT from `getAccessToken`/
508
+ Firebase, sent as `Authorization: Bearer` (see the atomicity caveat above).
509
+ Either way RLS behaves as it does for a PostgREST-issued call. `uid`
510
+ overrides the user id in connection-string mode only; when omitted there,
511
+ the id comes from `db.getUserId()` if set, otherwise automatically from the
512
+ signed-in Firebase user when `firebaseAuth` is configured — you rarely need
513
+ to pass it explicitly.
514
+
515
+ ```typescript
516
+ // anywhere on the server
517
+ import { withPublicDb } from "cloudflare-next-intl/db";
518
+
519
+ const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
520
+ ```
521
+
522
+ `cloudflare-next-intl/dbHelpers` also exports a set of generic Drizzle SQL
523
+ helpers (`excluded`, `onConflictSet`, `ago`, `currentDate`, `windowCount`,
524
+ `unnestLateral`, `ascNullsLast`, `alwaysTrue`, `lateral`, `aliasColumn`,
525
+ `minOf`, `maxOf`, `roundReal`, `multiply`, `scalarFromCte`) for building
526
+ upsert/window/lateral-join queries without dropping to raw SQL.
527
+
387
528
  ## License
388
529
 
389
530
  MIT
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Regenerates Drizzle models by introspecting a live Postgres with drizzle-kit.
3
+ // Usage: cfni-db-codegen [--check] [--ddl-dir=…] [--out-dir=…] [--out-file=…] [--db-url=…] [--drizzle-config=…]
4
+ //
5
+ // Needs a reachable Postgres to introspect — any Postgres, not specifically
6
+ // a Docker one. Set CODEGEN_DATABASE_URL to point at whichever you have:
7
+ // local Supabase (./supabase/scripts/db_start.sh --reset, needs Docker), a
8
+ // native Postgres install, or a remote/staging database. With no env var
9
+ // set, it tries the local Supabase default (127.0.0.1:54322).
10
+ // CODEGEN_CONNECT_TIMEOUT_MS overrides the 5s default reachability-check
11
+ // timeout — raise it for a slow/cold-starting remote or serverless target.
12
+ import { createHash } from 'node:crypto';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { join, relative } from 'node:path';
16
+ import { Client } from 'pg';
17
+ import resolveCodegenPaths from '../dist/src/db/codegen_paths.js';
18
+
19
+ const paths = resolveCodegenPaths(process.argv.slice(2), process.env, process.cwd());
20
+
21
+ async function assertReachable(url) {
22
+ const client = new Client({ connectionString: url, connectionTimeoutMillis: paths.timeoutMs });
23
+ try {
24
+ await client.connect();
25
+ await client.end();
26
+ } catch (error) {
27
+ await client.end().catch(() => { /* already failed to connect */ });
28
+ console.error(`❌ Could not reach Postgres at ${url}\n (${error.message})`);
29
+ console.error("\n drizzle-kit pull needs a live Postgres to introspect — any one works, this script has no Docker dependency of its own. Pick one:");
30
+ console.error(" - Local Supabase (needs Docker running): ./supabase/scripts/db_start.sh --reset");
31
+ console.error(" - A native Postgres you already have: CODEGEN_DATABASE_URL=postgresql://... npm run db:codegen");
32
+ console.error(" - A remote/staging database: CODEGEN_DATABASE_URL=postgresql://... npm run db:codegen");
33
+ console.error(` Slow/cold-starting target? Raise the timeout: CODEGEN_CONNECT_TIMEOUT_MS=15000 npm run db:codegen`);
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ function sqlFiles(dir) {
39
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
40
+ const path = join(dir, entry.name);
41
+ if (entry.isDirectory()) return sqlFiles(path);
42
+ return entry.name.endsWith(".sql") || entry.name.endsWith(".txt") ? [path] : [];
43
+ }).sort();
44
+ }
45
+
46
+ function ddlHash() {
47
+ const hash = createHash("sha256");
48
+ for (const file of sqlFiles(paths.ddlDir)) {
49
+ hash.update(relative(process.cwd(), file));
50
+ hash.update(readFileSync(file));
51
+ }
52
+ return hash.digest("hex");
53
+ }
54
+
55
+ const hash = ddlHash();
56
+
57
+ if (paths.check) {
58
+ const previous = existsSync(paths.manifest) ? JSON.parse(readFileSync(paths.manifest, "utf8")).ddlHash : null;
59
+ if (previous !== hash) {
60
+ console.error(`❌ ${relative(process.cwd(), paths.ddlDir)} changed without regenerating models. Run: npm run db:codegen`);
61
+ process.exit(1);
62
+ }
63
+ console.log(`✅ Drizzle models are in sync with ${relative(process.cwd(), paths.ddlDir)}`);
64
+ process.exit(0);
65
+ }
66
+
67
+ await assertReachable(paths.dbUrl);
68
+
69
+ rmSync(paths.pullDir, { recursive: true, force: true });
70
+ execFileSync('npx', ['drizzle-kit', 'pull', ...(paths.drizzleConfig ? [`--config=${paths.drizzleConfig}`] : [])], { stdio: 'inherit' });
71
+
72
+ mkdirSync(paths.outDir, { recursive: true });
73
+ const pulled = join(paths.pullDir, "schema.ts");
74
+ if (!existsSync(pulled)) {
75
+ console.error(`❌ drizzle-kit pull produced no schema at ${pulled}`);
76
+ process.exit(1);
77
+ }
78
+ // Known drizzle-kit 0.31.10 introspection limitation: a column default that
79
+ // is a call to a user-defined Postgres function (e.g. `default
80
+ // public.current_user_id()`) is emitted as a bare, unimported JS identifier
81
+ // call — e.g. `.default(current_user_id())` — instead of being wrapped as a
82
+ // raw SQL expression (`.default(sql\`public.current_user_id()\`)`) the way
83
+ // other non-literal defaults in the same file correctly are (see `sql\`'1000'\``,
84
+ // `sql\`CURRENT_DATE\`` elsewhere in the pulled output). Confirmed via
85
+ // drizzle-kit's own introspection source (node_modules/drizzle-kit/api.js):
86
+ // the Postgres column-default normalizer only flags a default as an
87
+ // expression (`isDefaultAnExpression`) for numeric columns; for
88
+ // text/varchar/uuid columns a non-quoted, non-numeric default string is
89
+ // returned verbatim with no such flag, so the codegen path that decides
90
+ // whether to wrap in sql`` never sees it. There is no newer drizzle-kit
91
+ // release (0.31.10 is already `latest`) and no pull/config flag that
92
+ // changes this. `now()` and `gen_random_uuid()` don't hit this because
93
+ // drizzle-kit special-cases those into `.defaultNow()` / `.defaultRandom()`;
94
+ // any other bare `identifier()` call default does not get that treatment.
95
+ // This patch detects that specific shape and rewrites it into the correct
96
+ // sql`` form so the generated file is importable without throwing
97
+ // `ReferenceError: <fn> is not defined`.
98
+ function patchBareFunctionCallDefaults(source) {
99
+ return source.replace(
100
+ /\.default\((?!sql`)([a-zA-Z_][a-zA-Z0-9_]*)\(\)\)/g,
101
+ (match, fnName) => `.default(sql\`public.${fnName}()\`)`,
102
+ );
103
+ }
104
+
105
+ const banner = `// GENERATED by cfni-db-codegen from ${relative(process.cwd(), paths.ddlDir)} — do not edit.\n`;
106
+ const pulledSource = patchBareFunctionCallDefaults(readFileSync(pulled, "utf8"));
107
+ writeFileSync(paths.outFile, banner + pulledSource);
108
+ rmSync(paths.pullDir, { recursive: true, force: true });
109
+ writeFileSync(paths.manifest, `${JSON.stringify({ ddlHash: hash }, null, 2)}\n`);
110
+ console.log(`✅ Generated ${relative(process.cwd(), paths.outFile)}`);
@@ -0,0 +1,14 @@
1
+ import type { DbConfig } from './connection';
2
+ /**
3
+ * Resolves the JWT that identifies the caller to Supabase, trying
4
+ * `db.getAccessToken()` first, then the signed-in Firebase user's ID token.
5
+ *
6
+ * PostgREST reads this token to pick the caller's role and populate
7
+ * `request.jwt.claims`, which is what makes RLS behave the same as it does in
8
+ * connection-string mode.
9
+ *
10
+ * @param config Your routing config; `config.db` must be set.
11
+ * @returns The bearer token to send with the request.
12
+ * @throws If `db` is not set, or no token can be resolved.
13
+ */
14
+ export default function resolveAccessToken(config: DbConfig): Promise<string>;
@@ -0,0 +1,30 @@
1
+ import requireDbConfig from './require_config';
2
+ /**
3
+ * Resolves the JWT that identifies the caller to Supabase, trying
4
+ * `db.getAccessToken()` first, then the signed-in Firebase user's ID token.
5
+ *
6
+ * PostgREST reads this token to pick the caller's role and populate
7
+ * `request.jwt.claims`, which is what makes RLS behave the same as it does in
8
+ * connection-string mode.
9
+ *
10
+ * @param config Your routing config; `config.db` must be set.
11
+ * @returns The bearer token to send with the request.
12
+ * @throws If `db` is not set, or no token can be resolved.
13
+ */
14
+ export default async function resolveAccessToken(config) {
15
+ const db = config.db;
16
+ requireDbConfig(db);
17
+ const fromConfig = await db.getAccessToken?.();
18
+ if (fromConfig)
19
+ return fromConfig;
20
+ if (config.firebaseAuth) {
21
+ const { getAuthUser } = await import('../firebase_auth/server/use_auth_user_server');
22
+ const { user } = await getAuthUser();
23
+ const token = await user?.getIdToken(false);
24
+ if (token)
25
+ return token;
26
+ }
27
+ throw new Error('db: withUserDb could not resolve an access token for Supabase. Set ' +
28
+ '`db.getAccessToken`, or configure `firebaseAuth` so the signed-in ' +
29
+ 'user\'s Firebase ID token is used.');
30
+ }
@@ -0,0 +1,13 @@
1
+ export interface CodegenPaths {
2
+ ddlDir: string;
3
+ outDir: string;
4
+ outFile: string;
5
+ pullDir: string;
6
+ manifest: string;
7
+ dbUrl: string;
8
+ check: boolean;
9
+ timeoutMs: number;
10
+ drizzleConfig: string | null;
11
+ }
12
+ /** Resolves every codegen path from flags, then env, then the documented defaults. */
13
+ export default function resolveCodegenPaths(argv: readonly string[], env: Record<string, string | undefined>, cwd: string): CodegenPaths;
@@ -0,0 +1,32 @@
1
+ import { isAbsolute, join, resolve } from 'node:path';
2
+ const DEFAULT_DDL_DIR = 'supabase/data-base';
3
+ const DEFAULT_OUT_DIR = 'src/shared/db/generated';
4
+ const DEFAULT_OUT_FILE = 'schema.ts';
5
+ const DEFAULT_DB_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
6
+ const DEFAULT_TIMEOUT_MS = 5000;
7
+ function flag(argv, name) {
8
+ const prefix = `--${name}=`;
9
+ const hit = argv.find((arg) => arg.startsWith(prefix));
10
+ return hit?.slice(prefix.length);
11
+ }
12
+ function abs(cwd, value) {
13
+ return isAbsolute(value) ? value : resolve(cwd, value);
14
+ }
15
+ /** Resolves every codegen path from flags, then env, then the documented defaults. */
16
+ export default function resolveCodegenPaths(argv, env, cwd) {
17
+ const ddlDir = abs(cwd, flag(argv, 'ddl-dir') ?? env.CFNI_DB_DDL_DIR ?? DEFAULT_DDL_DIR);
18
+ const outDir = abs(cwd, flag(argv, 'out-dir') ?? env.CFNI_DB_OUT_DIR ?? DEFAULT_OUT_DIR);
19
+ const outFileName = flag(argv, 'out-file') ?? env.CFNI_DB_OUT_FILE ?? DEFAULT_OUT_FILE;
20
+ const drizzleConfig = flag(argv, 'drizzle-config') ?? env.CFNI_DB_DRIZZLE_CONFIG ?? null;
21
+ return {
22
+ ddlDir,
23
+ outDir,
24
+ outFile: join(outDir, outFileName),
25
+ pullDir: resolve(outDir, '..', '.drizzle-pull'),
26
+ manifest: join(outDir, 'manifest.json'),
27
+ dbUrl: flag(argv, 'db-url') ?? env.CODEGEN_DATABASE_URL ?? DEFAULT_DB_URL,
28
+ check: argv.includes('--check'),
29
+ timeoutMs: Number(env.CODEGEN_CONNECT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS,
30
+ drizzleConfig: drizzleConfig === null ? null : abs(cwd, drizzleConfig),
31
+ };
32
+ }
@@ -0,0 +1,42 @@
1
+ import type { Client } from 'pg';
2
+ import type { LocalePrefixMode, Locales, RoutingConfig } from '../types/types';
3
+ export type DbConfig = RoutingConfig<Locales, LocalePrefixMode>;
4
+ /**
5
+ * Forgets the cached client and connection string so the next
6
+ * `connectToPostgres` call builds both from scratch. Intended for tests and
7
+ * for after a `db` config change.
8
+ *
9
+ * This drops the reference **without closing** an open connection, so only
10
+ * call it when no query is in flight — otherwise use `disconnectPostgres`,
11
+ * which closes the client properly.
12
+ */
13
+ export declare function resetConnectionState(): void;
14
+ /**
15
+ * Returns the request's shared, already-connected Postgres client, creating it
16
+ * on first use and reusing it for every later caller in the same request.
17
+ *
18
+ * Prefer `withPublicDb`/`withUserDb`, which call this for you and always
19
+ * release the connection. Reach for this directly only when you need the raw
20
+ * `pg` client — and then every call **must** be paired with a
21
+ * `disconnectPostgres` call, or the connection is never released.
22
+ *
23
+ * @param config Your routing config; `config.db` must be set.
24
+ * @returns The connected, shared client.
25
+ * @throws If `db` is not set, or no connection string can be resolved from
26
+ * `db.connectionString` or the Hyperdrive binding.
27
+ */
28
+ export default function connectToPostgres(config: DbConfig): Promise<Client>;
29
+ /**
30
+ * Releases one caller's hold on the shared client, closing it once the last
31
+ * holder of the request is done. Call it exactly once per
32
+ * `connectToPostgres` call.
33
+ *
34
+ * Returns immediately and finishes closing in the background (via
35
+ * `ctx.waitUntil` when a Cloudflare context is available), so it never delays
36
+ * the response. Closing errors are reported through `errorHandling`, not
37
+ * thrown. Does nothing when `db.disconnectAfterRequest` is `false`, which
38
+ * keeps the connection open for the life of the isolate.
39
+ *
40
+ * @param config Your routing config; safe to call when `config.db` is unset.
41
+ */
42
+ export declare function disconnectPostgres(config: DbConfig): void;
@@ -0,0 +1,178 @@
1
+ import reportError from '../error_handling/report_error';
2
+ import requireDbConfig from './require_config';
3
+ const DEFAULT_BINDING = 'HYPERDRIVE';
4
+ const DEFAULT_DISCONNECT_TIMEOUT_MS = 2000;
5
+ let connectionString = null;
6
+ let client = null;
7
+ let connectionPromise = null;
8
+ let connectingPromise = null;
9
+ let disconnectionPromise = null;
10
+ let activeUsers = 0;
11
+ /**
12
+ * Forgets the cached client and connection string so the next
13
+ * `connectToPostgres` call builds both from scratch. Intended for tests and
14
+ * for after a `db` config change.
15
+ *
16
+ * This drops the reference **without closing** an open connection, so only
17
+ * call it when no query is in flight — otherwise use `disconnectPostgres`,
18
+ * which closes the client properly.
19
+ */
20
+ export function resetConnectionState() {
21
+ connectionString = null;
22
+ client = null;
23
+ connectionPromise = null;
24
+ connectingPromise = null;
25
+ disconnectionPromise = null;
26
+ activeUsers = 0;
27
+ }
28
+ /**
29
+ * Resolves the connection string from `db.connectionString` first, then the
30
+ * Cloudflare Hyperdrive binding named by `db.hyperdriveBinding`.
31
+ */
32
+ async function resolveConnectionString(config, db) {
33
+ if (db.connectionString)
34
+ return db.connectionString;
35
+ const getContext = config.generate?.getCloudflareContext;
36
+ if (getContext) {
37
+ const context = await getContext({ async: true });
38
+ const env = context?.env;
39
+ const binding = env?.[db.hyperdriveBinding ?? DEFAULT_BINDING];
40
+ if (binding?.connectionString)
41
+ return binding.connectionString;
42
+ }
43
+ throw new Error('db: could not resolve a Postgres connection string. Set `db.connectionString`, or ' +
44
+ `configure \`generate.getCloudflareContext\` and a \`${db.hyperdriveBinding ?? DEFAULT_BINDING}\` ` +
45
+ 'Hyperdrive binding on your Worker.');
46
+ }
47
+ // A single `pg.Client` is not safe for concurrent queries; Next.js fires many
48
+ // in parallel per request. Serializing every `query` through one promise chain
49
+ // keeps one connection correct instead of paying for a pool on top of
50
+ // Hyperdrive's own pooling.
51
+ function serializeQueries(raw) {
52
+ const originalQuery = raw.query;
53
+ if (typeof originalQuery !== 'function')
54
+ return raw;
55
+ let last = Promise.resolve();
56
+ raw.query = (...args) => {
57
+ const run = () => originalQuery.apply(raw, args);
58
+ const next = last.then(run, run);
59
+ last = next.catch(() => undefined);
60
+ return next;
61
+ };
62
+ return raw;
63
+ }
64
+ /**
65
+ * Returns the request's shared, already-connected Postgres client, creating it
66
+ * on first use and reusing it for every later caller in the same request.
67
+ *
68
+ * Prefer `withPublicDb`/`withUserDb`, which call this for you and always
69
+ * release the connection. Reach for this directly only when you need the raw
70
+ * `pg` client — and then every call **must** be paired with a
71
+ * `disconnectPostgres` call, or the connection is never released.
72
+ *
73
+ * @param config Your routing config; `config.db` must be set.
74
+ * @returns The connected, shared client.
75
+ * @throws If `db` is not set, or no connection string can be resolved from
76
+ * `db.connectionString` or the Hyperdrive binding.
77
+ */
78
+ export default async function connectToPostgres(config) {
79
+ const db = config.db;
80
+ requireDbConfig(db);
81
+ await disconnectionPromise;
82
+ if (connectingPromise === null) {
83
+ connectingPromise = (async () => {
84
+ try {
85
+ connectionString ?? (connectionString = await resolveConnectionString(config, db));
86
+ const { Client } = await import('pg');
87
+ const created = serializeQueries(new Client({ connectionString }));
88
+ client = created;
89
+ connectionPromise = created.connect();
90
+ await connectionPromise;
91
+ return created;
92
+ }
93
+ catch (error) {
94
+ // A failed connect must not be cached forever — clear state so the
95
+ // next call retries instead of replaying the same rejection for the
96
+ // life of the Worker isolate.
97
+ connectingPromise = null;
98
+ connectionString = null;
99
+ client = null;
100
+ connectionPromise = null;
101
+ throw error;
102
+ }
103
+ })();
104
+ }
105
+ activeUsers++;
106
+ return connectingPromise;
107
+ }
108
+ /**
109
+ * Releases one caller's hold on the shared client, closing it once the last
110
+ * holder of the request is done. Call it exactly once per
111
+ * `connectToPostgres` call.
112
+ *
113
+ * Returns immediately and finishes closing in the background (via
114
+ * `ctx.waitUntil` when a Cloudflare context is available), so it never delays
115
+ * the response. Closing errors are reported through `errorHandling`, not
116
+ * thrown. Does nothing when `db.disconnectAfterRequest` is `false`, which
117
+ * keeps the connection open for the life of the isolate.
118
+ *
119
+ * @param config Your routing config; safe to call when `config.db` is unset.
120
+ */
121
+ export function disconnectPostgres(config) {
122
+ const db = config.db;
123
+ if (!db || db.disconnectAfterRequest === false)
124
+ return;
125
+ activeUsers = Math.max(0, activeUsers - 1);
126
+ if (!client || activeUsers !== 0)
127
+ return;
128
+ const closing = client;
129
+ // Null out synchronously so a concurrent request creates a fresh client
130
+ // instead of reusing one that is about to close.
131
+ client = null;
132
+ connectionPromise = null;
133
+ connectingPromise = null;
134
+ const endPromise = closing.end();
135
+ disconnectionPromise = endPromise;
136
+ const timeoutMs = db.disconnectTimeoutMs ?? DEFAULT_DISCONNECT_TIMEOUT_MS;
137
+ const settle = async () => {
138
+ try {
139
+ await Promise.race([
140
+ endPromise,
141
+ new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout closing postgres client')), timeoutMs)),
142
+ ]);
143
+ }
144
+ catch (error) {
145
+ await reportError({ errorHandling: config.errorHandling, generate: config.generate }, { error, classOrMethodName: 'db.disconnectPostgres' });
146
+ }
147
+ finally {
148
+ if (disconnectionPromise === endPromise)
149
+ disconnectionPromise = null;
150
+ }
151
+ };
152
+ const getContext = config.generate?.getCloudflareContext;
153
+ if (!getContext) {
154
+ void settle();
155
+ return;
156
+ }
157
+ void (async () => {
158
+ // No matter what happens resolving the context/waitUntil below,
159
+ // settle() must always run — it's the only place disconnectionPromise
160
+ // gets cleared, and connectToPostgres awaits that promise on every
161
+ // call. A rejected/throwing getContext must still fall through to
162
+ // settle() directly instead of leaving the disconnect stuck forever.
163
+ let waitUntil;
164
+ try {
165
+ const context = await getContext({ async: true });
166
+ if (typeof context?.ctx?.waitUntil === 'function') {
167
+ waitUntil = context.ctx.waitUntil.bind(context.ctx);
168
+ }
169
+ }
170
+ catch {
171
+ waitUntil = undefined;
172
+ }
173
+ if (waitUntil)
174
+ waitUntil(settle());
175
+ else
176
+ await settle();
177
+ })();
178
+ }
@@ -0,0 +1,57 @@
1
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
2
+ /**
3
+ * The Drizzle handle passed to `withPublicDb`/`withUserDb` callbacks. Use it
4
+ * exactly like a normal Drizzle database (`db.select().from(table)`); it is
5
+ * typed without a schema because you pass your own generated tables in.
6
+ */
7
+ export type DrizzleDb = NodePgDatabase<Record<string, never>>;
8
+ /**
9
+ * Runs a query as the **anonymous** role: no transaction, no role switch, no
10
+ * user identity attached. Use this for data any visitor may read.
11
+ *
12
+ * Because no user id is set, RLS policies that test `auth.jwt()->>'sub'` see
13
+ * no user and will deny access — reach for {@link withUserDb} whenever the
14
+ * rows depend on who is asking.
15
+ *
16
+ * In connection-string mode the connection is taken from the request's
17
+ * shared client and released when `fn` settles, even if it throws. In
18
+ * Supabase mode there is no connection to release — each call is one
19
+ * PostgREST round-trip authenticated as the anon key.
20
+ *
21
+ * @param fn Receives the Drizzle handle; return whatever the caller needs.
22
+ * @returns Whatever `fn` resolves to.
23
+ * @throws If `db` is not set on your `RoutingConfig`, or the connection fails.
24
+ *
25
+ * @example
26
+ * const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
27
+ */
28
+ export declare function withPublicDb<T>(fn: (db: DrizzleDb) => Promise<T>): Promise<T>;
29
+ /**
30
+ * Runs a query as the **signed-in user**.
31
+ *
32
+ * In connection-string mode this runs inside a transaction where Postgres
33
+ * sees the resolved user id as `auth.jwt()->>'sub'` under
34
+ * `db.authenticatedRole`, so RLS policies behave exactly as they do for a
35
+ * PostgREST-issued call. In Supabase mode identity instead rides on the JWT
36
+ * sent as `Authorization: Bearer` — PostgREST resolves the `authenticated`
37
+ * role and populates `request.jwt.claims` itself, and each statement is its
38
+ * own round-trip with no cross-statement transaction (the Postgres proxy
39
+ * Drizzle uses in this mode cannot open one). Either way this is the wrapper
40
+ * to use for anything user-owned.
41
+ *
42
+ * @param fn Receives the Drizzle handle. In connection-string mode it is
43
+ * bound to a transaction; in Supabase mode it is not — do not rely on
44
+ * multi-statement atomicity there.
45
+ * @param uid Connection-string mode only: overrides the user id. Omit it in
46
+ * normal use — the id then comes from `db.getUserId()` when set, otherwise
47
+ * from the signed-in Firebase user when `firebaseAuth` is configured.
48
+ * Ignored in Supabase mode, which resolves identity via `db.getAccessToken`/
49
+ * Firebase instead — see {@link resolveAccessToken}.
50
+ * @returns Whatever `fn` resolves to.
51
+ * @throws If `db` is not set on your `RoutingConfig`, if no user id/access
52
+ * token can be resolved, or the connection fails.
53
+ *
54
+ * @example
55
+ * const mine = await withUserDb((db) => db.select().from(orders));
56
+ */
57
+ export declare function withUserDb<T>(fn: (db: DrizzleDb) => Promise<T>, uid?: string): Promise<T>;