@rebasepro/server-postgresql 0.6.1 → 0.8.0

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.
Files changed (55) hide show
  1. package/dist/PostgresBackendDriver.d.ts +8 -0
  2. package/dist/auth/services.d.ts +21 -1
  3. package/dist/cli-errors.d.ts +29 -0
  4. package/dist/cli-helpers.d.ts +7 -0
  5. package/dist/collections/PostgresCollectionRegistry.d.ts +2 -2
  6. package/dist/index.es.js +2987 -230
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-default-policies.d.ts +12 -0
  9. package/dist/schema/auth-schema.d.ts +227 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  11. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  12. package/dist/services/entityService.d.ts +1 -1
  13. package/dist/utils/pg-error-utils.d.ts +10 -0
  14. package/dist/utils/table-classification.d.ts +8 -0
  15. package/package.json +15 -9
  16. package/src/PostgresBackendDriver.ts +200 -68
  17. package/src/PostgresBootstrapper.ts +34 -2
  18. package/src/auth/ensure-tables.ts +56 -1
  19. package/src/auth/services.ts +94 -1
  20. package/src/cli-errors.ts +162 -0
  21. package/src/cli-helpers.ts +183 -0
  22. package/src/cli.ts +264 -245
  23. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  24. package/src/data-transformer.ts +2 -2
  25. package/src/schema/auth-default-policies.ts +97 -0
  26. package/src/schema/auth-schema.ts +25 -2
  27. package/src/schema/doctor.ts +2 -4
  28. package/src/schema/generate-drizzle-schema-logic.ts +20 -82
  29. package/src/schema/generate-drizzle-schema.ts +3 -5
  30. package/src/schema/generate-postgres-ddl-logic.ts +487 -0
  31. package/src/schema/generate-postgres-ddl.ts +116 -0
  32. package/src/services/EntityPersistService.ts +10 -8
  33. package/src/services/entityService.ts +28 -3
  34. package/src/services/realtimeService.ts +26 -2
  35. package/src/utils/pg-error-utils.ts +16 -0
  36. package/src/utils/table-classification.ts +16 -0
  37. package/src/websocket.ts +9 -0
  38. package/test/auth-default-policies.test.ts +89 -0
  39. package/test/cli-helpers-extended.test.ts +324 -0
  40. package/test/cli-helpers.test.ts +59 -0
  41. package/test/connection.test.ts +292 -0
  42. package/test/databasePoolManager.test.ts +289 -0
  43. package/test/doctor-extended.test.ts +443 -0
  44. package/test/e2e/db-e2e.test.ts +293 -0
  45. package/test/e2e/pg-setup.ts +79 -0
  46. package/test/entity-callbacks-redaction.test.ts +86 -0
  47. package/test/entity-persist-composite-keys.test.ts +451 -0
  48. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  49. package/test/generate-postgres-ddl.test.ts +300 -0
  50. package/test/mfa-service.test.ts +544 -0
  51. package/test/pg-error-utils.test.ts +50 -1
  52. package/test/postgresDataDriver.test.ts +2 -1
  53. package/test/realtimeService-channels.test.ts +696 -0
  54. package/test/unmapped-tables-safety.test.ts +55 -342
  55. package/vitest.e2e.config.ts +10 -0
@@ -0,0 +1,293 @@
1
+ /**
2
+ * E2E test for the database migration pipeline.
3
+ *
4
+ * Scenario 1 — Fresh database:
5
+ * Clean Postgres → db:generate → db:migrate → verify tables + auth bootstrap
6
+ *
7
+ * Scenario 2 — Incremental migration:
8
+ * Add a second collection → db:generate → db:migrate → verify new + old tables
9
+ *
10
+ * Requires Docker to be running. The test spins up a temporary Postgres
11
+ * container and tears it down afterwards.
12
+ */
13
+
14
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
15
+ import fs from "fs";
16
+ import os from "os";
17
+ import path from "path";
18
+ import pg from "pg";
19
+ import { fileURLToPath } from "url";
20
+ import { execa } from "execa";
21
+ import { startPgContainer, stopPgContainer, type PgContainer } from "./pg-setup.js";
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = path.dirname(__filename);
25
+ const pkgRoot = path.resolve(__dirname, "../.."); // packages/server-postgresql
26
+ const monorepoRoot = path.resolve(pkgRoot, "../.."); // repo root
27
+
28
+ // ─── Helpers ────────────────────────────────────────────────────────────────
29
+
30
+ /** Minimal collection definition used by the tests. */
31
+ function tasksCollectionJs(): string {
32
+ return `
33
+ const tasksCollection = {
34
+ name: "Tasks",
35
+ singularName: "Task",
36
+ slug: "tasks",
37
+ table: "tasks",
38
+ icon: "CheckCircle",
39
+ group: "Core",
40
+ properties: {
41
+ id: { name: "ID", type: "string", isId: "uuid", validation: { required: true } },
42
+ title: { name: "Title", type: "string", validation: { required: true } },
43
+ done: { name: "Done", type: "boolean" }
44
+ }
45
+ };
46
+ export default tasksCollection;
47
+ `;
48
+ }
49
+
50
+ function notesCollectionJs(): string {
51
+ return `
52
+ const notesCollection = {
53
+ name: "Notes",
54
+ singularName: "Note",
55
+ slug: "notes",
56
+ table: "notes",
57
+ icon: "Notebook",
58
+ group: "Core",
59
+ properties: {
60
+ id: { name: "ID", type: "string", isId: "uuid", validation: { required: true } },
61
+ content: { name: "Content", type: "string" }
62
+ }
63
+ };
64
+ export default notesCollection;
65
+ `;
66
+ }
67
+
68
+ function collectionsIndex(names: string[]): string {
69
+ const imports = names.map(n => `import ${n}Collection from "./${n}.js";`).join("\n");
70
+ const exports = names.map(n => `${n}Collection`).join(", ");
71
+ return `${imports}\nexport default [${exports}];\n`;
72
+ }
73
+
74
+ function cleanEnv(connectionString: string): Record<string, string> {
75
+ const env = { ...process.env } as Record<string, string>;
76
+ // Strip pnpm/npm runner variables to avoid leaking the parent workspace context
77
+ for (const key of Object.keys(env)) {
78
+ if (/^(npm_|PNPM_|pnpm_|NPM_)/i.test(key)) delete env[key];
79
+ }
80
+ env.DATABASE_URL = connectionString;
81
+ return env;
82
+ }
83
+
84
+ // Resolve the CLI script path that the `rebase` command dispatches to
85
+ function resolveCliScript(): string {
86
+ const cliScript = path.join(pkgRoot, "src", "cli.ts");
87
+ if (!fs.existsSync(cliScript)) {
88
+ throw new Error(`CLI script not found at ${cliScript}`);
89
+ }
90
+ return cliScript;
91
+ }
92
+
93
+ // ─── Test suite ─────────────────────────────────────────────────────────────
94
+
95
+ describe("Database migration E2E", () => {
96
+ let container: PgContainer;
97
+ let dbClient: pg.Client;
98
+ let workDir: string; // temp directory acting as the "backend" cwd
99
+ let collectionsDir: string; // temp directory for collection definitions
100
+ let env: Record<string, string>;
101
+
102
+ beforeAll(async () => {
103
+ // 1. Spin up Postgres container
104
+ container = await startPgContainer();
105
+
106
+ // 2. Connect with pg.Client for assertions (retry a few times)
107
+ let connectAttempts = 0;
108
+ const maxConnectAttempts = 10;
109
+ while (connectAttempts < maxConnectAttempts) {
110
+ try {
111
+ dbClient = new pg.Client({ connectionString: container.connectionString });
112
+ await dbClient.connect();
113
+ break;
114
+ } catch {
115
+ connectAttempts++;
116
+ if (connectAttempts === maxConnectAttempts) {
117
+ throw new Error("Could not connect to Postgres after retries");
118
+ }
119
+ await new Promise(r => setTimeout(r, 1000));
120
+ }
121
+ }
122
+
123
+ // 3. Create temp working directory structure:
124
+ // workDir/ ← acts like app/backend
125
+ // workDir/../config/collections/ ← collection definitions
126
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-db-e2e-"));
127
+ workDir = path.join(base, "backend");
128
+ collectionsDir = path.join(base, "config", "collections");
129
+ fs.mkdirSync(workDir, { recursive: true });
130
+ fs.mkdirSync(collectionsDir, { recursive: true });
131
+
132
+ // Create a minimal package.json so tsx can resolve modules
133
+ fs.writeFileSync(
134
+ path.join(workDir, "package.json"),
135
+ JSON.stringify({ name: "e2e-test-backend", type: "module" }),
136
+ "utf-8"
137
+ );
138
+
139
+ // Collections dir also needs type:module for .js ESM imports
140
+ fs.writeFileSync(
141
+ path.join(collectionsDir, "package.json"),
142
+ JSON.stringify({ type: "module" }),
143
+ "utf-8"
144
+ );
145
+
146
+ env = cleanEnv(container.connectionString);
147
+ }, 60_000);
148
+
149
+ afterAll(async () => {
150
+ if (dbClient) try { await dbClient.end(); } catch { /* ignore */ }
151
+ if (container) await stopPgContainer(container.containerName);
152
+ }, 30_000);
153
+
154
+ // Resolve tsx binary from the monorepo
155
+ function tsx(...args: string[]) {
156
+ const tsxBin = path.join(monorepoRoot, "node_modules", ".bin", "tsx");
157
+ return execa(tsxBin, args, { cwd: workDir, env, stdio: "inherit" });
158
+ }
159
+
160
+ // ── Scenario 1: Fresh database ──────────────────────────────────────
161
+
162
+ it("should generate and apply the initial migration on an empty database", async () => {
163
+ const cliScript = resolveCliScript();
164
+
165
+ // Write a single collection
166
+ fs.writeFileSync(path.join(collectionsDir, "tasks.js"), tasksCollectionJs());
167
+ fs.writeFileSync(path.join(collectionsDir, "index.js"), collectionsIndex(["tasks"]));
168
+
169
+ // ── db:generate ──
170
+ console.log("[e2e] Running db generate…");
171
+ await tsx(cliScript, "db", "generate", "--collections", collectionsDir, "init");
172
+
173
+ // Verify migration file was created
174
+ const migrationsDir = path.join(workDir, "drizzle", "migrations");
175
+ expect(fs.existsSync(migrationsDir)).toBe(true);
176
+ const sqlFiles = fs.readdirSync(migrationsDir).filter(f => f.endsWith(".sql"));
177
+ expect(sqlFiles.length).toBe(1);
178
+ expect(sqlFiles[0]).toContain("init");
179
+
180
+ // Verify the migration contains our table
181
+ const migrationSql = fs.readFileSync(path.join(migrationsDir, sqlFiles[0]), "utf-8");
182
+ expect(migrationSql).toContain("CREATE TABLE");
183
+ expect(migrationSql).toContain("tasks");
184
+
185
+ // ── db:migrate ──
186
+ console.log("[e2e] Running db migrate…");
187
+ await tsx(cliScript, "db", "migrate");
188
+
189
+ // Verify collection table was created
190
+ const tablesAfterMigrate = await dbClient.query(`
191
+ SELECT table_schema, table_name
192
+ FROM information_schema.tables
193
+ WHERE table_schema IN ('public', 'rebase')
194
+ ORDER BY table_schema, table_name
195
+ `);
196
+ const tableList = tablesAfterMigrate.rows.map(
197
+ (r: { table_schema: string; table_name: string }) =>
198
+ `${r.table_schema}.${r.table_name}`
199
+ );
200
+ console.log("[e2e] Tables after migrate:", tableList);
201
+ expect(tableList).toContain("public.tasks");
202
+
203
+ // Verify atlas_schema_revisions is in the rebase schema, NOT public
204
+ expect(tableList).toContain("rebase.atlas_schema_revisions");
205
+ expect(tableList.filter(t => t === "public.atlas_schema_revisions")).toHaveLength(0);
206
+
207
+ // ── Auth bootstrap (ensureAuthTablesExist) ──
208
+ console.log("[e2e] Testing auth table bootstrap…");
209
+ const { ensureAuthTablesExist } = await import("../../src/auth/ensure-tables.js");
210
+ const { drizzle } = await import("drizzle-orm/node-postgres");
211
+ const pool = new pg.Pool({ connectionString: container.connectionString });
212
+ const db = drizzle(pool);
213
+ try {
214
+ await ensureAuthTablesExist(db);
215
+ } finally {
216
+ await pool.end();
217
+ }
218
+
219
+ // Verify auth tables were created
220
+ const authTablesRes = await dbClient.query(`
221
+ SELECT table_name
222
+ FROM information_schema.tables
223
+ WHERE table_schema = 'rebase'
224
+ ORDER BY table_name
225
+ `);
226
+ const authTables = authTablesRes.rows.map((r: { table_name: string }) => r.table_name);
227
+ console.log("[e2e] Auth tables:", authTables);
228
+
229
+ expect(authTables).toContain("users");
230
+ expect(authTables).toContain("user_identities");
231
+ expect(authTables).toContain("refresh_tokens");
232
+ expect(authTables).toContain("password_reset_tokens");
233
+
234
+ // Verify user registration works (INSERT into users)
235
+ const insertRes = await dbClient.query(`
236
+ INSERT INTO rebase.users (email, password_hash, email_verified, metadata)
237
+ VALUES ('e2e@test.com', 'hash123', true, '{}'::jsonb)
238
+ RETURNING id, email
239
+ `);
240
+ expect(insertRes.rows.length).toBe(1);
241
+ expect(insertRes.rows[0].email).toBe("e2e@test.com");
242
+ }, 120_000);
243
+
244
+ // ── Scenario 2: Incremental migration ───────────────────────────────
245
+
246
+ it("should generate and apply an incremental migration preserving existing data", async () => {
247
+ const cliScript = resolveCliScript();
248
+
249
+ // Add a second collection
250
+ fs.writeFileSync(path.join(collectionsDir, "notes.js"), notesCollectionJs());
251
+ fs.writeFileSync(
252
+ path.join(collectionsDir, "index.js"),
253
+ collectionsIndex(["tasks", "notes"])
254
+ );
255
+
256
+ // ── db:generate (incremental) ──
257
+ console.log("[e2e] Running incremental db generate…");
258
+ await tsx(cliScript, "db", "generate", "--collections", collectionsDir, "add_notes");
259
+
260
+ // Verify a SECOND migration file was created
261
+ const migrationsDir = path.join(workDir, "drizzle", "migrations");
262
+ const sqlFiles = fs.readdirSync(migrationsDir)
263
+ .filter(f => f.endsWith(".sql"))
264
+ .sort();
265
+ expect(sqlFiles.length).toBe(2);
266
+ expect(sqlFiles[1]).toContain("add_notes");
267
+
268
+ // ── db:migrate (incremental) ──
269
+ console.log("[e2e] Running incremental db migrate…");
270
+ await tsx(cliScript, "db", "migrate");
271
+
272
+ // Verify both tables exist
273
+ const tablesRes = await dbClient.query(`
274
+ SELECT table_schema, table_name
275
+ FROM information_schema.tables
276
+ WHERE table_schema = 'public'
277
+ ORDER BY table_name
278
+ `);
279
+ const publicTables = tablesRes.rows.map(
280
+ (r: { table_name: string }) => r.table_name
281
+ );
282
+ console.log("[e2e] Public tables after incremental migrate:", publicTables);
283
+
284
+ expect(publicTables).toContain("tasks");
285
+ expect(publicTables).toContain("notes");
286
+
287
+ // Verify existing data survived (the user inserted in scenario 1)
288
+ const userRes = await dbClient.query(
289
+ "SELECT email FROM rebase.users WHERE email = 'e2e@test.com'"
290
+ );
291
+ expect(userRes.rows.length).toBe(1);
292
+ }, 120_000);
293
+ });
@@ -0,0 +1,79 @@
1
+ import { execa } from "execa";
2
+ import crypto from "crypto";
3
+
4
+ export interface PgContainer {
5
+ containerName: string;
6
+ connectionString: string;
7
+ port: number;
8
+ }
9
+
10
+ /**
11
+ * Spins up a temporary PostgreSQL instance using a Docker container.
12
+ * Publishes port 5432 to a random host port to prevent collisions.
13
+ */
14
+ export async function startPgContainer(): Promise<PgContainer> {
15
+ const containerName = `rebase-db-e2e-${crypto.randomUUID().slice(0, 8)}`;
16
+
17
+ console.log(`[pg-setup] Starting PostgreSQL container: ${containerName}`);
18
+
19
+ await execa("docker", [
20
+ "run",
21
+ "--name", containerName,
22
+ "-e", "POSTGRES_DB=rebase",
23
+ "-e", "POSTGRES_USER=rebase",
24
+ "-e", "POSTGRES_PASSWORD=rebase",
25
+ "-p", "5432", // random host port
26
+ "-d",
27
+ "postgres:18-alpine"
28
+ ]);
29
+
30
+ // Discover the assigned host port
31
+ const { stdout: portOutput } = await execa("docker", ["port", containerName, "5432"]);
32
+ const portMatch = portOutput.match(/:(\d+)$/m);
33
+ if (!portMatch) {
34
+ await stopPgContainer(containerName);
35
+ throw new Error(`Failed to parse host port from: ${portOutput}`);
36
+ }
37
+ const port = parseInt(portMatch[1], 10);
38
+ const connectionString =
39
+ `postgresql://rebase:rebase@localhost:${port}/rebase?sslmode=disable`;
40
+
41
+ console.log(`[pg-setup] Container started on port ${port}. Waiting for readiness…`);
42
+
43
+ // Poll pg_isready
44
+ let attempts = 0;
45
+ const maxAttempts = 30;
46
+ while (attempts < maxAttempts) {
47
+ try {
48
+ await execa("docker", [
49
+ "exec", containerName,
50
+ "pg_isready", "-U", "rebase", "-d", "rebase"
51
+ ]);
52
+ console.log("[pg-setup] PostgreSQL is ready.");
53
+ break;
54
+ } catch {
55
+ attempts++;
56
+ await new Promise(r => setTimeout(r, 500));
57
+ }
58
+ }
59
+
60
+ if (attempts === maxAttempts) {
61
+ await stopPgContainer(containerName);
62
+ throw new Error("Postgres container failed to become ready in time");
63
+ }
64
+
65
+ return { containerName, connectionString, port };
66
+ }
67
+
68
+ /**
69
+ * Stops and removes a PostgreSQL container.
70
+ */
71
+ export async function stopPgContainer(containerName: string): Promise<void> {
72
+ console.log(`[pg-setup] Removing container: ${containerName}`);
73
+ try {
74
+ await execa("docker", ["rm", "-f", containerName]);
75
+ } catch (e: unknown) {
76
+ const msg = e instanceof Error ? e.message : String(e);
77
+ console.error(`[pg-setup] Cleanup failed for ${containerName}: ${msg}`);
78
+ }
79
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import type { Entity, EntityAfterReadProps, EntityCallbacks } from "@rebasepro/types";
3
+
4
+ /**
5
+ * Regression guard for the PII-redaction pattern.
6
+ *
7
+ * PII masking is defined in per-collection {@link EntityCallbacks.afterRead},
8
+ * which runs inside the DataDriver on every read path — REST, server-side
9
+ * `rebase.data`, and realtime.
10
+ *
11
+ * This suite proves the redaction contract:
12
+ * 1. the `afterRead` masker redacts values (and does not mutate the source), and
13
+ * 2. it is assignable to `EntityCallbacks.afterRead`, i.e. it is a valid
14
+ * driver/realtime callback.
15
+ *
16
+ * That the driver invokes `afterRead` on `fetchCollection` (REST list /
17
+ * `rebase.data.find`) and `fetchEntity` (REST get / `rebase.data.findById`) is
18
+ * already covered by the "storageSource in Callbacks" suite in
19
+ * `postgresDataDriver.test.ts`; the realtime refetch invokes the identical
20
+ * callback in `realtimeService.ts` (`fetchCollectionWithAuth` /
21
+ * `fetchEntityWithAuth`).
22
+ */
23
+
24
+ interface CustomerValues extends Record<string, unknown> {
25
+ email: string;
26
+ first_name: string;
27
+ phone: string;
28
+ }
29
+
30
+ const maskEmail = (email: string): string => {
31
+ const [local, domain] = email.split("@");
32
+ return local && domain ? `${local[0]}***@${domain}` : email;
33
+ };
34
+
35
+ /**
36
+ * A representative per-collection redactor. Typed to require only `entity`
37
+ * (a supertype of the full props), so it is assignable to
38
+ * `EntityCallbacks.afterRead` yet callable in isolation without constructing a
39
+ * full `RebaseCallContext`.
40
+ */
41
+ const redactCustomer = (
42
+ { entity }: Pick<EntityAfterReadProps<CustomerValues>, "entity">
43
+ ): Entity<CustomerValues> => ({
44
+ ...entity,
45
+ values: {
46
+ ...entity.values,
47
+ email: maskEmail(entity.values.email),
48
+ phone: "***"
49
+ }
50
+ });
51
+
52
+ // Compile-time proof: the redactor is a valid EntityCallbacks.afterRead, so the
53
+ // driver and realtime service accept and invoke it on every read path.
54
+ const customerCallbacks: EntityCallbacks<CustomerValues> = { afterRead: redactCustomer };
55
+ void customerCallbacks;
56
+
57
+ describe("EntityCallbacks.afterRead PII redaction contract", () => {
58
+
59
+ const customer: Entity<CustomerValues> = {
60
+ id: "c1",
61
+ path: "customers",
62
+ values: { email: "jane.doe@acme.com", first_name: "Jane", phone: "+15551234567" }
63
+ };
64
+
65
+ it("masks redacted fields in the returned entity", () => {
66
+ const result = redactCustomer({ entity: customer });
67
+
68
+ expect(result.values.email).toBe("j***@acme.com");
69
+ expect(result.values.phone).toBe("***");
70
+ });
71
+
72
+ it("leaves non-redacted fields untouched", () => {
73
+ const result = redactCustomer({ entity: customer });
74
+
75
+ expect(result.values.first_name).toBe("Jane");
76
+ expect(result.id).toBe("c1");
77
+ expect(result.path).toBe("customers");
78
+ });
79
+
80
+ it("does not mutate the source entity", () => {
81
+ redactCustomer({ entity: customer });
82
+
83
+ expect(customer.values.email).toBe("jane.doe@acme.com");
84
+ expect(customer.values.phone).toBe("+15551234567");
85
+ });
86
+ });