@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g06dbe5b
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 +49 -2
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +40 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-Bww-Lg0s.js +8999 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -0
- package/dist/cli-helpers.d.ts +96 -1
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -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-B_JMGa5K.js +57 -0
- package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
- package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +2460 -10233
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +185 -144
- 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 +23 -25
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +99 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +38 -8
- package/dist/services/RelationService.d.ts +54 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +62 -2
- package/dist/services/dataService.d.ts +8 -1
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +129 -6
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-D1qbmLZ2.js +587 -0
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +176 -6
- package/src/PostgresBootstrapper.ts +245 -37
- package/src/auth/ensure-tables.ts +357 -81
- package/src/auth/services.ts +93 -7
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +199 -10
- package/src/cli.ts +276 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +129 -20
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +46 -19
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +572 -42
- package/src/schema/generate-drizzle-schema-logic.ts +169 -43
- package/src/schema/generate-postgres-ddl-logic.ts +670 -29
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +446 -100
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +11 -3
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +265 -122
- package/src/services/PersistService.ts +57 -7
- package/src/services/RelationService.ts +358 -114
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +123 -4
- package/src/services/dataService.ts +8 -1
- package/src/services/realtimeService.ts +346 -81
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +396 -29
- package/src/utils/pg-error-utils.ts +73 -3
- package/src/websocket.ts +130 -24
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -26,12 +26,34 @@
|
|
|
26
26
|
* no-op.
|
|
27
27
|
*/
|
|
28
28
|
import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
29
|
-
import { getTableName } from "@rebasepro/common";
|
|
29
|
+
import { getTableName, relationalCollections } from "@rebasepro/common";
|
|
30
|
+
import { logger } from "@rebasepro/server";
|
|
31
|
+
import {
|
|
32
|
+
assertSearchIsPostgresOnly,
|
|
33
|
+
buildSearchColumnSpec,
|
|
34
|
+
searchExtensionStatements,
|
|
35
|
+
searchHelperFunctions,
|
|
36
|
+
searchIndexStatements,
|
|
37
|
+
searchColumnStamps,
|
|
38
|
+
SEARCH_STAMP_PREFIX,
|
|
39
|
+
SEARCH_TEXT_FN,
|
|
40
|
+
SEARCH_UNACCENT_FN,
|
|
41
|
+
type SearchColumnSpec
|
|
42
|
+
} from "./search-column";
|
|
30
43
|
import {
|
|
31
44
|
getSqlColumnType,
|
|
32
45
|
resolveColumnName,
|
|
33
|
-
isIdProperty
|
|
46
|
+
isIdProperty,
|
|
47
|
+
planRelationalColumns,
|
|
48
|
+
planJunctionTables,
|
|
49
|
+
quoteSqlLiteral
|
|
34
50
|
} from "./generate-postgres-ddl-logic";
|
|
51
|
+
import {
|
|
52
|
+
AUTH_USERS_COLUMNS,
|
|
53
|
+
authUsersColumnDefinition,
|
|
54
|
+
authUsersColumnSql,
|
|
55
|
+
isAuthCollection
|
|
56
|
+
} from "./auth-users-columns";
|
|
35
57
|
|
|
36
58
|
/**
|
|
37
59
|
* The subset of a database handle this needs: run a statement, get rows back.
|
|
@@ -62,10 +84,27 @@ export interface ExistingSchema {
|
|
|
62
84
|
tables: Map<string, Set<string>>;
|
|
63
85
|
/** `schema.typename` of every enum type that already exists. */
|
|
64
86
|
enums: Set<string>;
|
|
87
|
+
/**
|
|
88
|
+
* `schema.table.constraint` of every constraint that already exists.
|
|
89
|
+
*
|
|
90
|
+
* Optional so a caller that only cares about tables can still build one by
|
|
91
|
+
* hand; absent is read as "none known", which at worst re-attempts a
|
|
92
|
+
* constraint that then fails harmlessly as a duplicate.
|
|
93
|
+
*/
|
|
94
|
+
constraints?: Set<string>;
|
|
95
|
+
/**
|
|
96
|
+
* `schema.table.column` → that column's comment, for the columns that have
|
|
97
|
+
* one. This is where a generated search column's fingerprint lives, so it
|
|
98
|
+
* is the only evidence that a `search` block has changed since the column
|
|
99
|
+
* was built. Absent is read as "no column is stamped", which plans a stamp
|
|
100
|
+
* and reports nothing as drifted.
|
|
101
|
+
*/
|
|
102
|
+
columnComments?: Map<string, string>;
|
|
65
103
|
}
|
|
66
104
|
|
|
67
105
|
export interface EnsureAction {
|
|
68
|
-
kind: "create-enum" | "create-table" | "add-column"
|
|
106
|
+
kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column"
|
|
107
|
+
| "create-extension" | "create-function" | "create-index" | "comment-column";
|
|
69
108
|
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
70
109
|
target: string;
|
|
71
110
|
sql: string;
|
|
@@ -75,6 +114,82 @@ export interface EnsurePlan {
|
|
|
75
114
|
actions: EnsureAction[];
|
|
76
115
|
/** Every statement, in dependency order. Empty when the schema is current. */
|
|
77
116
|
statements: string[];
|
|
117
|
+
/**
|
|
118
|
+
* Relation columns this plan is about to create where the table already
|
|
119
|
+
* carries the same column under its pre-singularization name.
|
|
120
|
+
*
|
|
121
|
+
* The reason this is reported rather than silently handled: the ensure is
|
|
122
|
+
* additive, so it would add `category_id` beside a populated
|
|
123
|
+
* `categorie_id` and the relation would then read the new, empty one. No
|
|
124
|
+
* statement fails, no table is missing, and the only symptom is relations
|
|
125
|
+
* resolving to nothing — which is indistinguishable from having no data.
|
|
126
|
+
*/
|
|
127
|
+
legacyForeignKeys: LegacyForeignKey[];
|
|
128
|
+
/**
|
|
129
|
+
* Generated search columns whose `search` block has changed since they were
|
|
130
|
+
* built. Reported, never planned into `actions` — see
|
|
131
|
+
* {@link SearchColumnDrift} for why applying it is not this path's call.
|
|
132
|
+
*/
|
|
133
|
+
searchDrift: SearchColumnDrift[];
|
|
134
|
+
/**
|
|
135
|
+
* Generated search columns that exist but carry no fingerprint — created
|
|
136
|
+
* before this check existed, or by `search.sql` on an older CLI. The plan
|
|
137
|
+
* stamps them so the *next* change is detectable; whether they match the
|
|
138
|
+
* current block cannot be known, which is what the caller reports.
|
|
139
|
+
*/
|
|
140
|
+
searchAdopted: { table: string; column: string }[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A generated search column built from a `search` block that has since changed.
|
|
145
|
+
*
|
|
146
|
+
* Reported instead of applied because the two ways to apply it are both worse
|
|
147
|
+
* than stopping. `ALTER COLUMN … SET EXPRESSION` exists only on PG17+ and
|
|
148
|
+
* rewrites the table either way; `DROP COLUMN` + `ADD COLUMN` rewrites it under
|
|
149
|
+
* an ACCESS EXCLUSIVE lock and rebuilds the GIN index. This module runs
|
|
150
|
+
* unattended against live customer data with nobody reading a diff — the same
|
|
151
|
+
* reason it withholds `SET NOT NULL` from an adopted table — so a multi-minute
|
|
152
|
+
* outage is not a decision it may take on its own.
|
|
153
|
+
*
|
|
154
|
+
* Not applying it silently is not an option either: that is the bug this
|
|
155
|
+
* detection exists for. A collection that added a field, flipped `unaccent` or
|
|
156
|
+
* raised a weight kept indexing the *old* set forever, and the only symptom was
|
|
157
|
+
* searches returning nothing for content plainly in the row.
|
|
158
|
+
*/
|
|
159
|
+
export interface SearchColumnDrift {
|
|
160
|
+
/** `schema.table`. */
|
|
161
|
+
table: string;
|
|
162
|
+
column: string;
|
|
163
|
+
/** The fingerprint recorded on the column. */
|
|
164
|
+
found: string;
|
|
165
|
+
/** The fingerprint the current `search` block computes. */
|
|
166
|
+
expected: string;
|
|
167
|
+
/** The statements that would rebuild the column, for the operator to run. */
|
|
168
|
+
rebuild: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A relation column whose old and new spellings both plausibly apply. */
|
|
172
|
+
export interface LegacyForeignKey {
|
|
173
|
+
/** `schema.table`. */
|
|
174
|
+
table: string;
|
|
175
|
+
/** The name the current rule derives, and what this plan would create. */
|
|
176
|
+
expected: string;
|
|
177
|
+
/** The name the old rule derived, which the table already has. */
|
|
178
|
+
legacy: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface EnsureOutcome extends EnsurePlan {
|
|
182
|
+
/**
|
|
183
|
+
* Actions that could not be applied and are non-fatal by nature.
|
|
184
|
+
*
|
|
185
|
+
* Two kinds qualify. A foreign key can only fail on data that already
|
|
186
|
+
* violates it, and the column it would police exists either way, so the
|
|
187
|
+
* collection still serves; refusing to boot over one would turn a
|
|
188
|
+
* pre-existing data problem into an outage. A column comment is the search
|
|
189
|
+
* fingerprint, which needs table ownership — losing it costs drift
|
|
190
|
+
* detection on the next boot, not the deployment. Both are reported loudly.
|
|
191
|
+
*/
|
|
192
|
+
failures: { kind: EnsureAction["kind"]; target: string; error: string }[];
|
|
78
193
|
}
|
|
79
194
|
|
|
80
195
|
function schemaOf(collection: CollectionConfig): string {
|
|
@@ -114,11 +229,6 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
|
|
|
114
229
|
return out;
|
|
115
230
|
}
|
|
116
231
|
|
|
117
|
-
/** Single-quote escaping for an enum label. */
|
|
118
|
-
function quoteLiteral(value: string): string {
|
|
119
|
-
return `'${value.replace(/'/g, "''")}'`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
232
|
/**
|
|
123
233
|
* Decide what to add. Pure — the caller supplies what exists and runs the result.
|
|
124
234
|
*
|
|
@@ -127,9 +237,19 @@ function quoteLiteral(value: string): string {
|
|
|
127
237
|
* table may be the target of a relation), and nothing is emitted twice.
|
|
128
238
|
*/
|
|
129
239
|
export function planCollectionSchemaEnsure(
|
|
130
|
-
|
|
240
|
+
allCollections: CollectionConfig[],
|
|
131
241
|
existing: ExistingSchema
|
|
132
242
|
): EnsurePlan {
|
|
243
|
+
// Boot receives every collection the bundle declares, including the ones
|
|
244
|
+
// served by another engine entirely. Creating a Postgres table for a
|
|
245
|
+
// Firestore collection is not a harmless extra: the app keeps reading
|
|
246
|
+
// documents from Firestore while an empty table with the same name accretes
|
|
247
|
+
// policies and shows up in every drift report.
|
|
248
|
+
// Before the filter, deliberately: a `search` block on a collection this
|
|
249
|
+
// engine does not store would otherwise be dropped here without a word.
|
|
250
|
+
assertSearchIsPostgresOnly(allCollections);
|
|
251
|
+
|
|
252
|
+
const collections = relationalCollections(allCollections);
|
|
133
253
|
const actions: EnsureAction[] = [];
|
|
134
254
|
const plannedEnums = new Set<string>();
|
|
135
255
|
|
|
@@ -143,11 +263,39 @@ export function planCollectionSchemaEnsure(
|
|
|
143
263
|
actions.push({
|
|
144
264
|
kind: "create-enum",
|
|
145
265
|
target: name,
|
|
146
|
-
sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(
|
|
266
|
+
sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});`
|
|
147
267
|
});
|
|
148
268
|
}
|
|
149
269
|
}
|
|
150
270
|
|
|
271
|
+
// 1b. Search support, for collections that declared a `search` block.
|
|
272
|
+
//
|
|
273
|
+
// Before the tables, because a generated column's expression is
|
|
274
|
+
// resolved when the column is created: a table whose search column
|
|
275
|
+
// calls `rebase_search_text` cannot be added before that function
|
|
276
|
+
// exists. Both forms are idempotent, so a boot against a database that
|
|
277
|
+
// already has them plans nothing.
|
|
278
|
+
const searchSpecs = collections
|
|
279
|
+
.map(c => buildSearchColumnSpec(c))
|
|
280
|
+
.filter((spec): spec is SearchColumnSpec => spec !== undefined);
|
|
281
|
+
|
|
282
|
+
const plannedExtensions = new Set<string>();
|
|
283
|
+
for (const spec of searchSpecs) {
|
|
284
|
+
for (const statement of searchExtensionStatements(spec)) {
|
|
285
|
+
if (plannedExtensions.has(statement)) continue;
|
|
286
|
+
plannedExtensions.add(statement);
|
|
287
|
+
actions.push({ kind: "create-extension", target: statement.replace(/^CREATE EXTENSION IF NOT EXISTS |;$/g, ""), sql: statement });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const plannedFunctions = new Set<string>();
|
|
291
|
+
for (const spec of searchSpecs) {
|
|
292
|
+
for (const statement of searchHelperFunctions(spec)) {
|
|
293
|
+
if (plannedFunctions.has(statement)) continue;
|
|
294
|
+
plannedFunctions.add(statement);
|
|
295
|
+
actions.push({ kind: "create-function", target: statement.includes("unaccent") ? SEARCH_UNACCENT_FN : SEARCH_TEXT_FN, sql: statement });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
151
299
|
// 2. Missing tables. Only the identity column is created here; every other
|
|
152
300
|
// column is added by step 3, so a new table and an existing table that
|
|
153
301
|
// gained a field travel the exact same code path. One way to build a
|
|
@@ -164,17 +312,22 @@ export function planCollectionSchemaEnsure(
|
|
|
164
312
|
);
|
|
165
313
|
const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
|
|
166
314
|
const idProp = idEntry?.[1] as Property | undefined;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
315
|
+
// Derived through the generator's own type mapping, not re-decided here.
|
|
316
|
+
// This branch used to emit BIGSERIAL for a numeric id while `db push`
|
|
317
|
+
// emitted INTEGER GENERATED BY DEFAULT AS IDENTITY, so the same project
|
|
318
|
+
// got an int8 key when the runtime brought the schema up and an int4 key
|
|
319
|
+
// when a human pushed it. Two consequences, both real: node-postgres
|
|
320
|
+
// hands back int8 as a *string*, so a collection declaring
|
|
321
|
+
// `type: "number"` served `"1"` instead of `1` on the managed path only;
|
|
322
|
+
// and every foreign key and junction column pointing at it stayed
|
|
323
|
+
// INTEGER on both paths, which is a truncation waiting for the sequence
|
|
324
|
+
// to pass 2^31. The agreement test now pins this.
|
|
325
|
+
const idType = idProp
|
|
326
|
+
? getSqlColumnType(idEntry![0], idProp, collection, collections)
|
|
327
|
+
: "TEXT";
|
|
328
|
+
let idDef = `"${idName}" ${idType} PRIMARY KEY`;
|
|
329
|
+
if (idProp?.type === "string" && (idProp as { isId?: unknown }).isId === "uuid") {
|
|
330
|
+
idDef += " DEFAULT gen_random_uuid()";
|
|
178
331
|
}
|
|
179
332
|
actions.push({
|
|
180
333
|
kind: "create-table",
|
|
@@ -183,35 +336,266 @@ export function planCollectionSchemaEnsure(
|
|
|
183
336
|
});
|
|
184
337
|
}
|
|
185
338
|
|
|
339
|
+
// 2b. Junction tables behind many-to-many relations. No collection declares
|
|
340
|
+
// them, so the walk above never sees them — and until they existed, an
|
|
341
|
+
// m2m write had nowhere to land and the junction's derived RLS had
|
|
342
|
+
// nothing to attach to.
|
|
343
|
+
const junctions = planJunctionTables(collections);
|
|
344
|
+
for (const junction of junctions) {
|
|
345
|
+
const key = `${junction.schema}.${junction.table}`;
|
|
346
|
+
if (existing.tables.has(key) || created.has(key)) continue;
|
|
347
|
+
created.add(key);
|
|
348
|
+
actions.push({ kind: "create-table", target: key, sql: junction.createTable });
|
|
349
|
+
}
|
|
350
|
+
|
|
186
351
|
// 3. Missing columns, on both brand-new and pre-existing tables.
|
|
352
|
+
const legacyForeignKeys: LegacyForeignKey[] = [];
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Move a relation column that is only missing because it was renamed.
|
|
356
|
+
*
|
|
357
|
+
* Returns true when it handled the column, so the caller skips the ordinary
|
|
358
|
+
* ADD. Adding here would be the wrong move and a quiet one: the data is in
|
|
359
|
+
* the old column, `ADD COLUMN` creates the new one empty beside it, every
|
|
360
|
+
* statement succeeds, and the relation reads the empty one. A rename is
|
|
361
|
+
* metadata-only in Postgres, keeps the values, and carries the column's
|
|
362
|
+
* indexes and constraints with it.
|
|
363
|
+
*
|
|
364
|
+
* Only ever reached when the new name is absent and the old name is
|
|
365
|
+
* present, so there is nothing to overwrite and nothing to choose between.
|
|
366
|
+
*/
|
|
367
|
+
const renameLegacyColumn = (
|
|
368
|
+
key: string,
|
|
369
|
+
schema: string,
|
|
370
|
+
table: string,
|
|
371
|
+
column: string,
|
|
372
|
+
legacyName: string | undefined
|
|
373
|
+
): boolean => {
|
|
374
|
+
const present = existing.tables.get(key);
|
|
375
|
+
if (!legacyName || !present) return false;
|
|
376
|
+
if (present.has(column) || !present.has(legacyName)) return false;
|
|
377
|
+
|
|
378
|
+
legacyForeignKeys.push({ table: key, expected: column, legacy: legacyName });
|
|
379
|
+
actions.push({
|
|
380
|
+
kind: "rename-column",
|
|
381
|
+
target: `${key}.${column}`,
|
|
382
|
+
sql: `ALTER TABLE "${schema}"."${table}" RENAME COLUMN "${legacyName}" TO "${column}";`
|
|
383
|
+
});
|
|
384
|
+
return true;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const addColumn = (
|
|
388
|
+
key: string,
|
|
389
|
+
schema: string,
|
|
390
|
+
table: string,
|
|
391
|
+
column: string,
|
|
392
|
+
definition: string
|
|
393
|
+
): void => {
|
|
394
|
+
const present = existing.tables.get(key);
|
|
395
|
+
if (present?.has(column)) return;
|
|
396
|
+
actions.push({
|
|
397
|
+
kind: "add-column",
|
|
398
|
+
target: `${key}.${column}`,
|
|
399
|
+
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
|
|
187
403
|
for (const collection of collections) {
|
|
188
404
|
const key = qualified(collection);
|
|
189
405
|
const schema = schemaOf(collection);
|
|
190
406
|
const table = getTableName(collection);
|
|
191
|
-
|
|
407
|
+
// A table this run is creating has no rows yet, so the constraints
|
|
408
|
+
// `db push` writes are free to apply. On a table that already exists
|
|
409
|
+
// they are not: `SET NOT NULL` is checked against live rows and a UNIQUE
|
|
410
|
+
// would fail on existing duplicates, and this module runs unattended
|
|
411
|
+
// against customer data with nobody reading a diff. So the constraints
|
|
412
|
+
// are emitted for the fresh case — which is the whole managed-runtime
|
|
413
|
+
// path, and the one that diverged from `db push` — and withheld for the
|
|
414
|
+
// adopted one. `rebase db push` remains how an existing table gets them.
|
|
415
|
+
const fresh = created.has(key);
|
|
416
|
+
const auth = isAuthCollection(collection);
|
|
192
417
|
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
193
418
|
const p = prop as Property;
|
|
194
419
|
if (isIdProperty(propName, p, collection)) continue;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
420
|
+
// Relation and reference columns are planned from the shared
|
|
421
|
+
// relational planner below, which derives the column name, type and
|
|
422
|
+
// foreign key the same way `db push` does. Deriving them here as
|
|
423
|
+
// plain columns is what once produced a column with no constraint.
|
|
199
424
|
if (p.type === "reference" || p.type === "relation") continue;
|
|
425
|
+
|
|
200
426
|
const column = resolveColumnName(propName, p);
|
|
201
|
-
|
|
202
|
-
|
|
427
|
+
// On an auth collection, the columns auth itself reads and writes
|
|
428
|
+
// have exactly one definition, wherever the table is created from —
|
|
429
|
+
// see `auth-users-columns`. Anything else on that collection is an
|
|
430
|
+
// ordinary user-declared field and is generated like any other.
|
|
431
|
+
const authDefinition = auth ? authUsersColumnDefinition(column) : undefined;
|
|
432
|
+
if (authDefinition) {
|
|
433
|
+
addColumn(key, schema, table, column, authDefinition);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Assembled in the generator's order — type, UNIQUE, DEFAULT,
|
|
438
|
+
// NOT NULL — so the two produce byte-identical column definitions
|
|
439
|
+
// and the agreement test can compare them directly instead of
|
|
440
|
+
// checking that a column merely exists, which is how BIGSERIAL-vs-
|
|
441
|
+
// INTEGER and every missing constraint went unnoticed.
|
|
442
|
+
let definition = getSqlColumnType(propName, p, collection, collections);
|
|
443
|
+
if (fresh && p.validation?.unique) definition += " UNIQUE";
|
|
444
|
+
// Not gated on `fresh`: a default binds future writes only, so it is
|
|
445
|
+
// safe on a live table, and a column added without it would take the
|
|
446
|
+
// value the application forgot to send rather than `now()`.
|
|
447
|
+
const autoValue = (p as { autoValue?: string }).autoValue;
|
|
448
|
+
if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
|
|
449
|
+
definition += " DEFAULT now()";
|
|
450
|
+
}
|
|
451
|
+
if (fresh && p.validation?.required) definition += " NOT NULL";
|
|
452
|
+
addColumn(key, schema, table, column, definition);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The auth columns the collection never mentions. The scaffold's users
|
|
456
|
+
// collection describes 12 of the 14 auth reads and writes, so planning
|
|
457
|
+
// only from properties left `is_anonymous` and `tokens_valid_after` to
|
|
458
|
+
// `ensureAuthTablesExist` — which does create them, but only because
|
|
459
|
+
// that function happens to run later in the same boot. Planning them
|
|
460
|
+
// here makes this path self-contained and identical to `db push`, so
|
|
461
|
+
// neither depends on the other having run.
|
|
462
|
+
if (auth) {
|
|
463
|
+
const declared = new Set(
|
|
464
|
+
Object.entries(collection.properties ?? {})
|
|
465
|
+
.map(([name, prop]) => resolveColumnName(name, prop as Property))
|
|
466
|
+
);
|
|
467
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
468
|
+
if (declared.has(spec.column)) continue;
|
|
469
|
+
addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// 3aa. The generated search columns.
|
|
475
|
+
//
|
|
476
|
+
// Adding a STORED generated column rewrites the table, which on a large
|
|
477
|
+
// one is not free — but it is the same additive shape as every other
|
|
478
|
+
// column here, and the alternative (leaving it out until someone runs a
|
|
479
|
+
// migration) is a declared `search` block that silently does nothing.
|
|
480
|
+
//
|
|
481
|
+
// Changing one is not additive, and `ADD COLUMN IF NOT EXISTS` is a
|
|
482
|
+
// no-op against a column that is already there — which is why a `search`
|
|
483
|
+
// block that gained a field, flipped `unaccent` or moved a weight used
|
|
484
|
+
// to be inert forever, on every path, with nothing logged. Each column
|
|
485
|
+
// therefore carries a fingerprint of the expression it was built from
|
|
486
|
+
// (in its comment), and a mismatch is reported rather than applied.
|
|
487
|
+
const searchDrift: SearchColumnDrift[] = [];
|
|
488
|
+
const searchAdopted: { table: string; column: string }[] = [];
|
|
489
|
+
for (const spec of searchSpecs) {
|
|
490
|
+
const key = `${spec.schema}.${spec.table}`;
|
|
491
|
+
const definitions: Record<string, string> = {
|
|
492
|
+
[spec.column]: `tsvector GENERATED ALWAYS AS (${spec.expression}) STORED`
|
|
493
|
+
};
|
|
494
|
+
if (spec.fuzzy) {
|
|
495
|
+
definitions[spec.fuzzy.column] = `text GENERATED ALWAYS AS (${spec.fuzzy.expression}) STORED`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
for (const stamp of searchColumnStamps(spec)) {
|
|
499
|
+
const definition = definitions[stamp.column];
|
|
500
|
+
const exists = existing.tables.get(key)?.has(stamp.column) === true;
|
|
501
|
+
const recorded = existing.columnComments?.get(`${key}.${stamp.column}`);
|
|
502
|
+
|
|
503
|
+
if (exists && recorded?.startsWith(SEARCH_STAMP_PREFIX) && recorded !== stamp.fingerprint) {
|
|
504
|
+
searchDrift.push({
|
|
505
|
+
table: key,
|
|
506
|
+
column: stamp.column,
|
|
507
|
+
found: recorded,
|
|
508
|
+
expected: stamp.fingerprint,
|
|
509
|
+
rebuild: [
|
|
510
|
+
`ALTER TABLE "${spec.schema}"."${spec.table}" DROP COLUMN "${stamp.column}";`,
|
|
511
|
+
`ALTER TABLE "${spec.schema}"."${spec.table}" ADD COLUMN "${stamp.column}" ${definition};`,
|
|
512
|
+
stamp.sql
|
|
513
|
+
]
|
|
514
|
+
});
|
|
515
|
+
// The old stamp is the only evidence of what the column holds;
|
|
516
|
+
// overwriting it here would erase the drift instead of fixing it.
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
addColumn(key, spec.schema, spec.table, stamp.column, definition);
|
|
521
|
+
if (exists && recorded === undefined) {
|
|
522
|
+
searchAdopted.push({ table: key, column: stamp.column });
|
|
523
|
+
}
|
|
524
|
+
if (recorded !== stamp.fingerprint) {
|
|
525
|
+
actions.push({
|
|
526
|
+
kind: "comment-column",
|
|
527
|
+
target: `${key}.${stamp.column}`,
|
|
528
|
+
sql: stamp.sql
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 3b. A junction that already existed, but is short a column. One created
|
|
535
|
+
// above already carries both — unlike a collection table, whose CREATE
|
|
536
|
+
// declares only the identity column — so re-listing them would log two
|
|
537
|
+
// no-op statements and inflate the count of changes applied.
|
|
538
|
+
for (const junction of junctions) {
|
|
539
|
+
const key = `${junction.schema}.${junction.table}`;
|
|
540
|
+
if (created.has(key)) continue;
|
|
541
|
+
for (const column of junction.columns) {
|
|
542
|
+
if (renameLegacyColumn(key, junction.schema, junction.table, column.name, column.legacyName)) continue;
|
|
543
|
+
addColumn(key, junction.schema, junction.table, column.name, column.type);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 3c. The columns relation and reference properties own.
|
|
548
|
+
for (const relational of planRelationalColumns(collections)) {
|
|
549
|
+
const relKey = `${relational.schema}.${relational.table}`;
|
|
550
|
+
if (renameLegacyColumn(relKey, relational.schema, relational.table, relational.column, relational.legacyColumn)) continue;
|
|
551
|
+
addColumn(
|
|
552
|
+
relKey,
|
|
553
|
+
relational.schema,
|
|
554
|
+
relational.table,
|
|
555
|
+
relational.column,
|
|
556
|
+
relational.type
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 4. Foreign keys, last: the tables and columns on both ends have to exist
|
|
561
|
+
// first, and a constraint is the one thing here that can fail on data
|
|
562
|
+
// rather than on schema, so nothing else depends on it.
|
|
563
|
+
const knownConstraints = existing.constraints ?? new Set<string>();
|
|
564
|
+
const plannedConstraints = new Set<string>();
|
|
565
|
+
const foreignKeys = [
|
|
566
|
+
...planRelationalColumns(collections).map(r => r.foreignKey),
|
|
567
|
+
...junctions.flatMap(j => j.foreignKeys)
|
|
568
|
+
];
|
|
569
|
+
for (const fk of foreignKeys) {
|
|
570
|
+
if (!fk) continue;
|
|
571
|
+
const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
|
|
572
|
+
if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
|
|
573
|
+
plannedConstraints.add(name);
|
|
574
|
+
actions.push({
|
|
575
|
+
kind: "add-constraint",
|
|
576
|
+
target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
|
|
577
|
+
sql: fk.sql
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 5. Search indexes, after everything — the column has to exist, and this is
|
|
582
|
+
// the one step that runs against a populated table for real work.
|
|
583
|
+
//
|
|
584
|
+
// CONCURRENTLY: a plain CREATE INDEX takes a lock that blocks writes for
|
|
585
|
+
// the duration of the build, which on a live table is an outage. Each
|
|
586
|
+
// statement here is issued on its own, outside any transaction, which is
|
|
587
|
+
// the condition CONCURRENTLY requires.
|
|
588
|
+
for (const spec of searchSpecs) {
|
|
589
|
+
for (const statement of searchIndexStatements(spec)) {
|
|
203
590
|
actions.push({
|
|
204
|
-
kind: "
|
|
205
|
-
target: `${
|
|
206
|
-
|
|
207
|
-
// non-null column without a default, and inventing one would be
|
|
208
|
-
// guessing at the customer's data.
|
|
209
|
-
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
|
|
591
|
+
kind: "create-index",
|
|
592
|
+
target: `${spec.schema}.${spec.table}`,
|
|
593
|
+
sql: statement.replace("CREATE INDEX IF NOT EXISTS", "CREATE INDEX CONCURRENTLY IF NOT EXISTS")
|
|
210
594
|
});
|
|
211
595
|
}
|
|
212
596
|
}
|
|
213
597
|
|
|
214
|
-
return { actions, statements: actions.map(a => a.sql) };
|
|
598
|
+
return { actions, statements: actions.map(a => a.sql), legacyForeignKeys, searchDrift, searchAdopted };
|
|
215
599
|
}
|
|
216
600
|
|
|
217
601
|
/** Read what the database has, for the schemas the collections live in. */
|
|
@@ -250,7 +634,94 @@ export async function readExistingSchema(
|
|
|
250
634
|
);
|
|
251
635
|
for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
|
|
252
636
|
|
|
253
|
-
|
|
637
|
+
// `ADD CONSTRAINT` has no IF NOT EXISTS, so an existing foreign key is
|
|
638
|
+
// skipped by name rather than guarded in SQL.
|
|
639
|
+
const constraints = new Set<string>();
|
|
640
|
+
const { rows: constraintRows } = await client.query<{
|
|
641
|
+
schema: string;
|
|
642
|
+
table: string;
|
|
643
|
+
name: string;
|
|
644
|
+
}>(
|
|
645
|
+
`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
|
|
646
|
+
FROM pg_constraint con
|
|
647
|
+
JOIN pg_class c ON con.conrelid = c.oid
|
|
648
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
649
|
+
WHERE n.nspname IN (${inList})`
|
|
650
|
+
);
|
|
651
|
+
for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
|
|
652
|
+
|
|
653
|
+
// Column comments, which is where a generated search column records the
|
|
654
|
+
// expression it was built from. `objsubid > 0` is what makes a row a
|
|
655
|
+
// *column* comment rather than the table's own.
|
|
656
|
+
const columnComments = new Map<string, string>();
|
|
657
|
+
const { rows: commentRows } = await client.query<{
|
|
658
|
+
schema: string;
|
|
659
|
+
table: string;
|
|
660
|
+
column: string;
|
|
661
|
+
comment: string | null;
|
|
662
|
+
}>(
|
|
663
|
+
`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column, d.description AS comment
|
|
664
|
+
FROM pg_description d
|
|
665
|
+
JOIN pg_class c ON d.objoid = c.oid
|
|
666
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
667
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = d.objsubid
|
|
668
|
+
WHERE d.objsubid > 0 AND n.nspname IN (${inList})`
|
|
669
|
+
);
|
|
670
|
+
for (const row of commentRows) {
|
|
671
|
+
if (row.comment == null) continue;
|
|
672
|
+
columnComments.set(`${row.schema}.${row.table}.${row.column}`, row.comment);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return { tables, enums, constraints, columnComments };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* What to tell an operator whose `search` block no longer matches its column.
|
|
680
|
+
*
|
|
681
|
+
* Every line here is doing work: naming the collection is not enough, because
|
|
682
|
+
* the symptom (a search that finds nothing) points at the data, not the schema;
|
|
683
|
+
* and the remediation has to be exact, because it is a table rewrite the
|
|
684
|
+
* operator is being asked to schedule rather than discover.
|
|
685
|
+
*/
|
|
686
|
+
function searchDriftMessage(drift: SearchColumnDrift[]): string {
|
|
687
|
+
const blocks = drift.map(d =>
|
|
688
|
+
` "${d.table}"."${d.column}" was generated from a different \`search\` block ` +
|
|
689
|
+
`(recorded ${d.found}, current ${d.expected}).\n` +
|
|
690
|
+
d.rebuild.map(s => ` ${s}`).join("\n")
|
|
691
|
+
);
|
|
692
|
+
return (
|
|
693
|
+
"The `search` block changed after its generated column was created, and Postgres cannot alter a " +
|
|
694
|
+
"generated expression in place.\n" +
|
|
695
|
+
"Rebase will not rebuild it for you: dropping and re-adding a STORED generated column rewrites the whole " +
|
|
696
|
+
"table under an ACCESS EXCLUSIVE lock and rebuilds its GIN index, which is an outage this unattended path " +
|
|
697
|
+
"may not schedule on your behalf.\n" +
|
|
698
|
+
"Until it is rebuilt the column keeps indexing the previous fields, weights and language — searches for " +
|
|
699
|
+
"anything added since return nothing, which reads from outside as \"no such row\".\n" +
|
|
700
|
+
"Run these (or revert the block to what the column was built from), then boot again:\n" +
|
|
701
|
+
blocks.join("\n") +
|
|
702
|
+
"\n The GIN index is dropped with the column and recreated concurrently on the next boot."
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The missing-pgvector explanation, appended to the error that reveals it.
|
|
708
|
+
*
|
|
709
|
+
* A `{ type: "vector" }` property compiles to `VECTOR(n)`, and nothing in the
|
|
710
|
+
* OSS pipeline installs pgvector — not this ensure, not `db push`, not the
|
|
711
|
+
* scaffold's `postgres:18-alpine`, which does not ship it. Installing an
|
|
712
|
+
* extension on someone's database is a decision with a deployment behind it
|
|
713
|
+
* (image, superuser, cloud allow-list), so this path stays a refusal; what it
|
|
714
|
+
* must not stay is a bare `type "vector" does not exist` on a crash-looping
|
|
715
|
+
* pod, which names nothing the reader can act on.
|
|
716
|
+
*/
|
|
717
|
+
function vectorExtensionHint(message: string): string {
|
|
718
|
+
if (!/type "(vector|halfvec|sparsevec)" does not exist/i.test(message)) return "";
|
|
719
|
+
return (
|
|
720
|
+
"\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 (e.g. `pgvector/pgvector:pg18` — the scaffold's `postgres:18-alpine` " +
|
|
722
|
+
"does not) and a role allowed to run `CREATE EXTENSION vector;`. Install it once, then boot again. " +
|
|
723
|
+
"Note also that Rebase creates no ANN index for a vector column, so `vectorSearch` is an exact scan."
|
|
724
|
+
);
|
|
254
725
|
}
|
|
255
726
|
|
|
256
727
|
/**
|
|
@@ -265,8 +736,14 @@ export async function ensureCollectionTables(
|
|
|
265
736
|
client: Queryable,
|
|
266
737
|
collections: CollectionConfig[],
|
|
267
738
|
log?: (message: string) => void
|
|
268
|
-
): Promise<
|
|
269
|
-
|
|
739
|
+
): Promise<EnsureOutcome> {
|
|
740
|
+
// Junctions live alongside the collections that declare them, so their
|
|
741
|
+
// schema has to be read too — otherwise an existing junction reads as
|
|
742
|
+
// missing and its constraints as unplanned.
|
|
743
|
+
const schemas = Array.from(new Set([
|
|
744
|
+
...collections.map(schemaOf),
|
|
745
|
+
...planJunctionTables(collections).map(j => j.schema)
|
|
746
|
+
]));
|
|
270
747
|
for (const schema of schemas) {
|
|
271
748
|
assertSafeIdentifier(schema, "schema name");
|
|
272
749
|
if (schema !== "public") {
|
|
@@ -276,10 +753,49 @@ export async function ensureCollectionTables(
|
|
|
276
753
|
|
|
277
754
|
const existing = await readExistingSchema(client, schemas);
|
|
278
755
|
const plan = planCollectionSchemaEnsure(collections, existing);
|
|
756
|
+
const failures: EnsureOutcome["failures"] = [];
|
|
757
|
+
|
|
758
|
+
// Reported, not warned: this is a rename the ensure is about to perform, and
|
|
759
|
+
// the operator should be able to see in the log why a column changed name.
|
|
760
|
+
// The interesting case is the one that no longer happens — before this,
|
|
761
|
+
// ensure added the new column empty beside the populated old one, every
|
|
762
|
+
// statement succeeded, and the relation read the empty one.
|
|
763
|
+
for (const legacy of plan.legacyForeignKeys) {
|
|
764
|
+
const message =
|
|
765
|
+
`Renaming "${legacy.table}"."${legacy.legacy}" to "${legacy.expected}". The old name is ` +
|
|
766
|
+
"the one Rebase derived for this relation before it singularized properly; the column " +
|
|
767
|
+
"keeps its data, indexes and constraints. To keep the old name instead, set " +
|
|
768
|
+
`\`localKey: "${legacy.legacy}"\` on the relation and this will stop.`;
|
|
769
|
+
logger.info(`[schema] ${message}`);
|
|
770
|
+
log?.(message);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Before anything is applied: a `search` block that changed after its column
|
|
774
|
+
// was generated cannot be honoured by an additive plan, and serving the old
|
|
775
|
+
// index while the config describes a new one is the silent failure this
|
|
776
|
+
// check exists to end. Refusing is the loud half — boot is fatal on purpose
|
|
777
|
+
// (see `ensureCollectionSchema` in the server's boot) and the message
|
|
778
|
+
// carries the exact statements that resolve it.
|
|
779
|
+
if (plan.searchDrift.length > 0) {
|
|
780
|
+
throw new Error(searchDriftMessage(plan.searchDrift));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Said once per column, at the moment the stamp is applied: from here on a
|
|
784
|
+
// change is detected, but whether *this* column matches the block it is
|
|
785
|
+
// being stamped with is not knowable — it predates the stamp.
|
|
786
|
+
for (const adopted of plan.searchAdopted) {
|
|
787
|
+
const message =
|
|
788
|
+
`Adopting the existing generated column "${adopted.table}"."${adopted.column}" and recording what the ` +
|
|
789
|
+
"current `search` block would generate. Any later change to that block will be detected and refused; a " +
|
|
790
|
+
"change made *before* this version was deployed cannot be, so if search has been missing content, " +
|
|
791
|
+
`rebuild the column once: ALTER TABLE "${adopted.table.split(".").join('"."')}" DROP COLUMN "${adopted.column}"; and boot again.`;
|
|
792
|
+
logger.info(`[schema] ${message}`);
|
|
793
|
+
log?.(message);
|
|
794
|
+
}
|
|
279
795
|
|
|
280
796
|
if (plan.actions.length === 0) {
|
|
281
797
|
log?.("Schema is up to date; nothing to create.");
|
|
282
|
-
return plan;
|
|
798
|
+
return { ...plan, failures };
|
|
283
799
|
}
|
|
284
800
|
|
|
285
801
|
for (const action of plan.actions) {
|
|
@@ -287,11 +803,25 @@ export async function ensureCollectionTables(
|
|
|
287
803
|
await client.query(action.sql);
|
|
288
804
|
log?.(`${action.kind}: ${action.target}`);
|
|
289
805
|
} catch (err) {
|
|
806
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
807
|
+
// A foreign key is the only action that can fail on the customer's
|
|
808
|
+
// data rather than on the schema. The column it polices is already
|
|
809
|
+
// there, so the collection serves either way — record it and carry
|
|
810
|
+
// on rather than crash-looping the deployment.
|
|
811
|
+
//
|
|
812
|
+
// A comment is metadata about a column that was just created
|
|
813
|
+
// successfully, and it can only fail on ownership (COMMENT requires
|
|
814
|
+
// owning the table, which an adopted table may not grant). Losing
|
|
815
|
+
// the stamp costs drift detection on the next boot; it must not cost
|
|
816
|
+
// the deployment.
|
|
817
|
+
if (action.kind === "add-constraint" || action.kind === "comment-column") {
|
|
818
|
+
failures.push({ kind: action.kind, target: action.target, error: message });
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
290
821
|
throw new Error(
|
|
291
|
-
`Failed to ${action.kind} ${action.target}: `
|
|
292
|
-
`${err instanceof Error ? err.message : String(err)}\n ${action.sql}`
|
|
822
|
+
`Failed to ${action.kind} ${action.target}: ${message}${vectorExtensionHint(message)}\n ${action.sql}`
|
|
293
823
|
);
|
|
294
824
|
}
|
|
295
825
|
}
|
|
296
|
-
return plan;
|
|
826
|
+
return { ...plan, failures };
|
|
297
827
|
}
|