@rebasepro/server-postgres 0.13.1-canary.g18cfeb7 → 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/{auth-users-columns-Do9mw5Y5.js → auth-users-columns-Dt9g712t.js} +405 -7
- package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/{ensure-collection-policies-DMUdRQdM.js → ensure-collection-policies-CwYUliAa.js} +2 -2
- package/dist/{ensure-collection-policies-DMUdRQdM.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
- package/dist/{ensure-collection-tables-DSIxvLLD.js → ensure-collection-tables-DRxaUG96.js} +34 -7
- package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
- package/dist/index.es.js +234 -26
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/search-column.d.ts +199 -0
- package/dist/services/FetchService.d.ts +15 -0
- package/dist/services/dataService.d.ts +1 -0
- package/dist/services/realtimeService.d.ts +2 -0
- package/dist/src-C_wvdMnl.js.map +1 -1
- package/dist/utils/drizzle-conditions.d.ts +71 -2
- package/dist/websocket-D0TBU3ia.js.map +1 -1
- package/package.json +6 -6
- package/src/cli-helpers.ts +107 -1
- package/src/cli.ts +57 -0
- package/src/data-transformer.ts +18 -0
- package/src/schema/ensure-collection-tables.ts +77 -1
- package/src/schema/generate-drizzle-schema-logic.ts +25 -1
- package/src/schema/generate-postgres-ddl-logic.ts +176 -9
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/search-column.ts +558 -0
- package/src/services/FetchService.ts +138 -15
- package/src/services/RelationService.ts +2 -1
- package/src/services/dataService.ts +1 -0
- package/src/services/realtimeService.ts +10 -4
- package/src/utils/drizzle-conditions.ts +223 -2
- package/dist/auth-users-columns-Do9mw5Y5.js.map +0 -1
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +0 -1
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
getTableIncludes,
|
|
13
13
|
getDevDatabaseUrl,
|
|
14
14
|
ensureDevDatabaseExists,
|
|
15
|
+
applySearchDdl,
|
|
16
|
+
getSearchExcludes,
|
|
17
|
+
readSearchDdl,
|
|
18
|
+
seedDevDatabaseSearchHelpers,
|
|
15
19
|
getTableExcludes,
|
|
16
20
|
ExcludeIntrospectionError,
|
|
17
21
|
promptConfirm
|
|
@@ -70,6 +74,13 @@ export async function runPluginCommand(args: string[]) {
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
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
|
+
|
|
73
84
|
async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
74
85
|
const VALID_ACTIONS = ["push", "generate", "migrate", "branch", "backup", "restore", "backups"];
|
|
75
86
|
if (!subcommand || !VALID_ACTIONS.includes(subcommand)) {
|
|
@@ -116,7 +127,9 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
116
127
|
logger.info(chalk.gray(" Step 2/2: Generating SQL migration files with Atlas..."));
|
|
117
128
|
logger.info("");
|
|
118
129
|
const migrationName = argsList._[0] || "migration";
|
|
130
|
+
const migrationsBefore = listMigrationFiles();
|
|
119
131
|
await runAtlas("migrate", ["diff", migrationName, "--dir", "file://drizzle/migrations", "--to", "file://drizzle/schema.sql"], collectionsPath);
|
|
132
|
+
const wroteNewMigration = listMigrationFiles().length > migrationsBefore.length;
|
|
120
133
|
|
|
121
134
|
// Post-process the newest migration file
|
|
122
135
|
try {
|
|
@@ -137,6 +150,33 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
137
150
|
/CREATE SCHEMA (?!IF NOT EXISTS)("[^"]+");/g,
|
|
138
151
|
"CREATE SCHEMA IF NOT EXISTS $1;"
|
|
139
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
|
+
|
|
140
180
|
fs.writeFileSync(newestMigrationFile, migrationContent, "utf-8");
|
|
141
181
|
|
|
142
182
|
// Append RLS policies, preceded by the RLS bootstrap so the
|
|
@@ -234,6 +274,10 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
|
|
|
234
274
|
|
|
235
275
|
if (databaseUrl) {
|
|
236
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);
|
|
237
281
|
await applyPolicies(databaseUrl);
|
|
238
282
|
await reconcilePolicies(databaseUrl, collectionsPath);
|
|
239
283
|
await ensureRlsUserRole(databaseUrl);
|
|
@@ -759,6 +803,9 @@ async function runAtlas(
|
|
|
759
803
|
|
|
760
804
|
const devDatabaseUrl = getDevDatabaseUrl(databaseUrl);
|
|
761
805
|
await ensureDevDatabaseExists(databaseUrl, devDatabaseUrl);
|
|
806
|
+
if (collectionsPath) {
|
|
807
|
+
await seedDevDatabaseSearchHelpers(devDatabaseUrl, collectionsPath);
|
|
808
|
+
}
|
|
762
809
|
|
|
763
810
|
// Atlas speaks libpq, which rejects the `sslmode=no-verify` that
|
|
764
811
|
// node-postgres accepts — see `forLibpq`. Rewritten only for the argv, so
|
|
@@ -785,6 +832,16 @@ async function runAtlas(
|
|
|
785
832
|
}
|
|
786
833
|
}
|
|
787
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
|
+
|
|
788
845
|
if (domain === "schema" && args.includes("apply") && collectionsPath) {
|
|
789
846
|
// Fail CLOSED: the exclude list is the only thing shielding
|
|
790
847
|
// non-collection tables from the auto-approved apply. If we can't
|
package/src/data-transformer.ts
CHANGED
|
@@ -685,6 +685,15 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
685
685
|
* from the result (used by `normalizeDbValues` where
|
|
686
686
|
* Drizzle's relational API already hydrates them).
|
|
687
687
|
*/
|
|
688
|
+
/**
|
|
689
|
+
* Keys a query computes and attaches to a row, rather than reads from a column.
|
|
690
|
+
*
|
|
691
|
+
* An explicit set rather than a `_` prefix rule: a user's column may perfectly
|
|
692
|
+
* well be called `_internal`, and passing it through here would put a value on
|
|
693
|
+
* the row that no property describes and nothing downstream knows how to type.
|
|
694
|
+
*/
|
|
695
|
+
const QUERY_METADATA_KEYS = new Set(["_score", "_distance", "_matches"]);
|
|
696
|
+
|
|
688
697
|
function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
689
698
|
data: M,
|
|
690
699
|
properties: Properties,
|
|
@@ -709,6 +718,15 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
|
709
718
|
continue;
|
|
710
719
|
}
|
|
711
720
|
|
|
721
|
+
// Query-computed metadata, which by definition is not a column and so
|
|
722
|
+
// has no property to be found. Dropped here until now — which meant
|
|
723
|
+
// `_distance` was computed for every vector search on this path and
|
|
724
|
+
// then discarded before the caller ever saw it.
|
|
725
|
+
if (QUERY_METADATA_KEYS.has(key)) {
|
|
726
|
+
result[key] = value;
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
|
|
712
730
|
const property = properties[key as keyof M] as Property;
|
|
713
731
|
if (!property) continue; // Skip DB columns not defined in properties
|
|
714
732
|
|
|
@@ -28,6 +28,16 @@
|
|
|
28
28
|
import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
29
29
|
import { getTableName, relationalCollections } from "@rebasepro/common";
|
|
30
30
|
import { logger } from "@rebasepro/server";
|
|
31
|
+
import {
|
|
32
|
+
assertSearchIsPostgresOnly,
|
|
33
|
+
buildSearchColumnSpec,
|
|
34
|
+
searchExtensionStatements,
|
|
35
|
+
searchHelperFunctions,
|
|
36
|
+
searchIndexStatements,
|
|
37
|
+
SEARCH_TEXT_FN,
|
|
38
|
+
SEARCH_UNACCENT_FN,
|
|
39
|
+
type SearchColumnSpec
|
|
40
|
+
} from "./search-column";
|
|
31
41
|
import {
|
|
32
42
|
getSqlColumnType,
|
|
33
43
|
resolveColumnName,
|
|
@@ -83,7 +93,8 @@ export interface ExistingSchema {
|
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
export interface EnsureAction {
|
|
86
|
-
kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
|
|
96
|
+
kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
|
|
97
|
+
| "create-extension" | "create-function" | "create-index";
|
|
87
98
|
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
88
99
|
target: string;
|
|
89
100
|
sql: string;
|
|
@@ -181,6 +192,10 @@ export function planCollectionSchemaEnsure(
|
|
|
181
192
|
// Firestore collection is not a harmless extra: the app keeps reading
|
|
182
193
|
// documents from Firestore while an empty table with the same name accretes
|
|
183
194
|
// policies and shows up in every drift report.
|
|
195
|
+
// Before the filter, deliberately: a `search` block on a collection this
|
|
196
|
+
// engine does not store would otherwise be dropped here without a word.
|
|
197
|
+
assertSearchIsPostgresOnly(allCollections);
|
|
198
|
+
|
|
184
199
|
const collections = relationalCollections(allCollections);
|
|
185
200
|
const actions: EnsureAction[] = [];
|
|
186
201
|
const plannedEnums = new Set<string>();
|
|
@@ -200,6 +215,34 @@ export function planCollectionSchemaEnsure(
|
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
|
|
218
|
+
// 1b. Search support, for collections that declared a `search` block.
|
|
219
|
+
//
|
|
220
|
+
// Before the tables, because a generated column's expression is
|
|
221
|
+
// resolved when the column is created: a table whose search column
|
|
222
|
+
// calls `rebase_search_text` cannot be added before that function
|
|
223
|
+
// exists. Both forms are idempotent, so a boot against a database that
|
|
224
|
+
// already has them plans nothing.
|
|
225
|
+
const searchSpecs = collections
|
|
226
|
+
.map(c => buildSearchColumnSpec(c))
|
|
227
|
+
.filter((spec): spec is SearchColumnSpec => spec !== undefined);
|
|
228
|
+
|
|
229
|
+
const plannedExtensions = new Set<string>();
|
|
230
|
+
for (const spec of searchSpecs) {
|
|
231
|
+
for (const statement of searchExtensionStatements(spec)) {
|
|
232
|
+
if (plannedExtensions.has(statement)) continue;
|
|
233
|
+
plannedExtensions.add(statement);
|
|
234
|
+
actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const plannedFunctions = new Set<string>();
|
|
238
|
+
for (const spec of searchSpecs) {
|
|
239
|
+
for (const statement of searchHelperFunctions(spec)) {
|
|
240
|
+
if (plannedFunctions.has(statement)) continue;
|
|
241
|
+
plannedFunctions.add(statement);
|
|
242
|
+
actions.push({ kind: "create-function", target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN, sql: statement });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
203
246
|
// 2. Missing tables. Only the identity column is created here; every other
|
|
204
247
|
// column is added by step 3, so a new table and an existing table that
|
|
205
248
|
// gained a field travel the exact same code path. One way to build a
|
|
@@ -375,6 +418,22 @@ export function planCollectionSchemaEnsure(
|
|
|
375
418
|
}
|
|
376
419
|
}
|
|
377
420
|
|
|
421
|
+
// 3aa. The generated search columns.
|
|
422
|
+
//
|
|
423
|
+
// Adding a STORED generated column rewrites the table, which on a large
|
|
424
|
+
// one is not free — but it is the same additive shape as every other
|
|
425
|
+
// column here, and the alternative (leaving it out until someone runs a
|
|
426
|
+
// migration) is a declared `search` block that silently does nothing.
|
|
427
|
+
for (const spec of searchSpecs) {
|
|
428
|
+
const key = `${spec.schema}.${spec.table}`;
|
|
429
|
+
addColumn(key, spec.schema, spec.table, spec.column,
|
|
430
|
+
`tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`);
|
|
431
|
+
if (spec.fuzzy) {
|
|
432
|
+
addColumn(key, spec.schema, spec.table, spec.fuzzy.column,
|
|
433
|
+
`text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
378
437
|
// 3b. A junction that already existed, but is short a column. One created
|
|
379
438
|
// above already carries both — unlike a collection table, whose CREATE
|
|
380
439
|
// declares only the identity column — so re-listing them would log two
|
|
@@ -422,6 +481,23 @@ export function planCollectionSchemaEnsure(
|
|
|
422
481
|
});
|
|
423
482
|
}
|
|
424
483
|
|
|
484
|
+
// 5. Search indexes, after everything — the column has to exist, and this is
|
|
485
|
+
// the one step that runs against a populated table for real work.
|
|
486
|
+
//
|
|
487
|
+
// CONCURRENTLY: a plain CREATE INDEX takes a lock that blocks writes for
|
|
488
|
+
// the duration of the build, which on a live table is an outage. Each
|
|
489
|
+
// statement here is issued on its own, outside any transaction, which is
|
|
490
|
+
// the condition CONCURRENTLY requires.
|
|
491
|
+
for (const spec of searchSpecs) {
|
|
492
|
+
for (const statement of searchIndexStatements(spec)) {
|
|
493
|
+
actions.push({
|
|
494
|
+
kind: "create-index",
|
|
495
|
+
target: `${spec.schema}.${spec.table}`,
|
|
496
|
+
sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
425
501
|
return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
|
|
426
502
|
}
|
|
427
503
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
+
import { buildSearchColumnSpec } from "./search-column";
|
|
3
4
|
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
|
|
4
5
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
5
6
|
import { logger } from "@rebasepro/server";
|
|
@@ -479,11 +480,15 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
|
|
|
479
480
|
)
|
|
480
481
|
);
|
|
481
482
|
|
|
483
|
+
// drizzle ships no `tsvector` builder, so an opted-in search column reaches
|
|
484
|
+
// the schema the same way `bytea` does — through `customType`.
|
|
485
|
+
const hasSearch = collections.some(c => buildSearchColumnSpec(c) !== undefined);
|
|
486
|
+
|
|
482
487
|
// Always import pgPolicy and sql — RLS is enabled on every table (secure by default)
|
|
483
488
|
const pgCoreImports = ["primaryKey", "pgTable", "integer", "varchar", "text", "char", "boolean", "timestamp", "date", "time", "jsonb", "json", "pgEnum", "numeric", "real", "doublePrecision", "bigint", "serial", "bigserial", "pgPolicy"];
|
|
484
489
|
if (hasUuid) pgCoreImports.push("uuid");
|
|
485
490
|
if (hasVector) pgCoreImports.push("vector");
|
|
486
|
-
if (hasBinary) pgCoreImports.push("customType");
|
|
491
|
+
if (hasBinary || hasSearch) pgCoreImports.push("customType");
|
|
487
492
|
|
|
488
493
|
const uniqueSchemas = Array.from(new Set(
|
|
489
494
|
collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
|
|
@@ -632,6 +637,25 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
|
|
|
632
637
|
|
|
633
638
|
});
|
|
634
639
|
|
|
640
|
+
// The opt-in search column. Declared with its real generation
|
|
641
|
+
// expression rather than as a bare custom type: `schema.generated.ts`
|
|
642
|
+
// is what a developer running drizzle-kit themselves diffs against,
|
|
643
|
+
// and a column declared without its expression reads to drizzle-kit
|
|
644
|
+
// as one it should alter.
|
|
645
|
+
const searchSpec = buildSearchColumnSpec(collection);
|
|
646
|
+
if (searchSpec) {
|
|
647
|
+
columns.add(
|
|
648
|
+
` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}")` +
|
|
649
|
+
`.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
|
|
650
|
+
);
|
|
651
|
+
if (searchSpec.fuzzy) {
|
|
652
|
+
columns.add(
|
|
653
|
+
` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}")` +
|
|
654
|
+
`.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
635
659
|
// Backwards compatibility: if no id/primary key column is found in properties, but `id` wasn't explicitly provided
|
|
636
660
|
// We should generate a basic id column if one was completely omitted.
|
|
637
661
|
const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
|
|
@@ -1,7 +1,18 @@
|
|
|
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
2
|
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
|
|
3
|
-
import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
3
|
+
import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName, toPostgresIdentifier } from "@rebasepro/utils";
|
|
4
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";
|
|
5
16
|
import { REBASE_SCHEMA } from "@rebasepro/types";
|
|
6
17
|
|
|
7
18
|
// --- Helper Functions ---
|
|
@@ -237,9 +248,122 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
237
248
|
}
|
|
238
249
|
};
|
|
239
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
|
+
|
|
240
361
|
export const generatePostgresDdl = async (
|
|
241
362
|
allCollections: CollectionConfig[],
|
|
242
|
-
options: { includePolicies?: boolean } = {
|
|
363
|
+
options: { includePolicies?: boolean; includeSearch?: boolean } = {
|
|
364
|
+
includePolicies: true,
|
|
365
|
+
includeSearch: true
|
|
366
|
+
}
|
|
243
367
|
): Promise<string> => {
|
|
244
368
|
// Only the collections this engine stores. See `relationalCollections`.
|
|
245
369
|
const collections = relationalCollections(allCollections);
|
|
@@ -263,6 +387,32 @@ export const generatePostgresDdl = async (
|
|
|
263
387
|
});
|
|
264
388
|
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
265
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
|
+
|
|
266
416
|
// 2. Generate Enums
|
|
267
417
|
//
|
|
268
418
|
// The enum type name is derived from table + column, so two collections
|
|
@@ -330,6 +480,9 @@ export const generatePostgresDdl = async (
|
|
|
330
480
|
|
|
331
481
|
// 3. Generate tables
|
|
332
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[] = [];
|
|
333
486
|
// Policies are emitted after every CREATE TABLE, like the FK constraints:
|
|
334
487
|
// a policy may reference other tables (a junction's derived policies always
|
|
335
488
|
// reference both endpoints; `policy.existsIn` references a join table), and
|
|
@@ -526,6 +679,19 @@ export const generatePostgresDdl = async (
|
|
|
526
679
|
}
|
|
527
680
|
}
|
|
528
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
|
+
|
|
529
695
|
// Backwards compatibility: add default id primary key if missing
|
|
530
696
|
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
531
697
|
if (!hasPk) {
|
|
@@ -559,6 +725,11 @@ export const generatePostgresDdl = async (
|
|
|
559
725
|
ddl += fkStatements.join("\n") + "\n\n";
|
|
560
726
|
}
|
|
561
727
|
|
|
728
|
+
if (indexStatements.length > 0) {
|
|
729
|
+
ddl += "-- Indexes\n";
|
|
730
|
+
ddl += indexStatements.join("\n") + "\n\n";
|
|
731
|
+
}
|
|
732
|
+
|
|
562
733
|
if (policyStatements.length > 0) {
|
|
563
734
|
ddl += "-- Row Level Security Policies\n";
|
|
564
735
|
ddl += policyStatements.join("");
|
|
@@ -642,13 +813,9 @@ const bareTableName = (name: string): string => (name.includes(".") ? name.split
|
|
|
642
813
|
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
643
814
|
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
644
815
|
*/
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// `toString` on a slice that ends mid-character yields U+FFFD; dropping it
|
|
649
|
-
// lands on the last whole character that fits, which is what Postgres does.
|
|
650
|
-
return bytes.subarray(0, 63).toString("utf8").replace(/�+$/, "");
|
|
651
|
-
};
|
|
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.
|
|
652
819
|
|
|
653
820
|
const foreignKeyPlan = (
|
|
654
821
|
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: 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));
|