@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,87 @@
1
+ import { Pool } from "pg";
2
+ import { drizzle } from "drizzle-orm/node-postgres";
3
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
+ import { logger } from "@rebasepro/server";
5
+
6
+ export class DatabasePoolManager {
7
+ private pools: Map<string, Pool> = new Map();
8
+ private drizzleInstances: Map<string, NodePgDatabase> = new Map();
9
+ public readonly defaultDatabaseName: string;
10
+ private readonly rootConnectionString: string;
11
+
12
+ constructor(adminConnectionString: string) {
13
+ this.rootConnectionString = adminConnectionString;
14
+ try {
15
+ const url = new URL(adminConnectionString);
16
+ this.defaultDatabaseName = url.pathname.slice(1);
17
+ } catch (e) {
18
+ throw new Error(`Invalid adminConnectionString provided: ${e}`);
19
+ }
20
+ }
21
+
22
+ public getDrizzle(databaseName: string): NodePgDatabase<Record<string, never>> {
23
+ const existing = this.drizzleInstances.get(databaseName);
24
+ if (existing) {
25
+ return existing;
26
+ }
27
+
28
+ const pool = this.getPool(databaseName);
29
+ const db = drizzle(pool);
30
+ this.drizzleInstances.set(databaseName, db);
31
+ return db;
32
+ }
33
+
34
+ public getPool(databaseName: string): Pool {
35
+ if (this.pools.has(databaseName)) {
36
+ return this.pools.get(databaseName)!;
37
+ }
38
+
39
+ const url = new URL(this.rootConnectionString);
40
+ url.pathname = `/${databaseName}`;
41
+
42
+ const pool = new Pool({
43
+ connectionString: url.toString(),
44
+ max: 10, // Default sensible limit, can be tuned later
45
+ idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
46
+ allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
47
+ });
48
+
49
+ // Prevent idle client errors from crashing the Node.js process
50
+ pool.on("error", (err) => {
51
+ logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
52
+ });
53
+
54
+ this.pools.set(databaseName, pool);
55
+ return pool;
56
+ }
57
+
58
+ /**
59
+ * Disconnect and remove the pool for a specific database.
60
+ * Required before `CREATE DATABASE ... TEMPLATE` or `DROP DATABASE`,
61
+ * which need exclusive access to the target database.
62
+ */
63
+ public async disconnectDatabase(databaseName: string): Promise<void> {
64
+ const pool = this.pools.get(databaseName);
65
+ if (pool) {
66
+ await pool.end();
67
+ this.pools.delete(databaseName);
68
+ this.drizzleInstances.delete(databaseName);
69
+ }
70
+ }
71
+
72
+ /** Check if a pool exists for a given database name. */
73
+ public hasPool(databaseName: string): boolean {
74
+ return this.pools.has(databaseName);
75
+ }
76
+
77
+ public async shutdown(): Promise<void> {
78
+ const promises = [];
79
+ for (const [dbName, pool] of this.pools.entries()) {
80
+ logger.info(`[DatabasePoolManager] Shutting down pool for ${dbName}`);
81
+ promises.push(pool.end());
82
+ }
83
+ await Promise.all(promises);
84
+ this.pools.clear();
85
+ this.drizzleInstances.clear();
86
+ }
87
+ }
@@ -0,0 +1,272 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { logger } from "@rebasepro/server";
4
+
5
+ export interface HistoryEntry {
6
+ id: string;
7
+ table_name: string;
8
+ entity_id: string;
9
+ action: "create" | "update" | "delete";
10
+ changed_fields: string[] | null;
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
+ }
30
+
31
+ export interface HistoryRetentionConfig {
32
+ /** Max entries per row. Oldest pruned first. Default 200. */
33
+ maxEntries: number;
34
+ /** Entries older than this many days are pruned. Default 90. */
35
+ ttlDays: number;
36
+ }
37
+
38
+ const DEFAULT_RETENTION: HistoryRetentionConfig = {
39
+ maxEntries: 200,
40
+ ttlDays: 90
41
+ };
42
+
43
+ /**
44
+ * Service for recording and querying row change history.
45
+ * Stores history entries in the `rebase.entity_history` table.
46
+ */
47
+ export class HistoryService {
48
+ public retention: HistoryRetentionConfig;
49
+
50
+ constructor(
51
+ private db: NodePgDatabase,
52
+ retention?: Partial<HistoryRetentionConfig>
53
+ ) {
54
+ this.retention = { ...DEFAULT_RETENTION,
55
+ ...retention };
56
+ }
57
+
58
+ /**
59
+ * Record a history entry for an row change.
60
+ * This is intentionally fire-and-forget safe — errors are logged but never
61
+ * bubble up to block the main save/delete operation.
62
+ *
63
+ * After inserting, kicks off a non-blocking pruning pass for this row.
64
+ */
65
+ async recordHistory(params: RecordHistoryParams): Promise<void> {
66
+ const {
67
+ tableName,
68
+ id,
69
+ action,
70
+ values,
71
+ previousValues,
72
+ updatedBy
73
+ } = params;
74
+
75
+ const changedFields = previousValues && values
76
+ ? findChangedFields(previousValues, values)
77
+ : null;
78
+
79
+
80
+ // Skip recording if this is an update with zero actual changes
81
+
82
+ if (action === "update" && (!changedFields || changedFields.length === 0)) {
83
+ return;
84
+ }
85
+
86
+ try {
87
+ await this.db.execute(sql`
88
+ INSERT INTO rebase.entity_history
89
+ (table_name, entity_id, action, changed_fields, "values", previous_values, updated_by)
90
+ VALUES (
91
+ ${tableName},
92
+ ${String(id)},
93
+ ${action},
94
+ ${changedFields ? sql`ARRAY[${sql.join(changedFields.map(f => sql`${f}`), sql`, `)}]::text[]` : sql`NULL`},
95
+ ${values ? sql`${JSON.stringify(values)}::jsonb` : sql`NULL`},
96
+ ${previousValues ? sql`${JSON.stringify(previousValues)}::jsonb` : sql`NULL`},
97
+ ${updatedBy ?? null}
98
+ )
99
+ `);
100
+
101
+ // Non-blocking prune for this specific row
102
+ this.pruneEntity(tableName, id).catch(err =>
103
+ logger.error("History prune failed", { error: err })
104
+ );
105
+ } catch (error) {
106
+ logger.error("Failed to record row history", { error: error });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Fetch history entries for an row, ordered by most recent first.
112
+ */
113
+ async fetchHistory(
114
+ tableName: string,
115
+ id: string,
116
+ options: FetchHistoryOptions = {}
117
+ ): Promise<{ data: HistoryEntry[]; total: number }> {
118
+ const limit = options.limit ?? 20;
119
+ const offset = options.offset ?? 0;
120
+
121
+ const [countResult, dataResult] = await Promise.all([
122
+ this.db.execute(sql`
123
+ SELECT COUNT(*) as count
124
+ FROM rebase.entity_history
125
+ WHERE table_name = ${tableName}
126
+ AND entity_id = ${String(id)}
127
+ `),
128
+ this.db.execute(sql`
129
+ SELECT id, table_name, entity_id, action, changed_fields,
130
+ "values", previous_values, updated_by, updated_at
131
+ FROM rebase.entity_history
132
+ WHERE table_name = ${tableName}
133
+ AND entity_id = ${String(id)}
134
+ ORDER BY updated_at DESC
135
+ LIMIT ${limit}
136
+ OFFSET ${offset}
137
+ `)
138
+ ]);
139
+
140
+ const total = parseInt(
141
+ (countResult.rows[0] as Record<string, string>)?.count ?? "0",
142
+ 10
143
+ );
144
+
145
+ return {
146
+ data: dataResult.rows as unknown as HistoryEntry[],
147
+ total
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Fetch a single history entry by ID.
153
+ */
154
+ async fetchHistoryEntry(historyId: string): Promise<HistoryEntry | null> {
155
+ const result = await this.db.execute(sql`
156
+ SELECT id, table_name, entity_id, action, changed_fields,
157
+ "values", previous_values, updated_by, updated_at
158
+ FROM rebase.entity_history
159
+ WHERE id = ${historyId}
160
+ `);
161
+
162
+ if (result.rows.length === 0) return null;
163
+ return result.rows[0] as unknown as HistoryEntry;
164
+ }
165
+
166
+ // ───────── Retention / Pruning ─────────
167
+
168
+ /**
169
+ * Prune history for a single row: enforce maxEntries and TTL.
170
+ */
171
+ async pruneEntity(tableName: string, id: string): Promise<number> {
172
+ let deleted = 0;
173
+
174
+ // 1. TTL — delete entries older than ttlDays
175
+ const ttlResult = await this.db.execute(sql`
176
+ DELETE FROM rebase.entity_history
177
+ WHERE table_name = ${tableName}
178
+ AND entity_id = ${String(id)}
179
+ AND updated_at < NOW() - MAKE_INTERVAL(days => ${this.retention.ttlDays})
180
+ `);
181
+ deleted += ttlResult.rowCount ?? 0;
182
+
183
+ // 2. Max entries — keep the newest maxEntries, delete the rest
184
+ const maxResult = await this.db.execute(sql`
185
+ DELETE FROM rebase.entity_history
186
+ WHERE id IN (
187
+ SELECT id FROM rebase.entity_history
188
+ WHERE table_name = ${tableName}
189
+ AND entity_id = ${String(id)}
190
+ ORDER BY updated_at DESC
191
+ OFFSET ${this.retention.maxEntries}
192
+ )
193
+ `);
194
+ deleted += maxResult.rowCount ?? 0;
195
+
196
+ return deleted;
197
+ }
198
+
199
+ /**
200
+ * Global prune: enforce TTL across ALL rows in a single sweep.
201
+ * Intended to be called periodically (e.g. once per hour or daily).
202
+ */
203
+ async pruneExpired(): Promise<number> {
204
+ const result = await this.db.execute(sql`
205
+ DELETE FROM rebase.entity_history
206
+ WHERE updated_at < NOW() - MAKE_INTERVAL(days => ${this.retention.ttlDays})
207
+ `);
208
+ return result.rowCount ?? 0;
209
+ }
210
+ }
211
+
212
+
213
+ /**
214
+ * Deep equality without JSON.stringify.
215
+ * Handles primitives, arrays, Dates, and plain objects recursively.
216
+ */
217
+ function deepEqual(a: unknown, b: unknown): boolean {
218
+ if (a === b) return true;
219
+ if (a == null || b == null) return false;
220
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
221
+ if (Array.isArray(a) && Array.isArray(b)) {
222
+ if (a.length !== b.length) return false;
223
+ return a.every((v, i) => deepEqual(v, b[i]));
224
+ }
225
+ if (typeof a === "object" && typeof b === "object") {
226
+ const aObj = a as Record<string, unknown>;
227
+ const bObj = b as Record<string, unknown>;
228
+ const aKeys = Object.keys(aObj);
229
+ const bKeys = Object.keys(bObj);
230
+ if (aKeys.length !== bKeys.length) return false;
231
+ return aKeys.every(k => deepEqual(aObj[k], bObj[k]));
232
+ }
233
+ return false;
234
+ }
235
+
236
+ /**
237
+ * Shallow comparison to find top-level keys that changed between two objects.
238
+ */
239
+ export function findChangedFields(
240
+ oldValues: Record<string, unknown>,
241
+ newValues: Record<string, unknown>
242
+ ): string[] | null {
243
+ const changed: string[] = [];
244
+ const allKeys = new Set([
245
+ ...Object.keys(oldValues),
246
+ ...Object.keys(newValues)
247
+ ]);
248
+
249
+ for (const key of allKeys) {
250
+ const oldVal = oldValues[key];
251
+ const newVal = newValues[key];
252
+
253
+ // Skip internal metadata
254
+ if (key.startsWith("__")) continue;
255
+
256
+ if (oldVal !== newVal) {
257
+ // For objects/arrays, use structural comparison
258
+ if (
259
+ typeof oldVal === "object" && oldVal !== null &&
260
+ typeof newVal === "object" && newVal !== null
261
+ ) {
262
+ if (!deepEqual(oldVal, newVal)) {
263
+ changed.push(key);
264
+ }
265
+ } else {
266
+ changed.push(key);
267
+ }
268
+ }
269
+ }
270
+
271
+ return changed.length > 0 ? changed : null;
272
+ }
@@ -0,0 +1,46 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { logger } from "@rebasepro/server";
4
+
5
+ /**
6
+ * Auto-create the row history table if it doesn't exist.
7
+ * This runs on startup when history is enabled, following the same
8
+ * pattern as `ensureAuthTablesExist`.
9
+ */
10
+ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void> {
11
+ logger.info("🔍 Checking row history table...");
12
+
13
+ try {
14
+ // Create the rebase schema (idempotent — may already exist from auth init)
15
+ await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
16
+
17
+ await db.execute(sql`
18
+ CREATE TABLE IF NOT EXISTS rebase.entity_history (
19
+ id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20
+ table_name TEXT NOT NULL,
21
+ entity_id TEXT NOT NULL,
22
+ action TEXT NOT NULL,
23
+ changed_fields TEXT[],
24
+ "values" JSONB,
25
+ previous_values JSONB,
26
+ updated_by TEXT,
27
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
28
+ )
29
+ `);
30
+
31
+ await db.execute(sql`
32
+ CREATE INDEX IF NOT EXISTS idx_history_entity
33
+ ON rebase.entity_history(table_name, entity_id)
34
+ `);
35
+
36
+ await db.execute(sql`
37
+ CREATE INDEX IF NOT EXISTS idx_history_time
38
+ ON rebase.entity_history(table_name, entity_id, updated_at DESC)
39
+ `);
40
+
41
+ logger.info("✅ Entity history table ready");
42
+ } catch (error) {
43
+ logger.error("❌ Failed to create row history table", { error: error });
44
+ logger.warn("⚠️ Continuing without creating history table.");
45
+ }
46
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export * from "./connection";
2
+ export * from "./interfaces";
3
+ export * from "./PostgresBackendDriver";
4
+ export * from "./databasePoolManager";
5
+ export * from "./schema/auth-schema";
6
+ export * from "./schema/generate-drizzle-schema-logic";
7
+ export * from "./schema/generate-drizzle-schema";
8
+ export * from "./utils/drizzle-conditions";
9
+ export * from "./services/realtimeService";
10
+ export * from "./websocket";
11
+ export * from "./collections/PostgresCollectionRegistry";
12
+ export * from "./services/BranchService";
13
+ export * from "./backup";
14
+ export * from "./PostgresBootstrapper";
15
+ export * from "./PostgresAdapter";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Database Abstraction Interfaces
3
+ *
4
+ * These interfaces define the contracts that any database backend must implement
5
+ * to be used with Rebase. This allows for pluggable database backends like
6
+ * PostgreSQL, MongoDB, MySQL, etc.
7
+ */
8
+
9
+ import {
10
+ Entity,
11
+ CollectionConfig,
12
+ FilterValues,
13
+ WhereFilterOp,
14
+ DatabaseConnection,
15
+ QueryFilter,
16
+ FetchCollectionOptions,
17
+ SearchOptions,
18
+ CountOptions,
19
+ ConditionBuilder,
20
+ ConditionBuilderStatic,
21
+ DataRepository,
22
+ CollectionSubscriptionConfig,
23
+ SingleSubscriptionConfig,
24
+ RealtimeProvider,
25
+ CollectionRegistryInterface,
26
+ DataTransformer,
27
+ BackendConfig,
28
+ BackendInstance,
29
+ BackendFactory
30
+ } from "@rebasepro/types";
31
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
32
+ import { PgTransaction } from "drizzle-orm/pg-core";
33
+
34
+ /**
35
+ * Type representing either a direct database connection or a transaction.
36
+ * Used to allow services to operate within a transaction context.
37
+ * Note: `any` is intentional here — it represents a Drizzle client with
38
+ * a dynamic schema, enabling `db.query[tableName]` access without casts.
39
+ */
40
+
41
+ export type DrizzleClient = NodePgDatabase<any> | PgTransaction<any, any, any>;
42
+
43
+ export type {
44
+ DatabaseConnection,
45
+ QueryFilter,
46
+ FetchCollectionOptions,
47
+ SearchOptions,
48
+ CountOptions,
49
+ ConditionBuilder,
50
+ ConditionBuilderStatic,
51
+ DataRepository,
52
+ CollectionSubscriptionConfig,
53
+ SingleSubscriptionConfig,
54
+ RealtimeProvider,
55
+ CollectionRegistryInterface,
56
+ DataTransformer,
57
+ BackendConfig,
58
+ BackendInstance,
59
+ BackendFactory
60
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Canonical SQL bootstrap for the `auth` schema and its RLS helper functions.
3
+ *
4
+ * Generated RLS policies reference `auth.uid()` / `auth.roles()` /
5
+ * `auth.jwt()`, so any SQL stream that can contain policies must be
6
+ * self-contained: it has to (re)create these helpers first. This matters for
7
+ * the migration directory in particular — Atlas replays migrations against a
8
+ * clean dev database where no out-of-band bootstrap has ever run, so a
9
+ * migration carrying policies without this preamble fails with
10
+ * "function auth.uid() does not exist".
11
+ *
12
+ * Idempotent (`IF NOT EXISTS` / `OR REPLACE`) so it can be prepended to every
13
+ * policies block and re-applied freely. The runtime boot path
14
+ * (`auth/ensure-tables.ts`) creates the same functions under an advisory lock
15
+ * for HMR-safety; keep the definitions in sync.
16
+ *
17
+ * Deliberately does NOT create the `rebase` schema: that is runtime plumbing
18
+ * (Atlas revision table, auth tables) created directly against the target
19
+ * database. Creating it here would leak it into Atlas's replayed migration
20
+ * state — absent from the desired `schema.sql`, Atlas would then plan
21
+ * `DROP SCHEMA "rebase" CASCADE` on the next diff. The `auth` schema is safe
22
+ * because the generated schema.sql declares it.
23
+ */
24
+ export const AUTH_BOOTSTRAP_SQL = `-- Auth schema + RLS helper functions (required by the policies below)
25
+ CREATE SCHEMA IF NOT EXISTS auth;
26
+
27
+ CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
28
+ SELECT NULLIF(current_setting('app.user_id', true), '');
29
+ $$ LANGUAGE sql STABLE;
30
+
31
+ CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
32
+ SELECT COALESCE(
33
+ NULLIF(current_setting('app.jwt', true), ''),
34
+ '{}'
35
+ )::jsonb;
36
+ $$ LANGUAGE sql STABLE;
37
+
38
+ CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
39
+ SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
40
+ $$ LANGUAGE sql STABLE;
41
+ `;
@@ -0,0 +1,125 @@
1
+ import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
+ import { getTableName } from "@rebasepro/common";
3
+
4
+ /**
5
+ * Default RLS policies injected by the schema generator.
6
+ *
7
+ * Rebase's enforcement model is unified: authenticated (user-context) requests
8
+ * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
9
+ * statement — reads and writes. A collection's `securityRules` are the whole
10
+ * authorization model. The server context (auth flows, migrations,
11
+ * `dataAsAdmin`) runs as the owner and bypasses RLS.
12
+ *
13
+ * Because RLS default-denies, every collection is **locked by default**: with
14
+ * no rules, only the server context and admins can touch it. The generator
15
+ * injects that safe baseline:
16
+ *
17
+ * **For every collection**
18
+ * 1. A permissive **server-or-admin SELECT** grant.
19
+ * 2. A permissive **server-or-admin write** grant (insert/update/delete).
20
+ *
21
+ * Author `securityRules` are permissive and OR together, so explicit rules only
22
+ * *broaden* access from this locked baseline (e.g. "users read/write their own
23
+ * rows").
24
+ *
25
+ * **For auth collections additionally**
26
+ * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
27
+ * their own row (profile, session bootstrap) without every app re-declaring
28
+ * it.
29
+ * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
30
+ * every other policy, so a write is rejected unless the caller is an admin
31
+ * (or the server context) — even if the author also wrote a permissive rule
32
+ * such as "a user may edit their own row". Without this, a permissive owner
33
+ * rule would let a user change their own `roles`.
34
+ *
35
+ * The server context is recognised as `auth.uid() IS NULL` — the built-in flows
36
+ * that run without a user (signup, migrations) set no user GUC — which also
37
+ * lets the owner connection satisfy these policies even under FORCE RLS.
38
+ *
39
+ * Opt out with `disableDefaultPolicies: true` to take full responsibility for
40
+ * the collection's RLS.
41
+ */
42
+ // Expressed structurally (not as raw SQL) so the admin UI can evaluate it
43
+ // exactly — the framework's most security-critical policies must be reflected
44
+ // precisely, not left as un-evaluable raw clauses. Compiles to
45
+ // `auth.uid() IS NULL OR (string_to_array(auth.roles(), ',') && ARRAY['admin'])`.
46
+ const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
47
+ policy.not(policy.authenticated()),
48
+ policy.rolesOverlap(["admin"])
49
+ );
50
+
51
+ /** Write operations that must be admin-gated by default on auth collections. */
52
+ const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
53
+
54
+ /** Whether a collection is flagged as an authentication collection. */
55
+ function isAuthCollection(collection: CollectionConfig): boolean {
56
+ const auth = collection.auth;
57
+ return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
58
+ }
59
+
60
+ /** The property marked as the row id (falls back to `id`). */
61
+ function getIdPropertyName(collection: CollectionConfig): string {
62
+ for (const [name, prop] of Object.entries(collection.properties ?? {})) {
63
+ if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
64
+ return name;
65
+ }
66
+ }
67
+ return "id";
68
+ }
69
+
70
+ /**
71
+ * Returns the security rules that should be applied to a collection: the
72
+ * author's explicit `securityRules` plus the framework defaults described in
73
+ * the module doc (baseline server/admin read for all collections; self-read
74
+ * and the admin write gate for auth collections).
75
+ *
76
+ * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
77
+ */
78
+ export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
79
+ const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
80
+
81
+ if (collection.disableDefaultPolicies) {
82
+ return explicit;
83
+ }
84
+
85
+ const tableName = getTableName(collection);
86
+ const injected: SecurityRule[] = [];
87
+
88
+ // Baseline read + write: the server context and admins can always operate.
89
+ // RLS default-denies under the user role, so without these a rule-less
90
+ // collection would be locked to everyone — including the admin studio.
91
+ // Author rules are permissive and broaden access from here.
92
+ injected.push({
93
+ name: `${tableName}_default_admin_read`,
94
+ operations: ["select"],
95
+ condition: SERVER_OR_ADMIN_EXPR
96
+ });
97
+ injected.push({
98
+ name: `${tableName}_default_admin_write`,
99
+ operations: [...DEFAULT_GUARDED_OPS],
100
+ condition: SERVER_OR_ADMIN_EXPR,
101
+ check: SERVER_OR_ADMIN_EXPR
102
+ });
103
+
104
+ if (isAuthCollection(collection)) {
105
+ // Self-read: a user can always read their own row.
106
+ injected.push({
107
+ name: `${tableName}_default_self_read`,
108
+ operations: ["select"],
109
+ condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
110
+ });
111
+
112
+ // Restrictive gate: AND'd with all other policies, so no permissive rule
113
+ // (e.g. an owner "edit your own row" rule) can let a non-admin change
114
+ // privileged columns like `roles`.
115
+ injected.push({
116
+ name: `${tableName}_require_admin_write`,
117
+ mode: "restrictive",
118
+ operations: [...DEFAULT_GUARDED_OPS],
119
+ condition: SERVER_OR_ADMIN_EXPR,
120
+ check: SERVER_OR_ADMIN_EXPR
121
+ });
122
+ }
123
+
124
+ return [...explicit, ...injected];
125
+ }