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: {}