@rebasepro/common 0.27.0 → 0.28.1

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.
@@ -1 +1 @@
1
- {"version":3,"file":"policyToPostgres.es.js","names":[],"sources":["../../../src/util/policy/policyToPostgres.ts"],"sourcesContent":["import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ResolvedRelation, ExistsInPolicyExpression, RLS_IS_ANONYMOUS_SQL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, rewriteLegacyRlsFunctions } from \"@rebasepro/types\";\nimport { toSnakeCase } from \"@rebasepro/utils\";\nimport { findRelation, getTableName, resolveCollectionRelations } from \"../relations\";\n\n/**\n * Options for {@link policyToPostgres}.\n */\nexport interface PolicyCompileOptions {\n /**\n * Resolve a collection by slug. Required to compile\n * {@link ExistsInPolicyExpression} (`policy.existsIn`) — the compiler needs\n * the joined collection to derive its table name / schema. When omitted, the\n * join table falls back to a snake_cased slug.\n */\n resolveCollection?: (slug: string) => CollectionConfig | undefined;\n}\n\n/**\n * The lexical scope threaded through compilation. It changes when we descend\n * into an `existsIn` subquery: inside it, `field` refers to the joined table\n * (aliased) while `outerField` refers to the outer RLS row (table-qualified).\n */\ninterface CompileScope {\n /** Collection whose columns a bare `field` operand resolves against. */\n fieldCollection?: CollectionConfig;\n /** SQL prefix for `field` operands (`\"\"` at top level, `\"alias\".` in a subquery). */\n fieldPrefix: string;\n /** The outer RLS collection, for `outerField` operands. */\n outerCollection?: CollectionConfig;\n /** SQL prefix for `outerField` operands (`\"\"` at top level, `\"schema\".\"table\".` in a subquery). */\n outerPrefix: string;\n resolveCollection?: (slug: string) => CollectionConfig | undefined;\n /** Monotonic counter for generating unique subquery aliases. */\n alias: { n: number };\n /**\n * True inside a hoisted `(SELECT …)`. The subquery already evaluates its\n * whole body once per statement, so the helpers in it are emitted bare.\n */\n inInitPlan: boolean;\n}\n\n/**\n * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,\n * suitable for a `USING (...)` / `WITH CHECK (...)` clause.\n *\n * This is one of the two consumers of the shared policy model (the other being\n * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL\n * and the admin UI derive from the exact same expression.\n *\n * ## Row-independent parts are evaluated once per statement\n *\n * Every part of a policy that does not read the row — `serverContext()`, a\n * roles test, a comparison of the caller with a literal, a claim — is wrapped\n * in a scalar subquery: the default baseline compiles to\n * `(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))`,\n * and an owner rule to `owner_id = (SELECT rebase.uid())`. Postgres plans an\n * uncorrelated scalar subquery as an initPlan, computed once per statement.\n *\n * Bare, the same clause is a security-barrier qual Postgres evaluates per row,\n * and it estimates `uid() IS NULL OR roles && …` to pass 1.5% of the table. A\n * page past that point (offset 20,000 of a million rows) was planned as a\n * sequential scan and an on-disk sort of the whole table: 7.3 s and a 640 MB\n * sort per request, against 4 ms off the primary key once the predicate is an\n * initPlan, whose estimate is 50%.\n *\n * Same meaning, by construction: only subtrees that reference no column are\n * wrapped (never `field`, `outerField`, `existsIn` or `raw`), a scalar subquery\n * without `FROM` returns exactly one row whose value is the expression's —\n * NULL included, so three-valued logic is untouched — and the helpers are\n * STABLE, so their value cannot change within a statement anyway.\n * `policy-shapes-equivalence.test.ts` runs every shape against Postgres both\n * ways.\n */\nexport function policyToPostgres(expr: PolicyExpression, collection?: CollectionConfig, options?: PolicyCompileOptions): string {\n return compile(expr, {\n fieldCollection: collection,\n fieldPrefix: \"\",\n outerCollection: collection,\n outerPrefix: \"\",\n resolveCollection: options?.resolveCollection,\n alias: { n: 0 },\n inInitPlan: false\n });\n}\n\n/**\n * Whether `expr` never reads the row: no `field` / `outerField` operand, no\n * correlated `existsIn`, no `raw` SQL (which may name any column).\n */\nfunction readsNoRow(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"and\":\n case \"or\":\n return expr.operands.every(readsNoRow);\n case \"not\":\n return readsNoRow(expr.operand);\n case \"compare\":\n return !readsRow(expr.left) && !readsRow(expr.right);\n case \"existsIn\":\n case \"raw\":\n return false;\n default:\n return true;\n }\n}\n\nconst readsRow = (operand: PolicyOperand): boolean =>\n operand.kind === \"field\" || operand.kind === \"outerField\";\n\nconst callsHelper = (operand: PolicyOperand): boolean =>\n operand.kind === \"authUid\" || operand.kind === \"authRoles\" || operand.kind === \"authClaim\";\n\n/** Whether `expr` calls one of the `rebase.*` helpers anywhere. */\nfunction callsAuthHelper(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"and\":\n case \"or\":\n return expr.operands.some(callsAuthHelper);\n case \"not\":\n return callsAuthHelper(expr.operand);\n case \"compare\":\n return callsHelper(expr.left) || callsHelper(expr.right);\n case \"rolesOverlap\":\n case \"rolesContain\":\n case \"authenticated\":\n case \"registered\":\n case \"serverContext\":\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether `expr` is compiled as one `(SELECT …)`: it reads no row and calls a\n * helper (a constant has nothing to save).\n *\n * `authenticated()` and `registered()` on their own are the exception: they\n * wrap each helper call instead — `(SELECT rebase.uid()) IS NOT NULL AND …`.\n * Their estimate is already near 100%, so there is no plan to fix, and the\n * per-call spelling is the one RLS linters can still read: `rls-check` flags a\n * policy that is nothing but \"signed in\" (every account reads every row) by\n * splitting it on AND, and it reads `( SELECT rebase.uid() AS uid)` as the\n * caller id — but a whole conjunction inside one subquery is opaque to it.\n * Inside a larger hoisted subtree they are emitted bare, like everything else\n * there.\n */\nfunction isHoistable(expr: PolicyExpression): boolean {\n if (expr.kind === \"authenticated\" || expr.kind === \"registered\") return false;\n return readsNoRow(expr) && callsAuthHelper(expr);\n}\n\n/** A helper call, as an initPlan unless it already sits inside one. */\nfunction helperSql(sqlText: string, scope: CompileScope): string {\n return scope.inInitPlan ? sqlText : `(SELECT ${sqlText})`;\n}\n\nfunction compile(expr: PolicyExpression, scope: CompileScope): string {\n if (!scope.inInitPlan && isHoistable(expr)) {\n return `(SELECT ${compile(expr, { ...scope, inInitPlan: true })})`;\n }\n switch (expr.kind) {\n case \"true\":\n return \"true\";\n case \"false\":\n return \"false\";\n case \"and\":\n return expr.operands.length === 0\n ? \"true\"\n : expr.operands.map(o => `(${compile(o, scope)})`).join(\" AND \");\n case \"or\":\n return expr.operands.length === 0\n ? \"false\"\n : expr.operands.map(o => `(${compile(o, scope)})`).join(\" OR \");\n case \"not\":\n return `NOT (${compile(expr.operand, scope)})`;\n case \"compare\": {\n // `rebase.uid()` returns text; cast the column side so uuid / integer\n // id columns compare cleanly instead of failing with\n // \"operator does not exist: uuid = text\" at CREATE POLICY time.\n const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>\n other.kind === \"authUid\" && (operand.kind === \"field\" || operand.kind === \"outerField\")\n ? `(${sqlText})::text`\n : sqlText;\n // A claim is text too, and the same mismatch applies — but here the\n // cast goes the OTHER way, onto the claim. Casting the column would\n // compile and would take the index off it, and this operand exists\n // to carry a tenancy predicate that is ANDed into every read of the\n // table. See {@link claimCastType}.\n const claimSql = (operand: PolicyOperand, other: PolicyOperand): string | undefined =>\n operand.kind === \"authClaim\"\n ? helperSql(authClaimSql(operand.name, claimCastType(other, scope)), scope)\n : undefined;\n const leftSql = claimSql(expr.left, expr.right)\n ?? castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);\n const rightSql = claimSql(expr.right, expr.left)\n ?? castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);\n return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;\n }\n case \"rolesOverlap\":\n return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;\n case \"rolesContain\":\n return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;\n case \"authenticated\":\n // `IS NOT NULL` alone is a tautology on the user path: every\n // user-context request sets `app.uid`, and an anonymous one sets\n // it to a sentinel. Excluding the sentinels is what makes this mean\n // \"signed in\" rather than \"anyone at all\".\n //\n // Every sentinel, not just the current one. This clause is written\n // into the database and outlives the server that generated it: a\n // policy compiled here may be enforced against an older server that\n // still reports `'anon'`, which is exactly how excluding one\n // spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.\n return `${helperSql(RLS_UID_SQL, scope)} IS NOT NULL`\n + ` AND ${helperSql(RLS_UID_SQL, scope)} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(\", \")})`;\n case \"registered\":\n // \"Signed in\" AND \"not a guest\". The first half is the same clause\n // `authenticated` compiles to; the second is the fact anonymous\n // sign-in used not to put anywhere the database could see, so a\n // guest and an account were one principal inside every policy.\n //\n // `rebase.is_anonymous()` defaults to false when its GUC is unset,\n // so a policy compiled here and enforced by an older server reads\n // every session as an account — which is the behaviour that\n // deployment already had, rather than a lockout.\n return `${helperSql(RLS_UID_SQL, scope)} IS NOT NULL`\n + ` AND ${helperSql(RLS_UID_SQL, scope)} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(\", \")})`\n + ` AND NOT ${helperSql(RLS_IS_ANONYMOUS_SQL, scope)}`;\n case \"serverContext\":\n // Only the built-in server flows leave `app.uid` unset.\n return `${RLS_UID_SQL} IS NULL`;\n case \"existsIn\":\n return compileExistsIn(expr, scope);\n case \"raw\": {\n // A project written against a pre-1.0 release may still spell the\n // helpers `auth.uid()`. Rewritten rather than rejected: the rule\n // means exactly the same thing, the developer cannot be expected to\n // have read a changelog mid-deploy, and the alternative is a policy\n // that compiles cleanly and then denies every row at runtime because\n // it calls a function that no longer exists.\n //\n // The counterpart is `warnOnLegacyRlsFunctions`, which says so once\n // at boot with the file to edit — silence here would leave the old\n // spelling working forever and make the migration permanent.\n const sqlText = rewriteLegacyRlsFunctions(expr.sql);\n\n // Full-power escape hatch: `{column}` denotes a column of the outer\n // RLS row. It must be table-qualified, not bare: raw SQL may open its\n // own subquery over the same table, and there a bare name binds to the\n // inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.\n return substituteRawPlaceholders(sqlText, (col) =>\n `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);\n }\n }\n}\n\n/**\n * Replace each `{column}` placeholder in raw SQL with `replace(column)`, but\n * only in the SQL itself: not inside a string literal (`'…'`, `E'…'` with its\n * backslash escapes, `$$…$$`, `$tag$…$tag$`), a quoted identifier (`\"…\"`) or a\n * comment (`--` to the end of the line, or a block comment, which nests).\n *\n * A placeholder inside a literal is never meant: it would put the column's\n * *name* into the text. A regex quantifier is, and it has the same shape. A\n * stored claim-tenancy policy on a uuid column guards its cast with\n * `'^[0-9a-fA-F]{8}-…{12}$'`, so imported through \"Import from table\" or the\n * Studio's \"Import to codebase\" (which keep it as `raw`), its `{8}` became\n * `\"public\".\"projects\".\"8\"`, the guard never matched, and the policy denied\n * every tenant.\n */\nfunction substituteRawPlaceholders(sql: string, replace: (column: string) => string): string {\n let out = \"\";\n let i = 0;\n while (i < sql.length) {\n const c = sql[i];\n let end = i + 1;\n if (c === \"'\") {\n end = skipQuoted(sql, i, \"'\", isEscapeStringPrefix(sql, i));\n } else if (c === \"\\\"\") {\n end = skipQuoted(sql, i, \"\\\"\", false);\n } else if (c === \"-\" && sql[i + 1] === \"-\") {\n const newline = sql.indexOf(\"\\n\", i);\n end = newline === -1 ? sql.length : newline;\n } else if (c === \"/\" && sql[i + 1] === \"*\") {\n end = skipBlockComment(sql, i);\n } else if (c === \"$\") {\n const tag = dollarQuoteTagAt(sql, i);\n if (tag) {\n const close = sql.indexOf(tag, i + tag.length);\n end = close === -1 ? sql.length : close + tag.length;\n }\n } else if (c === \"{\") {\n const placeholder = /^\\{(\\w+)\\}/.exec(sql.slice(i));\n if (placeholder) {\n out += replace(placeholder[1]);\n i += placeholder[0].length;\n continue;\n }\n }\n out += sql.slice(i, end);\n i = end;\n }\n return out;\n}\n\n/** A character that continues an identifier, `$` included (`a$b` is one name). */\nconst IDENTIFIER_CHAR = /[A-Za-z0-9_$\\u0080-\\uffff]/;\n\n/** The end of the `quote`-quoted span opening at `start`; a doubled quote stays inside. */\nfunction skipQuoted(sql: string, start: number, quote: string, backslashEscapes: boolean): number {\n let i = start + 1;\n while (i < sql.length) {\n if (backslashEscapes && sql[i] === \"\\\\\") {\n i += 2;\n } else if (sql[i] === quote) {\n if (sql[i + 1] !== quote) return i + 1;\n i += 2;\n } else {\n i++;\n }\n }\n return sql.length;\n}\n\n/** Whether the `'` at `quote` opens an `E'…'` string, where `\\'` escapes a quote. */\nfunction isEscapeStringPrefix(sql: string, quote: number): boolean {\n const prefix = sql[quote - 1];\n return (prefix === \"E\" || prefix === \"e\") && !IDENTIFIER_CHAR.test(sql[quote - 2] ?? \"\");\n}\n\n/** The end of the block comment opening at `start`. Postgres nests them. */\nfunction skipBlockComment(sql: string, start: number): number {\n let depth = 0;\n let i = start;\n while (i < sql.length) {\n if (sql[i] === \"/\" && sql[i + 1] === \"*\") {\n depth++;\n i += 2;\n } else if (sql[i] === \"*\" && sql[i + 1] === \"/\") {\n depth--;\n i += 2;\n if (depth === 0) return i;\n } else {\n i++;\n }\n }\n return sql.length;\n}\n\n/**\n * The dollar-quote tag (`$$`, `$tag$`) opening at `at`, or undefined. Not one\n * after an identifier character (`a$b$` is a name), nor `$1` (a parameter: a\n * tag cannot start with a digit).\n */\nfunction dollarQuoteTagAt(sql: string, at: number): string | undefined {\n if (at > 0 && IDENTIFIER_CHAR.test(sql[at - 1])) return undefined;\n return /^\\$(?:[A-Za-z_\\u0080-\\uffff][A-Za-z0-9_\\u0080-\\uffff]*)?\\$/.exec(sql.slice(at))?.[0];\n}\n\n/**\n * Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.\n * Inside the subquery, `field` operands bind to the aliased join table and\n * `outerField` operands bind to the (table-qualified) outer RLS row.\n */\nfunction compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {\n const join = scope.resolveCollection?.(expr.collection);\n const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);\n const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? \"public\";\n const alias = `_ex${scope.alias.n++}`;\n\n // `outerField` inside the subquery must be qualified with the outer table,\n // otherwise a bare column name would bind to the joined table instead.\n const outerPrefix = outerQualifier(scope);\n\n const innerScope: CompileScope = {\n fieldCollection: join,\n fieldPrefix: `\"${alias}\".`,\n outerCollection: scope.outerCollection,\n outerPrefix,\n resolveCollection: scope.resolveCollection,\n alias: scope.alias,\n inInitPlan: scope.inInitPlan\n };\n return `EXISTS (SELECT 1 FROM \"${joinSchema}\".\"${joinTable}\" \"${alias}\" WHERE ${compile(expr.where, innerScope)})`;\n}\n\nconst COMPARE_SQL: Record<PolicyCompareOperator, string> = {\n eq: \"=\",\n neq: \"!=\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\"\n};\n\nfunction operandToSql(operand: PolicyOperand, scope: CompileScope): string {\n switch (operand.kind) {\n case \"field\":\n return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;\n case \"outerField\":\n return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;\n case \"literal\":\n return quoteLiteral(operand.value);\n case \"authUid\":\n return helperSql(RLS_UID_SQL, scope);\n case \"authRoles\":\n return helperSql(`string_to_array(${RLS_ROLES_SQL}, ',')`, scope);\n case \"authClaim\":\n // Uncast — the shape a claim has when nothing says what it is being\n // compared against (a claim on both sides, or against a literal).\n // The `compare` arm replaces this whenever the other operand names\n // a typed column.\n return helperSql(authClaimSql(operand.name, \"text\"), scope);\n }\n}\n\n/** Postgres types a text claim is cast to. `\"text\"` is the no-cast case. */\ntype ClaimCastType = \"text\" | \"uuid\" | \"bigint\" | \"numeric\";\n\n/**\n * The Postgres type a claim has to be cast to, to be compared with `operand`.\n *\n * `\"text\"` means \"no cast\": a claim already is text, and a `text` / `varchar`\n * column compares with it directly and keeps using its index.\n *\n * Resolved from the *property*, and through a relation's target when the column\n * is a foreign key — a `belongsTo` tenant field is the ordinary shape, and its\n * column's type is the target collection's primary key type rather than\n * anything visible on the property itself. Unknown resolves to `\"text\"`, which\n * is the safe direction: a redundant `text` comparison costs nothing, while a\n * missing `uuid` cast is a `CREATE POLICY` that fails and leaves a table with\n * RLS enabled and no policy — which denies every row.\n */\nfunction claimCastType(operand: PolicyOperand, scope: CompileScope): ClaimCastType {\n if (operand.kind !== \"field\" && operand.kind !== \"outerField\") return \"text\";\n const collection = operand.kind === \"field\" ? scope.fieldCollection : scope.outerCollection;\n return propertyClaimCastType(operand.name, collection, scope.resolveCollection);\n}\n\n/**\n * What `name` on `collection` compares against a text claim as.\n *\n * `depth` stops a `reference` cycle — two collections whose keys point at each\n * other — from recursing forever. Two hops is more than any real declaration\n * needs.\n */\nfunction propertyClaimCastType(\n name: string,\n collection: CollectionConfig | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined,\n depth = 0\n): ClaimCastType {\n const prop = collection?.properties?.[name] as Property | undefined;\n if (!prop || depth > 2) return \"text\";\n\n switch (prop.type) {\n case \"string\": {\n const sp = prop as { isId?: unknown; columnType?: unknown; enum?: unknown };\n if (sp.enum) return \"text\";\n return sp.isId === \"uuid\" || sp.columnType === \"uuid\" ? \"uuid\" : \"text\";\n }\n case \"number\": {\n const np = prop as { columnType?: string; isId?: unknown; validation?: { integer?: boolean } };\n if (np.columnType === \"numeric\") return \"numeric\";\n if (np.columnType || np.validation?.integer || np.isId) return \"bigint\";\n // A `number` with no `columnType` and no `validation.integer` is\n // NUMERIC — see `numberType` in the schema planner.\n return \"numeric\";\n }\n case \"reference\":\n return primaryKeyClaimCastType(\n resolveTargetCollection((prop as { path?: string }).path, resolveCollection),\n resolveCollection,\n depth\n );\n case \"relation\":\n return primaryKeyClaimCastType(\n relationTarget(name, prop, collection, resolveCollection),\n resolveCollection,\n depth\n );\n default:\n return \"text\";\n }\n}\n\n/** The cast a column pointing at `target`'s primary key needs. */\nfunction primaryKeyClaimCastType(\n target: CollectionConfig | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined,\n depth: number\n): ClaimCastType {\n if (!target) return \"text\";\n for (const [key, property] of Object.entries(target.properties ?? {})) {\n if (!(property as { isId?: unknown })?.isId) continue;\n return propertyClaimCastType(key, target, resolveCollection, depth + 1);\n }\n // No declared key: the implicit `id TEXT PRIMARY KEY`.\n return \"text\";\n}\n\n/**\n * A relation's target slug, whatever form the declaration took.\n *\n * `target` is a slug on a plain object and a thunk on a builder — the two\n * shapes `resolveRelation` normalises — and this runs on the raw property,\n * before that resolution.\n */\nfunction relationTargetSlug(relation: { target?: unknown } | undefined): string | undefined {\n const target = relation?.target;\n if (typeof target === \"string\") return target;\n if (typeof target !== \"function\") return undefined;\n try {\n const slug = ((target as () => unknown)() as { slug?: unknown })?.slug;\n return typeof slug === \"string\" ? slug : undefined;\n } catch {\n // A thunk needing a registry this compilation does not have. `text` is\n // the fallback, and a fallback is not worth failing a compile over.\n return undefined;\n }\n}\n\nfunction resolveTargetCollection(\n slug: string | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined\n): CollectionConfig | undefined {\n if (!slug || !resolveCollection) return undefined;\n // A `reference` path may be nested; the collection is its last segment.\n return resolveCollection(slug) ?? resolveCollection(slug.split(\"/\").pop() as string);\n}\n\n/**\n * `NULLIF(rebase.jwt() ->> 'name', '')`, cast to the column's type.\n *\n * Two things here are load-bearing beyond the cast:\n *\n * - **`NULLIF(…, '')`.** An absent claim already reads as NULL, but one set to\n * the empty string does not, and `''::uuid` raises rather than denying. Both\n * spellings of \"this caller has no tenant\" have to reach the comparison as\n * NULL, which is never true and therefore never a grant.\n * - **The guard.** The cast sits inside a `CASE` that first checks the text is\n * well-formed, because `'nonsense'::uuid` raises `invalid input syntax` — and\n * a policy that raises does not deny a row, it fails the whole statement. A\n * caller holding a malformed claim would get a 500 on every read of the table\n * instead of an empty list. `CASE` rather than an `AND` guard because only\n * `CASE` is guaranteed not to evaluate its arms out of order.\n *\n * The whole expression is STABLE (`rebase.jwt()` is), so Postgres evaluates it\n * once per query and can still use a btree index on the column it is compared\n * against — which is the entire reason the cast is on this side.\n */\nfunction authClaimSql(name: string, cast: ClaimCastType): string {\n const claim = `NULLIF(${RLS_JWT_SQL} ->> ${quoteLiteral(name)}, '')`;\n if (cast === \"text\") return claim;\n return `CASE WHEN ${claim} ~ '${CLAIM_CAST_GUARDS[cast]}' THEN (${claim})::${cast} END`;\n}\n\n/**\n * The text a claim must match before it is cast, per target type.\n *\n * The `bigint` guard caps the digit run at 18 rather than matching any run of\n * digits: `'99999999999999999999'::bigint` is a range error, which fails the\n * statement exactly as the syntax error would have.\n */\nconst CLAIM_CAST_GUARDS: Record<Exclude<ClaimCastType, \"text\">, string> = {\n uuid: \"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\",\n bigint: \"^-?[0-9]{1,18}$\",\n numeric: \"^-?[0-9]+(\\\\.[0-9]+)?$\"\n};\n\n/**\n * SQL prefix that qualifies a column of the outer RLS row (`\"schema\".\"table\".`),\n * or `\"\"` when the collection is unknown.\n */\nfunction outerQualifier(scope: CompileScope): string {\n const table = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;\n if (!table) return \"\";\n return `\"${schemaOf(scope.outerCollection) ?? \"public\"}\".\"${table}\".`;\n}\n\nfunction schemaOf(collection?: CollectionConfig): string | undefined {\n return (collection as { schema?: string } | undefined)?.schema || undefined;\n}\n\nfunction resolveColumnName(propName: string, collection?: CollectionConfig): string {\n const prop = collection?.properties?.[propName] as Property | undefined;\n if (prop && \"columnName\" in prop && typeof (prop as { columnName?: unknown }).columnName === \"string\") {\n return quoteColumnIdentifier((prop as { columnName: string }).columnName);\n }\n const relationColumn = belongsToColumn(propName, prop, collection);\n if (relationColumn) return quoteColumnIdentifier(relationColumn);\n return quoteColumnIdentifier(toSnakeCase(propName));\n}\n\n/**\n * The foreign key column a `relation` property addresses, when it has one.\n *\n * A `belongsTo` property is a *link*, and its column is the relation's\n * `localKey` — `org` addresses `org_id`. `toSnakeCase` cannot know that, so a\n * rule naming a relation (`ownerField: \"org\"`, a tenancy declaration over a\n * `belongsTo`) compiled to a comparison against a column called `org`, which\n * does not exist. `CREATE POLICY` then fails, and a table with RLS enabled and\n * no policy denies every row — the loudest possible symptom for the quietest\n * possible cause, since the rule reads exactly right.\n *\n * Only `belongsTo`. Every other kind puts its column on the target table, in a\n * junction row, or nowhere at all, and there is no column here to name.\n */\nfunction belongsToColumn(\n propName: string,\n prop: Property | undefined,\n collection?: CollectionConfig\n): string | undefined {\n if (prop?.type !== \"relation\") return undefined;\n const relation = resolvePropertyRelation(propName, collection);\n return relation?.kind === \"belongsTo\" ? relation.localKey : undefined;\n}\n\n/**\n * The resolved relation a `relation` property stands for, by the only name\n * both declarations share: the property's own key.\n *\n * A property may carry the link inline (`relation: { kind, target }`) or name\n * an entry in the collection's `relations` array — and the second form is\n * matched by the property key, so there is nothing on the property to read.\n * `resolveCollectionRelations` normalises both under that key, which is why\n * this asks it rather than reading the property.\n */\nfunction resolvePropertyRelation(\n propName: string,\n collection: CollectionConfig | undefined\n): ResolvedRelation | undefined {\n if (!collection) return undefined;\n try {\n return findRelation(resolveCollectionRelations(collection), propName);\n } catch {\n // A relation whose target is not in this bundle. The old spelling is\n // no worse than failing to compile at all.\n return undefined;\n }\n}\n\n/**\n * The collection a `relation` property points at.\n *\n * Preferred over the property's raw `target` because only the resolved\n * relation covers both declarations, and because a resolved `target()` hands\n * back the collection itself — no registry needed. The slug path stays as the\n * fallback for a `target` written as a plain string, which cannot become a\n * collection without one.\n */\nfunction relationTarget(\n propName: string,\n prop: Property,\n collection: CollectionConfig | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined\n): CollectionConfig | undefined {\n const relation = resolvePropertyRelation(propName, collection);\n if (relation) {\n try {\n const target = relation.target();\n if (target) return target;\n } catch {\n // A thunk needing a registry this compilation does not have.\n }\n }\n return resolveTargetCollection(\n relationTargetSlug((prop as { relation?: { target?: unknown } }).relation),\n resolveCollection\n );\n}\n\n/**\n * Every PostgreSQL keyword that cannot stand as a bare column reference.\n * Appendix C's two reserved categories — plain \"reserved\", and \"reserved (can\n * be function or type name)\" — since neither may name a column unquoted.\n */\nconst RESERVED_SQL_WORDS = new Set([\n \"all\", \"analyse\", \"analyze\", \"and\", \"any\", \"array\", \"as\", \"asc\", \"asymmetric\", \"authorization\",\n \"binary\", \"both\", \"case\", \"cast\", \"check\", \"collate\", \"collation\", \"column\", \"concurrently\",\n \"constraint\", \"create\", \"cross\", \"current_catalog\", \"current_date\", \"current_role\",\n \"current_schema\", \"current_time\", \"current_timestamp\", \"current_user\", \"default\", \"deferrable\",\n \"desc\", \"distinct\", \"do\", \"else\", \"end\", \"except\", \"false\", \"fetch\", \"for\", \"foreign\", \"freeze\",\n \"from\", \"full\", \"grant\", \"group\", \"having\", \"ilike\", \"in\", \"initially\", \"inner\", \"intersect\",\n \"into\", \"is\", \"isnull\", \"join\", \"lateral\", \"leading\", \"left\", \"like\", \"limit\", \"localtime\",\n \"localtimestamp\", \"natural\", \"not\", \"notnull\", \"null\", \"offset\", \"on\", \"only\", \"or\", \"order\",\n \"outer\", \"overlaps\", \"placing\", \"primary\", \"references\", \"returning\", \"right\", \"select\",\n \"session_user\", \"similar\", \"some\", \"symmetric\", \"system_user\", \"table\", \"tablesample\", \"then\",\n \"to\", \"trailing\", \"true\", \"union\", \"unique\", \"user\", \"using\", \"variadic\", \"verbose\", \"when\",\n \"where\", \"window\", \"with\"\n]);\n\n/** An identifier Postgres reads back unchanged without quotes. */\nconst BARE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;\n\n/**\n * Quote a column reference when Postgres would not read the bare name as that\n * column — and only then.\n *\n * Three ways a bare name goes wrong, in ascending order of how long it takes to\n * notice:\n *\n * - **Case.** `columnName` is used verbatim, and `rebase schema introspect`\n * populates it from a live database, so a legacy `\"createdAt\"` column arrives\n * spelled exactly that way. Unquoted, Postgres folds it to `createdat` and\n * `CREATE POLICY` fails with \"column does not exist\" — the collection keeps\n * RLS enabled with no policy, which denies every row.\n * - **Syntax.** A column named `order` or `default` is a syntax error mid-clause.\n * - **Silent rebinding.** `user`, `current_user`, `session_user`, `current_date`\n * and friends are *valid bare expressions*, so the policy compiles, applies,\n * and is reported as a success — while comparing against the connected role\n * or the wall clock instead of the column. Under RLS every request runs as the\n * same `rebase_user` role, so `USING (user = rebase.uid())` is a constant: it\n * denies everything, and its negation admits everything.\n *\n * Only the names that need it are quoted, so an ordinary snake_case policy body\n * is emitted byte-for-byte as before. That keeps generated artifacts and the\n * policies already stored in shipped databases stable — this fix reaches the\n * clauses that were broken and no others.\n */\nfunction quoteColumnIdentifier(name: string): string {\n if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;\n return `\"${name.replace(/\"/g, \"\\\"\\\"\")}\"`;\n}\n\nfunction quoteLiteral(value: string | number | boolean | null): string {\n if (value === null) return \"NULL\";\n if (typeof value === \"boolean\") return value ? \"true\" : \"false\";\n if (typeof value === \"number\") return String(value);\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n\n/** Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output. */\nfunction rolesArraySql(roles: readonly string[]): string {\n return `ARRAY[${[...roles].sort().map(r => `'${r}'`).join(\",\")}]`;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"policyToPostgres.es.js","names":[],"sources":["../../../src/util/policy/policyToPostgres.ts"],"sourcesContent":["import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ResolvedRelation, ExistsInPolicyExpression, RLS_IS_ANONYMOUS_SQL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, rewriteLegacyRlsFunctions } from \"@rebasepro/types\";\nimport { toSnakeCase } from \"@rebasepro/utils\";\nimport { findRelation, getTableName, resolveCollectionRelations } from \"../relations\";\n\n/**\n * Options for {@link policyToPostgres}.\n */\nexport interface PolicyCompileOptions {\n /**\n * Resolve a collection by slug. Required to compile\n * {@link ExistsInPolicyExpression} (`policy.existsIn`) — the compiler needs\n * the joined collection to derive its table name / schema. When omitted, the\n * join table falls back to a snake_cased slug.\n */\n resolveCollection?: (slug: string) => CollectionConfig | undefined;\n}\n\n/**\n * The lexical scope threaded through compilation. It changes when we descend\n * into an `existsIn` subquery: inside it, `field` refers to the joined table\n * (aliased) while `outerField` refers to the outer RLS row (table-qualified).\n */\ninterface CompileScope {\n /** Collection whose columns a bare `field` operand resolves against. */\n fieldCollection?: CollectionConfig;\n /** SQL prefix for `field` operands (`\"\"` at top level, `\"alias\".` in a subquery). */\n fieldPrefix: string;\n /** The outer RLS collection, for `outerField` operands. */\n outerCollection?: CollectionConfig;\n /** SQL prefix for `outerField` operands (`\"\"` at top level, `\"schema\".\"table\".` in a subquery). */\n outerPrefix: string;\n resolveCollection?: (slug: string) => CollectionConfig | undefined;\n /** Monotonic counter for generating unique subquery aliases. */\n alias: { n: number };\n /**\n * True inside a hoisted `(SELECT …)`. The subquery already evaluates its\n * whole body once per statement, so the helpers in it are emitted bare.\n */\n inInitPlan: boolean;\n}\n\n/**\n * Compiles a {@link PolicyExpression} to a PostgreSQL boolean SQL string,\n * suitable for a `USING (...)` / `WITH CHECK (...)` clause.\n *\n * This is one of the two consumers of the shared policy model (the other being\n * {@link evaluatePolicy}); the Postgres schema generators call it so that DDL\n * and the admin UI derive from the exact same expression.\n *\n * ## Row-independent parts are evaluated once per statement\n *\n * Every part of a policy that does not read the row — `serverContext()`, a\n * roles test, a comparison of the caller with a literal, a claim — is wrapped\n * in a scalar subquery: the default baseline compiles to\n * `(SELECT (rebase.uid() IS NULL) OR (string_to_array(rebase.roles(), ',') && ARRAY['admin']))`,\n * and an owner rule to `owner_id = (SELECT rebase.uid())`. Postgres plans an\n * uncorrelated scalar subquery as an initPlan, computed once per statement.\n *\n * Bare, the same clause is a security-barrier qual Postgres evaluates per row,\n * and it estimates `uid() IS NULL OR roles && …` to pass 1.5% of the table. A\n * page past that point (offset 20,000 of a million rows) was planned as a\n * sequential scan and an on-disk sort of the whole table: 7.3 s and a 640 MB\n * sort per request, against 4 ms off the primary key once the predicate is an\n * initPlan, whose estimate is 50%.\n *\n * Same meaning, by construction: only subtrees that reference no column are\n * wrapped (never `field`, `outerField`, `existsIn` or `raw`), a scalar subquery\n * without `FROM` returns exactly one row whose value is the expression's —\n * NULL included, so three-valued logic is untouched — and the helpers are\n * STABLE, so their value cannot change within a statement anyway.\n * `policy-shapes-equivalence.test.ts` runs every shape against Postgres both\n * ways.\n */\nexport function policyToPostgres(expr: PolicyExpression, collection?: CollectionConfig, options?: PolicyCompileOptions): string {\n return compile(expr, {\n fieldCollection: collection,\n fieldPrefix: \"\",\n outerCollection: collection,\n outerPrefix: \"\",\n resolveCollection: options?.resolveCollection,\n alias: { n: 0 },\n inInitPlan: false\n });\n}\n\n/**\n * Whether `expr` never reads the row: no `field` / `outerField` operand, no\n * correlated `existsIn`, no `raw` SQL (which may name any column).\n */\nfunction readsNoRow(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"and\":\n case \"or\":\n return expr.operands.every(readsNoRow);\n case \"not\":\n return readsNoRow(expr.operand);\n case \"compare\":\n return !readsRow(expr.left) && !readsRow(expr.right);\n case \"existsIn\":\n case \"raw\":\n return false;\n default:\n return true;\n }\n}\n\nconst readsRow = (operand: PolicyOperand): boolean =>\n operand.kind === \"field\" || operand.kind === \"outerField\";\n\nconst callsHelper = (operand: PolicyOperand): boolean =>\n operand.kind === \"authUid\" || operand.kind === \"authRoles\" || operand.kind === \"authClaim\";\n\n/** Whether `expr` calls one of the `rebase.*` helpers anywhere. */\nfunction callsAuthHelper(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"and\":\n case \"or\":\n return expr.operands.some(callsAuthHelper);\n case \"not\":\n return callsAuthHelper(expr.operand);\n case \"compare\":\n return callsHelper(expr.left) || callsHelper(expr.right);\n case \"rolesOverlap\":\n case \"rolesContain\":\n case \"authenticated\":\n case \"registered\":\n case \"serverContext\":\n return true;\n default:\n return false;\n }\n}\n\n/**\n * Whether `expr` is compiled as one `(SELECT …)`: it reads no row and calls a\n * helper (a constant has nothing to save).\n *\n * `authenticated()` and `registered()` on their own are the exception: they\n * wrap each helper call instead — `(SELECT rebase.uid()) IS NOT NULL AND …`.\n * Their estimate is already near 100%, so there is no plan to fix, and the\n * per-call spelling is the one RLS linters can still read: `rls-check` flags a\n * policy that is nothing but \"signed in\" (every account reads every row) by\n * splitting it on AND, and it reads `( SELECT rebase.uid() AS uid)` as the\n * caller id — but a whole conjunction inside one subquery is opaque to it.\n * Inside a larger hoisted subtree they are emitted bare, like everything else\n * there.\n */\nfunction isHoistable(expr: PolicyExpression): boolean {\n if (expr.kind === \"authenticated\" || expr.kind === \"registered\") return false;\n return readsNoRow(expr) && callsAuthHelper(expr);\n}\n\n/** A helper call, as an initPlan unless it already sits inside one. */\nfunction helperSql(sqlText: string, scope: CompileScope): string {\n return scope.inInitPlan ? sqlText : `(SELECT ${sqlText})`;\n}\n\nfunction compile(expr: PolicyExpression, scope: CompileScope): string {\n if (!scope.inInitPlan && isHoistable(expr)) {\n return `(SELECT ${compile(expr, { ...scope, inInitPlan: true })})`;\n }\n switch (expr.kind) {\n case \"true\":\n return \"true\";\n case \"false\":\n return \"false\";\n case \"and\":\n return expr.operands.length === 0\n ? \"true\"\n : expr.operands.map(o => `(${compile(o, scope)})`).join(\" AND \");\n case \"or\":\n return expr.operands.length === 0\n ? \"false\"\n : expr.operands.map(o => `(${compile(o, scope)})`).join(\" OR \");\n case \"not\":\n return `NOT (${compile(expr.operand, scope)})`;\n case \"compare\": {\n // `rebase.uid()` returns text; cast the column side so uuid / integer\n // id columns compare cleanly instead of failing with\n // \"operator does not exist: uuid = text\" at CREATE POLICY time.\n const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>\n other.kind === \"authUid\" && (operand.kind === \"field\" || operand.kind === \"outerField\")\n ? `(${sqlText})::text`\n : sqlText;\n // A claim is text too, and the same mismatch applies — but here the\n // cast goes the OTHER way, onto the claim. Casting the column would\n // compile and would take the index off it, and this operand exists\n // to carry a tenancy predicate that is ANDed into every read of the\n // table. See {@link claimCastType}.\n const claimSql = (operand: PolicyOperand, other: PolicyOperand): string | undefined =>\n operand.kind === \"authClaim\"\n ? helperSql(authClaimSql(operand.name, claimCastType(other, scope)), scope)\n : undefined;\n const leftSql = claimSql(expr.left, expr.right)\n ?? castForAuthUid(expr.left, operandToSql(expr.left, scope), expr.right);\n const rightSql = claimSql(expr.right, expr.left)\n ?? castForAuthUid(expr.right, operandToSql(expr.right, scope), expr.left);\n return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;\n }\n case \"rolesOverlap\":\n return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;\n case \"rolesContain\":\n return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;\n case \"authenticated\":\n // `IS NOT NULL` alone is a tautology on the user path: every\n // user-context request sets `app.uid`, and an anonymous one sets\n // it to a sentinel. Excluding the sentinels is what makes this mean\n // \"signed in\" rather than \"anyone at all\".\n //\n // Every sentinel, not just the current one. This clause is written\n // into the database and outlives the server that generated it: a\n // policy compiled here may be enforced against an older server that\n // still reports `'anon'`, which is exactly how excluding one\n // spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.\n return `${helperSql(RLS_UID_SQL, scope)} IS NOT NULL`\n + ` AND ${helperSql(RLS_UID_SQL, scope)} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(\", \")})`;\n case \"registered\":\n // \"Signed in\" AND \"not a guest\". The first half is the same clause\n // `authenticated` compiles to; the second is the fact anonymous\n // sign-in used not to put anywhere the database could see, so a\n // guest and an account were one principal inside every policy.\n //\n // `rebase.is_anonymous()` defaults to false when its GUC is unset,\n // so a policy compiled here and enforced by an older server reads\n // every session as an account — which is the behaviour that\n // deployment already had, rather than a lockout.\n return `${helperSql(RLS_UID_SQL, scope)} IS NOT NULL`\n + ` AND ${helperSql(RLS_UID_SQL, scope)} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(\", \")})`\n + ` AND NOT ${helperSql(RLS_IS_ANONYMOUS_SQL, scope)}`;\n case \"serverContext\":\n // Only the built-in server flows leave `app.uid` unset.\n return `${RLS_UID_SQL} IS NULL`;\n case \"existsIn\":\n return compileExistsIn(expr, scope);\n case \"raw\": {\n // A project written against a pre-1.0 release may still spell the\n // helpers `auth.uid()`. Rewritten rather than rejected: the rule\n // means exactly the same thing, the developer cannot be expected to\n // have read a changelog mid-deploy, and the alternative is a policy\n // that compiles cleanly and then denies every row at runtime because\n // it calls a function that no longer exists.\n //\n // The counterpart is `warnOnLegacyRlsFunctions`, which says so once\n // at boot with the file to edit — silence here would leave the old\n // spelling working forever and make the migration permanent.\n const sqlText = rewriteLegacyRlsFunctions(expr.sql);\n\n // Full-power escape hatch: `{column}` denotes a column of the outer\n // RLS row. It must be table-qualified, not bare: raw SQL may open its\n // own subquery over the same table, and there a bare name binds to the\n // inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.\n return substituteRawPlaceholders(sqlText, (col) =>\n `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);\n }\n }\n}\n\n/**\n * Replace each `{column}` placeholder in raw SQL with `replace(column)`, but\n * only in the SQL itself: not inside a string literal (`'…'`, `E'…'` with its\n * backslash escapes, `$$…$$`, `$tag$…$tag$`), a quoted identifier (`\"…\"`) or a\n * comment (`--` to the end of the line, or a block comment, which nests).\n *\n * A placeholder inside a literal is never meant: it would put the column's\n * *name* into the text. A regex quantifier is, and it has the same shape. A\n * stored claim-tenancy policy on a uuid column guards its cast with\n * `'^[0-9a-fA-F]{8}-…{12}$'`, so imported through \"Import from table\" or the\n * Studio's \"Import to codebase\" (which keep it as `raw`), its `{8}` became\n * `\"public\".\"projects\".\"8\"`, the guard never matched, and the policy denied\n * every tenant.\n */\nfunction substituteRawPlaceholders(sql: string, replace: (column: string) => string): string {\n let out = \"\";\n let i = 0;\n while (i < sql.length) {\n const c = sql[i];\n let end = i + 1;\n if (c === \"'\") {\n end = skipQuoted(sql, i, \"'\", isEscapeStringPrefix(sql, i));\n } else if (c === \"\\\"\") {\n end = skipQuoted(sql, i, \"\\\"\", false);\n } else if (c === \"-\" && sql[i + 1] === \"-\") {\n const newline = sql.indexOf(\"\\n\", i);\n if (newline === -1) {\n // A line comment ends at a newline and nowhere else, and this\n // text is spliced into `USING (…)` and between the operands of\n // an AND / OR: unterminated, the comment swallows the closing\n // paren and whatever follows it, and `CREATE POLICY` fails.\n out += `${sql.slice(i)}\\n`;\n break;\n }\n end = newline;\n } else if (c === \"/\" && sql[i + 1] === \"*\") {\n end = skipBlockComment(sql, i);\n } else if (c === \"$\") {\n const tag = dollarQuoteTagAt(sql, i);\n if (tag) {\n const close = sql.indexOf(tag, i + tag.length);\n end = close === -1 ? sql.length : close + tag.length;\n }\n } else if (c === \"{\") {\n const placeholder = /^\\{(\\w+)\\}/.exec(sql.slice(i));\n if (placeholder) {\n out += replace(placeholder[1]);\n i += placeholder[0].length;\n continue;\n }\n }\n out += sql.slice(i, end);\n i = end;\n }\n return out;\n}\n\n/** A character that continues an identifier, `$` included (`a$b` is one name). */\nconst IDENTIFIER_CHAR = /[A-Za-z0-9_$\\u0080-\\uffff]/;\n\n/** The end of the `quote`-quoted span opening at `start`; a doubled quote stays inside. */\nfunction skipQuoted(sql: string, start: number, quote: string, backslashEscapes: boolean): number {\n let i = start + 1;\n while (i < sql.length) {\n if (backslashEscapes && sql[i] === \"\\\\\") {\n i += 2;\n } else if (sql[i] === quote) {\n if (sql[i + 1] !== quote) return i + 1;\n i += 2;\n } else {\n i++;\n }\n }\n return sql.length;\n}\n\n/** Whether the `'` at `quote` opens an `E'…'` string, where `\\'` escapes a quote. */\nfunction isEscapeStringPrefix(sql: string, quote: number): boolean {\n const prefix = sql[quote - 1];\n return (prefix === \"E\" || prefix === \"e\") && !IDENTIFIER_CHAR.test(sql[quote - 2] ?? \"\");\n}\n\n/** The end of the block comment opening at `start`. Postgres nests them. */\nfunction skipBlockComment(sql: string, start: number): number {\n let depth = 0;\n let i = start;\n while (i < sql.length) {\n if (sql[i] === \"/\" && sql[i + 1] === \"*\") {\n depth++;\n i += 2;\n } else if (sql[i] === \"*\" && sql[i + 1] === \"/\") {\n depth--;\n i += 2;\n if (depth === 0) return i;\n } else {\n i++;\n }\n }\n return sql.length;\n}\n\n/**\n * The dollar-quote tag (`$$`, `$tag$`) opening at `at`, or undefined. Not one\n * after an identifier character (`a$b$` is a name), nor `$1` (a parameter: a\n * tag cannot start with a digit).\n */\nfunction dollarQuoteTagAt(sql: string, at: number): string | undefined {\n if (at > 0 && IDENTIFIER_CHAR.test(sql[at - 1])) return undefined;\n return /^\\$(?:[A-Za-z_\\u0080-\\uffff][A-Za-z0-9_\\u0080-\\uffff]*)?\\$/.exec(sql.slice(at))?.[0];\n}\n\n/**\n * Compiles `existsIn` to a correlated `EXISTS (SELECT 1 FROM <join> WHERE ...)`.\n * Inside the subquery, `field` operands bind to the aliased join table and\n * `outerField` operands bind to the (table-qualified) outer RLS row.\n */\nfunction compileExistsIn(expr: ExistsInPolicyExpression, scope: CompileScope): string {\n const join = scope.resolveCollection?.(expr.collection);\n const joinTable = join ? getTableName(join) : toSnakeCase(expr.collection);\n const joinSchema = schemaOf(join) ?? schemaOf(scope.outerCollection) ?? \"public\";\n const alias = `_ex${scope.alias.n++}`;\n\n // `outerField` inside the subquery must be qualified with the outer table,\n // otherwise a bare column name would bind to the joined table instead.\n const outerPrefix = outerQualifier(scope);\n\n const innerScope: CompileScope = {\n fieldCollection: join,\n fieldPrefix: `\"${alias}\".`,\n outerCollection: scope.outerCollection,\n outerPrefix,\n resolveCollection: scope.resolveCollection,\n alias: scope.alias,\n inInitPlan: scope.inInitPlan\n };\n return `EXISTS (SELECT 1 FROM \"${joinSchema}\".\"${joinTable}\" \"${alias}\" WHERE ${compile(expr.where, innerScope)})`;\n}\n\nconst COMPARE_SQL: Record<PolicyCompareOperator, string> = {\n eq: \"=\",\n neq: \"!=\",\n lt: \"<\",\n lte: \"<=\",\n gt: \">\",\n gte: \">=\"\n};\n\nfunction operandToSql(operand: PolicyOperand, scope: CompileScope): string {\n switch (operand.kind) {\n case \"field\":\n return `${scope.fieldPrefix}${resolveColumnName(operand.name, scope.fieldCollection)}`;\n case \"outerField\":\n return `${scope.outerPrefix}${resolveColumnName(operand.name, scope.outerCollection)}`;\n case \"literal\":\n return quoteLiteral(operand.value);\n case \"authUid\":\n return helperSql(RLS_UID_SQL, scope);\n case \"authRoles\":\n return helperSql(`string_to_array(${RLS_ROLES_SQL}, ',')`, scope);\n case \"authClaim\":\n // Uncast — the shape a claim has when nothing says what it is being\n // compared against (a claim on both sides, or against a literal).\n // The `compare` arm replaces this whenever the other operand names\n // a typed column.\n return helperSql(authClaimSql(operand.name, \"text\"), scope);\n }\n}\n\n/** Postgres types a text claim is cast to. `\"text\"` is the no-cast case. */\ntype ClaimCastType = \"text\" | \"uuid\" | \"bigint\" | \"numeric\";\n\n/**\n * The Postgres type a claim has to be cast to, to be compared with `operand`.\n *\n * `\"text\"` means \"no cast\": a claim already is text, and a `text` / `varchar`\n * column compares with it directly and keeps using its index.\n *\n * Resolved from the *property*, and through a relation's target when the column\n * is a foreign key — a `belongsTo` tenant field is the ordinary shape, and its\n * column's type is the target collection's primary key type rather than\n * anything visible on the property itself. Unknown resolves to `\"text\"`, which\n * is the safe direction: a redundant `text` comparison costs nothing, while a\n * missing `uuid` cast is a `CREATE POLICY` that fails and leaves a table with\n * RLS enabled and no policy — which denies every row.\n */\nfunction claimCastType(operand: PolicyOperand, scope: CompileScope): ClaimCastType {\n if (operand.kind !== \"field\" && operand.kind !== \"outerField\") return \"text\";\n const collection = operand.kind === \"field\" ? scope.fieldCollection : scope.outerCollection;\n return propertyClaimCastType(operand.name, collection, scope.resolveCollection);\n}\n\n/**\n * What `name` on `collection` compares against a text claim as.\n *\n * `depth` stops a `reference` cycle — two collections whose keys point at each\n * other — from recursing forever. Two hops is more than any real declaration\n * needs.\n */\nfunction propertyClaimCastType(\n name: string,\n collection: CollectionConfig | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined,\n depth = 0\n): ClaimCastType {\n const prop = collection?.properties?.[name] as Property | undefined;\n if (!prop || depth > 2) return \"text\";\n\n switch (prop.type) {\n case \"string\": {\n const sp = prop as { isId?: unknown; columnType?: unknown; enum?: unknown };\n if (sp.enum) return \"text\";\n return sp.isId === \"uuid\" || sp.columnType === \"uuid\" ? \"uuid\" : \"text\";\n }\n case \"number\": {\n const np = prop as { columnType?: string; isId?: unknown; validation?: { integer?: boolean } };\n if (np.columnType === \"numeric\") return \"numeric\";\n if (np.columnType || np.validation?.integer || np.isId) return \"bigint\";\n // A `number` with no `columnType` and no `validation.integer` is\n // NUMERIC — see `numberType` in the schema planner.\n return \"numeric\";\n }\n case \"reference\":\n return primaryKeyClaimCastType(\n resolveTargetCollection((prop as { path?: string }).path, resolveCollection),\n resolveCollection,\n depth\n );\n case \"relation\":\n return primaryKeyClaimCastType(\n relationTarget(name, prop, collection, resolveCollection),\n resolveCollection,\n depth\n );\n default:\n return \"text\";\n }\n}\n\n/** The cast a column pointing at `target`'s primary key needs. */\nfunction primaryKeyClaimCastType(\n target: CollectionConfig | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined,\n depth: number\n): ClaimCastType {\n if (!target) return \"text\";\n for (const [key, property] of Object.entries(target.properties ?? {})) {\n if (!(property as { isId?: unknown })?.isId) continue;\n return propertyClaimCastType(key, target, resolveCollection, depth + 1);\n }\n // No declared key: the implicit `id TEXT PRIMARY KEY`.\n return \"text\";\n}\n\n/**\n * A relation's target slug, whatever form the declaration took.\n *\n * `target` is a slug on a plain object and a thunk on a builder — the two\n * shapes `resolveRelation` normalises — and this runs on the raw property,\n * before that resolution.\n */\nfunction relationTargetSlug(relation: { target?: unknown } | undefined): string | undefined {\n const target = relation?.target;\n if (typeof target === \"string\") return target;\n if (typeof target !== \"function\") return undefined;\n try {\n const slug = ((target as () => unknown)() as { slug?: unknown })?.slug;\n return typeof slug === \"string\" ? slug : undefined;\n } catch {\n // A thunk needing a registry this compilation does not have. `text` is\n // the fallback, and a fallback is not worth failing a compile over.\n return undefined;\n }\n}\n\nfunction resolveTargetCollection(\n slug: string | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined\n): CollectionConfig | undefined {\n if (!slug || !resolveCollection) return undefined;\n // A `reference` path may be nested; the collection is its last segment.\n return resolveCollection(slug) ?? resolveCollection(slug.split(\"/\").pop() as string);\n}\n\n/**\n * `NULLIF(rebase.jwt() ->> 'name', '')`, cast to the column's type.\n *\n * Two things here are load-bearing beyond the cast:\n *\n * - **`NULLIF(…, '')`.** An absent claim already reads as NULL, but one set to\n * the empty string does not, and `''::uuid` raises rather than denying. Both\n * spellings of \"this caller has no tenant\" have to reach the comparison as\n * NULL, which is never true and therefore never a grant.\n * - **The guard.** The cast sits inside a `CASE` that first checks the text is\n * well-formed, because `'nonsense'::uuid` raises `invalid input syntax` — and\n * a policy that raises does not deny a row, it fails the whole statement. A\n * caller holding a malformed claim would get a 500 on every read of the table\n * instead of an empty list. `CASE` rather than an `AND` guard because only\n * `CASE` is guaranteed not to evaluate its arms out of order.\n *\n * The whole expression is STABLE (`rebase.jwt()` is), so Postgres evaluates it\n * once per query and can still use a btree index on the column it is compared\n * against — which is the entire reason the cast is on this side.\n */\nfunction authClaimSql(name: string, cast: ClaimCastType): string {\n const claim = `NULLIF(${RLS_JWT_SQL} ->> ${quoteLiteral(name)}, '')`;\n if (cast === \"text\") return claim;\n return `CASE WHEN ${claim} ~ '${CLAIM_CAST_GUARDS[cast]}' THEN (${claim})::${cast} END`;\n}\n\n/**\n * The text a claim must match before it is cast, per target type.\n *\n * The `bigint` guard caps the digit run at 18 rather than matching any run of\n * digits: `'99999999999999999999'::bigint` is a range error, which fails the\n * statement exactly as the syntax error would have.\n */\nconst CLAIM_CAST_GUARDS: Record<Exclude<ClaimCastType, \"text\">, string> = {\n uuid: \"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\",\n bigint: \"^-?[0-9]{1,18}$\",\n numeric: \"^-?[0-9]+(\\\\.[0-9]+)?$\"\n};\n\n/**\n * SQL prefix that qualifies a column of the outer RLS row (`\"schema\".\"table\".`),\n * or `\"\"` when the collection is unknown.\n */\nfunction outerQualifier(scope: CompileScope): string {\n const table = scope.outerCollection ? getTableName(scope.outerCollection) : undefined;\n if (!table) return \"\";\n return `\"${schemaOf(scope.outerCollection) ?? \"public\"}\".\"${table}\".`;\n}\n\nfunction schemaOf(collection?: CollectionConfig): string | undefined {\n return (collection as { schema?: string } | undefined)?.schema || undefined;\n}\n\nfunction resolveColumnName(propName: string, collection?: CollectionConfig): string {\n const prop = collection?.properties?.[propName] as Property | undefined;\n if (prop && \"columnName\" in prop && typeof (prop as { columnName?: unknown }).columnName === \"string\") {\n return quoteColumnIdentifier((prop as { columnName: string }).columnName);\n }\n const relationColumn = belongsToColumn(propName, prop, collection);\n if (relationColumn) return quoteColumnIdentifier(relationColumn);\n return quoteColumnIdentifier(toSnakeCase(propName));\n}\n\n/**\n * The foreign key column a `relation` property addresses, when it has one.\n *\n * A `belongsTo` property is a *link*, and its column is the relation's\n * `localKey` — `org` addresses `org_id`. `toSnakeCase` cannot know that, so a\n * rule naming a relation (`ownerField: \"org\"`, a tenancy declaration over a\n * `belongsTo`) compiled to a comparison against a column called `org`, which\n * does not exist. `CREATE POLICY` then fails, and a table with RLS enabled and\n * no policy denies every row — the loudest possible symptom for the quietest\n * possible cause, since the rule reads exactly right.\n *\n * Only `belongsTo`. Every other kind puts its column on the target table, in a\n * junction row, or nowhere at all, and there is no column here to name.\n */\nfunction belongsToColumn(\n propName: string,\n prop: Property | undefined,\n collection?: CollectionConfig\n): string | undefined {\n if (prop?.type !== \"relation\") return undefined;\n const relation = resolvePropertyRelation(propName, collection);\n return relation?.kind === \"belongsTo\" ? relation.localKey : undefined;\n}\n\n/**\n * The resolved relation a `relation` property stands for, by the only name\n * both declarations share: the property's own key.\n *\n * A property may carry the link inline (`relation: { kind, target }`) or name\n * an entry in the collection's `relations` array — and the second form is\n * matched by the property key, so there is nothing on the property to read.\n * `resolveCollectionRelations` normalises both under that key, which is why\n * this asks it rather than reading the property.\n */\nfunction resolvePropertyRelation(\n propName: string,\n collection: CollectionConfig | undefined\n): ResolvedRelation | undefined {\n if (!collection) return undefined;\n try {\n return findRelation(resolveCollectionRelations(collection), propName);\n } catch {\n // A relation whose target is not in this bundle. The old spelling is\n // no worse than failing to compile at all.\n return undefined;\n }\n}\n\n/**\n * The collection a `relation` property points at.\n *\n * Preferred over the property's raw `target` because only the resolved\n * relation covers both declarations, and because a resolved `target()` hands\n * back the collection itself — no registry needed. The slug path stays as the\n * fallback for a `target` written as a plain string, which cannot become a\n * collection without one.\n */\nfunction relationTarget(\n propName: string,\n prop: Property,\n collection: CollectionConfig | undefined,\n resolveCollection: ((slug: string) => CollectionConfig | undefined) | undefined\n): CollectionConfig | undefined {\n const relation = resolvePropertyRelation(propName, collection);\n if (relation) {\n try {\n const target = relation.target();\n if (target) return target;\n } catch {\n // A thunk needing a registry this compilation does not have.\n }\n }\n return resolveTargetCollection(\n relationTargetSlug((prop as { relation?: { target?: unknown } }).relation),\n resolveCollection\n );\n}\n\n/**\n * Every PostgreSQL keyword that cannot stand as a bare column reference.\n * Appendix C's two reserved categories — plain \"reserved\", and \"reserved (can\n * be function or type name)\" — since neither may name a column unquoted.\n */\nconst RESERVED_SQL_WORDS = new Set([\n \"all\", \"analyse\", \"analyze\", \"and\", \"any\", \"array\", \"as\", \"asc\", \"asymmetric\", \"authorization\",\n \"binary\", \"both\", \"case\", \"cast\", \"check\", \"collate\", \"collation\", \"column\", \"concurrently\",\n \"constraint\", \"create\", \"cross\", \"current_catalog\", \"current_date\", \"current_role\",\n \"current_schema\", \"current_time\", \"current_timestamp\", \"current_user\", \"default\", \"deferrable\",\n \"desc\", \"distinct\", \"do\", \"else\", \"end\", \"except\", \"false\", \"fetch\", \"for\", \"foreign\", \"freeze\",\n \"from\", \"full\", \"grant\", \"group\", \"having\", \"ilike\", \"in\", \"initially\", \"inner\", \"intersect\",\n \"into\", \"is\", \"isnull\", \"join\", \"lateral\", \"leading\", \"left\", \"like\", \"limit\", \"localtime\",\n \"localtimestamp\", \"natural\", \"not\", \"notnull\", \"null\", \"offset\", \"on\", \"only\", \"or\", \"order\",\n \"outer\", \"overlaps\", \"placing\", \"primary\", \"references\", \"returning\", \"right\", \"select\",\n \"session_user\", \"similar\", \"some\", \"symmetric\", \"system_user\", \"table\", \"tablesample\", \"then\",\n \"to\", \"trailing\", \"true\", \"union\", \"unique\", \"user\", \"using\", \"variadic\", \"verbose\", \"when\",\n \"where\", \"window\", \"with\"\n]);\n\n/** An identifier Postgres reads back unchanged without quotes. */\nconst BARE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/;\n\n/**\n * Quote a column reference when Postgres would not read the bare name as that\n * column — and only then.\n *\n * Three ways a bare name goes wrong, in ascending order of how long it takes to\n * notice:\n *\n * - **Case.** `columnName` is used verbatim, and `rebase schema introspect`\n * populates it from a live database, so a legacy `\"createdAt\"` column arrives\n * spelled exactly that way. Unquoted, Postgres folds it to `createdat` and\n * `CREATE POLICY` fails with \"column does not exist\" — the collection keeps\n * RLS enabled with no policy, which denies every row.\n * - **Syntax.** A column named `order` or `default` is a syntax error mid-clause.\n * - **Silent rebinding.** `user`, `current_user`, `session_user`, `current_date`\n * and friends are *valid bare expressions*, so the policy compiles, applies,\n * and is reported as a success — while comparing against the connected role\n * or the wall clock instead of the column. Under RLS every request runs as the\n * same `rebase_user` role, so `USING (user = rebase.uid())` is a constant: it\n * denies everything, and its negation admits everything.\n *\n * Only the names that need it are quoted, so an ordinary snake_case policy body\n * is emitted byte-for-byte as before. That keeps generated artifacts and the\n * policies already stored in shipped databases stable — this fix reaches the\n * clauses that were broken and no others.\n */\nfunction quoteColumnIdentifier(name: string): string {\n if (BARE_IDENTIFIER.test(name) && !RESERVED_SQL_WORDS.has(name)) return name;\n return `\"${name.replace(/\"/g, \"\\\"\\\"\")}\"`;\n}\n\nfunction quoteLiteral(value: string | number | boolean | null): string {\n if (value === null) return \"NULL\";\n if (typeof value === \"boolean\") return value ? \"true\" : \"false\";\n if (typeof value === \"number\") return String(value);\n return `'${value.replace(/'/g, \"''\")}'`;\n}\n\n/**\n * Sorted, single-quoted `ARRAY['a','b']` — matches the generators' output.\n *\n * Each role goes through {@link quoteLiteral}, which leaves an ordinary name\n * byte-for-byte as it is and doubles a quote inside one (`o'reilly`). An\n * unescaped quote would close the literal early: a syntax error at\n * `CREATE POLICY`, which leaves the table with RLS on and no policy, or — for a\n * name built to do it — SQL of its own in the clause.\n */\nfunction rolesArraySql(roles: readonly string[]): string {\n // An empty `ARRAY[]` has no element type, and Postgres refuses it (\"cannot\n // determine type of empty array\"): the whole `CREATE POLICY` fails, taking\n // the rest of the rule's condition with it. Typed, it means what the\n // evaluator says — overlapping no role is false, containing none is true.\n if (roles.length === 0) return \"ARRAY[]::text[]\";\n return `ARRAY[${[...roles].sort().map(quoteLiteral).join(\",\")}]`;\n}\n"],"mappings":"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"}
@@ -294,8 +294,7 @@ function parseOperand(str) {
294
294
  if (/^\(\s*select\s+rebase\.uid\(\)(?:\s+as\s+"?uid"?)?\s*\)$/i.test(str)) return policy.authUid();
295
295
  const literal = parseSingleQuoted(str);
296
296
  if (literal !== null) return policy.literal(literal);
297
- if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
298
- if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
297
+ if (/^-?\d+$/.test(str) || /^-?\d*\.\d+$/.test(str)) return isExactNumber(str) ? policy.literal(Number(str)) : null;
299
298
  if (/^true$/i.test(str)) return policy.literal(true);
300
299
  if (/^false$/i.test(str)) return policy.literal(false);
301
300
  if (/^null$/i.test(str)) return policy.literal(null);
@@ -303,6 +302,19 @@ function parseOperand(str) {
303
302
  return null;
304
303
  }
305
304
  /**
305
+ * Whether the decimal numeral `str` survives a trip through a JS `number` and
306
+ * back to text with its value intact: `0.50` does (as `0.5`), `2^53 + 1` and
307
+ * a twentieth decimal place do not.
308
+ */
309
+ function isExactNumber(str) {
310
+ const [whole, fraction = ""] = (str.startsWith("-") ? str.slice(1) : str).split(".");
311
+ const digits = whole.replace(/^0+(?=\d)/, "") || "0";
312
+ const decimals = fraction.replace(/0+$/, "");
313
+ const canonical = decimals ? `${digits}.${decimals}` : digits;
314
+ const value = Number(str);
315
+ return String(Math.abs(value)) === canonical;
316
+ }
317
+ /**
306
318
  * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
307
319
  *
308
320
  * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
@@ -1 +1 @@
1
- {"version":3,"file":"sqlToPolicy.es.js","names":[],"sources":["../../../src/util/policy/sqlToPolicy.ts"],"sourcesContent":["import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from \"@rebasepro/types\";\nimport { toSnakeCase } from \"@rebasepro/utils\";\n\n/**\n * A tiny, regex-based SQL \"parser\" for security rules.\n *\n * This is NOT a full SQL parser. It is designed to handle the subset of SQL\n * commonly used in `USING` and `WITH CHECK` clauses, enough to drive the\n * optimistic client-side UI decision.\n *\n * It handles:\n * - `field = 'literal'`\n * - `field != 'literal'`\n * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)\n * - `A AND B`, `A OR B` — only where the keyword is at the top level\n * - `true`\n * - `IN (...)` (as optimistic true)\n *\n * For anything it doesn't understand, it returns a `raw` expression, which\n * the evaluator treats as \"unknown\" (and usually optimistic true).\n *\n * **This output also round-trips back into DDL** via `policyToPostgres` (the\n * schema/policy generators), so decomposing a clause the parser only partly\n * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,\n * prefer `raw`: it is reproduced verbatim.\n */\n/** True when `keyword` starts at `i` as a standalone word. */\nfunction isKeywordAt(upper: string, i: number, keyword: string): boolean {\n if (!upper.startsWith(keyword, i)) return false;\n const before = i === 0 ? \" \" : upper[i - 1];\n const after = upper[i + keyword.length] ?? \" \";\n return /[\\s()]/.test(before) && /[\\s()]/.test(after);\n}\n\n/**\n * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and\n * outside a string literal. Returns null when it never does, so the caller\n * leaves the clause alone.\n *\n * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the\n * `AND` inside\n * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`\n * split the expression, and re-emitting the halves produced\n * `(EXISTS (...) AND m.user_id = rebase.uid())`\n * where `m` is no longer in scope — SQL that Postgres rejects outright with\n * \"missing FROM-clause entry for table\". Returning null instead keeps such a\n * clause as a `raw` expression, which round-trips verbatim.\n */\nfunction splitTopLevel(sql: string, keyword: \"AND\" | \"OR\"): string[] | null {\n const upper = sql.toUpperCase();\n const parts: string[] = [];\n let depth = 0;\n let inString = false;\n let start = 0;\n\n for (let i = 0; i < sql.length; i++) {\n const ch = sql[i];\n if (inString) {\n if (ch === \"'\") {\n if (sql[i + 1] === \"'\") i++; // '' escapes a quote inside a literal\n else inString = false;\n }\n continue;\n }\n if (ch === \"'\") { inString = true; continue; }\n if (ch === \"(\") { depth++; continue; }\n if (ch === \")\") { depth--; continue; }\n if (depth === 0 && isKeywordAt(upper, i, keyword)) {\n parts.push(sql.slice(start, i));\n i += keyword.length - 1;\n start = i + 1;\n }\n }\n\n if (parts.length === 0) return null;\n parts.push(sql.slice(start));\n const trimmedParts = parts.map(p => p.trim()).filter(p => p.length > 0);\n return trimmedParts.length > 1 ? trimmedParts : null;\n}\n\n/** `SELECT` as the first word of `sql`. */\nconst STARTS_WITH_SELECT = /^select(?=[\\s(])/i;\n\n/**\n * Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`\n * — and never the parens of a scalar subquery: `(SELECT x)` without them is not\n * an expression at all, and re-emitted bare it is a syntax error inside\n * `USING (…)`.\n */\nfunction stripOuterParens(sql: string): string {\n let s = sql.trim();\n while (wrapsWhole(s) && !STARTS_WITH_SELECT.test(s.slice(1).trim())) {\n s = s.slice(1, -1).trim();\n }\n return s;\n}\n\n/** Whether `s` is one parenthesised group: `(a AND b)`, not `(a) AND (b)`. */\nfunction wrapsWhole(s: string): boolean {\n if (!s.startsWith(\"(\") || !s.endsWith(\")\")) return false;\n let depth = 0;\n let inString = false;\n for (let i = 0; i < s.length; i++) {\n const ch = s[i];\n if (inString) {\n if (ch === \"'\") {\n if (s[i + 1] === \"'\") i++;\n else inString = false;\n }\n continue;\n }\n if (ch === \"'\") { inString = true; continue; }\n if (ch === \"(\") depth++;\n else if (ch === \")\") {\n depth--;\n if (depth === 0 && i < s.length - 1) return false;\n }\n }\n return true;\n}\n\n/** `x` when `s` is exactly `(SELECT x)`, otherwise null. */\nfunction scalarSubqueryBody(s: string): string | null {\n if (!wrapsWhole(s)) return null;\n const inner = s.slice(1, -1).trim();\n return STARTS_WITH_SELECT.test(inner) ? inner.slice(\"select\".length).trim() : null;\n}\n\n/**\n * The names in a role array's body — `'admin', 'editor'`, each with or without\n * Postgres's `::text` — or `null` when an element is anything else. A quote\n * or a comma inside a name is not split into roles that were never there: the\n * clause stays raw and round-trips verbatim.\n */\nfunction roleArrayElements(list: string): string[] | null {\n const roles: string[] = [];\n for (const element of list.split(\",\")) {\n const match = /^'([^',]*)'(?:::text)?$/.exec(element.trim());\n if (!match) return null;\n roles.push(match[1]);\n }\n return roles;\n}\n\n/** Whether any part of `expr` fell through to `raw`. */\nfunction containsRaw(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"raw\":\n return true;\n case \"and\":\n case \"or\":\n return expr.operands.some(containsRaw);\n case \"not\":\n return containsRaw(expr.operand);\n case \"existsIn\":\n return containsRaw(expr.where);\n default:\n return false;\n }\n}\n\nexport function sqlToPolicy(sql: string): PolicyExpression {\n // Normalised before anything else looks at it, so every pattern below only\n // has to know the current spelling. A database migrated by a pre-1.0 release\n // still holds `auth.uid()` in its policy bodies until the next push or boot\n // recompiles them — and until then the admin UI reads those bodies back\n // through here. Without this they parse as opaque `raw`, and the framework's\n // own policies get badged as hand-written drift.\n //\n // Normalising rather than accepting both spellings throughout is deliberate:\n // it also means a legacy policy that falls through to `raw` is stored in the\n // new spelling, so editing and saving one in the Studio migrates it.\n const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());\n\n // `(SELECT x)` — how the compiler spells a part of a policy that does not\n // read the row, so Postgres evaluates it once per statement. Read back as\n // `x` when `x` is understood completely: without a FROM a scalar subquery\n // is one row holding x's value, so the two mean the same. Anything less —\n // a FROM, a LIMIT, an alias, a clause the parser does not know — stays\n // `raw`, parens included, and is reproduced verbatim.\n const subqueryBody = scalarSubqueryBody(trimmed);\n if (subqueryBody !== null) {\n const inner = sqlToPolicy(subqueryBody);\n return containsRaw(inner) ? policy.raw(trimmed) : inner;\n }\n\n if (trimmed.toLowerCase() === \"true\") return policy.true();\n if (trimmed.toLowerCase() === \"false\") return policy.false();\n\n // Handle roles overlap (&&) and containment (@>), as the compiler writes\n // them and as Postgres stores them:\n // string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']\n // string_to_array(rebase.roles(), ','::text) && ARRAY['admin'::text, 'editor'::text]\n // The second is what `pg_policies` holds, so what `schema introspect`\n // writes as a rule's `using`. Read as raw, a junction could not inherit\n // the rule, and its derived edge-write policies were left with none.\n const rolesMatch = trimmed.match(/^string_to_array\\s*\\(\\s*rebase\\.roles\\(\\)\\s*,\\s*','(?:::text)?\\s*\\)\\s*(&&|@>)\\s*ARRAY\\s*\\[(.+)\\]$/i);\n const roles = rolesMatch ? roleArrayElements(rolesMatch[2]) : null;\n if (rolesMatch && roles) {\n return rolesMatch[1] === \"&&\" ? policy.rolesOverlap(roles) : policy.rolesContain(roles);\n }\n\n // OR binds looser than AND, so it splits first.\n const orParts = splitTopLevel(trimmed, \"OR\");\n if (orParts) return policy.or(...orParts.map(sqlToPolicy));\n\n const andParts = splitTopLevel(trimmed, \"AND\");\n if (andParts) return policy.and(...andParts.map(sqlToPolicy));\n\n // Handle = and !=\n const match = trimmed.match(/^(.+?)\\s*(!?=)\\s*(.+)$/);\n if (match) {\n const [, leftStr, op, rightStr] = match;\n const left = parseOperand(leftStr.trim());\n const right = parseOperand(rightStr.trim());\n if (left && right) {\n return policy.compare(left, op === \"=\" ? \"eq\" : \"neq\", right);\n }\n }\n\n // Fallback to raw — the NORMALISED text, not the input. Storing the input\n // verbatim would mean a legacy policy read out of a database, edited in the\n // Studio and saved, writes `auth.uid()` back into the project's config: a\n // call to a function 1.0 no longer creates.\n return policy.raw(trimmed);\n}\n\n/**\n * Literals from other BaaS platforms that people compare `rebase.uid()` against\n * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on\n * `pgRoles`, one surface over: the same muscle memory inside a `using:` string\n * is the more dangerous spelling, because it inverts a rule instead of\n * emptying a table.\n */\n/**\n * A `Map`, not an object literal.\n *\n * As `Record<string, string>` this was indexed with a literal taken straight\n * out of a policy, so every key on `Object.prototype` answered: a rule\n * comparing `rebase.uid()` to `\"valueOf\"`, `\"toString\"`, `\"constructor\"` or\n * `\"hasOwnProperty\"` found a truthy \"platform\" and reported an anonymous-grant\n * risk that does not exist — with the matched function interpolated into the\n * explanation as the platform's name. A security warning that fires on\n * innocent input is worse than none: it is what teaches people to skip the\n * warnings that are real.\n *\n * Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,\n * `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain\n * object. Found by a property test, on the input `\"valueOf\"`.\n */\nconst FOREIGN_CONVENTION_UIDS = new Map<string, string>([\n [\"anon\", \"Supabase\"],\n [\"authenticated\", \"Supabase\"],\n [\"service_role\", \"Supabase\"]\n]);\n\n/**\n * The same foreign literals, as a pattern for SQL that could not be parsed\n * back into structure.\n */\nconst FOREIGN_UID_LITERAL_SQL = new RegExp(\n String.raw`rebase\\.uid\\(\\)\\s*=\\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join(\"|\")})'`,\n \"i\"\n);\n\n/** A clause that reads as a lockdown but admits anonymous callers. */\nexport interface AnonymousGrantRisk {\n /** Which spelling was found. */\n pattern: \"foreign-uid-literal\" | \"uid-not-null\";\n /** The offending fragment — the literal, or the SQL that is a tautology. */\n detail: string;\n /** Why it admits anonymous callers, and what to write instead. */\n explanation: string;\n}\n\n/**\n * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.\n *\n * Both schema spellings, because this runs over policy bodies read back from a\n * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.\n * A security check that stops recognising a dangerous clause because the\n * framework renamed a function is a check that silently turns off.\n */\nconst UID_NOT_NULL = /\\b(?:rebase|auth)\\.uid\\(\\)\\s+IS\\s+NOT\\s+NULL/i;\n\n/**\n * Find clauses that read as \"signed-in users only\" but admit anonymous callers.\n *\n * Both spellings come from the same place — Supabase, where its own `auth.uid()`\n * really is NULL for an anonymous request. Rebase substitutes\n * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which\n * is how the trusted *server* context is recognised), so:\n *\n * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and\n * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the\n * other. This one is not hypothetical and was not only a foreign habit:\n * rebase's own request path reported `'anon'` while everything that compiled\n * or checked a policy used `'anonymous'`, so whichever literal an author\n * picked, half the anonymous callers walked through. See\n * {@link ANONYMOUS_USER_IDS}.\n *\n * Either one turns a lockdown into a full grant, and neither looks wrong. No\n * real user id is ever one of these literals, and a user-context request is\n * never NULL, so a match is always a mistake rather than a deliberate check.\n *\n * Structured expressions are checked too, not just parsed SQL: `policy.compare`\n * can spell the same mistake.\n */\nexport function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[] {\n const found: AnonymousGrantRisk[] = [];\n\n const visit = (e: PolicyExpression): void => {\n switch (e.kind) {\n case \"and\":\n case \"or\":\n e.operands.forEach(visit);\n return;\n case \"not\":\n visit(e.operand);\n return;\n case \"existsIn\":\n visit(e.where);\n return;\n case \"raw\": {\n if (UID_NOT_NULL.test(e.sql)) {\n found.push({\n pattern: \"uid-not-null\",\n detail: e.sql,\n explanation: \"`rebase.uid() IS NOT NULL` is true for every request that came from a client, \" +\n `including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +\n \"Use `condition: policy.authenticated()` to mean \\\"signed in\\\".\"\n });\n }\n // The foreign literals have to be looked for here too, not only\n // in `compare`. `sqlToPolicy` falls back to `raw` for anything\n // it cannot structure — an `EXISTS (...)` subquery always does —\n // so a policy read back from the database arrives as one opaque\n // string. Checking only the tautology meant a genuine\n // `rebase.uid() = 'anon'` inside an `existsIn` was structurally\n // undetectable once round-tripped, and the caller read the empty\n // result as \"no risks found\".\n const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);\n if (foreign) {\n const literal = foreign[1];\n found.push({\n pattern: \"foreign-uid-literal\",\n detail: literal,\n explanation: `'${literal}' is a ${FOREIGN_CONVENTION_UIDS.get(literal)} convention. Rebase ` +\n `reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against ` +\n `'${literal}' passes for every caller. Use \\`condition: policy.authenticated()\\` to ` +\n \"mean \\\"signed in\\\".\"\n });\n }\n return;\n }\n case \"compare\": {\n const literal = [e.left, e.right].find(o => o.kind === \"literal\") as LiteralPolicyOperand | undefined;\n const comparesUid = e.left.kind === \"authUid\" || e.right.kind === \"authUid\";\n if (!comparesUid || typeof literal?.value !== \"string\") return;\n const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);\n if (!platform) return;\n found.push({\n pattern: \"foreign-uid-literal\",\n detail: literal.value,\n explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +\n `request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +\n \"every caller. Use `condition: policy.authenticated()` to mean \\\"signed in\\\" — it \" +\n `compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(\", \")}), covering ` +\n \"every spelling rebase has reported rather than whichever one you remember.\"\n });\n return;\n }\n default:\n return;\n }\n };\n\n visit(expr);\n return found;\n}\n\nfunction parseOperand(str: string) {\n // current_setting('app.uid') or rebase.uid(). `app.user_id` is the\n // pre-rename spelling and stays parseable: policies are data, so a\n // database provisioned before the rename still holds rules written\n // against it, and round-tripping one must not silently drop the operand.\n //\n // ANCHORED, and that is the whole point. These tests used to be\n // unanchored — `.test(str)` rather than `^…$` — so any operand text that\n // merely *contained* a uid call was replaced wholesale by the call itself.\n // Everything else in the expression was discarded with it, including a\n // leading `NOT (`:\n //\n // NOT (rebase.uid() = rebase.uid()) parsed as rebase.uid() = rebase.uid()\n //\n // A deny became an unconditional grant. The realistic spelling is a\n // hand-written defensive rule with a uid call on both sides —\n // COALESCE(rebase.uid(), '') = COALESCE(owner_id, rebase.uid())\n // — which collapsed to the same tautology. This is not confined to the\n // admin UI: `securityRuleToConditions` feeds a rule's raw `using:` string\n // through here, and the Postgres DDL generators compile the result, so the\n // tautology was written into the database as the policy body.\n //\n // An operand this cannot identify exactly must return null, which drops the\n // whole clause to `raw` and reproduces it verbatim. That is the rule the\n // rest of this file already follows: when in doubt, prefer `raw`.\n if (/^current_setting\\s*\\(\\s*'app\\.(uid|user_id)'\\s*\\)$/i.test(str) || /^rebase\\.uid\\(\\)$/i.test(str)) {\n return policy.authUid();\n }\n // The same call as an initPlan — the compiler's spelling, `(SELECT\n // rebase.uid())`, and Postgres's re-rendering of it, `( SELECT rebase.uid()\n // AS uid)`. Anchored like the line above, for the same reason.\n if (/^\\(\\s*select\\s+rebase\\.uid\\(\\)(?:\\s+as\\s+\"?uid\"?)?\\s*\\)$/i.test(str)) {\n return policy.authUid();\n }\n\n // Literal string: 'value', with `''` decoded back to a single quote.\n //\n // `quoteLiteral` doubles every quote on the way out, and this did not undo\n // it, so a literal containing an apostrophe grew on every trip: O'Brien →\n // O''Brien → O''''Brien, doubling each time a policy was read back and\n // recompiled. Past the first trip the emitted policy compares against a\n // string no row holds.\n const literal = parseSingleQuoted(str);\n if (literal !== null) {\n return policy.literal(literal);\n }\n\n // Unquoted literals, which must be recognised BEFORE the bare-word branch\n // below or they are read as column names.\n //\n // `quoteLiteral` emits booleans, numbers and null unquoted, so `a = false`\n // came back as a comparison against a *field* called `false`, and `a = 42`\n // against a field called `42`. The recompiled SQL is identical either way,\n // which is why this survived a round-trip check on the SQL — but the\n // expression is now wrong, and the expression is what the admin UI\n // evaluates. Against a row with no `a`, Postgres denies (`NULL = false` is\n // not true) while the JS evaluator compared two missing columns, found them\n // equal, and allowed. That is precisely the client/database drift the\n // shared PolicyExpression model exists to make impossible.\n //\n // Unambiguous in both directions: a SQL identifier cannot begin with a\n // digit, and bare `true`/`false`/`null` are always the literals — a column\n // so named would have to be double-quoted to be referenced at all.\n if (/^-?\\d+$/.test(str)) return policy.literal(Number(str));\n if (/^-?\\d*\\.\\d+$/.test(str)) return policy.literal(Number(str));\n if (/^true$/i.test(str)) return policy.literal(true);\n if (/^false$/i.test(str)) return policy.literal(false);\n if (/^null$/i.test(str)) return policy.literal(null);\n\n // Bare field name — but only one that survives the snake-casing the\n // compiler will apply to it. `toSnakeCase(\"_\")` is the empty string, and a\n // field that compiles to an empty column reference emits `= 'x'`, which is\n // a syntax error at CREATE POLICY time. Such a name is left to `raw`, where\n // it round-trips verbatim instead. `toSnakeCase` itself is not touched:\n // column names derived by it are already in shipped databases.\n if (/^\\w+$/.test(str) && toSnakeCase(str) !== \"\") {\n return policy.field(str);\n }\n\n return null;\n}\n\n/**\n * Decode a single-quoted SQL literal, or null when `str` is not exactly one.\n *\n * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an\n * operator, not one literal whose body contains a quote, and a regex anchored\n * on the outer quotes would happily read it as the latter. Every interior quote\n * must therefore be part of a `''` pair.\n */\nfunction parseSingleQuoted(str: string): string | null {\n if (str.length < 2 || !str.startsWith(\"'\") || !str.endsWith(\"'\")) return null;\n const body = str.slice(1, -1);\n let out = \"\";\n for (let i = 0; i < body.length; i++) {\n if (body[i] !== \"'\") {\n out += body[i];\n continue;\n }\n if (body[i + 1] === \"'\") {\n out += \"'\";\n i++;\n continue;\n }\n return null; // a bare quote — `str` is not a single literal\n }\n return out;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sqlToPolicy.es.js","names":[],"sources":["../../../src/util/policy/sqlToPolicy.ts"],"sourcesContent":["import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from \"@rebasepro/types\";\nimport { toSnakeCase } from \"@rebasepro/utils\";\n\n/**\n * A tiny, regex-based SQL \"parser\" for security rules.\n *\n * This is NOT a full SQL parser. It is designed to handle the subset of SQL\n * commonly used in `USING` and `WITH CHECK` clauses, enough to drive the\n * optimistic client-side UI decision.\n *\n * It handles:\n * - `field = 'literal'`\n * - `field != 'literal'`\n * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)\n * - `A AND B`, `A OR B` — only where the keyword is at the top level\n * - `true`\n * - `IN (...)` (as optimistic true)\n *\n * For anything it doesn't understand, it returns a `raw` expression, which\n * the evaluator treats as \"unknown\" (and usually optimistic true).\n *\n * **This output also round-trips back into DDL** via `policyToPostgres` (the\n * schema/policy generators), so decomposing a clause the parser only partly\n * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,\n * prefer `raw`: it is reproduced verbatim.\n */\n/** True when `keyword` starts at `i` as a standalone word. */\nfunction isKeywordAt(upper: string, i: number, keyword: string): boolean {\n if (!upper.startsWith(keyword, i)) return false;\n const before = i === 0 ? \" \" : upper[i - 1];\n const after = upper[i + keyword.length] ?? \" \";\n return /[\\s()]/.test(before) && /[\\s()]/.test(after);\n}\n\n/**\n * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and\n * outside a string literal. Returns null when it never does, so the caller\n * leaves the clause alone.\n *\n * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the\n * `AND` inside\n * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`\n * split the expression, and re-emitting the halves produced\n * `(EXISTS (...) AND m.user_id = rebase.uid())`\n * where `m` is no longer in scope — SQL that Postgres rejects outright with\n * \"missing FROM-clause entry for table\". Returning null instead keeps such a\n * clause as a `raw` expression, which round-trips verbatim.\n */\nfunction splitTopLevel(sql: string, keyword: \"AND\" | \"OR\"): string[] | null {\n const upper = sql.toUpperCase();\n const parts: string[] = [];\n let depth = 0;\n let inString = false;\n let start = 0;\n\n for (let i = 0; i < sql.length; i++) {\n const ch = sql[i];\n if (inString) {\n if (ch === \"'\") {\n if (sql[i + 1] === \"'\") i++; // '' escapes a quote inside a literal\n else inString = false;\n }\n continue;\n }\n if (ch === \"'\") { inString = true; continue; }\n if (ch === \"(\") { depth++; continue; }\n if (ch === \")\") { depth--; continue; }\n if (depth === 0 && isKeywordAt(upper, i, keyword)) {\n parts.push(sql.slice(start, i));\n i += keyword.length - 1;\n start = i + 1;\n }\n }\n\n if (parts.length === 0) return null;\n parts.push(sql.slice(start));\n const trimmedParts = parts.map(p => p.trim()).filter(p => p.length > 0);\n return trimmedParts.length > 1 ? trimmedParts : null;\n}\n\n/** `SELECT` as the first word of `sql`. */\nconst STARTS_WITH_SELECT = /^select(?=[\\s(])/i;\n\n/**\n * Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`\n * — and never the parens of a scalar subquery: `(SELECT x)` without them is not\n * an expression at all, and re-emitted bare it is a syntax error inside\n * `USING (…)`.\n */\nfunction stripOuterParens(sql: string): string {\n let s = sql.trim();\n while (wrapsWhole(s) && !STARTS_WITH_SELECT.test(s.slice(1).trim())) {\n s = s.slice(1, -1).trim();\n }\n return s;\n}\n\n/** Whether `s` is one parenthesised group: `(a AND b)`, not `(a) AND (b)`. */\nfunction wrapsWhole(s: string): boolean {\n if (!s.startsWith(\"(\") || !s.endsWith(\")\")) return false;\n let depth = 0;\n let inString = false;\n for (let i = 0; i < s.length; i++) {\n const ch = s[i];\n if (inString) {\n if (ch === \"'\") {\n if (s[i + 1] === \"'\") i++;\n else inString = false;\n }\n continue;\n }\n if (ch === \"'\") { inString = true; continue; }\n if (ch === \"(\") depth++;\n else if (ch === \")\") {\n depth--;\n if (depth === 0 && i < s.length - 1) return false;\n }\n }\n return true;\n}\n\n/** `x` when `s` is exactly `(SELECT x)`, otherwise null. */\nfunction scalarSubqueryBody(s: string): string | null {\n if (!wrapsWhole(s)) return null;\n const inner = s.slice(1, -1).trim();\n return STARTS_WITH_SELECT.test(inner) ? inner.slice(\"select\".length).trim() : null;\n}\n\n/**\n * The names in a role array's body — `'admin', 'editor'`, each with or without\n * Postgres's `::text` — or `null` when an element is anything else. A quote\n * or a comma inside a name is not split into roles that were never there: the\n * clause stays raw and round-trips verbatim.\n */\nfunction roleArrayElements(list: string): string[] | null {\n const roles: string[] = [];\n for (const element of list.split(\",\")) {\n const match = /^'([^',]*)'(?:::text)?$/.exec(element.trim());\n if (!match) return null;\n roles.push(match[1]);\n }\n return roles;\n}\n\n/** Whether any part of `expr` fell through to `raw`. */\nfunction containsRaw(expr: PolicyExpression): boolean {\n switch (expr.kind) {\n case \"raw\":\n return true;\n case \"and\":\n case \"or\":\n return expr.operands.some(containsRaw);\n case \"not\":\n return containsRaw(expr.operand);\n case \"existsIn\":\n return containsRaw(expr.where);\n default:\n return false;\n }\n}\n\nexport function sqlToPolicy(sql: string): PolicyExpression {\n // Normalised before anything else looks at it, so every pattern below only\n // has to know the current spelling. A database migrated by a pre-1.0 release\n // still holds `auth.uid()` in its policy bodies until the next push or boot\n // recompiles them — and until then the admin UI reads those bodies back\n // through here. Without this they parse as opaque `raw`, and the framework's\n // own policies get badged as hand-written drift.\n //\n // Normalising rather than accepting both spellings throughout is deliberate:\n // it also means a legacy policy that falls through to `raw` is stored in the\n // new spelling, so editing and saving one in the Studio migrates it.\n const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());\n\n // `(SELECT x)` — how the compiler spells a part of a policy that does not\n // read the row, so Postgres evaluates it once per statement. Read back as\n // `x` when `x` is understood completely: without a FROM a scalar subquery\n // is one row holding x's value, so the two mean the same. Anything less —\n // a FROM, a LIMIT, an alias, a clause the parser does not know — stays\n // `raw`, parens included, and is reproduced verbatim.\n const subqueryBody = scalarSubqueryBody(trimmed);\n if (subqueryBody !== null) {\n const inner = sqlToPolicy(subqueryBody);\n return containsRaw(inner) ? policy.raw(trimmed) : inner;\n }\n\n if (trimmed.toLowerCase() === \"true\") return policy.true();\n if (trimmed.toLowerCase() === \"false\") return policy.false();\n\n // Handle roles overlap (&&) and containment (@>), as the compiler writes\n // them and as Postgres stores them:\n // string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']\n // string_to_array(rebase.roles(), ','::text) && ARRAY['admin'::text, 'editor'::text]\n // The second is what `pg_policies` holds, so what `schema introspect`\n // writes as a rule's `using`. Read as raw, a junction could not inherit\n // the rule, and its derived edge-write policies were left with none.\n const rolesMatch = trimmed.match(/^string_to_array\\s*\\(\\s*rebase\\.roles\\(\\)\\s*,\\s*','(?:::text)?\\s*\\)\\s*(&&|@>)\\s*ARRAY\\s*\\[(.+)\\]$/i);\n const roles = rolesMatch ? roleArrayElements(rolesMatch[2]) : null;\n if (rolesMatch && roles) {\n return rolesMatch[1] === \"&&\" ? policy.rolesOverlap(roles) : policy.rolesContain(roles);\n }\n\n // OR binds looser than AND, so it splits first.\n const orParts = splitTopLevel(trimmed, \"OR\");\n if (orParts) return policy.or(...orParts.map(sqlToPolicy));\n\n const andParts = splitTopLevel(trimmed, \"AND\");\n if (andParts) return policy.and(...andParts.map(sqlToPolicy));\n\n // Handle = and !=\n const match = trimmed.match(/^(.+?)\\s*(!?=)\\s*(.+)$/);\n if (match) {\n const [, leftStr, op, rightStr] = match;\n const left = parseOperand(leftStr.trim());\n const right = parseOperand(rightStr.trim());\n if (left && right) {\n return policy.compare(left, op === \"=\" ? \"eq\" : \"neq\", right);\n }\n }\n\n // Fallback to raw — the NORMALISED text, not the input. Storing the input\n // verbatim would mean a legacy policy read out of a database, edited in the\n // Studio and saved, writes `auth.uid()` back into the project's config: a\n // call to a function 1.0 no longer creates.\n return policy.raw(trimmed);\n}\n\n/**\n * Literals from other BaaS platforms that people compare `rebase.uid()` against\n * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on\n * `pgRoles`, one surface over: the same muscle memory inside a `using:` string\n * is the more dangerous spelling, because it inverts a rule instead of\n * emptying a table.\n */\n/**\n * A `Map`, not an object literal.\n *\n * As `Record<string, string>` this was indexed with a literal taken straight\n * out of a policy, so every key on `Object.prototype` answered: a rule\n * comparing `rebase.uid()` to `\"valueOf\"`, `\"toString\"`, `\"constructor\"` or\n * `\"hasOwnProperty\"` found a truthy \"platform\" and reported an anonymous-grant\n * risk that does not exist — with the matched function interpolated into the\n * explanation as the platform's name. A security warning that fires on\n * innocent input is worse than none: it is what teaches people to skip the\n * warnings that are real.\n *\n * Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,\n * `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain\n * object. Found by a property test, on the input `\"valueOf\"`.\n */\nconst FOREIGN_CONVENTION_UIDS = new Map<string, string>([\n [\"anon\", \"Supabase\"],\n [\"authenticated\", \"Supabase\"],\n [\"service_role\", \"Supabase\"]\n]);\n\n/**\n * The same foreign literals, as a pattern for SQL that could not be parsed\n * back into structure.\n */\nconst FOREIGN_UID_LITERAL_SQL = new RegExp(\n String.raw`rebase\\.uid\\(\\)\\s*=\\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join(\"|\")})'`,\n \"i\"\n);\n\n/** A clause that reads as a lockdown but admits anonymous callers. */\nexport interface AnonymousGrantRisk {\n /** Which spelling was found. */\n pattern: \"foreign-uid-literal\" | \"uid-not-null\";\n /** The offending fragment — the literal, or the SQL that is a tautology. */\n detail: string;\n /** Why it admits anonymous callers, and what to write instead. */\n explanation: string;\n}\n\n/**\n * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.\n *\n * Both schema spellings, because this runs over policy bodies read back from a\n * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.\n * A security check that stops recognising a dangerous clause because the\n * framework renamed a function is a check that silently turns off.\n */\nconst UID_NOT_NULL = /\\b(?:rebase|auth)\\.uid\\(\\)\\s+IS\\s+NOT\\s+NULL/i;\n\n/**\n * Find clauses that read as \"signed-in users only\" but admit anonymous callers.\n *\n * Both spellings come from the same place — Supabase, where its own `auth.uid()`\n * really is NULL for an anonymous request. Rebase substitutes\n * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which\n * is how the trusted *server* context is recognised), so:\n *\n * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and\n * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the\n * other. This one is not hypothetical and was not only a foreign habit:\n * rebase's own request path reported `'anon'` while everything that compiled\n * or checked a policy used `'anonymous'`, so whichever literal an author\n * picked, half the anonymous callers walked through. See\n * {@link ANONYMOUS_USER_IDS}.\n *\n * Either one turns a lockdown into a full grant, and neither looks wrong. No\n * real user id is ever one of these literals, and a user-context request is\n * never NULL, so a match is always a mistake rather than a deliberate check.\n *\n * Structured expressions are checked too, not just parsed SQL: `policy.compare`\n * can spell the same mistake.\n */\nexport function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[] {\n const found: AnonymousGrantRisk[] = [];\n\n const visit = (e: PolicyExpression): void => {\n switch (e.kind) {\n case \"and\":\n case \"or\":\n e.operands.forEach(visit);\n return;\n case \"not\":\n visit(e.operand);\n return;\n case \"existsIn\":\n visit(e.where);\n return;\n case \"raw\": {\n if (UID_NOT_NULL.test(e.sql)) {\n found.push({\n pattern: \"uid-not-null\",\n detail: e.sql,\n explanation: \"`rebase.uid() IS NOT NULL` is true for every request that came from a client, \" +\n `including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +\n \"Use `condition: policy.authenticated()` to mean \\\"signed in\\\".\"\n });\n }\n // The foreign literals have to be looked for here too, not only\n // in `compare`. `sqlToPolicy` falls back to `raw` for anything\n // it cannot structure — an `EXISTS (...)` subquery always does —\n // so a policy read back from the database arrives as one opaque\n // string. Checking only the tautology meant a genuine\n // `rebase.uid() = 'anon'` inside an `existsIn` was structurally\n // undetectable once round-tripped, and the caller read the empty\n // result as \"no risks found\".\n const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);\n if (foreign) {\n const literal = foreign[1];\n found.push({\n pattern: \"foreign-uid-literal\",\n detail: literal,\n explanation: `'${literal}' is a ${FOREIGN_CONVENTION_UIDS.get(literal)} convention. Rebase ` +\n `reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against ` +\n `'${literal}' passes for every caller. Use \\`condition: policy.authenticated()\\` to ` +\n \"mean \\\"signed in\\\".\"\n });\n }\n return;\n }\n case \"compare\": {\n const literal = [e.left, e.right].find(o => o.kind === \"literal\") as LiteralPolicyOperand | undefined;\n const comparesUid = e.left.kind === \"authUid\" || e.right.kind === \"authUid\";\n if (!comparesUid || typeof literal?.value !== \"string\") return;\n const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);\n if (!platform) return;\n found.push({\n pattern: \"foreign-uid-literal\",\n detail: literal.value,\n explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +\n `request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +\n \"every caller. Use `condition: policy.authenticated()` to mean \\\"signed in\\\" — it \" +\n `compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(\", \")}), covering ` +\n \"every spelling rebase has reported rather than whichever one you remember.\"\n });\n return;\n }\n default:\n return;\n }\n };\n\n visit(expr);\n return found;\n}\n\nfunction parseOperand(str: string) {\n // current_setting('app.uid') or rebase.uid(). `app.user_id` is the\n // pre-rename spelling and stays parseable: policies are data, so a\n // database provisioned before the rename still holds rules written\n // against it, and round-tripping one must not silently drop the operand.\n //\n // ANCHORED, and that is the whole point. These tests used to be\n // unanchored — `.test(str)` rather than `^…$` — so any operand text that\n // merely *contained* a uid call was replaced wholesale by the call itself.\n // Everything else in the expression was discarded with it, including a\n // leading `NOT (`:\n //\n // NOT (rebase.uid() = rebase.uid()) parsed as rebase.uid() = rebase.uid()\n //\n // A deny became an unconditional grant. The realistic spelling is a\n // hand-written defensive rule with a uid call on both sides —\n // COALESCE(rebase.uid(), '') = COALESCE(owner_id, rebase.uid())\n // — which collapsed to the same tautology. This is not confined to the\n // admin UI: `securityRuleToConditions` feeds a rule's raw `using:` string\n // through here, and the Postgres DDL generators compile the result, so the\n // tautology was written into the database as the policy body.\n //\n // An operand this cannot identify exactly must return null, which drops the\n // whole clause to `raw` and reproduces it verbatim. That is the rule the\n // rest of this file already follows: when in doubt, prefer `raw`.\n if (/^current_setting\\s*\\(\\s*'app\\.(uid|user_id)'\\s*\\)$/i.test(str) || /^rebase\\.uid\\(\\)$/i.test(str)) {\n return policy.authUid();\n }\n // The same call as an initPlan — the compiler's spelling, `(SELECT\n // rebase.uid())`, and Postgres's re-rendering of it, `( SELECT rebase.uid()\n // AS uid)`. Anchored like the line above, for the same reason.\n if (/^\\(\\s*select\\s+rebase\\.uid\\(\\)(?:\\s+as\\s+\"?uid\"?)?\\s*\\)$/i.test(str)) {\n return policy.authUid();\n }\n\n // Literal string: 'value', with `''` decoded back to a single quote.\n //\n // `quoteLiteral` doubles every quote on the way out, and this did not undo\n // it, so a literal containing an apostrophe grew on every trip: O'Brien →\n // O''Brien → O''''Brien, doubling each time a policy was read back and\n // recompiled. Past the first trip the emitted policy compares against a\n // string no row holds.\n const literal = parseSingleQuoted(str);\n if (literal !== null) {\n return policy.literal(literal);\n }\n\n // Unquoted literals, which must be recognised BEFORE the bare-word branch\n // below or they are read as column names.\n //\n // `quoteLiteral` emits booleans, numbers and null unquoted, so `a = false`\n // came back as a comparison against a *field* called `false`, and `a = 42`\n // against a field called `42`. The recompiled SQL is identical either way,\n // which is why this survived a round-trip check on the SQL — but the\n // expression is now wrong, and the expression is what the admin UI\n // evaluates. Against a row with no `a`, Postgres denies (`NULL = false` is\n // not true) while the JS evaluator compared two missing columns, found them\n // equal, and allowed. That is precisely the client/database drift the\n // shared PolicyExpression model exists to make impossible.\n //\n // Unambiguous in both directions: a SQL identifier cannot begin with a\n // digit, and bare `true`/`false`/`null` are always the literals — a column\n // so named would have to be double-quoted to be referenced at all.\n //\n // Only a number a JS `number` holds exactly. The literal is re-emitted from\n // the parsed value, so `org_id = 1234567890123456789` came back as\n // `org_id = 1234567890123456800` — a comparison against another tenant's\n // id, written into the database as the policy. Anything wider stays `raw`.\n if (/^-?\\d+$/.test(str) || /^-?\\d*\\.\\d+$/.test(str)) {\n return isExactNumber(str) ? policy.literal(Number(str)) : null;\n }\n if (/^true$/i.test(str)) return policy.literal(true);\n if (/^false$/i.test(str)) return policy.literal(false);\n if (/^null$/i.test(str)) return policy.literal(null);\n\n // Bare field name — but only one that survives the snake-casing the\n // compiler will apply to it. `toSnakeCase(\"_\")` is the empty string, and a\n // field that compiles to an empty column reference emits `= 'x'`, which is\n // a syntax error at CREATE POLICY time. Such a name is left to `raw`, where\n // it round-trips verbatim instead. `toSnakeCase` itself is not touched:\n // column names derived by it are already in shipped databases.\n if (/^\\w+$/.test(str) && toSnakeCase(str) !== \"\") {\n return policy.field(str);\n }\n\n return null;\n}\n\n/**\n * Whether the decimal numeral `str` survives a trip through a JS `number` and\n * back to text with its value intact: `0.50` does (as `0.5`), `2^53 + 1` and\n * a twentieth decimal place do not.\n */\nfunction isExactNumber(str: string): boolean {\n const negative = str.startsWith(\"-\");\n const [whole, fraction = \"\"] = (negative ? str.slice(1) : str).split(\".\");\n const digits = whole.replace(/^0+(?=\\d)/, \"\") || \"0\";\n const decimals = fraction.replace(/0+$/, \"\");\n const canonical = decimals ? `${digits}.${decimals}` : digits;\n const value = Number(str);\n return String(Math.abs(value)) === canonical;\n}\n\n/**\n * Decode a single-quoted SQL literal, or null when `str` is not exactly one.\n *\n * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an\n * operator, not one literal whose body contains a quote, and a regex anchored\n * on the outer quotes would happily read it as the latter. Every interior quote\n * must therefore be part of a `''` pair.\n */\nfunction parseSingleQuoted(str: string): string | null {\n if (str.length < 2 || !str.startsWith(\"'\") || !str.endsWith(\"'\")) return null;\n const body = str.slice(1, -1);\n let out = \"\";\n for (let i = 0; i < body.length; i++) {\n if (body[i] !== \"'\") {\n out += body[i];\n continue;\n }\n if (body[i + 1] === \"'\") {\n out += \"'\";\n i++;\n continue;\n }\n return null; // a bare quote — `str` is not a single literal\n }\n return out;\n}\n"],"mappings":"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"}
@@ -75,7 +75,15 @@ export declare function getArrayResolvedProperties({ propertyKey, propertyValue,
75
75
  propertyConfigs?: Record<string, PropertyConfig>;
76
76
  authController: AuthState;
77
77
  }): Property[];
78
- export declare function resolveEnumValues(input: EnumValues): EnumValueConfig[] | undefined;
78
+ /**
79
+ * An enum as `{ id, label }` entries, or `undefined` when it is not one.
80
+ *
81
+ * {@link enumToObjectEntries} is the reading: an array is a list of entries or
82
+ * of plain values (`["draft", "published"]`, whose ids are the values, not
83
+ * their positions), and a map entry's id is its key even when its value is a
84
+ * config that does not repeat it.
85
+ */
86
+ export declare function resolveEnumValues(input: EnumValues | Array<string | number>): EnumValueConfig[] | undefined;
79
87
  /**
80
88
  * The lists rendered inside an entity view of `collection` — its tabs.
81
89
  *
@@ -177,13 +177,17 @@ function getArrayResolvedProperties({ propertyKey, propertyValue, property, ...p
177
177
  });
178
178
  }).filter((e) => Boolean(e)) : [];
179
179
  }
180
+ /**
181
+ * An enum as `{ id, label }` entries, or `undefined` when it is not one.
182
+ *
183
+ * {@link enumToObjectEntries} is the reading: an array is a list of entries or
184
+ * of plain values (`["draft", "published"]`, whose ids are the values, not
185
+ * their positions), and a map entry's id is its key even when its value is a
186
+ * config that does not repeat it.
187
+ */
180
188
  function resolveEnumValues(input) {
181
- if (typeof input === "object") return Object.entries(input).map(([id, value]) => typeof value === "string" ? {
182
- id,
183
- label: value
184
- } : value);
185
- else if (Array.isArray(input)) return input;
186
- else return;
189
+ if (typeof input !== "object" || input === null) return void 0;
190
+ return enumToObjectEntries(input);
187
191
  }
188
192
  /**
189
193
  * The lists rendered inside an entity view of `collection` — its tabs.
@@ -1 +1 @@
1
- {"version":3,"file":"resolutions.es.js","names":[],"sources":["../../src/util/resolutions.ts"],"sourcesContent":["import {\n ArrayProperty,\n AuthState,\n CollectionConfig,\n EnumValueConfig,\n EnumValues,\n NumberProperty,\n Properties,\n Property,\n RelationProperty,\n ResolvedRelation,\n StringProperty,\n getDataSourceCapabilities,\n getDeclaredSubcollections,\n type EntityChildView\n} from \"@rebasepro/types\";\n\ntype PropertyConfig = { property: unknown; [key: string]: unknown };\nimport { isPropertyBuilder } from \"./entities\";\nimport { enumToObjectEntries } from \"./enums\";\nimport { DEFAULT_ONE_OF_TYPE } from \"./common\";\nimport { isDefaultFieldConfigId } from \"@rebasepro/utils\";\nimport { getIn, mergeDeep } from \"@rebasepro/utils\";\nimport { isJunctionBackedRelation, resolveCollectionRelations } from \"./relations\";\nimport { resolveRelation } from \"./resolve-relation\";\n\n/**\n * Resolve property builders, enums and arrays.\n */\n\nexport type ResolvePropertyProps<M extends Record<string, unknown> = Record<string, unknown>> = {\n property: Property\n propertyKey?: string,\n values?: Partial<M>,\n previousValues?: Partial<M>,\n path?: string,\n entityId?: string | number,\n index?: number,\n propertyConfigs?: Record<string, PropertyConfig>;\n ignoreMissingFields?: boolean;\n authController: AuthState;\n}\n\nexport function resolveProperty<M extends Record<string, unknown> = Record<string, unknown>>(props: ResolvePropertyProps<M>): Property | null {\n\n const {\n property,\n ignoreMissingFields = false,\n ...rest\n } = props;\n\n let resultProperty: Property;\n\n if (isPropertyBuilder(property)) {\n const path = rest.path;\n if (!path) {\n // When path is not available (e.g. in preview contexts), skip dynamic\n // resolution and use the property as-is without dynamic modifications.\n resultProperty = property as Property;\n } else {\n const usedPropertyValue = rest.propertyKey ? getIn(rest.values, rest.propertyKey) : undefined;\n const dynamicProps = property.dynamicProps?.({\n ...rest,\n path,\n propertyValue: usedPropertyValue,\n values: rest.values ?? {},\n previousValues: rest.previousValues ?? rest.values ?? {}\n });\n resultProperty = mergeDeep(property, dynamicProps ?? {});\n }\n } else {\n resultProperty = property as Property;\n }\n\n // Apply dynamic properties if they exist\n if (resultProperty?.dynamicProps && rest.path) {\n const path = rest.path;\n const usedPropertyValue = rest.propertyKey ? getIn(rest.values, rest.propertyKey) : undefined;\n const dynamicPropsResult = resultProperty.dynamicProps({\n ...rest,\n path,\n propertyValue: usedPropertyValue,\n values: rest.values ?? {},\n previousValues: rest.previousValues ?? rest.values ?? {}\n });\n\n if (dynamicPropsResult) {\n resultProperty = mergeDeep(resultProperty, dynamicPropsResult);\n }\n }\n\n let resolvedProperty: Property | null;\n\n if (resultProperty?.type === \"map\" && resultProperty.properties) {\n const properties = resolveProperties({\n ignoreMissingFields,\n ...rest,\n properties: resultProperty.properties\n });\n resolvedProperty = {\n ...resultProperty,\n properties\n } as Property;\n } else if (resultProperty?.type === \"array\") {\n resolvedProperty = resultProperty;\n } else if ((resultProperty?.type === \"string\" || resultProperty?.type === \"number\") && resultProperty.enum) {\n resolvedProperty = resolvePropertyEnum(resultProperty);\n } else {\n resolvedProperty = resultProperty;\n }\n\n if (resolvedProperty?.propertyConfig && !isDefaultFieldConfigId(resolvedProperty.propertyConfig)) {\n const cmsFields = rest.propertyConfigs;\n if (!cmsFields && !ignoreMissingFields) {\n throw Error(`Trying to resolve a property with key '${resolvedProperty.propertyConfig}' that inherits from a custom property config but no custom property configs were provided. Use the property 'propertyConfigs' in your app config to provide them`);\n }\n const customField: PropertyConfig | undefined = cmsFields?.[resolvedProperty.propertyConfig];\n if (!customField) {\n console.warn(`Trying to resolve a property with key '${resolvedProperty.propertyConfig}' that inherits from a custom property config but no custom property config with that key was found. Check the 'propertyConfigs' in your app config`)\n return resolvedProperty;\n }\n if (customField.property) {\n const restConfigProperty = { ...customField.property } as Record<string, unknown>;\n delete restConfigProperty.propertyConfig;\n const customFieldProperty = resolveProperty({\n property: { name: \"\",\n...restConfigProperty } as Property,\n ignoreMissingFields,\n ...rest\n });\n if (customFieldProperty) {\n resolvedProperty = mergeDeep(customFieldProperty, resolvedProperty);\n }\n }\n\n }\n\n return resolvedProperty;\n}\n\n/**\n * The resolved relation a relation property refers to.\n *\n * Normalization stamps `resolvedRelation` onto the property, so this is usually\n * a field read. It falls back to resolving from the collection for properties\n * that never went through the registry — a preview, or a form rendered straight\n * from an authored config.\n */\nexport function resolveRelationProperty(\n property: RelationProperty,\n collection: CollectionConfig,\n propertyKey?: string\n): ResolvedRelation {\n if (property.resolvedRelation) return property.resolvedRelation;\n\n if (property.relation) {\n return resolveRelation(property.relation, collection, propertyKey);\n }\n\n const name = propertyKey ?? \"\";\n const declared = resolveCollectionRelations(collection)[name];\n if (!declared) {\n throw Error(\n `Relation property '${name || \"(unnamed)\"}' on '${collection.slug}' declares no \\`relation\\`, ` +\n \"and the collection has no relation of that name.\"\n );\n }\n return declared;\n}\n\n/**\n * Resolve enum aliases for a string or number property\n * @param property\n */\nexport function resolvePropertyEnum(property: StringProperty | NumberProperty): StringProperty | NumberProperty {\n if (typeof property.enum === \"object\") {\n return {\n ...property,\n enum: enumToObjectEntries(property.enum)?.filter((value) => value && (value.id || value.id === 0) && value.label) ?? []\n };\n }\n return property as StringProperty | NumberProperty;\n}\n\n/**\n * Resolve enums and arrays for properties\n * @param properties\n * @param value\n */\nexport function resolveProperties<M extends Record<string, unknown>>({\n propertyKey,\n properties,\n ignoreMissingFields,\n ...props\n}: {\n propertyKey?: string,\n properties: Properties,\n values?: Partial<M>,\n previousValues?: Partial<M>,\n path?: string,\n entityId?: string | number,\n index?: number,\n propertyConfigs?: Record<string, PropertyConfig>;\n ignoreMissingFields?: boolean;\n authController: AuthState;\n}): Properties {\n return Object.entries<Property>(properties as Record<string, Property>)\n .map(([key, property]) => {\n const childResolvedProperty = resolveProperty({\n propertyKey: propertyKey ? `${propertyKey}.${key}` : undefined,\n property: property,\n ignoreMissingFields,\n ...props\n });\n if (!childResolvedProperty) return {};\n return {\n [key]: childResolvedProperty\n };\n })\n .filter((a) => a !== null)\n .reduce((a, b) => ({ ...a,\n...b }), {}) as Properties;\n}\n\nexport function resolveArrayProperties<M>({\n propertyKey,\n property,\n ignoreMissingFields = false,\n ...props\n}: {\n propertyKey?: string,\n property: ArrayProperty,\n values?: Partial<M>,\n previousValues?: Partial<M>,\n path?: string,\n entityId?: string | number,\n index?: number,\n propertyConfigs?: Record<string, PropertyConfig>;\n ignoreMissingFields?: boolean;\n authController: AuthState;\n}): Property[] {\n const propertyValue = propertyKey ? getIn(props.values, propertyKey) : undefined;\n\n if (property.of) {\n if (Array.isArray(property.of)) {\n return property.of.map((p, index) => {\n return resolveProperty({\n propertyKey: `${propertyKey}.${index}`,\n property: p as Property,\n ignoreMissingFields,\n ...props,\n index\n });\n }) as Property[];\n } else {\n const of = property.of;\n const resolvedProperties = getArrayResolvedProperties({\n propertyValue,\n propertyKey,\n property,\n ignoreMissingFields,\n ...props\n });\n // Destructured to be *excluded* from `...rest`, not to be used —\n // see the comment below. Said explicitly so the discarded-value\n // ratchet does not carry a finding that is working as intended.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { values, previousValues, ...rest } = props;\n const ofProperty = resolveProperty({ // we don't want to pass the values of the parent entity\n property: of,\n ignoreMissingFields,\n ...rest\n });\n if (!ofProperty && !ignoreMissingFields)\n throw Error(\"When using a property builder as the 'of' prop of an ArrayProperty, you must return a valid child property\")\n return resolvedProperties;\n }\n } else if (property.oneOf) {\n const typeField = property.oneOf?.typeField ?? DEFAULT_ONE_OF_TYPE;\n const resolvedProperties: Property[] = Array.isArray(propertyValue)\n ? propertyValue.map((v, index) => {\n const type = v && v[typeField];\n const childProperty = property.oneOf?.properties[type];\n if (!type || !childProperty) return null;\n return resolveProperty({\n propertyKey: `${propertyKey}.${index}`,\n property: childProperty,\n ignoreMissingFields,\n ...props\n });\n }).filter(e => Boolean(e)) as Property[]\n : [];\n return resolvedProperties;\n } else if (!property.columnType) {\n // An array with neither `of`/`oneOf` nor a `columnType` describes no element\n // type, so nothing can be generated or rendered from it.\n //\n // The escape hatch used to be `ui.Field` — \"a custom component can render\n // anything\" — which made a *presentation* field decide whether a schema was\n // valid, in code the Postgres generator runs. `columnType` is the same escape\n // hatch stated as data: `columnType: \"text[]\"` says what the column holds,\n // which is what both the generator and the form actually need.\n throw Error(`The array property (${propertyKey}) needs to declare an 'of' or a 'oneOf' property, or a \\`columnType\\` such as \"text[]\"`);\n } else {\n return [];\n }\n\n}\n\nexport function getArrayResolvedProperties({\n propertyKey,\n propertyValue,\n property,\n ...props\n}: {\n propertyValue: unknown,\n propertyKey?: string,\n property: ArrayProperty,\n ignoreMissingFields: boolean,\n values?: object;\n previousValues?: object;\n path?: string;\n entityId?: string | number;\n index?: number;\n propertyConfigs?: Record<string, PropertyConfig>;\n authController: AuthState;\n}) {\n\n const of = property.of;\n if (!of)\n throw Error(\n `Trying to resolve an array property (${propertyKey}) without providing an 'of' property`\n )\n return Array.isArray(propertyValue)\n ? propertyValue.map((v: unknown, index: number) => {\n return resolveProperty({\n propertyKey: `${propertyKey}.${index}`,\n property: Array.isArray(of) ? of[index] : of,\n ...props,\n index\n });\n }).filter(e => Boolean(e)) as Property[]\n : [];\n}\n\nexport function resolveEnumValues(input: EnumValues): EnumValueConfig[] | undefined {\n if (typeof input === \"object\") {\n return Object.entries(input).map(([id, value]) =>\n (typeof value === \"string\"\n ? {\n id,\n label: value\n }\n : value));\n } else if (Array.isArray(input)) {\n return input as EnumValueConfig[];\n } else {\n return undefined;\n }\n}\n\n\n/**\n * The lists rendered inside an entity view of `collection` — its tabs.\n *\n * The single derivation. There used to be two that disagreed: this one, and a\n * copy in `CollectionRegistry.normalizeCollection` that stamped each child with\n * the *target collection's* slug instead of the relation key. Since the\n * registry ran first and cached its answer onto `childCollections`, its version\n * was the one that won, and the frontend addressed child listings by a segment\n * the backend could not resolve.\n *\n * Order of precedence:\n * 1. `childCollections` — the explicit escape hatch for custom drivers.\n * 2. `subcollections` on an engine that has real containment (Firestore).\n * 3. many-relations on an engine that has relations (SQL).\n */\nexport function getEntityChildViews<M extends Record<string, unknown> = Record<string, unknown>>(\n collection: CollectionConfig<M>\n): EntityChildView[] {\n const asSubcollections = (collections: CollectionConfig<Record<string, unknown>>[]): EntityChildView[] =>\n collections.filter(Boolean).map(child => ({\n key: child.slug,\n collection: child,\n source: { kind: \"subcollection\" as const }\n }));\n\n if (collection.childCollections) {\n return asSubcollections(collection.childCollections() ?? []);\n }\n\n const capabilities = getDataSourceCapabilities(collection.engine);\n\n const declaredSubcollections = getDeclaredSubcollections(collection);\n if (capabilities.supportsSubcollections && declaredSubcollections) {\n return asSubcollections(declaredSubcollections() ?? []);\n }\n\n if (!capabilities.supportsRelations) return [];\n\n const resolvedRelations = resolveCollectionRelations(collection);\n const views: EntityChildView[] = [];\n const seen = new Set<string>();\n\n // Keyed by the map key, not by `relationName`: the map key is what\n // `findRelation` matches a path segment against, so it is the only one that\n // addresses the same relation on both sides of the wire. The map registers\n // some relations twice — once canonically, once under the declaring\n // property key — so dedupe on the underlying relation.\n for (const [relationKey, relation] of Object.entries(resolvedRelations)) {\n if (relation.cardinality !== \"many\") continue;\n\n const identity = relation.relationName ?? relationKey;\n if (seen.has(identity)) continue;\n\n let target: CollectionConfig | undefined;\n try {\n target = relation.target();\n } catch {\n continue;\n }\n if (!target) continue;\n seen.add(identity);\n\n // A name given to the declaring property is the author naming the tab.\n // Only the tab: it is a plural (\"Tags\"), and one row of it is still\n // what the target calls one (\"Tag\").\n const declaringProperty = Object.entries((collection.properties ?? {}) as Record<string, Property>)\n .find(([propKey, p]) => p.type === \"relation\" && ((p as RelationProperty).relation?.relationName ?? propKey) === identity);\n const customName = declaringProperty?.[1]?.name;\n\n const base: CollectionConfig<Record<string, unknown>> = {\n ...target,\n slug: relationKey,\n ...(customName ? { name: customName } : {})\n } as CollectionConfig<Record<string, unknown>>;\n\n views.push({\n key: relationKey,\n collection: (relation.overrides ? mergeDeep(base, relation.overrides) : base) as CollectionConfig<Record<string, unknown>>,\n source: {\n kind: \"relation\",\n relationKey,\n mode: isJunctionBackedRelation(relation) ? \"linked\" : \"owned\",\n targetSlug: target.slug\n }\n });\n }\n\n return views;\n}\n\n/**\n * Each of `collection`'s tabs paired with the property that declared it, when a\n * property declared it: child view key → property key.\n *\n * A many-relation can only be declared as a property — that is the documented\n * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So\n * one declaration reaches the panel twice, and neither surface knew about the\n * other. The form rendered a relation picker beside the tab, and the collection\n * table rendered *two* columns under one heading: the relation's own column,\n * showing the child rows, and a jump-to-tab button carrying the same name.\n *\n * The pairing is what lets each surface decide which half is redundant, and it\n * has to be a pairing rather than two sets because the two keys differ whenever\n * a relation is named. The match is on the resolved `relationName` — the\n * identity `getEntityChildViews` itself dedupes on — so a relation declared in\n * `relations` and pointed at by a differently-named property is recognised too.\n *\n * A relation with no property of its own is absent here, which is the point: it\n * has exactly one surface already, and nothing to weigh it against.\n *\n * Only top-level properties: a relation nested inside a `map` gets no tab.\n */\nexport function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(\n collection: CollectionConfig<M>\n): Map<string, string> {\n const pairs = new Map<string, string>();\n\n const relationProperties = Object.entries((collection.properties ?? {}) as Record<string, Property>)\n .filter(([, property]) => property?.type === \"relation\");\n if (relationProperties.length === 0) return pairs;\n\n const relationViews = getEntityChildViews(collection)\n .filter(view => view.source.kind === \"relation\");\n if (relationViews.length === 0) return pairs;\n\n const resolvedRelations = resolveCollectionRelations(collection);\n const identityOf = (relationKey: string): string =>\n resolvedRelations[relationKey]?.relationName ?? relationKey;\n\n const declaringPropertyByIdentity = new Map<string, string>();\n for (const [propertyKey, property] of relationProperties) {\n const relation = (property as RelationProperty).resolvedRelation ?? resolvedRelations[propertyKey];\n // A to-one relation is a foreign key the author edits, never a tab. No\n // view will match it — the views here are many-relations only — but\n // reading the cardinality says so where someone is looking.\n if (relation?.cardinality !== \"many\") continue;\n const identity = relation.relationName ?? propertyKey;\n if (!declaringPropertyByIdentity.has(identity)) declaringPropertyByIdentity.set(identity, propertyKey);\n }\n\n for (const view of relationViews) {\n const propertyKey = declaringPropertyByIdentity.get(\n identityOf((view.source as { relationKey: string }).relationKey));\n if (propertyKey) pairs.set(view.key, propertyKey);\n }\n\n return pairs;\n}\n\n/**\n * The property keys of `collection` whose relation is already one of its tabs.\n *\n * What a form asks: the tab is the treatment for a list of child rows, so the\n * picker beside it is the redundant half. See\n * {@link getChildViewDeclaringProperties}.\n */\nexport function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(\n collection: CollectionConfig<M>\n): Set<string> {\n return new Set(getChildViewDeclaringProperties(collection).values());\n}\n\n/**\n * The child views of `collection` as bare collections.\n *\n * The flattened view of {@link getEntityChildViews}, for navigation code that\n * only needs to match a path segment against a slug. Anything that cares *what\n * kind* of list it is showing — chiefly the admin, which must not offer a\n * global delete on a shared row — should read the views instead.\n */\nexport function getSubcollections<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): CollectionConfig<Record<string, unknown>>[] {\n return getEntityChildViews(collection).map(view => view.collection);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"resolutions.es.js","names":[],"sources":["../../src/util/resolutions.ts"],"sourcesContent":["import {\n ArrayProperty,\n AuthState,\n CollectionConfig,\n EnumValueConfig,\n EnumValues,\n NumberProperty,\n Properties,\n Property,\n RelationProperty,\n ResolvedRelation,\n StringProperty,\n getDataSourceCapabilities,\n getDeclaredSubcollections,\n type EntityChildView\n} from \"@rebasepro/types\";\n\ntype PropertyConfig = { property: unknown; [key: string]: unknown };\nimport { isPropertyBuilder } from \"./entities\";\nimport { enumToObjectEntries } from \"./enums\";\nimport { DEFAULT_ONE_OF_TYPE } from \"./common\";\nimport { isDefaultFieldConfigId } from \"@rebasepro/utils\";\nimport { getIn, mergeDeep } from \"@rebasepro/utils\";\nimport { isJunctionBackedRelation, resolveCollectionRelations } from \"./relations\";\nimport { resolveRelation } from \"./resolve-relation\";\n\n/**\n * Resolve property builders, enums and arrays.\n */\n\nexport type ResolvePropertyProps<M extends Record<string, unknown> = Record<string, unknown>> = {\n property: Property\n propertyKey?: string,\n values?: Partial<M>,\n previousValues?: Partial<M>,\n path?: string,\n entityId?: string | number,\n index?: number,\n propertyConfigs?: Record<string, PropertyConfig>;\n ignoreMissingFields?: boolean;\n authController: AuthState;\n}\n\nexport function resolveProperty<M extends Record<string, unknown> = Record<string, unknown>>(props: ResolvePropertyProps<M>): Property | null {\n\n const {\n property,\n ignoreMissingFields = false,\n ...rest\n } = props;\n\n let resultProperty: Property;\n\n if (isPropertyBuilder(property)) {\n const path = rest.path;\n if (!path) {\n // When path is not available (e.g. in preview contexts), skip dynamic\n // resolution and use the property as-is without dynamic modifications.\n resultProperty = property as Property;\n } else {\n const usedPropertyValue = rest.propertyKey ? getIn(rest.values, rest.propertyKey) : undefined;\n const dynamicProps = property.dynamicProps?.({\n ...rest,\n path,\n propertyValue: usedPropertyValue,\n values: rest.values ?? {},\n previousValues: rest.previousValues ?? rest.values ?? {}\n });\n resultProperty = mergeDeep(property, dynamicProps ?? {});\n }\n } else {\n resultProperty = property as Property;\n }\n\n // Apply dynamic properties if they exist\n if (resultProperty?.dynamicProps && rest.path) {\n const path = rest.path;\n const usedPropertyValue = rest.propertyKey ? getIn(rest.values, rest.propertyKey) : undefined;\n const dynamicPropsResult = resultProperty.dynamicProps({\n ...rest,\n path,\n propertyValue: usedPropertyValue,\n values: rest.values ?? {},\n previousValues: rest.previousValues ?? rest.values ?? {}\n });\n\n if (dynamicPropsResult) {\n resultProperty = mergeDeep(resultProperty, dynamicPropsResult);\n }\n }\n\n let resolvedProperty: Property | null;\n\n if (resultProperty?.type === \"map\" && resultProperty.properties) {\n const properties = resolveProperties({\n ignoreMissingFields,\n ...rest,\n properties: resultProperty.properties\n });\n resolvedProperty = {\n ...resultProperty,\n properties\n } as Property;\n } else if (resultProperty?.type === \"array\") {\n resolvedProperty = resultProperty;\n } else if ((resultProperty?.type === \"string\" || resultProperty?.type === \"number\") && resultProperty.enum) {\n resolvedProperty = resolvePropertyEnum(resultProperty);\n } else {\n resolvedProperty = resultProperty;\n }\n\n if (resolvedProperty?.propertyConfig && !isDefaultFieldConfigId(resolvedProperty.propertyConfig)) {\n const cmsFields = rest.propertyConfigs;\n if (!cmsFields && !ignoreMissingFields) {\n throw Error(`Trying to resolve a property with key '${resolvedProperty.propertyConfig}' that inherits from a custom property config but no custom property configs were provided. Use the property 'propertyConfigs' in your app config to provide them`);\n }\n const customField: PropertyConfig | undefined = cmsFields?.[resolvedProperty.propertyConfig];\n if (!customField) {\n console.warn(`Trying to resolve a property with key '${resolvedProperty.propertyConfig}' that inherits from a custom property config but no custom property config with that key was found. Check the 'propertyConfigs' in your app config`)\n return resolvedProperty;\n }\n if (customField.property) {\n const restConfigProperty = { ...customField.property } as Record<string, unknown>;\n delete restConfigProperty.propertyConfig;\n const customFieldProperty = resolveProperty({\n property: { name: \"\",\n...restConfigProperty } as Property,\n ignoreMissingFields,\n ...rest\n });\n if (customFieldProperty) {\n resolvedProperty = mergeDeep(customFieldProperty, resolvedProperty);\n }\n }\n\n }\n\n return resolvedProperty;\n}\n\n/**\n * The resolved relation a relation property refers to.\n *\n * Normalization stamps `resolvedRelation` onto the property, so this is usually\n * a field read. It falls back to resolving from the collection for properties\n * that never went through the registry — a preview, or a form rendered straight\n * from an authored config.\n */\nexport function resolveRelationProperty(\n property: RelationProperty,\n collection: CollectionConfig,\n propertyKey?: string\n): ResolvedRelation {\n if (property.resolvedRelation) return property.resolvedRelation;\n\n if (property.relation) {\n return resolveRelation(property.relation, collection, propertyKey);\n }\n\n const name = propertyKey ?? \"\";\n const declared = resolveCollectionRelations(collection)[name];\n if (!declared) {\n throw Error(\n `Relation property '${name || \"(unnamed)\"}' on '${collection.slug}' declares no \\`relation\\`, ` +\n \"and the collection has no relation of that name.\"\n );\n }\n return declared;\n}\n\n/**\n * Resolve enum aliases for a string or number property\n * @param property\n */\nexport function resolvePropertyEnum(property: StringProperty | NumberProperty): StringProperty | NumberProperty {\n if (typeof property.enum === \"object\") {\n return {\n ...property,\n enum: enumToObjectEntries(property.enum)?.filter((value) => value && (value.id || value.id === 0) && value.label) ?? []\n };\n }\n return property as StringProperty | NumberProperty;\n}\n\n/**\n * Resolve enums and arrays for properties\n * @param properties\n * @param value\n */\nexport function resolveProperties<M extends Record<string, unknown>>({\n propertyKey,\n properties,\n ignoreMissingFields,\n ...props\n}: {\n propertyKey?: string,\n properties: Properties,\n values?: Partial<M>,\n previousValues?: Partial<M>,\n path?: string,\n entityId?: string | number,\n index?: number,\n propertyConfigs?: Record<string, PropertyConfig>;\n ignoreMissingFields?: boolean;\n authController: AuthState;\n}): Properties {\n return Object.entries<Property>(properties as Record<string, Property>)\n .map(([key, property]) => {\n const childResolvedProperty = resolveProperty({\n propertyKey: propertyKey ? `${propertyKey}.${key}` : undefined,\n property: property,\n ignoreMissingFields,\n ...props\n });\n if (!childResolvedProperty) return {};\n return {\n [key]: childResolvedProperty\n };\n })\n .filter((a) => a !== null)\n .reduce((a, b) => ({ ...a,\n...b }), {}) as Properties;\n}\n\nexport function resolveArrayProperties<M>({\n propertyKey,\n property,\n ignoreMissingFields = false,\n ...props\n}: {\n propertyKey?: string,\n property: ArrayProperty,\n values?: Partial<M>,\n previousValues?: Partial<M>,\n path?: string,\n entityId?: string | number,\n index?: number,\n propertyConfigs?: Record<string, PropertyConfig>;\n ignoreMissingFields?: boolean;\n authController: AuthState;\n}): Property[] {\n const propertyValue = propertyKey ? getIn(props.values, propertyKey) : undefined;\n\n if (property.of) {\n if (Array.isArray(property.of)) {\n return property.of.map((p, index) => {\n return resolveProperty({\n propertyKey: `${propertyKey}.${index}`,\n property: p as Property,\n ignoreMissingFields,\n ...props,\n index\n });\n }) as Property[];\n } else {\n const of = property.of;\n const resolvedProperties = getArrayResolvedProperties({\n propertyValue,\n propertyKey,\n property,\n ignoreMissingFields,\n ...props\n });\n // Destructured to be *excluded* from `...rest`, not to be used —\n // see the comment below. Said explicitly so the discarded-value\n // ratchet does not carry a finding that is working as intended.\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n const { values, previousValues, ...rest } = props;\n const ofProperty = resolveProperty({ // we don't want to pass the values of the parent entity\n property: of,\n ignoreMissingFields,\n ...rest\n });\n if (!ofProperty && !ignoreMissingFields)\n throw Error(\"When using a property builder as the 'of' prop of an ArrayProperty, you must return a valid child property\")\n return resolvedProperties;\n }\n } else if (property.oneOf) {\n const typeField = property.oneOf?.typeField ?? DEFAULT_ONE_OF_TYPE;\n const resolvedProperties: Property[] = Array.isArray(propertyValue)\n ? propertyValue.map((v, index) => {\n const type = v && v[typeField];\n const childProperty = property.oneOf?.properties[type];\n if (!type || !childProperty) return null;\n return resolveProperty({\n propertyKey: `${propertyKey}.${index}`,\n property: childProperty,\n ignoreMissingFields,\n ...props\n });\n }).filter(e => Boolean(e)) as Property[]\n : [];\n return resolvedProperties;\n } else if (!property.columnType) {\n // An array with neither `of`/`oneOf` nor a `columnType` describes no element\n // type, so nothing can be generated or rendered from it.\n //\n // The escape hatch used to be `ui.Field` — \"a custom component can render\n // anything\" — which made a *presentation* field decide whether a schema was\n // valid, in code the Postgres generator runs. `columnType` is the same escape\n // hatch stated as data: `columnType: \"text[]\"` says what the column holds,\n // which is what both the generator and the form actually need.\n throw Error(`The array property (${propertyKey}) needs to declare an 'of' or a 'oneOf' property, or a \\`columnType\\` such as \"text[]\"`);\n } else {\n return [];\n }\n\n}\n\nexport function getArrayResolvedProperties({\n propertyKey,\n propertyValue,\n property,\n ...props\n}: {\n propertyValue: unknown,\n propertyKey?: string,\n property: ArrayProperty,\n ignoreMissingFields: boolean,\n values?: object;\n previousValues?: object;\n path?: string;\n entityId?: string | number;\n index?: number;\n propertyConfigs?: Record<string, PropertyConfig>;\n authController: AuthState;\n}) {\n\n const of = property.of;\n if (!of)\n throw Error(\n `Trying to resolve an array property (${propertyKey}) without providing an 'of' property`\n )\n return Array.isArray(propertyValue)\n ? propertyValue.map((v: unknown, index: number) => {\n return resolveProperty({\n propertyKey: `${propertyKey}.${index}`,\n property: Array.isArray(of) ? of[index] : of,\n ...props,\n index\n });\n }).filter(e => Boolean(e)) as Property[]\n : [];\n}\n\n/**\n * An enum as `{ id, label }` entries, or `undefined` when it is not one.\n *\n * {@link enumToObjectEntries} is the reading: an array is a list of entries or\n * of plain values (`[\"draft\", \"published\"]`, whose ids are the values, not\n * their positions), and a map entry's id is its key even when its value is a\n * config that does not repeat it.\n */\nexport function resolveEnumValues(input: EnumValues | Array<string | number>): EnumValueConfig[] | undefined {\n if (typeof input !== \"object\" || input === null) return undefined;\n return enumToObjectEntries(input);\n}\n\n\n/**\n * The lists rendered inside an entity view of `collection` — its tabs.\n *\n * The single derivation. There used to be two that disagreed: this one, and a\n * copy in `CollectionRegistry.normalizeCollection` that stamped each child with\n * the *target collection's* slug instead of the relation key. Since the\n * registry ran first and cached its answer onto `childCollections`, its version\n * was the one that won, and the frontend addressed child listings by a segment\n * the backend could not resolve.\n *\n * Order of precedence:\n * 1. `childCollections` — the explicit escape hatch for custom drivers.\n * 2. `subcollections` on an engine that has real containment (Firestore).\n * 3. many-relations on an engine that has relations (SQL).\n */\nexport function getEntityChildViews<M extends Record<string, unknown> = Record<string, unknown>>(\n collection: CollectionConfig<M>\n): EntityChildView[] {\n const asSubcollections = (collections: CollectionConfig<Record<string, unknown>>[]): EntityChildView[] =>\n collections.filter(Boolean).map(child => ({\n key: child.slug,\n collection: child,\n source: { kind: \"subcollection\" as const }\n }));\n\n if (collection.childCollections) {\n return asSubcollections(collection.childCollections() ?? []);\n }\n\n const capabilities = getDataSourceCapabilities(collection.engine);\n\n const declaredSubcollections = getDeclaredSubcollections(collection);\n if (capabilities.supportsSubcollections && declaredSubcollections) {\n return asSubcollections(declaredSubcollections() ?? []);\n }\n\n if (!capabilities.supportsRelations) return [];\n\n const resolvedRelations = resolveCollectionRelations(collection);\n const views: EntityChildView[] = [];\n const seen = new Set<string>();\n\n // Keyed by the map key, not by `relationName`: the map key is what\n // `findRelation` matches a path segment against, so it is the only one that\n // addresses the same relation on both sides of the wire. The map registers\n // some relations twice — once canonically, once under the declaring\n // property key — so dedupe on the underlying relation.\n for (const [relationKey, relation] of Object.entries(resolvedRelations)) {\n if (relation.cardinality !== \"many\") continue;\n\n const identity = relation.relationName ?? relationKey;\n if (seen.has(identity)) continue;\n\n let target: CollectionConfig | undefined;\n try {\n target = relation.target();\n } catch {\n continue;\n }\n if (!target) continue;\n seen.add(identity);\n\n // A name given to the declaring property is the author naming the tab.\n // Only the tab: it is a plural (\"Tags\"), and one row of it is still\n // what the target calls one (\"Tag\").\n const declaringProperty = Object.entries((collection.properties ?? {}) as Record<string, Property>)\n .find(([propKey, p]) => p.type === \"relation\" && ((p as RelationProperty).relation?.relationName ?? propKey) === identity);\n const customName = declaringProperty?.[1]?.name;\n\n const base: CollectionConfig<Record<string, unknown>> = {\n ...target,\n slug: relationKey,\n ...(customName ? { name: customName } : {})\n } as CollectionConfig<Record<string, unknown>>;\n\n views.push({\n key: relationKey,\n collection: (relation.overrides ? mergeDeep(base, relation.overrides) : base) as CollectionConfig<Record<string, unknown>>,\n source: {\n kind: \"relation\",\n relationKey,\n mode: isJunctionBackedRelation(relation) ? \"linked\" : \"owned\",\n targetSlug: target.slug\n }\n });\n }\n\n return views;\n}\n\n/**\n * Each of `collection`'s tabs paired with the property that declared it, when a\n * property declared it: child view key → property key.\n *\n * A many-relation can only be declared as a property — that is the documented\n * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So\n * one declaration reaches the panel twice, and neither surface knew about the\n * other. The form rendered a relation picker beside the tab, and the collection\n * table rendered *two* columns under one heading: the relation's own column,\n * showing the child rows, and a jump-to-tab button carrying the same name.\n *\n * The pairing is what lets each surface decide which half is redundant, and it\n * has to be a pairing rather than two sets because the two keys differ whenever\n * a relation is named. The match is on the resolved `relationName` — the\n * identity `getEntityChildViews` itself dedupes on — so a relation declared in\n * `relations` and pointed at by a differently-named property is recognised too.\n *\n * A relation with no property of its own is absent here, which is the point: it\n * has exactly one surface already, and nothing to weigh it against.\n *\n * Only top-level properties: a relation nested inside a `map` gets no tab.\n */\nexport function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(\n collection: CollectionConfig<M>\n): Map<string, string> {\n const pairs = new Map<string, string>();\n\n const relationProperties = Object.entries((collection.properties ?? {}) as Record<string, Property>)\n .filter(([, property]) => property?.type === \"relation\");\n if (relationProperties.length === 0) return pairs;\n\n const relationViews = getEntityChildViews(collection)\n .filter(view => view.source.kind === \"relation\");\n if (relationViews.length === 0) return pairs;\n\n const resolvedRelations = resolveCollectionRelations(collection);\n const identityOf = (relationKey: string): string =>\n resolvedRelations[relationKey]?.relationName ?? relationKey;\n\n const declaringPropertyByIdentity = new Map<string, string>();\n for (const [propertyKey, property] of relationProperties) {\n const relation = (property as RelationProperty).resolvedRelation ?? resolvedRelations[propertyKey];\n // A to-one relation is a foreign key the author edits, never a tab. No\n // view will match it — the views here are many-relations only — but\n // reading the cardinality says so where someone is looking.\n if (relation?.cardinality !== \"many\") continue;\n const identity = relation.relationName ?? propertyKey;\n if (!declaringPropertyByIdentity.has(identity)) declaringPropertyByIdentity.set(identity, propertyKey);\n }\n\n for (const view of relationViews) {\n const propertyKey = declaringPropertyByIdentity.get(\n identityOf((view.source as { relationKey: string }).relationKey));\n if (propertyKey) pairs.set(view.key, propertyKey);\n }\n\n return pairs;\n}\n\n/**\n * The property keys of `collection` whose relation is already one of its tabs.\n *\n * What a form asks: the tab is the treatment for a list of child rows, so the\n * picker beside it is the redundant half. See\n * {@link getChildViewDeclaringProperties}.\n */\nexport function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(\n collection: CollectionConfig<M>\n): Set<string> {\n return new Set(getChildViewDeclaringProperties(collection).values());\n}\n\n/**\n * The child views of `collection` as bare collections.\n *\n * The flattened view of {@link getEntityChildViews}, for navigation code that\n * only needs to match a path segment against a slug. Anything that cares *what\n * kind* of list it is showing — chiefly the admin, which must not offer a\n * global delete on a shared row — should read the views instead.\n */\nexport function getSubcollections<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): CollectionConfig<Record<string, unknown>>[] {\n return getEntityChildViews(collection).map(view => view.collection);\n}\n"],"mappings":"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"}
@@ -107,6 +107,7 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
107
107
  */
108
108
  function isSingleHopVia(cardinality, joinPath) {
109
109
  if (!Array.isArray(joinPath)) return false;
110
+ if (joinPath.some((step) => Array.isArray(step.on) && step.on.length !== 1)) return false;
110
111
  if (cardinality === "one") return joinPath.length === 1;
111
112
  return joinPath.length === 2 && joinPath[0].table !== joinPath[1].table;
112
113
  }