@rebasepro/server-postgresql 0.0.1-canary.ca2cb6e → 0.0.1-canary.dbf160a
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/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/package.json +6 -5
- package/src/cli.ts +59 -1
- package/src/schema/introspect-db-logic.ts +592 -0
- package/src/schema/introspect-db.ts +211 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +392 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests to verify that Rebase schema generation and drizzle-kit configuration
|
|
3
|
+
* never attempt to drop/modify tables, enums, or other database objects
|
|
4
|
+
* that are NOT defined in the managed schema.
|
|
5
|
+
*
|
|
6
|
+
* How the safety mechanism works:
|
|
7
|
+
*
|
|
8
|
+
* 1. `tablesFilter` in drizzle.config.ts tells drizzle-kit which tables to
|
|
9
|
+
* introspect from the live database. Tables not in the filter are INVISIBLE
|
|
10
|
+
* to drizzle-kit — they never enter the diff snapshot, so they can't appear
|
|
11
|
+
* in DROP TABLE statements.
|
|
12
|
+
*
|
|
13
|
+
* 2. `schemaFilter` restricts to the "public" PostgreSQL schema, so tables in
|
|
14
|
+
* other schemas (extensions, internal, etc.) are also invisible.
|
|
15
|
+
*
|
|
16
|
+
* 3. `entities.roles: false` prevents drizzle-kit from managing database roles.
|
|
17
|
+
*
|
|
18
|
+
* 4. `extensionsFilters: ["postgis"]` ignores extension-managed tables.
|
|
19
|
+
*
|
|
20
|
+
* 5. The CLI always passes `--strict --verbose` to `db push`, so destructive
|
|
21
|
+
* operations require explicit confirmation even if something slips through.
|
|
22
|
+
*
|
|
23
|
+
* These tests verify:
|
|
24
|
+
* - The schema generator outputs correct table/enum lists for tablesFilter
|
|
25
|
+
* - The tablesFilter scoping correctly excludes unmapped tables
|
|
26
|
+
* - The drizzle-kit diff engine produces no destructive SQL when the previous
|
|
27
|
+
* snapshot only contains managed tables (simulating what tablesFilter provides)
|
|
28
|
+
* - Unmapped tables in the raw snapshot DO produce DROP statements (proving
|
|
29
|
+
* the tablesFilter is the critical safety boundary, not the diff engine)
|
|
30
|
+
*/
|
|
31
|
+
import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
|
|
32
|
+
import { pgTable, varchar, text, integer, boolean, pgEnum } from "drizzle-orm/pg-core";
|
|
33
|
+
import { getTableName, Table } from "drizzle-orm";
|
|
34
|
+
import { EntityCollection } from "@rebasepro/types";
|
|
35
|
+
import { generateSchema } from "../src/schema/generate-drizzle-schema-logic";
|
|
36
|
+
|
|
37
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function buildPrevSnapshot(tables: Record<string, any>, enums: Record<string, any> = {}) {
|
|
40
|
+
return {
|
|
41
|
+
id: "prev-snapshot",
|
|
42
|
+
prevId: "prev-prev-snapshot",
|
|
43
|
+
version: "7",
|
|
44
|
+
dialect: "postgresql",
|
|
45
|
+
tables,
|
|
46
|
+
enums,
|
|
47
|
+
schemas: {},
|
|
48
|
+
sequences: {},
|
|
49
|
+
roles: {},
|
|
50
|
+
policies: {},
|
|
51
|
+
views: {},
|
|
52
|
+
_meta: { schemas: {}, tables: {}, columns: {} }
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function snapshotTable(name: string, columns: Record<string, any>) {
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
schema: "",
|
|
60
|
+
columns,
|
|
61
|
+
indexes: {},
|
|
62
|
+
foreignKeys: {},
|
|
63
|
+
compositePrimaryKeys: {},
|
|
64
|
+
uniqueConstraints: {},
|
|
65
|
+
policies: {},
|
|
66
|
+
checkConstraints: {},
|
|
67
|
+
isRLSEnabled: false
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function snapshotColumn(name: string, type: string, opts: {
|
|
72
|
+
primaryKey?: boolean;
|
|
73
|
+
notNull?: boolean;
|
|
74
|
+
} = {}) {
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
type,
|
|
78
|
+
primaryKey: opts.primaryKey ?? false,
|
|
79
|
+
notNull: opts.notNull ?? (opts.primaryKey ?? false),
|
|
80
|
+
default: undefined
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function snapshotEnum(name: string, schema: string, values: string[]) {
|
|
85
|
+
return { name, schema, values };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Drizzle schema objects (the "managed" schema) ───────────────────────
|
|
89
|
+
|
|
90
|
+
const managedUsers = pgTable("users", {
|
|
91
|
+
id: varchar("id").primaryKey(),
|
|
92
|
+
name: varchar("name"),
|
|
93
|
+
email: varchar("email")
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const managedPosts = pgTable("posts", {
|
|
97
|
+
id: varchar("id").primaryKey(),
|
|
98
|
+
title: varchar("title").notNull(),
|
|
99
|
+
user_id: varchar("user_id")
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── Tests ───────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("Unmapped tables safety", () => {
|
|
105
|
+
|
|
106
|
+
describe("tablesFilter scoping", () => {
|
|
107
|
+
|
|
108
|
+
it("should extract only managed table names from the tables export", () => {
|
|
109
|
+
const tables = { managedUsers, managedPosts };
|
|
110
|
+
const tableNames = Object.values(tables).map(t => getTableName(t as Table));
|
|
111
|
+
|
|
112
|
+
expect(tableNames).toEqual(["users", "posts"]);
|
|
113
|
+
expect(tableNames).not.toContain("legacy_orders");
|
|
114
|
+
expect(tableNames).not.toContain("external_analytics");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should produce a tablesFilter that excludes unmapped tables", () => {
|
|
118
|
+
const tables = { managedUsers, managedPosts };
|
|
119
|
+
const tablesFilter = Object.values(tables).map(t => getTableName(t as Table));
|
|
120
|
+
|
|
121
|
+
const unmappedTables = [
|
|
122
|
+
"legacy_orders",
|
|
123
|
+
"external_analytics",
|
|
124
|
+
"stripe_webhooks",
|
|
125
|
+
"audit_log"
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
for (const unmapped of unmappedTables) {
|
|
129
|
+
expect(tablesFilter).not.toContain(unmapped);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should produce stable, deterministic table names regardless of export key names", () => {
|
|
134
|
+
// The table name comes from pgTable("table_name", ...), not the JS variable name
|
|
135
|
+
const weirdVarName = pgTable("actual_table_name", {
|
|
136
|
+
id: varchar("id").primaryKey()
|
|
137
|
+
});
|
|
138
|
+
expect(getTableName(weirdVarName as Table)).toBe("actual_table_name");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("tablesFilter as the safety boundary — proof by contradiction", () => {
|
|
143
|
+
|
|
144
|
+
it("without tablesFilter: drizzle-kit WOULD drop unmapped tables (proving tablesFilter is essential)", async () => {
|
|
145
|
+
// If unmapped tables leak into the prev snapshot (i.e. tablesFilter is NOT applied),
|
|
146
|
+
// drizzle-kit's diff engine WILL generate DROP TABLE statements.
|
|
147
|
+
// This test proves that tablesFilter is the critical safety layer.
|
|
148
|
+
const prevWithUnmapped = buildPrevSnapshot({
|
|
149
|
+
"public.users": snapshotTable("users", {
|
|
150
|
+
id: snapshotColumn("id", "varchar", { primaryKey: true }),
|
|
151
|
+
name: snapshotColumn("name", "varchar"),
|
|
152
|
+
email: snapshotColumn("email", "varchar")
|
|
153
|
+
}),
|
|
154
|
+
// An unmapped table that leaked into the snapshot
|
|
155
|
+
"public.legacy_orders": snapshotTable("legacy_orders", {
|
|
156
|
+
id: snapshotColumn("id", "varchar", { primaryKey: true }),
|
|
157
|
+
amount: snapshotColumn("amount", "integer")
|
|
158
|
+
})
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const curJson = generateDrizzleJson({ managedUsers });
|
|
162
|
+
const statements = await generateMigration(prevWithUnmapped as any, curJson as any);
|
|
163
|
+
const sql = statements.join("\n");
|
|
164
|
+
|
|
165
|
+
// WITHOUT tablesFilter, drizzle-kit WOULD drop the unmapped table.
|
|
166
|
+
// This is expected behavior — it proves tablesFilter is essential.
|
|
167
|
+
expect(sql).toContain("DROP TABLE");
|
|
168
|
+
expect(sql).toContain("legacy_orders");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("without tablesFilter: drizzle-kit WOULD drop unmapped enums (proving tablesFilter scope is essential)", async () => {
|
|
172
|
+
const prevWithUnmappedEnum = buildPrevSnapshot(
|
|
173
|
+
{
|
|
174
|
+
"public.users": snapshotTable("users", {
|
|
175
|
+
id: snapshotColumn("id", "varchar", { primaryKey: true }),
|
|
176
|
+
name: snapshotColumn("name", "varchar"),
|
|
177
|
+
email: snapshotColumn("email", "varchar")
|
|
178
|
+
})
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"public.order_priority": snapshotEnum("order_priority", "public", ["low", "medium", "high"])
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const curJson = generateDrizzleJson({ managedUsers });
|
|
186
|
+
const statements = await generateMigration(prevWithUnmappedEnum as any, curJson as any);
|
|
187
|
+
const sql = statements.join("\n");
|
|
188
|
+
|
|
189
|
+
// Without filtering, drizzle-kit drops the unmapped enum
|
|
190
|
+
expect(sql).toContain("DROP TYPE");
|
|
191
|
+
expect(sql).toContain("order_priority");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("with tablesFilter applied: only managed tables enter the diff", () => {
|
|
196
|
+
|
|
197
|
+
it("should produce zero migration statements when managed schema is unchanged", async () => {
|
|
198
|
+
// Simulate: tablesFilter ensures only managed tables appear in the snapshot
|
|
199
|
+
const prevOnlyManaged = buildPrevSnapshot({
|
|
200
|
+
"public.users": snapshotTable("users", {
|
|
201
|
+
id: snapshotColumn("id", "varchar", { primaryKey: true }),
|
|
202
|
+
name: snapshotColumn("name", "varchar"),
|
|
203
|
+
email: snapshotColumn("email", "varchar")
|
|
204
|
+
}),
|
|
205
|
+
"public.posts": snapshotTable("posts", {
|
|
206
|
+
id: snapshotColumn("id", "varchar", { primaryKey: true }),
|
|
207
|
+
title: snapshotColumn("title", "varchar", { notNull: true }),
|
|
208
|
+
user_id: snapshotColumn("user_id", "varchar")
|
|
209
|
+
})
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const curJson = generateDrizzleJson({ managedUsers, managedPosts });
|
|
213
|
+
const statements = await generateMigration(prevOnlyManaged as any, curJson as any);
|
|
214
|
+
|
|
215
|
+
// No changes needed — managed schema is identical
|
|
216
|
+
expect(statements.length).toBe(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("should correctly detect changes to managed tables without touching anything else", async () => {
|
|
220
|
+
// Prev: users has id + name (missing email)
|
|
221
|
+
const prevOnlyManaged = buildPrevSnapshot({
|
|
222
|
+
"public.users": snapshotTable("users", {
|
|
223
|
+
id: snapshotColumn("id", "varchar", { primaryKey: true }),
|
|
224
|
+
name: snapshotColumn("name", "varchar")
|
|
225
|
+
})
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const curJson = generateDrizzleJson({ managedUsers });
|
|
229
|
+
const statements = await generateMigration(prevOnlyManaged as any, curJson as any);
|
|
230
|
+
const sql = statements.join("\n");
|
|
231
|
+
|
|
232
|
+
// Should add the email column
|
|
233
|
+
expect(sql.toLowerCase()).toContain("alter table");
|
|
234
|
+
expect(sql).toContain("email");
|
|
235
|
+
|
|
236
|
+
// Should NOT contain any DROP TABLE
|
|
237
|
+
expect(sql).not.toContain("DROP TABLE");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("schema generation exports correct metadata for tablesFilter", () => {
|
|
242
|
+
|
|
243
|
+
it("should export a tables object containing all and only defined collection tables", async () => {
|
|
244
|
+
const collections: EntityCollection[] = [
|
|
245
|
+
{
|
|
246
|
+
slug: "products",
|
|
247
|
+
table: "products",
|
|
248
|
+
name: "Products",
|
|
249
|
+
properties: {
|
|
250
|
+
name: { type: "string" },
|
|
251
|
+
price: { type: "number" }
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
slug: "categories",
|
|
256
|
+
table: "categories",
|
|
257
|
+
name: "Categories",
|
|
258
|
+
properties: {
|
|
259
|
+
title: { type: "string" }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const result = await generateSchema(collections);
|
|
265
|
+
|
|
266
|
+
expect(result).toContain("export const tables = {");
|
|
267
|
+
expect(result).toContain("products");
|
|
268
|
+
expect(result).toContain("categories");
|
|
269
|
+
expect(result).not.toContain("legacy_orders");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("should include junction tables in the tables export for M2M relations", async () => {
|
|
273
|
+
const postsCollection: EntityCollection = {
|
|
274
|
+
slug: "posts",
|
|
275
|
+
table: "posts",
|
|
276
|
+
name: "Posts",
|
|
277
|
+
properties: {
|
|
278
|
+
title: { type: "string" },
|
|
279
|
+
tags: { type: "relation", relationName: "tags" }
|
|
280
|
+
},
|
|
281
|
+
relations: [{
|
|
282
|
+
relationName: "tags",
|
|
283
|
+
target: () => tagsCollection,
|
|
284
|
+
cardinality: "many",
|
|
285
|
+
direction: "owning",
|
|
286
|
+
through: {
|
|
287
|
+
table: "posts_to_tags",
|
|
288
|
+
sourceColumn: "post_id",
|
|
289
|
+
targetColumn: "tag_id"
|
|
290
|
+
}
|
|
291
|
+
}]
|
|
292
|
+
};
|
|
293
|
+
const tagsCollection: EntityCollection = {
|
|
294
|
+
slug: "tags",
|
|
295
|
+
table: "tags",
|
|
296
|
+
name: "Tags",
|
|
297
|
+
properties: { name: { type: "string" } }
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const result = await generateSchema([postsCollection, tagsCollection]);
|
|
301
|
+
|
|
302
|
+
// Junction table must appear in the tables export
|
|
303
|
+
expect(result).toContain("export const tables = {");
|
|
304
|
+
expect(result).toContain("postsToTags");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should export enums for schema-defined enum types", async () => {
|
|
308
|
+
const collections: EntityCollection[] = [{
|
|
309
|
+
slug: "orders",
|
|
310
|
+
table: "orders",
|
|
311
|
+
name: "Orders",
|
|
312
|
+
properties: {
|
|
313
|
+
status: {
|
|
314
|
+
type: "string",
|
|
315
|
+
enumValues: ["pending", "shipped", "delivered"]
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}];
|
|
319
|
+
|
|
320
|
+
const result = await generateSchema(collections);
|
|
321
|
+
|
|
322
|
+
expect(result).toContain("export const enums = {");
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("drizzle.config.ts safety properties", () => {
|
|
327
|
+
|
|
328
|
+
it("schemaFilter should restrict to public schema only", () => {
|
|
329
|
+
const schemaFilter = ["public"];
|
|
330
|
+
expect(schemaFilter).toEqual(["public"]);
|
|
331
|
+
expect(schemaFilter).not.toContain("information_schema");
|
|
332
|
+
expect(schemaFilter).not.toContain("pg_catalog");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("entities.roles should be false to prevent managing DB roles", () => {
|
|
336
|
+
const entities = { roles: false };
|
|
337
|
+
expect(entities.roles).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("extensionsFilters should include postgis", () => {
|
|
341
|
+
const extensionsFilters = ["postgis"];
|
|
342
|
+
expect(extensionsFilters).toContain("postgis");
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|