@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g06f263c
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/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +16 -0
- package/dist/backup-service-vAKWJkYL.js +8867 -0
- package/dist/backup-service-vAKWJkYL.js.map +1 -0
- package/dist/cli-helpers.d.ts +24 -0
- 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-BjSwj0FM.js +57 -0
- package/dist/ensure-collection-policies-BjSwj0FM.js.map +1 -0
- package/dist/ensure-collection-tables-D6-XhqnQ.js +590 -0
- package/dist/ensure-collection-tables-D6-XhqnQ.js.map +1 -0
- package/dist/index.es.js +545 -9629
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +24 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +116 -1
- package/dist/{src-BbFOPJ1S.js → src-C_NHNVW2.js} +94 -153
- package/dist/src-C_NHNVW2.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/pg-error-utils.d.ts +19 -0
- package/dist/websocket-DB7TbFPT.js +529 -0
- package/dist/websocket-DB7TbFPT.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +14 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +21 -2
- package/src/cli-helpers.ts +44 -0
- package/src/cli.ts +31 -3
- package/src/connection.ts +73 -0
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/auth-schema.ts +30 -19
- 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 +142 -25
- package/src/schema/generate-drizzle-schema-logic.ts +16 -5
- package/src/schema/generate-postgres-ddl-logic.ts +335 -16
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/services/RelationService.ts +38 -3
- package/src/services/realtimeService.ts +3 -3
- package/src/utils/pg-error-utils.ts +46 -0
- package/src/websocket.ts +3 -3
- 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/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applying a bundle's RLS policies to a database at boot, idempotently.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* {@link ensureCollectionTables} creates the collection *tables* a managed
|
|
7
|
+
* runtime boots against, but a table with row-level security disabled and no
|
|
8
|
+
* policies is not servable: authenticated requests run as the restricted
|
|
9
|
+
* `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
|
|
10
|
+
* public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
|
|
11
|
+
* is denied. The policies live in the collections' `securityRules`; nothing at
|
|
12
|
+
* boot applied them. `rebase db push` does — but it drives Atlas against a
|
|
13
|
+
* local `DATABASE_URL`, and a managed tenant's database is reachable only from
|
|
14
|
+
* inside the cluster, by the runtime that is already connected to it. So the
|
|
15
|
+
* runtime is the only thing that *can* apply them, and this is where it does.
|
|
16
|
+
*
|
|
17
|
+
* ## Why this is safe to run on every boot
|
|
18
|
+
*
|
|
19
|
+
* Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
|
|
20
|
+
* enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
|
|
21
|
+
* a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
|
|
22
|
+
* and replaces; it never drops data. (It does not *reconcile* — a policy a
|
|
23
|
+
* previous push left behind under an old name is not removed here; that stays a
|
|
24
|
+
* `db push` / `db migrate` concern, alongside destructive schema changes.)
|
|
25
|
+
*
|
|
26
|
+
* Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
|
|
27
|
+
* table whose policies could not be applied fails **closed** (denies) rather
|
|
28
|
+
* than leaking rows. One collection's policy failing (e.g. a rule that
|
|
29
|
+
* references a table a real migration has not created yet) must not crash-loop
|
|
30
|
+
* the whole deployment and take the other collections' working routes down with
|
|
31
|
+
* it. Failures are reported loudly and per-table so the operator can see
|
|
32
|
+
* exactly which collection is not yet servable and why.
|
|
33
|
+
*/
|
|
34
|
+
import { type CollectionConfig } from "@rebasepro/types";
|
|
35
|
+
import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
|
|
36
|
+
import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
|
|
37
|
+
|
|
38
|
+
export interface PolicyEnsureResult {
|
|
39
|
+
/** `CREATE POLICY` statements that ran successfully. */
|
|
40
|
+
policiesApplied: number;
|
|
41
|
+
/** Tables that had RLS enabled. */
|
|
42
|
+
tablesSecured: number;
|
|
43
|
+
/** Declared tables absent from the database — left to a real migration. */
|
|
44
|
+
skipped: { table: string; reason: string }[];
|
|
45
|
+
/** Tables whose RLS could not be fully applied (fail closed). */
|
|
46
|
+
failures: { table: string; error: string }[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Bring the declared collections' RLS policies up to date. Returns what it did.
|
|
53
|
+
*
|
|
54
|
+
* Only tables that already exist are touched: the boot-time table creator runs
|
|
55
|
+
* first, so anything still missing is a table this additive path is not allowed
|
|
56
|
+
* to create (a junction, or a relation left to a migration). Enabling RLS on a
|
|
57
|
+
* non-existent table would error, so those are recorded as skipped, not failed.
|
|
58
|
+
*/
|
|
59
|
+
export async function ensureCollectionPolicies(
|
|
60
|
+
client: Queryable,
|
|
61
|
+
collections: CollectionConfig[],
|
|
62
|
+
log?: (message: string) => void
|
|
63
|
+
): Promise<PolicyEnsureResult> {
|
|
64
|
+
const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
|
|
65
|
+
|
|
66
|
+
const plans = planCollectionPolicies(collections);
|
|
67
|
+
if (plans.length === 0) return result;
|
|
68
|
+
|
|
69
|
+
const schemas = Array.from(new Set(plans.map(p => p.schema)));
|
|
70
|
+
const existing = await readExistingSchema(client, schemas);
|
|
71
|
+
|
|
72
|
+
for (const plan of plans) {
|
|
73
|
+
if (!existing.tables.has(plan.qualified)) {
|
|
74
|
+
result.skipped.push({
|
|
75
|
+
table: plan.qualified,
|
|
76
|
+
reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
|
|
77
|
+
});
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Enable first: if a later policy statement fails, the table is left
|
|
83
|
+
// locked (deny-all for the user role) rather than open.
|
|
84
|
+
await client.query(plan.enableRls);
|
|
85
|
+
result.tablesSecured++;
|
|
86
|
+
|
|
87
|
+
let created = 0;
|
|
88
|
+
for (const statement of plan.policyStatements) {
|
|
89
|
+
await client.query(statement);
|
|
90
|
+
if (isCreatePolicy(statement)) {
|
|
91
|
+
result.policiesApplied++;
|
|
92
|
+
created++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
result.failures.push({
|
|
98
|
+
table: plan.qualified,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
@@ -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
|
|
|
@@ -30,7 +30,9 @@ import { getTableName } from "@rebasepro/common";
|
|
|
30
30
|
import {
|
|
31
31
|
getSqlColumnType,
|
|
32
32
|
resolveColumnName,
|
|
33
|
-
isIdProperty
|
|
33
|
+
isIdProperty,
|
|
34
|
+
planRelationalColumns,
|
|
35
|
+
planJunctionTables
|
|
34
36
|
} from "./generate-postgres-ddl-logic";
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -62,10 +64,18 @@ export interface ExistingSchema {
|
|
|
62
64
|
tables: Map<string, Set<string>>;
|
|
63
65
|
/** `schema.typename` of every enum type that already exists. */
|
|
64
66
|
enums: Set<string>;
|
|
67
|
+
/**
|
|
68
|
+
* `schema.table.constraint` of every constraint that already exists.
|
|
69
|
+
*
|
|
70
|
+
* Optional so a caller that only cares about tables can still build one by
|
|
71
|
+
* hand; absent is read as "none known", which at worst re-attempts a
|
|
72
|
+
* constraint that then fails harmlessly as a duplicate.
|
|
73
|
+
*/
|
|
74
|
+
constraints?: Set<string>;
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
export interface EnsureAction {
|
|
68
|
-
kind: "create-enum" | "create-table" | "add-column";
|
|
78
|
+
kind: "create-enum" | "create-table" | "add-column" | "add-constraint";
|
|
69
79
|
/** Qualified target, for logging: `public.posts` or `public.posts.title`. */
|
|
70
80
|
target: string;
|
|
71
81
|
sql: string;
|
|
@@ -77,6 +87,18 @@ export interface EnsurePlan {
|
|
|
77
87
|
statements: string[];
|
|
78
88
|
}
|
|
79
89
|
|
|
90
|
+
export interface EnsureOutcome extends EnsurePlan {
|
|
91
|
+
/**
|
|
92
|
+
* Constraints that could not be added — always non-fatal.
|
|
93
|
+
*
|
|
94
|
+
* A foreign key can only fail on data that already violates it, and the
|
|
95
|
+
* column it would police exists either way, so the collection still serves.
|
|
96
|
+
* Refusing to boot over one would turn a pre-existing data problem into an
|
|
97
|
+
* outage. Reported loudly instead.
|
|
98
|
+
*/
|
|
99
|
+
failures: { target: string; error: string }[];
|
|
100
|
+
}
|
|
101
|
+
|
|
80
102
|
function schemaOf(collection: CollectionConfig): string {
|
|
81
103
|
return isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
82
104
|
}
|
|
@@ -183,34 +205,98 @@ export function planCollectionSchemaEnsure(
|
|
|
183
205
|
});
|
|
184
206
|
}
|
|
185
207
|
|
|
208
|
+
// 2b. Junction tables behind many-to-many relations. No collection declares
|
|
209
|
+
// them, so the walk above never sees them — and until they existed, an
|
|
210
|
+
// m2m write had nowhere to land and the junction's derived RLS had
|
|
211
|
+
// nothing to attach to.
|
|
212
|
+
const junctions = planJunctionTables(collections);
|
|
213
|
+
for (const junction of junctions) {
|
|
214
|
+
const key = `${junction.schema}.${junction.table}`;
|
|
215
|
+
if (existing.tables.has(key) || created.has(key)) continue;
|
|
216
|
+
created.add(key);
|
|
217
|
+
actions.push({ kind: "create-table", target: key, sql: junction.createTable });
|
|
218
|
+
}
|
|
219
|
+
|
|
186
220
|
// 3. Missing columns, on both brand-new and pre-existing tables.
|
|
221
|
+
const addColumn = (
|
|
222
|
+
key: string,
|
|
223
|
+
schema: string,
|
|
224
|
+
table: string,
|
|
225
|
+
column: string,
|
|
226
|
+
type: string
|
|
227
|
+
): void => {
|
|
228
|
+
const present = existing.tables.get(key);
|
|
229
|
+
if (present?.has(column)) return;
|
|
230
|
+
actions.push({
|
|
231
|
+
kind: "add-column",
|
|
232
|
+
target: `${key}.${column}`,
|
|
233
|
+
// Never NOT NULL: an existing table with rows cannot take a
|
|
234
|
+
// non-null column without a default, and inventing one would be
|
|
235
|
+
// guessing at the customer's data.
|
|
236
|
+
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
187
240
|
for (const collection of collections) {
|
|
188
241
|
const key = qualified(collection);
|
|
189
242
|
const schema = schemaOf(collection);
|
|
190
243
|
const table = getTableName(collection);
|
|
191
|
-
const present = existing.tables.get(key) ?? new Set<string>();
|
|
192
244
|
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
193
245
|
const p = prop as Property;
|
|
194
246
|
if (isIdProperty(propName, p, collection)) continue;
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
247
|
+
// Relation and reference columns are planned from the shared
|
|
248
|
+
// relational planner below, which derives the column name, type and
|
|
249
|
+
// foreign key the same way `db push` does. Deriving them here as
|
|
250
|
+
// plain columns is what once produced a column with no constraint.
|
|
199
251
|
if (p.type === "reference" || p.type === "relation") continue;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
252
|
+
addColumn(key, schema, table, resolveColumnName(propName, p), getSqlColumnType(propName, p, collection, collections));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 3b. A junction that already existed, but is short a column. One created
|
|
257
|
+
// above already carries both — unlike a collection table, whose CREATE
|
|
258
|
+
// declares only the identity column — so re-listing them would log two
|
|
259
|
+
// no-op statements and inflate the count of changes applied.
|
|
260
|
+
for (const junction of junctions) {
|
|
261
|
+
const key = `${junction.schema}.${junction.table}`;
|
|
262
|
+
if (created.has(key)) continue;
|
|
263
|
+
for (const column of junction.columns) {
|
|
264
|
+
addColumn(key, junction.schema, junction.table, column.name, column.type);
|
|
211
265
|
}
|
|
212
266
|
}
|
|
213
267
|
|
|
268
|
+
// 3c. The columns relation and reference properties own.
|
|
269
|
+
for (const relational of planRelationalColumns(collections)) {
|
|
270
|
+
addColumn(
|
|
271
|
+
`${relational.schema}.${relational.table}`,
|
|
272
|
+
relational.schema,
|
|
273
|
+
relational.table,
|
|
274
|
+
relational.column,
|
|
275
|
+
relational.type
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 4. Foreign keys, last: the tables and columns on both ends have to exist
|
|
280
|
+
// first, and a constraint is the one thing here that can fail on data
|
|
281
|
+
// rather than on schema, so nothing else depends on it.
|
|
282
|
+
const knownConstraints = existing.constraints ?? new Set<string>();
|
|
283
|
+
const plannedConstraints = new Set<string>();
|
|
284
|
+
const foreignKeys = [
|
|
285
|
+
...planRelationalColumns(collections).map(r => r.foreignKey),
|
|
286
|
+
...junctions.flatMap(j => j.foreignKeys)
|
|
287
|
+
];
|
|
288
|
+
for (const fk of foreignKeys) {
|
|
289
|
+
if (!fk) continue;
|
|
290
|
+
const name = `${fk.schema}.${fk.table}.${fk.constraintName}`;
|
|
291
|
+
if (knownConstraints.has(name) || plannedConstraints.has(name)) continue;
|
|
292
|
+
plannedConstraints.add(name);
|
|
293
|
+
actions.push({
|
|
294
|
+
kind: "add-constraint",
|
|
295
|
+
target: `${fk.schema}.${fk.table}.${fk.constraintName}`,
|
|
296
|
+
sql: fk.sql
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
214
300
|
return { actions, statements: actions.map(a => a.sql) };
|
|
215
301
|
}
|
|
216
302
|
|
|
@@ -250,7 +336,23 @@ export async function readExistingSchema(
|
|
|
250
336
|
);
|
|
251
337
|
for (const row of enumRows) enums.add(`${row.schema}.${row.name}`);
|
|
252
338
|
|
|
253
|
-
|
|
339
|
+
// `ADD CONSTRAINT` has no IF NOT EXISTS, so an existing foreign key is
|
|
340
|
+
// skipped by name rather than guarded in SQL.
|
|
341
|
+
const constraints = new Set<string>();
|
|
342
|
+
const { rows: constraintRows } = await client.query<{
|
|
343
|
+
schema: string;
|
|
344
|
+
table: string;
|
|
345
|
+
name: string;
|
|
346
|
+
}>(
|
|
347
|
+
`SELECT n.nspname AS schema, c.relname AS table, con.conname AS name
|
|
348
|
+
FROM pg_constraint con
|
|
349
|
+
JOIN pg_class c ON con.conrelid = c.oid
|
|
350
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
351
|
+
WHERE n.nspname IN (${inList})`
|
|
352
|
+
);
|
|
353
|
+
for (const row of constraintRows) constraints.add(`${row.schema}.${row.table}.${row.name}`);
|
|
354
|
+
|
|
355
|
+
return { tables, enums, constraints };
|
|
254
356
|
}
|
|
255
357
|
|
|
256
358
|
/**
|
|
@@ -265,8 +367,14 @@ export async function ensureCollectionTables(
|
|
|
265
367
|
client: Queryable,
|
|
266
368
|
collections: CollectionConfig[],
|
|
267
369
|
log?: (message: string) => void
|
|
268
|
-
): Promise<
|
|
269
|
-
|
|
370
|
+
): Promise<EnsureOutcome> {
|
|
371
|
+
// Junctions live alongside the collections that declare them, so their
|
|
372
|
+
// schema has to be read too — otherwise an existing junction reads as
|
|
373
|
+
// missing and its constraints as unplanned.
|
|
374
|
+
const schemas = Array.from(new Set([
|
|
375
|
+
...collections.map(schemaOf),
|
|
376
|
+
...planJunctionTables(collections).map(j => j.schema)
|
|
377
|
+
]));
|
|
270
378
|
for (const schema of schemas) {
|
|
271
379
|
assertSafeIdentifier(schema, "schema name");
|
|
272
380
|
if (schema !== "public") {
|
|
@@ -276,10 +384,11 @@ export async function ensureCollectionTables(
|
|
|
276
384
|
|
|
277
385
|
const existing = await readExistingSchema(client, schemas);
|
|
278
386
|
const plan = planCollectionSchemaEnsure(collections, existing);
|
|
387
|
+
const failures: { target: string; error: string }[] = [];
|
|
279
388
|
|
|
280
389
|
if (plan.actions.length === 0) {
|
|
281
390
|
log?.("Schema is up to date; nothing to create.");
|
|
282
|
-
return plan;
|
|
391
|
+
return { ...plan, failures };
|
|
283
392
|
}
|
|
284
393
|
|
|
285
394
|
for (const action of plan.actions) {
|
|
@@ -287,11 +396,19 @@ export async function ensureCollectionTables(
|
|
|
287
396
|
await client.query(action.sql);
|
|
288
397
|
log?.(`${action.kind}: ${action.target}`);
|
|
289
398
|
} catch (err) {
|
|
399
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
400
|
+
// A foreign key is the only action that can fail on the customer's
|
|
401
|
+
// data rather than on the schema. The column it polices is already
|
|
402
|
+
// there, so the collection serves either way — record it and carry
|
|
403
|
+
// on rather than crash-looping the deployment.
|
|
404
|
+
if (action.kind === "add-constraint") {
|
|
405
|
+
failures.push({ target: action.target, error: message });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
290
408
|
throw new Error(
|
|
291
|
-
`Failed to ${action.kind} ${action.target}: `
|
|
292
|
-
`${err instanceof Error ? err.message : String(err)}\n ${action.sql}`
|
|
409
|
+
`Failed to ${action.kind} ${action.target}: ${message}\n ${action.sql}`
|
|
293
410
|
);
|
|
294
411
|
}
|
|
295
412
|
}
|
|
296
|
-
return plan;
|
|
413
|
+
return { ...plan, failures };
|
|
297
414
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
4
4
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
5
5
|
import { logger } from "@rebasepro/server";
|
|
6
6
|
// --- Helper Functions ---
|
|
@@ -98,9 +98,13 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
98
98
|
} else if (stringProp.columnType === "uuid") {
|
|
99
99
|
columnDefinition = `uuid("${colName}")`;
|
|
100
100
|
} else if (stringProp.columnType === "char") {
|
|
101
|
-
columnDefinition = `char("${colName}")`;
|
|
101
|
+
columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
102
102
|
} else if (stringProp.columnType === "varchar") {
|
|
103
|
-
|
|
103
|
+
// The length is not optional decoration: `varchar("col")` with
|
|
104
|
+
// no length is an UNBOUNDED varchar in Postgres, which is what
|
|
105
|
+
// this emitted while the DDL generator emitted VARCHAR(255) for
|
|
106
|
+
// the very same property.
|
|
107
|
+
columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
104
108
|
} else {
|
|
105
109
|
// `text` is the default, and the only length-unbounded choice.
|
|
106
110
|
// Ask for `varchar` explicitly if you want the length constraint.
|
|
@@ -547,8 +551,15 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
547
551
|
const tableVarName = getTableVarName(tableName);
|
|
548
552
|
if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
|
|
549
553
|
const targetCollection = relation.target();
|
|
550
|
-
|
|
551
|
-
|
|
554
|
+
// Junctions live in `public`, full stop — `resolveJunctionSpecs`
|
|
555
|
+
// hardcodes that, so it is where `planJunctionTables` CREATEs them
|
|
556
|
+
// and where the derived RLS policies are applied. Inheriting the
|
|
557
|
+
// endpoint's schema here (as this used to) put the junction of any
|
|
558
|
+
// m2m onto `users` in `rebase`, while its policies were still
|
|
559
|
+
// created against `public.<junction>` — RLS enabled on one table,
|
|
560
|
+
// rows written to another. The three generators have to agree, and
|
|
561
|
+
// the other two already did.
|
|
562
|
+
const tableCreator = "pgTable";
|
|
552
563
|
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
553
564
|
const {
|
|
554
565
|
sourceColumn,
|