@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0
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/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1720 -946
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +124 -11
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +299 -168
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +129 -25
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +366 -30
- package/src/schema/generate-drizzle-schema-logic.ts +146 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +277 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +14 -7
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +243 -22
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +3 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
- package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
- package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-CU6WZGYV.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,21 @@
|
|
|
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
|
+
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
|
+
searchColumnStamps,
|
|
14
|
+
searchStampGuards,
|
|
15
|
+
searchIndexNames,
|
|
16
|
+
type SearchColumnSpec
|
|
17
|
+
} from "./search-column";
|
|
18
|
+
import { REBASE_SCHEMA } from "@rebasepro/types";
|
|
4
19
|
|
|
5
20
|
// --- Helper Functions ---
|
|
6
21
|
|
|
@@ -180,6 +195,11 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
180
195
|
const mapProp = prop as MapProperty;
|
|
181
196
|
return mapProp.columnType === "json" ? "JSON" : "JSONB";
|
|
182
197
|
}
|
|
198
|
+
// `{ latitude, longitude }` — a document, like `map`. It used to fall to
|
|
199
|
+
// the `TEXT` default below while the Drizzle generator emitted no column
|
|
200
|
+
// at all, which is the divergence that made the type unpersistable.
|
|
201
|
+
case "geopoint":
|
|
202
|
+
return "JSONB";
|
|
183
203
|
case "array": {
|
|
184
204
|
const arrayProp = prop as ArrayProperty;
|
|
185
205
|
let colType = arrayProp.columnType;
|
|
@@ -231,21 +251,162 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
231
251
|
return pkProp.type === "number" ? "INTEGER" : (pkProp.isUuid ? "UUID" : "TEXT");
|
|
232
252
|
}
|
|
233
253
|
default:
|
|
234
|
-
|
|
254
|
+
// A silent `TEXT` here is how the two generators drifted apart: the
|
|
255
|
+
// database got a column for `geopoint` and the Drizzle table did
|
|
256
|
+
// not, so the type looked supported and persisted nothing. Every
|
|
257
|
+
// member of `DataType` is handled above; anything else is a
|
|
258
|
+
// generator that has not been taught the type yet, and it should
|
|
259
|
+
// say so rather than invent a column.
|
|
260
|
+
throw new Error(
|
|
261
|
+
`No Postgres column type for property '${propName}' of type ` +
|
|
262
|
+
`'${(prop as Property).type}' in collection '${collection.slug}'. ` +
|
|
263
|
+
"Add a case to `getSqlColumnType` (and to `getDrizzleColumn`, which must agree)."
|
|
264
|
+
);
|
|
235
265
|
}
|
|
236
266
|
};
|
|
237
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Everything a `search` block needs, as a file Rebase applies itself.
|
|
270
|
+
*
|
|
271
|
+
* Search is the one part of the schema Atlas does not own. Two independent
|
|
272
|
+
* reasons, and either alone would be enough:
|
|
273
|
+
*
|
|
274
|
+
* 1. Its free tier refuses to *parse* a desired-state file that so much as
|
|
275
|
+
* contains a function — "functions and procedures are available to
|
|
276
|
+
* logged-in users only". A generated `tsvector` column cannot avoid one:
|
|
277
|
+
* `unaccent` is STABLE and jsonb flattening needs a set-returning function,
|
|
278
|
+
* so both have to be wrapped in an IMMUTABLE helper to be legal in a
|
|
279
|
+
* generated column at all.
|
|
280
|
+
* 2. Even with the file accepted, Atlas *wipes* the dev database it diffs
|
|
281
|
+
* against, so a helper seeded there beforehand is gone by the time the plan
|
|
282
|
+
* is analysed. There is no hook to reinstate it.
|
|
283
|
+
*
|
|
284
|
+
* So the column, its index and its helpers are excluded from Atlas's view
|
|
285
|
+
* (`searchExcludePatterns`) and applied from here — the same arrangement the
|
|
286
|
+
* RLS policies already use, and for the same underlying reason.
|
|
287
|
+
*
|
|
288
|
+
* Ordered as it must run: extensions, then helpers, then the column whose
|
|
289
|
+
* expression calls them, then the index over that column. Every statement is
|
|
290
|
+
* `IF NOT EXISTS` / `OR REPLACE`, because this is replayed on every push and
|
|
291
|
+
* appended to migrations that run against databases at any stage of their
|
|
292
|
+
* life. Empty when nothing opted in — the caller writes no file then.
|
|
293
|
+
*
|
|
294
|
+
* The leading half — extensions and helpers, without the table-shaped
|
|
295
|
+
* statements — is available on its own as {@link searchPrerequisiteStatements},
|
|
296
|
+
* for the dev database Atlas analyses plans against. That database has none of
|
|
297
|
+
* the project's tables, so it wants the functions and nothing else.
|
|
298
|
+
*/
|
|
299
|
+
export const searchPrerequisiteStatements = (allCollections: CollectionConfig[]): string[] => {
|
|
300
|
+
const specs = relationalCollections(allCollections)
|
|
301
|
+
.map(c => buildSearchColumnSpec(c))
|
|
302
|
+
.filter((s): s is SearchColumnSpec => s !== undefined);
|
|
303
|
+
if (specs.length === 0) return [];
|
|
304
|
+
return [
|
|
305
|
+
...new Set(specs.flatMap(searchExtensionStatements)),
|
|
306
|
+
...new Set(specs.flatMap(searchHelperFunctions))
|
|
307
|
+
];
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
export const generatePostgresSearchDdl = (allCollections: CollectionConfig[]): string => {
|
|
311
|
+
const collections = relationalCollections(allCollections);
|
|
312
|
+
const specs = collections
|
|
313
|
+
.map(c => buildSearchColumnSpec(c))
|
|
314
|
+
.filter((s): s is SearchColumnSpec => s !== undefined);
|
|
315
|
+
if (specs.length === 0) return "";
|
|
316
|
+
|
|
317
|
+
const extensions = Array.from(new Set(specs.flatMap(searchExtensionStatements)));
|
|
318
|
+
const helpers = Array.from(new Set(specs.flatMap(searchHelperFunctions)));
|
|
319
|
+
|
|
320
|
+
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n";
|
|
321
|
+
ddl += "--\n";
|
|
322
|
+
ddl += "-- Full-text search for the collections declaring a `search` block.\n";
|
|
323
|
+
ddl += "-- Applied by Rebase, not by Atlas — see generatePostgresSearchDdl.\n\n";
|
|
324
|
+
|
|
325
|
+
extensions.forEach(s => { ddl += `${s}\n`; });
|
|
326
|
+
if (extensions.length > 0) ddl += "\n";
|
|
327
|
+
helpers.forEach(s => { ddl += `${s}\n\n`; });
|
|
328
|
+
|
|
329
|
+
for (const collection of collections) {
|
|
330
|
+
const spec = buildSearchColumnSpec(collection);
|
|
331
|
+
if (!spec) continue;
|
|
332
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
333
|
+
const table = `"${schema}"."${getTableName(collection)}"`;
|
|
334
|
+
|
|
335
|
+
// ADD COLUMN IF NOT EXISTS rather than the inline definition the
|
|
336
|
+
// CREATE TABLE would use: by the time this runs the table exists,
|
|
337
|
+
// whether Atlas just created it or it has been live for a year.
|
|
338
|
+
// Refuse before altering anything if the column in the database was
|
|
339
|
+
// generated from a different `search` block: `ADD COLUMN IF NOT EXISTS`
|
|
340
|
+
// is a no-op against an existing column, so without this the file would
|
|
341
|
+
// report success and leave the old expression in place — and then stamp
|
|
342
|
+
// it as current.
|
|
343
|
+
searchStampGuards(spec).forEach(s => { ddl += `${s}\n`; });
|
|
344
|
+
ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${searchColumnDefinition(spec)};\n`;
|
|
345
|
+
const fuzzyDef = fuzzyColumnDefinition(spec);
|
|
346
|
+
if (fuzzyDef) ddl += `ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS ${fuzzyDef};\n`;
|
|
347
|
+
// The fingerprint of the expression each column was built from — the
|
|
348
|
+
// only record of *which* block a generated column came from, and what
|
|
349
|
+
// the guard above and the boot-time ensure both compare against.
|
|
350
|
+
searchColumnStamps(spec).forEach(s => { ddl += `${s.sql}\n`; });
|
|
351
|
+
searchIndexStatements(spec).forEach(s => { ddl += `${s}\n`; });
|
|
352
|
+
ddl += "\n";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return ddl;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Glob patterns telling Atlas to leave the search column and its index alone.
|
|
360
|
+
*
|
|
361
|
+
* Without these, a desired state that omits search reads to Atlas as an
|
|
362
|
+
* instruction to drop the column — taking the index and the whole search
|
|
363
|
+
* feature with it on the next push.
|
|
364
|
+
*
|
|
365
|
+
* Fully qualified, `schema.table.object`, matching the include list. The
|
|
366
|
+
* two-part form is what Atlas wants when the connection URL scopes it to one
|
|
367
|
+
* schema and is *silently ignored* otherwise: it reads `posts.search_vector`
|
|
368
|
+
* as a table named `search_vector` in a schema named `posts`, matches nothing,
|
|
369
|
+
* and reports no error for the pattern that never fired.
|
|
370
|
+
*/
|
|
371
|
+
export const searchExcludePatterns = (allCollections: CollectionConfig[]): string[] => {
|
|
372
|
+
const patterns: string[] = [];
|
|
373
|
+
for (const collection of relationalCollections(allCollections)) {
|
|
374
|
+
const spec = buildSearchColumnSpec(collection);
|
|
375
|
+
if (!spec) continue;
|
|
376
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
377
|
+
const table = getTableName(collection);
|
|
378
|
+
for (const name of searchColumnNames(collection)) {
|
|
379
|
+
patterns.push(`${schema}.${table}.${name}`);
|
|
380
|
+
}
|
|
381
|
+
for (const index of searchIndexNames(spec)) {
|
|
382
|
+
patterns.push(`${schema}.${table}.${index}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return patterns;
|
|
386
|
+
};
|
|
387
|
+
|
|
238
388
|
export const generatePostgresDdl = async (
|
|
239
389
|
allCollections: CollectionConfig[],
|
|
240
|
-
options: { includePolicies?: boolean } = {
|
|
390
|
+
options: { includePolicies?: boolean; includeSearch?: boolean } = {
|
|
391
|
+
includePolicies: true,
|
|
392
|
+
includeSearch: true
|
|
393
|
+
}
|
|
241
394
|
): Promise<string> => {
|
|
242
395
|
// Only the collections this engine stores. See `relationalCollections`.
|
|
243
396
|
const collections = relationalCollections(allCollections);
|
|
244
397
|
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
245
398
|
|
|
246
|
-
// 1. Create custom schemas
|
|
399
|
+
// 1. Create custom schemas.
|
|
400
|
+
//
|
|
401
|
+
// `rebase` is unconditional and load-bearing. The RLS helper functions live
|
|
402
|
+
// in it, so the migration preamble creates it — which puts it in Atlas's
|
|
403
|
+
// replayed state, and anything in that state but absent from this desired
|
|
404
|
+
// schema gets a `DROP SCHEMA … CASCADE` planned against it. That would take
|
|
405
|
+
// the auth tables. It used to appear here only when some collection
|
|
406
|
+
// happened to declare `schema: "rebase"`; the scaffold's users collection
|
|
407
|
+
// does, which is why nobody hit it, but nothing guaranteed it.
|
|
247
408
|
const uniqueSchemas = Array.from(new Set([
|
|
248
|
-
|
|
409
|
+
REBASE_SCHEMA,
|
|
249
410
|
...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
|
|
250
411
|
]));
|
|
251
412
|
uniqueSchemas.forEach(schema => {
|
|
@@ -253,6 +414,32 @@ export const generatePostgresDdl = async (
|
|
|
253
414
|
});
|
|
254
415
|
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
255
416
|
|
|
417
|
+
// 1b. Search support, for collections that opted in.
|
|
418
|
+
//
|
|
419
|
+
// Extensions and helper functions come before every CREATE TABLE because a
|
|
420
|
+
// generated column's expression is resolved at creation time: a table whose
|
|
421
|
+
// search column calls `rebase_search_text` cannot be created before that
|
|
422
|
+
// function exists. Both are `IF NOT EXISTS` / `OR REPLACE`, so a file
|
|
423
|
+
// replayed against a live database is a no-op here.
|
|
424
|
+
const searchSpecs = collections
|
|
425
|
+
.map(c => buildSearchColumnSpec(c))
|
|
426
|
+
.filter((s): s is SearchColumnSpec => s !== undefined);
|
|
427
|
+
|
|
428
|
+
if (searchSpecs.length > 0) {
|
|
429
|
+
if (options.includeSearch === false) {
|
|
430
|
+
// Everything search-related lives in `search.sql` — see
|
|
431
|
+
// `generatePostgresSearchDdl` for why Atlas is not shown any of it.
|
|
432
|
+
ddl += "-- Full-text search support lives in `search.sql`, applied separately.\n\n";
|
|
433
|
+
} else {
|
|
434
|
+
const extensions = Array.from(new Set(searchSpecs.flatMap(searchExtensionStatements)));
|
|
435
|
+
const helpers = Array.from(new Set(searchSpecs.flatMap(searchHelperFunctions)));
|
|
436
|
+
ddl += "-- Full-text search support (collections declaring a `search` block)\n";
|
|
437
|
+
extensions.forEach(s => { ddl += `${s}\n`; });
|
|
438
|
+
if (extensions.length > 0) ddl += "\n";
|
|
439
|
+
helpers.forEach(s => { ddl += `${s}\n\n`; });
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
256
443
|
// 2. Generate Enums
|
|
257
444
|
//
|
|
258
445
|
// The enum type name is derived from table + column, so two collections
|
|
@@ -320,6 +507,9 @@ export const generatePostgresDdl = async (
|
|
|
320
507
|
|
|
321
508
|
// 3. Generate tables
|
|
322
509
|
const fkStatements: string[] = [];
|
|
510
|
+
// Indexes follow the tables for the same reason the FK constraints do: the
|
|
511
|
+
// table has to exist first.
|
|
512
|
+
const indexStatements: string[] = [];
|
|
323
513
|
// Policies are emitted after every CREATE TABLE, like the FK constraints:
|
|
324
514
|
// a policy may reference other tables (a junction's derived policies always
|
|
325
515
|
// reference both endpoints; `policy.existsIn` references a join table), and
|
|
@@ -357,8 +547,8 @@ export const generatePostgresDdl = async (
|
|
|
357
547
|
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
358
548
|
ddl += `);\n\n`;
|
|
359
549
|
|
|
360
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
361
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
550
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
551
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
362
552
|
|
|
363
553
|
if (options.includePolicies) {
|
|
364
554
|
// Junction tables are generated tables like any other: locked by
|
|
@@ -416,7 +606,7 @@ export const generatePostgresDdl = async (
|
|
|
416
606
|
if (required) colDef += " NOT NULL";
|
|
417
607
|
columns.push(colDef);
|
|
418
608
|
|
|
419
|
-
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};`);
|
|
609
|
+
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};`);
|
|
420
610
|
} else if (prop.type === "reference") {
|
|
421
611
|
const refProp = prop as ReferenceProperty;
|
|
422
612
|
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
@@ -438,10 +628,27 @@ export const generatePostgresDdl = async (
|
|
|
438
628
|
if (required) colDef += " NOT NULL";
|
|
439
629
|
columns.push(colDef);
|
|
440
630
|
|
|
441
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
631
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
442
632
|
}
|
|
443
633
|
} else {
|
|
444
634
|
const colName = resolveColumnName(propName, prop);
|
|
635
|
+
|
|
636
|
+
// On an auth collection, the columns auth reads and writes
|
|
637
|
+
// are defined once, in `auth-users-columns`, and every
|
|
638
|
+
// creator of this table emits that definition. Before this,
|
|
639
|
+
// the desired state here was built from the collection file
|
|
640
|
+
// alone — which knows nothing of `is_anonymous` or
|
|
641
|
+
// `tokens_valid_after` — so `db push` and the runtime
|
|
642
|
+
// disagreed about `email`'s nullability and about which
|
|
643
|
+
// columns exist at all.
|
|
644
|
+
const authDefinition = isAuthCollection(collection)
|
|
645
|
+
? authUsersColumnDefinition(colName)
|
|
646
|
+
: undefined;
|
|
647
|
+
if (authDefinition && !isIdProperty(propName, prop, collection)) {
|
|
648
|
+
columns.push(` "${colName}" ${authDefinition}`);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
445
652
|
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
446
653
|
let colDef = ` "${colName}" ${colType}`;
|
|
447
654
|
|
|
@@ -478,6 +685,40 @@ export const generatePostgresDdl = async (
|
|
|
478
685
|
}
|
|
479
686
|
});
|
|
480
687
|
|
|
688
|
+
// ── Auth columns the collection file never mentions ──────────────
|
|
689
|
+
// `db push` is declarative: Atlas diffs the database against exactly
|
|
690
|
+
// what is emitted here and drops anything else. The scaffold's users
|
|
691
|
+
// collection describes 12 columns while auth needs 14, so
|
|
692
|
+
// `is_anonymous` and `tokens_valid_after` — created at boot by
|
|
693
|
+
// `ensureAuthTablesExist` — read as unmanaged drift, and a push run
|
|
694
|
+
// after the server had started once planned to DROP them. The
|
|
695
|
+
// destructive gate catches it, which makes it a data-loss prompt on
|
|
696
|
+
// the auth table rather than silent damage; either way the desired
|
|
697
|
+
// state was wrong. Emit the full contract so the two agree.
|
|
698
|
+
if (isAuthCollection(collection)) {
|
|
699
|
+
const declared = new Set(
|
|
700
|
+
Object.entries(collection.properties ?? {})
|
|
701
|
+
.map(([name, prop]) => resolveColumnName(name, prop))
|
|
702
|
+
);
|
|
703
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
704
|
+
if (declared.has(spec.column)) continue;
|
|
705
|
+
columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ── The opt-in search column ─────────────────────────────────────
|
|
710
|
+
// Last, so it reads as what it is: derived from the columns above
|
|
711
|
+
// it. Postgres recomputes it on every write of a source column and
|
|
712
|
+
// rejects any attempt to write it directly, which is the property
|
|
713
|
+
// that makes it impossible for the index to drift from the row.
|
|
714
|
+
const searchSpec = options.includeSearch === false ? undefined : buildSearchColumnSpec(collection);
|
|
715
|
+
if (searchSpec) {
|
|
716
|
+
columns.push(` ${searchColumnDefinition(searchSpec)}`);
|
|
717
|
+
const fuzzyDef = fuzzyColumnDefinition(searchSpec);
|
|
718
|
+
if (fuzzyDef) columns.push(` ${fuzzyDef}`);
|
|
719
|
+
indexStatements.push(...searchIndexStatements(searchSpec));
|
|
720
|
+
}
|
|
721
|
+
|
|
481
722
|
// Backwards compatibility: add default id primary key if missing
|
|
482
723
|
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
483
724
|
if (!hasPk) {
|
|
@@ -511,6 +752,11 @@ export const generatePostgresDdl = async (
|
|
|
511
752
|
ddl += fkStatements.join("\n") + "\n\n";
|
|
512
753
|
}
|
|
513
754
|
|
|
755
|
+
if (indexStatements.length > 0) {
|
|
756
|
+
ddl += "-- Indexes\n";
|
|
757
|
+
ddl += indexStatements.join("\n") + "\n\n";
|
|
758
|
+
}
|
|
759
|
+
|
|
514
760
|
if (policyStatements.length > 0) {
|
|
515
761
|
ddl += "-- Row Level Security Policies\n";
|
|
516
762
|
ddl += policyStatements.join("");
|
|
@@ -577,10 +823,31 @@ const schemaOfCollection = (collection: CollectionConfig): string =>
|
|
|
577
823
|
|
|
578
824
|
const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
|
|
579
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
|
|
828
|
+
*
|
|
829
|
+
* This is not cosmetic, and not really a naming choice at all — it is agreeing
|
|
830
|
+
* with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
|
|
831
|
+
* succeeds and records the truncated form, so the untruncated name this used to
|
|
832
|
+
* derive matched nothing in the catalogue. Boot-ensure compares its planned
|
|
833
|
+
* constraints against `readExistingSchema`, which reads catalogue names, so the
|
|
834
|
+
* comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
|
|
835
|
+
* constraint, forever, and got "already exists" every time. Non-fatal (foreign
|
|
836
|
+
* keys are the one action allowed to fail) and therefore permanent — an error in
|
|
837
|
+
* the log on every restart of a project whose table and column names happened to
|
|
838
|
+
* be long.
|
|
839
|
+
*
|
|
840
|
+
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
841
|
+
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
842
|
+
*/
|
|
843
|
+
// Moved to `@rebasepro/utils` so the search-column builder derives index names
|
|
844
|
+
// under the same 63-byte rule this file derives constraint names under. Two
|
|
845
|
+
// copies of a truncation rule is two rules the moment one of them is edited.
|
|
846
|
+
|
|
580
847
|
const foreignKeyPlan = (
|
|
581
848
|
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
|
|
582
849
|
): ForeignKeyPlan => {
|
|
583
|
-
const constraintName = `${args.table}_${args.column}_fkey
|
|
850
|
+
const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
|
|
584
851
|
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
585
852
|
return {
|
|
586
853
|
constraintName,
|
|
@@ -3,15 +3,20 @@ 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
|
-
import {
|
|
12
|
+
import { loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
13
|
+
import { out, outError } from "../cli-output";
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
const runGeneration = async (collectionsFilePath?: string, outputPath?: string) => {
|
|
12
17
|
try {
|
|
13
18
|
if (!collectionsFilePath) {
|
|
14
|
-
|
|
19
|
+
outError("Error: No collections file path provided. Skipping schema generation.");
|
|
15
20
|
return;
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -29,27 +34,46 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
29
34
|
// Sort collections by slug alphabetically to ensure deterministic DDL generation
|
|
30
35
|
collections.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
// `schema.sql` is Atlas's desired state, and it carries neither the RLS
|
|
38
|
+
// policies nor the search apparatus: Atlas manages neither, and in the
|
|
39
|
+
// search case cannot. Both are written beside it and applied by the CLI
|
|
40
|
+
// in their own right.
|
|
41
|
+
const ddlContent = await generatePostgresDdl(collections, {
|
|
42
|
+
includePolicies: false,
|
|
43
|
+
includeSearch: false
|
|
44
|
+
});
|
|
33
45
|
const policiesContent = generatePostgresPoliciesDdl(collections);
|
|
46
|
+
const searchContent = generatePostgresSearchDdl(collections);
|
|
34
47
|
|
|
35
48
|
if (outputPath) {
|
|
36
49
|
const outputDir = path.dirname(outputPath);
|
|
37
50
|
await fsPromises.mkdir(outputDir, { recursive: true });
|
|
38
51
|
await fsPromises.writeFile(outputPath, ddlContent);
|
|
39
|
-
|
|
52
|
+
out(`✅ PostgreSQL DDL generated successfully at ${outputPath}`);
|
|
40
53
|
|
|
41
54
|
const policiesPath = path.join(outputDir, "policies.sql");
|
|
42
55
|
await fsPromises.writeFile(policiesPath, policiesContent);
|
|
43
|
-
|
|
56
|
+
out(`✅ PostgreSQL Policies DDL generated successfully at ${policiesPath}`);
|
|
57
|
+
|
|
58
|
+
// Removed when the last `search` block goes: the CLI applies this
|
|
59
|
+
// file whenever it exists, and a stale one would keep re-creating
|
|
60
|
+
// functions for collections that no longer want them.
|
|
61
|
+
const searchPath = path.join(outputDir, "search.sql");
|
|
62
|
+
if (searchContent) {
|
|
63
|
+
await fsPromises.writeFile(searchPath, searchContent);
|
|
64
|
+
out(`✅ PostgreSQL search DDL generated successfully at ${searchPath}`);
|
|
65
|
+
} else if (fs.existsSync(searchPath)) {
|
|
66
|
+
await fsPromises.rm(searchPath);
|
|
67
|
+
}
|
|
44
68
|
} else {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
69
|
+
out("✅ PostgreSQL DDL generated successfully.");
|
|
70
|
+
out(String(ddlContent));
|
|
71
|
+
out("\n✅ PostgreSQL Policies DDL generated successfully.");
|
|
72
|
+
out(String(policiesContent));
|
|
49
73
|
}
|
|
50
74
|
|
|
51
75
|
} catch (error) {
|
|
52
|
-
|
|
76
|
+
outError(`Error generating DDL schema: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`);
|
|
53
77
|
}
|
|
54
78
|
};
|
|
55
79
|
|
|
@@ -63,7 +87,7 @@ const main = () => {
|
|
|
63
87
|
const watch = process.argv.includes("--watch");
|
|
64
88
|
|
|
65
89
|
if (!collectionsFilePath) {
|
|
66
|
-
|
|
90
|
+
out("Usage: ts-node generate-postgres-ddl.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
|
|
67
91
|
return;
|
|
68
92
|
}
|
|
69
93
|
|
|
@@ -71,14 +95,14 @@ const main = () => {
|
|
|
71
95
|
const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : undefined;
|
|
72
96
|
|
|
73
97
|
if (watch) {
|
|
74
|
-
|
|
98
|
+
out(`Watching for changes in ${resolvedPath}...`);
|
|
75
99
|
const watcher = chokidar.watch(resolvedPath, {
|
|
76
100
|
persistent: true,
|
|
77
101
|
ignoreInitial: false
|
|
78
102
|
});
|
|
79
103
|
|
|
80
104
|
watcher.on("all", (event, filePath) => {
|
|
81
|
-
|
|
105
|
+
out(`[${event}] ${filePath}. Regenerating DDL schema...`);
|
|
82
106
|
runGeneration(resolvedPath, resolvedOutputPath);
|
|
83
107
|
});
|
|
84
108
|
} else {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CollectionConfig } from "@rebasepro/types";
|
|
2
|
-
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
2
|
+
import { getTableName, resolveCollectionRelations, relationalCollections } from "@rebasepro/common";
|
|
3
3
|
import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -60,11 +60,13 @@ function tableBlock(source: string, table: string): string {
|
|
|
60
60
|
* clear the finding and the check would be permanently red.
|
|
61
61
|
*/
|
|
62
62
|
function declaresColumn(block: string, column: string): boolean {
|
|
63
|
-
// `
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
63
|
+
// `categorieId: integer("categorie_id")` — the Drizzle column shape. Only
|
|
64
|
+
// the string argument is the column; the key beside it is the *wire* name
|
|
65
|
+
// and no longer agrees with it (`authorId: integer("author_id")`). Matching
|
|
66
|
+
// both, as this did, made every camelCased key look like a column the
|
|
67
|
+
// generated file did not declare.
|
|
68
|
+
const literal = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
69
|
+
return new RegExp(`(^|[\\s,{])[\\w$"']+\\s*:\\s*\\w+\\(\\s*["']${literal}["']`, "m").test(block);
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -111,7 +113,12 @@ export function findLegacyForeignKeyNames(
|
|
|
111
113
|
found.push({ table, legacy, current, relation });
|
|
112
114
|
};
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
// Same rule as the generator whose output this reads: a collection that
|
|
117
|
+
// gets no table cannot have named a column in it. Inert in practice today —
|
|
118
|
+
// the finding also requires the generated file to declare the table — but
|
|
119
|
+
// the guard is what keeps that true when a non-SQL collection's slug happens
|
|
120
|
+
// to match a SQL one's table.
|
|
121
|
+
for (const collection of relationalCollections(collections)) {
|
|
115
122
|
const sourceTable = getTableName(collection);
|
|
116
123
|
|
|
117
124
|
for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
@@ -15,7 +15,14 @@ export function inferPropertyFromData(
|
|
|
15
15
|
pgDataType: string,
|
|
16
16
|
currentPropType: string,
|
|
17
17
|
sampleValues: unknown[],
|
|
18
|
-
isPk: boolean
|
|
18
|
+
isPk: boolean,
|
|
19
|
+
/**
|
|
20
|
+
* False when generating for a project without `@rebasepro/admin-types`, where
|
|
21
|
+
* `BaseProperty` declares no `admin` field and the block below would not
|
|
22
|
+
* compile. The type-level inferences (`propType`, `url`, `storage`) are
|
|
23
|
+
* unaffected — only the form-widget hints are dropped.
|
|
24
|
+
*/
|
|
25
|
+
emitAdmin = true
|
|
19
26
|
): InferenceResult {
|
|
20
27
|
const result: InferenceResult = {};
|
|
21
28
|
const extraLines: string[] = [];
|
|
@@ -239,7 +246,7 @@ export function inferPropertyFromData(
|
|
|
239
246
|
}
|
|
240
247
|
}
|
|
241
248
|
|
|
242
|
-
if (adminOptions.length > 0) {
|
|
249
|
+
if (emitAdmin && adminOptions.length > 0) {
|
|
243
250
|
extraLines.push(` admin: {\n${adminOptions.map((o) => ` ${o}`).join(",\n")}\n }`);
|
|
244
251
|
}
|
|
245
252
|
|