@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7
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 +21 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
- package/dist/auth-users-columns-Do9mw5Y5.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 +40 -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/ensure-collection-policies-DMUdRQdM.js +57 -0
- package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
- package/dist/index.es.js +1701 -10167
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CPkCqVTz.js +105 -0
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +45 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -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/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +14 -8
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/services/dataService.d.ts +5 -1
- package/dist/services/realtimeService.d.ts +25 -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 +5 -4
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-D0TBU3ia.js +530 -0
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +169 -3
- package/src/PostgresBootstrapper.ts +233 -35
- package/src/auth/ensure-tables.ts +335 -81
- package/src/auth/services.ts +24 -2
- 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 +92 -9
- package/src/cli.ts +219 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +22 -6
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- 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 +301 -44
- package/src/schema/generate-drizzle-schema-logic.ts +49 -7
- package/src/schema/generate-postgres-ddl-logic.ts +474 -27
- 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 +385 -71
- 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/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +11 -3
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +100 -107
- package/src/services/PersistService.ts +34 -7
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +5 -1
- package/src/services/realtimeService.ts +38 -35
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- 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
|
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
|
|
|
87
87
|
expect(sql).not.toMatch(/NOT NULL/i);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
// ── Relations ────────────────────────────────────────────────────────────
|
|
91
|
+
//
|
|
92
|
+
// These columns were once skipped outright, on the reasoning that a bare
|
|
93
|
+
// column without its foreign key would disagree with what `db push` later
|
|
94
|
+
// generated. On a managed tenant nothing pushes afterwards, so the table
|
|
95
|
+
// arrived without the column its own collection reads: every insert 400ed
|
|
96
|
+
// with `column "author_id" does not exist`. The answer is to emit the key
|
|
97
|
+
// too, from the same planner `db push` uses.
|
|
98
|
+
|
|
99
|
+
const authors = {
|
|
100
|
+
name: "Authors",
|
|
101
|
+
slug: "authors",
|
|
102
|
+
properties: {
|
|
103
|
+
id: { name: "ID", type: "string", isId: "uuid" },
|
|
104
|
+
name: { name: "Name", type: "string" }
|
|
105
|
+
}
|
|
106
|
+
} as unknown as CollectionConfig;
|
|
107
|
+
|
|
108
|
+
const postsWithAuthor = {
|
|
109
|
+
...posts,
|
|
110
|
+
properties: {
|
|
111
|
+
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
112
|
+
author: { name: "Author", type: "reference", path: "authors" }
|
|
113
|
+
}
|
|
114
|
+
} as unknown as CollectionConfig;
|
|
115
|
+
|
|
116
|
+
const tags = {
|
|
117
|
+
name: "Tags",
|
|
118
|
+
slug: "tags",
|
|
119
|
+
properties: {
|
|
120
|
+
id: { name: "ID", type: "number", isId: "increment" },
|
|
121
|
+
name: { name: "Name", type: "string" }
|
|
122
|
+
}
|
|
123
|
+
} as unknown as CollectionConfig;
|
|
124
|
+
|
|
125
|
+
const postsWithTags = {
|
|
126
|
+
...posts,
|
|
127
|
+
properties: {
|
|
128
|
+
...(posts as unknown as { properties: Record<string, unknown> }).properties,
|
|
129
|
+
tags: {
|
|
130
|
+
name: "Tags",
|
|
131
|
+
type: "relation",
|
|
132
|
+
relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
|
|
96
133
|
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
134
|
+
}
|
|
135
|
+
} as unknown as CollectionConfig;
|
|
136
|
+
|
|
137
|
+
it("adds a reference column, and the foreign key that makes it one", () => {
|
|
138
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
|
|
139
|
+
|
|
140
|
+
expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
|
|
141
|
+
const fk = plan.actions.find(a => a.kind === "add-constraint");
|
|
142
|
+
expect(fk?.target).toBe("public.posts.posts_author_fkey");
|
|
143
|
+
expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("orders every constraint after the columns and tables it depends on", () => {
|
|
147
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
|
|
148
|
+
const kinds = plan.actions.map(a => a.kind);
|
|
149
|
+
expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
150
|
+
expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
|
|
154
|
+
const existing: ExistingSchema = {
|
|
155
|
+
tables: new Map([
|
|
156
|
+
["public.posts", new Set(["id", "author"])],
|
|
157
|
+
["public.authors", new Set(["id", "name"])]
|
|
158
|
+
]),
|
|
159
|
+
enums: new Set(["public.posts_status"]),
|
|
160
|
+
constraints: new Set(["public.posts.posts_author_fkey"])
|
|
161
|
+
};
|
|
162
|
+
const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
|
|
163
|
+
expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
|
|
167
|
+
const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
|
|
168
|
+
const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
|
|
169
|
+
|
|
170
|
+
expect(junction).toBeDefined();
|
|
171
|
+
// The endpoint key types have to match the primary keys they reference:
|
|
172
|
+
// posts is a uuid, tags an auto-increment integer.
|
|
173
|
+
expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
|
|
174
|
+
expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
|
|
175
|
+
expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
|
|
176
|
+
|
|
177
|
+
const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
|
|
178
|
+
expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
|
|
179
|
+
expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("leaves an existing junction table alone", () => {
|
|
183
|
+
const existing: ExistingSchema = {
|
|
184
|
+
tables: new Map([
|
|
185
|
+
["public.posts", new Set(["id", "title", "views", "status"])],
|
|
186
|
+
["public.tags", new Set(["id", "name"])],
|
|
187
|
+
["public.posts_tags", new Set(["post_id", "tag_id"])]
|
|
188
|
+
]),
|
|
189
|
+
enums: new Set(["public.posts_status"]),
|
|
190
|
+
constraints: new Set([
|
|
191
|
+
"public.posts_tags.posts_tags_post_id_fkey",
|
|
192
|
+
"public.posts_tags.posts_tags_tag_id_fkey"
|
|
193
|
+
])
|
|
194
|
+
};
|
|
195
|
+
expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
|
|
100
196
|
});
|
|
101
197
|
});
|
|
102
198
|
|
|
@@ -26,12 +26,22 @@
|
|
|
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";
|
|
30
31
|
import {
|
|
31
32
|
getSqlColumnType,
|
|
32
33
|
resolveColumnName,
|
|
33
|
-
isIdProperty
|
|
34
|
+
isIdProperty,
|
|
35
|
+
planRelationalColumns,
|
|
36
|
+
planJunctionTables,
|
|
37
|
+
quoteSqlLiteral
|
|
34
38
|
} from "./generate-postgres-ddl-logic";
|
|
39
|
+
import {
|
|
40
|
+
AUTH_USERS_COLUMNS,
|
|
41
|
+
authUsersColumnDefinition,
|
|
42
|
+
authUsersColumnSql,
|
|
43
|
+
isAuthCollection
|
|
44
|
+
} from "./auth-users-columns";
|
|
35
45
|
|
|
36
46
|
/**
|
|
37
47
|
* The subset of a database handle this needs: run a statement, get rows back.
|
|
@@ -62,10 +72,18 @@ export interface ExistingSchema {
|
|
|
62
72
|
tables: Map<string, Set<string>>;
|
|
63
73
|
/** `schema.typename` of every enum type that already exists. */
|
|
64
74
|
enums: Set<string>;
|
|
75
|
+
/**
|
|
76
|
+
* `schema.table.constraint` of every constraint that already exists.
|
|
77
|
+
*
|
|
78
|
+
* Optional so a caller that only cares about tables can still build one by
|
|
79
|
+
* hand; absent is read as "none known", which at worst re-attempts a
|
|
80
|
+
* constraint that then fails harmlessly as a duplicate.
|
|
81
|
+
*/
|
|
82
|
+
constraints?: Set<string>;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
export interface EnsureAction {
|
|
68
|
-
kind: "create-enum" | "create-table" | "add-column";
|
|
86
|
+
kind: "create-enum" | "create-table" | "add-column" | "add-constraint" | "rename-column";
|
|
69
87
|
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
70
88
|
target: string;
|
|
71
89
|
sql: string;
|
|
@@ -75,6 +93,39 @@ export interface EnsurePlan {
|
|
|
75
93
|
actions: EnsureAction[];
|
|
76
94
|
/** Every statement, in dependency order. Empty when the schema is current. */
|
|
77
95
|
statements: string[];
|
|
96
|
+
/**
|
|
97
|
+
* Relation columns this plan is about to create where the table already
|
|
98
|
+
* carries the same column under its pre-singularization name.
|
|
99
|
+
*
|
|
100
|
+
* The reason this is reported rather than silently handled: the ensure is
|
|
101
|
+
* additive, so it would add `category_id` beside a populated
|
|
102
|
+
* `categorie_id` and the relation would then read the new, empty one. No
|
|
103
|
+
* statement fails, no table is missing, and the only symptom is relations
|
|
104
|
+
* resolving to nothing — which is indistinguishable from having no data.
|
|
105
|
+
*/
|
|
106
|
+
legacyForeignKeys: LegacyForeignKey[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A relation column whose old and new spellings both plausibly apply. */
|
|
110
|
+
export interface LegacyForeignKey {
|
|
111
|
+
/** `schema.table`. */
|
|
112
|
+
table: string;
|
|
113
|
+
/** The name the current rule derives, and what this plan would create. */
|
|
114
|
+
expected: string;
|
|
115
|
+
/** The name the old rule derived, which the table already has. */
|
|
116
|
+
legacy: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface EnsureOutcome extends EnsurePlan {
|
|
120
|
+
/**
|
|
121
|
+
* Constraints that could not be added — always non-fatal.
|
|
122
|
+
*
|
|
123
|
+
* A foreign key can only fail on data that already violates it, and the
|
|
124
|
+
* column it would police exists either way, so the collection still serves.
|
|
125
|
+
* Refusing to boot over one would turn a pre-existing data problem into an
|
|
126
|
+
* outage. Reported loudly instead.
|
|
127
|
+
*/
|
|
128
|
+
failures: { target: string; error: string }[];
|
|
78
129
|
}
|
|
79
130
|
|
|
80
131
|
function schemaOf(collection: CollectionConfig): string {
|
|
@@ -114,11 +165,6 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
|
|
|
114
165
|
return out;
|
|
115
166
|
}
|
|
116
167
|
|
|
117
|
-
/** Single-quote escaping for an enum label. */
|
|
118
|
-
function quoteLiteral(value: string): string {
|
|
119
|
-
return `'${value.replace(/'/g, "''")}'`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
168
|
/**
|
|
123
169
|
* Decide what to add. Pure — the caller supplies what exists and runs the result.
|
|
124
170
|
*
|
|
@@ -127,9 +173,15 @@ function quoteLiteral(value: string): string {
|
|
|
127
173
|
* table may be the target of a relation), and nothing is emitted twice.
|
|
128
174
|
*/
|
|
129
175
|
export function planCollectionSchemaEnsure(
|
|
130
|
-
|
|
176
|
+
allCollections: CollectionConfig[],
|
|
131
177
|
existing: ExistingSchema
|
|
132
178
|
): EnsurePlan {
|
|
179
|
+
// Boot receives every collection the bundle declares, including the ones
|
|
180
|
+
// served by another engine entirely. Creating a Postgres table for a
|
|
181
|
+
// Firestore collection is not a harmless extra: the app keeps reading
|
|
182
|
+
// documents from Firestore while an empty table with the same name accretes
|
|
183
|
+
// policies and shows up in every drift report.
|
|
184
|
+
const collections = relationalCollections(allCollections);
|
|
133
185
|
const actions: EnsureAction[] = [];
|
|
134
186
|
const plannedEnums = new Set<string>();
|
|
135
187
|
|
|
@@ -143,7 +195,7 @@ export function planCollectionSchemaEnsure(
|
|
|
143
195
|
actions.push({
|
|
144
196
|
kind: "create-enum",
|
|
145
197
|
target: name,
|
|
146
|
-
sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(
|
|
198
|
+
sql: `CREATE TYPE "${schema}"."${typeName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});`
|
|
147
199
|
});
|
|
148
200
|
}
|
|
149
201
|
}
|
|
@@ -164,17 +216,22 @@ export function planCollectionSchemaEnsure(
|
|
|
164
216
|
);
|
|
165
217
|
const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
|
|
166
218
|
const idProp = idEntry?.[1] as Property | undefined;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
219
|
+
// Derived through the generator's own type mapping, not re-decided here.
|
|
220
|
+
// This branch used to emit BIGSERIAL for a numeric id while `db push`
|
|
221
|
+
// emitted INTEGER GENERATED BY DEFAULT AS IDENTITY, so the same project
|
|
222
|
+
// got an int8 key when the runtime brought the schema up and an int4 key
|
|
223
|
+
// when a human pushed it. Two consequences, both real: node-postgres
|
|
224
|
+
// hands back int8 as a *string*, so a collection declaring
|
|
225
|
+
// `type: "number"` served `"1"` instead of `1` on the managed path only;
|
|
226
|
+
// and every foreign key and junction column pointing at it stayed
|
|
227
|
+
// INTEGER on both paths, which is a truncation waiting for the sequence
|
|
228
|
+
// to pass 2^31. The agreement test now pins this.
|
|
229
|
+
const idType = idProp
|
|
230
|
+
? getSqlColumnType(idEntry![0], idProp, collection, collections)
|
|
231
|
+
: "TEXT";
|
|
232
|
+
let idDef = `"${idName}" ${idType} PRIMARY KEY`;
|
|
233
|
+
if (idProp?.type === "string" && (idProp as { isId?: unknown }).isId === "uuid") {
|
|
234
|
+
idDef += " DEFAULT gen_random_uuid()";
|
|
178
235
|
}
|
|
179
236
|
actions.push({
|
|
180
237
|
kind: "create-table",
|
|
@@ -183,35 +240,189 @@ export function planCollectionSchemaEnsure(
|
|
|
183
240
|
});
|
|
184
241
|
}
|
|
185
242
|
|
|
243
|
+
// 2b. Junction tables behind many-to-many relations. No collection declares
|
|
244
|
+
// them, so the walk above never sees them — and until they existed, an
|
|
245
|
+
// m2m write had nowhere to land and the junction's derived RLS had
|
|
246
|
+
// nothing to attach to.
|
|
247
|
+
const junctions = planJunctionTables(collections);
|
|
248
|
+
for (const junction of junctions) {
|
|
249
|
+
const key = `${junction.schema}.${junction.table}`;
|
|
250
|
+
if (existing.tables.has(key) || created.has(key)) continue;
|
|
251
|
+
created.add(key);
|
|
252
|
+
actions.push({ kind: "create-table", target: key, sql: junction.createTable });
|
|
253
|
+
}
|
|
254
|
+
|
|
186
255
|
// 3. Missing columns, on both brand-new and pre-existing tables.
|
|
256
|
+
const legacyForeignKeys: LegacyForeignKey[] = [];
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Move a relation column that is only missing because it was renamed.
|
|
260
|
+
*
|
|
261
|
+
* Returns true when it handled the column, so the caller skips the ordinary
|
|
262
|
+
* ADD. Adding here would be the wrong move and a quiet one: the data is in
|
|
263
|
+
* the old column, `ADD COLUMN` creates the new one empty beside it, every
|
|
264
|
+
* statement succeeds, and the relation reads the empty one. A rename is
|
|
265
|
+
* metadata-only in Postgres, keeps the values, and carries the column's
|
|
266
|
+
* indexes and constraints with it.
|
|
267
|
+
*
|
|
268
|
+
* Only ever reached when the new name is absent and the old name is
|
|
269
|
+
* present, so there is nothing to overwrite and nothing to choose between.
|
|
270
|
+
*/
|
|
271
|
+
const renameLegacyColumn = (
|
|
272
|
+
key: string,
|
|
273
|
+
schema: string,
|
|
274
|
+
table: string,
|
|
275
|
+
column: string,
|
|
276
|
+
legacyName: string | undefined
|
|
277
|
+
): boolean => {
|
|
278
|
+
const present = existing.tables.get(key);
|
|
279
|
+
if (!legacyName || !present) return false;
|
|
280
|
+
if (present.has(column) || !present.has(legacyName)) return false;
|
|
281
|
+
|
|
282
|
+
legacyForeignKeys.push({ table: key, expected: column, legacy: legacyName });
|
|
283
|
+
actions.push({
|
|
284
|
+
kind: "rename-column",
|
|
285
|
+
target: `${key}.${column}`,
|
|
286
|
+
sql: `ALTER TABLE "${schema}"."${table}" RENAME COLUMN "${legacyName}" TO "${column}";`
|
|
287
|
+
});
|
|
288
|
+
return true;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const addColumn = (
|
|
292
|
+
key: string,
|
|
293
|
+
schema: string,
|
|
294
|
+
table: string,
|
|
295
|
+
column: string,
|
|
296
|
+
definition: string
|
|
297
|
+
): void => {
|
|
298
|
+
const present = existing.tables.get(key);
|
|
299
|
+
if (present?.has(column)) return;
|
|
300
|
+
actions.push({
|
|
301
|
+
kind: "add-column",
|
|
302
|
+
target: `${key}.${column}`,
|
|
303
|
+
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
|
|
187
307
|
for (const collection of collections) {
|
|
188
308
|
const key = qualified(collection);
|
|
189
309
|
const schema = schemaOf(collection);
|
|
190
310
|
const table = getTableName(collection);
|
|
191
|
-
|
|
311
|
+
// A table this run is creating has no rows yet, so the constraints
|
|
312
|
+
// `db push` writes are free to apply. On a table that already exists
|
|
313
|
+
// they are not: `SET NOT NULL` is checked against live rows and a UNIQUE
|
|
314
|
+
// would fail on existing duplicates, and this module runs unattended
|
|
315
|
+
// against customer data with nobody reading a diff. So the constraints
|
|
316
|
+
// are emitted for the fresh case — which is the whole managed-runtime
|
|
317
|
+
// path, and the one that diverged from `db push` — and withheld for the
|
|
318
|
+
// adopted one. `rebase db push` remains how an existing table gets them.
|
|
319
|
+
const fresh = created.has(key);
|
|
320
|
+
const auth = isAuthCollection(collection);
|
|
192
321
|
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
193
322
|
const p = prop as Property;
|
|
194
323
|
if (isIdProperty(propName, p, collection)) continue;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
324
|
+
// Relation and reference columns are planned from the shared
|
|
325
|
+
// relational planner below, which derives the column name, type and
|
|
326
|
+
// foreign key the same way `db push` does. Deriving them here as
|
|
327
|
+
// plain columns is what once produced a column with no constraint.
|
|
199
328
|
if (p.type === "reference" || p.type === "relation") continue;
|
|
329
|
+
|
|
200
330
|
const column = resolveColumnName(propName, p);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
331
|
+
// On an auth collection, the columns auth itself reads and writes
|
|
332
|
+
// have exactly one definition, wherever the table is created from —
|
|
333
|
+
// see `auth-users-columns`. Anything else on that collection is an
|
|
334
|
+
// ordinary user-declared field and is generated like any other.
|
|
335
|
+
const authDefinition = auth ? authUsersColumnDefinition(column) : undefined;
|
|
336
|
+
if (authDefinition) {
|
|
337
|
+
addColumn(key, schema, table, column, authDefinition);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Assembled in the generator's order — type, UNIQUE, DEFAULT,
|
|
342
|
+
// NOT NULL — so the two produce byte-identical column definitions
|
|
343
|
+
// and the agreement test can compare them directly instead of
|
|
344
|
+
// checking that a column merely exists, which is how BIGSERIAL-vs-
|
|
345
|
+
// INTEGER and every missing constraint went unnoticed.
|
|
346
|
+
let definition = getSqlColumnType(propName, p, collection, collections);
|
|
347
|
+
if (fresh && p.validation?.unique) definition += " UNIQUE";
|
|
348
|
+
// Not gated on `fresh`: a default binds future writes only, so it is
|
|
349
|
+
// safe on a live table, and a column added without it would take the
|
|
350
|
+
// value the application forgot to send rather than `now()`.
|
|
351
|
+
const autoValue = (p as { autoValue?: string }).autoValue;
|
|
352
|
+
if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
|
|
353
|
+
definition += " DEFAULT now()";
|
|
354
|
+
}
|
|
355
|
+
if (fresh && p.validation?.required) definition += " NOT NULL";
|
|
356
|
+
addColumn(key, schema, table, column, definition);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The auth columns the collection never mentions. The scaffold's users
|
|
360
|
+
// collection describes 12 of the 14 auth reads and writes, so planning
|
|
361
|
+
// only from properties left `is_anonymous` and `tokens_valid_after` to
|
|
362
|
+
// `ensureAuthTablesExist` — which does create them, but only because
|
|
363
|
+
// that function happens to run later in the same boot. Planning them
|
|
364
|
+
// here makes this path self-contained and identical to `db push`, so
|
|
365
|
+
// neither depends on the other having run.
|
|
366
|
+
if (auth) {
|
|
367
|
+
const declared = new Set(
|
|
368
|
+
Object.entries(collection.properties ?? {})
|
|
369
|
+
.map(([name, prop]) => resolveColumnName(name, prop as Property))
|
|
370
|
+
);
|
|
371
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
372
|
+
if (declared.has(spec.column)) continue;
|
|
373
|
+
addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
|
|
374
|
+
}
|
|
211
375
|
}
|
|
212
376
|
}
|
|
213
377
|
|
|
214
|
-
|
|
378
|
+
// 3b. A junction that already existed, but is short a column. One created
|
|
379
|
+
// above already carries both — unlike a collection table, whose CREATE
|
|
380
|
+
// declares only the identity column — so re-listing them would log two
|
|
381
|
+
// no-op statements and inflate the count of changes applied.
|
|
382
|
+
for (const junction of junctions) {
|
|
383
|
+
const key = `${junction.schema}.${junction.table}`;
|
|
384
|
+
if (created.has(key)) continue;
|
|
385
|
+
for (const column of junction.columns) {
|
|
386
|
+
if (renameLegacyColumn(key, junction.schema, junction.table, column.name, column.legacyName)) continue;
|
|
387
|
+
addColumn(key, junction.schema, junction.table, column.name, column.type);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 3c. The columns relation and reference properties own.
|
|
392
|
+
for (const relational of planRelationalColumns(collections)) {
|
|
393
|
+
const relKey = `${relational.schema}.${relational.table}`;
|
|
394
|
+
if (renameLegacyColumn(relKey, relational.schema, relational.table, relational.column, relational.legacyColumn)) continue;
|
|
395
|
+
addColumn(
|
|
396
|
+
relKey,
|
|
397
|
+
relational.schema,
|
|
398
|
+
relational.table,
|
|
399
|
+
relational.column,
|
|
400
|
+
relational.type
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 4. Foreign keys, last: the tables and columns on both ends have to exist
|
|
405
|
+
// first, and a constraint is the one thing here that can fail on data
|
|
406
|
+
// rather than on schema, so nothing else depends on it.
|
|
407
|
+
const knownConstraints = existing.constraints ?? new Set<string>();
|
|
408
|
+
const plannedConstraints = new Set<string>();
|
|
409
|
+
const foreignKeys = [
|
|
410
|
+
...planRelationalColumns(collections).map(r => r.foreignKey),
|
|
411
|
+
...junctions.flatMap(j => j.foreignKeys)
|
|
412
|
+
];
|
|
413
|
+
for (const fk of foreignKeys) {
|
|
414
|
+
if (!fk) continue;
|
|
415
|
+
const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
|
|
416
|
+
if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
|
|
417
|
+
plannedConstraints.add(name);
|
|
418
|
+
actions.push({
|
|
419
|
+
kind: "add-constraint",
|
|
420
|
+
target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
|
|
421
|
+
sql: fk.sql
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return { actions, statements: actions.map(a => a.sql), legacyForeignKeys };
|
|
215
426
|
}
|
|
216
427
|
|
|
217
428
|
/** Read what the database has, for the schemas the collections live in. */
|
|
@@ -250,7 +461,23 @@ export async function readExistingSchema(
|
|
|
250
461
|
);
|
|
251
462
|
for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
|
|
252
463
|
|
|
253
|
-
|
|
464
|
+
// `ADD CONSTRAINT` has no IF NOT EXISTS, so an existing foreign key is
|
|
465
|
+
// skipped by name rather than guarded in SQL.
|
|
466
|
+
const constraints = new Set<string>();
|
|
467
|
+
const { rows: constraintRows } = await client.query<{
|
|
468
|
+
schema: string;
|
|
469
|
+
table: string;
|
|
470
|
+
name: string;
|
|
471
|
+
}>(
|
|
472
|
+
`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
|
|
473
|
+
FROM pg_constraint con
|
|
474
|
+
JOIN pg_class c ON con.conrelid = c.oid
|
|
475
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
476
|
+
WHERE n.nspname IN (${inList})`
|
|
477
|
+
);
|
|
478
|
+
for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
|
|
479
|
+
|
|
480
|
+
return { tables, enums, constraints };
|
|
254
481
|
}
|
|
255
482
|
|
|
256
483
|
/**
|
|
@@ -265,8 +492,14 @@ export async function ensureCollectionTables(
|
|
|
265
492
|
client: Queryable,
|
|
266
493
|
collections: CollectionConfig[],
|
|
267
494
|
log?: (message: string) => void
|
|
268
|
-
): Promise<
|
|
269
|
-
|
|
495
|
+
): Promise<EnsureOutcome> {
|
|
496
|
+
// Junctions live alongside the collections that declare them, so their
|
|
497
|
+
// schema has to be read too — otherwise an existing junction reads as
|
|
498
|
+
// missing and its constraints as unplanned.
|
|
499
|
+
const schemas = Array.from(new Set([
|
|
500
|
+
...collections.map(schemaOf),
|
|
501
|
+
...planJunctionTables(collections).map(j => j.schema)
|
|
502
|
+
]));
|
|
270
503
|
for (const schema of schemas) {
|
|
271
504
|
assertSafeIdentifier(schema, "schema name");
|
|
272
505
|
if (schema !== "public") {
|
|
@@ -276,10 +509,26 @@ export async function ensureCollectionTables(
|
|
|
276
509
|
|
|
277
510
|
const existing = await readExistingSchema(client, schemas);
|
|
278
511
|
const plan = planCollectionSchemaEnsure(collections, existing);
|
|
512
|
+
const failures: { target: string; error: string }[] = [];
|
|
513
|
+
|
|
514
|
+
// Reported, not warned: this is a rename the ensure is about to perform, and
|
|
515
|
+
// the operator should be able to see in the log why a column changed name.
|
|
516
|
+
// The interesting case is the one that no longer happens — before this,
|
|
517
|
+
// ensure added the new column empty beside the populated old one, every
|
|
518
|
+
// statement succeeded, and the relation read the empty one.
|
|
519
|
+
for (const legacy of plan.legacyForeignKeys) {
|
|
520
|
+
const message =
|
|
521
|
+
`Renaming "${legacy.table}"."${legacy.legacy}" to "${legacy.expected}". The old name is ` +
|
|
522
|
+
"the one Rebase derived for this relation before it singularized properly; the column " +
|
|
523
|
+
"keeps its data, indexes and constraints. To keep the old name instead, set " +
|
|
524
|
+
`\`localKey: "${legacy.legacy}"\` on the relation and this will stop.`;
|
|
525
|
+
logger.info(`[schema] ${message}`);
|
|
526
|
+
log?.(message);
|
|
527
|
+
}
|
|
279
528
|
|
|
280
529
|
if (plan.actions.length === 0) {
|
|
281
530
|
log?.("Schema is up to date; nothing to create.");
|
|
282
|
-
return plan;
|
|
531
|
+
return { ...plan, failures };
|
|
283
532
|
}
|
|
284
533
|
|
|
285
534
|
for (const action of plan.actions) {
|
|
@@ -287,11 +536,19 @@ export async function ensureCollectionTables(
|
|
|
287
536
|
await client.query(action.sql);
|
|
288
537
|
log?.(`${action.kind}: ${action.target}`);
|
|
289
538
|
} catch (err) {
|
|
539
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
540
|
+
// A foreign key is the only action that can fail on the customer's
|
|
541
|
+
// data rather than on the schema. The column it polices is already
|
|
542
|
+
// there, so the collection serves either way — record it and carry
|
|
543
|
+
// on rather than crash-looping the deployment.
|
|
544
|
+
if (action.kind === "add-constraint") {
|
|
545
|
+
failures.push({ target: action.target, error: message });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
290
548
|
throw new Error(
|
|
291
|
-
`Failed to ${action.kind} ${action.target}: `
|
|
292
|
-
`${err instanceof Error ? err.message : String(err)}\n ${action.sql}`
|
|
549
|
+
`Failed to ${action.kind} ${action.target}: ${message}\n ${action.sql}`
|
|
293
550
|
);
|
|
294
551
|
}
|
|
295
552
|
}
|
|
296
|
-
return plan;
|
|
553
|
+
return { ...plan, failures };
|
|
297
554
|
}
|