@rebasepro/common 0.12.0 → 0.12.1-canary.g009ed95

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.
@@ -20,3 +20,21 @@ export declare function getEffectiveSecurityRules(collection: CollectionConfig):
20
20
  * DDL, which policies are injected and how to take them off.
21
21
  */
22
22
  export declare function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[];
23
+ /**
24
+ * Every policy name `rebase db push` would write for a collection.
25
+ *
26
+ * This is the answer to "did the codebase produce this live policy?", and it is
27
+ * more than `securityRules.map(r => r.name)` for two reasons:
28
+ *
29
+ * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
30
+ * per operation, so comparing `rule.name` to `policyname` never matches it;
31
+ * - the generator also injects the safe-by-default baseline
32
+ * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
33
+ *
34
+ * Every UI that flags drift has to get both right, and each one that derived it
35
+ * by hand got a different subset — which is how four policies *Rebase itself
36
+ * wrote* came to be badged as hand-written drift on every table in a project,
37
+ * with a button offering to import them back into the codebase that produced
38
+ * them. There is one derivation now, and this is it.
39
+ */
40
+ export declare function getGeneratedPolicyNames(collection: CollectionConfig): Set<string>;
@@ -1,14 +1,4 @@
1
- import { ArrayProperty, BooleanProperty, DateProperty, CollectionConfig, FirebaseCollectionConfig, FirebaseProperties, GeopointProperty, InferEntityType, MapProperty, MongoDBCollectionConfig, MongoProperties, NumberProperty, PostgresCollectionConfig, PostgresProperties, Property, ReferenceProperty, StringProperty, User } from "@rebasepro/types";
2
- /**
3
- * @deprecated Use {@link defineCollection} instead — it infers property
4
- * types automatically (autocomplete on `titleProperty`, `sort`,
5
- * `propertiesOrder`, callbacks) without manual generics.
6
- * `buildCollection` is kept for FireCMS migration compatibility and will
7
- * be removed before 1.0.
8
- *
9
- * @group Builder
10
- */
11
- export declare function buildCollection<M extends Record<string, unknown> = Record<string, unknown>, USER extends User = User>(collection: CollectionConfig<M, USER>): CollectionConfig<M, USER>;
1
+ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBCollectionConfig, MongoProperties, PostgresCollectionConfig, PostgresProperties, User } from "@rebasepro/types";
12
2
  /**
13
3
  * Define a PostgreSQL-backed collection with full type inference.
14
4
  *
@@ -56,12 +46,3 @@ export declare function defineCollection<const P extends MongoProperties, USER e
56
46
  }): MongoDBCollectionConfig<InferEntityType<P>, USER> & {
57
47
  properties: P;
58
48
  };
59
- /**
60
- * @deprecated Use plain typed property objects with {@link defineCollection}
61
- * instead — `defineCollection` infers property types automatically, making
62
- * this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
63
- * compatibility and will be removed before 1.0.
64
- *
65
- * @group Builder
66
- */
67
- export declare function buildProperty<T, P extends Property = Property>(property: P): P extends StringProperty ? StringProperty : P extends NumberProperty ? NumberProperty : P extends BooleanProperty ? BooleanProperty : P extends DateProperty ? DateProperty : P extends GeopointProperty ? GeopointProperty : P extends ReferenceProperty ? ReferenceProperty : P extends ArrayProperty ? ArrayProperty : P extends MapProperty ? MapProperty : never;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Email normalization — one implementation, because the database enforces it.
3
+ *
4
+ * `ensureAuthTablesExist` puts a `UNIQUE INDEX ON users (lower(email))` on the
5
+ * auth table. That index decides what "the same address" means, and it does not
6
+ * trim: to Postgres, `' foo@bar.com'` and `'foo@bar.com'` are two addresses and
7
+ * both may exist. So every write that reaches the column has to agree with
8
+ * every read, exactly, or the two disagree in the one direction that matters —
9
+ * a row that exists and cannot be found.
10
+ *
11
+ * That is not hypothetical. The lookup path trimmed and the admin create paths
12
+ * did not, so a user created through `POST /api/data/users` or
13
+ * `POST /api/auth/admin/users` with a stray space was stored untrimmed,
14
+ * survived the unique index alongside the real address, and was unreachable by
15
+ * login forever after. The HTTP auth routes were unaffected only because Zod's
16
+ * `.email()` happens to reject surrounding whitespace — a guard on a different
17
+ * layer, for a different reason, that the admin paths do not sit behind.
18
+ *
19
+ * It lives in `common` because `server`, `server-postgres` and `server-mongo`
20
+ * all write this column and must agree exactly, and `common` is the only
21
+ * package all three already depend on.
22
+ */
23
+ /**
24
+ * Canonical form of an email address: trimmed, lower-cased.
25
+ *
26
+ * Non-strings pass through untouched, so this is safe to apply to a value out
27
+ * of a partial update payload whose type is not known yet.
28
+ */
29
+ export declare function normalizeEmail<T>(email: T): T | string;
@@ -2,6 +2,7 @@ export * from "./collections";
2
2
  export * from "./common";
3
3
  export * from "./entities";
4
4
  export * from "./identity";
5
+ export * from "./email";
5
6
  export * from "./enums";
6
7
  export * from "./paths";
7
8
  export * from "./resolutions";
@@ -16,3 +17,4 @@ export * from "./auth-default-policies";
16
17
  export * from "./junction-policies";
17
18
  export * from "./conditions";
18
19
  export * from "./pg-column-to-property";
20
+ export * from "./string-column-length";
@@ -18,7 +18,12 @@ export interface AnonymousGrantRisk {
18
18
  * is how the trusted *server* context is recognised), so:
19
19
  *
20
20
  * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
21
- * - `auth.uid() != 'anon'` compares against a string no caller ever has.
21
+ * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
22
+ * other. This one is not hypothetical and was not only a foreign habit:
23
+ * rebase's own request path reported `'anon'` while everything that compiled
24
+ * or checked a policy used `'anonymous'`, so whichever literal an author
25
+ * picked, half the anonymous callers walked through. See
26
+ * {@link ANONYMOUS_USER_IDS}.
22
27
  *
23
28
  * Either one turns a lockdown into a full grant, and neither looks wrong. No
24
29
  * real user id is ever one of these literals, and a user-context request is
@@ -0,0 +1,24 @@
1
+ import type { StringProperty } from "@rebasepro/types";
2
+ /**
3
+ * The length a bounded string column is declared with when the property does
4
+ * not say. Historical: it is what the DDL generator hardcoded, kept so that
5
+ * regenerating an existing schema does not silently redefine its columns.
6
+ */
7
+ export declare const DEFAULT_STRING_COLUMN_LENGTH = 255;
8
+ /**
9
+ * How wide a `varchar`/`char` column should be for a given property.
10
+ *
11
+ * One definition, three call sites, because they used to disagree. For the same
12
+ * `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
13
+ * while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
14
+ * reads as *unbounded* — so which of the two you ran decided whether the column
15
+ * had a limit at all. Introspection then dropped the length entirely, so reading
16
+ * an existing `character varying(500)` column back and regenerating it produced
17
+ * a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
18
+ *
19
+ * `validation.max` is the property's own statement about how long the value may
20
+ * be, so it is the only sensible source for the column's width — and it keeps
21
+ * the constraint the database enforces in step with the one the app enforces,
22
+ * rather than inventing a second, different limit underneath it.
23
+ */
24
+ export declare function resolveStringColumnLength(prop: Pick<StringProperty, "validation">): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.12.0",
4
+ "version": "0.12.1-canary.g009ed95",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -38,26 +38,26 @@
38
38
  "./package.json": "./package.json"
39
39
  },
40
40
  "dependencies": {
41
- "fast-equals": "6.0.0",
41
+ "fast-equals": "6.0.2",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/types": "0.12.0",
44
- "@rebasepro/utils": "0.12.0"
43
+ "@rebasepro/utils": "0.12.1-canary.g009ed95",
44
+ "@rebasepro/types": "0.12.1-canary.g009ed95"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
48
48
  "@testing-library/react": "^16.3.2",
49
49
  "@testing-library/user-event": "^14.6.1",
50
50
  "@types/jest": "^30.0.0",
51
- "@types/node": "^25.9.3",
51
+ "@types/node": "^26.1.2",
52
52
  "@types/object-hash": "^3.0.6",
53
53
  "@types/react-measure": "^2.0.12",
54
54
  "babel-plugin-react-compiler": "beta",
55
55
  "cross-env": "^10.1.0",
56
56
  "jest": "^30.4.2",
57
- "ts-jest": "^29.4.11",
57
+ "ts-jest": "^29.4.12",
58
58
  "tsd": "^0.33.0",
59
59
  "typescript": "^6.0.3",
60
- "vite": "^8.0.16"
60
+ "vite": "^8.1.5"
61
61
  },
62
62
  "files": [
63
63
  "dist",
@@ -99,7 +99,7 @@ function rowToEntity<M extends Record<string, unknown>>(
99
99
  }
100
100
 
101
101
  /**
102
- * The relation envelope `toCmsRow` writes where a relation was:
102
+ * The relation envelope `toFlatRow` writes where a relation was:
103
103
  * `{ id, path, __type: "relation", data: { id, path, values } }`. It is the
104
104
  * admin's view-model, and the only pipeline that produces one is postgres'.
105
105
  */
@@ -1,5 +1,6 @@
1
1
  import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
2
  import { getTableName } from "./relations";
3
+ import { getPolicyNamesForRules } from "@rebasepro/utils";
3
4
 
4
5
  /**
5
6
  * Default RLS policies injected by the schema generator.
@@ -150,3 +151,24 @@ export function getInjectedSecurityRules(collection: CollectionConfig): Security
150
151
  // so everything past the author's count is injected.
151
152
  return getEffectiveSecurityRules(collection).slice(explicitCount);
152
153
  }
154
+
155
+ /**
156
+ * Every policy name `rebase db push` would write for a collection.
157
+ *
158
+ * This is the answer to "did the codebase produce this live policy?", and it is
159
+ * more than `securityRules.map(r => r.name)` for two reasons:
160
+ *
161
+ * - a rule without an explicit `name` compiles to `<table>_<op>_<hash>`, one
162
+ * per operation, so comparing `rule.name` to `policyname` never matches it;
163
+ * - the generator also injects the safe-by-default baseline
164
+ * (`<table>_default_admin_*`), which is in no collection's `securityRules`.
165
+ *
166
+ * Every UI that flags drift has to get both right, and each one that derived it
167
+ * by hand got a different subset — which is how four policies *Rebase itself
168
+ * wrote* came to be badged as hand-written drift on every table in a project,
169
+ * with a button offering to import them back into the codebase that produced
170
+ * them. There is one derivation now, and this is it.
171
+ */
172
+ export function getGeneratedPolicyNames(collection: CollectionConfig): Set<string> {
173
+ return getPolicyNamesForRules(getEffectiveSecurityRules(collection), getTableName(collection));
174
+ }
@@ -1,43 +1,16 @@
1
1
  import {
2
- ArrayProperty,
3
- BooleanProperty,
4
- DateProperty,
5
2
  CollectionConfig,
6
3
  FirebaseCollectionConfig,
7
4
  FirebaseProperties,
8
- GeopointProperty,
9
5
  InferEntityType,
10
- MapProperty,
11
6
  MongoDBCollectionConfig,
12
7
  MongoProperties,
13
- NumberProperty,
14
8
  PostgresCollectionConfig,
15
9
  PostgresProperties,
16
- Property,
17
- ReferenceProperty,
18
- StringProperty,
19
10
  User
20
11
  } from "@rebasepro/types";
21
12
 
22
13
 
23
- /**
24
- * @deprecated Use {@link defineCollection} instead — it infers property
25
- * types automatically (autocomplete on `titleProperty`, `sort`,
26
- * `propertiesOrder`, callbacks) without manual generics.
27
- * `buildCollection` is kept for FireCMS migration compatibility and will
28
- * be removed before 1.0.
29
- *
30
- * @group Builder
31
- */
32
- export function buildCollection<
33
- M extends Record<string, unknown> = Record<string, unknown>,
34
- USER extends User = User>
35
- (
36
- collection: CollectionConfig<M, USER>
37
- ): CollectionConfig<M, USER> {
38
- return collection;
39
- }
40
-
41
14
  // ── defineCollection ─────────────────────────────────────────────────────
42
15
  // A smarter builder that uses `const` type-parameter inference (TS 5.0+)
43
16
  // to capture literal property types automatically. This gives you
@@ -107,26 +80,3 @@ export function defineCollection(
107
80
  return collection;
108
81
  }
109
82
 
110
- /**
111
- * @deprecated Use plain typed property objects with {@link defineCollection}
112
- * instead — `defineCollection` infers property types automatically, making
113
- * this wrapper unnecessary. `buildProperty` is kept for FireCMS migration
114
- * compatibility and will be removed before 1.0.
115
- *
116
- * @group Builder
117
- */
118
- export function buildProperty<T, P extends Property = Property>(
119
- property: P
120
- ):
121
- P extends StringProperty ? StringProperty :
122
- P extends NumberProperty ? NumberProperty :
123
- P extends BooleanProperty ? BooleanProperty :
124
- P extends DateProperty ? DateProperty :
125
- P extends GeopointProperty ? GeopointProperty :
126
- P extends ReferenceProperty ? ReferenceProperty :
127
- P extends ArrayProperty ? ArrayProperty :
128
- P extends MapProperty ? MapProperty : never {
129
-
130
- // SAFETY: Identity function — P is a subtype of the conditional return type by definition
131
- return property as unknown as ReturnType<typeof buildProperty<T, P>>;
132
- }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Email normalization — one implementation, because the database enforces it.
3
+ *
4
+ * `ensureAuthTablesExist` puts a `UNIQUE INDEX ON users (lower(email))` on the
5
+ * auth table. That index decides what "the same address" means, and it does not
6
+ * trim: to Postgres, `' foo@bar.com'` and `'foo@bar.com'` are two addresses and
7
+ * both may exist. So every write that reaches the column has to agree with
8
+ * every read, exactly, or the two disagree in the one direction that matters —
9
+ * a row that exists and cannot be found.
10
+ *
11
+ * That is not hypothetical. The lookup path trimmed and the admin create paths
12
+ * did not, so a user created through `POST /api/data/users` or
13
+ * `POST /api/auth/admin/users` with a stray space was stored untrimmed,
14
+ * survived the unique index alongside the real address, and was unreachable by
15
+ * login forever after. The HTTP auth routes were unaffected only because Zod's
16
+ * `.email()` happens to reject surrounding whitespace — a guard on a different
17
+ * layer, for a different reason, that the admin paths do not sit behind.
18
+ *
19
+ * It lives in `common` because `server`, `server-postgres` and `server-mongo`
20
+ * all write this column and must agree exactly, and `common` is the only
21
+ * package all three already depend on.
22
+ */
23
+
24
+ /**
25
+ * Canonical form of an email address: trimmed, lower-cased.
26
+ *
27
+ * Non-strings pass through untouched, so this is safe to apply to a value out
28
+ * of a partial update payload whose type is not known yet.
29
+ */
30
+ export function normalizeEmail<T>(email: T): T | string {
31
+ return typeof email === "string" ? email.trim().toLowerCase() : email;
32
+ }
package/src/util/index.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./collections";
2
2
  export * from "./common";
3
3
  export * from "./entities";
4
4
  export * from "./identity";
5
+ export * from "./email";
5
6
  export * from "./enums";
6
7
  export * from "./paths";
7
8
  export * from "./resolutions";
@@ -16,3 +17,4 @@ export * from "./auth-default-policies";
16
17
  export * from "./junction-policies";
17
18
  export * from "./conditions";
18
19
  export * from "./pg-column-to-property";
20
+ export * from "./string-column-length";
@@ -148,13 +148,27 @@ roles: ["admin", "editor"] }
148
148
  expect(canReadCollection(collection, adminAuthController)).toBe(true); // admin
149
149
  });
150
150
 
151
- test("10. Roles on user are objects {id, name} — correctly mapped to strings", () => {
151
+ // The test that used to sit here claimed roles arrive as `{ id, name }`
152
+ // objects and are mapped to strings. `User.roles` is `string[]` and no such
153
+ // mapping exists anywhere, so the fixture was plain strings and the
154
+ // assertion was test 9 again with a different rule. What is worth pinning
155
+ // instead is that the match is an exact string membership test.
156
+ test("10. Role IDs match exactly — not by prefix, substring or case", () => {
152
157
  const collection = createMockCollection([
153
158
  { operation: "select",
154
159
  roles: ["author"] }
155
160
  ]);
156
- // mockUser.roles = [{ id: "author" }, { id: "user" }]
157
- expect(canReadCollection(collection, mockAuthController)).toBe(true);
161
+ const withRoles = (roles: string[] | undefined): AuthState<User> => ({
162
+ user: { ...mockUser,
163
+ roles }
164
+ });
165
+
166
+ expect(canReadCollection(collection, withRoles(["author", "user"]))).toBe(true);
167
+ expect(canReadCollection(collection, withRoles(["authors"]))).toBe(false);
168
+ expect(canReadCollection(collection, withRoles(["auth"]))).toBe(false);
169
+ expect(canReadCollection(collection, withRoles(["Author"]))).toBe(false);
170
+ expect(canReadCollection(collection, withRoles([]))).toBe(false);
171
+ expect(canReadCollection(collection, withRoles(undefined))).toBe(false);
158
172
  });
159
173
 
160
174
  test("11. Empty roles array [] adds no role restriction (public rule stays public)", () => {
@@ -42,6 +42,7 @@ function pgTypeToRebaseProperty(column: TableColumnInfo): Property | null {
42
42
  udt_name,
43
43
  is_nullable,
44
44
  column_default,
45
+ character_maximum_length,
45
46
  enum_values
46
47
  } = column;
47
48
 
@@ -78,11 +79,23 @@ label: prettifyIdentifier(v) })),
78
79
  let colType: "varchar" | "text" | "char" = "varchar";
79
80
  if (dt === "text" || dt === "citext") colType = "text";
80
81
  if (dt === "char" || dt === "character") colType = "char";
82
+ // Carry the declared width across. Dropping it made introspection
83
+ // lossy in the one direction that costs data: a `character
84
+ // varying(500)` column read back as a bare `varchar` regenerates as
85
+ // `VARCHAR(255)`, narrowing a column that already holds longer
86
+ // values. TEXT has no width, and reporting one would invent a limit
87
+ // the database does not have.
88
+ const declaredLength = colType === "text" ? null : character_maximum_length;
81
89
  const prop: StringProperty = {
82
90
  type: "string",
83
91
  name: prettifiedName,
84
92
  columnType: colType,
85
- validation: required ? { required: true } : undefined
93
+ validation: required || declaredLength
94
+ ? {
95
+ ...(required ? { required: true } : {}),
96
+ ...(declaredLength ? { max: declaredLength } : {})
97
+ }
98
+ : undefined
86
99
  };
87
100
  if (isAutoId) {
88
101
  prop.isId = "manual";
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, isAnonymousUid, Entity, PolicyCompareOperator, PolicyExpression, PolicyOperand } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * Result of evaluating a policy client-side. `"unknown"` means the expression
@@ -61,7 +61,11 @@ export function evaluatePolicy(expr: PolicyExpression, ctx: PolicyEvalContext):
61
61
  return expr.roles.every(r => r === "public" || userRoles.includes(r));
62
62
  }
63
63
  case "authenticated":
64
- return ctx.uid != null && ctx.uid !== ANONYMOUS_USER_ID;
64
+ // Every anonymous spelling, matching what this node compiles to in
65
+ // Postgres — the two evaluators disagreeing about who is signed in
66
+ // is the client optimistically rendering a row the database will
67
+ // refuse, or hiding one it would have allowed.
68
+ return ctx.uid != null && !isAnonymousUid(ctx.uid);
65
69
  case "serverContext":
66
70
  // A client is never the server context. Postgres decides this by
67
71
  // `auth.uid() IS NULL`, which a client request can never produce:
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_IDS, CollectionConfig, PolicyExpression, PolicyOperand, PolicyCompareOperator, Property, ExistsInPolicyExpression } from "@rebasepro/types";
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { getTableName } from "../relations";
4
4
 
@@ -88,9 +88,15 @@ function compile(expr: PolicyExpression, scope: CompileScope): string {
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
91
- // it to the sentinel. Excluding the sentinel is what makes this mean
91
+ // it to a sentinel. Excluding the sentinels is what makes this mean
92
92
  // "signed in" rather than "anyone at all".
93
- return `auth.uid() IS NOT NULL AND auth.uid() <> ${quoteLiteral(ANONYMOUS_USER_ID)}`;
93
+ //
94
+ // Every sentinel, not just the current one. This clause is written
95
+ // into the database and outlives the server that generated it: a
96
+ // policy compiled here may be enforced against an older server that
97
+ // still reports `'anon'`, which is exactly how excluding one
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(", ")})`;
94
100
  case "serverContext":
95
101
  // Only the built-in server flows leave `app.uid` unset.
96
102
  return "auth.uid() IS NULL";
@@ -1,4 +1,4 @@
1
- import { ANONYMOUS_USER_ID, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
1
+ import { ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, LiteralPolicyOperand, PolicyExpression, policy } from "@rebasepro/types";
2
2
 
3
3
  /**
4
4
  * A tiny, regex-based SQL "parser" for security rules.
@@ -185,7 +185,12 @@ const UID_NOT_NULL = /auth\.uid\(\)\s+IS\s+NOT\s+NULL/i;
185
185
  * is how the trusted *server* context is recognised), so:
186
186
  *
187
187
  * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
188
- * - `auth.uid() != 'anon'` compares against a string no caller ever has.
188
+ * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
189
+ * other. This one is not hypothetical and was not only a foreign habit:
190
+ * rebase's own request path reported `'anon'` while everything that compiled
191
+ * or checked a policy used `'anonymous'`, so whichever literal an author
192
+ * picked, half the anonymous callers walked through. See
193
+ * {@link ANONYMOUS_USER_IDS}.
189
194
  *
190
195
  * Either one turns a lockdown into a full grant, and neither looks wrong. No
191
196
  * real user id is ever one of these literals, and a user-context request is
@@ -231,7 +236,9 @@ export function findAnonymousGrants(expr: PolicyExpression): AnonymousGrantRisk[
231
236
  detail: literal.value,
232
237
  explanation: `'${literal.value}' is a ${platform} convention. Rebase reports an anonymous ` +
233
238
  `request as '${ANONYMOUS_USER_ID}', so comparing against '${literal.value}' passes for ` +
234
- "every caller. Use `condition: policy.authenticated()` to mean \"signed in\"."
239
+ "every caller. Use `condition: policy.authenticated()` to mean \"signed in\" — it " +
240
+ `compiles to NOT IN (${ANONYMOUS_USER_IDS.map(v => `'${v}'`).join(", ")}), covering ` +
241
+ "every spelling rebase has reported rather than whichever one you remember."
235
242
  });
236
243
  return;
237
244
  }
@@ -37,13 +37,7 @@ export function resolveRelation(
37
37
  );
38
38
  }
39
39
 
40
- const targetCollection = target();
41
- if (!targetCollection?.slug) {
42
- throw new Error(
43
- `Relation${relation.relationName ? ` '${relation.relationName}'` : ""} on ` +
44
- `'${sourceCollection.slug}' has a \`target\` that did not resolve to a collection.`
45
- );
46
- }
40
+ const targetCollection = callTarget(relation, sourceCollection, propertyKey, target);
47
41
 
48
42
  // The name is the address: the `include` key, the admin tab, and the
49
43
  // segment of a nested path. Declared name wins, then the declaring
@@ -80,7 +74,8 @@ export function resolveRelation(
80
74
  cardinality: "one",
81
75
  writable: true,
82
76
  shared: false,
83
- foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName)
77
+ foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),
78
+ sourceKey: relation.sourceKey
84
79
  };
85
80
 
86
81
  case "hasMany":
@@ -90,7 +85,11 @@ export function resolveRelation(
90
85
  cardinality: "many",
91
86
  writable: true,
92
87
  shared: false,
93
- foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName)
88
+ foreignKeyOnTarget: relation.foreignKeyOnTarget ?? generateForeignKeyName(sourceName),
89
+ // Not defaulted: the source's primary key needs the driver's
90
+ // schema to resolve, which resolution does not have. `undefined`
91
+ // means "the primary key" — see `ResolvedHasMany.sourceKey`.
92
+ sourceKey: relation.sourceKey
94
93
  };
95
94
 
96
95
  case "manyToMany": {
@@ -132,3 +131,69 @@ export function resolveRelation(
132
131
  }
133
132
  }
134
133
  }
134
+
135
+ /** How this relation is addressed in an error message, before it has a resolved name. */
136
+ function describe(relation: Relation, sourceCollection: CollectionConfig, propertyKey?: string): string {
137
+ const name = relation.relationName ?? propertyKey;
138
+ return `Relation${name ? ` '${name}'` : ""} on '${sourceCollection.slug}'`;
139
+ }
140
+
141
+ /**
142
+ * Call the `target` thunk, and translate the two ways an import cycle breaks it
143
+ * into an error that names the cause.
144
+ *
145
+ * The thunk exists to defer the reference until every module has finished
146
+ * evaluating, and for a cycle that closes at import time it does. What it cannot
147
+ * defer is a cycle that leaves the binding permanently unusable, and there are
148
+ * two shapes of that:
149
+ *
150
+ * - **ESM/TDZ.** `const` and `class` bindings in a not-yet-evaluated module are
151
+ * in the temporal dead zone, so reading one throws `ReferenceError: x is not
152
+ * defined`. The stack points at the thunk — a one-line arrow function that is
153
+ * obviously fine — and says nothing about the cycle that made it throw.
154
+ * - **CJS interop.** The half-initialised module object has no `default` yet,
155
+ * the import resolves to `undefined`, and the thunk returns it without
156
+ * complaint. That one used to surface here as "did not resolve to a
157
+ * collection", which is true and unhelpful.
158
+ *
159
+ * Both mean the same thing, and the fix for both is the same: break the cycle,
160
+ * or move the relation into the collection that does not close it.
161
+ */
162
+ function callTarget(
163
+ relation: Relation,
164
+ sourceCollection: CollectionConfig,
165
+ propertyKey: string | undefined,
166
+ target: Relation["target"]
167
+ ): ReturnType<Relation["target"]> {
168
+ let targetCollection: ReturnType<Relation["target"]> | undefined;
169
+ try {
170
+ targetCollection = target();
171
+ } catch (error) {
172
+ // A ReferenceError from inside the thunk is a binding that was never
173
+ // initialised — nothing else in a one-expression arrow can raise one.
174
+ if (error instanceof ReferenceError) {
175
+ throw new Error(
176
+ `${describe(relation, sourceCollection, propertyKey)} targets a collection that is not ` +
177
+ `initialized yet — almost always an import cycle between the two collection files. ` +
178
+ `Break the cycle (move the shared piece into a third module, or import the target ` +
179
+ `lazily) so the target's module finishes evaluating before the registry is built.`,
180
+ { cause: error }
181
+ );
182
+ }
183
+ throw error;
184
+ }
185
+
186
+ if (!targetCollection?.slug) {
187
+ throw new Error(
188
+ `${describe(relation, sourceCollection, propertyKey)} has a \`target\` that resolved to ` +
189
+ `${targetCollection === undefined ? "`undefined`" : "something that is not a collection"}. ` +
190
+ (targetCollection === undefined
191
+ ? "Under CommonJS interop an import cycle resolves the default import to `undefined`, " +
192
+ "so check whether this collection and its target import each other. Otherwise the thunk " +
193
+ "is returning the wrong value — it must return the collection itself, not a promise or a module."
194
+ : "The thunk must return a collection config with a `slug`.")
195
+ );
196
+ }
197
+
198
+ return targetCollection;
199
+ }
@@ -0,0 +1,31 @@
1
+ import type { StringProperty } from "@rebasepro/types";
2
+
3
+ /**
4
+ * The length a bounded string column is declared with when the property does
5
+ * not say. Historical: it is what the DDL generator hardcoded, kept so that
6
+ * regenerating an existing schema does not silently redefine its columns.
7
+ */
8
+ export const DEFAULT_STRING_COLUMN_LENGTH = 255;
9
+
10
+ /**
11
+ * How wide a `varchar`/`char` column should be for a given property.
12
+ *
13
+ * One definition, three call sites, because they used to disagree. For the same
14
+ * `columnType: "varchar"` property the DDL generator emitted `VARCHAR(255)`
15
+ * while the Drizzle generator emitted a bare `varchar("col")` — which Postgres
16
+ * reads as *unbounded* — so which of the two you ran decided whether the column
17
+ * had a limit at all. Introspection then dropped the length entirely, so reading
18
+ * an existing `character varying(500)` column back and regenerating it produced
19
+ * a `VARCHAR(255)`: a silent narrowing of a column with data already in it.
20
+ *
21
+ * `validation.max` is the property's own statement about how long the value may
22
+ * be, so it is the only sensible source for the column's width — and it keeps
23
+ * the constraint the database enforces in step with the one the app enforces,
24
+ * rather than inventing a second, different limit underneath it.
25
+ */
26
+ export function resolveStringColumnLength(prop: Pick<StringProperty, "validation">): number {
27
+ const max = prop.validation?.max;
28
+ return typeof max === "number" && Number.isInteger(max) && max > 0
29
+ ? max
30
+ : DEFAULT_STRING_COLUMN_LENGTH;
31
+ }