From 9dc3998fa0bd14e31465ca6c0432e539cc32b41a Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 18 May 2026 16:22:12 +0700 Subject: [PATCH] RLS Tester sandbox environment (#45839) ## Context Resolves FE-3221 Heavily inspired by what @filipecabaco has done previously here: https://github.com/supabase/supabase/pull/45360 This PR explores the use of pglite to set up a sandbox for RLS testing, which will pave the way for testing mutation based queries so to ensure no disruption to the actual database. Sandbox can be set up within the RLS tester panel as such: image Which the sandbox will mimic the project's database to the bare minimum required - entities from the `public` schema are copied over (types, tables, functions, policies) - `auth` schema is pseudo setup with `SANDBOX_SETUP_STATEMENTS` - Enough to support role impersonation + querying tables with references to the auth schema (e.g users table) - data is seeded up to 100 rows for each table - More info RE limitations in the last section below Once sandbox is ready, you'll see this UI where you can either leave the sandbox, or re-sync the sandbox from the actual database image Changes are currently feature flagged, so won't be available publicly just yet until things are ironed out and ready ## To test - [ ] Verify that setting up sandbox works - [ ] Verify that you can query your sandbox, and queries do not touch the actual database (can verify that we're not sending HTTP requests to the /query endpoint) - [ ] Verify correctness of RLS tester as well, should match correctness with testing against actual DB - [ ] Verify that re-syncing sandbox picks up changes - Can test by updating your policies that will affect the output of your select query - e.g SELECT for `authenticated`, change from just `true` to `false` - [ ] RLS tester should work as per normal (against actual DB) with the feature flag off with no additional overhead Let me know of any edge cases you might run into while testing ## Known quirks that will be addressed subsequently Leaving these for now just to not bloat this PR further - Pglite schema needs to be re-synced if updating RLS policies while testing, to ensure that pglite gets the updated policies. Will think about how to make this more seamless - Sandbox has its own limitations, will need to add a dialog to inform users how the sandbox works and what limitations to note of - e.g only the auth schema is mimicked - so policies that reference storage helpers won't work (although i think auth is probably the main use case and the rest might be niche) - We can slowly expand tho where required - Eventually we'll also move forward with figuring out testing mutation queries with this sandbox ## Summary by CodeRabbit * **New Features** * RLS tester gains an isolated Postgres sandbox with schema/seed import, start/refresh/exit controls, and pre-populated auth data. * Sandbox management UI with setup, loading, active, and error states; refresh and destroy actions. * **Bug Fixes** * Role impersonation now keeps the PostgREST role set to anon while the tester sheet is open. * **Chores** * Content Security Policy updated to allow sandbox/connectivity endpoints. * **Style** * Minor sheet styling adjustment (top border). [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45839) --- .../Auth/RLSTester/RLSTesterSheet.tsx | 28 ++- .../Auth/RLSTester/SandboxManagement.tsx | 105 +++++++++ .../Auth/RLSTester/useTestQueryRLS.ts | 39 ++-- apps/studio/csp.ts | 21 ++ apps/studio/data/rls-tester/get-schema-ddl.ts | 180 +++++++++++++++ apps/studio/data/rls-tester/get-seed-data.ts | 88 ++++++++ apps/studio/package.json | 2 + .../state/postgres-sandbox/pglite.worker.ts | 13 ++ .../postgres-sandbox/sandbox.constants.ts | 162 ++++++++++++++ .../state/postgres-sandbox/sandbox.core.ts | 74 ++++++ .../studio/state/postgres-sandbox/sandbox.tsx | 143 ++++++++++++ .../state/postgres-sandbox/sandbox.utils.ts | 210 ++++++++++++++++++ pnpm-lock.yaml | 56 +++-- 13 files changed, 1079 insertions(+), 42 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx create mode 100644 apps/studio/data/rls-tester/get-schema-ddl.ts create mode 100644 apps/studio/data/rls-tester/get-seed-data.ts create mode 100644 apps/studio/state/postgres-sandbox/pglite.worker.ts create mode 100644 apps/studio/state/postgres-sandbox/sandbox.constants.ts create mode 100644 apps/studio/state/postgres-sandbox/sandbox.core.ts create mode 100644 apps/studio/state/postgres-sandbox/sandbox.tsx create mode 100644 apps/studio/state/postgres-sandbox/sandbox.utils.ts diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx index 42a243b2a14..098a170034f 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx @@ -13,7 +13,7 @@ import { SelectTrigger, SelectValue, } from '@ui/components/shadcn/ui/select' -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' import { Code, ExternalLink } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { @@ -35,6 +35,7 @@ import { type ParseQueryResults } from './RLSTester.types' import { RLSTesterEmptyState } from './RLSTesterEmptyState' import { RLSTesterResults } from './RLSTesterResults' import { RoleSelector } from './RoleSelector' +import { SandboxManagement } from './SandboxManagement' import { UserSelector } from './UserSelector' import { UserSqlEditor } from './UserSqlEditor' import { useTestQueryRLS } from './useTestQueryRLS' @@ -44,6 +45,7 @@ import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { useTrack } from '@/lib/telemetry/track' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { PostgresSandboxProvider } from '@/state/postgres-sandbox/sandbox' import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' @@ -51,11 +53,20 @@ interface RLSTesterSheetProps { handleSelectEditPolicy: (policy: Policy) => void } -export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { +export const RLSTesterSheet = (props: RLSTesterSheetProps) => { + return ( + + + + ) +} + +const RLSTesterSheetContents = ({ handleSelectEditPolicy }: RLSTesterSheetProps) => { const track = useTrack() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const { setRole } = useRoleImpersonationStateSnapshot() + const sandboxEnabled = useFlag('rlsTesterSandbox') const [open, setOpen] = useState(false) const [selectedOption, setSelectedOption] = useState<'anon' | 'authenticated'>('anon') @@ -132,12 +143,13 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) } useEffect(() => { - setRole({ type: 'postgrest', role: 'anon' }) - // Flip back to service role - return () => { + if (open) { + setRole({ type: 'postgrest', role: 'anon' }) + } else { + // Flip back to service role setRole(undefined) } - }, [setRole]) + }, [open, setRole]) return ( @@ -159,7 +171,9 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps)
- + {sandboxEnabled && } + +
{selectedOption === 'authenticated' && } diff --git a/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx b/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx new file mode 100644 index 00000000000..a7ce45ebcc9 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/RLSTester/SandboxManagement.tsx @@ -0,0 +1,105 @@ +import { Box, Loader2, LogOut, RefreshCw } from 'lucide-react' +import { Badge, Button } from 'ui' +import { Admonition } from 'ui-patterns' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' + +export const SandboxManagement = () => { + const { status, error, isSyncing, startSandbox, destroySandbox, syncSandbox } = + usePostgresSandbox() + + if (status === 'idle') { + return ( + startSandbox()}> + Set up sandbox + , + ]} + > +
+

Set up sandbox for testing

+ Recommended +
+

+ Ensure that queries do not affect your actual database +

+
+ ) + } + + if (status === 'loading') { + return ( + +
+
+ +
+

Setting up sandbox

+
+
+ ) + } + + if (status === 'error') { + return ( + startSandbox()}> + Retry set up + , + ]} + /> + ) + } + + return ( + } + className="w-7" + disabled={isSyncing} + tooltip={{ content: { side: 'bottom', text: 'Exit sandbox' } }} + onClick={() => destroySandbox()} + />, + } + className="w-7" + loading={isSyncing} + tooltip={{ content: { side: 'bottom', text: 'Refresh schema' } }} + onClick={() => syncSandbox()} + />, + ]} + > +
+
+ +
+

Sandbox active

+

Your database is never modified

+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts index 5ddf6516c7e..91ede81b5a0 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts +++ b/apps/studio/components/interfaces/Auth/RLSTester/useTestQueryRLS.ts @@ -12,6 +12,7 @@ import { useParseSQLQueryMutation } from '@/data/misc/parse-query-mutation' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' +import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' import { isRoleImpersonationEnabled, useGetImpersonatedRoleState, @@ -35,20 +36,23 @@ export const useTestQueryRLS = () => { const { data: project } = useSelectedProjectQuery() const { role } = useRoleImpersonationStateSnapshot() + const { sandbox } = usePostgresSandbox() const getImpersonatedRoleState = useGetImpersonatedRoleState() const impersonatedRoleState = getImpersonatedRoleState() const user = useImpersonatedUser() const [isLoading, setIsLoading] = useState(false) + const [sandboxError, setSandboxError] = useState() const { data: policies = [] } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const { mutateAsync: executeSql, error: executeSqlError } = useExecuteSqlMutation({ + const { mutateAsync: executeSql, error: executeSqlMutationError } = useExecuteSqlMutation({ onError: () => {}, }) + const executeSqlError = sandbox ? sandboxError : executeSqlMutationError const { mutateAsync: parseClientCode, @@ -104,13 +108,14 @@ export const useTestQueryRLS = () => { try { setIsLoading(true) + setSandboxError(undefined) const { appendAutoLimit } = checkIfAppendLimitRequired(value, limit) const formattedSql = suffixWithLimit(value, limit) const data = await parseQuery({ sql: formattedSql }) if (data.operation !== 'SELECT') { - return toast('Only SELECT statements are supported for now') + return toast('Only SELECT statements are supported with the RLS Tester at the moment') } const formattedTables = data.tables.map((x) => { @@ -146,19 +151,25 @@ export const useTestQueryRLS = () => { }) const autoLimit = appendAutoLimit ? limit : undefined - const { result } = await executeSql({ - autoLimit, - projectRef: project.ref, - connectionString: project.connectionString, - sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState), - isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), - isStatementTimeoutDisabled: true, - handleError: (error) => { - throw error - }, - queryKey: ['rls-tester'], - }) + const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState) + const { result } = sandbox + ? await sandbox.run({ sql }).catch((e) => { + setSandboxError(e instanceof Error ? e : new Error(String(e))) + throw e + }) + : await executeSql({ + sql, + autoLimit, + projectRef: project.ref, + connectionString: project.connectionString, + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), + isStatementTimeoutDisabled: true, + handleError: (e) => { + throw e + }, + queryKey: ['rls-tester'], + }) onExecuteSQL({ result, isAutoLimit: !!autoLimit }) onParseQuery({ diff --git a/apps/studio/csp.ts b/apps/studio/csp.ts index 9c6218254c2..2501ba08f5a 100644 --- a/apps/studio/csp.ts +++ b/apps/studio/csp.ts @@ -192,7 +192,28 @@ export function getCSP() { const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ') + const connectSrcDirective = [ + `connect-src 'self'`, + `data:`, + `blob:`, + ...DEFAULT_SRC_URLS, + ...(isDevOrStaging + ? [ + SUPABASE_STAGING_PROJECTS_URL, + SUPABASE_STAGING_PROJECTS_URL_WS, + NIMBUS_STAGING_PROJECTS_URL, + NIMBUS_STAGING_PROJECTS_URL_WS, + VERCEL_LIVE_URL, + SUPABASE_DOCS_PROJECT_URL, + SUPABASE_CONTENT_API_URL, + ] + : []), + PUSHER_URL_WS, + SENTRY_URL, + ].join(' ') + const cspDirectives = [ + connectSrcDirective, defaultSrcDirective, imgSrcDirective, scriptSrcDirective, diff --git a/apps/studio/data/rls-tester/get-schema-ddl.ts b/apps/studio/data/rls-tester/get-schema-ddl.ts new file mode 100644 index 00000000000..cca3556bbc0 --- /dev/null +++ b/apps/studio/data/rls-tester/get-schema-ddl.ts @@ -0,0 +1,180 @@ +import pgMeta, { + getEntityDefinitionsSql, + joinSqlFragments, + literal, + safeSql, + type PGPolicy, +} from '@supabase/pg-meta' +import { z } from 'zod' + +import { executeSql } from '@/data/sql/execute-sql-query' +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' + +export interface RlsTableStatus { + schema: string + table: string + rls_enabled: boolean + rls_forced: boolean +} + +export interface CustomRole { + name: string +} + +export interface DatabaseSchemaDDL { + schemas: string[] + typeDefinitions: string[] + entityDefinitions: string[] + functionDefinitions: string[] + policies: PGPolicy[] + rlsStatuses: RlsTableStatus[] + customRoles: CustomRole[] +} + +const pgMetaRolesList = pgMeta.roles.list() +const pgMetaFunctionsZod = pgMeta.functions.list().zod +const pgMetaPoliciesZod = pgMeta.policies.list().zod +const pgMetaTablesZod = pgMeta.tables.list().zod + +// Extension-owned / platform-specific schemas whose DDL depends on C extensions, +// custom operators, and platform functions that PGlite cannot replicate. +// We skip entity/function/type DDL for these but still fetch their policies — +// those may reference user tables we do load. +const SUPABASE_INTERNAL_SCHEMAS = new Set([...INTERNAL_SCHEMAS, '_realtime']) + +const SYSTEM_ROLES = new Set([ + 'postgres', + 'anon', + 'authenticated', + 'service_role', + 'supabase_admin', + 'supabase_auth_admin', + 'supabase_storage_admin', + 'supabase_replication_admin', + 'supabase_read_only_user', + 'pg_monitor', + 'pg_read_all_settings', + 'pg_read_all_stats', + 'pg_stat_scan_tables', + 'pg_read_server_files', + 'pg_write_server_files', + 'pg_execute_server_program', + 'pg_signal_backend', + 'dashboard_user', + 'pgbouncer', +]) + +function getTypeDefinitionsSql(schemas: string[]) { + return safeSql` + SELECT + CASE t.typtype + WHEN 'e' THEN + 'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS ENUM (' || + (SELECT string_agg(quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder) + FROM pg_enum e WHERE e.enumtypid = t.oid) || + ')' + WHEN 'c' THEN + 'CREATE TYPE ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS (' || + (SELECT string_agg(quote_ident(a.attname) || ' ' || pg_catalog.format_type(a.atttypid, a.atttypmod), ', ' ORDER BY a.attnum) + FROM pg_attribute a WHERE a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped) || + ')' + WHEN 'd' THEN + 'CREATE DOMAIN ' || quote_ident(n.nspname) || '.' || quote_ident(t.typname) || + ' AS ' || pg_catalog.format_type(t.typbasetype, t.typtypmod) + END AS definition + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + LEFT JOIN pg_class c ON c.oid = t.typrelid + LEFT JOIN pg_depend d ON d.objid = t.oid AND d.deptype = 'e' + WHERE n.nspname IN (${joinSqlFragments(schemas.map(literal), ', ')}) + AND t.typtype IN ('e', 'c', 'd') + AND d.objid IS NULL + AND (t.typtype != 'c' OR c.relkind = 'c') + ORDER BY t.typtype, n.nspname, t.typname + ` +} + +type Variables = { + projectRef?: string + connectionString?: string | null + schemas: string[] +} + +export async function getDatabaseSchemaDDL( + { projectRef, connectionString, schemas }: Variables, + signal?: AbortSignal +): Promise { + const userSchemas = schemas.filter((s) => !SUPABASE_INTERNAL_SCHEMAS.has(s)) + + const entitySql = getEntityDefinitionsSql({ schemas: userSchemas }) + const functionsSql = pgMeta.functions.list({ includedSchemas: userSchemas }).sql + const policiesSql = pgMeta.policies.list({ includedSchemas: schemas }).sql + const tablesSql = pgMeta.tables.list({ includedSchemas: userSchemas }).sql + + const [entityResult, policiesResult, rlsResult, rolesResult, functionsResult, typesResult] = + await Promise.all([ + executeSql( + { projectRef, connectionString, sql: entitySql, queryKey: ['rls-sandbox-ddl'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: policiesSql, queryKey: ['rls-sandbox-policies'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: tablesSql, queryKey: ['rls-sandbox-rls'] }, + signal + ), + executeSql( + { projectRef, connectionString, sql: pgMetaRolesList.sql, queryKey: ['rls-sandbox-roles'] }, + signal + ), + executeSql( + { + projectRef, + connectionString, + sql: functionsSql, + queryKey: ['rls-sandbox-functions'], + }, + signal + ), + executeSql( + { + projectRef, + connectionString, + sql: getTypeDefinitionsSql(userSchemas), + queryKey: ['rls-sandbox-types'], + }, + signal + ), + ]) + + const roles = (rolesResult.result as z.infer).filter( + (r) => !SYSTEM_ROLES.has(r.name) && !r.name.startsWith('pg_') && !r.name.startsWith('supabase_') + ) + + const functions = (functionsResult.result as z.infer).filter( + (f) => (f.language === 'sql' || f.language === 'plpgsql') && f.return_type !== 'trigger' + ) + + return { + schemas: userSchemas, + typeDefinitions: (typesResult.result as { definition: string }[]).map((r) => r.definition), + entityDefinitions: (entityResult.result[0]?.data?.definitions ?? []).map( + (d: { sql: string }) => d.sql + ), + functionDefinitions: functions.map((f) => f.complete_statement), + policies: policiesResult.result as z.infer as PGPolicy[], + rlsStatuses: (rlsResult.result as z.infer).map((t) => ({ + schema: t.schema, + table: t.name, + rls_enabled: t.rls_enabled, + rls_forced: t.rls_forced, + })), + customRoles: roles, + } +} + +export type DatabaseSchemaDDLData = Awaited> diff --git a/apps/studio/data/rls-tester/get-seed-data.ts b/apps/studio/data/rls-tester/get-seed-data.ts new file mode 100644 index 00000000000..1d13f852b73 --- /dev/null +++ b/apps/studio/data/rls-tester/get-seed-data.ts @@ -0,0 +1,88 @@ +import { ident, joinSqlFragments, literal, safeSql } from '@supabase/pg-meta' + +import { RlsTableStatus } from './get-schema-ddl' +import { executeSql } from '@/data/sql/execute-sql-query' + +export interface TableSeedData { + schema: string + table: string + rows: Record[] +} + +// Each entry can optionally restrict which columns are fetched. Used by the +// sandbox to avoid pulling secrets (e.g. auth.users encrypted_password / tokens) +// into the browser-side PGlite instance. +export type SeedTableEntry = RlsTableStatus & { columns?: readonly string[] } + +type Variables = { + projectRef?: string + connectionString?: string | null + tables: SeedTableEntry[] + rowLimit: number +} + +async function fetchTableSeed( + { + projectRef, + connectionString, + schema, + table, + columns, + rowLimit, + }: Omit & { + schema: string + table: string + columns?: readonly string[] + }, + signal?: AbortSignal +): Promise { + try { + const projection = + columns && columns.length > 0 ? joinSqlFragments(columns.map(ident), ', ') : safeSql`*` + const { result } = await executeSql( + { + projectRef, + connectionString, + sql: safeSql`SELECT ${projection} FROM ${ident(schema)}.${ident(table)} LIMIT ${literal(Number(rowLimit))}`, + queryKey: ['rls-sandbox-seed', schema, table], + }, + signal + ) + return { schema, table, rows: (result ?? []) as Record[] } + } catch { + return { schema, table, rows: [] } + } +} + +const SEED_CONCURRENCY = 8 + +export async function getProjectSeedData( + { projectRef, connectionString, tables, rowLimit }: Variables, + signal?: AbortSignal +): Promise { + const results: TableSeedData[] = [] + const queue = tables.slice() + const workers = Array.from({ length: Math.min(SEED_CONCURRENCY, queue.length) }, async () => { + while (queue.length > 0) { + const entry = queue.shift() + if (!entry) break + results.push( + await fetchTableSeed( + { + projectRef, + connectionString, + schema: entry.schema, + table: entry.table, + columns: entry.columns, + rowLimit, + }, + signal + ) + ) + } + }) + await Promise.all(workers) + return results.filter((t) => t.rows.length > 0) +} + +export type ProjectSeedDataData = TableSeedData[] diff --git a/apps/studio/package.json b/apps/studio/package.json index 39b6703f675..6a401404d68 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -43,6 +43,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@electric-sql/pglite": "0.4.5", + "@electric-sql/pglite-tools": "^0.3.4", "@graphiql/react": "^0.37.3", "@graphiql/toolkit": "^0.11.3", "@hcaptcha/react-hcaptcha": "^1.12.0", diff --git a/apps/studio/state/postgres-sandbox/pglite.worker.ts b/apps/studio/state/postgres-sandbox/pglite.worker.ts new file mode 100644 index 00000000000..ab08903f83d --- /dev/null +++ b/apps/studio/state/postgres-sandbox/pglite.worker.ts @@ -0,0 +1,13 @@ +import { PGlite } from '@electric-sql/pglite' +import { pgcrypto } from '@electric-sql/pglite/contrib/pgcrypto' +import { uuid_ossp } from '@electric-sql/pglite/contrib/uuid_ossp' +import { worker } from '@electric-sql/pglite/worker' + +worker({ + async init() { + return new PGlite({ + dataDir: 'memory://', + extensions: { pgcrypto, uuid_ossp }, + }) + }, +}) diff --git a/apps/studio/state/postgres-sandbox/sandbox.constants.ts b/apps/studio/state/postgres-sandbox/sandbox.constants.ts new file mode 100644 index 00000000000..91e9f667507 --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.constants.ts @@ -0,0 +1,162 @@ +// ALTER ROLE postgres SUPERUSER succeeds because the bootstrap connection owns the cluster. +// Each statement is individual so a single failure cannot abort the rest. +export const SANDBOX_SETUP_STATEMENTS = [ + `ALTER ROLE postgres SUPERUSER`, + `DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'dashboard_user') THEN + CREATE ROLE dashboard_user NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'pgbouncer') THEN + CREATE ROLE pgbouncer NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_admin') THEN + CREATE ROLE supabase_admin NOLOGIN; + END IF; + END $$`, + `ALTER ROLE service_role BYPASSRLS`, + `GRANT anon TO postgres WITH ADMIN OPTION`, + `GRANT authenticated TO postgres WITH ADMIN OPTION`, + `GRANT service_role TO postgres WITH ADMIN OPTION`, + `GRANT CONNECT ON DATABASE postgres TO anon, authenticated, service_role`, + `CREATE SCHEMA IF NOT EXISTS auth`, + `GRANT USAGE ON SCHEMA auth TO anon, authenticated, service_role`, + `GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role`, + // Read both the per-claim setting (request.jwt.claim.) and the JSON blob + // (request.jwt.claims) so these work whether the caller uses Studio's role + // impersonation (sets the JSON blob) or PostgREST-style per-claim settings. + // Mirrors how the real Supabase auth.* helpers are defined. + `CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.sub', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub') + )::uuid $fn$`, + `CREATE OR REPLACE FUNCTION auth.role() RETURNS text LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.role', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role'), + 'anon' + ) $fn$`, + `CREATE OR REPLACE FUNCTION auth.email() RETURNS text LANGUAGE sql STABLE AS + $fn$ SELECT COALESCE( + NULLIF(current_setting('request.jwt.claim.email', true), ''), + (NULLIF(current_setting('request.jwt.claims', true), '')::jsonb ->> 'email') + ) $fn$`, + `GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated, service_role`, + `GRANT EXECUTE ON FUNCTION auth.role() TO anon, authenticated, service_role`, + `GRANT EXECUTE ON FUNCTION auth.email() TO anon, authenticated, service_role`, + // Minimal auth table stubs — enough for FK references and policy expressions. + // Projects commonly have FKs to auth.users from public schema tables (e.g. profiles), + // so without this stub those tables fail to create and their policies can't be tested. + `CREATE TABLE IF NOT EXISTS auth.users ( + instance_id uuid, + id uuid NOT NULL PRIMARY KEY, + aud varchar(255), + role varchar(255), + email varchar(255), + encrypted_password varchar(255), + email_confirmed_at timestamptz, + invited_at timestamptz, + confirmation_token varchar(255), + confirmation_sent_at timestamptz, + recovery_token varchar(255), + recovery_sent_at timestamptz, + email_change_token_new varchar(255), + email_change varchar(255), + email_change_sent_at timestamptz, + last_sign_in_at timestamptz, + raw_app_meta_data jsonb, + raw_user_meta_data jsonb, + is_super_admin boolean, + created_at timestamptz, + updated_at timestamptz, + phone text DEFAULT NULL, + phone_confirmed_at timestamptz, + phone_change text DEFAULT '', + phone_change_token varchar(255) DEFAULT '', + phone_change_sent_at timestamptz, + confirmed_at timestamptz, + email_change_token_current varchar(255) DEFAULT '', + email_change_confirm_status smallint DEFAULT 0, + banned_until timestamptz, + reauthentication_token varchar(255) DEFAULT '', + reauthentication_sent_at timestamptz, + is_sso_user boolean NOT NULL DEFAULT false, + deleted_at timestamptz, + is_anonymous boolean NOT NULL DEFAULT false + )`, + `CREATE TABLE IF NOT EXISTS auth.sessions ( + id uuid NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + created_at timestamptz, + updated_at timestamptz, + factor_id uuid, + aal text, + not_after timestamptz, + refreshed_at timestamp, + user_agent text, + ip inet, + tag text + )`, + `CREATE TABLE IF NOT EXISTS auth.mfa_factors ( + id uuid NOT NULL PRIMARY KEY, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + friendly_name text, + factor_type text NOT NULL, + status text NOT NULL, + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + secret text, + phone text, + last_challenged_at timestamptz, + web_authn_credential jsonb, + web_authn_aaguid uuid + )`, + `GRANT SELECT, INSERT, UPDATE, DELETE ON auth.users, auth.sessions, auth.mfa_factors TO anon, authenticated, service_role`, +] + +// Seeded alongside public tables so FK references from public → auth.users +// resolve to real rows. rls flags are ignored here — auth.users is set up by +// SANDBOX_SETUP_STATEMENTS, this entry is only used by the seed step. +// +// Columns are an explicit allow-list: enough to evaluate realistic RLS +// policies (id for FK matching, role/email/metadata for claim-style checks) +// while keeping secrets out of the browser-side PGlite instance — no +// encrypted_password, no *_token columns. +export const AUTH_USERS_SEED_TABLE = { + schema: 'auth', + table: 'users', + rls_enabled: false, + rls_forced: false, + columns: [ + 'id', + 'aud', + 'role', + 'email', + 'phone', + 'email_confirmed_at', + 'phone_confirmed_at', + 'last_sign_in_at', + 'confirmed_at', + 'raw_app_meta_data', + 'raw_user_meta_data', + 'is_super_admin', + 'is_sso_user', + 'is_anonymous', + 'banned_until', + 'deleted_at', + 'created_at', + 'updated_at', + ], +} as const diff --git a/apps/studio/state/postgres-sandbox/sandbox.core.ts b/apps/studio/state/postgres-sandbox/sandbox.core.ts new file mode 100644 index 00000000000..5751ecdb646 --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.core.ts @@ -0,0 +1,74 @@ +import { PGliteWorker } from '@electric-sql/pglite/worker' + +import { SANDBOX_SETUP_STATEMENTS } from './sandbox.constants' +import { applySchema, applySeed } from './sandbox.utils' +import { type DatabaseSchemaDDLData } from '@/data/rls-tester/get-schema-ddl' +import { type TableSeedData } from '@/data/rls-tester/get-seed-data' +import { getErrorMessage } from '@/lib/get-error-message' + +type RLSTestResult = Record[] + +export interface SandboxCore { + setSchema(data: DatabaseSchemaDDLData): Promise + setSeed(tables: TableSeedData[]): Promise + destroy(): Promise + run: (props: { sql: string }) => Promise<{ result: RLSTestResult }> +} + +let instance: SandboxCore | null = null +let initPromise: Promise | null = null + +export const getSandboxCore = async () => { + if (instance) return instance + if (!initPromise) { + initPromise = boot().finally(() => { + initPromise = null + }) + } + return initPromise +} + +const boot = async (): Promise => { + const webWorker = new Worker(new URL('./pglite.worker.ts', import.meta.url), { type: 'module' }) + const pg = await PGliteWorker.create(webWorker) + + for (const sql of SANDBOX_SETUP_STATEMENTS) { + try { + await pg.exec(sql) + } catch (err) { + console.warn('[Postgres sandbox] setup:', (err as Error).message, `— ${sql.slice(0, 60)}`) + } + } + + function makeExecutor() { + return { execSql: (sql: string) => pg.exec(sql).then(() => undefined as void) } + } + + async function setSchema(data: DatabaseSchemaDDLData): Promise { + await applySchema(makeExecutor(), data) + } + + async function setSeed(tables: TableSeedData[]): Promise { + await applySeed(makeExecutor(), tables) + } + + const run = async ({ sql }: { sql: string }) => { + try { + // [Joshen] First 2 results will be from role impersonation, the actual result from the + // query will be returned as the 3rd result. + const results = await pg.exec(sql) + return { result: results[2].rows ?? [] } + } catch (error) { + await pg.exec('ROLLBACK').catch(() => {}) + throw error instanceof Error ? error : new Error(getErrorMessage(error) ?? String(error)) + } + } + + const destroy = async () => { + webWorker.terminate() + instance = null + } + + instance = { run, destroy, setSchema, setSeed } + return instance +} diff --git a/apps/studio/state/postgres-sandbox/sandbox.tsx b/apps/studio/state/postgres-sandbox/sandbox.tsx new file mode 100644 index 00000000000..f6a7e1b447a --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.tsx @@ -0,0 +1,143 @@ +import { noop } from 'lodash' +import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' +import { toast } from 'sonner' + +import { AUTH_USERS_SEED_TABLE } from './sandbox.constants' +import { getSandboxCore, type SandboxCore } from './sandbox.core' +import { getDatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' +import { getProjectSeedData, TableSeedData } from '@/data/rls-tester/get-seed-data' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { getErrorMessage } from '@/lib/get-error-message' + +type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error' + +const SandboxContext = createContext<{ + status: SandboxStatus + error?: string + sandbox: SandboxCore | null + isSyncing: boolean + startSandbox: () => void + destroySandbox: () => Promise + syncSandbox: () => Promise +}>({ + status: 'idle', + error: undefined, + sandbox: null, + isSyncing: false, + startSandbox: noop, + destroySandbox: async () => {}, + syncSandbox: async () => {}, +}) + +export const PostgresSandboxProvider = ({ children }: PropsWithChildren) => { + const { data: project } = useSelectedProjectQuery() + + const [start, setStart] = useState(false) + const [error, setError] = useState() + const [sandbox, setSandbox] = useState(null) + + const [status, setStatus] = useState('idle') + const [isSyncing, setIsSyncing] = useState(false) + + const destroySandbox = async () => { + if (isSyncing) return + if (!sandbox) return console.error('Sandbox is not set up') + + await sandbox.destroy() + setSandbox(null) + setStatus('idle') + setError(undefined) + setStart(false) + } + + // Internal — takes the target explicitly so the boot path can pass the + // freshly booted core before React state has caught up. Callers outside + // the provider use `syncSandbox()` which sources the target from state. + const applyToCore = async (target: SandboxCore) => { + setIsSyncing(true) + + try { + const schemaDDL = await getDatabaseSchemaDDL({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: ['public'], + }) + + const seedData: TableSeedData[] = await getProjectSeedData({ + projectRef: project?.ref, + connectionString: project?.connectionString, + tables: [AUTH_USERS_SEED_TABLE, ...(schemaDDL.rlsStatuses ?? [])], + rowLimit: 100, + }) + + await target.setSchema(schemaDDL) + await target.setSeed(seedData) + } catch (e) { + const message = getErrorMessage(e) ?? String(e) + if (sandbox) { + // Refresh path — sandbox is still usable with the previous schema/data. + toast.error(`Failed to refresh sandbox: ${message}`) + } else { + // Boot path — propagate so the outer .catch sets status='error' and + // the SandboxManagement error branch renders. + throw e + } + } finally { + setIsSyncing(false) + } + } + + const syncSandbox = async () => { + if (isSyncing) return + if (!sandbox) return console.error('Sandbox has not been loaded') + await applyToCore(sandbox) + } + + useEffect(() => { + if (!start) return + + let cancelled = false + setStatus('loading') + + getSandboxCore() + .then(async (core) => { + if (cancelled) return + + await applyToCore(core) + setSandbox(core) + setStatus('ready') + }) + .catch((error) => { + if (cancelled) return + + setError(getErrorMessage(error) ?? '') + setStatus('error') + setStart(false) + }) + + return () => { + cancelled = true + } + // applyToCore intentionally omitted: this effect should fire once when + // `start` flips, not every time the helper identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [start]) + + return ( + setStart(true), + destroySandbox, + syncSandbox, + }} + > + {children} + + ) +} + +export const usePostgresSandbox = () => useContext(SandboxContext) diff --git a/apps/studio/state/postgres-sandbox/sandbox.utils.ts b/apps/studio/state/postgres-sandbox/sandbox.utils.ts new file mode 100644 index 00000000000..d92b4a0cb8c --- /dev/null +++ b/apps/studio/state/postgres-sandbox/sandbox.utils.ts @@ -0,0 +1,210 @@ +import { ident, literal, type PGPolicy } from '@supabase/pg-meta' + +import { DatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl' +import { TableSeedData } from '@/data/rls-tester/get-seed-data' +import { getErrorMessage } from '@/lib/get-error-message' + +interface Executor { + execSql(sql: string): Promise +} + +function buildPolicySQL(policy: PGPolicy): string { + const name = ident(policy.name) + const target = `${ident(policy.schema)}.${ident(policy.table)}` + const permissiveness = policy.action === 'RESTRICTIVE' ? 'AS RESTRICTIVE' : '' + const command = policy.command === 'ALL' ? '' : `FOR ${policy.command}` + const roles = policy.roles?.length ? `TO ${policy.roles.map(ident).join(', ')}` : '' + const using = policy.definition ? `USING (${policy.definition})` : '' + const withCheck = policy.check ? `WITH CHECK (${policy.check})` : '' + + const drop = `DROP POLICY IF EXISTS ${name} ON ${target}` + const create = [ + `CREATE POLICY ${name}`, + `ON ${target}`, + permissiveness, + command, + roles, + using, + withCheck, + ] + .filter(Boolean) + .join(' ') + return `${drop}; ${create}` +} + +async function tryExec(sandbox: Executor, sql: string, label: string): Promise { + try { + await sandbox.execSql(sql) + } catch (err) { + console.warn(`[rls-sandbox] skipped ${label}:`, getErrorMessage(err) ?? err) + } +} + +// Retry items until no further progress can be made — handles ordering +// dependencies (e.g. table A references type B that hasn't been created yet). +// Each pass attempts every pending item; survivors carry forward. When a full +// pass makes zero progress, surviving items are reported as unresolved. +async function runUntilFixpoint( + items: T[], + attempt: (item: T) => Promise, + onUnresolved: (item: T, error: unknown) => void +): Promise { + let pending = items.slice() + while (pending.length > 0) { + const failed: Array<{ item: T; error: unknown }> = [] + for (const item of pending) { + try { + await attempt(item) + } catch (error) { + failed.push({ item, error }) + } + } + if (failed.length === pending.length) { + for (const { item, error } of failed) onUnresolved(item, error) + break + } + pending = failed.map((f) => f.item) + } +} + +async function applyDDLWithRetries(sandbox: Executor, ddlStatements: string[]): Promise { + await runUntilFixpoint( + ddlStatements, + (ddl) => sandbox.execSql(ddl), + (ddl, error) => + console.warn( + `[rls-sandbox] skipped DDL: ${ddl.slice(0, 80).replace(/\s+/g, ' ')} — ${getErrorMessage(error) ?? String(error)}` + ) + ) +} + +export async function applySchema( + sandbox: Executor, + { + schemas, + typeDefinitions, + entityDefinitions, + functionDefinitions, + policies, + rlsStatuses, + customRoles, + }: DatabaseSchemaDDL +): Promise { + // Reset each user schema so re-syncs pick up renames/drops/column changes and + // CREATE statements don't collide with the previous run's objects. + for (const schema of schemas) { + const schemaId = ident(schema) + await tryExec(sandbox, `DROP SCHEMA IF EXISTS ${schemaId} CASCADE`, `drop schema ${schema}`) + await tryExec(sandbox, `CREATE SCHEMA ${schemaId}`, `create schema ${schema}`) + await tryExec( + sandbox, + `GRANT USAGE ON SCHEMA ${schemaId} TO anon, authenticated, service_role`, + `grant schema ${schema}` + ) + } + + if (customRoles.length > 0) { + const checks = customRoles + .map( + ({ name }) => + `IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${literal(name)}) THEN CREATE ROLE ${ident(name)} NOLOGIN; END IF;` + ) + .join('\n') + await tryExec(sandbox, `DO $$ BEGIN\n${checks}\nEND $$`, 'custom roles') + } + + await applyDDLWithRetries(sandbox, typeDefinitions) + await applyDDLWithRetries(sandbox, entityDefinitions) + + for (const schema of [...new Set(rlsStatuses.map((t) => t.schema))]) { + await tryExec( + sandbox, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} TO anon, authenticated, service_role`, + `grant tables in schema ${schema}` + ) + } + + for (const { schema, table, rls_enabled, rls_forced } of rlsStatuses) { + const actions: string[] = [] + if (rls_enabled) actions.push('ENABLE ROW LEVEL SECURITY') + if (rls_forced) actions.push('FORCE ROW LEVEL SECURITY') + if (actions.length === 0) continue + await tryExec( + sandbox, + `ALTER TABLE ${ident(schema)}.${ident(table)} ${actions.join(', ')}`, + `RLS on ${schema}.${table}` + ) + } + + // Disable check_function_bodies so functions referencing not-yet-created objects don't abort. + // Postgres resolves policy→function references at query time, not at CREATE POLICY time. + await tryExec(sandbox, `SET check_function_bodies = off`, 'set check_function_bodies') + for (const fn of functionDefinitions) { + await tryExec(sandbox, fn, `function ${fn.slice(0, 60).replace(/\s+/g, ' ')}`) + } + await tryExec(sandbox, `RESET check_function_bodies`, 'reset check_function_bodies') + + for (const policy of policies) { + await tryExec( + sandbox, + buildPolicySQL(policy), + `policy ${policy.schema}.${policy.table} "${policy.name}"` + ) + } +} + +function serializeValue(val: unknown): string { + if (val === null || val === undefined) return 'NULL' + if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE' + if (typeof val === 'number') return String(val) + if (val instanceof Date) return `'${val.toISOString()}'` + if (Array.isArray(val)) return `ARRAY[${val.map(serializeValue).join(', ')}]` + if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb` + return `'${String(val).replace(/'/g, "''")}'` +} + +function buildInsertSQL(schema: string, table: string, rows: Record[]): string { + if (rows.length === 0) throw new Error(`buildInsertSQL requires at least one row`) + const columns = Object.keys(rows[0]) + const colList = columns.map((c) => ident(c)).join(', ') + const valuesList = rows + .map((row) => `(${columns.map((c) => serializeValue(row[c])).join(', ')})`) + .join(',\n ') + return `INSERT INTO ${ident(schema)}.${ident(table)} (${colList}) VALUES\n ${valuesList};` +} + +export async function applySeed(sandbox: Executor, tables: TableSeedData[]): Promise { + // Disable FK triggers so we can delete and re-insert in any order. + // Requires superuser (ALTER ROLE postgres SUPERUSER in SANDBOX_SETUP_STATEMENTS). + // Falls back gracefully if the privilege is not available. + let triggersDisabled = false + try { + await sandbox.execSql(`SET session_replication_role = replica`) + triggersDisabled = true + } catch { + // postgres not yet a superuser in this PGlite build — proceed without it + } + + try { + // Always clear before inserting so re-seed reflects the latest data. + for (const { schema, table } of tables) { + try { + await sandbox.execSql(`DELETE FROM ${ident(schema)}.${ident(table)}`) + } catch { + // table may not exist yet — ignore + } + } + + // Retry loop handles any remaining FK ordering constraints. + await runUntilFixpoint( + tables.filter((t) => t.rows.length > 0), + (entry) => sandbox.execSql(buildInsertSQL(entry.schema, entry.table, entry.rows)), + (entry) => + console.warn(`[rls-sandbox] seed skipped ${entry.schema}.${entry.table}: unresolved FK`) + ) + } finally { + if (triggersDisabled) { + await sandbox.execSql(`SET session_replication_role = DEFAULT`) + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 400fb29d026..e2b4aa92deb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -890,6 +890,12 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.6) + '@electric-sql/pglite': + specifier: 0.4.5 + version: 0.4.5 + '@electric-sql/pglite-tools': + specifier: ^0.3.4 + version: 0.3.5(@electric-sql/pglite@0.4.5) '@graphiql/react': specifier: ^0.37.3 version: 0.37.3(@emotion/is-prop-valid@1.4.0)(@types/node@22.13.14)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(immer@10.1.1)(react-compiler-runtime@19.1.0-rc.1(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) @@ -1912,7 +1918,7 @@ importers: version: 0.562.0(vue@3.5.30(typescript@6.0.2)) nuxt: specifier: ^4.4.0 - version: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + version: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -3409,8 +3415,13 @@ packages: '@effect-ts/system@0.57.5': resolution: {integrity: sha512-/crHGujo0xnuHIYNc1VgP0HGJGFSoSqq88JFXe6FmFyXPpWt8Xu39LyLg7rchsxfXFeEdA9CrIZvLV5eswXV5g==} - '@electric-sql/pglite@0.2.15': - resolution: {integrity: sha512-Jiq31Dnk+rg8rMhcSxs4lQvHTyizNo5b269c1gCC3ldQ0sCLrNVPGzy+KnmonKy1ZArTUuXZf23/UamzFMKVaA==} + '@electric-sql/pglite-tools@0.3.5': + resolution: {integrity: sha512-4him0RnIyqrSqk0zzeBJKJ++VVFk6bFCwKSVV7DP3T8fOQgRSVZShlrHfAChLG2uA/T4JdQ8b/15CxBn+E34TQ==} + peerDependencies: + '@electric-sql/pglite': 0.4.5 + + '@electric-sql/pglite@0.4.5': + resolution: {integrity: sha512-aGG2zGEyZzGWKy8P+9ZoNUV0jxt1+hgbeTf+bVAYyxVZZLXg3/9aFlfLxb08AYZVAfAkQlQIysmWjhc5hwDG8g==} '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -18994,8 +19005,11 @@ snapshots: '@effect-ts/system@0.57.5': {} - '@electric-sql/pglite@0.2.15': - optional: true + '@electric-sql/pglite-tools@0.3.5(@electric-sql/pglite@0.4.5)': + dependencies: + '@electric-sql/pglite': 0.4.5 + + '@electric-sql/pglite@0.4.5': {} '@emnapi/core@1.9.2': dependencies: @@ -20725,7 +20739,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/nitro-server@4.4.2(cad3000854b121359e207ff30aa7ffec)': + '@nuxt/nitro-server@4.4.2(2a462464d62fb8b7515044774e7d5187)': dependencies: '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0(supports-color@8.1.1)) '@nuxt/devalue': 2.0.2 @@ -20743,8 +20757,8 @@ snapshots: impound: 1.1.5 klona: 2.0.6 mocked-exports: 0.1.1 - nitropack: 2.13.4(@electric-sql/pglite@0.2.15)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1) - nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + nitropack: 2.13.4(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1) + nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) nypm: 0.6.5 ohash: 2.0.11 pathe: 2.0.3 @@ -20753,7 +20767,7 @@ snapshots: std-env: 4.1.0 ufo: 1.6.3 unctx: 2.5.0 - unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)) + unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)) vue: 3.5.30(typescript@6.0.2) vue-bundle-renderer: 2.2.0 vue-devtools-stub: 0.1.0 @@ -20809,7 +20823,7 @@ snapshots: rc9: 3.0.0 std-env: 3.10.0 - '@nuxt/vite-builder@4.4.2(034982d584f76f7f1b97787c79a9bfb8)': + '@nuxt/vite-builder@4.4.2(d040f269d3a8e69eae621831338741ff)': dependencies: '@nuxt/kit': 4.4.2(magicast@0.5.2) '@rollup/plugin-replace': 6.0.3(rollup@4.60.3) @@ -20827,7 +20841,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.1 mocked-exports: 0.1.1 - nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) + nuxt: 4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3) nypm: 0.6.5 pathe: 2.0.3 pkg-types: 2.3.0 @@ -26895,9 +26909,9 @@ snapshots: dayjs@1.11.20: {} - db0@0.3.4(@electric-sql/pglite@0.2.15): + db0@0.3.4(@electric-sql/pglite@0.4.5): optionalDependencies: - '@electric-sql/pglite': 0.2.15 + '@electric-sql/pglite': 0.4.5 dc-browser@1.0.4: {} @@ -31314,7 +31328,7 @@ snapshots: nice-try@1.0.5: {} - nitropack@2.13.4(@electric-sql/pglite@0.2.15)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1): + nitropack@2.13.4(@electric-sql/pglite@0.4.5)(aws4fetch@1.0.20)(encoding@0.1.13)(oxc-parser@0.117.0)(rolldown@1.0.0-rc.15)(supports-color@8.1.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@rollup/plugin-alias': 6.0.0(rollup@4.60.3) @@ -31335,7 +31349,7 @@ snapshots: cookie-es: 2.0.1 croner: 10.0.1 crossws: 0.3.5 - db0: 0.3.4(@electric-sql/pglite@0.2.15) + db0: 0.3.4(@electric-sql/pglite@0.4.5) defu: 6.1.7 destr: 2.0.5 dot-prop: 10.1.0 @@ -31381,7 +31395,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 6.2.0(oxc-parser@0.117.0) unplugin-utils: 0.3.1 - unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)) + unstorage: 1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)) untyped: 2.0.0 unwasm: 0.5.3 youch: 4.1.1 @@ -31591,16 +31605,16 @@ snapshots: next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(sass@1.77.4) react-router: 7.13.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): + nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.4.5)(@parcel/watcher@2.5.6)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.5))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.1(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.0-rc.15)(rollup@4.60.3))(rollup@4.60.3)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): dependencies: '@dxup/nuxt': 0.4.0(magicast@0.5.2)(typescript@6.0.2) '@nuxt/cli': 3.34.0(@nuxt/schema@4.4.2)(cac@6.7.14)(magicast@0.5.2)(supports-color@8.1.1) '@nuxt/devtools': 3.2.4(supports-color@8.1.1)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(vue@3.5.30(typescript@6.0.2)) '@nuxt/kit': 4.4.2(magicast@0.5.2) - '@nuxt/nitro-server': 4.4.2(cad3000854b121359e207ff30aa7ffec) + '@nuxt/nitro-server': 4.4.2(2a462464d62fb8b7515044774e7d5187) '@nuxt/schema': 4.4.2 '@nuxt/telemetry': 2.7.0(@nuxt/kit@4.4.2(magicast@0.5.2)) - '@nuxt/vite-builder': 4.4.2(034982d584f76f7f1b97787c79a9bfb8) + '@nuxt/vite-builder': 4.4.2(d040f269d3a8e69eae621831338741ff) '@unhead/vue': 2.1.12(vue@3.5.30(typescript@6.0.2)) '@vue/shared': 3.5.30 c12: 3.3.4(magicast@0.5.2) @@ -35157,7 +35171,7 @@ snapshots: escape-string-regexp: 5.0.0 ufo: 1.6.3 - unstorage@1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.2.15))(ioredis@5.10.1(supports-color@8.1.1)): + unstorage@1.17.5(aws4fetch@1.0.20)(db0@0.3.4(@electric-sql/pglite@0.4.5))(ioredis@5.10.1(supports-color@8.1.1)): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -35169,7 +35183,7 @@ snapshots: ufo: 1.6.4 optionalDependencies: aws4fetch: 1.0.20 - db0: 0.3.4(@electric-sql/pglite@0.2.15) + db0: 0.3.4(@electric-sql/pglite@0.4.5) ioredis: 5.10.1(supports-color@8.1.1) until-async@3.0.2: {}