@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.
- package/LICENSE +22 -0
- package/README.md +86 -0
- package/dist/PostgresAdapter.d.ts +6 -0
- package/dist/PostgresBackendDriver.d.ts +150 -0
- package/dist/PostgresBootstrapper.d.ts +51 -0
- package/dist/auth/ensure-tables.d.ts +10 -0
- package/dist/auth/services.d.ts +250 -0
- package/dist/backup/backup-cli.d.ts +3 -0
- package/dist/backup/backup-cron.d.ts +53 -0
- package/dist/backup/backup-service.d.ts +85 -0
- package/dist/backup/index.d.ts +12 -0
- package/dist/backup/pg-tools.d.ts +110 -0
- package/dist/backup/retention.d.ts +35 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-errors.d.ts +42 -0
- package/dist/cli-helpers.d.ts +7 -0
- package/dist/cli.d.ts +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
- package/dist/connection.d.ts +65 -0
- package/dist/data-transformer.d.ts +55 -0
- package/dist/databasePoolManager.d.ts +20 -0
- package/dist/history/HistoryService.d.ts +71 -0
- package/dist/history/ensure-history-table.d.ts +7 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.es.js +23535 -0
- package/dist/index.es.js.map +1 -0
- package/dist/interfaces.d.ts +18 -0
- package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
- package/dist/schema/auth-default-policies.d.ts +10 -0
- package/dist/schema/auth-schema.d.ts +2376 -0
- package/dist/schema/doctor-cli.d.ts +2 -0
- package/dist/schema/doctor.d.ts +58 -0
- package/dist/schema/dynamic-tables.d.ts +31 -0
- package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/introspect-db-inference.d.ts +5 -0
- package/dist/schema/introspect-db-logic.d.ts +118 -0
- package/dist/schema/introspect-db.d.ts +1 -0
- package/dist/schema/introspect-runtime.d.ts +57 -0
- package/dist/schema/test-schema.d.ts +24 -0
- package/dist/security/policy-drift.d.ts +57 -0
- package/dist/security/rls-enforcement.d.ts +122 -0
- package/dist/services/BranchService.d.ts +47 -0
- package/dist/services/FetchService.d.ts +214 -0
- package/dist/services/PersistService.d.ts +39 -0
- package/dist/services/RelationService.d.ts +109 -0
- package/dist/services/cdc/CdcListener.d.ts +54 -0
- package/dist/services/cdc/trigger-cdc.d.ts +64 -0
- package/dist/services/collection-helpers.d.ts +38 -0
- package/dist/services/dataService.d.ts +110 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/realtimeService.d.ts +298 -0
- package/dist/src-Eh-CZosp.js +595 -0
- package/dist/src-Eh-CZosp.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/drizzle-conditions.d.ts +138 -0
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- package/dist/utils/pg-error-utils.d.ts +65 -0
- package/dist/utils/table-classification.d.ts +8 -0
- package/dist/websocket.d.ts +18 -0
- package/package.json +113 -0
- package/src/PostgresAdapter.ts +58 -0
- package/src/PostgresBackendDriver.ts +1387 -0
- package/src/PostgresBootstrapper.ts +581 -0
- package/src/auth/ensure-tables.ts +367 -0
- package/src/auth/services.ts +1321 -0
- package/src/backup/backup-cli.ts +383 -0
- package/src/backup/backup-cron.ts +189 -0
- package/src/backup/backup-service.ts +299 -0
- package/src/backup/index.ts +12 -0
- package/src/backup/pg-tools.ts +231 -0
- package/src/backup/retention.ts +75 -0
- package/src/cli-errors.ts +265 -0
- package/src/cli-helpers.ts +196 -0
- package/src/cli.ts +786 -0
- package/src/collections/PostgresCollectionRegistry.ts +103 -0
- package/src/connection.ts +166 -0
- package/src/data-transformer.ts +733 -0
- package/src/databasePoolManager.ts +87 -0
- package/src/history/HistoryService.ts +272 -0
- package/src/history/ensure-history-table.ts +46 -0
- package/src/index.ts +15 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-bootstrap-sql.ts +41 -0
- package/src/schema/auth-default-policies.ts +125 -0
- package/src/schema/auth-schema.ts +233 -0
- package/src/schema/doctor-cli.ts +113 -0
- package/src/schema/doctor.ts +733 -0
- package/src/schema/dynamic-tables.test.ts +302 -0
- package/src/schema/dynamic-tables.ts +293 -0
- package/src/schema/generate-drizzle-schema-logic.ts +850 -0
- package/src/schema/generate-drizzle-schema.ts +131 -0
- package/src/schema/generate-postgres-ddl-logic.ts +490 -0
- package/src/schema/generate-postgres-ddl.ts +92 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +910 -0
- package/src/schema/introspect-db.ts +266 -0
- package/src/schema/introspect-runtime.test.ts +212 -0
- package/src/schema/introspect-runtime.ts +293 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/security/policy-drift.test.ts +122 -0
- package/src/security/policy-drift.ts +159 -0
- package/src/security/rls-enforcement.ts +295 -0
- package/src/services/BranchService.ts +251 -0
- package/src/services/FetchService.ts +1661 -0
- package/src/services/PersistService.ts +329 -0
- package/src/services/RelationService.ts +1306 -0
- package/src/services/cdc/CdcListener.ts +167 -0
- package/src/services/cdc/trigger-cdc.ts +169 -0
- package/src/services/collection-helpers.ts +151 -0
- package/src/services/dataService.ts +246 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1502 -0
- package/src/types.ts +4 -0
- package/src/utils/drizzle-conditions.ts +1162 -0
- package/src/utils/pg-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +227 -0
- package/src/utils/table-classification.ts +16 -0
- 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
|
+
}
|