@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g18cfeb7

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 (63) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Do9mw5Y5.js} +318 -42
  3. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  4. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  5. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  6. package/dist/cli-helpers.d.ts +1 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-DMUdRQdM.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-DMUdRQdM.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DSIxvLLD.js} +59 -15
  10. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  11. package/dist/index.es.js +534 -200
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
  14. package/dist/policy-CPkCqVTz.js.map +1 -0
  15. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  16. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  17. package/dist/schema/auth-users-columns.d.ts +97 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +1 -1
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
  21. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  22. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  23. package/dist/security/rls-enforcement.d.ts +53 -2
  24. package/dist/services/FetchService.d.ts +10 -7
  25. package/dist/services/dataService.d.ts +2 -0
  26. package/dist/services/realtimeService.d.ts +25 -21
  27. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  28. package/dist/src-C_wvdMnl.js.map +1 -0
  29. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  30. package/dist/{websocket-B2LsrINK.js.map → websocket-D0TBU3ia.js.map} +1 -1
  31. package/package.json +9 -8
  32. package/src/PostgresBackendDriver.ts +165 -3
  33. package/src/PostgresBootstrapper.ts +41 -2
  34. package/src/auth/ensure-tables.ts +185 -86
  35. package/src/cli-helpers.ts +22 -9
  36. package/src/cli.ts +175 -30
  37. package/src/collections/validate-relations.ts +124 -17
  38. package/src/data-transformer.ts +13 -3
  39. package/src/history/ensure-history-table.ts +7 -0
  40. package/src/schema/auth-users-columns.ts +131 -0
  41. package/src/schema/doctor.ts +7 -5
  42. package/src/schema/ensure-collection-tables.ts +88 -19
  43. package/src/schema/generate-drizzle-schema-logic.ts +8 -2
  44. package/src/schema/generate-postgres-ddl-logic.ts +97 -13
  45. package/src/schema/generated-schema-staleness.ts +169 -0
  46. package/src/schema/introspect-db-logic.ts +1 -1
  47. package/src/schema/non-sql-collections.test.ts +131 -0
  48. package/src/schema/rls-bootstrap-sql.ts +288 -0
  49. package/src/security/anonymous-grants.test.ts +4 -2
  50. package/src/security/rls-enforcement.ts +141 -3
  51. package/src/services/BranchService.ts +5 -0
  52. package/src/services/FetchService.ts +10 -93
  53. package/src/services/PersistService.ts +14 -1
  54. package/src/services/channel-history.ts +8 -0
  55. package/src/services/channel-presence.ts +6 -0
  56. package/src/services/dataService.ts +2 -0
  57. package/src/services/realtimeService.ts +36 -33
  58. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  59. package/dist/policy-CeA1JcxP.js.map +0 -1
  60. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  61. package/dist/src-DlPBctw_.js.map +0 -1
  62. package/dist/src-DoU9yPqq.js.map +0 -1
  63. package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import path from "path";
5
5
  import fs from "fs";
6
6
  import { fileURLToPath } from "url";
7
7
  import { logger } from "@rebasepro/server";
8
+ import { formatRelativeTime } from "@rebasepro/utils";
8
9
  import {
9
10
  diagnoseMissingBin,
10
11
  resolveLocalBin,
@@ -17,7 +18,7 @@ import {
17
18
  } from "./cli-helpers";
18
19
  import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
19
20
  import { forLibpq } from "./utils/connection-string";
20
- import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
21
+ import { dropLegacyAuthSchema, RLS_BOOTSTRAP_SQL } from "./schema/rls-bootstrap-sql";
21
22
  import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
22
23
 
23
24
  const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
@@ -138,14 +139,14 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
138
139
  );
139
140
  fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
140
141
 
141
- // Append RLS policies, preceded by the auth bootstrap so the
142
+ // Append RLS policies, preceded by the RLS bootstrap so the
142
143
  // migration is self-contained: Atlas replays migrations
143
- // against a clean dev database where `auth.uid()` would not
144
- // otherwise exist.
144
+ // against a clean dev database where `rebase.uid()` would
145
+ // not otherwise exist.
145
146
  const policiesFile = path.resolve(process.cwd(), "drizzle", "policies.sql");
146
147
  if (fs.existsSync(policiesFile)) {
147
148
  const policiesContent = fs.readFileSync(policiesFile, "utf-8");
148
- fs.appendFileSync(newestMigrationFile, "\n\n" + AUTH_BOOTSTRAP_SQL + "\n" + policiesContent);
149
+ fs.appendFileSync(newestMigrationFile, "\n\n" + RLS_BOOTSTRAP_SQL + "\n" + policiesContent);
149
150
  logger.info(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
150
151
 
151
152
  // Re-hash the migration directory
@@ -236,6 +237,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
236
237
  await applyPolicies(databaseUrl);
237
238
  await reconcilePolicies(databaseUrl, collectionsPath);
238
239
  await ensureRlsUserRole(databaseUrl);
240
+ await retireLegacyAuthSchema(databaseUrl);
239
241
  } else {
240
242
  logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
241
243
  }
@@ -248,6 +250,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
248
250
  await runAtlas("migrate", ["apply", "--dir", "file://drizzle/migrations", ...extraArgs], collectionsPath);
249
251
  if (databaseUrl) {
250
252
  await ensureRlsUserRole(databaseUrl);
253
+ await retireLegacyAuthSchema(databaseUrl);
251
254
  }
252
255
  }
253
256
 
@@ -263,16 +266,20 @@ async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void>
263
266
  const client = new Client({ connectionString: databaseUrl });
264
267
  await client.connect();
265
268
  try {
266
- await client.query(AUTH_BOOTSTRAP_SQL);
267
- // Runtime-only: pre-create the schema Atlas uses for its revision
268
- // table (`--revisions-schema rebase`). Kept out of
269
- // AUTH_BOOTSTRAP_SQL so it never enters the migration stream.
270
- await client.query("CREATE SCHEMA IF NOT EXISTS rebase");
269
+ // Creates the `rebase` schema as its first statement, which also
270
+ // covers the schema Atlas puts its revision table in
271
+ // (`--revisions-schema rebase`). That used to need a separate,
272
+ // deliberately migration-stream-excluded statement here, because the
273
+ // helper functions lived in `auth` and creating `rebase` from the
274
+ // preamble would have made Atlas plan a DROP for it. The generator
275
+ // now always declares `rebase` in the desired schema, so there is
276
+ // nothing to keep out.
277
+ await client.query(RLS_BOOTSTRAP_SQL);
271
278
  } finally {
272
279
  await client.end();
273
280
  }
274
281
  } catch (err) {
275
- logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap auth schema and helper functions: ${err instanceof Error ? err.message : String(err)}`));
282
+ logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap the RLS helper functions: ${err instanceof Error ? err.message : String(err)}`));
276
283
  }
277
284
  }
278
285
 
@@ -320,19 +327,82 @@ async function ensureAuthTables(databaseUrl: string, collectionsPath: string): P
320
327
  * connection would bypass RLS (superuser / BYPASSRLS / table owner).
321
328
  */
322
329
  async function ensureRlsUserRole(databaseUrl: string): Promise<void> {
323
- const { detectConnectionPosture, ensureAppRole, REBASE_USER_ROLE } = await import("./security/rls-enforcement");
324
- const { Client } = await import("pg");
325
- const client = new Client({ connectionString: databaseUrl });
326
- await client.connect();
330
+ // Every failure in here used to escape uncaught, and `db migrate`'s caller
331
+ // turns that into a bare `process.exit(1)` — so the migration would apply,
332
+ // print `-- ok`, and then the command would die with NO output whatsoever
333
+ // and a non-zero status. Seen with a module-resolution error inside the
334
+ // dynamic import, where the entire diagnosis was one silent exit code.
335
+ //
336
+ // Reported rather than rethrown, and deliberately not fatal: the schema
337
+ // change has already landed at this point, so failing the command implies a
338
+ // rollback that did not happen. What is actually lost is the role
339
+ // provisioning, and the message says so and how to finish it by hand.
340
+ // No `auth`: the RLS helpers live in `rebase` now, and a schema the
341
+ // framework no longer creates must not be granted on — on a Supabase
342
+ // database that would hand the end-user role USAGE on their auth schema.
343
+ const schemas = ["public", "rebase"];
344
+ let rls: typeof import("./security/rls-enforcement");
327
345
  try {
328
- const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
329
- const posture = await detectConnectionPosture(runSql);
330
- if (posture.privileged) {
331
- await ensureAppRole(runSql, ["public", "rebase", "auth"]);
332
- logger.info(chalk.gray(` ✓ RLS role "${REBASE_USER_ROLE}" provisioned/refreshed.`));
346
+ rls = await import("./security/rls-enforcement");
347
+ } catch (err) {
348
+ // A module-resolution failure here is a build problem, not a database
349
+ // one, and it is exactly the case that used to produce the silent exit.
350
+ logger.error(chalk.red(
351
+ `\n ✗ Could not load the RLS provisioning module: ${err instanceof Error ? err.message : String(err)}`
352
+ ));
353
+ return;
354
+ }
355
+
356
+ try {
357
+ const { Client } = await import("pg");
358
+ const client = new Client({ connectionString: databaseUrl });
359
+ await client.connect();
360
+ try {
361
+ const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
362
+ const posture = await rls.detectConnectionPosture(runSql);
363
+ if (posture.privileged) {
364
+ await rls.ensureAppRole(runSql, schemas);
365
+ logger.info(chalk.gray(` ✓ RLS role "${rls.REBASE_USER_ROLE}" provisioned/refreshed.`));
366
+ }
367
+ } finally {
368
+ await client.end();
333
369
  }
334
- } finally {
335
- await client.end();
370
+ } catch (err) {
371
+ logger.error(chalk.red(
372
+ `\n ✗ The schema change was applied, but the "${rls.REBASE_USER_ROLE}" role could not be ` +
373
+ `provisioned: ${err instanceof Error ? err.message : String(err)}`
374
+ ));
375
+ logger.error(chalk.gray(rls.appRoleSetupInstructions("your database user", schemas)));
376
+ }
377
+ }
378
+
379
+ /**
380
+ * Retire the pre-1.0 `auth` schema, once nothing depends on it any more.
381
+ *
382
+ * Runs last on purpose. Postgres refuses to drop a function an RLS policy still
383
+ * calls, so this only succeeds after the policies above have been rewritten to
384
+ * `rebase.uid()`. See `dropLegacyAuthSchema` for what it reports when something
385
+ * hand-written is still holding the schema open, and DROP_LEGACY_AUTH_SCHEMA_SQL
386
+ * for the guards that keep it off a Supabase `auth` schema.
387
+ */
388
+ async function retireLegacyAuthSchema(databaseUrl: string): Promise<void> {
389
+ try {
390
+ const { Client } = await import("pg");
391
+ const client = new Client({ connectionString: databaseUrl });
392
+ await client.connect();
393
+ try {
394
+ await dropLegacyAuthSchema(
395
+ async (text) => (await client.query(text)).rows as Record<string, unknown>[],
396
+ {
397
+ info: (m) => logger.info(chalk.gray(` ${m}`)),
398
+ warn: (m) => logger.warn(chalk.yellow(` ⚠️ ${m}`))
399
+ }
400
+ );
401
+ } finally {
402
+ await client.end();
403
+ }
404
+ } catch {
405
+ // Connection-level failure only; the schema is inert either way.
336
406
  }
337
407
  }
338
408
 
@@ -599,15 +669,11 @@ function formatBytes(bytes: number): string {
599
669
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
600
670
  }
601
671
 
672
+ /** A branch's age, for "…created 6d ago". */
602
673
  function timeAgo(date: Date): string {
603
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
604
- if (seconds < 60) return "just now";
605
- const minutes = Math.floor(seconds / 60);
606
- if (minutes < 60) return `${minutes}m ago`;
607
- const hours = Math.floor(minutes / 60);
608
- if (hours < 24) return `${hours}h ago`;
609
- const days = Math.floor(hours / 24);
610
- return `${days}d ago`;
674
+ // No horizon: a branch created a year ago should still report an age here,
675
+ // rather than fall back to a bare date in a one-line list entry.
676
+ return formatRelativeTime(date, { maxMs: Number.POSITIVE_INFINITY }) ?? "unknown";
611
677
  }
612
678
 
613
679
 
@@ -828,7 +894,86 @@ async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
828
894
  }
829
895
  }
830
896
 
897
+ /**
898
+ * `schema stale [--fix]` — is the generated Drizzle schema older than the rule
899
+ * that derives its foreign-key names?
900
+ *
901
+ * This exists for one upgrade path and is worth the command. 0.13 derives
902
+ * `category_id` where 0.12 derived `categorie_id`; boot-ensure renames the
903
+ * database column to match, and the project's checked-in
904
+ * `backend/src/schema.generated.ts` is then wrong in a way nothing the developer
905
+ * did would explain. Relation validation refuses to boot on it, permanently,
906
+ * because the rename has already been applied and will not run again.
907
+ *
908
+ * `rebase dev` calls this with `--fix` before starting the backend, so the
909
+ * upgrade behaves the way the release note says it does: the column moves and the
910
+ * project keeps working. Without `--fix` it reports and exits non-zero, which is
911
+ * what a build or a CI step wants.
912
+ */
913
+ async function schemaStaleCommand(rawArgs: string[]): Promise<void> {
914
+ const argsList = arg(
915
+ {
916
+ "--collections": String,
917
+ "--output": String,
918
+ "--fix": Boolean,
919
+ "-c": "--collections",
920
+ "-o": "--output"
921
+ },
922
+ { argv: rawArgs.slice(2), permissive: true }
923
+ );
924
+
925
+ const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
926
+ const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
927
+ const schemaFile = path.resolve(process.cwd(), outputPath);
928
+
929
+ // No generated schema yet is not staleness — a fresh project has not run the
930
+ // generator, and saying "stale" about a file that does not exist would send
931
+ // the reader looking for something to fix.
932
+ if (!fs.existsSync(schemaFile)) return;
933
+
934
+ const { loadCollections } = await import("./schema/doctor");
935
+ const { findLegacyForeignKeyNames, describeLegacyForeignKeyNames } =
936
+ await import("./schema/generated-schema-staleness");
937
+
938
+ let stale;
939
+ try {
940
+ const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
941
+ stale = findLegacyForeignKeyNames(fs.readFileSync(schemaFile, "utf8"), collections);
942
+ } catch (err) {
943
+ // Best-effort by design: a collections directory that will not load is a
944
+ // real error, but it is one the boot reports far better than this does.
945
+ logger.debug(`schema stale: skipped (${err instanceof Error ? err.message : String(err)})`);
946
+ return;
947
+ }
948
+
949
+ if (stale.length === 0) return;
950
+
951
+ logger.info("");
952
+ logger.warn(chalk.yellow(
953
+ ` ⚠️ ${outputPath} names ${stale.length} foreign key(s) the way an earlier release did:`
954
+ ));
955
+ logger.info(chalk.gray(describeLegacyForeignKeyNames(stale)));
956
+ logger.info("");
957
+
958
+ if (!argsList["--fix"]) {
959
+ logger.error(chalk.red(
960
+ " The database column has already been renamed at boot, so the generated schema no " +
961
+ "longer matches it and the server will refuse to start."
962
+ ));
963
+ logger.error(chalk.red(" Run `rebase schema generate` to regenerate it."));
964
+ process.exit(1);
965
+ }
966
+
967
+ logger.info(chalk.gray(" Regenerating the Drizzle schema so it matches..."));
968
+ await schemaCommand("generate", ["schema", "generate", `--collections=${collectionsPath}`, `--output=${outputPath}`]);
969
+ }
970
+
831
971
  async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
972
+ if (subcommand === "stale") {
973
+ await schemaStaleCommand(rawArgs);
974
+ return;
975
+ }
976
+
832
977
  if (subcommand === "generate") {
833
978
  const argsList = arg(
834
979
  {
@@ -2,6 +2,7 @@ import { getTableColumns } from "drizzle-orm";
2
2
  import { PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
4
4
  import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
5
+ import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
5
6
 
6
7
  import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
7
8
 
@@ -58,6 +59,68 @@ const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join
58
59
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
59
60
  const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
60
61
 
62
+ /**
63
+ * Distinguish "this column name is wrong" from "the generated schema is old".
64
+ *
65
+ * They present identically here — a relation asks for a column the registered
66
+ * table does not have — but they are opposite problems with opposite fixes, and
67
+ * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
68
+ * projects.
69
+ *
70
+ * The registered table is not the database. It comes from the project's
71
+ * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
72
+ * derives foreign-key names: `categories` yields `category_id` where it used to
73
+ * yield `categorie_id`. Boot-ensure renames the database column to match, so by
74
+ * the time this runs the *database* is correct and the *generated module* is the
75
+ * stale one. Reporting "not a column" then points at the wrong artifact, and the
76
+ * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
77
+ * name because that is what the stale module still has — talks the reader into
78
+ * pinning a column that no longer exists.
79
+ *
80
+ * So when the wanted name is what the current rule derives, and the table
81
+ * carries what the *previous* rule would have derived from the same source, say
82
+ * that instead.
83
+ *
84
+ * @param wanted the column the relation asks for
85
+ * @param available every column the registered table has
86
+ * @param sources names the default could have been derived from (a slug, a
87
+ * relation name) — checking against these rather than guessing
88
+ * backwards from `wanted` keeps the match exact
89
+ */
90
+ function staleCodegenRename(
91
+ wanted: string,
92
+ available: Set<string>,
93
+ sources: string[]
94
+ ): { legacy: string; current: string } | null {
95
+ for (const source of sources) {
96
+ if (!source) continue;
97
+ const current = generateForeignKeyName(source);
98
+ const legacy = legacyForeignKeyName(source);
99
+ // Only a name that actually moved, and only when the table still has the
100
+ // old spelling and not the new one.
101
+ if (current !== wanted || legacy === current) continue;
102
+ if (available.has(legacy) && !available.has(current)) return { legacy, current };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /** The shared explanation, so every relation kind reports it identically. */
108
+ function staleCodegenDefect(
109
+ table: string,
110
+ { legacy, current }: { legacy: string; current: string }
111
+ ): Pick<RelationDefect, "problem" | "fix"> {
112
+ return {
113
+ problem:
114
+ `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this ` +
115
+ `release derives \`${current}\` — the generated schema predates the foreign-key ` +
116
+ "naming fix and no longer describes the database",
117
+ fix:
118
+ "regenerate it with `rebase schema generate` (or `pnpm run schema:generate`). The " +
119
+ "database column has already been renamed for you at boot, so nothing else is needed. " +
120
+ `To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
121
+ };
122
+ }
123
+
61
124
  /**
62
125
  * Relations whose names do not resolve against the registered schema.
63
126
  *
@@ -117,11 +180,20 @@ kind: relation.kind };
117
180
  switch (relation.kind) {
118
181
  case "belongsTo": {
119
182
  if (!sourceColumns.has(relation.localKey)) {
120
- defects.push({
121
- ...at,
122
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
123
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
124
- });
183
+ // `localKey` defaults to the relation name run through
184
+ // the foreign-key rule, so it moves with that rule.
185
+ const stale = staleCodegenRename(
186
+ relation.localKey,
187
+ sourceColumns,
188
+ [relation.relationName, targetCollection.slug]
189
+ );
190
+ defects.push(stale
191
+ ? { ...at, ...staleCodegenDefect(sourceTableName, stale) }
192
+ : {
193
+ ...at,
194
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
195
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
196
+ });
125
197
  }
126
198
  break;
127
199
  }
@@ -129,11 +201,20 @@ kind: relation.kind };
129
201
  case "hasOne":
130
202
  case "hasMany": {
131
203
  if (!targetColumns.has(relation.foreignKeyOnTarget)) {
132
- defects.push({
133
- ...at,
134
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
135
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
136
- });
204
+ // The default is derived from *this* collection's slug —
205
+ // the column on the target that points back here.
206
+ const stale = staleCodegenRename(
207
+ relation.foreignKeyOnTarget,
208
+ targetColumns,
209
+ [collection.slug]
210
+ );
211
+ defects.push(stale
212
+ ? { ...at, ...staleCodegenDefect(targetTableName, stale) }
213
+ : {
214
+ ...at,
215
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
216
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
217
+ });
137
218
  }
138
219
  // `sourceKey` is the easiest of the two to put on the wrong
139
220
  // side — it is the only column in a `hasMany` that lives
@@ -167,14 +248,24 @@ kind: relation.kind };
167
248
  break;
168
249
  }
169
250
  const junctionColumns = columnNames(junction);
251
+ // Junction columns are the ones that actually moved in 0.13:
252
+ // each defaults to its endpoint collection's *slug* run
253
+ // through the foreign-key rule, and slugs are plural.
254
+ const derivedFrom = {
255
+ sourceColumn: [collection.slug],
256
+ targetColumn: [targetCollection.slug]
257
+ } as const;
170
258
  for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
171
259
  if (!junctionColumns.has(column)) {
172
- defects.push({
173
- ...at,
174
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
175
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
176
- (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
177
- });
260
+ const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
261
+ defects.push(stale
262
+ ? { ...at, ...staleCodegenDefect(table, stale) }
263
+ : {
264
+ ...at,
265
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
266
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
267
+ (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
268
+ });
178
269
  }
179
270
  }
180
271
  break;
@@ -287,9 +378,25 @@ export function assertRelationsResolve(
287
378
  );
288
379
 
289
380
  throw new Error(
290
- `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\n` +
381
+ `${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against ` +
382
+ "`backend/src/schema.generated.ts`.\n\n" +
291
383
  "Each of these would return no rows at query time rather than reporting an error, " +
292
384
  "so they are fatal at boot instead.\n\n" +
385
+ // This reads the *generated file*, not the database, and the difference
386
+ // is the whole diagnosis after an upgrade. Boot-ensure renames columns
387
+ // in the database — a 0.12 → 0.13 upgrade singularises a junction key,
388
+ // `categorie_id` → `category_id` — and the checked-in file still
389
+ // declares the old name. The config is then correct and the file is
390
+ // stale, so the per-defect advice below, which lists the columns this
391
+ // file has, names a column that no longer exists in the database.
392
+ // Following it turns a recoverable state into a broken config.
393
+ //
394
+ // Hence the ordering: regenerate first, and only then consider that the
395
+ // collection might be the thing that is wrong.
396
+ "If the database was migrated recently — an upgrade, a `db push`, a restore — this file is\n" +
397
+ "probably older than the schema it describes. Regenerate it before changing anything else:\n\n" +
398
+ " rebase schema generate\n\n" +
399
+ "If it is already current, then the collection is what disagrees with it:\n\n" +
293
400
  lines.join("\n\n") + "\n"
294
401
  );
295
402
  }
@@ -3,6 +3,7 @@ import { AnyPgColumn } from "drizzle-orm/pg-core";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
6
+ import { isPrototypePollutingKey } from "@rebasepro/utils";
6
7
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
7
8
  import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
8
9
  import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
@@ -68,9 +69,15 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
68
69
  if (typeof obj === "object") {
69
70
  const newObj: Record<string, unknown> = {};
70
71
  for (const key in obj) {
71
- if (Object.prototype.hasOwnProperty.call(obj, key)) {
72
- newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
73
- }
72
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
73
+ // `JSON.parse` creates `__proto__` as an *own* property, so it gets
74
+ // past the check above — and `newObj[key] = …` then invokes the
75
+ // prototype setter instead of creating a property. The row's
76
+ // prototype becomes whatever the request body supplied, so
77
+ // `row.isAdmin` answers `true` while `Object.keys(row)` shows
78
+ // nothing of the sort. No column can be reached by these names.
79
+ if (isPrototypePollutingKey(key)) continue;
80
+ newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
74
81
  }
75
82
  return newObj;
76
83
  }
@@ -126,6 +133,9 @@ joinPathRelationUpdates: [] };
126
133
  });
127
134
 
128
135
  for (const [key, value] of Object.entries(row)) {
136
+ // Same reasoning as `sanitizeAndConvertDates`: these keys come from the
137
+ // request body, and no column answers to them.
138
+ if (isPrototypePollutingKey(key)) continue;
129
139
  const property = properties[key as keyof M] as Property;
130
140
 
131
141
  // Coerce empty strings to null for any field that acts as a foreign key
@@ -1,6 +1,7 @@
1
1
  import { sql } from "drizzle-orm";
2
2
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
3
  import { logger } from "@rebasepro/server";
4
+ import { revokeInternalTableSql } from "@rebasepro/common";
4
5
 
5
6
  /**
6
7
  * Auto-create the row history table if it doesn't exist.
@@ -38,6 +39,12 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
38
39
  ON rebase.entity_history(table_name, entity_id, updated_at DESC)
39
40
  `);
40
41
 
42
+ // Every previous value of every audited row, in one table with no RLS
43
+ // and no tenant scoping — so a readable copy defeats the row policies on
44
+ // the tables it shadows. The driver's schema-wide grant reaches it
45
+ // (created here, after that grant ran), so take it back.
46
+ await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
47
+
41
48
  logger.info("✅ Entity history table ready");
42
49
  } catch (error) {
43
50
  logger.error("❌ Failed to create row history table", { error: error });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The one description of what an auth user table's columns must be.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Three different code paths create `rebase.users`, and before this module they
7
+ * disagreed about it:
8
+ *
9
+ * | column | `db push` (generator) | `ensureAuthTablesExist` | `ensureCollectionTables` |
10
+ * |------------------|-----------------------|-------------------------|--------------------------|
11
+ * | `email` | `TEXT UNIQUE NOT NULL`| `TEXT NOT NULL` + CHECK | `TEXT` (nullable) |
12
+ * | `roles` | `TEXT[]` | `NOT NULL DEFAULT '{}'` | nullable, no default |
13
+ * | `email_verified` | `BOOLEAN` | `NOT NULL DEFAULT FALSE`| nullable |
14
+ * | `created_at` | `DEFAULT now()` | `NOT NULL DEFAULT NOW()`| no default |
15
+ *
16
+ * They are all `CREATE TABLE IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS`, so
17
+ * whichever ran first decided the table and the other two silently no-op'd.
18
+ * Boot order therefore chose the constraints: a managed deploy (collection
19
+ * tables first) got a users table whose `email` was nullable and whose
20
+ * `email_verified` had no default, while the same project pushed from a
21
+ * checkout got the strict one. In the same table, `is_anonymous` came out
22
+ * `NOT NULL DEFAULT false` — because *that* column existed in only one of the
23
+ * three lists, so its owner's definition won by default.
24
+ *
25
+ * A second consequence was drift in the other direction. The scaffold's
26
+ * `users.ts` describes 12 columns; auth needs 14. `db push` is declarative and
27
+ * builds its desired state from the collection alone, so the two columns only
28
+ * auth knows about (`is_anonymous`, `tokens_valid_after`) read as unmanaged
29
+ * drift, and a push run after the server had booted once planned to DROP them.
30
+ *
31
+ * Both problems are the same problem: no single place said what this table is.
32
+ * This is that place. Every creator asks here first and only then falls back to
33
+ * the collection's own property definitions, so a column auth owns has one
34
+ * definition regardless of who gets there first, and a column the developer
35
+ * added to their users collection still behaves like any other field.
36
+ *
37
+ * ## What belongs here
38
+ *
39
+ * Only columns the auth services read or write. A developer's own additions to
40
+ * their users collection (`bio`, `stripe_customer_id`) are ordinary columns and
41
+ * must NOT be listed — they are generated from the collection like every other
42
+ * field, and listing them here would freeze a user's schema.
43
+ *
44
+ * `id` is deliberately absent: its type comes from the collection's id property
45
+ * (uuid / increment / text) and each creator already derives it.
46
+ *
47
+ * Keep in step with {@link AUTH_SCHEMA_VERSION} when a change here makes an
48
+ * older runtime unable to work against a migrated table.
49
+ */
50
+
51
+ /**
52
+ * A column auth owns.
53
+ *
54
+ * Structured rather than one SQL string because the same facts are needed in
55
+ * three grammars — a `CREATE TABLE` column list, an `ADD COLUMN IF NOT EXISTS`,
56
+ * and a pair of `ALTER COLUMN … SET DEFAULT` / `SET NOT NULL` reconciles for a
57
+ * table that already exists with the wrong shape. Parsing a string back apart
58
+ * for the third of those is how the copies drifted in the first place.
59
+ */
60
+ export interface AuthUsersColumn {
61
+ /** Physical column name. */
62
+ column: string;
63
+ /** Postgres type. */
64
+ type: string;
65
+ /** Default expression, verbatim, or absent for no default. */
66
+ default?: string;
67
+ /** Whether the column is NOT NULL. */
68
+ notNull?: boolean;
69
+ }
70
+
71
+ /**
72
+ * `email` is NOT NULL on purpose, and the anonymous sign-in route depends on it
73
+ * — it synthesizes `anon_<32 hex>@anonymous.local` rather than inserting NULL.
74
+ * The 320-char bound (RFC 5321) is a CHECK rather than a `VARCHAR(n)`, added
75
+ * separately by `ensureAuthTablesExist` so it can be `NOT VALID` on an adopted
76
+ * table that already holds a longer row.
77
+ */
78
+ export const AUTH_USERS_COLUMNS: readonly AuthUsersColumn[] = [
79
+ { column: "email", type: "TEXT", notNull: true },
80
+ { column: "display_name", type: "TEXT" },
81
+ { column: "photo_url", type: "TEXT" },
82
+ { column: "roles", type: "TEXT[]", default: "'{}'", notNull: true },
83
+ { column: "password_hash", type: "TEXT" },
84
+ { column: "email_verified", type: "BOOLEAN", default: "FALSE", notNull: true },
85
+ { column: "email_verification_token", type: "TEXT" },
86
+ { column: "email_verification_sent_at", type: "TIMESTAMP WITH TIME ZONE" },
87
+ { column: "is_anonymous", type: "BOOLEAN", default: "FALSE", notNull: true },
88
+ { column: "metadata", type: "JSONB", default: "'{}'", notNull: true },
89
+ { column: "tokens_valid_after", type: "TIMESTAMP WITH TIME ZONE" },
90
+ { column: "created_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true },
91
+ { column: "updated_at", type: "TIMESTAMP WITH TIME ZONE", default: "NOW()", notNull: true }
92
+ ];
93
+
94
+ const BY_COLUMN = new Map(AUTH_USERS_COLUMNS.map(c => [c.column, c]));
95
+
96
+ /** Type + inline constraints, as they appear after the column name. */
97
+ export function authUsersColumnSql(spec: AuthUsersColumn): string {
98
+ return [
99
+ spec.type,
100
+ spec.default !== undefined ? `DEFAULT ${spec.default}` : "",
101
+ spec.notNull ? "NOT NULL" : ""
102
+ ].filter(Boolean).join(" ");
103
+ }
104
+
105
+ /**
106
+ * The auth-owned definition for a physical column name, or `undefined` when
107
+ * auth does not own it.
108
+ *
109
+ * Callers pass the RESOLVED column name (after `columnName` mapping), because
110
+ * that is the only name the three creators agree on: the scaffold's users
111
+ * collection spells the property `displayName` and the column `display_name`.
112
+ */
113
+ export function authUsersColumnDefinition(column: string): string | undefined {
114
+ const spec = BY_COLUMN.get(column);
115
+ return spec ? authUsersColumnSql(spec) : undefined;
116
+ }
117
+
118
+ /**
119
+ * Whether a collection is an auth collection, i.e. whether the definitions in
120
+ * this module apply to its table at all.
121
+ *
122
+ * Duplicated in shape from `@rebasepro/common`'s policy defaults on purpose:
123
+ * that one takes a `CollectionConfig`, this one is called from DDL code paths
124
+ * that hold looser objects, and both spellings must accept `auth: true` as well
125
+ * as `auth: { enabled: true }`.
126
+ */
127
+ export function isAuthCollection(collection: unknown): boolean {
128
+ const auth = (collection as { auth?: unknown } | undefined)?.auth;
129
+ if (auth === true) return true;
130
+ return typeof auth === "object" && auth !== null && (auth as { enabled?: unknown }).enabled === true;
131
+ }