@rebasepro/common 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/util/copy.d.ts +3 -2
- package/dist/util/copy.es.js +3 -2
- package/dist/util/copy.es.js.map +1 -1
- package/dist/util/policy/sqlToPolicy.es.js +18 -10
- package/dist/util/policy/sqlToPolicy.es.js.map +1 -1
- package/dist/util/resolve-relation.es.js +23 -1
- package/dist/util/resolve-relation.es.js.map +1 -1
- package/package.json +3 -3
package/dist/util/copy.d.ts
CHANGED
|
@@ -9,8 +9,9 @@ import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
|
9
9
|
* lives on the other row. Writing a `hasMany` or a `hasOne` points the
|
|
10
10
|
* children's foreign key at the copy, which takes them away from the record
|
|
11
11
|
* that was copied: duplicating an author moved every one of their posts to the
|
|
12
|
-
* duplicate and left the original with none. A `via` is
|
|
13
|
-
*
|
|
12
|
+
* duplicate and left the original with none. A `via` is left out too: a
|
|
13
|
+
* single-hop one's one-to-one writer re-points the target's key the same way,
|
|
14
|
+
* and a multi-hop one is read-only.
|
|
14
15
|
*
|
|
15
16
|
* Exhaustive over the kinds, so a new one is a compile error here rather than
|
|
16
17
|
* a copy that quietly carries it.
|
package/dist/util/copy.es.js
CHANGED
|
@@ -11,8 +11,9 @@ import { resolveRelationProperty } from "./resolutions.es.js";
|
|
|
11
11
|
* lives on the other row. Writing a `hasMany` or a `hasOne` points the
|
|
12
12
|
* children's foreign key at the copy, which takes them away from the record
|
|
13
13
|
* that was copied: duplicating an author moved every one of their posts to the
|
|
14
|
-
* duplicate and left the original with none. A `via` is
|
|
15
|
-
*
|
|
14
|
+
* duplicate and left the original with none. A `via` is left out too: a
|
|
15
|
+
* single-hop one's one-to-one writer re-points the target's key the same way,
|
|
16
|
+
* and a multi-hop one is read-only.
|
|
16
17
|
*
|
|
17
18
|
* Exhaustive over the kinds, so a new one is a compile error here rather than
|
|
18
19
|
* a copy that quietly carries it.
|
package/dist/util/copy.es.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"copy.es.js","names":[],"sources":["../../src/util/copy.ts"],"sourcesContent":["import type { CollectionConfig, Property, RelationProperty, ResolvedRelation } from \"@rebasepro/types\";\nimport { resolveCollectionRelations } from \"./relations\";\nimport { resolveRelationProperty } from \"./resolutions\";\n\n/**\n * Whether a duplicate of a record may carry this relation's value.\n *\n * A duplicate is saved as a create, and a create writes every relation it\n * carries. That is right where the link is the copy's own: a `belongsTo` key on\n * the copy's row, or `manyToMany` links, which are new junction rows beside the\n * original's — the targets are shared by design. It is wrong wherever the key\n * lives on the other row. Writing a `hasMany` or a `hasOne` points the\n * children's foreign key at the copy, which takes them away from the record\n * that was copied: duplicating an author moved every one of their posts to the\n * duplicate and left the original with none. A `via` is
|
|
1
|
+
{"version":3,"file":"copy.es.js","names":[],"sources":["../../src/util/copy.ts"],"sourcesContent":["import type { CollectionConfig, Property, RelationProperty, ResolvedRelation } from \"@rebasepro/types\";\nimport { resolveCollectionRelations } from \"./relations\";\nimport { resolveRelationProperty } from \"./resolutions\";\n\n/**\n * Whether a duplicate of a record may carry this relation's value.\n *\n * A duplicate is saved as a create, and a create writes every relation it\n * carries. That is right where the link is the copy's own: a `belongsTo` key on\n * the copy's row, or `manyToMany` links, which are new junction rows beside the\n * original's — the targets are shared by design. It is wrong wherever the key\n * lives on the other row. Writing a `hasMany` or a `hasOne` points the\n * children's foreign key at the copy, which takes them away from the record\n * that was copied: duplicating an author moved every one of their posts to the\n * duplicate and left the original with none. A `via` is left out too: a\n * single-hop one's one-to-one writer re-points the target's key the same way,\n * and a multi-hop one is read-only.\n *\n * Exhaustive over the kinds, so a new one is a compile error here rather than\n * a copy that quietly carries it.\n */\nexport function copyCarriesRelation(relation: ResolvedRelation): boolean {\n switch (relation.kind) {\n case \"belongsTo\":\n case \"manyToMany\":\n return true;\n case \"hasOne\":\n case \"hasMany\":\n case \"via\":\n return false;\n default: {\n const exhaustive: never = relation;\n throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/**\n * The values a duplicate of a record is created with: the original's, less\n * its key and less every relation the copy cannot take without changing\n * another row (see {@link copyCarriesRelation}).\n *\n * The key goes so the database, or the user, gives the copy its own. The\n * relations go because the copy is written as a create, and a create writes\n * them — for a child-held key that is a re-parent of rows the user never\n * touched, with nothing on screen to say so.\n *\n * A relation property that does not resolve is left out as well: what writing\n * it would do cannot be known here, and leaving a value out of a new record\n * changes nothing that exists.\n */\nexport function getCopyValues<M extends Record<string, unknown>>(\n collection: CollectionConfig,\n values: Partial<M>\n): Partial<M> {\n const result: Record<string, unknown> = { ...values };\n\n for (const [key, raw] of Object.entries(collection.properties ?? {})) {\n const property = raw as Property | undefined;\n if (!property) continue;\n if (\"isId\" in property && property.isId) {\n delete result[key];\n continue;\n }\n if (property.type !== \"relation\") continue;\n let relation: ResolvedRelation | undefined;\n try {\n relation = resolveRelationProperty(property as RelationProperty, collection, key);\n } catch {\n relation = undefined;\n }\n if (!relation || !copyCarriesRelation(relation)) delete result[key];\n }\n\n // A relation declared in `relations`, with no property, can still come back\n // on the row under its name. One whose target does not resolve cannot be\n // written by the server either, so the copy's save reports it; failing\n // here would only stop the form from opening.\n let declared: Record<string, ResolvedRelation> = {};\n try {\n declared = resolveCollectionRelations(collection);\n } catch {\n declared = {};\n }\n for (const [name, relation] of Object.entries(declared)) {\n if (!copyCarriesRelation(relation)) delete result[name];\n }\n\n return result as Partial<M>;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,oBAAoB,UAAqC;CACrE,QAAQ,SAAS,MAAjB;EACI,KAAK;EACL,KAAK,cACD,OAAO;EACX,KAAK;EACL,KAAK;EACL,KAAK,OACD,OAAO;EACX,SAEI,MAAM,IAAI,MAAM,0BAA0B,KAAK,UAAU,QAAU,GAAG;CAE9E;AACJ;;;;;;;;;;;;;;;AAgBA,SAAgB,cACZ,YACA,QACU;CACV,MAAM,SAAkC,EAAE,GAAG,OAAO;CAEpD,KAAK,MAAM,CAAC,KAAK,QAAQ,OAAO,QAAQ,WAAW,cAAc,CAAC,CAAC,GAAG;EAClE,MAAM,WAAW;EACjB,IAAI,CAAC,UAAU;EACf,IAAI,UAAU,YAAY,SAAS,MAAM;GACrC,OAAO,OAAO;GACd;EACJ;EACA,IAAI,SAAS,SAAS,YAAY;EAClC,IAAI;EACJ,IAAI;GACA,WAAW,wBAAwB,UAA8B,YAAY,GAAG;EACpF,QAAQ;GACJ,WAAW,KAAA;EACf;EACA,IAAI,CAAC,YAAY,CAAC,oBAAoB,QAAQ,GAAG,OAAO,OAAO;CACnE;CAMA,IAAI,WAA6C,CAAC;CAClD,IAAI;EACA,WAAW,2BAA2B,UAAU;CACpD,QAAQ;EACJ,WAAW,CAAC;CAChB;CACA,KAAK,MAAM,CAAC,MAAM,aAAa,OAAO,QAAQ,QAAQ,GAClD,IAAI,CAAC,oBAAoB,QAAQ,GAAG,OAAO,OAAO;CAGtD,OAAO;AACX"}
|
|
@@ -124,6 +124,21 @@ function scalarSubqueryBody(s) {
|
|
|
124
124
|
const inner = s.slice(1, -1).trim();
|
|
125
125
|
return STARTS_WITH_SELECT.test(inner) ? inner.slice(6).trim() : null;
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* The names in a role array's body — `'admin', 'editor'`, each with or without
|
|
129
|
+
* Postgres's `::text` — or `null` when an element is anything else. A quote
|
|
130
|
+
* or a comma inside a name is not split into roles that were never there: the
|
|
131
|
+
* clause stays raw and round-trips verbatim.
|
|
132
|
+
*/
|
|
133
|
+
function roleArrayElements(list) {
|
|
134
|
+
const roles = [];
|
|
135
|
+
for (const element of list.split(",")) {
|
|
136
|
+
const match = /^'([^',]*)'(?:::text)?$/.exec(element.trim());
|
|
137
|
+
if (!match) return null;
|
|
138
|
+
roles.push(match[1]);
|
|
139
|
+
}
|
|
140
|
+
return roles;
|
|
141
|
+
}
|
|
127
142
|
/** Whether any part of `expr` fell through to `raw`. */
|
|
128
143
|
function containsRaw(expr) {
|
|
129
144
|
switch (expr.kind) {
|
|
@@ -144,16 +159,9 @@ function sqlToPolicy(sql) {
|
|
|
144
159
|
}
|
|
145
160
|
if (trimmed.toLowerCase() === "true") return policy.true();
|
|
146
161
|
if (trimmed.toLowerCase() === "false") return policy.false();
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return policy.rolesOverlap(roles);
|
|
151
|
-
}
|
|
152
|
-
const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
|
|
153
|
-
if (containMatch) {
|
|
154
|
-
const roles = containMatch[1].split(",").map((s) => s.trim().replace(/^'|'$/g, ""));
|
|
155
|
-
return policy.rolesContain(roles);
|
|
156
|
-
}
|
|
162
|
+
const rolesMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','(?:::text)?\s*\)\s*(&&|@>)\s*ARRAY\s*\[(.+)\]$/i);
|
|
163
|
+
const roles = rolesMatch ? roleArrayElements(rolesMatch[2]) : null;
|
|
164
|
+
if (rolesMatch && roles) return rolesMatch[1] === "&&" ? policy.rolesOverlap(roles) : policy.rolesContain(roles);
|
|
157
165
|
const orParts = splitTopLevel(trimmed, "OR");
|
|
158
166
|
if (orParts) return policy.or(...orParts.map(sqlToPolicy));
|
|
159
167
|
const andParts = splitTopLevel(trimmed, "AND");
|
|
@@ -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/** 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 (&&)\n // Matches: string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']\n const overlapMatch = trimmed.match(/^string_to_array\\s*\\(\\s*rebase\\.roles\\(\\)\\s*,\\s*','\\s*\\)\\s*&&\\s*ARRAY\\s*\\[(.+)\\]$/i);\n if (overlapMatch) {\n const roles = overlapMatch[1].split(\",\").map(s => s.trim().replace(/^'|'$/g, \"\"));\n return policy.rolesOverlap(roles);\n }\n\n // Handle roles containment (@>)\n // Matches: string_to_array(rebase.roles(), ',') @> ARRAY['admin']\n const containMatch = trimmed.match(/^string_to_array\\s*\\(\\s*rebase\\.roles\\(\\)\\s*,\\s*','\\s*\\)\\s*@>\\s*ARRAY\\s*\\[(.+)\\]$/i);\n if (containMatch) {\n const roles = containMatch[1].split(\",\").map(s => s.trim().replace(/^'|'$/g, \"\"));\n return 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 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":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAS,YAAY,OAAe,GAAW,SAA0B;CACrE,IAAI,CAAC,MAAM,WAAW,SAAS,CAAC,GAAG,OAAO;CAC1C,MAAM,SAAS,MAAM,IAAI,MAAM,MAAM,IAAI;CACzC,MAAM,QAAQ,MAAM,IAAI,QAAQ,WAAW;CAC3C,OAAO,SAAS,KAAK,MAAM,KAAK,SAAS,KAAK,KAAK;AACvD;;;;;;;;;;;;;;;AAgBA,SAAS,cAAc,KAAa,SAAwC;CACxE,MAAM,QAAQ,IAAI,YAAY;CAC9B,MAAM,QAAkB,CAAC;CACzB,IAAI,QAAQ;CACZ,IAAI,WAAW;CACf,IAAI,QAAQ;CAEZ,KAAK,IAAI,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;EACjC,MAAM,KAAK,IAAI;EACf,IAAI,UAAU;GACV,IAAI,OAAO,KACP,IAAI,IAAI,IAAI,OAAO,KAAK;QACnB,WAAW;GAEpB;EACJ;EACA,IAAI,OAAO,KAAK;GAAE,WAAW;GAAM;EAAU;EAC7C,IAAI,OAAO,KAAK;GAAE;GAAS;EAAU;EACrC,IAAI,OAAO,KAAK;GAAE;GAAS;EAAU;EACrC,IAAI,UAAU,KAAK,YAAY,OAAO,GAAG,OAAO,GAAG;GAC/C,MAAM,KAAK,IAAI,MAAM,OAAO,CAAC,CAAC;GAC9B,KAAK,QAAQ,SAAS;GACtB,QAAQ,IAAI;EAChB;CACJ;CAEA,IAAI,MAAM,WAAW,GAAG,OAAO;CAC/B,MAAM,KAAK,IAAI,MAAM,KAAK,CAAC;CAC3B,MAAM,eAAe,MAAM,KAAI,MAAK,EAAE,KAAK,CAAC,CAAC,CAAC,QAAO,MAAK,EAAE,SAAS,CAAC;CACtE,OAAO,aAAa,SAAS,IAAI,eAAe;AACpD;;AAGA,IAAM,qBAAqB;;;;;;;AAQ3B,SAAS,iBAAiB,KAAqB;CAC3C,IAAI,IAAI,IAAI,KAAK;CACjB,OAAO,WAAW,CAAC,KAAK,CAAC,mBAAmB,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,GAC9D,IAAI,EAAE,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK;CAE5B,OAAO;AACX;;AAGA,SAAS,WAAW,GAAoB;CACpC,IAAI,CAAC,EAAE,WAAW,GAAG,KAAK,CAAC,EAAE,SAAS,GAAG,GAAG,OAAO;CACnD,IAAI,QAAQ;CACZ,IAAI,WAAW;CACf,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;EAC/B,MAAM,KAAK,EAAE;EACb,IAAI,UAAU;GACV,IAAI,OAAO,KACP,IAAI,EAAE,IAAI,OAAO,KAAK;QACjB,WAAW;GAEpB;EACJ;EACA,IAAI,OAAO,KAAK;GAAE,WAAW;GAAM;EAAU;EAC7C,IAAI,OAAO,KAAK;OACX,IAAI,OAAO,KAAK;GACjB;GACA,IAAI,UAAU,KAAK,IAAI,EAAE,SAAS,GAAG,OAAO;EAChD;CACJ;CACA,OAAO;AACX;;AAGA,SAAS,mBAAmB,GAA0B;CAClD,IAAI,CAAC,WAAW,CAAC,GAAG,OAAO;CAC3B,MAAM,QAAQ,EAAE,MAAM,GAAG,EAAE,CAAC,CAAC,KAAK;CAClC,OAAO,mBAAmB,KAAK,KAAK,IAAI,MAAM,MAAM,CAAe,CAAC,CAAC,KAAK,IAAI;AAClF;;;;;;;AAQA,SAAS,kBAAkB,MAA+B;CACtD,MAAM,QAAkB,CAAC;CACzB,KAAK,MAAM,WAAW,KAAK,MAAM,GAAG,GAAG;EACnC,MAAM,QAAQ,0BAA0B,KAAK,QAAQ,KAAK,CAAC;EAC3D,IAAI,CAAC,OAAO,OAAO;EACnB,MAAM,KAAK,MAAM,EAAE;CACvB;CACA,OAAO;AACX;;AAGA,SAAS,YAAY,MAAiC;CAClD,QAAQ,KAAK,MAAb;EACI,KAAK,OACD,OAAO;EACX,KAAK;EACL,KAAK,MACD,OAAO,KAAK,SAAS,KAAK,WAAW;EACzC,KAAK,OACD,OAAO,YAAY,KAAK,OAAO;EACnC,KAAK,YACD,OAAO,YAAY,KAAK,KAAK;EACjC,SACI,OAAO;CACf;AACJ;AAEA,SAAgB,YAAY,KAA+B;CAWvD,MAAM,UAAU,iBAAiB,0BAA0B,GAAG,CAAC,CAAC,KAAK,CAAC;CAQtE,MAAM,eAAe,mBAAmB,OAAO;CAC/C,IAAI,iBAAiB,MAAM;EACvB,MAAM,QAAQ,YAAY,YAAY;EACtC,OAAO,YAAY,KAAK,IAAI,OAAO,IAAI,OAAO,IAAI;CACtD;CAEA,IAAI,QAAQ,YAAY,MAAM,QAAQ,OAAO,OAAO,KAAK;CACzD,IAAI,QAAQ,YAAY,MAAM,SAAS,OAAO,OAAO,MAAM;CAS3D,MAAM,aAAa,QAAQ,MAAM,oGAAoG;CACrI,MAAM,QAAQ,aAAa,kBAAkB,WAAW,EAAE,IAAI;CAC9D,IAAI,cAAc,OACd,OAAO,WAAW,OAAO,OAAO,OAAO,aAAa,KAAK,IAAI,OAAO,aAAa,KAAK;CAI1F,MAAM,UAAU,cAAc,SAAS,IAAI;CAC3C,IAAI,SAAS,OAAO,OAAO,GAAG,GAAG,QAAQ,IAAI,WAAW,CAAC;CAEzD,MAAM,WAAW,cAAc,SAAS,KAAK;CAC7C,IAAI,UAAU,OAAO,OAAO,IAAI,GAAG,SAAS,IAAI,WAAW,CAAC;CAG5D,MAAM,QAAQ,QAAQ,MAAM,wBAAwB;CACpD,IAAI,OAAO;EACP,MAAM,GAAG,SAAS,IAAI,YAAY;EAClC,MAAM,OAAO,aAAa,QAAQ,KAAK,CAAC;EACxC,MAAM,QAAQ,aAAa,SAAS,KAAK,CAAC;EAC1C,IAAI,QAAQ,OACR,OAAO,OAAO,QAAQ,MAAM,OAAO,MAAM,OAAO,OAAO,KAAK;CAEpE;CAMA,OAAO,OAAO,IAAI,OAAO;AAC7B;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,IAAM,0CAA0B,IAAI,IAAoB;CACpD,CAAC,QAAQ,UAAU;CACnB,CAAC,iBAAiB,UAAU;CAC5B,CAAC,gBAAgB,UAAU;AAC/B,CAAC;;;;;AAMD,IAAM,0BAA0B,IAAI,OAChC,OAAO,GAAG,2BAA2B,CAAC,GAAG,wBAAwB,KAAK,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,KACnF,GACJ;;;;;;;;;AAoBA,IAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;AAyBrB,SAAgB,oBAAoB,MAA8C;CAC9E,MAAM,QAA8B,CAAC;CAErC,MAAM,SAAS,MAA8B;EACzC,QAAQ,EAAE,MAAV;GACI,KAAK;GACL,KAAK;IACD,EAAE,SAAS,QAAQ,KAAK;IACxB;GACJ,KAAK;IACD,MAAM,EAAE,OAAO;IACf;GACJ,KAAK;IACD,MAAM,EAAE,KAAK;IACb;GACJ,KAAK,OAAO;IACR,IAAI,aAAa,KAAK,EAAE,GAAG,GACvB,MAAM,KAAK;KACP,SAAS;KACT,QAAQ,EAAE;KACV,aAAa,0HACiC,kBAAkB;IAEpE,CAAC;IAUL,MAAM,UAAU,wBAAwB,KAAK,EAAE,GAAG;IAClD,IAAI,SAAS;KACT,MAAM,UAAU,QAAQ;KACxB,MAAM,KAAK;MACP,SAAS;MACT,QAAQ;MACR,aAAa,IAAI,QAAQ,SAAS,wBAAwB,IAAI,OAAO,EAAE,uDAC/B,kBAAkB,2BAClD,QAAQ;KAEpB,CAAC;IACL;IACA;GACJ;GACA,KAAK,WAAW;IACZ,MAAM,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,MAAK,MAAK,EAAE,SAAS,SAAS;IAEhE,IAAI,EADgB,EAAE,KAAK,SAAS,aAAa,EAAE,MAAM,SAAS,cAC9C,OAAO,SAAS,UAAU,UAAU;IACxD,MAAM,WAAW,wBAAwB,IAAI,QAAQ,KAAK;IAC1D,IAAI,CAAC,UAAU;IACf,MAAM,KAAK;KACP,SAAS;KACT,QAAQ,QAAQ;KAChB,aAAa,IAAI,QAAQ,MAAM,SAAS,SAAS,uDAC9B,kBAAkB,2BAA2B,QAAQ,MAAM,oHAEnD,mBAAmB,KAAI,MAAK,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE;IAEhF,CAAC;IACD;GACJ;GACA,SACI;EACR;CACJ;CAEA,MAAM,IAAI;CACV,OAAO;AACX;AAEA,SAAS,aAAa,KAAa;CAyB/B,IAAI,sDAAsD,KAAK,GAAG,KAAK,qBAAqB,KAAK,GAAG,GAChG,OAAO,OAAO,QAAQ;CAK1B,IAAI,4DAA4D,KAAK,GAAG,GACpE,OAAO,OAAO,QAAQ;CAU1B,MAAM,UAAU,kBAAkB,GAAG;CACrC,IAAI,YAAY,MACZ,OAAO,OAAO,QAAQ,OAAO;CAmBjC,IAAI,UAAU,KAAK,GAAG,GAAG,OAAO,OAAO,QAAQ,OAAO,GAAG,CAAC;CAC1D,IAAI,eAAe,KAAK,GAAG,GAAG,OAAO,OAAO,QAAQ,OAAO,GAAG,CAAC;CAC/D,IAAI,UAAU,KAAK,GAAG,GAAG,OAAO,OAAO,QAAQ,IAAI;CACnD,IAAI,WAAW,KAAK,GAAG,GAAG,OAAO,OAAO,QAAQ,KAAK;CACrD,IAAI,UAAU,KAAK,GAAG,GAAG,OAAO,OAAO,QAAQ,IAAI;CAQnD,IAAI,QAAQ,KAAK,GAAG,KAAK,YAAY,GAAG,MAAM,IAC1C,OAAO,OAAO,MAAM,GAAG;CAG3B,OAAO;AACX;;;;;;;;;AAUA,SAAS,kBAAkB,KAA4B;CACnD,IAAI,IAAI,SAAS,KAAK,CAAC,IAAI,WAAW,GAAG,KAAK,CAAC,IAAI,SAAS,GAAG,GAAG,OAAO;CACzE,MAAM,OAAO,IAAI,MAAM,GAAG,EAAE;CAC5B,IAAI,MAAM;CACV,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;EAClC,IAAI,KAAK,OAAO,KAAK;GACjB,OAAO,KAAK;GACZ;EACJ;EACA,IAAI,KAAK,IAAI,OAAO,KAAK;GACrB,OAAO;GACP;GACA;EACJ;EACA,OAAO;CACX;CACA,OAAO;AACX"}
|
|
@@ -81,13 +81,35 @@ function resolveRelation(relation, sourceCollection, propertyKey) {
|
|
|
81
81
|
...shared,
|
|
82
82
|
kind: "via",
|
|
83
83
|
cardinality: relation.cardinality,
|
|
84
|
-
writable:
|
|
84
|
+
writable: isSingleHopVia(relation.cardinality, relation.joinPath),
|
|
85
85
|
shared: true,
|
|
86
86
|
joinPath: relation.joinPath
|
|
87
87
|
};
|
|
88
88
|
default: throw new Error(`Unknown relation kind: ${JSON.stringify(relation)}`);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Whether a `via` chain has a single link for a write to set — the chains
|
|
93
|
+
* Rebase writes through a create or update:
|
|
94
|
+
*
|
|
95
|
+
* - **to-one, one step** (`{ table: target, on: { from, to } }`): the target
|
|
96
|
+
* row holds the key, and the write points it at this row, as a `hasOne`
|
|
97
|
+
* write does;
|
|
98
|
+
* - **to-many, two steps through one table between the ends**: that table
|
|
99
|
+
* holds the links, and the write sets them as junction rows, as a
|
|
100
|
+
* `manyToMany` write does.
|
|
101
|
+
*
|
|
102
|
+
* Both shipped writable (0.24.0 fixed the one-to-one writer). A longer chain
|
|
103
|
+
* has no single link to maintain: a to-many one found no junction connecting
|
|
104
|
+
* both ends and failed as a 500, and a to-one one re-pointed the target at
|
|
105
|
+
* whatever column the first step read. So those are read-only, and every door
|
|
106
|
+
* reads this answer through `writable` rather than re-deriving it.
|
|
107
|
+
*/
|
|
108
|
+
function isSingleHopVia(cardinality, joinPath) {
|
|
109
|
+
if (!Array.isArray(joinPath)) return false;
|
|
110
|
+
if (cardinality === "one") return joinPath.length === 1;
|
|
111
|
+
return joinPath.length === 2 && joinPath[0].table !== joinPath[1].table;
|
|
112
|
+
}
|
|
91
113
|
/** How this relation is addressed in an error message, before it has a resolved name. */
|
|
92
114
|
function describe(relation, sourceCollection, propertyKey) {
|
|
93
115
|
const name = relation.relationName ?? propertyKey;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-relation.es.js","names":[],"sources":["../../src/util/resolve-relation.ts"],"sourcesContent":["import {\n CollectionConfig,\n Relation,\n ResolvedRelation\n} from \"@rebasepro/types\";\nimport { generateForeignKeyName, toSnakeCase } from \"@rebasepro/utils\";\n\nimport { getTableName } from \"./relations\";\n\n/**\n * Fill in a relation's defaults.\n *\n * This replaces `sanitizeRelation`, which had to work out *which kind of link\n * you meant* from whichever optional fields happened to be set — 194 lines of\n * it, including a pass that inspected the target collection's own relations to\n * decide whether a `many`/`inverse` pair was a one-to-many or the far side of a\n * many-to-many, wrapped in a `try/catch` that fell through to the wrong answer\n * when it could not tell. Two consumers running that logic at different moments\n * could reach different conclusions about the same relation.\n *\n * With the kind declared there is nothing to work out. What remains is\n * defaulting — a table name, a column name — which is deterministic, depends\n * only on the relation and its two endpoints, and cannot fail. That is why this\n * function returns rather than throws, and why it needs no cache to be\n * consistent.\n */\nexport function resolveRelation(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey?: string\n): ResolvedRelation {\n const target = relation.target;\n if (typeof target !== \"function\") {\n throw new Error(\n `Relation${relation.relationName ? ` '${relation.relationName}'` : \"\"} on ` +\n `'${sourceCollection.slug}' has no \\`target\\`. Give it a thunk: \\`target: () => otherCollection\\`.`\n );\n }\n\n const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);\n\n // The name is the address: the `include` key, the admin tab, and the\n // segment of a nested path. Declared name wins, then the declaring\n // property's key, then the target's slug.\n const relationName = relation.relationName ?? propertyKey ?? toSnakeCase(targetCollection.slug);\n\n const shared: Pick<ResolvedRelation, \"relationName\" | \"target\" | \"targetSlug\" | \"onUpdate\" | \"onDelete\" | \"overrides\"> = {\n relationName,\n // Normalised, not the thunk as written. Resolution reads the target once\n // and every later consumer calls it again — the driver building a join,\n // the DDL and policy generators, the admin's relation fields — so\n // handing back the raw thunk would give all of them the module namespace\n // `callTarget` just looked past, and the fix would hold only for the\n // fields resolution happens to read here. Still lazy: same call at the\n // same moment, one unwrap on the way out.\n target: () => unwrapModuleNamespace(target()) as CollectionConfig,\n targetSlug: targetCollection.slug,\n onUpdate: relation.onUpdate,\n onDelete: relation.onDelete,\n overrides: relation.overrides\n // No `validation`. Whether the link is required is a fact about the\n // *property*, and copying it onto the resolved relation gave the\n // question two answers that were free to disagree. Ask\n // `isRelationRequired(collection, relation)`, which reads the one.\n };\n\n const sourceName = toSnakeCase(sourceCollection.slug ?? sourceCollection.name);\n\n switch (relation.kind) {\n case \"belongsTo\":\n return {\n ...shared,\n kind: \"belongsTo\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n localKey: relation.localKey ?? generateForeignKeyName(relationName),\n index: relation.index !== false\n };\n\n case \"hasOne\":\n return {\n ...shared,\n kind: \"hasOne\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n sourceKey: relation.sourceKey\n };\n\n case \"hasMany\":\n return {\n ...shared,\n kind: \"hasMany\",\n cardinality: \"many\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n // Not defaulted: the source's primary key needs the driver's\n // schema to resolve, which resolution does not have. `undefined`\n // means \"the primary key\" — see `ResolvedHasMany.sourceKey`.\n sourceKey: relation.sourceKey\n };\n\n case \"manyToMany\": {\n const sourceTable = getTableName(sourceCollection);\n const targetTable = getTableName(targetCollection);\n return {\n ...shared,\n kind: \"manyToMany\",\n cardinality: \"many\",\n writable: true,\n shared: true,\n through: {\n // Sorted so both sides of the same link derive the same\n // table without having to agree in advance.\n table: relation.through?.table ?? [sourceTable, targetTable].sort().join(\"_\"),\n sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),\n targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),\n // `{}` rather than `undefined`, for the reason every other\n // field here is filled in: a consumer reads one shape and\n // does not have to decide what an absent payload means.\n properties: relation.through?.properties ?? {}\n }\n };\n }\n\n case \"via\":\n return {\n ...shared,\n kind: \"via\",\n cardinality: relation.cardinality,\n writable: false,\n // A join chain reaches rows that other parents reach too, and\n // Rebase does not know which hop, if any, is a link it owns.\n shared: true,\n joinPath: relation.joinPath\n };\n\n default: {\n // Exhaustive: a new kind is a compile error here, not a silent\n // fall-through to whatever shape happened to match first.\n const exhaustive: never = relation;\n throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/** How this relation is addressed in an error message, before it has a resolved name. */\nfunction describe(relation: Relation, sourceCollection: CollectionConfig, propertyKey?: string): string {\n const name = relation.relationName ?? propertyKey;\n return `Relation${name ? ` '${name}'` : \"\"} on '${sourceCollection.slug}'`;\n}\n\n/**\n * A module namespace, unwrapped to the collection it exports.\n *\n * A cycle transpiled to CommonJS does not hand the importing module the\n * *default export* — it hands it the module object, `{ __esModule: true,\n * default: … }`, captured before the exporting module finished evaluating. The\n * `default` slot fills in later, so by the time a lazy `target` thunk runs the\n * collection is sitting right there, one level down. Returning the namespace is\n * never a thing a thunk means to do, and there is exactly one reading of it.\n *\n * Only unwrapped when the inner value is itself a collection: a `default` that\n * is not one is a genuinely wrong thunk, and it should reach the error below\n * rather than be quietly swapped in.\n */\nfunction unwrapModuleNamespace(value: unknown): unknown {\n if (!value || typeof value !== \"object\") return value;\n if ((value as { slug?: unknown }).slug) return value;\n const inner = (value as { default?: unknown }).default;\n return inner && typeof inner === \"object\" && (inner as { slug?: unknown }).slug ? inner : value;\n}\n\n/**\n * Call the `target` thunk, and translate the ways an import cycle breaks it into\n * an error that names the cause — or, where the value is recoverable, into the\n * collection the thunk meant.\n *\n * The thunk exists to defer the reference until every module has finished\n * evaluating, and for a cycle that closes at import time it does. Two cycles\n * leave the binding permanently unusable:\n *\n * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are\n * in the temporal dead zone, so reading one throws `ReferenceError: x is not\n * defined`. The stack points at the thunk — a one-line arrow function that is\n * obviously fine — and says nothing about the cycle that made it throw.\n * - **CJS interop, unresolved.** The half-initialised module object has no\n * `default` yet, the import resolves to `undefined`, and the thunk returns it\n * without complaint. That one used to surface here as \"did not resolve to a\n * collection\", which is true and unhelpful.\n *\n * Both mean the same thing, and the fix for both is the same: break the cycle,\n * or move the relation into the collection that does not close it.\n *\n * A third shape is *not* an error, and used to be reported as one. A loader that\n * transpiles ESM to CJS — jiti, which is what `rebase generate-sdk` and\n * `rebase build` load collections with — gives the module entered second in a\n * cycle a namespace object rather than the default export, and never replaces it\n * with a live binding. The thunk then returns `{ __esModule: true, default: … }`\n * holding the fully-initialised collection. Native ESM resolves the same thunk\n * to the collection directly, so this was a loader artefact reported as an\n * authoring mistake, and the advice it gave — make the target a lazy thunk — was\n * already satisfied by the code it was rejecting. Bidirectional relations make\n * these cycles unavoidable, and the lazy thunk is this framework's own answer to\n * them, so {@link unwrapModuleNamespace} takes the collection and moves on.\n */\nfunction callTarget(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey: string | undefined,\n target: Relation[\"target\"]\n): ReturnType<Relation[\"target\"]> {\n let targetCollection: ReturnType<Relation[\"target\"]> | undefined;\n try {\n targetCollection = unwrapModuleNamespace(target()) as ReturnType<Relation[\"target\"]>;\n } catch (error) {\n // A ReferenceError from inside the thunk is a binding that was never\n // initialised — nothing else in a one-expression arrow can raise one.\n if (error instanceof ReferenceError) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} targets a collection that is not ` +\n `initialized yet — almost always an import cycle between the two collection files. ` +\n `Break the cycle (move the shared piece into a third module, or import the target ` +\n `lazily) so the target's module finishes evaluating before the registry is built.`,\n { cause: error }\n );\n }\n throw error;\n }\n\n if (!targetCollection?.slug) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} has a \\`target\\` that resolved to ` +\n `${targetCollection === undefined ? \"`undefined`\" : \"something that is not a collection\"}. ` +\n (targetCollection === undefined\n ? \"Under CommonJS interop an import cycle resolves the default import to `undefined`, \" +\n \"so check whether this collection and its target import each other. Otherwise the thunk \" +\n \"is returning the wrong value — it must return the collection itself, not a promise or a module.\"\n : typeof (targetCollection as { then?: unknown }).then === \"function\"\n ? \"The thunk returned a promise — `target: () => import(\\\"./other\\\")` is asynchronous. \" +\n \"Import the collection at the top of the file and return the binding: \" +\n \"`target: () => otherCollection`.\"\n : \"The thunk must return a collection config with a `slug`.\")\n );\n }\n\n return targetCollection;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"resolve-relation.es.js","names":[],"sources":["../../src/util/resolve-relation.ts"],"sourcesContent":["import {\n CollectionConfig,\n Relation,\n ResolvedRelation\n} from \"@rebasepro/types\";\nimport { generateForeignKeyName, toSnakeCase } from \"@rebasepro/utils\";\n\nimport { getTableName } from \"./relations\";\n\n/**\n * Fill in a relation's defaults.\n *\n * This replaces `sanitizeRelation`, which had to work out *which kind of link\n * you meant* from whichever optional fields happened to be set — 194 lines of\n * it, including a pass that inspected the target collection's own relations to\n * decide whether a `many`/`inverse` pair was a one-to-many or the far side of a\n * many-to-many, wrapped in a `try/catch` that fell through to the wrong answer\n * when it could not tell. Two consumers running that logic at different moments\n * could reach different conclusions about the same relation.\n *\n * With the kind declared there is nothing to work out. What remains is\n * defaulting — a table name, a column name — which is deterministic, depends\n * only on the relation and its two endpoints, and cannot fail. That is why this\n * function returns rather than throws, and why it needs no cache to be\n * consistent.\n */\nexport function resolveRelation(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey?: string\n): ResolvedRelation {\n const target = relation.target;\n if (typeof target !== \"function\") {\n throw new Error(\n `Relation${relation.relationName ? ` '${relation.relationName}'` : \"\"} on ` +\n `'${sourceCollection.slug}' has no \\`target\\`. Give it a thunk: \\`target: () => otherCollection\\`.`\n );\n }\n\n const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);\n\n // The name is the address: the `include` key, the admin tab, and the\n // segment of a nested path. Declared name wins, then the declaring\n // property's key, then the target's slug.\n const relationName = relation.relationName ?? propertyKey ?? toSnakeCase(targetCollection.slug);\n\n const shared: Pick<ResolvedRelation, \"relationName\" | \"target\" | \"targetSlug\" | \"onUpdate\" | \"onDelete\" | \"overrides\"> = {\n relationName,\n // Normalised, not the thunk as written. Resolution reads the target once\n // and every later consumer calls it again — the driver building a join,\n // the DDL and policy generators, the admin's relation fields — so\n // handing back the raw thunk would give all of them the module namespace\n // `callTarget` just looked past, and the fix would hold only for the\n // fields resolution happens to read here. Still lazy: same call at the\n // same moment, one unwrap on the way out.\n target: () => unwrapModuleNamespace(target()) as CollectionConfig,\n targetSlug: targetCollection.slug,\n onUpdate: relation.onUpdate,\n onDelete: relation.onDelete,\n overrides: relation.overrides\n // No `validation`. Whether the link is required is a fact about the\n // *property*, and copying it onto the resolved relation gave the\n // question two answers that were free to disagree. Ask\n // `isRelationRequired(collection, relation)`, which reads the one.\n };\n\n const sourceName = toSnakeCase(sourceCollection.slug ?? sourceCollection.name);\n\n switch (relation.kind) {\n case \"belongsTo\":\n return {\n ...shared,\n kind: \"belongsTo\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n localKey: relation.localKey ?? generateForeignKeyName(relationName),\n index: relation.index !== false\n };\n\n case \"hasOne\":\n return {\n ...shared,\n kind: \"hasOne\",\n cardinality: \"one\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n sourceKey: relation.sourceKey\n };\n\n case \"hasMany\":\n return {\n ...shared,\n kind: \"hasMany\",\n cardinality: \"many\",\n writable: true,\n shared: false,\n foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),\n // Not defaulted: the source's primary key needs the driver's\n // schema to resolve, which resolution does not have. `undefined`\n // means \"the primary key\" — see `ResolvedHasMany.sourceKey`.\n sourceKey: relation.sourceKey\n };\n\n case \"manyToMany\": {\n const sourceTable = getTableName(sourceCollection);\n const targetTable = getTableName(targetCollection);\n return {\n ...shared,\n kind: \"manyToMany\",\n cardinality: \"many\",\n writable: true,\n shared: true,\n through: {\n // Sorted so both sides of the same link derive the same\n // table without having to agree in advance.\n table: relation.through?.table ?? [sourceTable, targetTable].sort().join(\"_\"),\n sourceColumn: relation.through?.sourceColumn ?? generateForeignKeyName(sourceName),\n targetColumn: relation.through?.targetColumn ?? generateForeignKeyName(relationName),\n // `{}` rather than `undefined`, for the reason every other\n // field here is filled in: a consumer reads one shape and\n // does not have to decide what an absent payload means.\n properties: relation.through?.properties ?? {}\n }\n };\n }\n\n case \"via\":\n return {\n ...shared,\n kind: \"via\",\n cardinality: relation.cardinality,\n writable: isSingleHopVia(relation.cardinality, relation.joinPath),\n // A join chain reaches rows that other parents reach too, and\n // Rebase does not know which hop, if any, is a link it owns.\n shared: true,\n joinPath: relation.joinPath\n };\n\n default: {\n // Exhaustive: a new kind is a compile error here, not a silent\n // fall-through to whatever shape happened to match first.\n const exhaustive: never = relation;\n throw new Error(`Unknown relation kind: ${JSON.stringify(exhaustive)}`);\n }\n }\n}\n\n/**\n * Whether a `via` chain has a single link for a write to set — the chains\n * Rebase writes through a create or update:\n *\n * - **to-one, one step** (`{ table: target, on: { from, to } }`): the target\n * row holds the key, and the write points it at this row, as a `hasOne`\n * write does;\n * - **to-many, two steps through one table between the ends**: that table\n * holds the links, and the write sets them as junction rows, as a\n * `manyToMany` write does.\n *\n * Both shipped writable (0.24.0 fixed the one-to-one writer). A longer chain\n * has no single link to maintain: a to-many one found no junction connecting\n * both ends and failed as a 500, and a to-one one re-pointed the target at\n * whatever column the first step read. So those are read-only, and every door\n * reads this answer through `writable` rather than re-deriving it.\n */\nfunction isSingleHopVia(cardinality: \"one\" | \"many\", joinPath: readonly { table: string }[] | undefined): boolean {\n // A chain declared without its steps resolves — resolution cannot fail;\n // the relation validator names the missing `joinPath` — and is not written.\n if (!Array.isArray(joinPath)) return false;\n if (cardinality === \"one\") return joinPath.length === 1;\n return joinPath.length === 2 && joinPath[0].table !== joinPath[1].table;\n}\n\n/** How this relation is addressed in an error message, before it has a resolved name. */\nfunction describe(relation: Relation, sourceCollection: CollectionConfig, propertyKey?: string): string {\n const name = relation.relationName ?? propertyKey;\n return `Relation${name ? ` '${name}'` : \"\"} on '${sourceCollection.slug}'`;\n}\n\n/**\n * A module namespace, unwrapped to the collection it exports.\n *\n * A cycle transpiled to CommonJS does not hand the importing module the\n * *default export* — it hands it the module object, `{ __esModule: true,\n * default: … }`, captured before the exporting module finished evaluating. The\n * `default` slot fills in later, so by the time a lazy `target` thunk runs the\n * collection is sitting right there, one level down. Returning the namespace is\n * never a thing a thunk means to do, and there is exactly one reading of it.\n *\n * Only unwrapped when the inner value is itself a collection: a `default` that\n * is not one is a genuinely wrong thunk, and it should reach the error below\n * rather than be quietly swapped in.\n */\nfunction unwrapModuleNamespace(value: unknown): unknown {\n if (!value || typeof value !== \"object\") return value;\n if ((value as { slug?: unknown }).slug) return value;\n const inner = (value as { default?: unknown }).default;\n return inner && typeof inner === \"object\" && (inner as { slug?: unknown }).slug ? inner : value;\n}\n\n/**\n * Call the `target` thunk, and translate the ways an import cycle breaks it into\n * an error that names the cause — or, where the value is recoverable, into the\n * collection the thunk meant.\n *\n * The thunk exists to defer the reference until every module has finished\n * evaluating, and for a cycle that closes at import time it does. Two cycles\n * leave the binding permanently unusable:\n *\n * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are\n * in the temporal dead zone, so reading one throws `ReferenceError: x is not\n * defined`. The stack points at the thunk — a one-line arrow function that is\n * obviously fine — and says nothing about the cycle that made it throw.\n * - **CJS interop, unresolved.** The half-initialised module object has no\n * `default` yet, the import resolves to `undefined`, and the thunk returns it\n * without complaint. That one used to surface here as \"did not resolve to a\n * collection\", which is true and unhelpful.\n *\n * Both mean the same thing, and the fix for both is the same: break the cycle,\n * or move the relation into the collection that does not close it.\n *\n * A third shape is *not* an error, and used to be reported as one. A loader that\n * transpiles ESM to CJS — jiti, which is what `rebase generate-sdk` and\n * `rebase build` load collections with — gives the module entered second in a\n * cycle a namespace object rather than the default export, and never replaces it\n * with a live binding. The thunk then returns `{ __esModule: true, default: … }`\n * holding the fully-initialised collection. Native ESM resolves the same thunk\n * to the collection directly, so this was a loader artefact reported as an\n * authoring mistake, and the advice it gave — make the target a lazy thunk — was\n * already satisfied by the code it was rejecting. Bidirectional relations make\n * these cycles unavoidable, and the lazy thunk is this framework's own answer to\n * them, so {@link unwrapModuleNamespace} takes the collection and moves on.\n */\nfunction callTarget(\n relation: Relation,\n sourceCollection: CollectionConfig,\n propertyKey: string | undefined,\n target: Relation[\"target\"]\n): ReturnType<Relation[\"target\"]> {\n let targetCollection: ReturnType<Relation[\"target\"]> | undefined;\n try {\n targetCollection = unwrapModuleNamespace(target()) as ReturnType<Relation[\"target\"]>;\n } catch (error) {\n // A ReferenceError from inside the thunk is a binding that was never\n // initialised — nothing else in a one-expression arrow can raise one.\n if (error instanceof ReferenceError) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} targets a collection that is not ` +\n `initialized yet — almost always an import cycle between the two collection files. ` +\n `Break the cycle (move the shared piece into a third module, or import the target ` +\n `lazily) so the target's module finishes evaluating before the registry is built.`,\n { cause: error }\n );\n }\n throw error;\n }\n\n if (!targetCollection?.slug) {\n throw new Error(\n `${describe(relation, sourceCollection, propertyKey)} has a \\`target\\` that resolved to ` +\n `${targetCollection === undefined ? \"`undefined`\" : \"something that is not a collection\"}. ` +\n (targetCollection === undefined\n ? \"Under CommonJS interop an import cycle resolves the default import to `undefined`, \" +\n \"so check whether this collection and its target import each other. Otherwise the thunk \" +\n \"is returning the wrong value — it must return the collection itself, not a promise or a module.\"\n : typeof (targetCollection as { then?: unknown }).then === \"function\"\n ? \"The thunk returned a promise — `target: () => import(\\\"./other\\\")` is asynchronous. \" +\n \"Import the collection at the top of the file and return the binding: \" +\n \"`target: () => otherCollection`.\"\n : \"The thunk must return a collection config with a `slug`.\")\n );\n }\n\n return targetCollection;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Rebase shared core — collection registry, data driver adapter and fluent query builder. No React dependency.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"rebase",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"dependencies": {
|
|
47
47
|
"fast-equals": "6.0.2",
|
|
48
48
|
"json-logic-js": "^2.0.5",
|
|
49
|
-
"@rebasepro/types": "0.
|
|
50
|
-
"@rebasepro/utils": "0.
|
|
49
|
+
"@rebasepro/types": "0.26.0",
|
|
50
|
+
"@rebasepro/utils": "0.26.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@jest/globals": "^30.4.1",
|