@rebasepro/common 0.13.0 → 0.13.1-canary.g06dbe5b

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 (41) hide show
  1. package/dist/data/buildRebaseData.d.ts +10 -1
  2. package/dist/data/filter-conditions.d.ts +34 -0
  3. package/dist/data/filter-dialect.d.ts +19 -3
  4. package/dist/data/paginate.d.ts +20 -0
  5. package/dist/data/query_builder.d.ts +16 -4
  6. package/dist/data/resolveDataSource.d.ts +36 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.es.js +622 -92
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/util/builders.d.ts +2 -2
  11. package/dist/util/collections.d.ts +17 -0
  12. package/dist/util/conditions.d.ts +7 -3
  13. package/dist/util/entities.d.ts +8 -1
  14. package/dist/util/index.d.ts +1 -0
  15. package/dist/util/internal-tables.d.ts +95 -0
  16. package/dist/util/permissions.d.ts +30 -0
  17. package/dist/util/policy/sqlToPolicy.d.ts +4 -4
  18. package/dist/util/relations.d.ts +18 -1
  19. package/dist/util/resolutions.d.ts +31 -0
  20. package/package.json +4 -3
  21. package/src/data/buildRebaseData.ts +161 -27
  22. package/src/data/filter-conditions.ts +46 -0
  23. package/src/data/filter-dialect.ts +110 -37
  24. package/src/data/paginate.ts +30 -0
  25. package/src/data/query_builder.ts +26 -4
  26. package/src/data/resolveDataSource.ts +56 -0
  27. package/src/index.ts +1 -0
  28. package/src/util/auth-default-policies.ts +8 -2
  29. package/src/util/builders.ts +3 -3
  30. package/src/util/collections.ts +17 -1
  31. package/src/util/conditions.ts +8 -3
  32. package/src/util/entities.ts +15 -1
  33. package/src/util/index.ts +1 -0
  34. package/src/util/internal-tables.ts +154 -0
  35. package/src/util/permissions.test.ts +23 -2
  36. package/src/util/permissions.ts +43 -6
  37. package/src/util/policy/evaluatePolicy.ts +24 -2
  38. package/src/util/policy/policyToPostgres.ts +23 -10
  39. package/src/util/policy/sqlToPolicy.ts +127 -26
  40. package/src/util/relations.ts +31 -0
  41. package/src/util/resolutions.ts +77 -5
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression, RLS_ROLES_SQL, RLS_UID_SQL, rewriteLegacyRlsFunctions } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { getTableName } from "../relations";
4
4
 
@@ -70,7 +70,7 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
70
70
  case "not":
71
71
  return `NOT (${compile(expr.operand, scope)})`;
72
72
  case "compare": {
73
- // `auth.uid()` returns text; cast the column side so uuid / integer
73
+ // `rebase.uid()` returns text; cast the column side so uuid / integer
74
74
  // id columns compare cleanly instead of failing with
75
75
  // "operator does not exist: uuid = text" at CREATE POLICY time.
76
76
  const castForAuthUid = (operand: PolicyOperand, sqlText: string, other: PolicyOperand): string =>
@@ -82,9 +82,9 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
82
82
  return `${leftSql} ${COMPARE_SQL[expr.op]} ${rightSql}`;
83
83
  }
84
84
  case "rolesOverlap":
85
- return `string_to_array(auth.roles(), ',') && ${rolesArraySql(expr.roles)}`;
85
+ return `string_to_array(${RLS_ROLES_SQL}, ',') && ${rolesArraySql(expr.roles)}`;
86
86
  case "rolesContain":
87
- return `string_to_array(auth.roles(), ',') @> ${rolesArraySql(expr.roles)}`;
87
+ return `string_to_array(${RLS_ROLES_SQL}, ',') @> ${rolesArraySql(expr.roles)}`;
88
88
  case "authenticated":
89
89
  // `IS NOT NULL` alone is a tautology on the user path: every
90
90
  // user-context request sets `app.uid`, and an anonymous one sets
@@ -96,19 +96,32 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
96
96
  // policy compiled here may be enforced against an older server that
97
97
  // still reports `'anon'`, which is exactly how excluding one
98
98
  // spelling turned this helper into a grant. See ANONYMOUS_USER_IDS.
99
- return `auth.uid() IS NOT NULL AND auth.uid() NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
99
+ return `${RLS_UID_SQL} IS NOT NULL AND ${RLS_UID_SQL} NOT IN (${ANONYMOUS_USER_IDS.map(quoteLiteral).join(", ")})`;
100
100
  case "serverContext":
101
101
  // Only the built-in server flows leave `app.uid` unset.
102
- return "auth.uid() IS NULL";
102
+ return `${RLS_UID_SQL} IS NULL`;
103
103
  case "existsIn":
104
104
  return compileExistsIn(expr, scope);
105
- case "raw":
105
+ case "raw": {
106
+ // A project written against a pre-1.0 release may still spell the
107
+ // helpers `auth.uid()`. Rewritten rather than rejected: the rule
108
+ // means exactly the same thing, the developer cannot be expected to
109
+ // have read a changelog mid-deploy, and the alternative is a policy
110
+ // that compiles cleanly and then denies every row at runtime because
111
+ // it calls a function that no longer exists.
112
+ //
113
+ // The counterpart is `warnOnLegacyRlsFunctions`, which says so once
114
+ // at boot with the file to edit — silence here would leave the old
115
+ // spelling working forever and make the migration permanent.
116
+ const sqlText = rewriteLegacyRlsFunctions(expr.sql);
117
+
106
118
  // Full-power escape hatch: `{column}` denotes a column of the outer
107
119
  // RLS row. It must be table-qualified, not bare: raw SQL may open its
108
120
  // own subquery over the same table, and there a bare name binds to the
109
121
  // inner scope, collapsing `m.x = {x}` into the tautology `m.x = m.x`.
110
- return expr.sql.replace(/\{(\w+)\}/g, (_, col) =>
122
+ return sqlText.replace(/\{(\w+)\}/g, (_, col) =>
111
123
  `${outerQualifier(scope)}${resolveColumnName(col, scope.outerCollection)}`);
124
+ }
112
125
  }
113
126
  }
114
127
 
@@ -156,9 +169,9 @@ function operandToSql(operand: PolicyOperand, scope: CompileScope): string {
156
169
  case "literal":
157
170
  return quoteLiteral(operand.value);
158
171
  case "authUid":
159
- return "auth.uid()";
172
+ return RLS_UID_SQL;
160
173
  case "authRoles":
161
- return "string_to_array(auth.roles(), ',')";
174
+ return `string_to_array(${RLS_ROLES_SQL}, ',')`;
162
175
  }
163
176
  }
164
177
 
@@ -1,4 +1,5 @@
1
- import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy, rewriteLegacyRlsFunctions } from "@rebasepro/types";
2
+ import { toSnakeCase } from "@rebasepro/utils";
2
3
 
3
4
  /**
4
5
  * A tiny, regex-based SQL "parser" for security rules.
@@ -38,9 +39,9 @@ function isKeywordAt(upper: string, i: number, keyword: string): boolean {
38
39
  *
39
40
  * This used to be `sql.split(/ AND /i)`, which tore subqueries in half: the
40
41
  * `AND` inside
41
- * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = auth.uid())`
42
+ * `EXISTS (SELECT 1 FROM organization_members m WHERE m.org = t.org AND m.user_id = rebase.uid())`
42
43
  * split the expression, and re-emitting the halves produced
43
- * `(EXISTS (...) AND m.user_id = auth.uid())`
44
+ * `(EXISTS (...) AND m.user_id = rebase.uid())`
44
45
  * where `m` is no longer in scope — SQL that Postgres rejects outright with
45
46
  * "missing FROM-clause entry for table". Returning null instead keeps such a
46
47
  * clause as a `raw` expression, which round-trips verbatim.
@@ -107,22 +108,32 @@ function stripOuterParens(sql: string): string {
107
108
  }
108
109
 
109
110
  export function sqlToPolicy(sql: string): PolicyExpression {
110
- const trimmed = stripOuterParens(sql.trim());
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());
111
122
 
112
123
  if (trimmed.toLowerCase() === "true") return policy.true();
113
124
  if (trimmed.toLowerCase() === "false") return policy.false();
114
125
 
115
126
  // Handle roles overlap (&&)
116
- // Matches: string_to_array(auth.roles(), ',') && ARRAY['admin', 'editor']
117
- const overlapMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*&&\s*ARRAY\s*\[(.+)\]$/i);
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);
118
129
  if (overlapMatch) {
119
130
  const roles = overlapMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
120
131
  return policy.rolesOverlap(roles);
121
132
  }
122
133
 
123
134
  // Handle roles containment (@>)
124
- // Matches: string_to_array(auth.roles(), ',') @> ARRAY['admin']
125
- const containMatch = trimmed.match(/^string_to_array\s*\(\s*auth\.roles\(\)\s*,\s*','\s*\)\s*@>\s*ARRAY\s*\[(.+)\]$/i);
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);
126
137
  if (containMatch) {
127
138
  const roles = containMatch[1].split(",").map(s => s.trim().replace(/^'|'$/g, ""));
128
139
  return policy.rolesContain(roles);
@@ -146,12 +157,15 @@ export function sqlToPolicy(sql: string): PolicyExpression {
146
157
  }
147
158
  }
148
159
 
149
- // Fallback to raw
150
- return policy.raw(sql);
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);
151
165
  }
152
166
 
153
167
  /**
154
- * Literals from other BaaS platforms that people compare `auth.uid()` against
168
+ * Literals from other BaaS platforms that people compare `rebase.uid()` against
155
169
  * out of habit. Mirrors the driver's `FOREIGN_CONVENTION_ROLES` guard on
156
170
  * `pgRoles`, one surface over: the same muscle memory inside a `using:` string
157
171
  * is the more dangerous spelling, because it inverts a rule instead of
@@ -173,19 +187,26 @@ export interface AnonymousGrantRisk {
173
187
  explanation: string;
174
188
  }
175
189
 
176
- /** `auth.uid() IS NOT NULL` in raw SQL, the clause that is always true. */
177
- const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
190
+ /**
191
+ * `rebase.uid() IS NOT NULL` in raw SQL, the clause that is always true.
192
+ *
193
+ * Both schema spellings, because this runs over policy bodies read back from a
194
+ * database, and one migrated by a pre-1.0 release still holds `auth.uid()`.
195
+ * A security check that stops recognising a dangerous clause because the
196
+ * framework renamed a function is a check that silently turns off.
197
+ */
198
+ const UID_NOT_NULL = /\b(?:rebase|auth)\.uid\(\)\s+IS\s+NOT\s+NULL/i;
178
199
 
179
200
  /**
180
201
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
181
202
  *
182
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
183
- * is NULL for an anonymous request. Rebase substitutes
203
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
204
+ * really is NULL for an anonymous request. Rebase substitutes
184
205
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
185
206
  * is how the trusted *server* context is recognised), so:
186
207
  *
187
- * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
188
- * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
208
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
209
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
189
210
  * other. This one is not hypothetical and was not only a foreign habit:
190
211
  * rebase's own request path reported `'anon'` while everything that compiled
191
212
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -219,7 +240,7 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
219
240
  found.push({
220
241
  pattern: "uid-not-null",
221
242
  detail: e.sql,
222
- explanation: "`auth.uid() IS NOT NULL` is true for every request that came from a client, " +
243
+ explanation: "`rebase.uid() IS NOT NULL` is true for every request that came from a client, " +
223
244
  `including anonymous ones — they carry '${ANONYMOUS_USER_ID}', not NULL. ` +
224
245
  "Use `condition: policy.authenticated()` to mean \"signed in\"."
225
246
  });
@@ -252,24 +273,104 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
252
273
  }
253
274
 
254
275
  function parseOperand(str: string) {
255
- // current_setting('app.uid') or auth.uid(). `app.user_id` is the
276
+ // current_setting('app.uid') or rebase.uid(). `app.user_id` is the
256
277
  // pre-rename spelling and stays parseable: policies are data, so a
257
278
  // database provisioned before the rename still holds rules written
258
279
  // against it, and round-tripping one must not silently drop the operand.
259
- if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) {
280
+ //
281
+ // ANCHORED, and that is the whole point. These tests used to be
282
+ // unanchored — `.test(str)` rather than `^…$` — so any operand text that
283
+ // merely *contained* a uid call was replaced wholesale by the call itself.
284
+ // Everything else in the expression was discarded with it, including a
285
+ // leading `NOT (`:
286
+ //
287
+ // NOT (rebase.uid() = rebase.uid()) parsed as rebase.uid() = rebase.uid()
288
+ //
289
+ // A deny became an unconditional grant. The realistic spelling is a
290
+ // hand-written defensive rule with a uid call on both sides —
291
+ // COALESCE(rebase.uid(), '') = COALESCE(owner_id, rebase.uid())
292
+ // — which collapsed to the same tautology. This is not confined to the
293
+ // admin UI: `securityRuleToConditions` feeds a rule's raw `using:` string
294
+ // through here, and the Postgres DDL generators compile the result, so the
295
+ // tautology was written into the database as the policy body.
296
+ //
297
+ // An operand this cannot identify exactly must return null, which drops the
298
+ // whole clause to `raw` and reproduces it verbatim. That is the rule the
299
+ // rest of this file already follows: when in doubt, prefer `raw`.
300
+ if (/^current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)$/i.test(str) || /^rebase\.uid\(\)$/i.test(str)) {
260
301
  return policy.authUid();
261
302
  }
262
303
 
263
- // Literal string: 'value'
264
- const stringMatch = str.match(/^'(.+)'$/);
265
- if (stringMatch) {
266
- return policy.literal(stringMatch[1]);
304
+ // Literal string: 'value', with `''` decoded back to a single quote.
305
+ //
306
+ // `quoteLiteral` doubles every quote on the way out, and this did not undo
307
+ // it, so a literal containing an apostrophe grew on every trip: O'Brien →
308
+ // O''Brien → O''''Brien, doubling each time a policy was read back and
309
+ // recompiled. Past the first trip the emitted policy compares against a
310
+ // string no row holds.
311
+ const literal = parseSingleQuoted(str);
312
+ if (literal !== null) {
313
+ return policy.literal(literal);
267
314
  }
268
315
 
269
- // Bare field name
270
- if (/^\w+$/.test(str)) {
316
+ // Unquoted literals, which must be recognised BEFORE the bare-word branch
317
+ // below or they are read as column names.
318
+ //
319
+ // `quoteLiteral` emits booleans, numbers and null unquoted, so `a = false`
320
+ // came back as a comparison against a *field* called `false`, and `a = 42`
321
+ // against a field called `42`. The recompiled SQL is identical either way,
322
+ // which is why this survived a round-trip check on the SQL — but the
323
+ // expression is now wrong, and the expression is what the admin UI
324
+ // evaluates. Against a row with no `a`, Postgres denies (`NULL = false` is
325
+ // not true) while the JS evaluator compared two missing columns, found them
326
+ // equal, and allowed. That is precisely the client/database drift the
327
+ // shared PolicyExpression model exists to make impossible.
328
+ //
329
+ // Unambiguous in both directions: a SQL identifier cannot begin with a
330
+ // digit, and bare `true`/`false`/`null` are always the literals — a column
331
+ // so named would have to be double-quoted to be referenced at all.
332
+ if (/^-?\d+$/.test(str)) return policy.literal(Number(str));
333
+ if (/^-?\d*\.\d+$/.test(str)) return policy.literal(Number(str));
334
+ if (/^true$/i.test(str)) return policy.literal(true);
335
+ if (/^false$/i.test(str)) return policy.literal(false);
336
+ if (/^null$/i.test(str)) return policy.literal(null);
337
+
338
+ // Bare field name — but only one that survives the snake-casing the
339
+ // compiler will apply to it. `toSnakeCase("_")` is the empty string, and a
340
+ // field that compiles to an empty column reference emits `= 'x'`, which is
341
+ // a syntax error at CREATE POLICY time. Such a name is left to `raw`, where
342
+ // it round-trips verbatim instead. `toSnakeCase` itself is not touched:
343
+ // column names derived by it are already in shipped databases.
344
+ if (/^\w+$/.test(str) && toSnakeCase(str) !== "") {
271
345
  return policy.field(str);
272
346
  }
273
347
 
274
348
  return null;
275
349
  }
350
+
351
+ /**
352
+ * Decode a single-quoted SQL literal, or null when `str` is not exactly one.
353
+ *
354
+ * Rejecting is as important as decoding: `'a' = 'b'` is two literals and an
355
+ * operator, not one literal whose body contains a quote, and a regex anchored
356
+ * on the outer quotes would happily read it as the latter. Every interior quote
357
+ * must therefore be part of a `''` pair.
358
+ */
359
+ function parseSingleQuoted(str: string): string | null {
360
+ if (str.length < 2 || !str.startsWith("'") || !str.endsWith("'")) return null;
361
+ const body = str.slice(1, -1);
362
+ let out = "";
363
+ for (let i = 0; i < body.length; i++) {
364
+ if (body[i] !== "'") {
365
+ out += body[i];
366
+ continue;
367
+ }
368
+ if (body[i + 1] === "'") {
369
+ out += "'";
370
+ i++;
371
+ continue;
372
+ }
373
+ return null; // a bare quote — `str` is not a single literal
374
+ }
375
+ return out;
376
+ }
@@ -66,6 +66,37 @@ export function resolveCollectionRelations(
66
66
  return relations;
67
67
  }
68
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
+
69
100
  export function getTableName(collection: CollectionConfig): string {
70
101
  if (isRelationalCollectionConfig(collection)) {
71
102
  return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);
@@ -261,11 +261,11 @@ export function resolveArrayProperties<M>({
261
261
  ignoreMissingFields,
262
262
  ...props
263
263
  });
264
- const {
265
- values,
266
- previousValues,
267
- ...rest
268
- } = props;
264
+ // Destructured to be *excluded* from `...rest`, not to be used —
265
+ // see the comment below. Said explicitly so the discarded-value
266
+ // ratchet does not carry a finding that is working as intended.
267
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
268
+ const { values, previousValues, ...rest } = props;
269
269
  const ofProperty = resolveProperty({ // we don't want to pass the values of the parent entity
270
270
  property: of,
271
271
  ignoreMissingFields,
@@ -449,6 +449,78 @@ singularName: customName } : {})
449
449
  return views;
450
450
  }
451
451
 
452
+ /**
453
+ * Each of `collection`'s tabs paired with the property that declared it, when a
454
+ * property declared it: child view key → property key.
455
+ *
456
+ * A many-relation can only be declared as a property — that is the documented
457
+ * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
458
+ * one declaration reaches the panel twice, and neither surface knew about the
459
+ * other. The form rendered a relation picker beside the tab, and the collection
460
+ * table rendered *two* columns under one heading: the relation's own column,
461
+ * showing the child rows, and a jump-to-tab button carrying the same name.
462
+ *
463
+ * The pairing is what lets each surface decide which half is redundant, and it
464
+ * has to be a pairing rather than two sets because the two keys differ whenever
465
+ * a relation is named. The match is on the resolved `relationName` — the
466
+ * identity `getEntityChildViews` itself dedupes on — so a relation declared in
467
+ * `relations` and pointed at by a differently-named property is recognised too.
468
+ *
469
+ * A relation with no property of its own is absent here, which is the point: it
470
+ * has exactly one surface already, and nothing to weigh it against.
471
+ *
472
+ * Only top-level properties: a relation nested inside a `map` gets no tab.
473
+ */
474
+ export function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(
475
+ collection: CollectionConfig<M>
476
+ ): Map<string, string> {
477
+ const pairs = new Map<string, string>();
478
+
479
+ const relationProperties = Object.entries((collection.properties ?? {}) as Record<string, Property>)
480
+ .filter(([, property]) => property?.type === "relation");
481
+ if (relationProperties.length === 0) return pairs;
482
+
483
+ const relationViews = getEntityChildViews(collection)
484
+ .filter(view => view.source.kind === "relation");
485
+ if (relationViews.length === 0) return pairs;
486
+
487
+ const resolvedRelations = resolveCollectionRelations(collection);
488
+ const identityOf = (relationKey: string): string =>
489
+ resolvedRelations[relationKey]?.relationName ?? relationKey;
490
+
491
+ const declaringPropertyByIdentity = new Map<string, string>();
492
+ for (const [propertyKey, property] of relationProperties) {
493
+ const relation = (property as RelationProperty).resolvedRelation ?? resolvedRelations[propertyKey];
494
+ // A to-one relation is a foreign key the author edits, never a tab. No
495
+ // view will match it — the views here are many-relations only — but
496
+ // reading the cardinality says so where someone is looking.
497
+ if (relation?.cardinality !== "many") continue;
498
+ const identity = relation.relationName ?? propertyKey;
499
+ if (!declaringPropertyByIdentity.has(identity)) declaringPropertyByIdentity.set(identity, propertyKey);
500
+ }
501
+
502
+ for (const view of relationViews) {
503
+ const propertyKey = declaringPropertyByIdentity.get(
504
+ identityOf((view.source as { relationKey: string }).relationKey));
505
+ if (propertyKey) pairs.set(view.key, propertyKey);
506
+ }
507
+
508
+ return pairs;
509
+ }
510
+
511
+ /**
512
+ * The property keys of `collection` whose relation is already one of its tabs.
513
+ *
514
+ * What a form asks: the tab is the treatment for a list of child rows, so the
515
+ * picker beside it is the redundant half. See
516
+ * {@link getChildViewDeclaringProperties}.
517
+ */
518
+ export function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(
519
+ collection: CollectionConfig<M>
520
+ ): Set<string> {
521
+ return new Set(getChildViewDeclaringProperties(collection).values());
522
+ }
523
+
452
524
  /**
453
525
  * The child views of `collection` as bare collections.
454
526
  *