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

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 (70) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
  3. package/dist/auth-users-columns-Dt9g712t.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 +57 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
  10. package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
  11. package/dist/index.es.js +765 -223
  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 +2 -2
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -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/schema/search-column.d.ts +199 -0
  24. package/dist/security/rls-enforcement.d.ts +53 -2
  25. package/dist/services/FetchService.d.ts +25 -7
  26. package/dist/services/dataService.d.ts +3 -0
  27. package/dist/services/realtimeService.d.ts +27 -21
  28. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  29. package/dist/src-C_wvdMnl.js.map +1 -0
  30. package/dist/utils/drizzle-conditions.d.ts +71 -2
  31. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  32. package/dist/websocket-D0TBU3ia.js.map +1 -0
  33. package/package.json +9 -8
  34. package/src/PostgresBackendDriver.ts +165 -3
  35. package/src/PostgresBootstrapper.ts +41 -2
  36. package/src/auth/ensure-tables.ts +185 -86
  37. package/src/cli-helpers.ts +129 -10
  38. package/src/cli.ts +232 -30
  39. package/src/collections/validate-relations.ts +124 -17
  40. package/src/data-transformer.ts +31 -3
  41. package/src/history/ensure-history-table.ts +7 -0
  42. package/src/schema/auth-users-columns.ts +131 -0
  43. package/src/schema/doctor.ts +7 -5
  44. package/src/schema/ensure-collection-tables.ts +165 -20
  45. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  46. package/src/schema/generate-postgres-ddl-logic.ts +266 -15
  47. package/src/schema/generate-postgres-ddl.ts +25 -2
  48. package/src/schema/generated-schema-staleness.ts +169 -0
  49. package/src/schema/introspect-db-logic.ts +1 -1
  50. package/src/schema/non-sql-collections.test.ts +131 -0
  51. package/src/schema/rls-bootstrap-sql.ts +288 -0
  52. package/src/schema/search-column.ts +558 -0
  53. package/src/security/anonymous-grants.test.ts +4 -2
  54. package/src/security/rls-enforcement.ts +141 -3
  55. package/src/services/BranchService.ts +5 -0
  56. package/src/services/FetchService.ts +148 -108
  57. package/src/services/PersistService.ts +14 -1
  58. package/src/services/RelationService.ts +2 -1
  59. package/src/services/channel-history.ts +8 -0
  60. package/src/services/channel-presence.ts +6 -0
  61. package/src/services/dataService.ts +3 -0
  62. package/src/services/realtimeService.ts +46 -37
  63. package/src/utils/drizzle-conditions.ts +223 -2
  64. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  65. package/dist/policy-CeA1JcxP.js.map +0 -1
  66. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  67. package/dist/src-DlPBctw_.js.map +0 -1
  68. package/dist/src-DoU9yPqq.js.map +0 -1
  69. package/dist/websocket-B2LsrINK.js.map +0 -1
  70. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -86,10 +86,16 @@ export function resolveLocalBin(binName: string): string | null {
86
86
  return null;
87
87
  }
88
88
 
89
- export async function getTableIncludesFromCollections(collections: CollectionConfig[]): Promise<string[]> {
90
- const { getTableName, resolveCollectionRelations } = await import("@rebasepro/common");
89
+ export async function getTableIncludesFromCollections(allCollections: CollectionConfig[]): Promise<string[]> {
90
+ const { getTableName, resolveCollectionRelations, relationalCollections } = await import("@rebasepro/common");
91
91
  const { isPostgresCollectionConfig } = await import("@rebasepro/types");
92
92
 
93
+ // The include list is the inverse of {@link getTableExcludes}: a name on it
94
+ // is a name Atlas is allowed to drop. A Firestore collection called
95
+ // `exercises` must not put `public.exercises` here — a real, unrelated table
96
+ // of that name would lose its protection and be dropped by the next
97
+ // auto-approved `db push`.
98
+ const collections = relationalCollections(allCollections);
93
99
  const includes: string[] = [];
94
100
  for (const col of collections) {
95
101
  const tableName = getTableName(col);
@@ -113,6 +119,19 @@ export async function getTableIncludesFromCollections(collections: CollectionCon
113
119
  }
114
120
 
115
121
  export async function getTableIncludes(collectionsPath: string): Promise<string[]> {
122
+ return getTableIncludesFromCollections(await loadCollectionsForCli(collectionsPath));
123
+ }
124
+
125
+ /**
126
+ * Load a project's collections the way the Atlas-facing commands need them.
127
+ *
128
+ * Deliberately forgiving — a file that fails to import is skipped rather than
129
+ * fatal — because the callers use this to *narrow* what Atlas may touch, and a
130
+ * hard failure here would block a push over an unrelated broken file. Callers
131
+ * that cannot tolerate a partial answer (the table excludes, which fail closed)
132
+ * check the result themselves.
133
+ */
134
+ export async function loadCollectionsForCli(collectionsPath: string): Promise<CollectionConfig[]> {
116
135
  // Note: a relative --collections path resolves against the *current working
117
136
  // directory*, not the backend package. When invoked via the generated npm
118
137
  // script (cwd = backend/), the script uses "../config/collections" to
@@ -152,7 +171,7 @@ export async function getTableIncludes(collectionsPath: string): Promise<string[
152
171
  }
153
172
  }
154
173
 
155
- return getTableIncludesFromCollections(collections);
174
+ return collections;
156
175
  }
157
176
 
158
177
  export function getDevDatabaseUrl(databaseUrl: string): string {
@@ -189,6 +208,99 @@ export async function ensureDevDatabaseExists(databaseUrl: string, devDatabaseUr
189
208
  }
190
209
  }
191
210
 
211
+ /**
212
+ * The generated SQL for the project's `search` blocks, if it has any.
213
+ *
214
+ * @param drizzleDir directory holding the generated SQL. Defaults to `drizzle`
215
+ * under the working directory.
216
+ */
217
+ export function readSearchDdl(drizzleDir: string = path.resolve(process.cwd(), "drizzle")): string {
218
+ const searchFile = path.join(drizzleDir, "search.sql");
219
+ if (!fs.existsSync(searchFile)) return "";
220
+ return fs.readFileSync(searchFile, "utf-8").trim();
221
+ }
222
+
223
+ /**
224
+ * Bring the search column, its index and their helpers up to date.
225
+ *
226
+ * Runs *after* Atlas, not before: the statements are `ALTER TABLE ... ADD
227
+ * COLUMN`, so the table has to exist. Atlas is told to ignore these objects
228
+ * entirely (`getSearchExcludes`) — it cannot manage them, and left to itself it
229
+ * would plan a `DROP COLUMN` for every one, since they are absent from the
230
+ * desired state it was given.
231
+ *
232
+ * A no-op when no collection declared `search`. A failure is *not* swallowed:
233
+ * silently pushing a schema whose search column never appeared is how a
234
+ * collection ends up with search configured, no error anywhere, and no results.
235
+ */
236
+ export async function applySearchDdl(
237
+ databaseUrl: string,
238
+ drizzleDir: string = path.resolve(process.cwd(), "drizzle")
239
+ ): Promise<void> {
240
+ const sql = readSearchDdl(drizzleDir);
241
+ if (!sql) return;
242
+
243
+ const { Client } = await import("pg");
244
+ const client = new Client({ connectionString: databaseUrl });
245
+ await client.connect();
246
+ try {
247
+ await client.query(sql);
248
+ } finally {
249
+ await client.end();
250
+ }
251
+ logger.info(chalk.gray(" ✓ Applied full-text search columns and indexes"));
252
+ }
253
+
254
+ /**
255
+ * Glob patterns keeping Atlas away from the search objects.
256
+ *
257
+ * Returns an empty list — and so changes nothing — for a project with no
258
+ * `search` block, which is every project that has not opted in.
259
+ */
260
+ export async function getSearchExcludes(collectionsPath: string): Promise<string[]> {
261
+ const { searchExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
262
+ return searchExcludePatterns(await loadCollectionsForCli(collectionsPath));
263
+ }
264
+
265
+ /**
266
+ * Give the dev database the search helper functions before Atlas plans.
267
+ *
268
+ * Excluding the search column keeps Atlas from *diffing* it, but not from
269
+ * materialising the inspected schema — column and all — in the dev database to
270
+ * analyse the plan against. That replay is where a push against an
271
+ * already-searchable database died with `function public.rebase_search_text
272
+ * (jsonb) does not exist`: the column came across, the function it calls did
273
+ * not, because Atlas will not carry a function at all.
274
+ *
275
+ * Only the extensions and functions, never the tables: the dev database holds
276
+ * whatever Atlas puts there and nothing of ours.
277
+ *
278
+ * Best-effort by design. Failing here would block a push for a project whose
279
+ * collections merely failed to import, and if the functions really are needed
280
+ * and really are missing, Atlas says so a moment later in its own words.
281
+ */
282
+ export async function seedDevDatabaseSearchHelpers(
283
+ devDatabaseUrl: string,
284
+ collectionsPath: string
285
+ ): Promise<void> {
286
+ try {
287
+ const { searchPrerequisiteStatements } = await import("./schema/generate-postgres-ddl-logic");
288
+ const statements = searchPrerequisiteStatements(await loadCollectionsForCli(collectionsPath));
289
+ if (statements.length === 0) return;
290
+
291
+ const { Client } = await import("pg");
292
+ const client = new Client({ connectionString: devDatabaseUrl });
293
+ await client.connect();
294
+ try {
295
+ await client.query(statements.join("\n"));
296
+ } finally {
297
+ await client.end();
298
+ }
299
+ } catch {
300
+ // See above: not worth failing a push over.
301
+ }
302
+ }
303
+
192
304
  /**
193
305
  * Query the live database for every user table/view outside the system
194
306
  * catalogs. Separated from {@link getTableExcludes} so its failure mode can
@@ -249,13 +361,20 @@ export async function getTableExcludes(
249
361
  const queryTables = deps.queryExistingTables ?? queryExistingTables;
250
362
 
251
363
  const includes = await getIncludes(collectionsPath);
252
- // Framework-owned schemas Rebase manages itself the declarative apply
253
- // must never touch them. `auth` holds the helper functions; `rebase` holds
254
- // the auth tables (users, refresh_tokens, …) and Atlas's own revision
255
- // table. Excluding both the schema object AND its contents keeps Atlas
256
- // from planning a `DROP SCHEMA CASCADE` — which on a live database would
257
- // take the user/auth tables with it. `atlas_schema_revisions.*` is kept
258
- // for backwards-compatibility with any external revision schema.
364
+ // Schemas the declarative apply must never touch. `rebase` holds the auth
365
+ // tables (users, refresh_tokens, …), the RLS helper functions and Atlas's
366
+ // own revision table. Excluding both the schema object AND its contents
367
+ // keeps Atlas from planning a `DROP SCHEMA CASCADE` which on a live
368
+ // database would take the user/auth tables with it.
369
+ // `atlas_schema_revisions.*` is kept for backwards-compatibility with any
370
+ // external revision schema.
371
+ //
372
+ // `auth` stays on this list even though Rebase no longer creates it, and
373
+ // that is the point: on a database still holding the pre-1.0 schema it must
374
+ // survive until the guarded cleanup retires it, and on a Supabase database
375
+ // it is somebody else's and must never be in scope at all. Removing it here
376
+ // would let a `db push` plan `DROP SCHEMA auth CASCADE` against a live
377
+ // Supabase installation.
259
378
  const excludes: string[] = ["atlas_schema_revisions.*", "auth", "auth.*", "rebase", "rebase.*"];
260
379
 
261
380
  let existingTables: string[];
package/src/cli.ts CHANGED
@@ -5,19 +5,24 @@ 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,
11
12
  getTableIncludes,
12
13
  getDevDatabaseUrl,
13
14
  ensureDevDatabaseExists,
15
+ applySearchDdl,
16
+ getSearchExcludes,
17
+ readSearchDdl,
18
+ seedDevDatabaseSearchHelpers,
14
19
  getTableExcludes,
15
20
  ExcludeIntrospectionError,
16
21
  promptConfirm
17
22
  } from "./cli-helpers";
18
23
  import { checkDatabaseConnectivity, diagnoseDbError } from "./cli-errors";
19
24
  import { forLibpq } from "./utils/connection-string";
20
- import { AUTH_BOOTSTRAP_SQL } from "./schema/auth-bootstrap-sql";
25
+ import { dropLegacyAuthSchema, RLS_BOOTSTRAP_SQL } from "./schema/rls-bootstrap-sql";
21
26
  import { detectDestructiveStatements, decidePushSafety } from "./schema/destructive-sql";
22
27
 
23
28
  const __cliDirname = path.dirname(fileURLToPath(import.meta.url));
@@ -69,6 +74,13 @@ export async function runPluginCommand(args: string[]) {
69
74
  }
70
75
  }
71
76
 
77
+ /** The migration files on disk, sorted, or none if the directory is absent. */
78
+ function listMigrationFiles(): string[] {
79
+ const dir = path.resolve(process.cwd(), "drizzle", "migrations");
80
+ if (!fs.existsSync(dir)) return [];
81
+ return fs.readdirSync(dir).filter(f => f.endsWith(".sql")).sort();
82
+ }
83
+
72
84
  async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
73
85
  const VALID_ACTIONS = ["push", "generate", "migrate", "branch", "backup", "restore", "backups"];
74
86
  if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
@@ -115,7 +127,9 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
115
127
  logger.info(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
116
128
  logger.info("");
117
129
  const migrationName = argsList._[0] || "migration";
130
+ const migrationsBefore = listMigrationFiles();
118
131
  await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
132
+ const wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
119
133
 
120
134
  // Post-process the newest migration file
121
135
  try {
@@ -136,16 +150,43 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
136
150
  /CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
137
151
  "CREATE SCHEMA IF NOT EXISTS $1;"
138
152
  );
153
+ // Atlas never writes the search objects into a migration —
154
+ // it is not shown them — so a migration replayed against a
155
+ // fresh database would build every table except the part
156
+ // that makes search work. Appending, because these are
157
+ // `ALTER TABLE ... ADD COLUMN` over the tables the
158
+ // migration just created.
159
+ //
160
+ // Only into a migration Atlas just wrote. The file picked
161
+ // here is simply the newest one, which when there was no
162
+ // diff is a migration that has already run in production:
163
+ // editing it changes a hash Atlas has recorded, and the
164
+ // appended SQL would never be applied anywhere.
165
+ const searchContent = readSearchDdl();
166
+ if (searchContent && wroteNewMigration) {
167
+ migrationContent = `${migrationContent}\n\n${searchContent}`;
168
+ logger.info(chalk.gray(" ✓ Appended search DDL to the migration"));
169
+ } else if (searchContent) {
170
+ // Reachable whenever a `search` block is the *only*
171
+ // thing that changed: Atlas cannot see one, so it finds
172
+ // nothing to diff and writes no file.
173
+ logger.info(chalk.gray(
174
+ " ℹ Search DDL not written to a migration — this change produced none.\n" +
175
+ " It is applied by `rebase db push`, and at boot by the schema ensure.\n" +
176
+ " For a migration-only deployment, add drizzle/search.sql to a migration by hand."
177
+ ));
178
+ }
179
+
139
180
  fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
140
181
 
141
- // Append RLS policies, preceded by the auth bootstrap so the
182
+ // Append RLS policies, preceded by the RLS bootstrap so the
142
183
  // migration is self-contained: Atlas replays migrations
143
- // against a clean dev database where `auth.uid()` would not
144
- // otherwise exist.
184
+ // against a clean dev database where `rebase.uid()` would
185
+ // not otherwise exist.
145
186
  const policiesFile = path.resolve(process.cwd(), "drizzle", "policies.sql");
146
187
  if (fs.existsSync(policiesFile)) {
147
188
  const policiesContent = fs.readFileSync(policiesFile, "utf-8");
148
- fs.appendFileSync(newestMigrationFile, "\n\n" + AUTH_BOOTSTRAP_SQL + "\n" + policiesContent);
189
+ fs.appendFileSync(newestMigrationFile, "\n\n" + RLS_BOOTSTRAP_SQL + "\n" + policiesContent);
149
190
  logger.info(chalk.gray(` ✓ Appended RLS policies to migration file: ${path.basename(newestMigrationFile)}`));
150
191
 
151
192
  // Re-hash the migration directory
@@ -233,9 +274,14 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
233
274
 
234
275
  if (databaseUrl) {
235
276
  await ensureAuthTables(databaseUrl, collectionsPath);
277
+ // After the tables exist and before the policies: the search
278
+ // column is `ALTER TABLE ... ADD COLUMN`, and a policy may
279
+ // reference the table it is added to.
280
+ await applySearchDdl(databaseUrl);
236
281
  await applyPolicies(databaseUrl);
237
282
  await reconcilePolicies(databaseUrl, collectionsPath);
238
283
  await ensureRlsUserRole(databaseUrl);
284
+ await retireLegacyAuthSchema(databaseUrl);
239
285
  } else {
240
286
  logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
241
287
  }
@@ -248,6 +294,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
248
294
  await runAtlas("migrate", ["apply", "--dir", "file://drizzle/migrations", ...extraArgs], collectionsPath);
249
295
  if (databaseUrl) {
250
296
  await ensureRlsUserRole(databaseUrl);
297
+ await retireLegacyAuthSchema(databaseUrl);
251
298
  }
252
299
  }
253
300
 
@@ -263,16 +310,20 @@ async function ensureAuthSchemaAndFunctions(databaseUrl: string): Promise<void>
263
310
  const client = new Client({ connectionString: databaseUrl });
264
311
  await client.connect();
265
312
  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");
313
+ // Creates the `rebase` schema as its first statement, which also
314
+ // covers the schema Atlas puts its revision table in
315
+ // (`--revisions-schema rebase`). That used to need a separate,
316
+ // deliberately migration-stream-excluded statement here, because the
317
+ // helper functions lived in `auth` and creating `rebase` from the
318
+ // preamble would have made Atlas plan a DROP for it. The generator
319
+ // now always declares `rebase` in the desired schema, so there is
320
+ // nothing to keep out.
321
+ await client.query(RLS_BOOTSTRAP_SQL);
271
322
  } finally {
272
323
  await client.end();
273
324
  }
274
325
  } catch (err) {
275
- logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap auth schema and helper functions: ${err instanceof Error ? err.message : String(err)}`));
326
+ logger.warn(chalk.yellow(` ⚠️ Failed to bootstrap the RLS helper functions: ${err instanceof Error ? err.message : String(err)}`));
276
327
  }
277
328
  }
278
329
 
@@ -320,19 +371,82 @@ async function ensureAuthTables(databaseUrl: string, collectionsPath: string): P
320
371
  * connection would bypass RLS (superuser / BYPASSRLS / table owner).
321
372
  */
322
373
  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();
374
+ // Every failure in here used to escape uncaught, and `db migrate`'s caller
375
+ // turns that into a bare `process.exit(1)` — so the migration would apply,
376
+ // print `-- ok`, and then the command would die with NO output whatsoever
377
+ // and a non-zero status. Seen with a module-resolution error inside the
378
+ // dynamic import, where the entire diagnosis was one silent exit code.
379
+ //
380
+ // Reported rather than rethrown, and deliberately not fatal: the schema
381
+ // change has already landed at this point, so failing the command implies a
382
+ // rollback that did not happen. What is actually lost is the role
383
+ // provisioning, and the message says so and how to finish it by hand.
384
+ // No `auth`: the RLS helpers live in `rebase` now, and a schema the
385
+ // framework no longer creates must not be granted on — on a Supabase
386
+ // database that would hand the end-user role USAGE on their auth schema.
387
+ const schemas = ["public", "rebase"];
388
+ let rls: typeof import("./security/rls-enforcement");
389
+ try {
390
+ rls = await import("./security/rls-enforcement");
391
+ } catch (err) {
392
+ // A module-resolution failure here is a build problem, not a database
393
+ // one, and it is exactly the case that used to produce the silent exit.
394
+ logger.error(chalk.red(
395
+ `\n ✗ Could not load the RLS provisioning module: ${err instanceof Error ? err.message : String(err)}`
396
+ ));
397
+ return;
398
+ }
399
+
327
400
  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.`));
401
+ const { Client } = await import("pg");
402
+ const client = new Client({ connectionString: databaseUrl });
403
+ await client.connect();
404
+ try {
405
+ const runSql = async (text: string) => (await client.query(text)).rows as Record<string, unknown>[];
406
+ const posture = await rls.detectConnectionPosture(runSql);
407
+ if (posture.privileged) {
408
+ await rls.ensureAppRole(runSql, schemas);
409
+ logger.info(chalk.gray(` ✓ RLS role "${rls.REBASE_USER_ROLE}" provisioned/refreshed.`));
410
+ }
411
+ } finally {
412
+ await client.end();
333
413
  }
334
- } finally {
335
- await client.end();
414
+ } catch (err) {
415
+ logger.error(chalk.red(
416
+ `\n ✗ The schema change was applied, but the "${rls.REBASE_USER_ROLE}" role could not be ` +
417
+ `provisioned: ${err instanceof Error ? err.message : String(err)}`
418
+ ));
419
+ logger.error(chalk.gray(rls.appRoleSetupInstructions("your database user", schemas)));
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Retire the pre-1.0 `auth` schema, once nothing depends on it any more.
425
+ *
426
+ * Runs last on purpose. Postgres refuses to drop a function an RLS policy still
427
+ * calls, so this only succeeds after the policies above have been rewritten to
428
+ * `rebase.uid()`. See `dropLegacyAuthSchema` for what it reports when something
429
+ * hand-written is still holding the schema open, and DROP_LEGACY_AUTH_SCHEMA_SQL
430
+ * for the guards that keep it off a Supabase `auth` schema.
431
+ */
432
+ async function retireLegacyAuthSchema(databaseUrl: string): Promise<void> {
433
+ try {
434
+ const { Client } = await import("pg");
435
+ const client = new Client({ connectionString: databaseUrl });
436
+ await client.connect();
437
+ try {
438
+ await dropLegacyAuthSchema(
439
+ async (text) => (await client.query(text)).rows as Record<string, unknown>[],
440
+ {
441
+ info: (m) => logger.info(chalk.gray(` ${m}`)),
442
+ warn: (m) => logger.warn(chalk.yellow(` ⚠️ ${m}`))
443
+ }
444
+ );
445
+ } finally {
446
+ await client.end();
447
+ }
448
+ } catch {
449
+ // Connection-level failure only; the schema is inert either way.
336
450
  }
337
451
  }
338
452
 
@@ -599,15 +713,11 @@ function formatBytes(bytes: number): string {
599
713
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
600
714
  }
601
715
 
716
+ /** A branch's age, for "…created 6d ago". */
602
717
  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`;
718
+ // No horizon: a branch created a year ago should still report an age here,
719
+ // rather than fall back to a bare date in a one-line list entry.
720
+ return formatRelativeTime(date, { maxMs: Number.POSITIVE_INFINITY }) ?? "unknown";
611
721
  }
612
722
 
613
723
 
@@ -693,6 +803,9 @@ async function runAtlas(
693
803
 
694
804
  const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
695
805
  await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
806
+ if (collectionsPath) {
807
+ await seedDevDatabaseSearchHelpers(devDatabaseUrl, collectionsPath);
808
+ }
696
809
 
697
810
  // Atlas speaks libpq, which rejects the `sslmode=no-verify` that
698
811
  // node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
@@ -719,6 +832,16 @@ async function runAtlas(
719
832
  }
720
833
  }
721
834
 
835
+ // Search objects are Rebase's, not Atlas's — on both paths. `schema apply`
836
+ // would drop them as absent from the desired state; `migrate diff` would
837
+ // write that same drop into the next migration file, where it would sit
838
+ // waiting to be applied later.
839
+ if (collectionsPath && (args.includes("apply") || args.includes("diff"))) {
840
+ for (const exc of await getSearchExcludes(collectionsPath)) {
841
+ atlasArgs.push("--exclude", exc);
842
+ }
843
+ }
844
+
722
845
  if (domain === "schema" && args.includes("apply") && collectionsPath) {
723
846
  // Fail CLOSED: the exclude list is the only thing shielding
724
847
  // non-collection tables from the auto-approved apply. If we can't
@@ -828,7 +951,86 @@ async function generatePostgresDdlCommand(rawArgs: string[]): Promise<void> {
828
951
  }
829
952
  }
830
953
 
954
+ /**
955
+ * `schema stale [--fix]` — is the generated Drizzle schema older than the rule
956
+ * that derives its foreign-key names?
957
+ *
958
+ * This exists for one upgrade path and is worth the command. 0.13 derives
959
+ * `category_id` where 0.12 derived `categorie_id`; boot-ensure renames the
960
+ * database column to match, and the project's checked-in
961
+ * `backend/src/schema.generated.ts` is then wrong in a way nothing the developer
962
+ * did would explain. Relation validation refuses to boot on it, permanently,
963
+ * because the rename has already been applied and will not run again.
964
+ *
965
+ * `rebase dev` calls this with `--fix` before starting the backend, so the
966
+ * upgrade behaves the way the release note says it does: the column moves and the
967
+ * project keeps working. Without `--fix` it reports and exits non-zero, which is
968
+ * what a build or a CI step wants.
969
+ */
970
+ async function schemaStaleCommand(rawArgs: string[]): Promise<void> {
971
+ const argsList = arg(
972
+ {
973
+ "--collections": String,
974
+ "--output": String,
975
+ "--fix": Boolean,
976
+ "-c": "--collections",
977
+ "-o": "--output"
978
+ },
979
+ { argv: rawArgs.slice(2), permissive: true }
980
+ );
981
+
982
+ const collectionsPath = argsList["--collections"] || path.join("..", "config", "collections");
983
+ const outputPath = argsList["--output"] || path.join("src", "schema.generated.ts");
984
+ const schemaFile = path.resolve(process.cwd(), outputPath);
985
+
986
+ // No generated schema yet is not staleness — a fresh project has not run the
987
+ // generator, and saying "stale" about a file that does not exist would send
988
+ // the reader looking for something to fix.
989
+ if (!fs.existsSync(schemaFile)) return;
990
+
991
+ const { loadCollections } = await import("./schema/doctor");
992
+ const { findLegacyForeignKeyNames, describeLegacyForeignKeyNames } =
993
+ await import("./schema/generated-schema-staleness");
994
+
995
+ let stale;
996
+ try {
997
+ const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
998
+ stale = findLegacyForeignKeyNames(fs.readFileSync(schemaFile, "utf8"), collections);
999
+ } catch (err) {
1000
+ // Best-effort by design: a collections directory that will not load is a
1001
+ // real error, but it is one the boot reports far better than this does.
1002
+ logger.debug(`schema stale: skipped (${err instanceof Error ? err.message : String(err)})`);
1003
+ return;
1004
+ }
1005
+
1006
+ if (stale.length === 0) return;
1007
+
1008
+ logger.info("");
1009
+ logger.warn(chalk.yellow(
1010
+ ` ⚠️ ${outputPath} names ${stale.length} foreign key(s) the way an earlier release did:`
1011
+ ));
1012
+ logger.info(chalk.gray(describeLegacyForeignKeyNames(stale)));
1013
+ logger.info("");
1014
+
1015
+ if (!argsList["--fix"]) {
1016
+ logger.error(chalk.red(
1017
+ " The database column has already been renamed at boot, so the generated schema no " +
1018
+ "longer matches it and the server will refuse to start."
1019
+ ));
1020
+ logger.error(chalk.red(" Run `rebase schema generate` to regenerate it."));
1021
+ process.exit(1);
1022
+ }
1023
+
1024
+ logger.info(chalk.gray(" Regenerating the Drizzle schema so it matches..."));
1025
+ await schemaCommand("generate", ["schema", "generate", `--collections=${collectionsPath}`, `--output=${outputPath}`]);
1026
+ }
1027
+
831
1028
  async function schemaCommand(subcommand: string, rawArgs: string[]): Promise<void> {
1029
+ if (subcommand === "stale") {
1030
+ await schemaStaleCommand(rawArgs);
1031
+ return;
1032
+ }
1033
+
832
1034
  if (subcommand === "generate") {
833
1035
  const argsList = arg(
834
1036
  {