cloudflare-next-intl 0.8.5 → 0.8.7

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.
Files changed (48) hide show
  1. package/README.md +215 -12
  2. package/bin/db_codegen.mjs +44 -1
  3. package/bin/db_install_exec.mjs +16 -0
  4. package/bin/install_exec_step.mjs +39 -0
  5. package/dist/src/db/codegen_paths.d.ts +6 -0
  6. package/dist/src/db/codegen_paths.js +14 -1
  7. package/dist/src/db/context.js +11 -1
  8. package/dist/src/db/encode_param.d.ts +10 -0
  9. package/dist/src/db/encode_param.js +60 -0
  10. package/dist/src/db/eslint_config.d.ts +21 -0
  11. package/dist/src/db/eslint_config.js +29 -0
  12. package/dist/src/db/helpers.d.ts +2 -2
  13. package/dist/src/db/helpers.js +2 -2
  14. package/dist/src/db/index.d.ts +6 -0
  15. package/dist/src/db/index.js +6 -0
  16. package/dist/src/db/inline_params.d.ts +18 -0
  17. package/dist/src/db/inline_params.js +118 -0
  18. package/dist/src/db/install_exec.d.ts +24 -0
  19. package/dist/src/db/install_exec.js +42 -0
  20. package/dist/src/db/parse_composite.d.ts +17 -0
  21. package/dist/src/db/parse_composite.js +60 -0
  22. package/dist/src/db/parse_statement.d.ts +76 -0
  23. package/dist/src/db/parse_statement.js +388 -0
  24. package/dist/src/db/parse_where.d.ts +59 -0
  25. package/dist/src/db/parse_where.js +235 -0
  26. package/dist/src/db/resolve_raw_sql.d.ts +24 -0
  27. package/dist/src/db/resolve_raw_sql.js +55 -0
  28. package/dist/src/db/rest_client.d.ts +58 -0
  29. package/dist/src/db/rest_client.js +26 -0
  30. package/dist/src/db/rest_execute.d.ts +25 -0
  31. package/dist/src/db/rest_execute.js +113 -0
  32. package/dist/src/db/rest_filters.d.ts +59 -0
  33. package/dist/src/db/rest_filters.js +130 -0
  34. package/dist/src/db/schema.d.ts +9 -0
  35. package/dist/src/db/schema.js +9 -0
  36. package/dist/src/db/sql_tokens.d.ts +33 -0
  37. package/dist/src/db/sql_tokens.js +96 -0
  38. package/dist/src/db/supabase_rest.d.ts +178 -0
  39. package/dist/src/db/supabase_rest.js +262 -0
  40. package/dist/src/db/supabase_transport.d.ts +6 -3
  41. package/dist/src/db/supabase_transport.js +63 -17
  42. package/dist/src/db/unsupported_sql.d.ts +16 -0
  43. package/dist/src/db/unsupported_sql.js +18 -0
  44. package/dist/src/types/types.d.ts +9 -0
  45. package/llms.txt +26 -3
  46. package/package.json +282 -273
  47. package/supabase/cfni_exec.sql +131 -16
  48. package/supabase/tests/cfni_exec.sql +150 -0
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
@@ -464,12 +506,15 @@ const rows = await withPublicDb((db) => db.select().from(bonds).limit(10));
464
506
 
465
507
  Supabase mode requires one function in your database, shipped at
466
508
  `node_modules/cloudflare-next-intl/supabase/cfni_exec.sql`. Run it once (via
467
- `supabase db push`, a migration, or the SQL editor). It is `security invoker`,
468
- so statements execute with the caller's own privileges and RLS applies exactly
469
- as it does over the REST API. `@supabase/supabase-js` ships as a dependency of
470
- this package and is loaded through dynamic `import()` inside the `db` exports,
471
- same as `pg`/`drizzle-orm`an app that never calls a `db` export never
472
- bundles any of them.
509
+ `supabase db push`, a migration, or the SQL editor) the file starts with a
510
+ `drop function if exists` so re-running it to upgrade is always safe. It is
511
+ `security invoker`, so statements execute with the caller's own privileges and
512
+ RLS applies exactly as it does over the REST API; **never** change this to
513
+ `security definer` — that would let any caller (anon included) bypass RLS
514
+ through the function, regardless of the role PostgREST resolved them as.
515
+ `@supabase/supabase-js` ships as a dependency of this package and is loaded
516
+ through dynamic `import()` inside the `db` exports, same as `pg`/`drizzle-orm`
517
+ — an app that never calls a `db` export never bundles any of them.
473
518
 
474
519
  `db.supabase` fields (all optional):
475
520
 
@@ -479,6 +524,10 @@ bundles any of them.
479
524
  Defaults to `NEXT_PUBLIC_SUPABASE_ANON_KEY`. Never put a service-role key
480
525
  here.
481
526
  - `execFunction` — name of the exec function. Defaults to `'cfni_exec'`.
527
+ - `rawSql` — whether `withPublicDb`/`withUserDb` may run arbitrary SQL
528
+ through `cfni_exec`. Defaults to `true`. Set `false` if you don't want to
529
+ (or can't) install `cfni_exec` — see
530
+ [Supabase mode without `cfni_exec`](#supabase-mode-without-cfni_exec) below.
482
531
 
483
532
  Any of `connectionString`, `supabase.url`, and `supabase.anonKey` may be a
484
533
  function instead of a string, resolved (and awaited) at use time rather than
@@ -516,7 +565,72 @@ user's Firebase ID token is used automatically.
516
565
  role that can execute it can run arbitrary SQL *within that role's own
517
566
  privileges* — a broader surface than PostgREST's normal verbs, though still
518
567
  bounded by RLS and your grants. If your app only uses `withUserDb`, drop the
519
- anon grant: `revoke execute on function public.cfni_exec(text, jsonb) from anon;`
568
+ anon grant: `revoke execute on function public.cfni_exec(text) from anon;`
569
+
570
+ **What `cfni_exec` actually supports:** plain `SELECT` (including joins with
571
+ duplicate column names, CTEs, window functions), `INSERT`/`UPDATE`/`DELETE`
572
+ with or without `RETURNING` (including `ON CONFLICT ... DO UPDATE` via
573
+ `excluded`/`onConflictSet` from `cloudflare-next-intl/dbHelpers`), and
574
+ writable CTEs (`with x as (update ... returning ...) select ... from x`).
575
+ Every value round-trips through Postgres' own text representation, not JSON,
576
+ so arrays/`numeric`/timestamps/`bytea` decode the same way they do in
577
+ connection-string mode. Not supported: multi-statement transactions (each
578
+ call is its own PostgREST round-trip — see above).
579
+
580
+ #### Supabase mode and REST translation
581
+
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:
583
+
584
+ ```typescript
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
+ ```
606
+
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";
628
+
629
+ export default [
630
+ ...dbEslint,
631
+ // your other configs...
632
+ ];
633
+ ```
520
634
 
521
635
  Two query wrappers, both from `cloudflare-next-intl/db`. Choose by who is
522
636
  allowed to see the rows:
@@ -551,12 +665,22 @@ helpers (`excluded`, `onConflictSet`, `ago`, `currentDate`, `windowCount`,
551
665
  upsert/window/lateral-join queries without dropping to raw SQL, plus a
552
666
  re-export of `drizzle-orm`'s common query-building primitives (`eq`, `and`,
553
667
  `or`, `asc`, `desc`, `gte`, `gt`, `lte`, `lt`, `isNull`, `isNotNull`, `count`,
554
- `sum`, `max`, `min`, `sql`) so code that only builds queries against the
668
+ `sum`, `max`, `min`, `sql`, `inArray`, `notInArray`, `ne`, `like`, `ilike`,
669
+ `between`, `not`, `exists`) — so code that only builds queries against the
555
670
  `DrizzleDb` handle from `withPublicDb`/`withUserDb` doesn't need its own
556
- `drizzle-orm` import for these. Anything not listed here (schema definitions,
557
- `drizzle-orm/pg-core` column builders, relations) still comes from
558
- `drizzle-orm` directly — this package re-exports the query-operator surface
559
- only, not the whole library.
671
+ `drizzle-orm` import for these.
672
+
673
+ For schema definitions, `cloudflare-next-intl/dbSchema` re-exports the
674
+ `drizzle-orm/pg-core` table and column builders (`pgTable`, `varchar`,
675
+ `integer`, `index`, `pgEnum`, …) alongside the `sql` tag, so generated schema
676
+ files can import from this package too:
677
+
678
+ ```ts
679
+ import { pgTable, varchar, integer } from 'cloudflare-next-intl/dbSchema';
680
+ ```
681
+
682
+ Relations and any other advanced Drizzle surface still come from `drizzle-orm`
683
+ directly.
560
684
 
561
685
  #### Schema codegen (`cfni-db-codegen`)
562
686
 
@@ -578,6 +702,10 @@ npx cfni-db-codegen --check
578
702
  | `--out-file=` | `CFNI_DB_OUT_FILE` | `schema.ts` |
579
703
  | `--db-url=` | `CODEGEN_DATABASE_URL` | `postgresql://postgres:postgres@127.0.0.1:54322/postgres` |
580
704
  | `--drizzle-config=` | `CFNI_DB_DRIZZLE_CONFIG` | none |
705
+ | `--rpc-dir=` | `CFNI_DB_RPC_DIR` | sibling of `--ddl-dir`, e.g. `supabase/rpc` |
706
+ | `--tests-dir=` | `CFNI_DB_TESTS_DIR` | sibling of `--ddl-dir`, e.g. `supabase/tests` |
707
+ | `--force` | `CFNI_DB_FORCE_EXEC=true` | off |
708
+ | `--skip-exec` | `CFNI_DB_SKIP_EXEC=true` | off |
581
709
  | `--check` | — | off |
582
710
 
583
711
  `--out-dir` may be repeated, or given a comma-separated list, to generate the
@@ -590,6 +718,42 @@ them and fails naming the first one that is stale.
590
718
  npx cfni-db-codegen --out-dir=src/shared/db/generated --out-dir=../other-app/src/db/generated
591
719
  ```
592
720
 
721
+ ##### Keeping `cfni_exec.sql` in sync (`--rpc-dir`/`--tests-dir`/`--force`/`--skip-exec`)
722
+
723
+ After a successful (non-`--check`) run, `cfni-db-codegen` also copies
724
+ `supabase/cfni_exec.sql` and its pgTAP test file (see
725
+ [Testing `cfni_exec.sql` itself](#testing-cfni_execsql-itself) below) into
726
+ your project — `--rpc-dir`/`--tests-dir` (defaulting to sibling folders of
727
+ `--ddl-dir`, so `rpc`/`tests` next to `data-base`). This keeps a project that
728
+ enables Supabase mode's raw-SQL path always holding the current version of the
729
+ function, without a manual copy-paste step.
730
+
731
+ This step is gated on `db.supabase.rawSql` (see
732
+ [Supabase mode without `cfni_exec`](#supabase-mode-without-cfni_exec)):
733
+ codegen reads your `next.config.*`'s `@intl-config` alias, opens the intl
734
+ config file it points at, and looks for a literal `rawSql: true`/`rawSql:
735
+ false`. If it's explicitly `false`, the copy is skipped entirely — no
736
+ `supabase/rpc`/`supabase/tests` folders are created. If it can't be
737
+ determined (no `next.config.*` found, no alias, or `rawSql` isn't a plain
738
+ `true`/`false` literal in the source), a warning is printed and codegen
739
+ assumes `true`, matching `withPublicDb`/`withUserDb`'s own default.
740
+
741
+ An existing target file that already matches is left untouched; one that
742
+ exists with **different** content (a customization, or a stale version) is
743
+ skipped with a warning rather than silently overwritten — pass `--force` (or
744
+ set `CFNI_DB_FORCE_EXEC=true`) to overwrite it anyway. Pass `--skip-exec` (or
745
+ set `CFNI_DB_SKIP_EXEC=true`) to turn this whole step off, independent of
746
+ `rawSql`.
747
+
748
+ To run only this step — no `drizzle-kit pull`, no live Postgres needed — use
749
+ the standalone `cfni-db-install-exec` binary instead, which accepts the same
750
+ `--rpc-dir`/`--tests-dir`/`--force` flags:
751
+
752
+ ```bash
753
+ npx cfni-db-install-exec
754
+ npx cfni-db-install-exec --force
755
+ ```
756
+
593
757
  #### Testing code that calls `withPublicDb`/`withUserDb`
594
758
 
595
759
  `cloudflare-next-intl/dbTesting` exports a fake `DrizzleDb` so repository/unit
@@ -613,6 +777,45 @@ just "select was called" but "the second select's `.where(...)` argument was
613
777
  X". Handles `db.$with(name).as(builder)` / `db.with(...).select(...)`
614
778
  CTE-style queries the same way the real Drizzle client does.
615
779
 
780
+ #### Testing `cfni_exec.sql` itself
781
+
782
+ Because `cfni_exec.sql` runs real SQL — statement classification (SELECT vs.
783
+ DML, writable CTEs), literal-encoding of parameters, and RLS behavior — it's
784
+ tested two ways in this package's own repo, and both are shipped so you can
785
+ reuse them against your own database rather than trusting the function
786
+ untested:
787
+
788
+ - `supabase/tests/cfni_exec.sql` — a [pgTAP](https://pgtap.org/) suite,
789
+ runnable with `supabase test db` (or `pg_prove`) once both this file and
790
+ `cfni_exec.sql` are installed in a database. It checks the SQL function
791
+ directly: every statement shape `cfni_exec` classifies (plain `SELECT`,
792
+ `INSERT`/`UPDATE`/`DELETE` with and without `RETURNING`, writable CTEs,
793
+ `ON CONFLICT DO UPDATE`), value fidelity (arrays/booleans/`numeric`/`NULL`
794
+ round-tripping through pg's own text form, not JSON), and that
795
+ `SECURITY INVOKER` really does make RLS apply per-role (`anon` vs.
796
+ `authenticated` see different rows under the same policy). `cfni-db-codegen`
797
+ and `cfni-db-install-exec` copy this file into your project the same way
798
+ they copy `cfni_exec.sql` itself, so it's there to run against your own
799
+ schema whenever you want the same confidence.
800
+ - `src/db/cfni_exec.integration.test.ts` (in this package's source, not
801
+ something copied into your project) — a Vitest suite that drives the exact
802
+ same scenarios through the real TypeScript transport path:
803
+ `inlineParams` → `cfni_exec` → `parseComposite`, over an actual Postgres
804
+ connection. It's skipped automatically unless `CFNI_TEST_DATABASE_URL` is
805
+ set (e.g. to a throwaway `docker run -d -e POSTGRES_PASSWORD=postgres -p
806
+ 55432:5432 postgres:15`), so it never runs — and never needs a database —
807
+ during a normal `npm test`.
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
+
616
818
  ## License
617
819
 
618
820
  MIT
821
+
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Regenerates Drizzle models by introspecting a live Postgres with drizzle-kit.
3
3
  // Usage: cfni-db-codegen [--check] [--ddl-dir=…] [--out-dir=…] [--out-file=…] [--db-url=…] [--drizzle-config=…]
4
+ // [--rpc-dir=…] [--tests-dir=…] [--force] [--skip-exec]
4
5
  //
5
6
  // --out-dir may be repeated, or given a comma-separated list, to generate the
6
7
  // same schema into several projects at once (CFNI_DB_OUT_DIR accepts a
@@ -13,12 +14,23 @@
13
14
  // set, it tries the local Supabase default (127.0.0.1:54322).
14
15
  // CODEGEN_CONNECT_TIMEOUT_MS overrides the 5s default reachability-check
15
16
  // timeout — raise it for a slow/cold-starting remote or serverless target.
17
+ //
18
+ // After a successful (non-`--check`) run, also installs `cfni_exec.sql` (and
19
+ // its pgTAP test file) into `--rpc-dir`/`--tests-dir` (default siblings of
20
+ // `--ddl-dir`, e.g. `supabase/rpc`/`supabase/tests`) — but only when the
21
+ // project's `db.supabase.rawSql` isn't explicitly `false` (read from
22
+ // `next.config.*`'s `@intl-config` alias; a warning is printed if it can't
23
+ // be determined). An existing, differing file is left alone unless
24
+ // `--force`/CFNI_DB_FORCE_EXEC=true is set. Pass `--skip-exec`/
25
+ // CFNI_DB_SKIP_EXEC=true to turn this step off entirely, or use the
26
+ // standalone `cfni-db-install-exec` command to run only this step.
16
27
  import { createHash } from 'node:crypto';
17
28
  import { execFileSync } from 'node:child_process';
18
29
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
19
30
  import { join, relative } from 'node:path';
20
31
  import { Client } from 'pg';
21
32
  import resolveCodegenPaths from '../dist/src/db/codegen_paths.js';
33
+ import { runInstallExecStep } from './install_exec_step.mjs';
22
34
 
23
35
  const paths = resolveCodegenPaths(process.argv.slice(2), process.env, process.cwd());
24
36
 
@@ -106,8 +118,37 @@ function patchBareFunctionCallDefaults(source) {
106
118
  );
107
119
  }
108
120
 
121
+ // drizzle-kit emits imports from `drizzle-orm/pg-core` and `drizzle-orm`.
122
+ // Point them at this package's re-exports so consuming projects don't need a
123
+ // direct `drizzle-orm` dependency just to load their generated schema.
124
+ function retargetDrizzleImports(source) {
125
+ const coreMatch = source.match(
126
+ /^import \{([^}]*)\} from "drizzle-orm\/pg-core";?$/m,
127
+ );
128
+ const rootMatch = source.match(/^import \{([^}]*)\} from "drizzle-orm";?$/m);
129
+ if (!coreMatch && !rootMatch) return source;
130
+
131
+ const names = [...(coreMatch ? [coreMatch[1]] : []), ...(rootMatch ? [rootMatch[1]] : [])]
132
+ .flatMap((group) => group.split(","))
133
+ .map((name) => name.trim())
134
+ .filter(Boolean);
135
+ const merged = `import { ${[...new Set(names)].join(", ")} } from "cloudflare-next-intl/dbSchema"`;
136
+
137
+ let seen = false;
138
+ return source.replace(
139
+ /^import \{[^}]*\} from "drizzle-orm(?:\/pg-core)?";?$/gm,
140
+ () => {
141
+ if (seen) return "";
142
+ seen = true;
143
+ return merged;
144
+ },
145
+ ).replace(/\n{3,}/g, "\n\n");
146
+ }
147
+
109
148
  const banner = `// GENERATED by cfni-db-codegen from ${relative(process.cwd(), paths.ddlDir)} — do not edit.\n`;
110
- const pulledSource = patchBareFunctionCallDefaults(readFileSync(pulled, "utf8"));
149
+ const pulledSource = retargetDrizzleImports(
150
+ patchBareFunctionCallDefaults(readFileSync(pulled, "utf8")),
151
+ );
111
152
  rmSync(paths.pullDir, { recursive: true, force: true });
112
153
  for (const target of paths.targets) {
113
154
  mkdirSync(target.outDir, { recursive: true });
@@ -115,3 +156,5 @@ for (const target of paths.targets) {
115
156
  writeFileSync(target.manifest, `${JSON.stringify({ ddlHash: hash }, null, 2)}\n`);
116
157
  console.log(`✅ Generated ${relative(process.cwd(), target.outFile)}`);
117
158
  }
159
+
160
+ runInstallExecStep(paths, process.cwd());
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // Installs cfni_exec.sql (and its pgTAP test file) into the consuming
3
+ // project — the same step cfni-db-codegen runs after a successful
4
+ // generation, exposed standalone for when you only want this and nothing
5
+ // else (no drizzle-kit pull, no live Postgres needed).
6
+ //
7
+ // Usage: cfni-db-install-exec [--rpc-dir=…] [--tests-dir=…] [--force]
8
+ //
9
+ // Gated on the project's `db.supabase.rawSql` (read from `next.config.*`'s
10
+ // `@intl-config` alias) the same way cfni-db-codegen's step is — pass
11
+ // `--force`/CFNI_DB_FORCE_EXEC=true to overwrite an existing, differing file.
12
+ import resolveCodegenPaths from '../dist/src/db/codegen_paths.js';
13
+ import { runInstallExecStep } from './install_exec_step.mjs';
14
+
15
+ const paths = resolveCodegenPaths(process.argv.slice(2), process.env, process.cwd());
16
+ runInstallExecStep(paths, process.cwd());
@@ -0,0 +1,39 @@
1
+ // Shared by cfni-db-codegen and cfni-db-install-exec: copies cfni_exec.sql
2
+ // (and its pgTAP test file) from this package's own `supabase/` folder into
3
+ // the consuming project, gated on the project's `db.supabase.rawSql` setting.
4
+ import { join } from 'node:path';
5
+ import resolveRawSql from '../dist/src/db/resolve_raw_sql.js';
6
+ import { installExecFile } from '../dist/src/db/install_exec.js';
7
+
8
+ const PACKAGE_ROOT = new URL('..', import.meta.url).pathname;
9
+
10
+ /**
11
+ * @param {import('../dist/src/db/codegen_paths.js').CodegenPaths} paths
12
+ * @param {string} cwd
13
+ */
14
+ export function runInstallExecStep(paths, cwd) {
15
+ if (paths.skipExec) {
16
+ console.log('ℹ️ Skipping cfni_exec install — --skip-exec passed.');
17
+ return;
18
+ }
19
+
20
+ const rawSql = resolveRawSql(cwd);
21
+ if (rawSql.status === 'false') {
22
+ console.log(`ℹ️ Skipping cfni_exec install — db.supabase.rawSql is false (${rawSql.reason}).`);
23
+ return;
24
+ }
25
+ if (rawSql.status === 'unknown') {
26
+ console.warn(`⚠️ Could not determine db.supabase.rawSql (${rawSql.reason}) — assuming true and installing cfni_exec. Pass --skip-exec, or set db.supabase.rawSql: false, to turn this off.`);
27
+ }
28
+
29
+ const files = [
30
+ { sourcePath: join(PACKAGE_ROOT, 'supabase/cfni_exec.sql'), targetPath: paths.rpcFile },
31
+ { sourcePath: join(PACKAGE_ROOT, 'supabase/tests/cfni_exec.sql'), targetPath: paths.testsFile },
32
+ ];
33
+
34
+ for (const file of files) {
35
+ const result = installExecFile(file, paths.force);
36
+ const icon = { created: '✅', updated: '✅', unchanged: 'ℹ️', 'skipped-differs': '⚠️', 'skipped-missing-source': '⚠️' }[result.action];
37
+ console.log(`${icon} ${result.targetPath}: ${result.message}`);
38
+ }
39
+ }
@@ -14,6 +14,12 @@ export interface CodegenPaths {
14
14
  check: boolean;
15
15
  timeoutMs: number;
16
16
  drizzleConfig: string | null;
17
+ rpcDir: string;
18
+ rpcFile: string;
19
+ testsDir: string;
20
+ testsFile: string;
21
+ force: boolean;
22
+ skipExec: boolean;
17
23
  }
18
24
  /** Resolves every codegen path from flags, then env, then the documented defaults. */
19
25
  export default function resolveCodegenPaths(argv: readonly string[], env: Record<string, string | undefined>, cwd: string): CodegenPaths;
@@ -1,9 +1,11 @@
1
- import { isAbsolute, join, resolve } from 'node:path';
1
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
2
2
  const DEFAULT_DDL_DIR = 'supabase/data-base';
3
3
  const DEFAULT_OUT_DIR = 'src/shared/db/generated';
4
4
  const DEFAULT_OUT_FILE = 'schema.ts';
5
5
  const DEFAULT_DB_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
6
6
  const DEFAULT_TIMEOUT_MS = 5000;
7
+ const DEFAULT_RPC_FILE_NAME = 'cfni_exec.sql';
8
+ const DEFAULT_TESTS_FILE_NAME = 'cfni_exec.sql';
7
9
  function flags(argv, name) {
8
10
  const prefix = `--${name}=`;
9
11
  return argv.filter((arg) => arg.startsWith(prefix)).map((arg) => arg.slice(prefix.length));
@@ -31,6 +33,11 @@ export default function resolveCodegenPaths(argv, env, cwd) {
31
33
  const outDir = outDirs[0];
32
34
  const outFileName = flag(argv, 'out-file') ?? env.CFNI_DB_OUT_FILE ?? DEFAULT_OUT_FILE;
33
35
  const drizzleConfig = flag(argv, 'drizzle-config') ?? env.CFNI_DB_DRIZZLE_CONFIG ?? null;
36
+ // Sibling of ddlDir (default `supabase/data-base` → `supabase`), matching
37
+ // where `cfni_exec.sql` ships in this package's own `supabase/` folder.
38
+ const supabaseRoot = dirname(ddlDir);
39
+ const rpcDir = abs(cwd, flag(argv, 'rpc-dir') ?? env.CFNI_DB_RPC_DIR ?? join(supabaseRoot, 'rpc'));
40
+ const testsDir = abs(cwd, flag(argv, 'tests-dir') ?? env.CFNI_DB_TESTS_DIR ?? join(supabaseRoot, 'tests'));
34
41
  return {
35
42
  ddlDir,
36
43
  targets: outDirs.map((dir) => ({
@@ -46,5 +53,11 @@ export default function resolveCodegenPaths(argv, env, cwd) {
46
53
  check: argv.includes('--check'),
47
54
  timeoutMs: Number(env.CODEGEN_CONNECT_TIMEOUT_MS) || DEFAULT_TIMEOUT_MS,
48
55
  drizzleConfig: drizzleConfig === null ? null : abs(cwd, drizzleConfig),
56
+ rpcDir,
57
+ rpcFile: join(rpcDir, DEFAULT_RPC_FILE_NAME),
58
+ testsDir,
59
+ testsFile: join(testsDir, DEFAULT_TESTS_FILE_NAME),
60
+ force: argv.includes('--force') || env.CFNI_DB_FORCE_EXEC === 'true',
61
+ skipExec: argv.includes('--skip-exec') || env.CFNI_DB_SKIP_EXEC === 'true',
49
62
  };
50
63
  }
@@ -33,7 +33,17 @@ async function resolveUserId(uid) {
33
33
  */
34
34
  async function supabaseDb(supabase, bearerToken) {
35
35
  const { drizzle } = await import('drizzle-orm/pg-proxy');
36
- return drizzle(createSupabaseTransport(supabase, bearerToken));
36
+ const db = drizzle(createSupabaseTransport(supabase, bearerToken));
37
+ return Object.assign(db, {
38
+ // pg-proxy has no session to open a real transaction over — every
39
+ // statement is its own PostgREST round-trip — so failing loudly here
40
+ // beats silently running the callback non-atomically.
41
+ transaction() {
42
+ throw new Error('db: transactions are not available in Supabase mode. Each statement runs as its ' +
43
+ 'own PostgREST round-trip with no shared session, so `.transaction()` cannot provide ' +
44
+ 'atomicity. Use connection-string mode (`db.connectionString`) if you need it.');
45
+ },
46
+ });
37
47
  }
38
48
  /**
39
49
  * Runs a query as the **anonymous** role: no transaction, no role switch, no
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Serialises a JS value to a Postgres literal, mirroring how `pg` sends
3
+ * values over the wire so an inlined literal type-infers the same way a
4
+ * bound parameter would.
5
+ *
6
+ * Used to substitute `$n` placeholders client-side in Supabase mode, where
7
+ * `cfni_exec` takes a single already-complete statement — see
8
+ * {@link inlineParams}.
9
+ */
10
+ export default function encodeParam(value: unknown): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Serialises a JS value to a Postgres literal, mirroring how `pg` sends
3
+ * values over the wire so an inlined literal type-infers the same way a
4
+ * bound parameter would.
5
+ *
6
+ * Used to substitute `$n` placeholders client-side in Supabase mode, where
7
+ * `cfni_exec` takes a single already-complete statement — see
8
+ * {@link inlineParams}.
9
+ */
10
+ export default function encodeParam(value) {
11
+ if (value === null || value === undefined)
12
+ return 'NULL';
13
+ if (typeof value === 'boolean')
14
+ return value ? 'true' : 'false';
15
+ if (typeof value === 'number') {
16
+ if (Number.isNaN(value))
17
+ return "'NaN'";
18
+ if (value === Infinity)
19
+ return "'Infinity'";
20
+ if (value === -Infinity)
21
+ return "'-Infinity'";
22
+ return String(value);
23
+ }
24
+ if (typeof value === 'bigint')
25
+ return value.toString();
26
+ if (value instanceof Date)
27
+ return quoteLiteral(value.toISOString());
28
+ if (value instanceof Uint8Array)
29
+ return quoteLiteral(`\\x${bytesToHex(value)}`);
30
+ if (Array.isArray(value))
31
+ return quoteLiteral(encodeArray(value));
32
+ if (typeof value === 'string')
33
+ return quoteLiteral(value);
34
+ // Plain objects (jsonb columns) — pg sends these JSON-stringified.
35
+ return quoteLiteral(JSON.stringify(value));
36
+ }
37
+ function quoteLiteral(text) {
38
+ return `'${text.replace(/'/g, "''")}'`;
39
+ }
40
+ function bytesToHex(bytes) {
41
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
42
+ }
43
+ /** Encodes a JS array as a Postgres array literal body, e.g. `{1,2,"a,b"}`. */
44
+ function encodeArray(value) {
45
+ const items = value.map((item) => {
46
+ if (item === null || item === undefined)
47
+ return 'NULL';
48
+ if (Array.isArray(item))
49
+ return encodeArray(item);
50
+ if (item instanceof Date)
51
+ return quoteArrayElement(item.toISOString());
52
+ if (typeof item === 'number' || typeof item === 'bigint' || typeof item === 'boolean')
53
+ return String(item);
54
+ return quoteArrayElement(typeof item === 'string' ? item : JSON.stringify(item));
55
+ });
56
+ return `{${items.join(',')}}`;
57
+ }
58
+ function quoteArrayElement(text) {
59
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
60
+ }
@@ -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;
@@ -1,11 +1,11 @@
1
- import { sql, eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, type SQL, type Table } from 'drizzle-orm';
1
+ import { sql, eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, inArray, notInArray, ne, like, ilike, between, not, exists, type SQL, type Table } from 'drizzle-orm';
2
2
  /**
3
3
  * Re-exported `drizzle-orm` query-building primitives, so code that only
4
4
  * calls `withPublicDb`/`withUserDb` and builds queries against the returned
5
5
  * `DrizzleDb` never needs its own `drizzle-orm` import for common predicates,
6
6
  * ordering, and aggregates.
7
7
  */
8
- export { eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, sql };
8
+ export { eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, sql, inArray, notInArray, ne, like, ilike, between, not, exists };
9
9
  /**
10
10
  * Type-safe helper returning `excluded.<db_column_name>` SQL expressions for a Drizzle table.
11
11
  *
@@ -1,11 +1,11 @@
1
- import { getTableColumns, getTableName, sql, eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, } from 'drizzle-orm';
1
+ import { getTableColumns, getTableName, sql, eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, inArray, notInArray, ne, like, ilike, between, not, exists, } from 'drizzle-orm';
2
2
  /**
3
3
  * Re-exported `drizzle-orm` query-building primitives, so code that only
4
4
  * calls `withPublicDb`/`withUserDb` and builds queries against the returned
5
5
  * `DrizzleDb` never needs its own `drizzle-orm` import for common predicates,
6
6
  * ordering, and aggregates.
7
7
  */
8
- export { eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, sql };
8
+ export { eq, and, or, asc, desc, gte, gt, lte, lt, isNull, isNotNull, count, sum, max, min, sql, inArray, notInArray, ne, like, ilike, between, not, exists };
9
9
  /**
10
10
  * Type-safe helper returning `excluded.<db_column_name>` SQL expressions for a Drizzle table.
11
11
  *
@@ -14,6 +14,12 @@
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
+ * 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.
22
+ *
17
23
  * Generic Drizzle SQL helpers (`excluded`, `onConflictSet`, `ago`, …) live in
18
24
  * the separate `cloudflare-next-intl/dbHelpers` entry point.
19
25
  */