@rebasepro/common 0.17.3 → 0.18.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.
Files changed (58) hide show
  1. package/README.md +4 -0
  2. package/dist/collections/CollectionRegistry.d.ts +1 -1
  3. package/dist/collections/default-collections.d.ts +15 -84
  4. package/dist/data/buildRebaseData.d.ts +1 -1
  5. package/dist/data/filter-dialect.d.ts +11 -0
  6. package/dist/data/sort-dialect.d.ts +15 -3
  7. package/dist/index.es.js +375 -63
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/util/builders.d.ts +69 -24
  10. package/dist/util/callback-errors.d.ts +77 -0
  11. package/dist/util/callback-errors.test.d.ts +1 -0
  12. package/dist/util/index.d.ts +1 -0
  13. package/dist/util/policy/evaluatePolicy.d.ts +6 -0
  14. package/dist/util/relations.d.ts +41 -0
  15. package/dist/util/table-name.test.d.ts +1 -0
  16. package/package.json +26 -22
  17. package/src/collections/CollectionRegistry.ts +0 -485
  18. package/src/collections/default-collections.ts +0 -109
  19. package/src/collections/index.ts +0 -2
  20. package/src/data/buildRebaseData.ts +0 -816
  21. package/src/data/buildRoutedRebaseData.ts +0 -103
  22. package/src/data/filter-conditions.ts +0 -46
  23. package/src/data/filter-dialect.ts +0 -737
  24. package/src/data/paginate.ts +0 -334
  25. package/src/data/query_builder.ts +0 -176
  26. package/src/data/resolveDataSource.ts +0 -135
  27. package/src/data/sort-dialect.ts +0 -237
  28. package/src/index.ts +0 -11
  29. package/src/table-classification.ts +0 -109
  30. package/src/types/json-logic-js.d.ts +0 -8
  31. package/src/util/auth-default-policies.ts +0 -215
  32. package/src/util/builders.ts +0 -82
  33. package/src/util/callbacks.ts +0 -122
  34. package/src/util/collections.ts +0 -117
  35. package/src/util/common.ts +0 -2
  36. package/src/util/conditions.ts +0 -168
  37. package/src/util/email.ts +0 -32
  38. package/src/util/entities.ts +0 -282
  39. package/src/util/enums.ts +0 -26
  40. package/src/util/identity.ts +0 -202
  41. package/src/util/index.ts +0 -21
  42. package/src/util/internal-tables.test.ts +0 -188
  43. package/src/util/internal-tables.ts +0 -197
  44. package/src/util/junction-policies.ts +0 -355
  45. package/src/util/paths.ts +0 -27
  46. package/src/util/permissions.test.ts +0 -866
  47. package/src/util/permissions.ts +0 -206
  48. package/src/util/pg-column-to-property.ts +0 -377
  49. package/src/util/policy/evaluatePolicy.ts +0 -194
  50. package/src/util/policy/index.ts +0 -4
  51. package/src/util/policy/policyToPostgres.ts +0 -263
  52. package/src/util/policy/securityRuleToConditions.ts +0 -67
  53. package/src/util/policy/sqlToPolicy.ts +0 -422
  54. package/src/util/relations.ts +0 -236
  55. package/src/util/resolutions.ts +0 -534
  56. package/src/util/resolve-relation.ts +0 -243
  57. package/src/util/storage.ts +0 -177
  58. package/src/util/string-column-length.ts +0 -31
@@ -1,422 +0,0 @@
1
- import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from "@rebasepro/types";
2
- import { toSnakeCase } from "@rebasepro/utils";
3
-
4
- /**
5
- * A tiny, regex-based SQL "parser" for security rules.
6
- *
7
- * This is NOT a full SQL parser. It is designed to handle the subset of SQL
8
- * commonly used in `USING` and `WITH CHECK` clauses, enough to drive the
9
- * optimistic client-side UI decision.
10
- *
11
- * It handles:
12
- * - `field = 'literal'`
13
- * - `field != 'literal'`
14
- * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
15
- * - `A AND B`, `A OR B` — only where the keyword is at the top level
16
- * - `true`
17
- * - `IN (...)` (as optimistic true)
18
- *
19
- * For anything it doesn't understand, it returns a `raw` expression, which
20
- * the evaluator treats as "unknown" (and usually optimistic true).
21
- *
22
- * **This output also round-trips back into DDL** via `policyToPostgres` (the
23
- * schema/policy generators), so decomposing a clause the parser only partly
24
- * understands is not a cosmetic mistake — it emits invalid SQL. When in doubt,
25
- * prefer `raw`: it is reproduced verbatim.
26
- */
27
- /** True when `keyword` starts at `i` as a standalone word. */
28
- function isKeywordAt(upper: string, i: number, keyword: string): boolean {
29
- if (!upper.startsWith(keyword, i)) return false;
30
- const before = i === 0 ? " " : upper[i - 1];
31
- const after = upper[i + keyword.length] ?? " ";
32
- return /[\s()]/.test(before) && /[\s()]/.test(after);
33
- }
34
-
35
- /**
36
- * Split `sql` on a boolean keyword, but only where it sits at paren depth 0 and
37
- * outside a string literal. Returns null when it never does, so the caller
38
- * leaves the clause alone.
39
- *
40
- * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
41
- * `AND` inside
42
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
43
- * split the expression, and re-emitting the halves produced
44
- * `(EXISTS (...) AND m.user_id = rebase.uid())`
45
- * where `m` is no longer in scope — SQL that Postgres rejects outright with
46
- * "missing FROM-clause entry for table". Returning null instead keeps such a
47
- * clause as a `raw` expression, which round-trips verbatim.
48
- */
49
- function splitTopLevel(sql: string, keyword: "AND" | "OR"): string[] | null {
50
- const upper = sql.toUpperCase();
51
- const parts: string[] = [];
52
- let depth = 0;
53
- let inString = false;
54
- let start = 0;
55
-
56
- for (let i = 0; i < sql.length; i++) {
57
- const ch = sql[i];
58
- if (inString) {
59
- if (ch === "'") {
60
- if (sql[i + 1] === "'") i++; // '' escapes a quote inside a literal
61
- else inString = false;
62
- }
63
- continue;
64
- }
65
- if (ch === "'") { inString = true; continue; }
66
- if (ch === "(") { depth++; continue; }
67
- if (ch === ")") { depth--; continue; }
68
- if (depth === 0 && isKeywordAt(upper, i, keyword)) {
69
- parts.push(sql.slice(start, i));
70
- i += keyword.length - 1;
71
- start = i + 1;
72
- }
73
- }
74
-
75
- if (parts.length === 0) return null;
76
- parts.push(sql.slice(start));
77
- const trimmedParts = parts.map(p => p.trim()).filter(p => p.length > 0);
78
- return trimmedParts.length > 1 ? trimmedParts : null;
79
- }
80
-
81
- /** Drop redundant wrapping parens (`(a AND b)` → `a AND b`), never `(a) AND (b)`. */
82
- function stripOuterParens(sql: string): string {
83
- let s = sql.trim();
84
- for (;;) {
85
- if (!s.startsWith("(") || !s.endsWith(")")) return s;
86
- let depth = 0;
87
- let inString = false;
88
- let wraps = true;
89
- for (let i = 0; i < s.length; i++) {
90
- const ch = s[i];
91
- if (inString) {
92
- if (ch === "'") {
93
- if (s[i + 1] === "'") i++;
94
- else inString = false;
95
- }
96
- continue;
97
- }
98
- if (ch === "'") { inString = true; continue; }
99
- if (ch === "(") depth++;
100
- else if (ch === ")") {
101
- depth--;
102
- if (depth === 0 && i < s.length - 1) { wraps = false; break; }
103
- }
104
- }
105
- if (!wraps) return s;
106
- s = s.slice(1, -1).trim();
107
- }
108
- }
109
-
110
- export function sqlToPolicy(sql: string): PolicyExpression {
111
- // Normalised before anything else looks at it, so every pattern below only
112
- // has to know the current spelling. A database migrated by a pre-1.0 release
113
- // still holds `auth.uid()` in its policy bodies until the next push or boot
114
- // recompiles them — and until then the admin UI reads those bodies back
115
- // through here. Without this they parse as opaque `raw`, and the framework's
116
- // own policies get badged as hand-written drift.
117
- //
118
- // Normalising rather than accepting both spellings throughout is deliberate:
119
- // it also means a legacy policy that falls through to `raw` is stored in the
120
- // new spelling, so editing and saving one in the Studio migrates it.
121
- const trimmed = stripOuterParens(rewriteLegacyRlsFunctions(sql).trim());
122
-
123
- if (trimmed.toLowerCase() === "true") return policy.true();
124
- if (trimmed.toLowerCase() === "false") return policy.false();
125
-
126
- // Handle roles overlap (&&)
127
- // Matches: string_to_array(rebase.roles(), ',') && ARRAY['admin', 'editor']
128
- const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
129
- if (overlapMatch) {
130
- const roles = overlapMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
131
- return policy.rolesOverlap(roles);
132
- }
133
-
134
- // Handle roles containment (@>)
135
- // Matches: string_to_array(rebase.roles(), ',') @> ARRAY['admin']
136
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*rebase\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
137
- if (containMatch) {
138
- const roles = containMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
139
- return policy.rolesContain(roles);
140
- }
141
-
142
- // OR binds looser than AND, so it splits first.
143
- const orParts = splitTopLevel(trimmed, "OR");
144
- if (orParts) return policy.or(...orParts.map(sqlToPolicy));
145
-
146
- const andParts = splitTopLevel(trimmed, "AND");
147
- if (andParts) return policy.and(...andParts.map(sqlToPolicy));
148
-
149
- // Handle = and !=
150
- const match = trimmed.match(/^(.+?)\s*(!?=)\s*(.+)$/);
151
- if (match) {
152
- const [, leftStr, op, rightStr] = match;
153
- const left = parseOperand(leftStr.trim());
154
- const right = parseOperand(rightStr.trim());
155
- if (left && right) {
156
- return policy.compare(left, op === "=" ? "eq" : "neq", right);
157
- }
158
- }
159
-
160
- // Fallback to raw — the NORMALISED text, not the input. Storing the input
161
- // verbatim would mean a legacy policy read out of a database, edited in the
162
- // Studio and saved, writes `auth.uid()` back into the project's config: a
163
- // call to a function 1.0 no longer creates.
164
- return policy.raw(trimmed);
165
- }
166
-
167
- /**
168
- * Literals from other BaaS platforms that people compare `rebase.uid()` against
169
- * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
170
- * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
171
- * is the more dangerous spelling, because it inverts a rule instead of
172
- * emptying a table.
173
- */
174
- /**
175
- * A `Map`, not an object literal.
176
- *
177
- * As `Record<string, string>` this was indexed with a literal taken straight
178
- * out of a policy, so every key on `Object.prototype` answered: a rule
179
- * comparing `rebase.uid()` to `"valueOf"`, `"toString"`, `"constructor"` or
180
- * `"hasOwnProperty"` found a truthy "platform" and reported an anonymous-grant
181
- * risk that does not exist — with the matched function interpolated into the
182
- * explanation as the platform's name. A security warning that fires on
183
- * innocent input is worse than none: it is what teaches people to skip the
184
- * warnings that are real.
185
- *
186
- * Same shape as the prototype-pollution class swept out of `setIn`, `getIn`,
187
- * `mergeDeep` and `unflattenObject` — a data-derived key reaching a plain
188
- * object. Found by a property test, on the input `"valueOf"`.
189
- */
190
- const FOREIGN_CONVENTION_UIDS = new Map<string, string>([
191
- ["anon", "Supabase"],
192
- ["authenticated", "Supabase"],
193
- ["service_role", "Supabase"]
194
- ]);
195
-
196
- /**
197
- * The same foreign literals, as a pattern for SQL that could not be parsed
198
- * back into structure.
199
- */
200
- const FOREIGN_UID_LITERAL_SQL = new RegExp(
201
- String.raw`rebase\.uid\(\)\s*=\s*'(${[...FOREIGN_CONVENTION_UIDS.keys()].join("|")})'`,
202
- "i"
203
- );
204
-
205
- /** A clause that reads as a lockdown but admits anonymous callers. */
206
- export interface AnonymousGrantRisk {
207
- /** Which spelling was found. */
208
- pattern: "foreign-uid-literal" | "uid-not-null";
209
- /** The offending fragment — the literal, or the SQL that is a tautology. */
210
- detail: string;
211
- /** Why it admits anonymous callers, and what to write instead. */
212
- explanation: string;
213
- }
214
-
215
- /**
216
- * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
217
- *
218
- * Both schema spellings, because this runs over policy bodies read back from a
219
- * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
220
- * A security check that stops recognising a dangerous clause because the
221
- * framework renamed a function is a check that silently turns off.
222
- */
223
- const UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
224
-
225
- /**
226
- * Find clauses that read as "signed-in users only" but admit anonymous callers.
227
- *
228
- * Both spellings come from the same place — Supabase, where its own `auth.uid()`
229
- * really is NULL for an anonymous request. Rebase substitutes
230
- * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
231
- * is how the trusted *server* context is recognised), so:
232
- *
233
- * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
234
- * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
235
- * other. This one is not hypothetical and was not only a foreign habit:
236
- * rebase's own request path reported `'anon'` while everything that compiled
237
- * or checked a policy used `'anonymous'`, so whichever literal an author
238
- * picked, half the anonymous callers walked through. See
239
- * {@link ANONYMOUS_USER_IDS}.
240
- *
241
- * Either one turns a lockdown into a full grant, and neither looks wrong. No
242
- * real user id is ever one of these literals, and a user-context request is
243
- * never NULL, so a match is always a mistake rather than a deliberate check.
244
- *
245
- * Structured expressions are checked too, not just parsed SQL: `policy.compare`
246
- * can spell the same mistake.
247
- */
248
- export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[] {
249
- const found: AnonymousGrantRisk[] = [];
250
-
251
- const visit = (e: PolicyExpression): void => {
252
- switch (e.kind) {
253
- case "and":
254
- case "or":
255
- e.operands.forEach(visit);
256
- return;
257
- case "not":
258
- visit(e.operand);
259
- return;
260
- case "existsIn":
261
- visit(e.where);
262
- return;
263
- case "raw": {
264
- if (UID_NOT_NULL.test(e.sql)) {
265
- found.push({
266
- pattern: "uid-not-null",
267
- detail: e.sql,
268
- explanation: "`rebase.uid() IS NOT NULL` is true for every request that came from a client, " +
269
- `including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
270
- "Use `condition: policy.authenticated()` to mean \"signed in\"."
271
- });
272
- }
273
- // The foreign literals have to be looked for here too, not only
274
- // in `compare`. `sqlToPolicy` falls back to `raw` for anything
275
- // it cannot structure — an `EXISTS (...)` subquery always does —
276
- // so a policy read back from the database arrives as one opaque
277
- // string. Checking only the tautology meant a genuine
278
- // `rebase.uid() = 'anon'` inside an `existsIn` was structurally
279
- // undetectable once round-tripped, and the caller read the empty
280
- // result as "no risks found".
281
- const foreign = FOREIGN_UID_LITERAL_SQL.exec(e.sql);
282
- if (foreign) {
283
- const literal = foreign[1];
284
- found.push({
285
- pattern: "foreign-uid-literal",
286
- detail: literal,
287
- explanation: `'${literal}' is a ${FOREIGN_CONVENTION_UIDS.get(literal)} convention. Rebase ` +
288
- `reports an anonymous request as '${ANONYMOUS_USER_ID}', so comparing against ` +
289
- `'${literal}' passes for every caller. Use \`condition: policy.authenticated()\` to ` +
290
- "mean \"signed in\"."
291
- });
292
- }
293
- return;
294
- }
295
- case "compare": {
296
- const literal = [e.left, e.right].find(o => o.kind === "literal") as LiteralPolicyOperand | undefined;
297
- const comparesUid = e.left.kind === "authUid" || e.right.kind === "authUid";
298
- if (!comparesUid || typeof literal?.value !== "string") return;
299
- const platform = FOREIGN_CONVENTION_UIDS.get(literal.value);
300
- if (!platform) return;
301
- found.push({
302
- pattern: "foreign-uid-literal",
303
- detail: literal.value,
304
- explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
305
- `request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
306
- "every caller. Use `condition: policy.authenticated()` to mean \"signed in\" — it " +
307
- `compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(", ")}), covering ` +
308
- "every spelling rebase has reported rather than whichever one you remember."
309
- });
310
- return;
311
- }
312
- default:
313
- return;
314
- }
315
- };
316
-
317
- visit(expr);
318
- return found;
319
- }
320
-
321
- function parseOperand(str: string) {
322
- // current_setting('app.uid') or rebase.uid(). `app.user_id` is the
323
- // pre-rename spelling and stays parseable: policies are data, so a
324
- // database provisioned before the rename still holds rules written
325
- // against it, and round-tripping one must not silently drop the operand.
326
- //
327
- // ANCHORED, and that is the whole point. These tests used to be
328
- // unanchored — `.test(str)` rather than `^…$` — so any operand text that
329
- // merely *contained* a uid call was replaced wholesale by the call itself.
330
- // Everything else in the expression was discarded with it, including a
331
- // leading `NOT (`:
332
- //
333
- // NOT (rebase.uid() = rebase.uid()) parsed as rebase.uid() = rebase.uid()
334
- //
335
- // A deny became an unconditional grant. The realistic spelling is a
336
- // hand-written defensive rule with a uid call on both sides —
337
- // COALESCE(rebase.uid(), '') = COALESCE(owner_id, rebase.uid())
338
- // — which collapsed to the same tautology. This is not confined to the
339
- // admin UI: `securityRuleToConditions` feeds a rule's raw `using:` string
340
- // through here, and the Postgres DDL generators compile the result, so the
341
- // tautology was written into the database as the policy body.
342
- //
343
- // An operand this cannot identify exactly must return null, which drops the
344
- // whole clause to `raw` and reproduces it verbatim. That is the rule the
345
- // rest of this file already follows: when in doubt, prefer `raw`.
346
- if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) {
347
- return policy.authUid();
348
- }
349
-
350
- // Literal string: 'value', with `''` decoded back to a single quote.
351
- //
352
- // `quoteLiteral` doubles every quote on the way out, and this did not undo
353
- // it, so a literal containing an apostrophe grew on every trip: O'Brien →
354
- // O''Brien → O''''Brien, doubling each time a policy was read back and
355
- // recompiled. Past the first trip the emitted policy compares against a
356
- // string no row holds.
357
- const literal = parseSingleQuoted(str);
358
- if (literal !== null) {
359
- return policy.literal(literal);
360
- }
361
-
362
- // Unquoted literals, which must be recognised BEFORE the bare-word branch
363
- // below or they are read as column names.
364
- //
365
- // `quoteLiteral` emits booleans, numbers and null unquoted, so `a = false`
366
- // came back as a comparison against a *field* called `false`, and `a = 42`
367
- // against a field called `42`. The recompiled SQL is identical either way,
368
- // which is why this survived a round-trip check on the SQL — but the
369
- // expression is now wrong, and the expression is what the admin UI
370
- // evaluates. Against a row with no `a`, Postgres denies (`NULL = false` is
371
- // not true) while the JS evaluator compared two missing columns, found them
372
- // equal, and allowed. That is precisely the client/database drift the
373
- // shared PolicyExpression model exists to make impossible.
374
- //
375
- // Unambiguous in both directions: a SQL identifier cannot begin with a
376
- // digit, and bare `true`/`false`/`null` are always the literals — a column
377
- // so named would have to be double-quoted to be referenced at all.
378
- if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
379
- if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
380
- if (/^true$/i.test(str)) return policy.literal(true);
381
- if (/^false$/i.test(str)) return policy.literal(false);
382
- if (/^null$/i.test(str)) return policy.literal(null);
383
-
384
- // Bare field name — but only one that survives the snake-casing the
385
- // compiler will apply to it. `toSnakeCase("_")` is the empty string, and a
386
- // field that compiles to an empty column reference emits `= 'x'`, which is
387
- // a syntax error at CREATE POLICY time. Such a name is left to `raw`, where
388
- // it round-trips verbatim instead. `toSnakeCase` itself is not touched:
389
- // column names derived by it are already in shipped databases.
390
- if (/^\w+$/.test(str) && toSnakeCase(str) !== "") {
391
- return policy.field(str);
392
- }
393
-
394
- return null;
395
- }
396
-
397
- /**
398
- * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
399
- *
400
- * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
401
- * operator, not one literal whose body contains a quote, and a regex anchored
402
- * on the outer quotes would happily read it as the latter. Every interior quote
403
- * must therefore be part of a `''` pair.
404
- */
405
- function parseSingleQuoted(str: string): string | null {
406
- if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
407
- const body = str.slice(1, -1);
408
- let out = "";
409
- for (let i = 0; i < body.length; i++) {
410
- if (body[i] !== "'") {
411
- out += body[i];
412
- continue;
413
- }
414
- if (body[i + 1] === "'") {
415
- out += "'";
416
- i++;
417
- continue;
418
- }
419
- return null; // a bare quote — `str` is not a single literal
420
- }
421
- return out;
422
- }
@@ -1,236 +0,0 @@
1
- import { CollectionConfig, isRelationalCollectionConfig, Property, ResolvedRelation, RelationProperty } from "@rebasepro/types";
2
- import { toSnakeCase, toWireKey } from "@rebasepro/utils";
3
-
4
- import { resolveRelation } from "./resolve-relation";
5
-
6
- /**
7
- * Whether the target rows are shared with other parents — a many-to-many, or a
8
- * multi-hop `via` chain.
9
- *
10
- * Decides what a write "through" the relation may touch: a shared target
11
- * belongs to every parent that links it, so the parent owns the *link* and not
12
- * the row. The backend enforces that (an unlink rather than a delete) and the
13
- * admin renders it (remove-from-parent rather than delete).
14
- *
15
- * Now a field on the resolved relation rather than a re-derivation, so both
16
- * sides read the same answer instead of each computing one.
17
- */
18
- export function isJunctionBackedRelation(relation: ResolvedRelation): boolean {
19
- return relation.shared;
20
- }
21
-
22
- /** WeakMap cache — same collection instance always yields the same relation map. */
23
- const _resolvedRelationsCache = new WeakMap<CollectionConfig, Record<string, ResolvedRelation>>();
24
-
25
- /**
26
- * Every relation a collection declares, keyed by the name it is addressed by.
27
- *
28
- * A relation reaches the map from either of two places — the collection's
29
- * `relations` array, or a `relation` property that declares one inline — and is
30
- * keyed by its resolved `relationName`, which is what a nested path segment,
31
- * an `include` key and an admin tab all match against.
32
- *
33
- * Resolution no longer swallows failures. It used to wrap each relation in a
34
- * `try/catch` that dropped anything it could not work out, so a
35
- * mis-declared relation silently vanished instead of being reported; with the
36
- * kind declared, the only remaining failure is a `target` that does not resolve,
37
- * which is worth hearing about.
38
- */
39
- export function resolveCollectionRelations(
40
- collection: CollectionConfig
41
- ): Record<string, ResolvedRelation> {
42
- const cached = _resolvedRelationsCache.get(collection);
43
- if (cached) return cached;
44
-
45
- if (!isRelationalCollectionConfig(collection)) return {};
46
-
47
- const relations: Record<string, ResolvedRelation> = {};
48
-
49
- for (const relation of collection.relations ?? []) {
50
- const resolved = resolveRelation(relation, collection);
51
- relations[resolved.relationName] = resolved;
52
- }
53
-
54
- // A property declaring a relation inline is registered under the property
55
- // key as well: the fetch layer hydrates the result back onto that key, and
56
- // it is the name the admin addresses the field by.
57
- for (const [propertyKey, property] of Object.entries(collection.properties ?? {})) {
58
- if ((property as Property)?.type !== "relation") continue;
59
- const declared = (property as RelationProperty).relation;
60
- if (!declared || relations[propertyKey]) continue;
61
-
62
- relations[propertyKey] = resolveRelation(declared, collection, propertyKey);
63
- }
64
-
65
- _resolvedRelationsCache.set(collection, relations);
66
- return relations;
67
- }
68
-
69
- /**
70
- * The path of the collection a relation property points at, derived from the
71
- * property alone.
72
- *
73
- * A preview holds a property and a value and no collection, so it cannot call
74
- * `resolveRelationProperty`. It does not need to: both forms that carry a
75
- * target — the stamped `resolvedRelation` and the inline `relation` — name it
76
- * directly. Only the third form, a relation declared by name in the
77
- * collection's `relations` array, is out of reach, and that one has no target
78
- * to read without the collection anyway.
79
- *
80
- * This is what lets a preview render a relation column that arrived as a bare
81
- * foreign key: the id says *which* row, the declared target says *which
82
- * collection*, and `RelationPreview` fetches the rest. Without it a scalar id
83
- * is indistinguishable from a value of the wrong type.
84
- */
85
- export function getRelationTargetPath(property: RelationProperty): string | undefined {
86
- const stamped = property.resolvedRelation?.targetSlug;
87
- if (stamped) return stamped;
88
-
89
- const target = property.relation?.target;
90
- if (typeof target !== "function") return undefined;
91
- try {
92
- return target()?.slug;
93
- } catch (_e) {
94
- // A thunk reaching into a module that has not finished initialising:
95
- // there is no target to name yet, and a preview is not worth throwing over.
96
- return undefined;
97
- }
98
- }
99
-
100
- export function getTableName(collection: CollectionConfig): string {
101
- if (isRelationalCollectionConfig(collection)) {
102
- return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
103
- }
104
- return toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
105
- }
106
-
107
- /**
108
- * A JavaScript identifier: what a generated `export const <name> =` needs.
109
- *
110
- * Deliberately the same shape the two schema generators already define
111
- * privately — this is the third place that needed it, and the first two guard
112
- * property keys and member accesses while nothing guarded the variable name
113
- * itself.
114
- */
115
- const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
116
-
117
- /**
118
- * The variable name a generated table is bound to.
119
- *
120
- * Camel-cases underscores, and then guarantees the result is a legal
121
- * identifier. It did only the first, so a table name that is legal in Postgres
122
- * and not in JavaScript produced a `schema.generated.ts` that does not parse:
123
- *
124
- * `2024_archive` → `export const 2024Archive = pgTable(…)`
125
- * "An identifier or keyword cannot immediately follow
126
- * a numeric literal"
127
- * `reporting.events` → `export const reporting.events = pgTable(…)`
128
- * "',' expected"
129
- *
130
- * That file is imported by the server, so the failure is not one broken
131
- * collection — `rebase build` and `db push` fail at tsc for the whole
132
- * directory. And it is reachable from a documented flow: `rebase init` against
133
- * a database holding a table called `2024_archive` writes a collection file
134
- * that parses and a schema file that does not.
135
- *
136
- * **A no-op for every name that already worked**, which is what makes changing
137
- * a derived name safe here: the only inputs whose output changes are the ones
138
- * that produced a syntax error, and nothing can be running against those.
139
- * Separators become camel case rather than disappearing, so `reporting.events`
140
- * and `reporting_events` do not collide into one variable.
141
- */
142
- export function getTableVarName(tableName: string): string {
143
- const camel = tableName.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
144
- if (JS_IDENTIFIER.test(camel)) return camel;
145
-
146
- const sanitised = camel
147
- // Any other separator gets the same treatment `_` did, so two tables
148
- // differing only by separator keep differing.
149
- .replace(/[^A-Za-z0-9_$]+([A-Za-z0-9])?/g, (_, char?: string) =>
150
- (char ? char.toUpperCase() : ""))
151
- // A leading digit is legal in Postgres and not in JavaScript. Prefixed
152
- // rather than stripped, so `2024_archive` and `archive` stay distinct.
153
- .replace(/^([0-9])/, "t$1");
154
-
155
- return JS_IDENTIFIER.test(sanitised) ? sanitised : `t${sanitised}`;
156
- }
157
-
158
- export function getEnumVarName(tableName: string, propName: string): string {
159
- const tableVar = getTableVarName(tableName);
160
- const propVar = propName.charAt(0).toUpperCase() + propName.slice(1);
161
- return `${tableVar}${propVar}`;
162
- }
163
-
164
- export function getColumnName(fullColumn: string): string {
165
- return fullColumn.includes(".") ? fullColumn.split(".").pop()! : fullColumn;
166
- }
167
-
168
- /**
169
- * The field key a database column is served and addressed under.
170
- *
171
- * A column has two names and they are not the same name. `author_id` is what
172
- * Postgres stores; `authorId` is the key on the JSON row, the key in the
173
- * generated Drizzle table, and the key a caller writes in `where` and
174
- * `orderBy`. Every place that starts from a column and has to reach a row, a
175
- * Drizzle table or a payload goes through here, so there is one answer rather
176
- * than one per call site — the two that disagreed put `displayName` and
177
- * `author_id` on the same API.
178
- *
179
- * A declared property is the authority when there is one, because its key *is*
180
- * the wire name and `columnName` is the only thing that ever renamed the
181
- * column:
182
- *
183
- * 1. an explicit `columnName` equal to this column;
184
- * 2. a property whose key is literally the column (an author who wrote
185
- * `author_id:` meant `author_id` on the wire, and gets it);
186
- * 3. a property whose key snake-cases to the column, which is the default
187
- * mapping — `authorId` → `author_id`.
188
- *
189
- * With no property in the way — a foreign key derived from a relation, which
190
- * usually has none — the name is derived: {@link toWireKey}.
191
- *
192
- * Note the fallback is *not* the column verbatim. That was the old behaviour
193
- * and it is precisely the defect: a derived foreign key reached the wire under
194
- * its column name while every hand-authored field beside it was camelCase.
195
- */
196
- export function fieldKeyForColumn(collection: CollectionConfig | undefined, column: string): string {
197
- const properties = collection?.properties;
198
- if (properties) {
199
- for (const [key, prop] of Object.entries(properties)) {
200
- const columnName = (prop as { columnName?: unknown } | undefined)?.columnName;
201
- if (typeof columnName === "string" && columnName === column) return key;
202
- }
203
- for (const key of Object.keys(properties)) {
204
- if (key === column) return key;
205
- if (toSnakeCase(key) === column) return key;
206
- }
207
- }
208
- return toWireKey(column);
209
- }
210
-
211
- /**
212
- * Look up a relation by key with forgiving normalization.
213
- *
214
- * `resolveCollectionRelations` stores each relation under a single canonical
215
- * key (no aliases). This helper tries the given key as-is, then falls back to
216
- * slug form (underscores → hyphens) and snake_case form (hyphens → underscores)
217
- * so that callers that receive a key from external input (URL path segments,
218
- * user-provided config, etc.) can still find the right entry.
219
- */
220
- export function findRelation(
221
- resolvedRelations: Record<string, ResolvedRelation>,
222
- key: string
223
- ): ResolvedRelation | undefined {
224
- // Exact match first
225
- if (resolvedRelations[key]) return resolvedRelations[key];
226
-
227
- // Try slug form (e.g. "company_id" → "company-id")
228
- const slugKey = key.replace(/_/g, "-");
229
- if (slugKey !== key && resolvedRelations[slugKey]) return resolvedRelations[slugKey];
230
-
231
- // Try snake_case form (e.g. "company-id" → "company_id")
232
- const snakeKey = key.replace(/-/g, "_");
233
- if (snakeKey !== key && resolvedRelations[snakeKey]) return resolvedRelations[snakeKey];
234
-
235
- return undefined;
236
- }