@rebasepro/server-postgres 0.17.1 → 0.17.2-canary.g4d21a53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{backup-service-BtgHxfFm.js → backup-service-DCk7KhhL.js} +3 -3
- package/dist/{backup-service-BtgHxfFm.js.map → backup-service-DCk7KhhL.js.map} +1 -1
- package/dist/cli-helpers.d.ts +26 -14
- package/dist/{collection-index-DxJBvVTH.js → collection-index-BRUg10H5.js} +2 -2
- package/dist/{collection-index-DxJBvVTH.js.map → collection-index-BRUg10H5.js.map} +1 -1
- package/dist/{ensure-collection-policies-BHk4TRuf.js → ensure-collection-policies-UCqgv_8c.js} +4 -4
- package/dist/{ensure-collection-policies-BHk4TRuf.js.map → ensure-collection-policies-UCqgv_8c.js.map} +1 -1
- package/dist/{ensure-collection-tables-DMjOkeRy.js → ensure-collection-tables-DgVixhX3.js} +256 -38
- package/dist/ensure-collection-tables-DgVixhX3.js.map +1 -0
- package/dist/index.es.js +12 -12
- package/dist/{rls-bootstrap-sql-DNzaWd4C.js → rls-bootstrap-sql-B5C9LoJ6.js} +2 -2
- package/dist/{rls-bootstrap-sql-DNzaWd4C.js.map → rls-bootstrap-sql-B5C9LoJ6.js.map} +1 -1
- package/dist/{rls-enforcement-C1RJ1uI2.js → rls-enforcement-DvAbL9YJ.js} +3 -3
- package/dist/{rls-enforcement-C1RJ1uI2.js.map → rls-enforcement-DvAbL9YJ.js.map} +1 -1
- package/dist/schema/atlas-argv.d.ts +58 -0
- package/dist/schema/carved-out-migration.d.ts +65 -0
- package/dist/schema/ensure-collection-tables.d.ts +13 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +57 -5
- package/dist/schema/vector-index.d.ts +120 -0
- package/dist/{src-DiDgtX8P.js → src-DiB5RP2Z.js} +33 -3
- package/dist/{src-DiDgtX8P.js.map → src-DiB5RP2Z.js.map} +1 -1
- package/dist/{websocket-g1Ji7m4o.js → websocket-BZ4H5wUz.js} +19 -9
- package/dist/websocket-BZ4H5wUz.js.map +1 -0
- package/package.json +6 -6
- package/src/cli-helpers.ts +65 -34
- package/src/cli.ts +168 -71
- package/src/schema/atlas-argv.ts +94 -0
- package/src/schema/carved-out-migration.ts +404 -0
- package/src/schema/ensure-collection-tables.ts +59 -32
- package/src/schema/generate-postgres-ddl-logic.ts +147 -21
- package/src/schema/generate-postgres-ddl.ts +59 -6
- package/src/schema/generate-schema-commit.ts +31 -6
- package/src/schema/vector-index.ts +213 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +0 -1
- package/dist/websocket-g1Ji7m4o.js.map +0 -1
package/dist/{ensure-collection-policies-BHk4TRuf.js → ensure-collection-policies-UCqgv_8c.js}
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createRequire as __createRequire } from "module";
|
|
2
2
|
import "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
|
-
import "./src-
|
|
5
|
-
import {
|
|
6
|
-
import { c as REBASE_USER_ROLE } from "./rls-enforcement-
|
|
4
|
+
import "./src-DiB5RP2Z.js";
|
|
5
|
+
import { l as planCollectionPolicies, r as readExistingSchema } from "./ensure-collection-tables-DgVixhX3.js";
|
|
6
|
+
import { c as REBASE_USER_ROLE } from "./rls-enforcement-DvAbL9YJ.js";
|
|
7
7
|
//#region src/security/policy-drift.ts
|
|
8
8
|
/**
|
|
9
9
|
* Does this name look like one the generator produced for this table?
|
|
@@ -122,4 +122,4 @@ async function ensureCollectionPolicies(client, collections, log) {
|
|
|
122
122
|
//#endregion
|
|
123
123
|
export { ensureCollectionPolicies };
|
|
124
124
|
|
|
125
|
-
//# sourceMappingURL=ensure-collection-policies-
|
|
125
|
+
//# sourceMappingURL=ensure-collection-policies-UCqgv_8c.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ensure-collection-policies-BHk4TRuf.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
|
|
1
|
+
{"version":3,"file":"ensure-collection-policies-UCqgv_8c.js","names":[],"sources":["../src/security/policy-drift.ts","../src/schema/ensure-collection-policies.ts"],"sourcesContent":["/**\n * Compare the RLS policies a database actually has against the ones the\n * collections describe.\n *\n * Policies live in Postgres; the collection config is only their *source*.\n * Nothing reconciled the two, which is how the demo database served empty\n * collections indefinitely: its policies granted `TO authenticated` (a Supabase\n * role name) while requests run as `rebase_user`, so RLS filtered every row.\n * The config was later corrected and the database never noticed — an empty\n * table is indistinguishable from a table with no data.\n *\n * Expected policies are parsed from `generatePostgresPoliciesDdl`, the same\n * function `db push` uses to write `drizzle/policies.sql`, so this compares\n * against exactly what would be applied rather than a reimplementation.\n */\nimport { RLS_UID_SQL, type CollectionConfig } from \"@rebasepro/types\";\n\nimport { generatePostgresPoliciesDdl } from \"../schema/generate-postgres-ddl-logic\";\n\nexport interface PolicyRef {\n schema: string;\n table: string;\n name: string;\n /** Roles in the TO clause. */\n roles: string[];\n /** SELECT / INSERT / UPDATE / DELETE / ALL. */\n command: string;\n /** Whether a USING clause is present at all (not what it says). */\n hasUsing: boolean;\n /** Whether a WITH CHECK clause is present at all (not what it says). */\n hasWithCheck: boolean;\n /**\n * PERMISSIVE or RESTRICTIVE — the `AS` clause.\n *\n * An exact catalogue value on both sides, so it belongs with roles and\n * command rather than with the expression text. It matters more than either:\n * permissive policies are ORed together and restrictive ones ANDed, so a rule\n * declared `mode: \"restrictive\"` whose live policy is PERMISSIVE has had its\n * gate turned from a requirement into an alternative — the maximally\n * permissive way for this to be wrong.\n *\n * The DDL regex captured this from the start and the destructuring threw it\n * away; `pg_policies.permissive` was never selected.\n */\n mode?: \"PERMISSIVE\" | \"RESTRICTIVE\";\n /**\n * The live clause text, when read from `pg_policies`. Present only for live\n * policies (the expected side is parsed from DDL and does not carry it).\n * Used solely for the insecure-tautology scan, not for divergence — Postgres\n * rewrites this text, so it is not safe to diff against expected.\n */\n qual?: string | null;\n withCheck?: string | null;\n}\n\nexport interface PolicyDrift {\n /** Described by the collections, absent from the database. */\n missing: PolicyRef[];\n /** In the database, described by no collection — stale pushes live here. */\n orphaned: PolicyRef[];\n /** Same policy name, different roles or command. */\n diverged: { expected: PolicyRef; actual: PolicyRef; differences: string[] }[];\n /**\n * A live policy whose expression is the known-permissive tautology\n * `rebase.uid() IS NOT NULL` — true for anonymous visitors too, because the\n * user path coerces a blank id to the `'anonymous'` sentinel. This is what\n * `policy.authenticated()` used to compile to, so a database pushed before\n * that fix carries it, and neither the name, roles, command nor clause\n * *presence* differs from the corrected policy — the only thing that changed\n * is the expression text, which this checker otherwise (correctly) ignores.\n * So it is the one drift that hides from every other check here.\n *\n * @see reason a sentence naming the clause and what to do.\n */\n insecure: { policy: PolicyRef; reason: string }[];\n /**\n * A table the collections describe whose RLS switch is off.\n *\n * `ALTER TABLE posts DISABLE ROW LEVEL SECURITY` leaves every row in\n * `pg_policies` untouched, so before this category every expected policy\n * still matched on name, roles, command and clause presence and the checker\n * reported clean — on a table Postgres was applying no filter to at all.\n * Requests run as `rebase_user`, which holds full DML, so the table is wide\n * open while `doctor` certifies it.\n *\n * `forced` reports `relforcerowsecurity`, which is what also subjects the\n * table's *owner* to its policies. Its absence is not drift on its own —\n * Rebase does not connect as the owner in the request path — so it is\n * reported for context rather than raised as a failure.\n */\n rlsDisabled: { schema: string; table: string; forced: boolean }[];\n}\n\nexport interface Queryable {\n query<R>(text: string, values?: unknown[]): Promise<{ rows: R[] }>;\n}\n\n// The trailing group captures whichever clause follows the TO list, which is\n// what tells us the clause is present.\nconst CREATE_POLICY = /CREATE POLICY \"([^\"]+)\" ON \"([^\"]+)\"\\.\"([^\"]+)\"\\s+AS (\\w+)\\s+FOR (\\w+)\\s+TO ([^\\n]+?)(\\s+USING\\s*\\(|\\s+WITH CHECK\\s*\\(|;)/gi;\n\nconst WITH_CHECK_NEXT = /^\\s+WITH CHECK\\s*\\(/i;\n\n/**\n * Index just past the `)` closing a clause whose `(` ends at `open`.\n *\n * Needed because a policy expression nests parens and can contain a quoted\n * literal holding either character, so \"find the next `)`\" would stop early and\n * miss the `WITH CHECK` that follows.\n */\nfunction clauseEnd(ddl: string, open: number): number {\n let depth = 1;\n let inQuote = false;\n for (let i = open; i < ddl.length; i++) {\n const c = ddl[i];\n if (inQuote) {\n // '' is an escaped quote inside a string, not a close.\n if (c === \"'\") {\n if (ddl[i + 1] === \"'\") i++;\n else inQuote = false;\n }\n continue;\n }\n if (c === \"'\") inQuote = true;\n else if (c === \"(\") depth++;\n else if (c === \")\" && --depth === 0) return i + 1;\n }\n return ddl.length;\n}\n\n/**\n * `AS PERMISSIVE` / `AS RESTRICTIVE` from either side, or undefined.\n *\n * The DDL spells it as a bare word; `pg_policies.permissive` is the string\n * `\"PERMISSIVE\"`/`\"RESTRICTIVE\"` on modern Postgres but a boolean on some\n * drivers and older servers, so both are accepted. Anything unrecognised\n * becomes `undefined` and the comparison below skips it rather than inventing\n * drift from a shape we did not anticipate.\n */\nfunction normalizeMode(raw: unknown): \"PERMISSIVE\" | \"RESTRICTIVE\" | undefined {\n if (typeof raw === \"boolean\") return raw ? \"PERMISSIVE\" : \"RESTRICTIVE\";\n if (typeof raw !== \"string\") return undefined;\n const upper = raw.trim().toUpperCase();\n if (upper === \"PERMISSIVE\" || upper === \"TRUE\" || upper === \"T\") return \"PERMISSIVE\";\n if (upper === \"RESTRICTIVE\" || upper === \"FALSE\" || upper === \"F\") return \"RESTRICTIVE\";\n return undefined;\n}\n\n/** Parse the generated DDL rather than rebuilding the shape by hand. */\nexport function parseExpectedPolicies(ddl: string): PolicyRef[] {\n const found: PolicyRef[] = [];\n for (const m of ddl.matchAll(CREATE_POLICY)) {\n const [, name, schema, table, modeRaw, command, rolesRaw, clause] = m;\n const roles = rolesRaw\n .split(\",\")\n .map((r) => r.trim().replace(/^\"|\"$/g, \"\"))\n .filter(Boolean);\n\n // The generator emits USING before WITH CHECK, so what follows the TO\n // list settles USING; WITH CHECK is then whatever follows that clause.\n const hasUsing = /USING/i.test(clause);\n const hasWithCheck = hasUsing\n ? WITH_CHECK_NEXT.test(ddl.slice(clauseEnd(ddl, m.index + m[0].length)))\n : /WITH CHECK/i.test(clause);\n\n found.push({\n schema, table, name, roles,\n command: command.toUpperCase(),\n hasUsing, hasWithCheck,\n mode: normalizeMode(modeRaw)\n });\n }\n return found;\n}\n\nasync function readLivePolicies(client: Queryable, schemas: string[]): Promise<PolicyRef[]> {\n const { rows } = await client.query<{\n schemaname: string; tablename: string; policyname: string; roles: string[] | string; cmd: string;\n qual: string | null; with_check: string | null; permissive: string | boolean | null;\n }>(\n `SELECT schemaname, tablename, policyname, roles, cmd, qual, with_check, permissive\n FROM pg_policies\n WHERE schemaname = ANY($1)`,\n [schemas]\n );\n\n return rows.map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n name: r.policyname,\n // node-postgres yields text[] as an array; some drivers hand back \"{a,b}\".\n roles: Array.isArray(r.roles)\n ? r.roles\n : String(r.roles ?? \"\").replace(/^\\{|\\}$/g, \"\").split(\",\").filter(Boolean),\n command: (r.cmd ?? \"ALL\").toUpperCase(),\n // Presence only. Postgres rewrites the text, but it does not invent or\n // drop a clause: NULL here means the policy genuinely has none.\n hasUsing: r.qual != null,\n hasWithCheck: r.with_check != null,\n mode: normalizeMode(r.permissive),\n qual: r.qual,\n withCheck: r.with_check\n }));\n}\n\n/**\n * Tables the collections describe whose RLS switch is off.\n *\n * Read from `pg_class` because `pg_policies` cannot answer it: disabling RLS\n * leaves the policy rows in place, so every other comparison in this file keeps\n * matching while Postgres applies none of them.\n *\n * Only tables that expect at least one policy are considered. A table with no\n * declared rules is not asserting anything about RLS, and reporting it would\n * make the check noisy on exactly the projects least able to judge the noise.\n */\nasync function readTablesWithRlsOff(\n client: Queryable,\n tables: { schema: string; table: string }[]\n): Promise<{ schema: string; table: string; forced: boolean }[]> {\n if (tables.length === 0) return [];\n const { rows } = await client.query<{\n schemaname: string; tablename: string; relrowsecurity: boolean; relforcerowsecurity: boolean;\n }>(\n `SELECT n.nspname AS schemaname,\n c.relname AS tablename,\n c.relrowsecurity,\n c.relforcerowsecurity\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind = 'r'\n AND (n.nspname || '.' || c.relname) = ANY($1)`,\n [tables.map((t) => `${t.schema}.${t.table}`)]\n );\n\n return rows\n .filter((r) => !r.relrowsecurity)\n .map((r) => ({\n schema: r.schemaname,\n table: r.tablename,\n forced: !!r.relforcerowsecurity\n }));\n}\n\n/**\n * The permissive tautology `rebase.uid() IS NOT NULL`, without the\n * `<> 'anonymous'` guard that makes it mean \"signed in\".\n *\n * Whitespace varies with Postgres's rewrite, so match on a collapsed form. The\n * guard clause (`<> 'anonymous'`, in any spelling) is what distinguishes the\n * corrected policy from the stale one, so its presence clears the text.\n *\n * Both schema spellings are matched. The helpers moved from `auth` to `rebase`\n * in 1.0, and this check reads policies *as the database stored them*: a\n * database pushed before the move still holds `auth.uid()` until it is\n * recompiled, while everything written since — including raw `securityRules`\n * SQL, which the compiler rewrites on the way in — stores `rebase.uid()`.\n * Matching only the pre-1.0 name made the check blind to every policy a current\n * release could write, which is precisely the set still worth scanning.\n */\nfunction isPermissiveAuthTautology(clause: string | null | undefined): boolean {\n if (!clause) return false;\n const flat = clause.toLowerCase().replace(/\\s+/g, \" \");\n if (!/\\b(?:rebase|auth)\\.uid\\s*\\(\\s*\\)\\s*is not null/.test(flat)) return false;\n // The fix appends `AND rebase.uid() <> 'anonymous'`; Postgres may store the\n // literal as `'anonymous'::text`. Either spelling means it is the corrected\n // policy, not the tautology.\n return !/<>\\s*'anonymous'/.test(flat) && !/!=\\s*'anonymous'/.test(flat);\n}\n\nconst keyOf = (p: PolicyRef) => `${p.schema}.${p.table}.${p.name}`;\nconst sameRoles = (a: string[], b: string[]) =>\n a.length === b.length && [...a].sort().join(\",\") === [...b].sort().join(\",\");\n\n/**\n * Diff expected against live.\n *\n * Compares names, roles, command, and whether each clause exists — all exact\n * values. Policy expression *text* is deliberately not compared: Postgres\n * rewrites `qual`/`with_check` when storing them (parenthesising, casting,\n * schema-qualifying), so text comparison reports drift that does not exist, and\n * a check that cries wolf gets ignored.\n *\n * Presence is not text, though. A NULL `qual` is not a rewrite of an\n * expression, it is the absence of one, and absence has no false-positive risk:\n * either the generator emitted a clause or it did not. That distinction is worth\n * the extra comparison — a production database was found with a SELECT policy\n * whose `qual` was NULL, matching on every field this checked and denying 100%\n * of reads. The same blindness would hide a policy that fails open.\n */\nexport async function checkPolicyDrift(\n client: Queryable,\n collections: CollectionConfig[]\n): Promise<PolicyDrift> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const schemas = [...new Set(expected.map((p) => p.schema))];\n // Nothing expected means nothing to reconcile against; scanning every\n // schema would report the whole database as orphaned.\n if (schemas.length === 0) return { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n const live = await readLivePolicies(client, schemas);\n const liveByKey = new Map(live.map((p) => [keyOf(p), p]));\n const expectedByKey = new Map(expected.map((p) => [keyOf(p), p]));\n\n const drift: PolicyDrift = { missing: [], orphaned: [], diverged: [], insecure: [], rlsDisabled: [] };\n\n // Before comparing policy against policy: is the switch even on? A table\n // with RLS disabled matches every expected policy on every field compared\n // below, so this has to be asked separately or it cannot be asked at all.\n const expectedTables = [...new Map(\n expected.map((p) => [`${p.schema}.${p.table}`, { schema: p.schema, table: p.table }])\n ).values()];\n drift.rlsDisabled = await readTablesWithRlsOff(client, expectedTables);\n\n // Scan every live policy for the permissive tautology. This is deliberately\n // independent of the name-keyed diff below: a database pushed before the\n // `authenticated()` fix matches its expected policy on name, roles, command\n // and clause presence, so nothing else here would flag it.\n for (const p of live) {\n const clause = isPermissiveAuthTautology(p.qual)\n ? \"USING\"\n : isPermissiveAuthTautology(p.withCheck) ? \"WITH CHECK\" : null;\n if (clause) {\n drift.insecure.push({\n policy: p,\n reason: `${clause} is \\`${RLS_UID_SQL} IS NOT NULL\\`, which is true for anonymous ` +\n `visitors too — this grants access to signed-out requests. It predates the ` +\n `\\`policy.authenticated()\\` fix; re-run \\`rebase db push\\` to tighten it.`\n });\n }\n }\n\n for (const [key, want] of expectedByKey) {\n const got = liveByKey.get(key);\n if (!got) {\n drift.missing.push(want);\n continue;\n }\n const differences: string[] = [];\n if (!sameRoles(want.roles, got.roles)) {\n differences.push(`roles: expected [${want.roles.join(\", \")}], database has [${got.roles.join(\", \")}]`);\n }\n if (want.command !== got.command) {\n differences.push(`command: expected ${want.command}, database has ${got.command}`);\n }\n // Skipped when either side is unknown: an unrecognised catalogue shape\n // should not manufacture drift. See `normalizeMode`.\n if (want.mode && got.mode && want.mode !== got.mode) {\n differences.push(\n `mode: expected ${want.mode}, database has ${got.mode}` +\n (got.mode === \"PERMISSIVE\"\n ? \" — a RESTRICTIVE rule is ANDed with the others, a PERMISSIVE one is ORed, so this policy now widens access instead of narrowing it\"\n : \" — a PERMISSIVE rule is ORed with the others, a RESTRICTIVE one is ANDed, so this policy now narrows access instead of widening it\")\n );\n }\n for (const clause of [\"USING\", \"WITH CHECK\"] as const) {\n const key = clause === \"USING\" ? \"hasUsing\" : \"hasWithCheck\";\n if (want[key] === got[key]) continue;\n differences.push(want[key]\n ? `${clause}: expected an expression, database has none — this policy matches no rows`\n : `${clause}: expected none, database has an expression`);\n }\n if (differences.length > 0) drift.diverged.push({ expected: want, actual: got, differences });\n }\n\n for (const [key, got] of liveByKey) {\n if (!expectedByKey.has(key)) drift.orphaned.push(got);\n }\n\n return drift;\n}\n\n/**\n * Does this name look like one the generator produced for this table?\n *\n * Unnamed rules compile to `<table>_<op>_<sha1[0:7]>` (plus `_<idx>` when one\n * rule spans several operations), and the hash covers the rule's semantics — so\n * *editing* a rule renames its policy. The policy under the old name is left\n * behind by `db push`, which only DROPs the names it is about to CREATE, and\n * Postgres ORs PERMISSIVE policies together: a superseded `USING (true)` keeps\n * granting everything no matter how tight its replacement is.\n *\n * Matching the shape is what makes dropping them safe. A hand-written policy\n * would have to collide with a 7-hex digest to be mistaken for generated one;\n * a policy named anything else is left alone and merely reported, because a\n * custom name is indistinguishable from one someone wrote in SQL on purpose.\n */\nexport function isGeneratedPolicyName(name: string, table: string): boolean {\n return new RegExp(`^${table.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\")}_(select|insert|update|delete|all)_[0-9a-f]{7}(_\\\\d+)?$`)\n .test(name);\n}\n\nexport interface OrphanCleanup {\n /** Superseded generated policies that were dropped. */\n dropped: PolicyRef[];\n /** Orphans left in place because their names are not generator-shaped. */\n kept: PolicyRef[];\n}\n\n/**\n * Drop the policies an earlier push superseded but never removed.\n *\n * Only touches tables the collections describe — a table with no expected\n * policy is not ours to reconcile, and scanning by schema alone would sweep up\n * policies belonging to something else sharing the database.\n */\nexport async function dropOrphanedPolicies(\n client: Queryable,\n drift: PolicyDrift,\n collections: CollectionConfig[]\n): Promise<OrphanCleanup> {\n const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(collections));\n const managed = new Set(expected.map((p) => `${p.schema}.${p.table}`));\n\n const cleanup: OrphanCleanup = { dropped: [], kept: [] };\n for (const p of drift.orphaned) {\n if (!managed.has(`${p.schema}.${p.table}`) || !isGeneratedPolicyName(p.name, p.table)) {\n cleanup.kept.push(p);\n continue;\n }\n // Identifiers are quoted, and the name came from pg_policies rather than\n // from user input, so it is already a valid identifier.\n await client.query(`DROP POLICY IF EXISTS \"${p.name}\" ON \"${p.schema}\".\"${p.table}\"`);\n cleanup.dropped.push(p);\n }\n return cleanup;\n}\n\nexport const hasDrift = (d: PolicyDrift): boolean =>\n d.missing.length > 0 || d.orphaned.length > 0 || d.diverged.length > 0 || d.insecure.length > 0\n || d.rlsDisabled.length > 0;\n\n/** Human-readable report; empty string when the database matches the config. */\nexport function formatPolicyDrift(drift: PolicyDrift): string {\n if (!hasDrift(drift)) return \"\";\n const lines: string[] = [];\n\n // First, because it subsumes every other finding on the same table: if RLS\n // is off, the policies listed below are not being applied at all.\n if (drift.rlsDisabled.length > 0) {\n lines.push(\" RLS DISABLED — the table has policies, and Postgres is applying none of them:\");\n for (const t of drift.rlsDisabled) {\n lines.push(` • ${t.schema}.${t.table}${t.forced ? \"\" : \" (and FORCE is off)\"}`);\n }\n lines.push(\" Every row is readable and writable by any request that reaches the table.\");\n lines.push(` Fix: ALTER TABLE \"<schema>\".\"<table>\" ENABLE ROW LEVEL SECURITY; or re-run \\`rebase db push\\`.`);\n }\n if (drift.missing.length > 0) {\n lines.push(\" Missing — described by your collections, absent from the database:\");\n for (const p of drift.missing) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Run `rebase db push` to apply them.\");\n }\n if (drift.orphaned.length > 0) {\n lines.push(\" Orphaned — in the database, described by no collection:\");\n for (const p of drift.orphaned) lines.push(` • ${p.schema}.${p.table} → \"${p.name}\" (${p.command} TO ${p.roles.join(\", \")})`);\n lines.push(\" Left behind by an earlier push. These still filter rows.\");\n }\n if (drift.diverged.length > 0) {\n lines.push(\" Diverged — same policy, different definition:\");\n for (const d of drift.diverged) {\n lines.push(` • ${d.expected.schema}.${d.expected.table} → \"${d.expected.name}\"`);\n for (const diff of d.differences) lines.push(` ${diff}`);\n }\n }\n if (drift.insecure.length > 0) {\n lines.push(\" Insecure — a live policy grants access it should not:\");\n for (const i of drift.insecure) {\n lines.push(` • ${i.policy.schema}.${i.policy.table} → \"${i.policy.name}\"`);\n lines.push(` ${i.reason}`);\n }\n }\n return lines.join(\"\\n\");\n}\n","/**\n * Applying a bundle's RLS policies to a database at boot, idempotently.\n *\n * ## Why this exists\n *\n * {@link ensureCollectionTables} creates the collection *tables* a managed\n * runtime boots against, but a table with row-level security disabled and no\n * policies is not servable: authenticated requests run as the restricted\n * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a\n * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy\n * is denied. The policies live in the collections' `securityRules`; nothing at\n * boot applied them. `rebase db push` does — but it drives Atlas against a\n * local `DATABASE_URL`, and a managed tenant's database is reachable only from\n * inside the cluster, by the runtime that is already connected to it. So the\n * runtime is the only thing that *can* apply them, and this is where it does.\n *\n * ## Why this is safe to run on every boot\n *\n * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once\n * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by\n * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds\n * and replaces; it never drops data. (It does not *reconcile* — a policy a\n * previous push left behind under an old name is not removed here; that stays a\n * `db push` / `db migrate` concern, alongside destructive schema changes.)\n *\n * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a\n * table whose policies could not be applied fails **closed** (denies) rather\n * than leaking rows. One collection's policy failing (e.g. a rule that\n * references a table a real migration has not created yet) must not crash-loop\n * the whole deployment and take the other collections' working routes down with\n * it. Failures are reported loudly and per-table so the operator can see\n * exactly which collection is not yet servable and why.\n */\nimport { type CollectionConfig } from \"@rebasepro/types\";\nimport { planCollectionPolicies, type CollectionPolicyPlan } from \"./generate-postgres-ddl-logic\";\nimport { isGeneratedPolicyName } from \"../security/policy-drift\";\nimport { readExistingSchema, type Queryable } from \"./ensure-collection-tables\";\nimport { REBASE_USER_ROLE } from \"../security/rls-enforcement\";\n\nexport interface PolicyEnsureResult {\n /** `CREATE POLICY` statements that ran successfully. */\n policiesApplied: number;\n /** Tables that had RLS enabled. */\n tablesSecured: number;\n /** Declared tables absent from the database — left to a real migration. */\n skipped: { table: string; reason: string }[];\n /**\n * Tables that have RLS on but did not get every policy. They deny — RLS\n * with no matching policy is deny-all — so they are safe but not servable.\n */\n failures: { table: string; error: string }[];\n /**\n * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.\n *\n * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`\n * failing was recorded as a `failure` and reported with the same \"it stays\n * locked (denies)\" wording as a failed policy — but the two are opposites.\n * A policy statement failing leaves RLS on and the table denying. `enableRls`\n * failing leaves RLS *off*, and the schema-wide grant to the user role has\n * already been made by `ensureRlsEnforcement`, so the table is readable and\n * writable by every authenticated request with no row filtering at all.\n */\n unsecured: { table: string; error: string; grantWithdrawn: boolean }[];\n /**\n * Generated policies removed because no current rule produces them.\n *\n * A policy's name embeds a hash of the rule's semantics, so editing a rule\n * does not update a policy — it creates a new one and abandons the old.\n * Postgres ORs permissive policies, so the abandoned one keeps granting:\n * a `USING (true)` tightened to an owner check went on admitting everyone,\n * forever, while the deploy logged success.\n *\n * `db push` reconciles this, and cannot reach a managed tenant's in-cluster\n * database — which is the reason this module exists. So boot has to do it.\n */\n orphansDropped: number;\n}\n\nconst isCreatePolicy = (statement: string): boolean => /^\\s*CREATE POLICY/i.test(statement);\n\n/**\n * Bring the declared collections' RLS policies up to date. Returns what it did.\n *\n * Only tables that already exist are touched: the boot-time table creator runs\n * first, so anything still missing is a table this additive path is not allowed\n * to create (a junction, or a relation left to a migration). Enabling RLS on a\n * non-existent table would error, so those are recorded as skipped, not failed.\n */\n\n/**\n * Remove generated policies on this table that the current plan does not\n * produce.\n *\n * Scoped hard, because dropping a policy is destructive: only this one table,\n * only names matching the generated `<table>_<op>_<sha1>` shape, and only names\n * absent from the statements just applied. A hand-written policy, or one\n * belonging to another table, is never touched — `isGeneratedPolicyName` is the\n * same predicate `db push` uses to draw that line.\n */\nasync function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {\n const expected = new Set<string>();\n for (const statement of plan.policyStatements) {\n const match = /^\\s*CREATE POLICY\\s+\"([^\"]+)\"/i.exec(statement);\n if (match) expected.add(match[1]);\n }\n\n const existing = await client.query<{ policyname: string }>(\n `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, \"''\")}' ` +\n `AND tablename = '${plan.table.replace(/'/g, \"''\")}'`\n );\n\n let dropped = 0;\n for (const row of existing.rows) {\n const name = row.policyname;\n if (expected.has(name)) continue;\n if (!isGeneratedPolicyName(name, plan.table)) continue;\n await client.query(`DROP POLICY IF EXISTS \"${name}\" ON \"${plan.schema}\".\"${plan.table}\"`);\n dropped++;\n }\n return dropped;\n}\n\nexport async function ensureCollectionPolicies(\n client: Queryable,\n collections: CollectionConfig[],\n log?: (message: string) => void\n): Promise<PolicyEnsureResult> {\n const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };\n\n const plans = planCollectionPolicies(collections);\n if (plans.length === 0) return result;\n\n const schemas = Array.from(new Set(plans.map(p => p.schema)));\n const existing = await readExistingSchema(client, schemas);\n\n for (const plan of plans) {\n if (!existing.tables.has(plan.qualified)) {\n result.skipped.push({\n table: plan.qualified,\n reason: \"table is not present in the database; create it with `rebase db push` / `rebase db migrate`\"\n });\n continue;\n }\n\n // Enabling RLS is its own step, because its failure is the opposite of\n // every other failure here. Once RLS is on, anything that goes wrong\n // afterwards leaves the table denying; while it is off, the grant made\n // earlier in boot leaves the table wide open. Sharing one `try` meant\n // the dangerous case was reported in the safe case's words.\n try {\n await client.query(plan.enableRls);\n result.tablesSecured++;\n } catch (err) {\n const error = err instanceof Error ? err.message : String(err);\n // Take the privilege back rather than serve an unprotected table.\n // This is the fail-closed step the old code assumed it already had:\n // per-table, so one collection cannot take the rest of the\n // deployment down, but leaving nothing readable without RLS.\n let grantWithdrawn = false;\n try {\n await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);\n grantWithdrawn = true;\n } catch {\n // Fall through: reported below with `grantWithdrawn: false`,\n // which the caller escalates. There is nothing else this\n // function can do to make the table safe.\n }\n result.unsecured.push({ table: plan.qualified,\nerror,\ngrantWithdrawn });\n continue;\n }\n\n try {\n let created = 0;\n for (const statement of plan.policyStatements) {\n await client.query(statement);\n if (isCreatePolicy(statement)) {\n result.policiesApplied++;\n created++;\n }\n }\n\n const orphans = await dropOrphanedPoliciesOn(client, plan);\n result.orphansDropped += orphans;\n\n log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +\n (orphans > 0 ? `, ${orphans} orphan(s) dropped` : \"\"));\n } catch (err) {\n result.failures.push({\n table: plan.qualified,\n error: err instanceof Error ? err.message : String(err)\n });\n }\n }\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAmYA,SAAgB,sBAAsB,MAAc,OAAwB;CACxE,OAAO,IAAI,OAAO,IAAI,MAAM,QAAQ,uBAAuB,MAAM,EAAE,wDAAwD,CAAC,CACvH,KAAK,IAAI;AAClB;;;ACxTA,IAAM,kBAAkB,cAA+B,qBAAqB,KAAK,SAAS;;;;;;;;;;;;;;;;;;;AAqB1F,eAAe,uBAAuB,QAAmB,MAA6C;CAClG,MAAM,2BAAW,IAAI,IAAY;CACjC,KAAK,MAAM,aAAa,KAAK,kBAAkB;EAC3C,MAAM,QAAQ,iCAAiC,KAAK,SAAS;EAC7D,IAAI,OAAO,SAAS,IAAI,MAAM,EAAE;CACpC;CAEA,MAAM,WAAW,MAAM,OAAO,MAC1B,0DAA0D,KAAK,OAAO,QAAQ,MAAM,IAAI,EAAE,qBACtE,KAAK,MAAM,QAAQ,MAAM,IAAI,EAAE,EACvD;CAEA,IAAI,UAAU;CACd,KAAK,MAAM,OAAO,SAAS,MAAM;EAC7B,MAAM,OAAO,IAAI;EACjB,IAAI,SAAS,IAAI,IAAI,GAAG;EACxB,IAAI,CAAC,sBAAsB,MAAM,KAAK,KAAK,GAAG;EAC9C,MAAM,OAAO,MAAM,0BAA0B,KAAK,QAAQ,KAAK,OAAO,KAAK,KAAK,MAAM,EAAE;EACxF;CACJ;CACA,OAAO;AACX;AAEA,eAAsB,yBAClB,QACA,aACA,KAC2B;CAC3B,MAAM,SAA6B;EAAE,iBAAiB;EAAG,eAAe;EAAG,SAAS,CAAC;EAAG,UAAU,CAAC;EAAG,WAAW,CAAC;EAAG,gBAAgB;CAAE;CAEvI,MAAM,QAAQ,uBAAuB,WAAW;CAChD,IAAI,MAAM,WAAW,GAAG,OAAO;CAG/B,MAAM,WAAW,MAAM,mBAAmB,QAD1B,MAAM,KAAK,IAAI,IAAI,MAAM,KAAI,MAAK,EAAE,MAAM,CAAC,CACT,CAAO;CAEzD,KAAK,MAAM,QAAQ,OAAO;EACtB,IAAI,CAAC,SAAS,OAAO,IAAI,KAAK,SAAS,GAAG;GACtC,OAAO,QAAQ,KAAK;IAChB,OAAO,KAAK;IACZ,QAAQ;GACZ,CAAC;GACD;EACJ;EAOA,IAAI;GACA,MAAM,OAAO,MAAM,KAAK,SAAS;GACjC,OAAO;EACX,SAAS,KAAK;GACV,MAAM,QAAQ,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAK7D,IAAI,iBAAiB;GACrB,IAAI;IACA,MAAM,OAAO,MAAM,4BAA4B,KAAK,UAAU,QAAQ,kBAAkB;IACxF,iBAAiB;GACrB,QAAQ,CAIR;GACA,OAAO,UAAU,KAAK;IAAE,OAAO,KAAK;IAChD;IACA;GAAe,CAAC;GACJ;EACJ;EAEA,IAAI;GACA,IAAI,UAAU;GACd,KAAK,MAAM,aAAa,KAAK,kBAAkB;IAC3C,MAAM,OAAO,MAAM,SAAS;IAC5B,IAAI,eAAe,SAAS,GAAG;KAC3B,OAAO;KACP;IACJ;GACJ;GAEA,MAAM,UAAU,MAAM,uBAAuB,QAAQ,IAAI;GACzD,OAAO,kBAAkB;GAEzB,MAAM,GAAG,KAAK,UAAU,iBAAiB,QAAQ,yBAC5C,UAAU,IAAI,KAAK,QAAQ,sBAAsB,GAAG;EAC7D,SAAS,KAAK;GACV,OAAO,SAAS,KAAK;IACjB,OAAO,KAAK;IACZ,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAC1D,CAAC;EACL;CACJ;CAEA,OAAO;AACX"}
|
|
@@ -2,8 +2,8 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
import "process";
|
|
3
3
|
__createRequire(import.meta.url);
|
|
4
4
|
import { u as __exportAll } from "./connection-GOKU3Hu5.js";
|
|
5
|
-
import { B as getPolicyNamesForRule, C as getTableName, I as generateForeignKeyName, L as legacyForeignKeyName, R as toPostgresIdentifier, T as resolveCollectionRelations, W as toSnakeCase, Z as isManyToMany, _ as securityRuleToConditions, b as findRelation, d as getJunctionCollectionConfig, f as getJunctionSecurityRules, g as policyToPostgres, h as getInjectedSecurityRules, i as collectionIndexStatements, m as getEffectiveSecurityRules, n as buildCollectionIndexSpecs, p as resolveJunctionSpecs, r as collectionIndexStatement, t as buildCollectionIndexPlan, u as relationalCollections } from "./collection-index-
|
|
6
|
-
import {
|
|
5
|
+
import { B as getPolicyNamesForRule, C as getTableName, I as generateForeignKeyName, L as legacyForeignKeyName, R as toPostgresIdentifier, T as resolveCollectionRelations, W as toSnakeCase, Z as isManyToMany, _ as securityRuleToConditions, b as findRelation, d as getJunctionCollectionConfig, f as getJunctionSecurityRules, g as policyToPostgres, h as getInjectedSecurityRules, i as collectionIndexStatements, m as getEffectiveSecurityRules, n as buildCollectionIndexSpecs, p as resolveJunctionSpecs, r as collectionIndexStatement, t as buildCollectionIndexPlan, u as relationalCollections } from "./collection-index-BRUg10H5.js";
|
|
6
|
+
import { l as isPostgresCollectionConfig, r as REBASE_SCHEMA, t as declaredDatabaseExtensions } from "./src-DiB5RP2Z.js";
|
|
7
7
|
import { isConcurrentDdlRace, isDuplicateObjectRace, logger } from "@rebasepro/server";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
/**
|
|
@@ -171,9 +171,9 @@ var rawTextSql = (field) => {
|
|
|
171
171
|
const col = `"${field.column}"`;
|
|
172
172
|
if (field.kind === "text") return `coalesce(${col}, '')`;
|
|
173
173
|
if (field.kind === "text_array") return `${SEARCH_TEXT_FN}(coalesce(${col}, '{}'::text[]))`;
|
|
174
|
-
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote(field.jsonPath[0])}` : `${col} #> ${quote(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
174
|
+
return `${SEARCH_TEXT_FN}(coalesce(${field.jsonPath.length === 0 ? col : field.jsonPath.length === 1 ? `${col} -> ${quote$1(field.jsonPath[0])}` : `${col} #> ${quote$1(`{${field.jsonPath.join(",")}}`)}`}, '{}'::jsonb))`;
|
|
175
175
|
};
|
|
176
|
-
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
176
|
+
var quote$1 = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
177
177
|
/**
|
|
178
178
|
* Resolve and validate one declared field path.
|
|
179
179
|
*
|
|
@@ -210,7 +210,7 @@ var resolveField = (entry, collection, cfg) => {
|
|
|
210
210
|
jsonPath: rest,
|
|
211
211
|
kind: classified.kind,
|
|
212
212
|
weight,
|
|
213
|
-
sql: `setweight(to_tsvector(${quote(language)}, ${textSql}), ${quote(weight)})`,
|
|
213
|
+
sql: `setweight(to_tsvector(${quote$1(language)}, ${textSql}), ${quote$1(weight)})`,
|
|
214
214
|
textSql
|
|
215
215
|
};
|
|
216
216
|
};
|
|
@@ -343,7 +343,7 @@ var searchColumnStamps = (spec) => {
|
|
|
343
343
|
column,
|
|
344
344
|
expression,
|
|
345
345
|
fingerprint,
|
|
346
|
-
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote(fingerprint)};`
|
|
346
|
+
sql: `COMMENT ON COLUMN "${spec.schema}"."${spec.table}"."${column}" IS ${quote$1(fingerprint)};`
|
|
347
347
|
};
|
|
348
348
|
};
|
|
349
349
|
const stamps = [stamp(spec.column, spec.expression)];
|
|
@@ -361,14 +361,14 @@ var searchColumnStamps = (spec) => {
|
|
|
361
361
|
* and the operator reading the failure is the person who changed the block.
|
|
362
362
|
*/
|
|
363
363
|
var searchStampGuards = (spec) => searchColumnStamps(spec).map((stamp) => {
|
|
364
|
-
const relation = quote(`"${spec.schema}"."${spec.table}"`);
|
|
364
|
+
const relation = quote$1(`"${spec.schema}"."${spec.table}"`);
|
|
365
365
|
return `DO $rebase_search$
|
|
366
366
|
DECLARE recorded text;
|
|
367
367
|
BEGIN
|
|
368
368
|
SELECT col_description(a.attrelid, a.attnum) INTO recorded
|
|
369
369
|
FROM pg_attribute a
|
|
370
|
-
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote(stamp.column)} AND NOT a.attisdropped;
|
|
371
|
-
IF recorded LIKE ${quote(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote(stamp.fingerprint)} THEN
|
|
370
|
+
WHERE a.attrelid = ${relation}::regclass AND a.attname = ${quote$1(stamp.column)} AND NOT a.attisdropped;
|
|
371
|
+
IF recorded LIKE ${quote$1(`${SEARCH_STAMP_PREFIX}%`)} AND recorded <> ${quote$1(stamp.fingerprint)} THEN
|
|
372
372
|
RAISE EXCEPTION 'Rebase: the search block for ${spec.schema}.${spec.table} changed after the generated column "${stamp.column}" was built (recorded %, expected ${stamp.fingerprint}). Postgres cannot alter a generated expression in place. Drop the column and re-apply this file — it rewrites the table and rebuilds the index: ALTER TABLE ${relation.slice(1, -1)} DROP COLUMN "${stamp.column}";', recorded;
|
|
373
373
|
END IF;
|
|
374
374
|
END
|
|
@@ -678,6 +678,141 @@ var vectorIndexStatement = (spec) => {
|
|
|
678
678
|
};
|
|
679
679
|
/** Every statement for a plan, in a stable order. */
|
|
680
680
|
var vectorIndexStatements = (plan) => plan.specs.map(vectorIndexStatement);
|
|
681
|
+
var quote = (v) => `'${v.replace(/'/g, "''")}'`;
|
|
682
|
+
/**
|
|
683
|
+
* The schema pgvector's types are installed into.
|
|
684
|
+
*
|
|
685
|
+
* `WITH SCHEMA public` for the same load-bearing reason `searchExtensionStatements`
|
|
686
|
+
* gives: an unqualified `CREATE EXTENSION` lands in the first schema on
|
|
687
|
+
* `search_path`, which is `"$user", public` — and the scaffold's role is named
|
|
688
|
+
* `rebase`, the same as a schema the generator creates. Left to itself the
|
|
689
|
+
* extension would install into `rebase`, and every unqualified `VECTOR(n)`
|
|
690
|
+
* below would fail to resolve.
|
|
691
|
+
*/
|
|
692
|
+
var VECTOR_EXTENSION_SCHEMA = "public";
|
|
693
|
+
/** The extension name a database has to name to let Rebase install pgvector. */
|
|
694
|
+
var VECTOR_EXTENSION = "vector";
|
|
695
|
+
/**
|
|
696
|
+
* How a project says Rebase may install pgvector, quoted into the messages that
|
|
697
|
+
* have to name it. Spelled once so the option and the advice cannot drift.
|
|
698
|
+
*/
|
|
699
|
+
var VECTOR_EXTENSION_OPT_IN = `database({ extensions: ["${VECTOR_EXTENSION}"] })`;
|
|
700
|
+
/** Did the project give Rebase leave to install pgvector? */
|
|
701
|
+
var vectorExtensionDeclared = (extensions) => (extensions ?? []).includes(VECTOR_EXTENSION);
|
|
702
|
+
/**
|
|
703
|
+
* Install pgvector — emitted only when the database asked for it.
|
|
704
|
+
*
|
|
705
|
+
* Opt-in because installing an extension is a decision with a deployment behind
|
|
706
|
+
* it: the image has to ship the library, the role has to be allowed to install
|
|
707
|
+
* it, and a managed provider has to have it on an allow-list. None of that is
|
|
708
|
+
* visible from inside the connection, so Rebase does not decide it. See
|
|
709
|
+
* `DatabaseOptions.extensions`.
|
|
710
|
+
*
|
|
711
|
+
* Withholding the statement is not withholding the *column*: the column is
|
|
712
|
+
* still created, and Postgres refuses it with `type "vector" does not exist`
|
|
713
|
+
* on a database where pgvector was never installed by hand. That error is the
|
|
714
|
+
* one this design accepts, and `vectorExtensionHint` is what makes it name
|
|
715
|
+
* {@link VECTOR_EXTENSION_OPT_IN} rather than nothing.
|
|
716
|
+
*/
|
|
717
|
+
var vectorExtensionStatement = () => `CREATE EXTENSION IF NOT EXISTS ${VECTOR_EXTENSION} WITH SCHEMA ${VECTOR_EXTENSION_SCHEMA};`;
|
|
718
|
+
/**
|
|
719
|
+
* The missing-pgvector explanation, appended to whichever error revealed it.
|
|
720
|
+
*
|
|
721
|
+
* Two readers, needing opposite things, which is why the branch is on the error
|
|
722
|
+
* text rather than on the configuration:
|
|
723
|
+
*
|
|
724
|
+
* - **`type "vector" does not exist`** — nobody opted in and the database has
|
|
725
|
+
* no pgvector. The fix is one line of config they cannot guess, so naming
|
|
726
|
+
* the option *is* the hint.
|
|
727
|
+
* - **the install itself failed** — the config is already right, and repeating
|
|
728
|
+
* the option would send them to edit a correct line. What is missing is the
|
|
729
|
+
* library on the server, or the grant.
|
|
730
|
+
*
|
|
731
|
+
* Lives here rather than beside either caller because both need it: the boot
|
|
732
|
+
* ensure raises the first through its action applier, and `rebase db push`
|
|
733
|
+
* raises it out of `applyVectorDdl`. A hint on only one path is how a bare
|
|
734
|
+
* `type "vector" does not exist` reaches somebody — which is the thing this
|
|
735
|
+
* exists to prevent.
|
|
736
|
+
*/
|
|
737
|
+
var vectorExtensionHint = (message) => {
|
|
738
|
+
if (/extension "vector" is not available/i.test(message) || /(permission denied to create extension|must be (superuser|owner).{0,40}extension)/i.test(message)) return "\n pgvector was declared and could not be installed. It is a server extension, so it needs an image that ships the library (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` does not) and a role allowed to run `CREATE EXTENSION vector;`. Managed Postgres usually allows it once the extension is on the provider's allow-list.";
|
|
739
|
+
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
740
|
+
return `
|
|
741
|
+
pgvector is not installed on this database, and Rebase installs it only where a database says it may: add \`${VECTOR_EXTENSION_OPT_IN}\` in config/resources.ts, or install it once by hand with \`CREATE EXTENSION vector;\`. Either way the server needs an image that ships the library — the scaffold's \`pgvector/pgvector:pg18\` does, a stock \`postgres:18\` does not. Rebase then creates the column and its ANN index automatically — see the \`index\` option on the property.`;
|
|
742
|
+
};
|
|
743
|
+
/**
|
|
744
|
+
* Every vector column a collection declares.
|
|
745
|
+
*
|
|
746
|
+
* Wider than {@link buildVectorIndexPlan} on purpose: that one answers "what
|
|
747
|
+
* gets an ANN index", and skips both a property with `index: false` and one too
|
|
748
|
+
* wide to index. Either still needs its column.
|
|
749
|
+
*/
|
|
750
|
+
var buildVectorColumnSpecs = (collection, resolveColumn) => {
|
|
751
|
+
const table = getTableName(collection);
|
|
752
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
753
|
+
const specs = [];
|
|
754
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
755
|
+
if (!isVectorProperty(prop)) continue;
|
|
756
|
+
const validation = prop.validation;
|
|
757
|
+
let modifiers = "";
|
|
758
|
+
if (validation?.unique) modifiers += " UNIQUE";
|
|
759
|
+
if (validation?.required) modifiers += " NOT NULL";
|
|
760
|
+
specs.push({
|
|
761
|
+
schema,
|
|
762
|
+
table,
|
|
763
|
+
column: resolveColumn(propName, prop),
|
|
764
|
+
dimensions: prop.dimensions,
|
|
765
|
+
modifiers
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return specs;
|
|
769
|
+
};
|
|
770
|
+
/** The column type — the one place `VECTOR(n)` is spelled. */
|
|
771
|
+
var vectorColumnType = (spec) => `VECTOR(${spec.dimensions})`;
|
|
772
|
+
/** The column definition as it appears inside `CREATE TABLE`. */
|
|
773
|
+
var vectorColumnDefinition = (spec) => `"${spec.column}" ${vectorColumnType(spec)}${spec.modifiers}`;
|
|
774
|
+
/**
|
|
775
|
+
* Refuse — or perform — a `dimensions` change that `ADD COLUMN IF NOT EXISTS`
|
|
776
|
+
* would otherwise swallow.
|
|
777
|
+
*
|
|
778
|
+
* Without this the file *launders* the change: the ADD is a no-op against a
|
|
779
|
+
* column that exists, so a project that went from 384 to 768 dimensions would
|
|
780
|
+
* push clean, keep a 384-wide column, and fail on the next insert with a
|
|
781
|
+
* message about the row rather than about the config. Atlas used to catch this
|
|
782
|
+
* — it owned the column and planned the `ALTER … TYPE` — and taking the column
|
|
783
|
+
* out of its sight is exactly what makes the guard necessary.
|
|
784
|
+
*
|
|
785
|
+
* The widening is performed when the column holds no values, because that is
|
|
786
|
+
* the case Atlas handled and it is the common one: a developer changing
|
|
787
|
+
* embedding models before there are any embeddings. With values present the
|
|
788
|
+
* conversion is pgvector's to reject — every stored vector is the old width —
|
|
789
|
+
* so this refuses first and names the statement to run afterwards.
|
|
790
|
+
*
|
|
791
|
+
* `atttypmod` on a `vector` column *is* the dimension count: pgvector stores it
|
|
792
|
+
* directly rather than offsetting it the way `varchar` does. `-1` means the
|
|
793
|
+
* column was declared as a bare `vector`, which is drift in the same sense.
|
|
794
|
+
*/
|
|
795
|
+
var vectorDimensionGuard = (spec) => {
|
|
796
|
+
const relation = `"${spec.schema}"."${spec.table}"`;
|
|
797
|
+
return `DO $rebase_vector$
|
|
798
|
+
DECLARE actual int;
|
|
799
|
+
BEGIN
|
|
800
|
+
SELECT a.atttypmod INTO actual
|
|
801
|
+
FROM pg_attribute a
|
|
802
|
+
JOIN pg_type t ON t.oid = a.atttypid
|
|
803
|
+
WHERE a.attrelid = ${quote(relation)}::regclass
|
|
804
|
+
AND a.attname = ${quote(spec.column)}
|
|
805
|
+
AND NOT a.attisdropped
|
|
806
|
+
AND t.typname = 'vector';
|
|
807
|
+
IF actual IS NOT NULL AND actual <> ${spec.dimensions} THEN
|
|
808
|
+
IF EXISTS (SELECT 1 FROM ${relation} WHERE "${spec.column}" IS NOT NULL) THEN
|
|
809
|
+
RAISE EXCEPTION 'Rebase: ${spec.schema}.${spec.table}."${spec.column}" declares ${spec.dimensions} dimensions but the column is vector(%), and it already holds values of the old width. pgvector cannot convert them. Re-embed the rows at ${spec.dimensions} dimensions (or clear the column), then re-apply: ALTER TABLE ${relation} ALTER COLUMN "${spec.column}" TYPE ${vectorColumnType(spec)};', actual;
|
|
810
|
+
END IF;
|
|
811
|
+
ALTER TABLE ${relation} ALTER COLUMN "${spec.column}" TYPE ${vectorColumnType(spec)};
|
|
812
|
+
END IF;
|
|
813
|
+
END
|
|
814
|
+
$rebase_vector$;`;
|
|
815
|
+
};
|
|
681
816
|
//#endregion
|
|
682
817
|
//#region src/schema/generate-postgres-ddl-logic.ts
|
|
683
818
|
var resolveColumnName = (propName, prop) => {
|
|
@@ -850,6 +985,41 @@ var getSqlColumnType = (propName, prop, collection, collections) => {
|
|
|
850
985
|
default: throw new Error(`No Postgres column type for property '${propName}' of type '${prop.type}' in collection '${collection.slug}'. Add a case to \`getSqlColumnType\` (and to \`getDrizzleColumn\`, which must agree).`);
|
|
851
986
|
}
|
|
852
987
|
};
|
|
988
|
+
/**
|
|
989
|
+
* Everything a `search` block needs, as a file Rebase applies itself.
|
|
990
|
+
*
|
|
991
|
+
* Search is the one part of the schema Atlas does not own. Two independent
|
|
992
|
+
* reasons, and either alone would be enough:
|
|
993
|
+
*
|
|
994
|
+
* 1. Its free tier refuses to *parse* a desired-state file that so much as
|
|
995
|
+
* contains a function — "functions and procedures are available to
|
|
996
|
+
* logged-in users only". A generated `tsvector` column cannot avoid one:
|
|
997
|
+
* `unaccent` is STABLE and jsonb flattening needs a set-returning function,
|
|
998
|
+
* so both have to be wrapped in an IMMUTABLE helper to be legal in a
|
|
999
|
+
* generated column at all.
|
|
1000
|
+
* 2. Even with the file accepted, Atlas *wipes* the dev database it diffs
|
|
1001
|
+
* against, so a helper seeded there beforehand is gone by the time the plan
|
|
1002
|
+
* is analysed. There is no hook to reinstate it.
|
|
1003
|
+
*
|
|
1004
|
+
* So the column, its index and its helpers are excluded from Atlas's view
|
|
1005
|
+
* (`searchExcludePatterns`) and applied from here — the same arrangement the
|
|
1006
|
+
* RLS policies already use, and for the same underlying reason.
|
|
1007
|
+
*
|
|
1008
|
+
* Ordered as it must run: extensions, then helpers, then the column whose
|
|
1009
|
+
* expression calls them, then the index over that column. Every statement is
|
|
1010
|
+
* `IF NOT EXISTS` / `OR REPLACE`, because this is replayed on every push and
|
|
1011
|
+
* appended to migrations that run against databases at any stage of their
|
|
1012
|
+
* life. Empty when nothing opted in — the caller writes no file then.
|
|
1013
|
+
*
|
|
1014
|
+
* The dev database Atlas plans against needs none of this. `searchExcludePatterns`
|
|
1015
|
+
* removes the search column from the *inspected* state, so the state Atlas
|
|
1016
|
+
* replays there never mentions the column and never calls a helper. Seeding the
|
|
1017
|
+
* helpers into that database was tried and removed: measured on Atlas 1.2.3, a
|
|
1018
|
+
* `schema apply --dry-run` produces byte-identical output seeded or not, and
|
|
1019
|
+
* Atlas wipes the dev database before it plans anyway — so a seed could not have
|
|
1020
|
+
* helped even where the excludes are missing, which is the case that really does
|
|
1021
|
+
* fail with `function public.rebase_search_unaccent(text) does not exist`.
|
|
1022
|
+
*/
|
|
853
1023
|
var generatePostgresSearchDdl = (allCollections) => {
|
|
854
1024
|
const collections = relationalCollections(allCollections);
|
|
855
1025
|
const specs = collections.map((c) => buildSearchColumnSpec(c)).filter((s) => s !== void 0);
|
|
@@ -887,9 +1057,66 @@ var generatePostgresSearchDdl = (allCollections) => {
|
|
|
887
1057
|
}
|
|
888
1058
|
return ddl;
|
|
889
1059
|
};
|
|
1060
|
+
/**
|
|
1061
|
+
* Everything a `{ type: "vector" }` property needs, as a file Rebase applies
|
|
1062
|
+
* itself.
|
|
1063
|
+
*
|
|
1064
|
+
* Vector is the second part of the schema Atlas does not own, and it is out for
|
|
1065
|
+
* a narrower reason than search: not that Atlas refuses to parse the file, but
|
|
1066
|
+
* that it cannot *execute* it. To compute a desired state Atlas materialises
|
|
1067
|
+
* `schema.sql` in a dev database, that database is created empty, and Atlas
|
|
1068
|
+
* empties it again at the start of every run — so `VECTOR(384)` resolves
|
|
1069
|
+
* against a database that structurally cannot have pgvector, and every push
|
|
1070
|
+
* dies with `type "vector" does not exist`. Seeding the extension beforehand
|
|
1071
|
+
* does not survive the clean (measured: present before the run, gone after),
|
|
1072
|
+
* putting `CREATE EXTENSION` in `schema.sql` is refused as an Atlas Pro
|
|
1073
|
+
* feature, and `--exclude` alone cannot help because Atlas still parses and
|
|
1074
|
+
* applies the whole file.
|
|
1075
|
+
*
|
|
1076
|
+
* So the column, its ANN indexes and the extension are excluded from Atlas's
|
|
1077
|
+
* view ({@link vectorExcludePatterns}) and applied from here — the same
|
|
1078
|
+
* arrangement search and the RLS policies already use.
|
|
1079
|
+
*
|
|
1080
|
+
* `CREATE EXTENSION` appears only when the project's database named `vector` in
|
|
1081
|
+
* its `extensions` — see `DatabaseOptions.extensions`. Everything else is
|
|
1082
|
+
* emitted either way: a database that already has pgvector installed by hand
|
|
1083
|
+
* needs no permission from anyone, and one that does not gets the column
|
|
1084
|
+
* refused by Postgres with a message this repo makes name the opt-in.
|
|
1085
|
+
*
|
|
1086
|
+
* Ordered as it must run: the extension, then the drift guard, then the column
|
|
1087
|
+
* whose type needs the extension, then the indexes over that column. Every
|
|
1088
|
+
* statement is `IF NOT EXISTS`, because this is replayed on every push and
|
|
1089
|
+
* appended to migrations that run against databases at any stage of their life.
|
|
1090
|
+
* Empty when no collection declares a vector — the caller writes no file then.
|
|
1091
|
+
*/
|
|
1092
|
+
var generatePostgresVectorDdl = (allCollections, options = {}) => {
|
|
1093
|
+
const withVectors = relationalCollections(allCollections).filter((c) => buildVectorColumnSpecs(c, resolveColumnName).length > 0);
|
|
1094
|
+
if (withVectors.length === 0) return "";
|
|
1095
|
+
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
|
|
1096
|
+
ddl += "--\n";
|
|
1097
|
+
ddl += "-- Vector columns and their ANN indexes, for the collections declaring a\n";
|
|
1098
|
+
ddl += "-- `vector` property. Applied by Rebase, not by Atlas — see\n";
|
|
1099
|
+
ddl += "-- generatePostgresVectorDdl.\n\n";
|
|
1100
|
+
ddl += vectorExtensionDeclared(options.extensions) ? `${vectorExtensionStatement()}\n\n` : `-- pgvector is not installed from here: no database declared it. To let Rebase\n-- install it, add ${VECTOR_EXTENSION_OPT_IN} in config/resources.ts.\n-- Otherwise install it once by hand; the column below needs the type either way.\n\n`;
|
|
1101
|
+
for (const collection of withVectors) {
|
|
1102
|
+
const table = `"${isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public"}"."${getTableName(collection)}"`;
|
|
1103
|
+
for (const spec of buildVectorColumnSpecs(collection, resolveColumnName)) {
|
|
1104
|
+
ddl += `${vectorDimensionGuard(spec)}\n`;
|
|
1105
|
+
ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${vectorColumnDefinition(spec)};\n`;
|
|
1106
|
+
}
|
|
1107
|
+
const plan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
1108
|
+
vectorIndexStatements(plan).forEach((s) => {
|
|
1109
|
+
ddl += `${s}\n`;
|
|
1110
|
+
});
|
|
1111
|
+
for (const skip of plan.skipped) ddl += `-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}\n`;
|
|
1112
|
+
ddl += "\n";
|
|
1113
|
+
}
|
|
1114
|
+
return ddl;
|
|
1115
|
+
};
|
|
890
1116
|
var generatePostgresDdl = async (allCollections, options = {
|
|
891
1117
|
includePolicies: true,
|
|
892
|
-
includeSearch: true
|
|
1118
|
+
includeSearch: true,
|
|
1119
|
+
includeVector: true
|
|
893
1120
|
}) => {
|
|
894
1121
|
const collections = relationalCollections(allCollections);
|
|
895
1122
|
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
@@ -912,6 +1139,7 @@ var generatePostgresDdl = async (allCollections, options = {
|
|
|
912
1139
|
ddl += `${s}\n\n`;
|
|
913
1140
|
});
|
|
914
1141
|
}
|
|
1142
|
+
if (options.includeVector === false && collections.some((c) => buildVectorColumnSpecs(c, resolveColumnName).length > 0)) ddl += "-- Vector columns and their ANN indexes live in `vector.sql`, applied separately.\n\n";
|
|
915
1143
|
const emittedEnums = /* @__PURE__ */ new Set();
|
|
916
1144
|
collections.forEach((collection) => {
|
|
917
1145
|
const collectionTable = getTableName(collection);
|
|
@@ -988,6 +1216,7 @@ var generatePostgresDdl = async (allCollections, options = {
|
|
|
988
1216
|
ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
|
|
989
1217
|
const columns = [];
|
|
990
1218
|
Object.entries(collection.properties ?? {}).forEach(([propName, prop]) => {
|
|
1219
|
+
if (prop.type === "vector" && options.includeVector === false) return;
|
|
991
1220
|
if (prop.type === "relation") {
|
|
992
1221
|
const refProp = prop;
|
|
993
1222
|
const relInfo = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
|
|
@@ -1067,9 +1296,11 @@ var generatePostgresDdl = async (allCollections, options = {
|
|
|
1067
1296
|
if (fuzzyDef) columns.push(` ${fuzzyDef}`);
|
|
1068
1297
|
indexStatements.push(...searchIndexStatements(searchSpec));
|
|
1069
1298
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1299
|
+
if (options.includeVector !== false) {
|
|
1300
|
+
const vectorPlan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
1301
|
+
indexStatements.push(...vectorIndexStatements(vectorPlan));
|
|
1302
|
+
for (const skip of vectorPlan.skipped) indexStatements.push(`-- No ANN index on "${skip.schema}"."${skip.table}"."${skip.column}": ${skip.reason}`);
|
|
1303
|
+
}
|
|
1073
1304
|
indexStatements.push(...collectionIndexStatements(buildCollectionIndexSpecs(collection, resolveColumnName)));
|
|
1074
1305
|
if (!columns.some((c) => c.includes("PRIMARY KEY"))) columns.unshift(" \"id\" TEXT PRIMARY KEY");
|
|
1075
1306
|
ddl += columns.join(",\n");
|
|
@@ -1478,15 +1709,17 @@ function planCollectionSchemaEnsure(allCollections, existing, options = {}) {
|
|
|
1478
1709
|
}
|
|
1479
1710
|
const searchSpecs = collections.map((c) => buildSearchColumnSpec(c)).filter((spec) => spec !== void 0);
|
|
1480
1711
|
const plannedExtensions = /* @__PURE__ */ new Set();
|
|
1481
|
-
|
|
1482
|
-
if (plannedExtensions.has(statement))
|
|
1712
|
+
const planExtension = (statement) => {
|
|
1713
|
+
if (plannedExtensions.has(statement)) return;
|
|
1483
1714
|
plannedExtensions.add(statement);
|
|
1484
1715
|
actions.push({
|
|
1485
1716
|
kind: "create-extension",
|
|
1486
1717
|
target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""),
|
|
1487
1718
|
sql: statement
|
|
1488
1719
|
});
|
|
1489
|
-
}
|
|
1720
|
+
};
|
|
1721
|
+
for (const spec of searchSpecs) for (const statement of searchExtensionStatements(spec)) planExtension(statement);
|
|
1722
|
+
if (vectorExtensionDeclared(options.databaseExtensions) && collections.some((c) => buildVectorColumnSpecs(c, resolveColumnName).length > 0)) planExtension(vectorExtensionStatement());
|
|
1490
1723
|
const plannedFunctions = /* @__PURE__ */ new Set();
|
|
1491
1724
|
for (const spec of searchSpecs) for (const statement of searchHelperFunctions(spec)) {
|
|
1492
1725
|
if (plannedFunctions.has(statement)) continue;
|
|
@@ -1812,24 +2045,6 @@ function searchDriftMessage(drift) {
|
|
|
1812
2045
|
return "The `search` block changed after its generated column was created, and Postgres cannot alter a generated expression in place.\nRebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path may not schedule on your behalf.\nUntil it is rebuilt the column keeps indexing the previous fields, weights and language — searches for anything added since return nothing, which reads from outside as \"no such row\".\nRun these (or revert the block to what the column was built from), then boot again:\n" + drift.map((d) => ` "${d.table}"."${d.column}" was generated from a different \`search\` block (recorded ${d.found}, current ${d.expected}).\n` + d.rebuild.map((s) => ` ${s}`).join("\n")).join("\n") + "\n The GIN index is dropped with the column and recreated concurrently on the next boot.";
|
|
1813
2046
|
}
|
|
1814
2047
|
/**
|
|
1815
|
-
* The missing-pgvector explanation, appended to the error that reveals it.
|
|
1816
|
-
*
|
|
1817
|
-
* A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
|
|
1818
|
-
* OSS pipeline installs pgvector — not this ensure, not `db push`. Installing
|
|
1819
|
-
* an extension on someone's database is a decision with a deployment behind it
|
|
1820
|
-
* (image, superuser, cloud allow-list), so this path stays a refusal; what it
|
|
1821
|
-
* must not stay is a bare `type "vector" does not exist` on a crash-looping
|
|
1822
|
-
* pod, which names nothing the reader can act on.
|
|
1823
|
-
*
|
|
1824
|
-
* The scaffold now ships `pgvector/pgvector:pg18`, so this is reached by a
|
|
1825
|
-
* project pointed at a database someone else provisioned — which is exactly
|
|
1826
|
-
* the case where naming the extension and the image is worth the words.
|
|
1827
|
-
*/
|
|
1828
|
-
function vectorExtensionHint(message) {
|
|
1829
|
-
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
1830
|
-
return "\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, so it needs an image that ships it (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. Rebase then creates an ANN index for the column automatically — see the `index` option on the property.";
|
|
1831
|
-
}
|
|
1832
|
-
/**
|
|
1833
2048
|
* Read what the database looks like, for the schemas a set of collections
|
|
1834
2049
|
* lives in.
|
|
1835
2050
|
*
|
|
@@ -1850,13 +2065,16 @@ async function readSchemaFactsFor(client, collections) {
|
|
|
1850
2065
|
* cannot be added, say) should not roll back the tables that were created fine.
|
|
1851
2066
|
* The error is surfaced with the statement that caused it.
|
|
1852
2067
|
*/
|
|
1853
|
-
async function ensureCollectionTables(client, collections, log) {
|
|
2068
|
+
async function ensureCollectionTables(client, collections, log, options = {}) {
|
|
1854
2069
|
const schemas = Array.from(/* @__PURE__ */ new Set([...collections.map(schemaOf), ...planJunctionTables(collections).map((j) => j.schema)]));
|
|
1855
2070
|
for (const schema of schemas) {
|
|
1856
2071
|
assertSafeIdentifier(schema, "schema name");
|
|
1857
2072
|
if (schema !== "public") await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}";`);
|
|
1858
2073
|
}
|
|
1859
|
-
const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas)
|
|
2074
|
+
const plan = planCollectionSchemaEnsure(collections, await readExistingSchema(client, schemas), {
|
|
2075
|
+
...options,
|
|
2076
|
+
databaseExtensions: options.databaseExtensions ?? declaredDatabaseExtensions()
|
|
2077
|
+
});
|
|
1860
2078
|
const failures = [];
|
|
1861
2079
|
for (const legacy of plan.legacyForeignKeys) {
|
|
1862
2080
|
const message = `Renaming "${legacy.table}"."${legacy.legacy}" to "${legacy.expected}". The old name is the one Rebase derived for this relation before it singularized properly; the column keeps its data, indexes and constraints. To keep the old name instead, set \`localKey: "${legacy.legacy}"\` on the relation and this will stop.`;
|
|
@@ -1947,6 +2165,6 @@ async function applyAction(client, action) {
|
|
|
1947
2165
|
}
|
|
1948
2166
|
}
|
|
1949
2167
|
//#endregion
|
|
1950
|
-
export { generatePostgresDdl as a,
|
|
2168
|
+
export { resolveStringColumnLength as _, generatePostgresDdl as a, generatePostgresVectorDdl as c, AUTH_USERS_COLUMNS as d, authUsersColumnSql as f, visibleColumnProjection as g, hiddenColumnsOption as h, readSchemaFactsFor as i, planCollectionPolicies as l, buildSearchColumnSpec as m, planCollectionSchemaEnsure as n, generatePostgresPoliciesDdl as o, SEARCH_UNACCENT_FN as p, readExistingSchema as r, generatePostgresSearchDdl as s, ensure_collection_tables_exports as t, resolveColumnName as u };
|
|
1951
2169
|
|
|
1952
|
-
//# sourceMappingURL=ensure-collection-tables-
|
|
2170
|
+
//# sourceMappingURL=ensure-collection-tables-DgVixhX3.js.map
|