@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.1-canary.g009ed95
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 +1 -1
- package/dist/PostgresBootstrapper.d.ts +33 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-CD8o_1Sl.js +8999 -0
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/collections/buildRegistry.d.ts +1 -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-BrUVgjz3.js +57 -0
- package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
- package/dist/history/HistoryService.d.ts +9 -29
- package/dist/index.es.js +1234 -9753
- 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/dynamic-tables.d.ts +1 -1
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
- 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/introspect-runtime.d.ts +1 -1
- package/dist/services/FetchService.d.ts +40 -2
- 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 +24 -1
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +4 -2
- package/dist/{src-3VmUJ8Xn.js → src-CzbghKwf.js} +464 -187
- package/dist/src-CzbghKwf.js.map +1 -0
- package/dist/{src-D5xBTl32.js → src-DoU9yPqq.js} +79 -189
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +162 -7
- package/dist/utils/pg-error-utils.d.ts +25 -3
- package/dist/websocket-B2LsrINK.js +530 -0
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +212 -36
- package/src/auth/ensure-tables.ts +164 -9
- 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 +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/buildRegistry.ts +1 -1
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/history/HistoryService.ts +13 -31
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/dynamic-tables.ts +1 -1
- 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 +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +33 -8
- package/src/schema/generate-postgres-ddl-logic.ts +382 -19
- 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 +32 -10
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/FetchService.ts +148 -18
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +40 -1
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +4 -2
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +539 -50
- package/src/utils/pg-error-utils.ts +98 -3
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-DGMYK0fr.js +0 -304
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +0 -1
- package/dist/src-3VmUJ8Xn.js.map +0 -1
- package/dist/src-D5xBTl32.js.map +0 -1
|
@@ -2,7 +2,7 @@ import { Pool } from "pg";
|
|
|
2
2
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { logger } from "@rebasepro/server";
|
|
5
|
-
import { guardPoolAgainstDirtyRelease } from "./connection";
|
|
5
|
+
import { guardPoolAgainstDirtyRelease, pinSearchPath } from "./connection";
|
|
6
6
|
|
|
7
7
|
export class DatabasePoolManager {
|
|
8
8
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -41,7 +41,10 @@ export class DatabasePoolManager {
|
|
|
41
41
|
url.pathname = `/${databaseName}`;
|
|
42
42
|
|
|
43
43
|
const pool = new Pool({
|
|
44
|
-
|
|
44
|
+
// Same pin as the primary pool: these are branch/multi-database
|
|
45
|
+
// connections to the *same* server, so they inherit the same
|
|
46
|
+
// `"$user"` hazard. See `pinSearchPath`.
|
|
47
|
+
connectionString: pinSearchPath(url.toString()),
|
|
45
48
|
max: 10, // Default sensible limit, can be tuned later
|
|
46
49
|
idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
|
|
47
50
|
allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
|
|
@@ -1,39 +1,21 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { logger } from "@rebasepro/server";
|
|
4
|
+
import type { EntityHistoryEntry } from "@rebasepro/types";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
values: Record<string, unknown> | null;
|
|
12
|
-
previous_values: Record<string, unknown> | null;
|
|
13
|
-
updated_by: string | null;
|
|
14
|
-
updated_at: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface RecordHistoryParams {
|
|
18
|
-
tableName: string;
|
|
19
|
-
id: string;
|
|
20
|
-
action: "create" | "update" | "delete";
|
|
21
|
-
values?: Record<string, unknown> | null;
|
|
22
|
-
previousValues?: Record<string, unknown> | null;
|
|
23
|
-
updatedBy?: string | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface FetchHistoryOptions {
|
|
27
|
-
limit?: number;
|
|
28
|
-
offset?: number;
|
|
29
|
-
}
|
|
6
|
+
export type {
|
|
7
|
+
RecordHistoryParams,
|
|
8
|
+
FetchHistoryOptions,
|
|
9
|
+
HistoryRetentionConfig
|
|
10
|
+
} from "@rebasepro/types";
|
|
11
|
+
import type { RecordHistoryParams, FetchHistoryOptions, HistoryRetentionConfig } from "@rebasepro/types";
|
|
30
12
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A Postgres history row is already the wire shape — `updated_at` comes back
|
|
15
|
+
* from the driver as a string. Kept as an alias because the name is used
|
|
16
|
+
* throughout this package and in `PostgresBackendDriver`.
|
|
17
|
+
*/
|
|
18
|
+
export type HistoryEntry = EntityHistoryEntry;
|
|
37
19
|
|
|
38
20
|
const DEFAULT_RETENTION: HistoryRetentionConfig = {
|
|
39
21
|
maxEntries: 200,
|
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { pgSchema, pgTable,
|
|
1
|
+
import { pgSchema, pgTable, uuid, timestamp, boolean, jsonb, text, unique, index } from "drizzle-orm/pg-core";
|
|
2
2
|
import { relations } from "drizzle-orm";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
6
|
+
*
|
|
7
|
+
* This module builds queries; it does not create tables. `ensureAuthTablesExist`
|
|
8
|
+
* owns the DDL, which makes everything here a *claim* about a database it cannot
|
|
9
|
+
* enforce — and the claims drifted. Every column below was declared
|
|
10
|
+
* `varchar(n)` while the DDL created it as `TEXT`: `user_agent` as varchar(500),
|
|
11
|
+
* `ip_address` as varchar(45), `secret_encrypted` as varchar(500), every
|
|
12
|
+
* `token_hash` as varchar(255). None of it was true of any database this
|
|
13
|
+
* framework ever provisioned. Harmless at runtime — drizzle does not enforce a
|
|
14
|
+
* length client-side, so the widths only ever misled the next reader — but a
|
|
15
|
+
* schema module that describes columns that do not exist is worse than no
|
|
16
|
+
* schema module. They are `text` here now because they are TEXT there.
|
|
6
17
|
*/
|
|
7
18
|
export function createAuthSchema(usersSchemaName = "rebase") {
|
|
8
19
|
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
@@ -15,12 +26,12 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
15
26
|
*/
|
|
16
27
|
const users = usersTableCreator("users", {
|
|
17
28
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
18
|
-
email:
|
|
19
|
-
passwordHash:
|
|
20
|
-
displayName:
|
|
21
|
-
photoUrl:
|
|
29
|
+
email: text("email").notNull().unique(),
|
|
30
|
+
passwordHash: text("password_hash"), // NULL for OAuth-only users
|
|
31
|
+
displayName: text("display_name"),
|
|
32
|
+
photoUrl: text("photo_url"),
|
|
22
33
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
23
|
-
emailVerificationToken:
|
|
34
|
+
emailVerificationToken: text("email_verification_token"),
|
|
24
35
|
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
25
36
|
isAnonymous: boolean("is_anonymous").default(false).notNull(),
|
|
26
37
|
roles: text("roles").array().default([]).notNull(),
|
|
@@ -65,7 +76,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
65
76
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
66
77
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
67
78
|
sessionId: uuid("session_id").defaultRandom().notNull(),
|
|
68
|
-
tokenHash:
|
|
79
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
69
80
|
expiresAt: timestamp("expires_at").notNull(),
|
|
70
81
|
revoked: boolean("revoked").default(false).notNull(),
|
|
71
82
|
rotatedAt: timestamp("rotated_at"),
|
|
@@ -76,8 +87,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
76
87
|
* that rotates immediately after it.
|
|
77
88
|
*/
|
|
78
89
|
sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
|
|
79
|
-
userAgent:
|
|
80
|
-
ipAddress:
|
|
90
|
+
userAgent: text("user_agent"),
|
|
91
|
+
ipAddress: text("ip_address"),
|
|
81
92
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
82
93
|
}, (table) => ({
|
|
83
94
|
sessionIdx: index("idx_refresh_tokens_session").on(table.sessionId)
|
|
@@ -89,7 +100,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
89
100
|
const passwordResetTokens = tableCreator("password_reset_tokens", {
|
|
90
101
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
91
102
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
92
|
-
tokenHash:
|
|
103
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
93
104
|
expiresAt: timestamp("expires_at").notNull(),
|
|
94
105
|
usedAt: timestamp("used_at"),
|
|
95
106
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -99,7 +110,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
99
110
|
* App config - key/value store for custom settings
|
|
100
111
|
*/
|
|
101
112
|
const appConfig = tableCreator("app_config", {
|
|
102
|
-
key:
|
|
113
|
+
key: text("key").primaryKey(),
|
|
103
114
|
value: jsonb("value").notNull(),
|
|
104
115
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
105
116
|
});
|
|
@@ -110,8 +121,8 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
110
121
|
const userIdentities = tableCreator("user_identities", {
|
|
111
122
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
112
123
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
113
|
-
provider:
|
|
114
|
-
providerId:
|
|
124
|
+
provider: text("provider").notNull(), // e.g. 'google', 'linkedin'
|
|
125
|
+
providerId: text("provider_id").notNull(),
|
|
115
126
|
profileData: jsonb("profile_data"),
|
|
116
127
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
117
128
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -125,9 +136,9 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
125
136
|
const mfaFactors = tableCreator("mfa_factors", {
|
|
126
137
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
127
138
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
128
|
-
factorType:
|
|
129
|
-
secretEncrypted:
|
|
130
|
-
friendlyName:
|
|
139
|
+
factorType: text("factor_type").notNull(), // 'totp'
|
|
140
|
+
secretEncrypted: text("secret_encrypted").notNull(),
|
|
141
|
+
friendlyName: text("friendly_name"),
|
|
131
142
|
verified: boolean("verified").default(false).notNull(),
|
|
132
143
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
133
144
|
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
@@ -141,7 +152,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
141
152
|
factorId: uuid("factor_id").notNull().references(() => mfaFactors.id, { onDelete: "cascade" }),
|
|
142
153
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
143
154
|
verifiedAt: timestamp("verified_at"),
|
|
144
|
-
ipAddress:
|
|
155
|
+
ipAddress: text("ip_address"),
|
|
145
156
|
expiresAt: timestamp("expires_at").notNull()
|
|
146
157
|
});
|
|
147
158
|
|
|
@@ -151,7 +162,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
151
162
|
const recoveryCodes = tableCreator("recovery_codes", {
|
|
152
163
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
153
164
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
154
|
-
codeHash:
|
|
165
|
+
codeHash: text("code_hash").notNull(),
|
|
155
166
|
usedAt: timestamp("used_at"),
|
|
156
167
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
157
168
|
});
|
|
@@ -162,7 +173,7 @@ export function createAuthSchema(usersSchemaName = "rebase") {
|
|
|
162
173
|
const magicLinkTokens = tableCreator("magic_link_tokens", {
|
|
163
174
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
164
175
|
uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
165
|
-
tokenHash:
|
|
176
|
+
tokenHash: text("token_hash").notNull().unique(),
|
|
166
177
|
expiresAt: timestamp("expires_at").notNull(),
|
|
167
178
|
usedAt: timestamp("used_at"),
|
|
168
179
|
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build drizzle tables at runtime from an introspected schema.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* A project with declared collections gets its drizzle tables from a generated `schema.generated.ts` that
|
|
5
5
|
* the developer commits. BaaS mode has no such file — it points at a database
|
|
6
6
|
* and serves it — so the equivalent table objects are constructed here from
|
|
7
7
|
* `information_schema` metadata.
|
|
@@ -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
|
|