@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g0d7af95
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/PostgresAdapter.d.ts +1 -1
- package/dist/PostgresBackendDriver.d.ts +75 -12
- package/dist/PostgresBootstrapper.d.ts +6 -6
- package/dist/auth/services.d.ts +1 -1
- package/dist/backup/backup-cron.d.ts +1 -1
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/index.d.ts +4 -4
- package/dist/{backup-service-BZoixhVl.js → backup-service-BtgHxfFm.js} +5 -4
- package/dist/{backup-service-BZoixhVl.js.map → backup-service-BtgHxfFm.js.map} +1 -1
- package/dist/cli-helpers.d.ts +41 -0
- package/dist/{auth-users-columns-CgyPWQ18.js → collection-index-DxJBvVTH.js} +486 -503
- package/dist/collection-index-DxJBvVTH.js.map +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/collections/buildRegistry.d.ts +1 -1
- package/dist/collections/validate-relations.d.ts +1 -1
- package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
- package/dist/connection-GOKU3Hu5.js.map +1 -0
- package/dist/connection.d.ts +16 -0
- package/dist/data-transformer.d.ts +1 -1
- package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-DFpOl8SM.js} +4 -4
- package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
- package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
- package/dist/index.d.ts +16 -16
- package/dist/index.es.js +19 -7128
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DNzaWd4C.js} +3 -3
- package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DNzaWd4C.js.map} +1 -1
- package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CInuYj1-.js} +14 -4
- package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
- package/dist/schema/classify-change.d.ts +82 -0
- package/dist/schema/collection-index.d.ts +182 -0
- package/dist/schema/dynamic-tables.d.ts +1 -1
- package/dist/schema/ensure-collection-policies.d.ts +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +93 -2
- package/dist/schema/generate-schema-commit.d.ts +136 -0
- package/dist/schema/generated-schema-staleness.d.ts +19 -0
- package/dist/schema/introspect-db-constraints.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +3 -3
- package/dist/schema/introspect-db-project.d.ts +1 -1
- package/dist/schema/introspect-db-queries.d.ts +1 -1
- package/dist/schema/introspect-db-structure.d.ts +2 -2
- package/dist/schema/introspect-runtime.d.ts +1 -1
- package/dist/schema/vector-index.d.ts +88 -0
- package/dist/services/BranchService.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -4
- package/dist/services/PersistService.d.ts +5 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/RelationWriteService.d.ts +3 -3
- package/dist/services/cdc/junction-tables.d.ts +1 -1
- package/dist/services/cdc/trigger-cdc.d.ts +1 -1
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
- package/dist/services/channel-bus/index.d.ts +2 -2
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/dataService.d.ts +10 -10
- package/dist/services/index.d.ts +4 -4
- package/dist/services/junction-writes.d.ts +2 -2
- package/dist/services/nested-path.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +3 -3
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/services/write-denial.d.ts +1 -1
- package/dist/{src-BBFsDaeA.js → src-DiDgtX8P.js} +97 -1
- package/dist/src-DiDgtX8P.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +2 -2
- package/dist/websocket-CUnpSe8v.js +8188 -0
- package/dist/websocket-CUnpSe8v.js.map +1 -0
- package/dist/websocket.d.ts +29 -2
- package/package.json +7 -7
- package/src/PostgresBackendDriver.ts +190 -59
- package/src/backup/backup-service.ts +1 -1
- package/src/cli-helpers.ts +117 -2
- package/src/cli.ts +22 -0
- package/src/connection.ts +37 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/classify-change.ts +436 -0
- package/src/schema/collection-index.ts +427 -0
- package/src/schema/ensure-collection-tables.test.ts +168 -1
- package/src/schema/ensure-collection-tables.ts +365 -14
- package/src/schema/generate-drizzle-schema-logic.ts +23 -11
- package/src/schema/generate-drizzle-schema.ts +13 -2
- package/src/schema/generate-postgres-ddl-logic.ts +33 -6
- package/src/schema/generate-postgres-ddl.ts +13 -2
- package/src/schema/generate-schema-commit.ts +242 -0
- package/src/schema/generated-schema-staleness.ts +114 -1
- package/src/schema/vector-index.ts +278 -0
- package/src/services/collection-helpers.ts +3 -2
- package/src/websocket.ts +47 -3
- package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
- package/dist/connection-BuZ97wsr.js.map +0 -1
- package/dist/data_driver-ULAyJEi9.js +0 -193
- package/dist/data_driver-ULAyJEi9.js.map +0 -1
- package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
- package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
- package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
- package/dist/src-BBFsDaeA.js.map +0 -1
- package/dist/utils/table-classification.d.ts +0 -8
- package/dist/websocket-BVgDVO-V.js +0 -614
- package/dist/websocket-BVgDVO-V.js.map +0 -1
- package/src/utils/table-classification.ts +0 -16
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
planJunctionTables,
|
|
49
49
|
quoteSqlLiteral
|
|
50
50
|
} from "./generate-postgres-ddl-logic";
|
|
51
|
+
import { buildVectorIndexPlan, vectorIndexStatement, type SkippedVectorIndex } from "./vector-index";
|
|
52
|
+
import { buildCollectionIndexPlan, collectionIndexStatement } from "./collection-index";
|
|
51
53
|
import {
|
|
52
54
|
AUTH_USERS_COLUMNS,
|
|
53
55
|
authUsersColumnDefinition,
|
|
@@ -100,11 +102,57 @@ export interface ExistingSchema {
|
|
|
100
102
|
* and reports nothing as drifted.
|
|
101
103
|
*/
|
|
102
104
|
columnComments?: Map<string, string>;
|
|
105
|
+
/**
|
|
106
|
+
* `schema.typename` → the values that type currently holds, in order.
|
|
107
|
+
*
|
|
108
|
+
* Without this, an enum type that already exists is skipped whole and a
|
|
109
|
+
* value added to it never reaches the database — the type is there, so
|
|
110
|
+
* nothing plans anything, and the first row using the new value is rejected
|
|
111
|
+
* by a constraint nobody changed. Absent is read as "the values are
|
|
112
|
+
* unknown", which keeps the old skip-by-name behaviour rather than guessing.
|
|
113
|
+
*/
|
|
114
|
+
enumValues?: Map<string, string[]>;
|
|
115
|
+
/** `schema.table.column` for every column the database marks NOT NULL. */
|
|
116
|
+
notNullColumns?: Set<string>;
|
|
117
|
+
/**
|
|
118
|
+
* Tables known to hold at least one row.
|
|
119
|
+
*
|
|
120
|
+
* The only thing that decides whether a NOT NULL can be added without
|
|
121
|
+
* reading the data: on an empty table the constraint cannot fail, on a
|
|
122
|
+
* populated one it is checked against every existing row. Absent is read as
|
|
123
|
+
* "assume populated", which is the conservative direction — it withholds a
|
|
124
|
+
* constraint rather than attempting one that aborts the boot.
|
|
125
|
+
*/
|
|
126
|
+
populatedTables?: Set<string>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How far the planner may go in making the database's constraints match the
|
|
131
|
+
* configuration.
|
|
132
|
+
*
|
|
133
|
+
* - `additive` — the boot default. Columns, tables, indexes and enum values are
|
|
134
|
+
* created; no existing column's constraints are touched. Unattended boots run
|
|
135
|
+
* against customer data with nobody reading a diff, and a database adopted by
|
|
136
|
+
* introspection legitimately carries NOT NULL on columns the generated
|
|
137
|
+
* collection leaves optional (`introspect-db-logic` withholds `required` from
|
|
138
|
+
* a column with a default or a trigger behind it). Converging there would
|
|
139
|
+
* strip real constraints on first boot.
|
|
140
|
+
* - `converge` — the live schema editor. Every statement is planned, shown to
|
|
141
|
+
* the person making the change, and applied only once they confirm it. That
|
|
142
|
+
* is the context in which changing an existing column's constraints is a
|
|
143
|
+
* reviewed act rather than a surprise.
|
|
144
|
+
*/
|
|
145
|
+
export type ConstraintPolicy = "additive" | "converge";
|
|
146
|
+
|
|
147
|
+
export interface EnsureOptions {
|
|
148
|
+
/** Defaults to `additive`. See {@link ConstraintPolicy}. */
|
|
149
|
+
constraints?: ConstraintPolicy;
|
|
103
150
|
}
|
|
104
151
|
|
|
105
152
|
export interface EnsureAction {
|
|
106
153
|
kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
|
|
107
|
-
| "create-extension" | "create-function" | "create-index" | "comment-column"
|
|
154
|
+
| "create-extension" | "create-function" | "create-index" | "comment-column"
|
|
155
|
+
| "add-enum-value" | "set-not-null" | "drop-not-null";
|
|
108
156
|
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
109
157
|
target: string;
|
|
110
158
|
sql: string;
|
|
@@ -138,6 +186,44 @@ export interface EnsurePlan {
|
|
|
138
186
|
* current block cannot be known, which is what the caller reports.
|
|
139
187
|
*/
|
|
140
188
|
searchAdopted: { table: string; column: string }[];
|
|
189
|
+
/**
|
|
190
|
+
* Vector columns this plan is deliberately leaving unindexed, because
|
|
191
|
+
* pgvector cannot build an ANN index that wide.
|
|
192
|
+
*
|
|
193
|
+
* Reported rather than thrown: the column is valid, storable and
|
|
194
|
+
* searchable, and refusing the boot over it would make a working
|
|
195
|
+
* configuration unbootable. Reported rather than dropped: an unindexed
|
|
196
|
+
* vector column and an indexed one differ only in latency, so nothing
|
|
197
|
+
* about the running system says which one you got.
|
|
198
|
+
*/
|
|
199
|
+
vectorIndexSkipped: SkippedVectorIndex[];
|
|
200
|
+
/**
|
|
201
|
+
* Constraints the configuration asks for that this plan is not applying,
|
|
202
|
+
* and why.
|
|
203
|
+
*
|
|
204
|
+
* This is the half of the feature that matters most. Every one of these was
|
|
205
|
+
* previously withheld in silence: a required property arrived nullable, and
|
|
206
|
+
* the only evidence was a database that disagreed with its own
|
|
207
|
+
* configuration. Reporting them is what lets boot warn, the live editor
|
|
208
|
+
* refuse, and the doctor explain — three surfaces that until now had nothing
|
|
209
|
+
* to read.
|
|
210
|
+
*/
|
|
211
|
+
withheldConstraints: WithheldConstraint[];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A constraint the configuration asks for that the planner is not applying. */
|
|
215
|
+
export interface WithheldConstraint {
|
|
216
|
+
/** `schema.table.column`. */
|
|
217
|
+
target: string;
|
|
218
|
+
kind: "not-null";
|
|
219
|
+
/**
|
|
220
|
+
* Why, in a sentence that names the obstacle rather than the rule. The
|
|
221
|
+
* reader is looking at a column that is nullable when they asked for
|
|
222
|
+
* required, and needs to know what to do about it.
|
|
223
|
+
*/
|
|
224
|
+
reason: string;
|
|
225
|
+
/** What would make it applicable. */
|
|
226
|
+
remedy: string;
|
|
141
227
|
}
|
|
142
228
|
|
|
143
229
|
/**
|
|
@@ -238,8 +324,11 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
|
|
|
238
324
|
*/
|
|
239
325
|
export function planCollectionSchemaEnsure(
|
|
240
326
|
allCollections: CollectionConfig[],
|
|
241
|
-
existing: ExistingSchema
|
|
327
|
+
existing: ExistingSchema,
|
|
328
|
+
options: EnsureOptions = {}
|
|
242
329
|
): EnsurePlan {
|
|
330
|
+
const constraintPolicy: ConstraintPolicy = options.constraints ?? "additive";
|
|
331
|
+
const withheldConstraints: WithheldConstraint[] = [];
|
|
243
332
|
// Boot receives every collection the bundle declares, including the ones
|
|
244
333
|
// served by another engine entirely. Creating a Postgres table for a
|
|
245
334
|
// Firestore collection is not a harmless extra: the app keeps reading
|
|
@@ -257,7 +346,35 @@ export function planCollectionSchemaEnsure(
|
|
|
257
346
|
// skipped by name rather than guarded in SQL.
|
|
258
347
|
for (const collection of collections) {
|
|
259
348
|
for (const { name, values } of requiredEnums(collection)) {
|
|
260
|
-
if (existing.enums.has(name) || plannedEnums.has(name))
|
|
349
|
+
if (existing.enums.has(name) || plannedEnums.has(name)) {
|
|
350
|
+
// The type is there, but that says nothing about its *values*.
|
|
351
|
+
// Skipping the whole type by name is what made an added enum
|
|
352
|
+
// value vanish: nothing was planned, the boot reported success,
|
|
353
|
+
// and the first row using the value was rejected by a type that
|
|
354
|
+
// had never heard of it. `ADD VALUE` is the one alteration
|
|
355
|
+
// Postgres offers here, it is purely additive, and it is
|
|
356
|
+
// idempotent with `IF NOT EXISTS`.
|
|
357
|
+
//
|
|
358
|
+
// `enumValues` absent means the caller built the schema by hand
|
|
359
|
+
// and does not know the values; skip by name as before rather
|
|
360
|
+
// than plan against a guess.
|
|
361
|
+
const current = existing.enumValues?.get(name);
|
|
362
|
+
if (!current || plannedEnums.has(name)) continue;
|
|
363
|
+
const [schema, typeName] = name.split(".");
|
|
364
|
+
for (const value of values) {
|
|
365
|
+
if (current.includes(value)) continue;
|
|
366
|
+
actions.push({
|
|
367
|
+
kind: "add-enum-value",
|
|
368
|
+
target: `${name}.${value}`,
|
|
369
|
+
// Not inside a transaction with any use of the value:
|
|
370
|
+
// Postgres refuses to read a value added by the
|
|
371
|
+
// transaction still adding it. The applier runs these
|
|
372
|
+
// one statement at a time, which is what makes it legal.
|
|
373
|
+
sql: `ALTER TYPE "${schema}"."${typeName}" ADD VALUE IF NOT EXISTS ${quoteSqlLiteral(value)};`
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
261
378
|
plannedEnums.add(name);
|
|
262
379
|
const [schema, typeName] = name.split(".");
|
|
263
380
|
actions.push({
|
|
@@ -445,11 +562,93 @@ export function planCollectionSchemaEnsure(
|
|
|
445
562
|
// safe on a live table, and a column added without it would take the
|
|
446
563
|
// value the application forgot to send rather than `now()`.
|
|
447
564
|
const autoValue = (p as { autoValue?: string }).autoValue;
|
|
448
|
-
|
|
449
|
-
|
|
565
|
+
const hasDefault = p.type === "date" && (autoValue === "on_create" || autoValue === "on_update");
|
|
566
|
+
if (hasDefault) definition += " DEFAULT now()";
|
|
567
|
+
|
|
568
|
+
const required = p.validation?.required === true;
|
|
569
|
+
const columnKey = `${key}.${column}`;
|
|
570
|
+
const columnExists = existing.tables.get(key)?.has(column) === true;
|
|
571
|
+
|
|
572
|
+
// A NOT NULL is safe exactly when it cannot fail against rows that
|
|
573
|
+
// are already there, and there are three ways to know that:
|
|
574
|
+
//
|
|
575
|
+
// - the table is being created by this plan (no rows yet);
|
|
576
|
+
// - the table exists and is empty;
|
|
577
|
+
// - the column arrives with a DEFAULT, which Postgres backfills
|
|
578
|
+
// into every existing row as part of ADD COLUMN.
|
|
579
|
+
//
|
|
580
|
+
// Anything else is checked against live data and can abort the boot,
|
|
581
|
+
// which is why it used to be withheld — correctly. What was wrong was
|
|
582
|
+
// withholding it in *silence*: the config said required, the column
|
|
583
|
+
// came out nullable, and nothing anywhere said so.
|
|
584
|
+
// `populatedTables` absent means the caller does not know, and not
|
|
585
|
+
// knowing has to read as "assume rows" — the other direction emits a
|
|
586
|
+
// NOT NULL that is checked against live data and aborts the boot.
|
|
587
|
+
// Written as an explicit `!== undefined` because the optional-chain
|
|
588
|
+
// form (`!existing.populatedTables?.has(key)`) quietly says *empty*
|
|
589
|
+
// when the fact is missing, which is the wrong way to be wrong.
|
|
590
|
+
const tableIsEmpty = existing.populatedTables !== undefined
|
|
591
|
+
&& existing.tables.has(key)
|
|
592
|
+
&& !existing.populatedTables.has(key);
|
|
593
|
+
const notNullIsSafe = fresh || tableIsEmpty || hasDefault;
|
|
594
|
+
|
|
595
|
+
if (required && !columnExists) {
|
|
596
|
+
if (notNullIsSafe) {
|
|
597
|
+
definition += " NOT NULL";
|
|
598
|
+
} else {
|
|
599
|
+
withheldConstraints.push({
|
|
600
|
+
target: columnKey,
|
|
601
|
+
kind: "not-null",
|
|
602
|
+
reason:
|
|
603
|
+
`"${column}" is required, but "${key}" already holds rows and the column ` +
|
|
604
|
+
"has no default to backfill them with, so NOT NULL would be checked " +
|
|
605
|
+
"against data that does not have a value yet.",
|
|
606
|
+
remedy:
|
|
607
|
+
"Backfill the column, then add the constraint — or give the property a " +
|
|
608
|
+
"default so every existing row gets one."
|
|
609
|
+
});
|
|
610
|
+
}
|
|
450
611
|
}
|
|
451
|
-
if (fresh && p.validation?.required) definition += " NOT NULL";
|
|
452
612
|
addColumn(key, schema, table, column, definition);
|
|
613
|
+
|
|
614
|
+
// The column is already there and only its constraint differs. Two
|
|
615
|
+
// directions, and they are not equally safe — see `ConstraintPolicy`
|
|
616
|
+
// for why neither runs at an unattended boot.
|
|
617
|
+
if (columnExists && constraintPolicy === "converge") {
|
|
618
|
+
const isNotNull = existing.notNullColumns?.has(columnKey) === true;
|
|
619
|
+
if (required && !isNotNull) {
|
|
620
|
+
if (tableIsEmpty) {
|
|
621
|
+
actions.push({
|
|
622
|
+
kind: "set-not-null",
|
|
623
|
+
target: columnKey,
|
|
624
|
+
sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" SET NOT NULL;`
|
|
625
|
+
});
|
|
626
|
+
} else {
|
|
627
|
+
withheldConstraints.push({
|
|
628
|
+
target: columnKey,
|
|
629
|
+
kind: "not-null",
|
|
630
|
+
reason:
|
|
631
|
+
`"${column}" became required, but "${key}" holds rows and any of them ` +
|
|
632
|
+
"with no value would make SET NOT NULL fail.",
|
|
633
|
+
remedy:
|
|
634
|
+
"Backfill the column first — `UPDATE … SET \"" + column +
|
|
635
|
+
"\" = … WHERE \"" + column + "\" IS NULL` — then apply this again."
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (!required && isNotNull) {
|
|
640
|
+
// Loosening never fails and never loses data. It is here
|
|
641
|
+
// rather than at boot because a database adopted by
|
|
642
|
+
// introspection carries NOT NULL on columns the generated
|
|
643
|
+
// collection deliberately leaves optional, and converging
|
|
644
|
+
// those unasked would drop constraints nobody edited.
|
|
645
|
+
actions.push({
|
|
646
|
+
kind: "drop-not-null",
|
|
647
|
+
target: columnKey,
|
|
648
|
+
sql: `ALTER TABLE "${schema}"."${table}" ALTER COLUMN "${column}" DROP NOT NULL;`
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
453
652
|
}
|
|
454
653
|
|
|
455
654
|
// The auth columns the collection never mentions. The scaffold's users
|
|
@@ -595,7 +794,54 @@ export function planCollectionSchemaEnsure(
|
|
|
595
794
|
}
|
|
596
795
|
}
|
|
597
796
|
|
|
598
|
-
|
|
797
|
+
// ANN indexes for vector columns, on the same terms: the column has to
|
|
798
|
+
// exist, the build is real work against real rows, and CONCURRENTLY is
|
|
799
|
+
// what keeps that from locking writes for its duration.
|
|
800
|
+
//
|
|
801
|
+
// A column too wide for pgvector to index is reported, not planned —
|
|
802
|
+
// silence there would read as "indexed" to anyone watching the boot.
|
|
803
|
+
const vectorIndexSkipped: SkippedVectorIndex[] = [];
|
|
804
|
+
for (const collection of collections) {
|
|
805
|
+
const plan = buildVectorIndexPlan(collection, resolveColumnName);
|
|
806
|
+
for (const spec of plan.specs) {
|
|
807
|
+
actions.push({
|
|
808
|
+
kind: "create-index",
|
|
809
|
+
target: `${spec.schema}.${spec.table}`,
|
|
810
|
+
sql: vectorIndexStatement(spec).replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
vectorIndexSkipped.push(...plan.skipped);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Declared indexes, on exactly the same terms as the ANN ones above.
|
|
817
|
+
//
|
|
818
|
+
// Boot has to emit these, not just `db push`: the managed runtime
|
|
819
|
+
// provisions at boot and never runs a push, so a push-only index would
|
|
820
|
+
// simply not exist there — and nothing would say so.
|
|
821
|
+
// `contracts/derived-names.txt` states the rule ("Both, or it is a
|
|
822
|
+
// bug") and the gate enforces it, which is what caught this.
|
|
823
|
+
//
|
|
824
|
+
// `concurrently` is a parameter here rather than a string replacement
|
|
825
|
+
// on the rendered SQL. The `.replace("CREATE INDEX IF NOT EXISTS", …)`
|
|
826
|
+
// just above silently does nothing for a UNIQUE index, whose text is
|
|
827
|
+
// `CREATE UNIQUE INDEX …` and never matches the pattern.
|
|
828
|
+
for (const spec of buildCollectionIndexPlan(collections, resolveColumnName)) {
|
|
829
|
+
actions.push({
|
|
830
|
+
kind: "create-index",
|
|
831
|
+
target: `${spec.schema}.${spec.table}`,
|
|
832
|
+
sql: collectionIndexStatement(spec, { concurrently: true, ifNotExists: true })
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
actions,
|
|
838
|
+
statements: actions.map(a => a.sql),
|
|
839
|
+
legacyForeignKeys,
|
|
840
|
+
searchDrift,
|
|
841
|
+
searchAdopted,
|
|
842
|
+
vectorIndexSkipped,
|
|
843
|
+
withheldConstraints
|
|
844
|
+
};
|
|
599
845
|
}
|
|
600
846
|
|
|
601
847
|
/** Read what the database has, for the schemas the collections live in. */
|
|
@@ -611,12 +857,14 @@ export async function readExistingSchema(
|
|
|
611
857
|
.map(schema => `'${assertSafeIdentifier(schema, "schema name")}'`)
|
|
612
858
|
.join(", ");
|
|
613
859
|
|
|
860
|
+
const notNullColumns = new Set<string>();
|
|
614
861
|
const { rows: columns } = await client.query<{
|
|
615
862
|
table_schema: string;
|
|
616
863
|
table_name: string;
|
|
617
864
|
column_name: string;
|
|
865
|
+
is_nullable: string;
|
|
618
866
|
}>(
|
|
619
|
-
`SELECT table_schema, table_name, column_name
|
|
867
|
+
`SELECT table_schema, table_name, column_name, is_nullable
|
|
620
868
|
FROM information_schema.columns
|
|
621
869
|
WHERE table_schema IN (${inList})`
|
|
622
870
|
);
|
|
@@ -624,6 +872,64 @@ export async function readExistingSchema(
|
|
|
624
872
|
const key = `${row.table_schema}.${row.table_name}`;
|
|
625
873
|
if (!tables.has(key)) tables.set(key, new Set());
|
|
626
874
|
tables.get(key)!.add(row.column_name);
|
|
875
|
+
if (row.is_nullable === "NO") notNullColumns.add(`${key}.${row.column_name}`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Which tables hold rows. This is the only fact that decides whether a
|
|
879
|
+
// NOT NULL can be added without reading the data, so it is worth a query.
|
|
880
|
+
//
|
|
881
|
+
// `reltuples` would be cheaper and is wrong for this: it is a planner
|
|
882
|
+
// estimate, it is -1 on a table that has never been analyzed, and a table
|
|
883
|
+
// that was full an hour ago still reads as full after a DELETE. A wrong
|
|
884
|
+
// "empty" here means a boot that aborts on a constraint violation, so the
|
|
885
|
+
// estimate is not good enough. `EXISTS … LIMIT 1` stops at the first row,
|
|
886
|
+
// which makes the true cost one page read per table.
|
|
887
|
+
//
|
|
888
|
+
// Restricted to ordinary and partitioned tables: `information_schema.columns`
|
|
889
|
+
// also lists views and materialized views, and probing those runs whatever
|
|
890
|
+
// query defines them.
|
|
891
|
+
const populatedTables = new Set<string>();
|
|
892
|
+
const { rows: realTables } = await client.query<{ schema: string; name: string }>(
|
|
893
|
+
`SELECT n.nspname AS schema, c.relname AS name
|
|
894
|
+
FROM pg_class c
|
|
895
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
896
|
+
WHERE c.relkind IN ('r', 'p') AND n.nspname IN (${inList})`
|
|
897
|
+
);
|
|
898
|
+
if (realTables.length > 0) {
|
|
899
|
+
const probes = realTables.map(row => {
|
|
900
|
+
const schema = assertSafeIdentifier(row.schema, "schema name");
|
|
901
|
+
const table = assertSafeIdentifier(row.name, "table name");
|
|
902
|
+
return `SELECT ${quoteSqlLiteral(`${schema}.${table}`)} AS key, ` +
|
|
903
|
+
`EXISTS(SELECT 1 FROM "${schema}"."${table}" LIMIT 1) AS populated`;
|
|
904
|
+
});
|
|
905
|
+
const { rows: populationRows } = await client.query<{ key: string; populated: boolean }>(
|
|
906
|
+
probes.join(" UNION ALL ")
|
|
907
|
+
);
|
|
908
|
+
for (const row of populationRows) {
|
|
909
|
+
if (row.populated) populatedTables.add(row.key);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const enumValues = new Map<string, string[]>();
|
|
914
|
+
const { rows: enumValueRows } = await client.query<{
|
|
915
|
+
schema: string;
|
|
916
|
+
name: string;
|
|
917
|
+
value: string;
|
|
918
|
+
}>(
|
|
919
|
+
// Ordered by `enumsortorder`, not by label: an enum's order is part of
|
|
920
|
+
// its meaning (it is what `<` compares), and reading it back sorted
|
|
921
|
+
// alphabetically would make a correct type look drifted.
|
|
922
|
+
`SELECT n.nspname AS schema, t.typname AS name, e.enumlabel AS value
|
|
923
|
+
FROM pg_enum e
|
|
924
|
+
JOIN pg_type t ON e.enumtypid = t.oid
|
|
925
|
+
JOIN pg_namespace n ON t.typnamespace = n.oid
|
|
926
|
+
WHERE n.nspname IN (${inList})
|
|
927
|
+
ORDER BY t.typname, e.enumsortorder`
|
|
928
|
+
);
|
|
929
|
+
for (const row of enumValueRows) {
|
|
930
|
+
const key = `${row.schema}.${row.name}`;
|
|
931
|
+
if (!enumValues.has(key)) enumValues.set(key, []);
|
|
932
|
+
enumValues.get(key)!.push(row.value);
|
|
627
933
|
}
|
|
628
934
|
|
|
629
935
|
const { rows: enumRows } = await client.query<{ schema: string; name: string }>(
|
|
@@ -672,7 +978,7 @@ export async function readExistingSchema(
|
|
|
672
978
|
columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
|
|
673
979
|
}
|
|
674
980
|
|
|
675
|
-
return { tables, enums, constraints, columnComments };
|
|
981
|
+
return { tables, enums, constraints, columnComments, enumValues, notNullColumns, populatedTables };
|
|
676
982
|
}
|
|
677
983
|
|
|
678
984
|
/**
|
|
@@ -707,23 +1013,47 @@ function searchDriftMessage(drift: SearchColumnDrift[]): string {
|
|
|
707
1013
|
* The missing-pgvector explanation, appended to the error that reveals it.
|
|
708
1014
|
*
|
|
709
1015
|
* A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
|
|
710
|
-
* OSS pipeline installs pgvector — not this ensure, not `db push
|
|
711
|
-
*
|
|
712
|
-
* extension on someone's database is a decision with a deployment behind it
|
|
1016
|
+
* OSS pipeline installs pgvector — not this ensure, not `db push`. Installing
|
|
1017
|
+
* an extension on someone's database is a decision with a deployment behind it
|
|
713
1018
|
* (image, superuser, cloud allow-list), so this path stays a refusal; what it
|
|
714
1019
|
* must not stay is a bare `type "vector" does not exist` on a crash-looping
|
|
715
1020
|
* pod, which names nothing the reader can act on.
|
|
1021
|
+
*
|
|
1022
|
+
* The scaffold now ships `pgvector/pgvector:pg18`, so this is reached by a
|
|
1023
|
+
* project pointed at a database someone else provisioned — which is exactly
|
|
1024
|
+
* the case where naming the extension and the image is worth the words.
|
|
716
1025
|
*/
|
|
717
1026
|
function vectorExtensionHint(message: string): string {
|
|
718
1027
|
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
719
1028
|
return (
|
|
720
1029
|
"\n pgvector is not installed on this database, and Rebase does not install it: it is a server extension, " +
|
|
721
|
-
"so it needs an image that ships it (
|
|
1030
|
+
"so it needs an image that ships it (the scaffold's `pgvector/pgvector:pg18` does; a stock `postgres:18` " +
|
|
722
1031
|
"does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. " +
|
|
723
|
-
"
|
|
1032
|
+
"Rebase then creates an ANN index for the column automatically — see the `index` option on the property."
|
|
724
1033
|
);
|
|
725
1034
|
}
|
|
726
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* Read what the database looks like, for the schemas a set of collections
|
|
1038
|
+
* lives in.
|
|
1039
|
+
*
|
|
1040
|
+
* The same read `ensureCollectionTables` does at boot, exposed on its own for
|
|
1041
|
+
* the callers that want to *plan* against a real database without changing it —
|
|
1042
|
+
* the live schema editor, which has to tell somebody what a change would do
|
|
1043
|
+
* before they agree to it.
|
|
1044
|
+
*/
|
|
1045
|
+
export async function readSchemaFactsFor(
|
|
1046
|
+
client: Queryable,
|
|
1047
|
+
collections: CollectionConfig[]
|
|
1048
|
+
): Promise<ExistingSchema> {
|
|
1049
|
+
const relational = relationalCollections(collections);
|
|
1050
|
+
const schemas = Array.from(new Set([
|
|
1051
|
+
...relational.map(schemaOf),
|
|
1052
|
+
...planJunctionTables(relational).map(junction => junction.schema)
|
|
1053
|
+
]));
|
|
1054
|
+
return readExistingSchema(client, schemas);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
727
1057
|
/**
|
|
728
1058
|
* Bring the database up to date. Returns what it did.
|
|
729
1059
|
*
|
|
@@ -793,6 +1123,27 @@ export async function ensureCollectionTables(
|
|
|
793
1123
|
log?.(message);
|
|
794
1124
|
}
|
|
795
1125
|
|
|
1126
|
+
// Said once per column, every boot: an unindexed vector column and an
|
|
1127
|
+
// indexed one behave identically apart from latency, so the only way anyone
|
|
1128
|
+
// learns which one they have is if the boot says so.
|
|
1129
|
+
for (const skip of plan.vectorIndexSkipped) {
|
|
1130
|
+
const message = `No ANN index on "${skip.table}"."${skip.column}": ${skip.reason}`;
|
|
1131
|
+
logger.warn(`[schema] ${message}`);
|
|
1132
|
+
log?.(message);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Said once per column, every boot, because the alternative is what this
|
|
1136
|
+
// whole feature exists to end: a column the configuration calls required,
|
|
1137
|
+
// sitting there nullable, with every surface reporting success. The boot
|
|
1138
|
+
// does not fail over it — the column is usable and the data is intact — but
|
|
1139
|
+
// it stops being invisible.
|
|
1140
|
+
for (const withheld of plan.withheldConstraints) {
|
|
1141
|
+
const message =
|
|
1142
|
+
`No NOT NULL on "${withheld.target}": ${withheld.reason} ${withheld.remedy}`;
|
|
1143
|
+
logger.warn(`[schema] ${message}`);
|
|
1144
|
+
log?.(message);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
796
1147
|
if (plan.actions.length === 0) {
|
|
797
1148
|
log?.("Schema is up to date; nothing to create.");
|
|
798
1149
|
return { ...plan, failures };
|
|
@@ -308,7 +308,12 @@ export const getDrizzleColumn = (propName: string, prop: Property, collection: C
|
|
|
308
308
|
const targetTableVar = getTableVarName(getTableName(targetCollection));
|
|
309
309
|
const pkProp = getPrimaryKeyProp(targetCollection);
|
|
310
310
|
const targetIdField = pkProp.name;
|
|
311
|
-
|
|
311
|
+
// `quote`, like every other column literal in this file: a column
|
|
312
|
+
// name only has to be quotable in Postgres, and this one is derived
|
|
313
|
+
// from a relation the author wrote.
|
|
314
|
+
const baseColumn = pkProp.type === "number"
|
|
315
|
+
? `integer(${quote(fkColumnName)})`
|
|
316
|
+
: (pkProp.isUuid ? `uuid(${quote(fkColumnName)})` : `text(${quote(fkColumnName)})`);
|
|
312
317
|
|
|
313
318
|
const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
|
|
314
319
|
const required = prop.validation?.required;
|
|
@@ -681,10 +686,13 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
|
|
|
681
686
|
const targetId = getPrimaryKeyName(targetCollection);
|
|
682
687
|
|
|
683
688
|
schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
|
|
684
|
-
|
|
685
|
-
|
|
689
|
+
// The junction block was the one place these three helpers were not
|
|
690
|
+
// applied, so a junction column containing a space or a hyphen —
|
|
691
|
+
// both legal in Postgres — produced a file that does not parse.
|
|
692
|
+
schemaContent += ` ${propKey(sourceColumn)}: ${sourceColType}(${quote(sourceColumn)}).notNull().references(() => ${member(getTableVarName(getTableName(sourceCollection)), sourceId)}, ${refOptions}),\n`;
|
|
693
|
+
schemaContent += ` ${propKey(targetColumn)}: ${targetColType}(${quote(targetColumn)}).notNull().references(() => ${member(getTableVarName(getTableName(targetCollection)), targetId)}, ${refOptions}),\n`;
|
|
686
694
|
schemaContent += "}, (table) => ([\n";
|
|
687
|
-
schemaContent += ` primaryKey({ columns: [table
|
|
695
|
+
schemaContent += ` primaryKey({ columns: [${member("table", sourceColumn)}, ${member("table", targetColumn)}] }),\n`;
|
|
688
696
|
|
|
689
697
|
// Junctions are generated tables like any other: locked by default,
|
|
690
698
|
// with derived policies (reads follow the endpoints, writes follow
|
|
@@ -719,19 +727,20 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
|
|
|
719
727
|
const searchSpec = buildSearchColumnSpec(collection);
|
|
720
728
|
if (searchSpec) {
|
|
721
729
|
columns.add(
|
|
722
|
-
` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })(
|
|
730
|
+
` ${propKey(searchSpec.column)}: customType({ dataType() { return 'tsvector'; } })(${quote(searchSpec.column)})` +
|
|
723
731
|
`.generatedAlwaysAs(sql\`${searchSpec.expression}\`)`
|
|
724
732
|
);
|
|
725
733
|
if (searchSpec.fuzzy) {
|
|
726
734
|
columns.add(
|
|
727
|
-
` ${searchSpec.fuzzy.column}: text(
|
|
735
|
+
` ${propKey(searchSpec.fuzzy.column)}: text(${quote(searchSpec.fuzzy.column)})` +
|
|
728
736
|
`.generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`
|
|
729
737
|
);
|
|
730
738
|
}
|
|
731
739
|
}
|
|
732
740
|
|
|
733
|
-
//
|
|
734
|
-
//
|
|
741
|
+
// A collection that declares no primary key gets an implicit one:
|
|
742
|
+
// `id TEXT PRIMARY KEY`. The DDL generator emits the same column,
|
|
743
|
+
// and `derivePrimaryKeys` reads it back.
|
|
735
744
|
const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
|
|
736
745
|
if (!hasIdColumn) {
|
|
737
746
|
columns.add(" id: text(\"id\").primaryKey()");
|
|
@@ -860,11 +869,14 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
|
|
|
860
869
|
// `fields`/`references` here is invalid and crashes
|
|
861
870
|
// `normalizeRelation` with "Cannot read properties of
|
|
862
871
|
// undefined (reading 'referencedTable')".
|
|
863
|
-
|
|
872
|
+
// A `relationName` is authored, and a `"` in one
|
|
873
|
+
// closed this string literal early — in a file that
|
|
874
|
+
// is compiled and imported by the server.
|
|
875
|
+
tableRelations.push(` ${quote(relationKey)}: one(${targetTableVar}, {\n relationName: ${quote(drizzleRelationName)}\n })`);
|
|
864
876
|
break;
|
|
865
877
|
|
|
866
878
|
case "hasMany":
|
|
867
|
-
tableRelations.push(`
|
|
879
|
+
tableRelations.push(` ${quote(relationKey)}: many(${targetTableVar}, { relationName: ${quote(drizzleRelationName)} })`);
|
|
868
880
|
break;
|
|
869
881
|
|
|
870
882
|
case "manyToMany": {
|
|
@@ -874,7 +886,7 @@ export const generateSchema = async (allCollections: CollectionConfig[], stripPo
|
|
|
874
886
|
// junction table — unnecessary now that each side names
|
|
875
887
|
// its own.
|
|
876
888
|
const junctionTableVar = getTableVarName(rel.through.table);
|
|
877
|
-
tableRelations.push(`
|
|
889
|
+
tableRelations.push(` ${quote(relationKey)}: many(${junctionTableVar}, { relationName: ${quote(drizzleRelationName)} })`);
|
|
878
890
|
break;
|
|
879
891
|
}
|
|
880
892
|
|
|
@@ -2,7 +2,6 @@ import { promises as fsPromises } from "fs";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { pathToFileURL } from "url";
|
|
5
|
-
import chokidar from "chokidar";
|
|
6
5
|
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
7
6
|
import { CollectionConfig } from "@rebasepro/types";
|
|
8
7
|
import { loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
@@ -93,7 +92,7 @@ const runGeneration = async (collectionsFilePath?: string, outputPath?: string)
|
|
|
93
92
|
}
|
|
94
93
|
};
|
|
95
94
|
|
|
96
|
-
const main = () => {
|
|
95
|
+
const main = async () => {
|
|
97
96
|
const collectionsFilePathArg = process.argv.find(arg => arg.startsWith("--collections="));
|
|
98
97
|
const collectionsFilePath = collectionsFilePathArg ? collectionsFilePathArg.split("=")[1] : process.argv[2];
|
|
99
98
|
|
|
@@ -112,6 +111,18 @@ const main = () => {
|
|
|
112
111
|
|
|
113
112
|
if (watch) {
|
|
114
113
|
out(`Watching for changes in ${resolvedPath}...`);
|
|
114
|
+
// Imported here rather than at module scope, and this is not a style
|
|
115
|
+
// choice: chokidar is needed only by `--watch`, which is a
|
|
116
|
+
// schema-authoring path that never runs inside the runtime image. A
|
|
117
|
+
// top-level import puts it on the boot path of the published driver
|
|
118
|
+
// bundle, and the image installs a hand-listed set of runtime
|
|
119
|
+
// dependencies that does not include it — so the whole driver failed to
|
|
120
|
+
// load with "Cannot find package 'chokidar'", and every self-hosted
|
|
121
|
+
// container answered 500 with a stack trace about a file watcher.
|
|
122
|
+
//
|
|
123
|
+
// Same reasoning the image already applies to @ariga/atlas: an
|
|
124
|
+
// authoring-only dependency does not belong on a boot path.
|
|
125
|
+
const { default: chokidar } = await import("chokidar");
|
|
115
126
|
const watcher = chokidar.watch(resolvedPath, {
|
|
116
127
|
persistent: true,
|
|
117
128
|
ignoreInitial: false
|