@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.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
- package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
- package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
- package/dist/index.es.js +765 -223
- package/dist/index.es.js.map +1 -1
- package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-tables.d.ts +2 -2
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +199 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +25 -7
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/realtimeService.d.ts +27 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +165 -3
- package/src/PostgresBootstrapper.ts +41 -2
- package/src/auth/ensure-tables.ts +185 -86
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +31 -3
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-tables.ts +165 -20
- package/src/schema/generate-drizzle-schema-logic.ts +33 -3
- package/src/schema/generate-postgres-ddl-logic.ts +266 -15
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +1 -1
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +558 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +148 -108
- package/src/services/PersistService.ts +14 -1
- package/src/services/RelationService.ts +2 -1
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/dataService.ts +3 -0
- package/src/services/realtimeService.ts +46 -37
- package/src/utils/drizzle-conditions.ts +223 -2
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
package/src/cli-helpers.ts
CHANGED
|
@@ -86,10 +86,16 @@ export function resolveLocalBin(binName: string): string | null {
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
export async function getTableIncludesFromCollections(
|
|
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
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
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 {
|
|
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
|
|
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 `
|
|
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" +
|
|
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
|
-
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
}
|
|
335
|
-
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
{
|