Supabase → Kuunda Cloud migration
Auth, SDK, cutover. SQL export and console wizard: Import to Kuunda Cloud (schema.sql + data.sql).
1. Overview
| Supabase | Kuunda Cloud |
|---|---|
Schema public | Tenant schema proj_{uuid32hex} |
https://{ref}.supabase.co | https://{ref8}.kuunda-cloud.com |
@supabase/supabase-js | @kuunda/kuunda-js |
| Auth + Storage in the same DB | Separate Auth; helpers auth.uid() recreated in the tenant |
Import wizard: Project → Settings → Data import (https://app.kuunda-cloud.com).
SQL editor (project schema)
The SQL editor and POST /migrate run in the project schema proj_…, not public — same as any Postgres dump whose source schema is public. The import wizard rewrites the schema; pasting an unadapted script into the editor often fails.
| Topic | Kuunda rule |
|---|---|
public. schema | Drop the public. prefix. Session search_path is already the project schema. Omit SET search_path = public. |
auth.users | No SELECT on the Auth catalog (separate database). Helpers: auth.uid(), auth.email(), auth.role(), auth.jwt(). Signup metadata: auth.jwt() -> 'user_metadata'. In the editor these functions return NULL (no user JWT); they evaluate through the authenticated REST / RPC API. |
| SQL comments | Comments and string literals are not treated as executable SQL. |
| schema.sql dumps | Prefer Project → Settings → Import data: rewrites public. → proj_…, keeps auth.uid(), ignores auth.users FKs. |
Editor-compatible example
CREATE OR REPLACE FUNCTION ensure_user_profile(
p_email text DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_uid uuid := auth.uid();
BEGIN
IF v_uid IS NULL THEN RAISE EXCEPTION 'Not authenticated'; END IF;
INSERT INTO profiles (id, email)
VALUES (
v_uid,
COALESCE(NULLIF(TRIM(p_email), ''), COALESCE(auth.email(), ''))
)
ON CONFLICT (id) DO NOTHING;
END;
$$;
GRANT EXECUTE ON FUNCTION ensure_user_profile(text) TO authenticated;2. Prerequisites
On your PC: a terminal (PowerShell or bash) and a PostgreSQL client with pg_dump.
On Supabase: database password (the Connect button, or Settings → Database).
On Kuunda: an account at https://app.kuunda-cloud.com/register.
| Resource | Wizard limit |
|---|---|
| SQL file | ~20 MB |
| Auth accounts | 2,000 |
| Storage | 50 buckets, 2,000 files, 50 MB/file |
| Inventoried tables | 500 |
Above these limits: split the exports and repeat the imports.
3. Inventory before migration
| Component | Migration |
|---|---|
| Tables, views, functions, triggers, RLS | ✅ SQL export + wizard |
| Auth (email / password) | ✅ Import auth.users button |
| OAuth (Google, GitHub…) | ⚠️ Reconfigure providers |
| Storage | ✅ Import Storage button |
| Realtime | ✅ Automatic sync on import |
| Edge Functions | ❌ Manual redeploy |
| DB webhooks, pg_cron, Vault | ❌ Manual recreation |
| PostgreSQL extensions | ⚠️ Database → Extensions |
| SMTP / email templates | ❌ Auth → Emails |
4. Create the Kuunda project
- Kuunda console → New project
- Settings → API — note:
- URL:
https://xxxxxxxx.kuunda-cloud.com - Key anon :
kuunda_anon_… - Key service_role :
kuunda_service_…(server only) - Tenant schema:
proj_+ 32 hex characters
- URL:
5. Configure Auth (before importing users)
On Supabase
Authentication → URL Configuration (Site URL, Redirect URLs) and Providers (OAuth Client ID / Secret).
On Kuunda
Auth → Providers: reuse the same URLs, enable the same providers, and register the new callback URI shown in Kuunda.
SaaS production: https://api.kuunda-cloud.com/auth/v1/callback. Update it at Google / Apple. Keep the Supabase URI until cutover is validated. Details: Google sign-in (OAuth).
6. Export the Supabase database
Commands, credentials (URL / URI / password) and where to paste them: Import page. Method: schema.sql then data.sql via pg_dump on the direct host db.{ref}.supabase.co (not the all-in-one dump, not the transaction pooler on port 6543).
7. Import into Kuunda
URL: https://app.kuunda-cloud.com/{ref}/settings/import
Step 1 — Source
- BaaS export (public schema) card
- Paste the Supabase Postgres URI (optional, for counts)
- Source schema:
public - Analyze source (counts)
Step 2 — Import accounts
After schema.sql, Source step → Import auth.users
| Kept | Not migrated |
|---|---|
| Same UUIDs, bcrypt passwords, metadata | Supabase sessions / JWTs, OAuth identities, accounts without email |
Users will need to sign in again. On the first OAuth sign-in, Kuunda links the account by email.
Step 3 — Import Storage
| Field | Value |
|---|---|
| Storage URL | https://{ref}.supabase.co |
| service_role key | Settings → API → Legacy → service_role (eyJ…) |
After data.sql, click Import Storage. Without URL + key: empty buckets only.
Step 4 — Apply SQL
Order: schema.sql → auth.users → data.sql → Storage. Field details: Import page.
Before the schema: required extensions (e.g. pgcrypto, already CORE) via Database → Extensions.
- Script step: upload schema.sql
- Transform → proj_…
- Apply on Kuunda
- Back to Source → Import auth.users
- Repeat Script / Transform / Apply with data.sql
- Source → Import Storage
- Source ↔ Kuunda report — Source counts = Kuunda
Migrated: tables, RLS (auth.uid()), functions, triggers, views, Realtime sync. Ignored in SQL: FKs to auth.users / storage.*, Supabase roles, COMMENT ON SCHEMA, cluster-level event triggers (CREATE EVENT TRIGGER), blocked CREATE EXTENSION (install via the Extensions UI).
SQL import troubleshooting
| Message | Cause / action |
|---|---|
transaction is aborted (often on the first DDL statement) | Means a previous statement in the same batch failed. Check the dashboard version (savepoint fix for session_replication_role). On a partial import, drop the conflicting object then re-apply, e.g. DROP TYPE IF EXISTS proj_…mon_enum CASCADE; or DROP TABLE IF EXISTS proj_…ma_table CASCADE; |
type … already exists | Partial import — drop the type or table, or start from a fresh Kuunda project. |
function gen_random_uuid() does not exist | Install pgcrypto (Database → Extensions) before applying the schema. CORE already provides it on Kuunda. |
permission denied for schema auth (CREATE POLICY) | Fixed on the Kuunda side: the proj_…_owner role gets USAGE/EXECUTE on auth.uid() before import. Update the dashboard then re-apply the schema. |
permission denied for schema public | Remove public. and SET search_path = public. The project schema is already the search_path. Or use the import wizard (automatic rewrite). |
Forbidden reference … (auth.users) | No access to auth.users. Use auth.uid(), auth.email(), auth.role(), auth.jwt(). A comment containing auth.users is no longer blocked. |
| File > ~20 MB | Split (--schema-only + --data-only) or remote migrations in chunks. |
8. Realtime
Import adds tables to the Realtime publication. If tables are missing: Console → Realtime → Sync publication.
9. Edge Functions, cron, secrets
| Item | Action |
|---|---|
| Edge Functions | Retrieve the code → redeploy in Kuunda → Functions |
| Secrets | Copy them manually |
| Webhooks, pg_cron, Vault | Recreate in Kuunda |
10. Adapt the application
Environment variables
| Before (Supabase) | After (Kuunda) |
|---|---|
NEXT_PUBLIC_SUPABASE_URL | NEXT_PUBLIC_KUUNDA_URL = https://{ref8}.kuunda-cloud.com |
NEXT_PUBLIC_SUPABASE_ANON_KEY | NEXT_PUBLIC_KUUNDA_ANON_KEY = kuunda_anon_… |
SUPABASE_SERVICE_ROLE_KEY | kuunda_service_… — server only |
SDK
import { createClient } from '@kuunda/kuunda-js';
const kuunda = createClient(
process.env.NEXT_PUBLIC_KUUNDA_URL!,
process.env.NEXT_PUBLIC_KUUNDA_ANON_KEY!,
{
dbSchema: 'proj_uuid32hexsanstirets',
projectRef: 'abcdef12',
}
);Key points
- anon key →
apikeyheader only, notAuthorization: Bearer - Bearer = Auth session JWT (set by the SDK after sign-in)
- OAuth →
@kuunda/kuunda-js+projectRef(provider: 'google'/github/ …). Without SDK:custom:proj-{ref}-google(hyphens), notprovider=google. - RLS data → load after sign-in
- Storage → same bucket names as before
API compatibility
| API | Status |
|---|---|
.from().select/insert/update/delete | ✅ |
.rpc() | ✅ |
.auth.signUp / signIn / signOut | ✅ |
.auth.signInWithOAuth | ✅ with @kuunda/kuunda-js |
.storage.from() | ✅ |
| Realtime | ✅ |
| Edge Functions | ⚠️ Redeploy before use |
SDK Auth differences
// getSession — synchronous
const session = kuunda.auth.getSession();
// getUser
const { data: user, error } = await kuunda.auth.getUser();
// onAuthStateChange — a single argument
kuunda.auth.onAuthStateChange(({ event, session }) => { … });11. Production cutover
- Validate the DB import (count report OK)
- Test email + OAuth sign-in (same UUID for imported users)
- Test Storage and RLS
- Deploy the app with @kuunda/kuunda-js
- Add the Kuunda OAuth callback (keep Supabase for rollback)
- Maintenance: stop Supabase writes, delta import if needed
- Switch DNS / env vars to Kuunda
Delta import: re-export changed tables or remote migrations (POST /api/projects/{ref8}/migrate/service-role).
12. Final validation
| Test | Expected result |
|---|---|
| Table counts | Source = Kuunda |
| Email/password sign-in | Same password, active session |
| OAuth sign-in | Same UUID as before |
| RLS | anon blocked, authenticated OK |
| Storage | Upload + download OK |
| Realtime | Event received |
| RPC | .rpc('ma_fonction') returns the data |
13. Order of operations
- Create a Kuunda project and note the ref, schema, and API keys
- Configure Auth (URLs + OAuth providers)
- Install the required PostgreSQL extensions (pgcrypto, etc.)
- Export Supabase → schema.sql + data.sql
- Kuunda Import: analyze the source
- Transform + Apply schema.sql
- Import auth.users
- Transform + Apply data.sql
- Import Storage
- Source ↔ Kuunda report
- Sync Realtime if needed
- Redeploy Edge Functions + secrets
- Adapt the app → @kuunda/kuunda-js
- Full tests
- Production cutover
14. What does not migrate automatically
| Supabase | Required action |
|---|---|
| Edge Functions | Redeploy on Kuunda |
| Database Webhooks | Recreate (triggers + HTTP) |
| Vault / secrets | Re-enter manually |
| Missing extensions | Database → Extensions |
| Cron jobs | External job or VPS cron |
| Hard-coded Storage URLs | Update code and database |
| SMTP / email templates | Auth → Emails |