sandbox.utils.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. import { ident, literal, type PGPolicy } from '@supabase/pg-meta'
  2. import { DatabaseSchemaDDL } from '@/data/rls-tester/get-schema-ddl'
  3. import { TableSeedData } from '@/data/rls-tester/get-seed-data'
  4. import { getErrorMessage } from '@/lib/get-error-message'
  5. interface Executor {
  6. execSql(sql: string): Promise<void>
  7. }
  8. function buildPolicySQL(policy: PGPolicy): string {
  9. const name = ident(policy.name)
  10. const target = `${ident(policy.schema)}.${ident(policy.table)}`
  11. const permissiveness = policy.action === 'RESTRICTIVE' ? 'AS RESTRICTIVE' : ''
  12. const command = policy.command === 'ALL' ? '' : `FOR ${policy.command}`
  13. const roles = policy.roles?.length ? `TO ${policy.roles.map(ident).join(', ')}` : ''
  14. const using = policy.definition ? `USING (${policy.definition})` : ''
  15. const withCheck = policy.check ? `WITH CHECK (${policy.check})` : ''
  16. const drop = `DROP POLICY IF EXISTS ${name} ON ${target}`
  17. const create = [
  18. `CREATE POLICY ${name}`,
  19. `ON ${target}`,
  20. permissiveness,
  21. command,
  22. roles,
  23. using,
  24. withCheck,
  25. ]
  26. .filter(Boolean)
  27. .join(' ')
  28. return `${drop}; ${create}`
  29. }
  30. async function tryExec(sandbox: Executor, sql: string, label: string): Promise<void> {
  31. try {
  32. await sandbox.execSql(sql)
  33. } catch (err) {
  34. console.warn(`[rls-sandbox] skipped ${label}:`, getErrorMessage(err) ?? err)
  35. }
  36. }
  37. // Retry items until no further progress can be made — handles ordering
  38. // dependencies (e.g. table A references type B that hasn't been created yet).
  39. // Each pass attempts every pending item; survivors carry forward. When a full
  40. // pass makes zero progress, surviving items are reported as unresolved.
  41. async function runUntilFixpoint<T>(
  42. items: T[],
  43. attempt: (item: T) => Promise<void>,
  44. onUnresolved: (item: T, error: unknown) => void
  45. ): Promise<void> {
  46. let pending = items.slice()
  47. while (pending.length > 0) {
  48. const failed: Array<{ item: T; error: unknown }> = []
  49. for (const item of pending) {
  50. try {
  51. await attempt(item)
  52. } catch (error) {
  53. failed.push({ item, error })
  54. }
  55. }
  56. if (failed.length === pending.length) {
  57. for (const { item, error } of failed) onUnresolved(item, error)
  58. break
  59. }
  60. pending = failed.map((f) => f.item)
  61. }
  62. }
  63. async function applyDDLWithRetries(sandbox: Executor, ddlStatements: string[]): Promise<void> {
  64. await runUntilFixpoint(
  65. ddlStatements,
  66. (ddl) => sandbox.execSql(ddl),
  67. (ddl, error) =>
  68. console.warn(
  69. `[rls-sandbox] skipped DDL: ${ddl.slice(0, 80).replace(/\s+/g, ' ')} — ${getErrorMessage(error) ?? String(error)}`
  70. )
  71. )
  72. }
  73. export async function applySchema(
  74. sandbox: Executor,
  75. {
  76. schemas,
  77. typeDefinitions,
  78. entityDefinitions,
  79. functionDefinitions,
  80. policies,
  81. rlsStatuses,
  82. customRoles,
  83. }: DatabaseSchemaDDL
  84. ): Promise<void> {
  85. // Reset each user schema so re-syncs pick up renames/drops/column changes and
  86. // CREATE statements don't collide with the previous run's objects.
  87. for (const schema of schemas) {
  88. const schemaId = ident(schema)
  89. await tryExec(sandbox, `DROP SCHEMA IF EXISTS ${schemaId} CASCADE`, `drop schema ${schema}`)
  90. await tryExec(sandbox, `CREATE SCHEMA ${schemaId}`, `create schema ${schema}`)
  91. await tryExec(
  92. sandbox,
  93. `GRANT USAGE ON SCHEMA ${schemaId} TO anon, authenticated, service_role`,
  94. `grant schema ${schema}`
  95. )
  96. }
  97. if (customRoles.length > 0) {
  98. const checks = customRoles
  99. .map(
  100. ({ name }) =>
  101. `IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ${literal(name)}) THEN CREATE ROLE ${ident(name)} NOLOGIN; END IF;`
  102. )
  103. .join('\n')
  104. await tryExec(sandbox, `DO $$ BEGIN\n${checks}\nEND $$`, 'custom roles')
  105. }
  106. await applyDDLWithRetries(sandbox, typeDefinitions)
  107. await applyDDLWithRetries(sandbox, entityDefinitions)
  108. for (const schema of [...new Set(rlsStatuses.map((t) => t.schema))]) {
  109. await tryExec(
  110. sandbox,
  111. `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${ident(schema)} TO anon, authenticated, service_role`,
  112. `grant tables in schema ${schema}`
  113. )
  114. }
  115. for (const { schema, table, rls_enabled, rls_forced } of rlsStatuses) {
  116. const actions: string[] = []
  117. if (rls_enabled) actions.push('ENABLE ROW LEVEL SECURITY')
  118. if (rls_forced) actions.push('FORCE ROW LEVEL SECURITY')
  119. if (actions.length === 0) continue
  120. await tryExec(
  121. sandbox,
  122. `ALTER TABLE ${ident(schema)}.${ident(table)} ${actions.join(', ')}`,
  123. `RLS on ${schema}.${table}`
  124. )
  125. }
  126. // Disable check_function_bodies so functions referencing not-yet-created objects don't abort.
  127. // Postgres resolves policy→function references at query time, not at CREATE POLICY time.
  128. await tryExec(sandbox, `SET check_function_bodies = off`, 'set check_function_bodies')
  129. for (const fn of functionDefinitions) {
  130. await tryExec(sandbox, fn, `function ${fn.slice(0, 60).replace(/\s+/g, ' ')}`)
  131. }
  132. await tryExec(sandbox, `RESET check_function_bodies`, 'reset check_function_bodies')
  133. for (const policy of policies) {
  134. await tryExec(
  135. sandbox,
  136. buildPolicySQL(policy),
  137. `policy ${policy.schema}.${policy.table} "${policy.name}"`
  138. )
  139. }
  140. }
  141. function serializeValue(val: unknown): string {
  142. if (val === null || val === undefined) return 'NULL'
  143. if (typeof val === 'boolean') return val ? 'TRUE' : 'FALSE'
  144. if (typeof val === 'number') return String(val)
  145. if (val instanceof Date) return `'${val.toISOString()}'`
  146. if (Array.isArray(val)) return `ARRAY[${val.map(serializeValue).join(', ')}]`
  147. if (typeof val === 'object') return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb`
  148. return `'${String(val).replace(/'/g, "''")}'`
  149. }
  150. function buildInsertSQL(schema: string, table: string, rows: Record<string, unknown>[]): string {
  151. if (rows.length === 0) throw new Error(`buildInsertSQL requires at least one row`)
  152. const columns = Object.keys(rows[0])
  153. const colList = columns.map((c) => ident(c)).join(', ')
  154. const valuesList = rows
  155. .map((row) => `(${columns.map((c) => serializeValue(row[c])).join(', ')})`)
  156. .join(',\n ')
  157. return `INSERT INTO ${ident(schema)}.${ident(table)} (${colList}) VALUES\n ${valuesList};`
  158. }
  159. export async function applySeed(sandbox: Executor, tables: TableSeedData[]): Promise<void> {
  160. // Disable FK triggers so we can delete and re-insert in any order.
  161. // Requires superuser (ALTER ROLE postgres SUPERUSER in SANDBOX_SETUP_STATEMENTS).
  162. // Falls back gracefully if the privilege is not available.
  163. let triggersDisabled = false
  164. try {
  165. await sandbox.execSql(`SET session_replication_role = replica`)
  166. triggersDisabled = true
  167. } catch {
  168. // postgres not yet a superuser in this PGlite build — proceed without it
  169. }
  170. try {
  171. // Always clear before inserting so re-seed reflects the latest data.
  172. for (const { schema, table } of tables) {
  173. try {
  174. await sandbox.execSql(`DELETE FROM ${ident(schema)}.${ident(table)}`)
  175. } catch {
  176. // table may not exist yet — ignore
  177. }
  178. }
  179. // Retry loop handles any remaining FK ordering constraints.
  180. await runUntilFixpoint(
  181. tables.filter((t) => t.rows.length > 0),
  182. (entry) => sandbox.execSql(buildInsertSQL(entry.schema, entry.table, entry.rows)),
  183. (entry) =>
  184. console.warn(`[rls-sandbox] seed skipped ${entry.schema}.${entry.table}: unresolved FK`)
  185. )
  186. } finally {
  187. if (triggersDisabled) {
  188. await sandbox.execSql(`SET session_replication_role = DEFAULT`)
  189. }
  190. }
  191. }