cloudflare-next-intl 0.8.6 → 0.8.8

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
@@ -74,6 +74,48 @@ export default nextConfig;
74
74
  If this alias is missing, any import from `cloudflare-next-intl` throws an
75
75
  error naming the missing `@intl-config` alias at startup.
76
76
 
77
+ ### 2b. Deploying to Cloudflare Workers: alias it in `wrangler.toml` too
78
+
79
+ `wrangler deploy`/`wrangler dev` bundle the Worker with their own esbuild
80
+ pass, separate from Next's webpack/Turbopack build — the `next.config`
81
+ alias above doesn't apply to it. Without this, `wrangler deploy` fails with
82
+ `Could not resolve "@intl-config"`. Path is relative to `wrangler.toml`.
83
+
84
+ ```toml
85
+ # wrangler.toml
86
+ [alias]
87
+ "@intl-config" = "./src/i18n/intl_config.ts"
88
+ ```
89
+
90
+ Put `[alias]` after all top-level scalar keys (`name`, `main`,
91
+ `compatibility_date`, `compatibility_flags`, etc.) and before any other
92
+ `[table]`/`[[array_of_tables]]` header — TOML assigns every key that follows
93
+ a table header to that table until the next header, so an `[alias]` placed
94
+ earlier silently swallows the keys meant for the top level.
95
+
96
+ **In a monorepo, give every Worker its own alias target — never point two
97
+ Workers' `[alias]` at the same shared config file.** Declaring `[alias]`
98
+ makes Wrangler eagerly resolve and bundle whatever it points to, even if
99
+ nothing in that Worker's own code imports `@intl-config`. If a second,
100
+ smaller Worker (e.g. a cron/background worker) points its alias at the main
101
+ app's config file, it inherits that file's whole import graph — every
102
+ package the main app's config touches (Firebase, a DB client, `zod`, etc.)
103
+ must then be a dependency of the small Worker too, or the build fails with
104
+ `Could not resolve "<package>"` for packages that Worker never actually
105
+ uses. Give it its own minimal file instead:
106
+
107
+ ```typescript
108
+ // backend/src/i18n/intl_config.ts — deliberately minimal, not the main
109
+ // app's config: aliasing it there would drag in every dependency that
110
+ // config touches, even ones this Worker never imports.
111
+ import { setIntlConfig } from "cloudflare-next-intl";
112
+
113
+ export default setIntlConfig({
114
+ locales: ["en"],
115
+ defaultLocale: "en",
116
+ });
117
+ ```
118
+
77
119
  ### 3. Wire up the middleware
78
120
 
79
121
  ```typescript
@@ -535,82 +577,60 @@ so arrays/`numeric`/timestamps/`bytea` decode the same way they do in
535
577
  connection-string mode. Not supported: multi-statement transactions (each
536
578
  call is its own PostgREST round-trip — see above).
537
579
 
538
- #### Supabase mode without `cfni_exec`
580
+ #### Supabase mode and REST translation
539
581
 
540
- If you don't want to install `cfni_exec` at all, set `db.supabase.rawSql:
541
- false`. `withPublicDb`/`withUserDb` then throw immediately in Supabase mode
542
- instead of failing later against PostgREST, and you reach Postgres instead
543
- through a set of helpers that call `@supabase/supabase-js`'s `.from()`/`.rpc()`
544
- API directly — the same REST surface the Supabase client itself uses, no raw
545
- SQL involved:
582
+ You write the exact same Drizzle code with `withPublicDb`/`withUserDb` in Supabase mode as in connection-string mode. Behind the scenes, the transport translates each statement into `@supabase/supabase-js` `.from()` PostgREST calls whenever possible:
546
583
 
547
584
  ```typescript
548
- import { supabaseSelect, supabaseInsert, supabaseUpdate, supabaseDelete, supabaseUpsert, supabaseRpc } from "cloudflare-next-intl/db";
585
+ import { withPublicDb, withUserDb } from "cloudflare-next-intl/db";
586
+ import { eq, gte, desc } from "cloudflare-next-intl/dbHelpers";
587
+
588
+ // Automatically translated to PostgREST REST calls:
589
+ const rows = await withPublicDb((db) =>
590
+ db.select({ id: bonds.id, name: bonds.name, yield: bonds.yield })
591
+ .from(bonds)
592
+ .where(gte(bonds.yield, 5))
593
+ .orderBy(desc(bonds.yield))
594
+ .limit(10)
595
+ );
596
+
597
+ await withPublicDb((db) => db.insert(bonds).values({ name: "10Y", yield: 4.2 }));
598
+ await withPublicDb((db) =>
599
+ db.insert(bonds)
600
+ .values({ id: 1, name: "10Y", yield: 4.5 })
601
+ .onConflictDoUpdate({ target: bonds.id, set: { yield: 4.5 } })
602
+ );
603
+ await withPublicDb((db) => db.update(bonds).set({ yield: 4.3 }).where(eq(bonds.id, 1)));
604
+ await withPublicDb((db) => db.delete(bonds).where(eq(bonds.id, 1)));
605
+ ```
549
606
 
550
- const { rows } = await supabaseSelect("bonds", {
551
- where: { active: true, yield: ["gte", 5] },
552
- orderBy: { column: "yield", ascending: false },
553
- limit: 10,
554
- });
607
+ **Transport routing:**
608
+ 1. **REST translation first:** Single-table `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `on conflict`, and `returning` are mapped to `@supabase/supabase-js` `.from()` calls.
609
+ 2. **`cfni_exec` fallback:** Statements PostgREST cannot express (joins, CTEs, complex subqueries) fall back to the `cfni_exec` SQL-exec function.
610
+ 3. **`rawSql: false` mode:** If `db.supabase.rawSql: false` is configured (when `cfni_exec` is not installed), any statement requiring raw SQL throws an error explaining the limitation and how to resolve it.
611
+
612
+ | Operation | REST API | `cfni_exec` fallback |
613
+ | --- | --- | --- |
614
+ | Single-table `SELECT` (projections, `count(*)`, `WHERE`, `ORDER BY`, `LIMIT`, `OFFSET`) | Supported | Used if untranslatable |
615
+ | Single-table `INSERT` / `UPDATE` / `DELETE` | Supported | Used if untranslatable |
616
+ | `ON CONFLICT DO NOTHING / UPDATE` | Supported | Supported |
617
+ | `RETURNING` clauses | Supported | Supported |
618
+ | Operators: `=`, `<>`, `!=`, `>`, `>=`, `<`, `<=`, `like`, `ilike`, `is [not] null`, `[not] in`, `is [not] distinct from`, `~`, `~*`, `@>`, `<@`, `&&`, `>>`, `<<`, `&>`, `&<`, `-|-`, `@@` | Supported | Supported |
619
+ | Multi-table joins, CTEs, non-count aggregates, `GROUP BY`, `UNION`, `DISTINCT`, raw SQL | Not supported by REST | Supported |
620
+ | Multi-statement transactions | Not supported | Not supported (Postgres connection only) |
621
+
622
+ #### Enforcing single API via ESLint (`cloudflare-next-intl/dbEslint`)
623
+
624
+ To prevent application code from bypassing the single `db` API with direct driver imports (`@supabase/supabase-js`, `pg`, `postgres`, or deep `dist/` paths), spread the shipped flat-config fragment in your `eslint.config.js`:
625
+
626
+ ```typescript
627
+ import dbEslint from "cloudflare-next-intl/dbEslint";
555
628
 
556
- await supabaseInsert("bonds", { name: "10Y", yield: 4.2 });
557
- await supabaseUpsert("bonds", { id: 1, yield: 4.5 }, { onConflict: "id" });
558
- await supabaseUpdate("bonds", { yield: 4.3 }, { where: { id: 1 } });
559
- await supabaseDelete("bonds", { where: { id: 1 } });
560
-
561
- // Anything a plain select/insert/update/delete/upsert can't express (joins,
562
- // aggregates, custom logic) — write a Postgres function, `grant execute` to
563
- // `anon`/`authenticated`, and call it by name. No cfni_exec needed for this
564
- // either, since PostgREST always supports calling a named function you own.
565
- const total = await supabaseRpc("total_yield", { bond_ids: [1, 2, 3] });
566
- ```
567
-
568
- Each has a `*AsUser` counterpart (`supabaseSelectAsUser`,
569
- `supabaseInsertAsUser`, `supabaseUpsertAsUser`, `supabaseUpdateAsUser`,
570
- `supabaseDeleteAsUser`, `supabaseRpcAsUser`) that authenticates as the
571
- signed-in user instead of anon — same token resolution as `withUserDb`
572
- (`db.getAccessToken`, or the signed-in Firebase user's ID token), so RLS
573
- applies the same way.
574
-
575
- `supabaseSelect`/`supabaseSelectAsUser` accept:
576
-
577
- - `columns` — PostgREST column-list syntax. Defaults to `'*'`. Supports an
578
- embedded resource for a foreign-key join in one round-trip, e.g.
579
- `'*, author(name)'`.
580
- - `where` — filters, ANDed together. A bare value (`{ id: 5 }`) is shorthand
581
- for `eq`; use `{ column: [operator, value] }` for any other operator —
582
- `neq`, `gt`, `gte`, `lt`, `lte`, `like`, `likeAllOf`, `likeAnyOf`, `ilike`,
583
- `ilikeAllOf`, `ilikeAnyOf`, `regexMatch`, `regexIMatch`, `is`,
584
- `isDistinct`, `in`, `contains`, `containedBy`, `overlaps`, `rangeGt`,
585
- `rangeGte`, `rangeLt`, `rangeLte`, or `rangeAdjacent` — or
586
- `{ column: ['not', operator, value] }` to negate any of them. This is the
587
- complete operator set `@supabase/supabase-js`'s `.from()` builder exposes
588
- (it *is* a `postgrest-js` builder — there's no separate, larger "Supabase
589
- client" query surface on top of it).
590
- - `match` — shorthand for several `eq` filters at once (`{ status: 'active',
591
- archived: false }`). ANDed with `where`.
592
- - `or` — a raw PostgREST `or()` filter string (e.g.
593
- `'age.gt.18,status.eq.active'`) for filters spanning multiple columns in
594
- one clause; ANDed with `where`/`match`.
595
- - `textSearch` — `{ column, query, type?, config? }` for full-text search via
596
- PostgREST's `@@` operators.
597
- - `orderBy` — one `{ column, ascending?, nullsFirst? }`, or an array for
598
- multiple `order by` clauses.
599
- - `limit` / `range` — row limit, or a `[from, to]` PostgREST pagination range.
600
- - `single` / `maybeSingle` — resolve to one row (erroring if there isn't
601
- exactly one), or one row or `null` (erroring if there's more than one).
602
- - `count` — also return the total matching row count (`'exact'`, `'planned'`,
603
- or `'estimated'`).
604
-
605
- `supabaseUpdate`/`supabaseDelete` (and their `*AsUser` forms) accept `where`/
606
- `match`/`or` the same way, and require at least one of them — an unfiltered
607
- update/delete would otherwise touch every row.
608
-
609
- This mode trades capability for not needing `cfni_exec`: everything the
610
- Supabase JS client itself can express against a single table (including an
611
- embedded-resource join) works; multi-table joins beyond that, aggregates,
612
- window functions, and raw SQL of any kind do not — those need `cfni_exec`, or
613
- a Postgres function called via `supabaseRpc`.
629
+ export default [
630
+ ...dbEslint,
631
+ // your other configs...
632
+ ];
633
+ ```
614
634
 
615
635
  Two query wrappers, both from `cloudflare-next-intl/db`. Choose by who is
616
636
  allowed to see the rows:
@@ -786,6 +806,16 @@ untested:
786
806
  55432:5432 postgres:15`), so it never runs — and never needs a database —
787
807
  during a normal `npm test`.
788
808
 
809
+ ## AI Agent Setup & Conventions
810
+
811
+ When using AI coding assistants (Claude Code, Cursor, Copilot, Antigravity) with `cloudflare-next-intl`:
812
+
813
+ - **One Database API**: Always use `withPublicDb` or `withUserDb` from `cloudflare-next-intl/db`. Never import `@supabase/supabase-js`, `pg`, or `postgres` in application code.
814
+ - **Drizzle Schema & Helpers**: Always import schema definitions from `cloudflare-next-intl/dbSchema` (`pgTable`, `text`, `timestamp`, `uuid`, etc.) and query operators from `cloudflare-next-intl/dbHelpers` (`eq`, `and`, `or`, `inArray`, `count`, etc.).
815
+ - **Lint Enforcement**: Spread `...dbEslint` from `cloudflare-next-intl/dbEslint` in `eslint.config.js` to catch accidental driver imports.
816
+ - **Reference Document**: Direct AI tools to `llms.txt` in this package for concise rules and subpath exports.
817
+
789
818
  ## License
790
819
 
791
820
  MIT
821
+
@@ -27,19 +27,11 @@ async function resolveUserId(uid) {
27
27
  throw new Error('db: withUserDb could not resolve a user id. Pass one explicitly, set ' +
28
28
  '`db.getUserId`, or configure `firebaseAuth` so the signed-in Firebase uid is used.');
29
29
  }
30
- function requireRawSql(supabase) {
31
- if (supabase.rawSql === false) {
32
- throw new Error('db: withPublicDb/withUserDb need `cfni_exec` to run SQL in Supabase mode, but ' +
33
- '`db.supabase.rawSql` is set to `false`. Use `supabaseSelect`/`supabaseInsert`/' +
34
- '`supabaseUpdate`/`supabaseDelete` instead, which call the Supabase REST API directly.');
35
- }
36
- }
37
30
  /**
38
31
  * Builds a Drizzle handle backed by PostgREST. `bearerToken` decides the role
39
32
  * Postgres sees: the anon key for public access, a user JWT for `withUserDb`.
40
33
  */
41
34
  async function supabaseDb(supabase, bearerToken) {
42
- requireRawSql(supabase);
43
35
  const { drizzle } = await import('drizzle-orm/pg-proxy');
44
36
  const db = drizzle(createSupabaseTransport(supabase, bearerToken));
45
37
  return Object.assign(db, {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A flat-config fragment consumers can spread into their `eslint.config.*` to
3
+ * keep application code on the single `db` API.
4
+ *
5
+ * The runtime already refuses to give out a raw client, but an import of
6
+ * `@supabase/supabase-js` or a deep `dist/` path is how that guarantee gets
7
+ * bypassed in practice, so it is worth failing at lint time where the fix is
8
+ * cheap.
9
+ */
10
+ declare const dbEslintConfig: {
11
+ rules: {
12
+ 'no-restricted-imports': (string | {
13
+ paths: {
14
+ name: string;
15
+ message: string;
16
+ }[];
17
+ patterns: string[];
18
+ })[];
19
+ };
20
+ }[];
21
+ export default dbEslintConfig;
@@ -0,0 +1,29 @@
1
+ const MESSAGE = 'Query the database through `withPublicDb`/`withUserDb` from cloudflare-next-intl/db. ' +
2
+ 'The package picks the transport (direct Postgres, cfni_exec, or PostgREST) for you.';
3
+ /**
4
+ * A flat-config fragment consumers can spread into their `eslint.config.*` to
5
+ * keep application code on the single `db` API.
6
+ *
7
+ * The runtime already refuses to give out a raw client, but an import of
8
+ * `@supabase/supabase-js` or a deep `dist/` path is how that guarantee gets
9
+ * bypassed in practice, so it is worth failing at lint time where the fix is
10
+ * cheap.
11
+ */
12
+ const dbEslintConfig = [
13
+ {
14
+ rules: {
15
+ 'no-restricted-imports': [
16
+ 'error',
17
+ {
18
+ paths: [
19
+ { name: '@supabase/supabase-js', message: MESSAGE },
20
+ { name: 'pg', message: MESSAGE },
21
+ { name: 'postgres', message: MESSAGE },
22
+ ],
23
+ patterns: ['cloudflare-next-intl/dist/*'],
24
+ },
25
+ ],
26
+ },
27
+ },
28
+ ];
29
+ export default dbEslintConfig;
@@ -14,12 +14,11 @@
14
14
  * load through dynamic `import()` inside these functions, so an app that
15
15
  * never calls a `db` export never bundles any of them.
16
16
  *
17
- * When `db.supabase.rawSql` is `false` (or `cfni_exec` can't be installed),
18
- * `withPublicDb`/`withUserDb` throw instead of running in Supabase mode; use
19
- * `supabaseSelect`/`supabaseInsert`/`supabaseUpsert`/`supabaseUpdate`/
20
- * `supabaseDelete`/`supabaseRpc` (and their `*AsUser` counterparts) instead
21
- * they call `@supabase/supabase-js`'s `.from()`/`.rpc()` API directly, no
22
- * `cfni_exec`, no raw SQL, only what PostgREST's REST API itself supports.
17
+ * You write the same Drizzle code either way. In Supabase mode each generated
18
+ * statement is first translated into `@supabase/supabase-js` `.from()` calls;
19
+ * anything PostgREST cannot express falls back to `cfni_exec`, and if
20
+ * `db.supabase.rawSql` is `false` the call throws naming the construct that
21
+ * needs raw SQL. `.transaction()` is never available in Supabase mode.
23
22
  *
24
23
  * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
25
24
  * the separate `cloudflare-next-intl/dbHelpers` entry point.
@@ -28,5 +27,3 @@ export { withPublicDb, withUserDb } from './context';
28
27
  export type { DrizzleDb } from './context';
29
28
  export { default as connectToPostgres, disconnectPostgres, resetConnectionState } from './connection';
30
29
  export type { DbRoutingConfig } from '../types/types';
31
- export { supabaseSelect, supabaseSelectAsUser, supabaseInsert, supabaseInsertAsUser, supabaseUpsert, supabaseUpsertAsUser, supabaseUpdate, supabaseUpdateAsUser, supabaseDelete, supabaseDeleteAsUser, supabaseRpc, supabaseRpcAsUser, } from './supabase_rest';
32
- export type { SupabaseWhere, SupabaseFilterOperator, SupabaseFilterValue, SupabaseOrderBy, SupabaseTextSearch, SupabaseSelectOptions, SupabaseMutationOptions, SupabaseUpsertOptions, SupabaseResult, } from './supabase_rest';
@@ -14,16 +14,14 @@
14
14
  * load through dynamic `import()` inside these functions, so an app that
15
15
  * never calls a `db` export never bundles any of them.
16
16
  *
17
- * When `db.supabase.rawSql` is `false` (or `cfni_exec` can't be installed),
18
- * `withPublicDb`/`withUserDb` throw instead of running in Supabase mode; use
19
- * `supabaseSelect`/`supabaseInsert`/`supabaseUpsert`/`supabaseUpdate`/
20
- * `supabaseDelete`/`supabaseRpc` (and their `*AsUser` counterparts) instead
21
- * they call `@supabase/supabase-js`'s `.from()`/`.rpc()` API directly, no
22
- * `cfni_exec`, no raw SQL, only what PostgREST's REST API itself supports.
17
+ * You write the same Drizzle code either way. In Supabase mode each generated
18
+ * statement is first translated into `@supabase/supabase-js` `.from()` calls;
19
+ * anything PostgREST cannot express falls back to `cfni_exec`, and if
20
+ * `db.supabase.rawSql` is `false` the call throws naming the construct that
21
+ * needs raw SQL. `.transaction()` is never available in Supabase mode.
23
22
  *
24
23
  * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
25
24
  * the separate `cloudflare-next-intl/dbHelpers` entry point.
26
25
  */
27
26
  export { withPublicDb, withUserDb } from './context';
28
27
  export { default as connectToPostgres, disconnectPostgres, resetConnectionState } from './connection';
29
- export { supabaseSelect, supabaseSelectAsUser, supabaseInsert, supabaseInsertAsUser, supabaseUpsert, supabaseUpsertAsUser, supabaseUpdate, supabaseUpdateAsUser, supabaseDelete, supabaseDeleteAsUser, supabaseRpc, supabaseRpcAsUser, } from './supabase_rest';
@@ -0,0 +1,76 @@
1
+ import { type SqlValue, type WhereNode } from './parse_where';
2
+ /** One selected/returned column, with its output name when aliased. */
3
+ export interface Projection {
4
+ column: string;
5
+ alias?: string;
6
+ }
7
+ /** One `order by` term. */
8
+ export interface OrderBy {
9
+ column: string;
10
+ ascending: boolean;
11
+ nullsFirst?: boolean;
12
+ }
13
+ /** A single-table `select` reduced to what PostgREST can express. */
14
+ export interface ParsedSelect {
15
+ kind: 'select';
16
+ table: string;
17
+ projection: Projection[] | 'all' | 'count';
18
+ where?: WhereNode;
19
+ orderBy: OrderBy[];
20
+ limit?: SqlValue;
21
+ offset?: SqlValue;
22
+ }
23
+ /** The `excluded.<column>` reference an upsert's `do update set` may use. */
24
+ export interface ExcludedRef {
25
+ kind: 'excluded';
26
+ column: string;
27
+ }
28
+ /** A parsed `on conflict` clause. */
29
+ export type OnConflict = {
30
+ columns: string[];
31
+ action: 'nothing';
32
+ } | {
33
+ columns: string[];
34
+ action: 'update';
35
+ set: Record<string, SqlValue | ExcludedRef>;
36
+ };
37
+ /** A single-table `insert`, optionally an upsert, optionally `returning`. */
38
+ export interface ParsedInsert {
39
+ kind: 'insert';
40
+ table: string;
41
+ columns: string[];
42
+ rows: SqlValue[][];
43
+ onConflict?: OnConflict;
44
+ returning?: Projection[] | 'all';
45
+ }
46
+ /** A single-table `update`. */
47
+ export interface ParsedUpdate {
48
+ kind: 'update';
49
+ table: string;
50
+ set: Record<string, SqlValue>;
51
+ where?: WhereNode;
52
+ returning?: Projection[] | 'all';
53
+ }
54
+ /** A single-table `delete`. */
55
+ export interface ParsedDelete {
56
+ kind: 'delete';
57
+ table: string;
58
+ where?: WhereNode;
59
+ returning?: Projection[] | 'all';
60
+ }
61
+ /** Any statement the REST executor knows how to run. */
62
+ export type ParsedStatement = ParsedSelect | ParsedInsert | ParsedUpdate | ParsedDelete;
63
+ /**
64
+ * Parses a generated statement into the smallest description the REST
65
+ * executor needs, rejecting anything PostgREST's single-table API cannot do.
66
+ *
67
+ * The parser is deliberately strict: a statement it does not fully understand
68
+ * must raise rather than translate approximately, because the transport reads
69
+ * a raise as "send this to `cfni_exec` instead" and a wrong translation would
70
+ * silently return wrong rows.
71
+ *
72
+ * @param sql The generated statement text, `$n` placeholders included.
73
+ * @returns The parsed statement.
74
+ * @throws {UnsupportedSqlError} If the statement is outside the supported subset.
75
+ */
76
+ export default function parseStatement(sql: string): ParsedStatement;