@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
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
|
|
2
|
-
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
3
|
-
import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
|
|
3
|
+
import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName, toPostgresIdentifier } from "@rebasepro/utils";
|
|
4
|
+
import { AUTH_USERS_COLUMNS, authUsersColumnDefinition, authUsersColumnSql, isAuthCollection } from "./auth-users-columns";
|
|
5
|
+
import {
|
|
6
|
+
buildSearchColumnSpec,
|
|
7
|
+
searchColumnDefinition,
|
|
8
|
+
fuzzyColumnDefinition,
|
|
9
|
+
searchIndexStatements,
|
|
10
|
+
searchHelperFunctions,
|
|
11
|
+
searchExtensionStatements,
|
|
12
|
+
searchColumnNames,
|
|
13
|
+
searchIndexNames,
|
|
14
|
+
type SearchColumnSpec
|
|
15
|
+
} from "./search-column";
|
|
16
|
+
import { REBASE_SCHEMA } from "@rebasepro/types";
|
|
4
17
|
|
|
5
18
|
// --- Helper Functions ---
|
|
6
19
|
|
|
@@ -235,15 +248,138 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
235
248
|
}
|
|
236
249
|
};
|
|
237
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Everything a `search` block needs, as a file Rebase applies itself.
|
|
253
|
+
*
|
|
254
|
+
* Search is the one part of the schema Atlas does not own. Two independent
|
|
255
|
+
* reasons, and either alone would be enough:
|
|
256
|
+
*
|
|
257
|
+
* 1. Its free tier refuses to *parse* a desired-state file that so much as
|
|
258
|
+
* contains a function — "functions and procedures are available to
|
|
259
|
+
* logged-in users only". A generated `tsvector` column cannot avoid one:
|
|
260
|
+
* `unaccent` is STABLE and jsonb flattening needs a set-returning function,
|
|
261
|
+
* so both have to be wrapped in an IMMUTABLE helper to be legal in a
|
|
262
|
+
* generated column at all.
|
|
263
|
+
* 2. Even with the file accepted, Atlas *wipes* the dev database it diffs
|
|
264
|
+
* against, so a helper seeded there beforehand is gone by the time the plan
|
|
265
|
+
* is analysed. There is no hook to reinstate it.
|
|
266
|
+
*
|
|
267
|
+
* So the column, its index and its helpers are excluded from Atlas's view
|
|
268
|
+
* (`searchExcludePatterns`) and applied from here — the same arrangement the
|
|
269
|
+
* RLS policies already use, and for the same underlying reason.
|
|
270
|
+
*
|
|
271
|
+
* Ordered as it must run: extensions, then helpers, then the column whose
|
|
272
|
+
* expression calls them, then the index over that column. Every statement is
|
|
273
|
+
* `IF NOT EXISTS` / `OR REPLACE`, because this is replayed on every push and
|
|
274
|
+
* appended to migrations that run against databases at any stage of their
|
|
275
|
+
* life. Empty when nothing opted in — the caller writes no file then.
|
|
276
|
+
*
|
|
277
|
+
* The leading half — extensions and helpers, without the table-shaped
|
|
278
|
+
* statements — is available on its own as {@link searchPrerequisiteStatements},
|
|
279
|
+
* for the dev database Atlas analyses plans against. That database has none of
|
|
280
|
+
* the project's tables, so it wants the functions and nothing else.
|
|
281
|
+
*/
|
|
282
|
+
export const searchPrerequisiteStatements = (allCollections: CollectionConfig[]): string[] => {
|
|
283
|
+
const specs = relationalCollections(allCollections)
|
|
284
|
+
.map(c => buildSearchColumnSpec(c))
|
|
285
|
+
.filter((s): s is SearchColumnSpec => s !== undefined);
|
|
286
|
+
if (specs.length === 0) return [];
|
|
287
|
+
return [
|
|
288
|
+
...new Set(specs.flatMap(searchExtensionStatements)),
|
|
289
|
+
...new Set(specs.flatMap(searchHelperFunctions))
|
|
290
|
+
];
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export const generatePostgresSearchDdl = (allCollections: CollectionConfig[]): string => {
|
|
294
|
+
const collections = relationalCollections(allCollections);
|
|
295
|
+
const specs = collections
|
|
296
|
+
.map(c => buildSearchColumnSpec(c))
|
|
297
|
+
.filter((s): s is SearchColumnSpec => s !== undefined);
|
|
298
|
+
if (specs.length === 0) return "";
|
|
299
|
+
|
|
300
|
+
const extensions = Array.from(new Set(specs.flatMap(searchExtensionStatements)));
|
|
301
|
+
const helpers = Array.from(new Set(specs.flatMap(searchHelperFunctions)));
|
|
302
|
+
|
|
303
|
+
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
|
|
304
|
+
ddl += "--\n";
|
|
305
|
+
ddl += "-- Full-text search for the collections declaring a `search` block.\n";
|
|
306
|
+
ddl += "-- Applied by Rebase, not by Atlas — see generatePostgresSearchDdl.\n\n";
|
|
307
|
+
|
|
308
|
+
extensions.forEach(s => { ddl += `${s}\n`; });
|
|
309
|
+
if (extensions.length > 0) ddl += "\n";
|
|
310
|
+
helpers.forEach(s => { ddl += `${s}\n\n`; });
|
|
311
|
+
|
|
312
|
+
for (const collection of collections) {
|
|
313
|
+
const spec = buildSearchColumnSpec(collection);
|
|
314
|
+
if (!spec) continue;
|
|
315
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
316
|
+
const table = `"${schema}"."${getTableName(collection)}"`;
|
|
317
|
+
|
|
318
|
+
// ADD COLUMN IF NOT EXISTS rather than the inline definition the
|
|
319
|
+
// CREATE TABLE would use: by the time this runs the table exists,
|
|
320
|
+
// whether Atlas just created it or it has been live for a year.
|
|
321
|
+
ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
|
|
322
|
+
const fuzzyDef = fuzzyColumnDefinition(spec);
|
|
323
|
+
if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
|
|
324
|
+
searchIndexStatements(spec).forEach(s => { ddl += `${s}\n`; });
|
|
325
|
+
ddl += "\n";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return ddl;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Glob patterns telling Atlas to leave the search column and its index alone.
|
|
333
|
+
*
|
|
334
|
+
* Without these, a desired state that omits search reads to Atlas as an
|
|
335
|
+
* instruction to drop the column — taking the index and the whole search
|
|
336
|
+
* feature with it on the next push.
|
|
337
|
+
*
|
|
338
|
+
* Fully qualified, `schema.table.object`, matching the include list. The
|
|
339
|
+
* two-part form is what Atlas wants when the connection URL scopes it to one
|
|
340
|
+
* schema and is *silently ignored* otherwise: it reads `posts.search_vector`
|
|
341
|
+
* as a table named `search_vector` in a schema named `posts`, matches nothing,
|
|
342
|
+
* and reports no error for the pattern that never fired.
|
|
343
|
+
*/
|
|
344
|
+
export const searchExcludePatterns = (allCollections: CollectionConfig[]): string[] => {
|
|
345
|
+
const patterns: string[] = [];
|
|
346
|
+
for (const collection of relationalCollections(allCollections)) {
|
|
347
|
+
const spec = buildSearchColumnSpec(collection);
|
|
348
|
+
if (!spec) continue;
|
|
349
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
350
|
+
const table = getTableName(collection);
|
|
351
|
+
for (const name of searchColumnNames(collection)) {
|
|
352
|
+
patterns.push(`${schema}.${table}.${name}`);
|
|
353
|
+
}
|
|
354
|
+
for (const index of searchIndexNames(spec)) {
|
|
355
|
+
patterns.push(`${schema}.${table}.${index}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return patterns;
|
|
359
|
+
};
|
|
360
|
+
|
|
238
361
|
export const generatePostgresDdl = async (
|
|
239
|
-
|
|
240
|
-
options: { includePolicies?: boolean } = {
|
|
362
|
+
allCollections: CollectionConfig[],
|
|
363
|
+
options: { includePolicies?: boolean; includeSearch?: boolean } = {
|
|
364
|
+
includePolicies: true,
|
|
365
|
+
includeSearch: true
|
|
366
|
+
}
|
|
241
367
|
): Promise<string> => {
|
|
368
|
+
// Only the collections this engine stores. See `relationalCollections`.
|
|
369
|
+
const collections = relationalCollections(allCollections);
|
|
242
370
|
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
243
371
|
|
|
244
|
-
// 1. Create custom schemas
|
|
372
|
+
// 1. Create custom schemas.
|
|
373
|
+
//
|
|
374
|
+
// `rebase` is unconditional and load-bearing. The RLS helper functions live
|
|
375
|
+
// in it, so the migration preamble creates it — which puts it in Atlas's
|
|
376
|
+
// replayed state, and anything in that state but absent from this desired
|
|
377
|
+
// schema gets a `DROP SCHEMA … CASCADE` planned against it. That would take
|
|
378
|
+
// the auth tables. It used to appear here only when some collection
|
|
379
|
+
// happened to declare `schema: "rebase"`; the scaffold's users collection
|
|
380
|
+
// does, which is why nobody hit it, but nothing guaranteed it.
|
|
245
381
|
const uniqueSchemas = Array.from(new Set([
|
|
246
|
-
|
|
382
|
+
REBASE_SCHEMA,
|
|
247
383
|
...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
|
|
248
384
|
]));
|
|
249
385
|
uniqueSchemas.forEach(schema => {
|
|
@@ -251,6 +387,32 @@ export const generatePostgresDdl = async (
|
|
|
251
387
|
});
|
|
252
388
|
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
253
389
|
|
|
390
|
+
// 1b. Search support, for collections that opted in.
|
|
391
|
+
//
|
|
392
|
+
// Extensions and helper functions come before every CREATE TABLE because a
|
|
393
|
+
// generated column's expression is resolved at creation time: a table whose
|
|
394
|
+
// search column calls `rebase_search_text` cannot be created before that
|
|
395
|
+
// function exists. Both are `IF NOT EXISTS` / `OR REPLACE`, so a file
|
|
396
|
+
// replayed against a live database is a no-op here.
|
|
397
|
+
const searchSpecs = collections
|
|
398
|
+
.map(c => buildSearchColumnSpec(c))
|
|
399
|
+
.filter((s): s is SearchColumnSpec => s !== undefined);
|
|
400
|
+
|
|
401
|
+
if (searchSpecs.length > 0) {
|
|
402
|
+
if (options.includeSearch === false) {
|
|
403
|
+
// Everything search-related lives in `search.sql` — see
|
|
404
|
+
// `generatePostgresSearchDdl` for why Atlas is not shown any of it.
|
|
405
|
+
ddl += "-- Full-text search support lives in `search.sql`, applied separately.\n\n";
|
|
406
|
+
} else {
|
|
407
|
+
const extensions = Array.from(new Set(searchSpecs.flatMap(searchExtensionStatements)));
|
|
408
|
+
const helpers = Array.from(new Set(searchSpecs.flatMap(searchHelperFunctions)));
|
|
409
|
+
ddl += "-- Full-text search support (collections declaring a `search` block)\n";
|
|
410
|
+
extensions.forEach(s => { ddl += `${s}\n`; });
|
|
411
|
+
if (extensions.length > 0) ddl += "\n";
|
|
412
|
+
helpers.forEach(s => { ddl += `${s}\n\n`; });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
254
416
|
// 2. Generate Enums
|
|
255
417
|
//
|
|
256
418
|
// The enum type name is derived from table + column, so two collections
|
|
@@ -318,6 +480,9 @@ export const generatePostgresDdl = async (
|
|
|
318
480
|
|
|
319
481
|
// 3. Generate tables
|
|
320
482
|
const fkStatements: string[] = [];
|
|
483
|
+
// Indexes follow the tables for the same reason the FK constraints do: the
|
|
484
|
+
// table has to exist first.
|
|
485
|
+
const indexStatements: string[] = [];
|
|
321
486
|
// Policies are emitted after every CREATE TABLE, like the FK constraints:
|
|
322
487
|
// a policy may reference other tables (a junction's derived policies always
|
|
323
488
|
// reference both endpoints; `policy.existsIn` references a join table), and
|
|
@@ -355,8 +520,8 @@ export const generatePostgresDdl = async (
|
|
|
355
520
|
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
356
521
|
ddl += `);\n\n`;
|
|
357
522
|
|
|
358
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
359
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
523
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
524
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
360
525
|
|
|
361
526
|
if (options.includePolicies) {
|
|
362
527
|
// Junction tables are generated tables like any other: locked by
|
|
@@ -414,7 +579,7 @@ export const generatePostgresDdl = async (
|
|
|
414
579
|
if (required) colDef += " NOT NULL";
|
|
415
580
|
columns.push(colDef);
|
|
416
581
|
|
|
417
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${relInfo.localKey}_fkey" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
|
|
582
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${relInfo.localKey}_fkey`)}" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
|
|
418
583
|
} else if (prop.type === "reference") {
|
|
419
584
|
const refProp = prop as ReferenceProperty;
|
|
420
585
|
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
@@ -436,10 +601,27 @@ export const generatePostgresDdl = async (
|
|
|
436
601
|
if (required) colDef += " NOT NULL";
|
|
437
602
|
columns.push(colDef);
|
|
438
603
|
|
|
439
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
604
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
440
605
|
}
|
|
441
606
|
} else {
|
|
442
607
|
const colName = resolveColumnName(propName, prop);
|
|
608
|
+
|
|
609
|
+
// On an auth collection, the columns auth reads and writes
|
|
610
|
+
// are defined once, in `auth-users-columns`, and every
|
|
611
|
+
// creator of this table emits that definition. Before this,
|
|
612
|
+
// the desired state here was built from the collection file
|
|
613
|
+
// alone — which knows nothing of `is_anonymous` or
|
|
614
|
+
// `tokens_valid_after` — so `db push` and the runtime
|
|
615
|
+
// disagreed about `email`'s nullability and about which
|
|
616
|
+
// columns exist at all.
|
|
617
|
+
const authDefinition = isAuthCollection(collection)
|
|
618
|
+
? authUsersColumnDefinition(colName)
|
|
619
|
+
: undefined;
|
|
620
|
+
if (authDefinition && !isIdProperty(propName, prop, collection)) {
|
|
621
|
+
columns.push(` "${colName}" ${authDefinition}`);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
443
625
|
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
444
626
|
let colDef = ` "${colName}" ${colType}`;
|
|
445
627
|
|
|
@@ -476,6 +658,40 @@ export const generatePostgresDdl = async (
|
|
|
476
658
|
}
|
|
477
659
|
});
|
|
478
660
|
|
|
661
|
+
// ── Auth columns the collection file never mentions ──────────────
|
|
662
|
+
// `db push` is declarative: Atlas diffs the database against exactly
|
|
663
|
+
// what is emitted here and drops anything else. The scaffold's users
|
|
664
|
+
// collection describes 12 columns while auth needs 14, so
|
|
665
|
+
// `is_anonymous` and `tokens_valid_after` — created at boot by
|
|
666
|
+
// `ensureAuthTablesExist` — read as unmanaged drift, and a push run
|
|
667
|
+
// after the server had started once planned to DROP them. The
|
|
668
|
+
// destructive gate catches it, which makes it a data-loss prompt on
|
|
669
|
+
// the auth table rather than silent damage; either way the desired
|
|
670
|
+
// state was wrong. Emit the full contract so the two agree.
|
|
671
|
+
if (isAuthCollection(collection)) {
|
|
672
|
+
const declared = new Set(
|
|
673
|
+
Object.entries(collection.properties ?? {})
|
|
674
|
+
.map(([name, prop]) => resolveColumnName(name, prop))
|
|
675
|
+
);
|
|
676
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
677
|
+
if (declared.has(spec.column)) continue;
|
|
678
|
+
columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── The opt-in search column ─────────────────────────────────────
|
|
683
|
+
// Last, so it reads as what it is: derived from the columns above
|
|
684
|
+
// it. Postgres recomputes it on every write of a source column and
|
|
685
|
+
// rejects any attempt to write it directly, which is the property
|
|
686
|
+
// that makes it impossible for the index to drift from the row.
|
|
687
|
+
const searchSpec = options.includeSearch === false ? undefined : buildSearchColumnSpec(collection);
|
|
688
|
+
if (searchSpec) {
|
|
689
|
+
columns.push(` ${searchColumnDefinition(searchSpec)}`);
|
|
690
|
+
const fuzzyDef = fuzzyColumnDefinition(searchSpec);
|
|
691
|
+
if (fuzzyDef) columns.push(` ${fuzzyDef}`);
|
|
692
|
+
indexStatements.push(...searchIndexStatements(searchSpec));
|
|
693
|
+
}
|
|
694
|
+
|
|
479
695
|
// Backwards compatibility: add default id primary key if missing
|
|
480
696
|
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
481
697
|
if (!hasPk) {
|
|
@@ -509,6 +725,11 @@ export const generatePostgresDdl = async (
|
|
|
509
725
|
ddl += fkStatements.join("\n") + "\n\n";
|
|
510
726
|
}
|
|
511
727
|
|
|
728
|
+
if (indexStatements.length > 0) {
|
|
729
|
+
ddl += "-- Indexes\n";
|
|
730
|
+
ddl += indexStatements.join("\n") + "\n\n";
|
|
731
|
+
}
|
|
732
|
+
|
|
512
733
|
if (policyStatements.length > 0) {
|
|
513
734
|
ddl += "-- Row Level Security Policies\n";
|
|
514
735
|
ddl += policyStatements.join("");
|
|
@@ -575,10 +796,31 @@ const schemaOfCollection = (collection: CollectionConfig): string =>
|
|
|
575
796
|
|
|
576
797
|
const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
|
|
577
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
|
|
801
|
+
*
|
|
802
|
+
* This is not cosmetic, and not really a naming choice at all — it is agreeing
|
|
803
|
+
* with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
|
|
804
|
+
* succeeds and records the truncated form, so the untruncated name this used to
|
|
805
|
+
* derive matched nothing in the catalogue. Boot-ensure compares its planned
|
|
806
|
+
* constraints against `readExistingSchema`, which reads catalogue names, so the
|
|
807
|
+
* comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
|
|
808
|
+
* constraint, forever, and got "already exists" every time. Non-fatal (foreign
|
|
809
|
+
* keys are the one action allowed to fail) and therefore permanent — an error in
|
|
810
|
+
* the log on every restart of a project whose table and column names happened to
|
|
811
|
+
* be long.
|
|
812
|
+
*
|
|
813
|
+
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
814
|
+
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
815
|
+
*/
|
|
816
|
+
// Moved to `@rebasepro/utils` so the search-column builder derives index names
|
|
817
|
+
// under the same 63-byte rule this file derives constraint names under. Two
|
|
818
|
+
// copies of a truncation rule is two rules the moment one of them is edited.
|
|
819
|
+
|
|
578
820
|
const foreignKeyPlan = (
|
|
579
821
|
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
|
|
580
822
|
): ForeignKeyPlan => {
|
|
581
|
-
const constraintName = `${args.table}_${args.column}_fkey
|
|
823
|
+
const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
|
|
582
824
|
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
583
825
|
return {
|
|
584
826
|
constraintName,
|
|
@@ -610,7 +852,8 @@ const foreignKeyPlan = (
|
|
|
610
852
|
* is unknown yields the column without a constraint. Both mirror the generator
|
|
611
853
|
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
612
854
|
*/
|
|
613
|
-
export const planRelationalColumns = (
|
|
855
|
+
export const planRelationalColumns = (allCollections: CollectionConfig[]): RelationalColumnPlan[] => {
|
|
856
|
+
const collections = relationalCollections(allCollections);
|
|
614
857
|
const plans: RelationalColumnPlan[] = [];
|
|
615
858
|
|
|
616
859
|
for (const collection of collections) {
|
|
@@ -703,7 +946,8 @@ export const planRelationalColumns = (collections: CollectionConfig[]): Relation
|
|
|
703
946
|
* junction with row-level security left off is readable and writable by every
|
|
704
947
|
* signed-in user, which is why the two must ship together.
|
|
705
948
|
*/
|
|
706
|
-
export const planJunctionTables = (
|
|
949
|
+
export const planJunctionTables = (allCollections: CollectionConfig[]): JunctionTablePlan[] => {
|
|
950
|
+
const collections = relationalCollections(allCollections);
|
|
707
951
|
const plans: JunctionTablePlan[] = [];
|
|
708
952
|
|
|
709
953
|
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
@@ -780,7 +1024,10 @@ export interface CollectionPolicyPlan {
|
|
|
780
1024
|
* writable by every signed-in user. A junction whose table is still absent is
|
|
781
1025
|
* skipped by the applier, not planned away here.
|
|
782
1026
|
*/
|
|
783
|
-
export const planCollectionPolicies = (
|
|
1027
|
+
export const planCollectionPolicies = (allCollections: CollectionConfig[]): CollectionPolicyPlan[] => {
|
|
1028
|
+
// A store with no RLS gets no policies planned for it — `supportsRLS` is
|
|
1029
|
+
// false for exactly the engines `relationalCollections` filters out.
|
|
1030
|
+
const collections = relationalCollections(allCollections);
|
|
784
1031
|
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
785
1032
|
const plans: CollectionPolicyPlan[] = [];
|
|
786
1033
|
const seen = new Set<string>();
|
|
@@ -833,7 +1080,11 @@ export const planCollectionPolicies = (collections: CollectionConfig[]): Collect
|
|
|
833
1080
|
return plans;
|
|
834
1081
|
};
|
|
835
1082
|
|
|
836
|
-
export const generatePostgresPoliciesDdl = (
|
|
1083
|
+
export const generatePostgresPoliciesDdl = (allCollections: CollectionConfig[]): string => {
|
|
1084
|
+
// Also the expectation `checkPolicyDrift` reconciles the database against,
|
|
1085
|
+
// so a non-SQL collection filtered out here is one the drift report cannot
|
|
1086
|
+
// invent a missing policy for.
|
|
1087
|
+
const collections = relationalCollections(allCollections);
|
|
837
1088
|
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
838
1089
|
|
|
839
1090
|
const allTablesToGenerate = new Map<string, {
|
|
@@ -3,7 +3,11 @@ import * as fs from "fs";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
5
|
import chokidar from "chokidar";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
generatePostgresDdl,
|
|
8
|
+
generatePostgresPoliciesDdl,
|
|
9
|
+
generatePostgresSearchDdl
|
|
10
|
+
} from "./generate-postgres-ddl-logic";
|
|
7
11
|
import { CollectionConfig } from "@rebasepro/types";
|
|
8
12
|
import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
9
13
|
|
|
@@ -29,8 +33,16 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
29
33
|
// Sort collections by slug alphabetically to ensure deterministic DDL generation
|
|
30
34
|
collections.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
// `schema.sql` is Atlas's desired state, and it carries neither the RLS
|
|
37
|
+
// policies nor the search apparatus: Atlas manages neither, and in the
|
|
38
|
+
// search case cannot. Both are written beside it and applied by the CLI
|
|
39
|
+
// in their own right.
|
|
40
|
+
const ddlContent = await generatePostgresDdl(collections, {
|
|
41
|
+
includePolicies: false,
|
|
42
|
+
includeSearch: false
|
|
43
|
+
});
|
|
33
44
|
const policiesContent = generatePostgresPoliciesDdl(collections);
|
|
45
|
+
const searchContent = generatePostgresSearchDdl(collections);
|
|
34
46
|
|
|
35
47
|
if (outputPath) {
|
|
36
48
|
const outputDir = path.dirname(outputPath);
|
|
@@ -41,6 +53,17 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
41
53
|
const policiesPath = path.join(outputDir, "policies.sql");
|
|
42
54
|
await fsPromises.writeFile(policiesPath, policiesContent);
|
|
43
55
|
logger.info(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
|
|
56
|
+
|
|
57
|
+
// Removed when the last `search` block goes: the CLI applies this
|
|
58
|
+
// file whenever it exists, and a stale one would keep re-creating
|
|
59
|
+
// functions for collections that no longer want them.
|
|
60
|
+
const searchPath = path.join(outputDir, "search.sql");
|
|
61
|
+
if (searchContent) {
|
|
62
|
+
await fsPromises.writeFile(searchPath, searchContent);
|
|
63
|
+
logger.info(`✅ PostgreSQL search DDL generated successfully at ${searchPath}`);
|
|
64
|
+
} else if (fs.existsSync(searchPath)) {
|
|
65
|
+
await fsPromises.rm(searchPath);
|
|
66
|
+
}
|
|
44
67
|
} else {
|
|
45
68
|
logger.info("✅ PostgreSQL DDL generated successfully.");
|
|
46
69
|
logger.info(String(ddlContent));
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
+
import { getTableName, resolveCollectionRelations, relationalCollections } from "@rebasepro/common";
|
|
3
|
+
import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Notice a generated Drizzle schema that a *library upgrade* invalidated.
|
|
7
|
+
*
|
|
8
|
+
* `rebase dev` already watches `config/collections` and warns when a collection
|
|
9
|
+
* file changes. That covers drift the developer caused. It cannot cover this one,
|
|
10
|
+
* because nothing the developer owns changed: 0.13 derives `category_id` where
|
|
11
|
+
* 0.12 derived `categorie_id`, from the same unedited collection. The watcher
|
|
12
|
+
* never fires, and `backend/src/schema.generated.ts` quietly stops describing the
|
|
13
|
+
* schema the runtime expects.
|
|
14
|
+
*
|
|
15
|
+
* The consequence is not cosmetic. Boot-ensure renames the column in the
|
|
16
|
+
* database, then relation validation reads the stale module and refuses to
|
|
17
|
+
* start — on that boot and every boot after it, because the rename is already
|
|
18
|
+
* applied and will not be attempted again.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately narrow: this answers "does the generated schema name a foreign key
|
|
21
|
+
* the way the previous rule did", not "is this file what we would generate now".
|
|
22
|
+
* The wide question would report every whitespace change in the generator as a
|
|
23
|
+
* fatal staleness, and a check that cries wolf gets switched off.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** One column the generated schema names under the pre-0.13 rule. */
|
|
27
|
+
export interface LegacyForeignKeyName {
|
|
28
|
+
/** Table whose column declaration is stale. */
|
|
29
|
+
table: string;
|
|
30
|
+
/** The name the generated schema declares. */
|
|
31
|
+
legacy: string;
|
|
32
|
+
/** The name this release derives, and which the database now carries. */
|
|
33
|
+
current: string;
|
|
34
|
+
/** `<collection>.<relation>` that derives it, for the message. */
|
|
35
|
+
relation: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The slice of the generated source declaring one table.
|
|
40
|
+
*
|
|
41
|
+
* Scoping matters: two junctions in one file can carry columns of the same name,
|
|
42
|
+
* and a whole-file match would attribute a stale column to whichever table the
|
|
43
|
+
* reader looks at first. Returns "" when the table is not in the file at all,
|
|
44
|
+
* which is not staleness — it is a table the generator has not been asked about.
|
|
45
|
+
*/
|
|
46
|
+
function tableBlock(source: string, table: string): string {
|
|
47
|
+
const start = source.indexOf(`pgTable("${table}"`);
|
|
48
|
+
if (start === -1) return "";
|
|
49
|
+
// Generated files put every table in its own `export const`, so the next one
|
|
50
|
+
// is the end of this block. No brace counting, nothing to get wrong.
|
|
51
|
+
const next = source.indexOf("\nexport ", start);
|
|
52
|
+
return next === -1 ? source.slice(start) : source.slice(start, next);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a block *declares* the column, rather than merely mentioning it.
|
|
57
|
+
*
|
|
58
|
+
* A comment explaining the rename, or a policy expression naming the old column,
|
|
59
|
+
* must not read as a declaration — otherwise regenerating the file would not
|
|
60
|
+
* clear the finding and the check would be permanently red.
|
|
61
|
+
*/
|
|
62
|
+
function declaresColumn(block: string, column: string): boolean {
|
|
63
|
+
// `categorie_id: integer("categorie_id")` — the Drizzle column shape. The key
|
|
64
|
+
// and the string argument agree in generated output, so requiring both is
|
|
65
|
+
// both precise and cheap.
|
|
66
|
+
const key = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
67
|
+
return new RegExp(`(^|[\\s,{])${key}\\s*:\\s*\\w+\\(\\s*["']${key}["']`, "m").test(block);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param generatedSource contents of `backend/src/schema.generated.ts`
|
|
72
|
+
* @param collections the project's collections, as this release reads them
|
|
73
|
+
*/
|
|
74
|
+
export function findLegacyForeignKeyNames(
|
|
75
|
+
generatedSource: string,
|
|
76
|
+
collections: CollectionConfig[]
|
|
77
|
+
): LegacyForeignKeyName[] {
|
|
78
|
+
const found: LegacyForeignKeyName[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Report `wanted` as stale when the generated schema declares what the
|
|
83
|
+
* previous rule would have derived from `source` instead.
|
|
84
|
+
*
|
|
85
|
+
* The `wanted !== current` guard is what honours an explicitly named column.
|
|
86
|
+
* An author who pinned `categorie_id` has said so on the relation, so the
|
|
87
|
+
* generated file agreeing with them is correct, not stale — and the rename
|
|
88
|
+
* note documents exactly that opt-out.
|
|
89
|
+
*/
|
|
90
|
+
const consider = (
|
|
91
|
+
table: string,
|
|
92
|
+
wanted: string,
|
|
93
|
+
sourceName: string | undefined,
|
|
94
|
+
relation: string
|
|
95
|
+
): void => {
|
|
96
|
+
if (!table || !wanted || !sourceName) return;
|
|
97
|
+
|
|
98
|
+
const current = generateForeignKeyName(sourceName);
|
|
99
|
+
const legacy = legacyForeignKeyName(sourceName);
|
|
100
|
+
if (legacy === current) return; // this name never moved
|
|
101
|
+
if (wanted !== current) return; // pinned by the author, or unrelated
|
|
102
|
+
|
|
103
|
+
const block = tableBlock(generatedSource, table);
|
|
104
|
+
if (!block) return;
|
|
105
|
+
if (!declaresColumn(block, legacy)) return;
|
|
106
|
+
if (declaresColumn(block, current)) return; // already regenerated
|
|
107
|
+
|
|
108
|
+
const key = `${table}.${legacy}`;
|
|
109
|
+
if (seen.has(key)) return;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
found.push({ table, legacy, current, relation });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Same rule as the generator whose output this reads: a collection that
|
|
115
|
+
// gets no table cannot have named a column in it. Inert in practice today —
|
|
116
|
+
// the finding also requires the generated file to declare the table — but
|
|
117
|
+
// the guard is what keeps that true when a non-SQL collection's slug happens
|
|
118
|
+
// to match a SQL one's table.
|
|
119
|
+
for (const collection of relationalCollections(collections)) {
|
|
120
|
+
const sourceTable = getTableName(collection);
|
|
121
|
+
|
|
122
|
+
for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
123
|
+
const at = `${collection.slug}.${name}`;
|
|
124
|
+
|
|
125
|
+
let target: CollectionConfig | undefined;
|
|
126
|
+
try {
|
|
127
|
+
target = relation.target?.();
|
|
128
|
+
} catch {
|
|
129
|
+
// A throwing target thunk is its own defect, reported at boot by
|
|
130
|
+
// `validate-relations`. Nothing to say about its column names.
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
switch (relation.kind) {
|
|
135
|
+
case "belongsTo":
|
|
136
|
+
consider(sourceTable, relation.localKey, relation.relationName, at);
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case "hasOne":
|
|
140
|
+
case "hasMany":
|
|
141
|
+
if (target) {
|
|
142
|
+
consider(getTableName(target), relation.foreignKeyOnTarget, collection.slug, at);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
case "manyToMany":
|
|
147
|
+
consider(relation.through.table, relation.through.sourceColumn, collection.slug, at);
|
|
148
|
+
if (target) {
|
|
149
|
+
consider(relation.through.table, relation.through.targetColumn, target.slug, at);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
default:
|
|
154
|
+
// `via` joins are written by hand — there is no derived name
|
|
155
|
+
// for the rule change to have moved.
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One-line summary for a log or a CLI notice. */
|
|
165
|
+
export function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): string {
|
|
166
|
+
return found
|
|
167
|
+
.map(f => ` • ${f.table}.${f.legacy} → ${f.current} (${f.relation})`)
|
|
168
|
+
.join("\n");
|
|
169
|
+
}
|
|
@@ -1089,7 +1089,7 @@ export function generateCollectionFile(
|
|
|
1089
1089
|
|
|
1090
1090
|
if (derivedFacts) {
|
|
1091
1091
|
const titleProperty = deriveTitleProperty(derivedFacts);
|
|
1092
|
-
if (titleProperty) adminEntries.push(`
|
|
1092
|
+
if (titleProperty) adminEntries.push(`display: { title: ${quote(titleProperty)} }`);
|
|
1093
1093
|
|
|
1094
1094
|
const kanbanProperty = deriveKanbanProperty(derivedFacts);
|
|
1095
1095
|
if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(kanbanProperty)}\n }`);
|