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 +141 -0
- package/bin/db_codegen.mjs +110 -0
- package/dist/src/db/access_token.d.ts +14 -0
- package/dist/src/db/access_token.js +30 -0
- package/dist/src/db/codegen_paths.d.ts +13 -0
- package/dist/src/db/codegen_paths.js +32 -0
- package/dist/src/db/connection.d.ts +42 -0
- package/dist/src/db/connection.js +178 -0
- package/dist/src/db/context.d.ts +57 -0
- package/dist/src/db/context.js +125 -0
- package/dist/src/db/helpers.d.ts +57 -0
- package/dist/src/db/helpers.js +112 -0
- package/dist/src/db/index.d.ts +24 -0
- package/dist/src/db/index.js +22 -0
- package/dist/src/db/require_config.d.ts +15 -0
- package/dist/src/db/require_config.js +20 -0
- package/dist/src/db/resolve_mode.d.ts +17 -0
- package/dist/src/db/resolve_mode.js +18 -0
- package/dist/src/db/supabase_config.d.ts +18 -0
- package/dist/src/db/supabase_config.js +22 -0
- package/dist/src/db/supabase_transport.d.ts +29 -0
- package/dist/src/db/supabase_transport.js +49 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/types/types.d.ts +91 -0
- package/llms.txt +35 -0
- package/package.json +22 -5
- package/supabase/cfni_exec.sql +34 -0
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>;
|