@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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 +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- 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/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
+
|
|
5
|
+
export class DatabasePoolManager {
|
|
6
|
+
private pools: Map<string, Pool> = new Map();
|
|
7
|
+
private drizzleInstances: Map<string, NodePgDatabase> = new Map();
|
|
8
|
+
public readonly defaultDatabaseName: string;
|
|
9
|
+
private readonly rootConnectionString: string;
|
|
10
|
+
|
|
11
|
+
constructor(adminConnectionString: string) {
|
|
12
|
+
this.rootConnectionString = adminConnectionString;
|
|
13
|
+
try {
|
|
14
|
+
const url = new URL(adminConnectionString);
|
|
15
|
+
this.defaultDatabaseName = url.pathname.slice(1);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
throw new Error(`Invalid adminConnectionString provided: ${e}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public getDrizzle(databaseName: string): NodePgDatabase<any> {
|
|
22
|
+
const existing = this.drizzleInstances.get(databaseName);
|
|
23
|
+
if (existing) {
|
|
24
|
+
return existing;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pool = this.getPool(databaseName);
|
|
28
|
+
const db = drizzle(pool);
|
|
29
|
+
this.drizzleInstances.set(databaseName, db);
|
|
30
|
+
return db;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public getPool(databaseName: string): Pool {
|
|
34
|
+
if (this.pools.has(databaseName)) {
|
|
35
|
+
return this.pools.get(databaseName)!;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const url = new URL(this.rootConnectionString);
|
|
39
|
+
url.pathname = `/${databaseName}`;
|
|
40
|
+
|
|
41
|
+
const pool = new Pool({
|
|
42
|
+
connectionString: url.toString(),
|
|
43
|
+
max: 10, // Default sensible limit, can be tuned later
|
|
44
|
+
idleTimeoutMillis: 10000, // Reduced from 30000 for aggressive cleanup
|
|
45
|
+
allowExitOnIdle: true // Prevent idle clients from hanging the Node.js process
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Prevent idle client errors from crashing the Node.js process
|
|
49
|
+
pool.on("error", (err) => {
|
|
50
|
+
console.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, err);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.pools.set(databaseName, pool);
|
|
54
|
+
return pool;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Disconnect and remove the pool for a specific database.
|
|
59
|
+
* Required before `CREATE DATABASE ... TEMPLATE` or `DROP DATABASE`,
|
|
60
|
+
* which need exclusive access to the target database.
|
|
61
|
+
*/
|
|
62
|
+
public async disconnectDatabase(databaseName: string): Promise<void> {
|
|
63
|
+
const pool = this.pools.get(databaseName);
|
|
64
|
+
if (pool) {
|
|
65
|
+
await pool.end();
|
|
66
|
+
this.pools.delete(databaseName);
|
|
67
|
+
this.drizzleInstances.delete(databaseName);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Check if a pool exists for a given database name. */
|
|
72
|
+
public hasPool(databaseName: string): boolean {
|
|
73
|
+
return this.pools.has(databaseName);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public async shutdown(): Promise<void> {
|
|
77
|
+
const promises = [];
|
|
78
|
+
for (const [dbName, pool] of this.pools.entries()) {
|
|
79
|
+
console.log(`[DatabasePoolManager] Shutting down pool for ${dbName}`);
|
|
80
|
+
promises.push(pool.end());
|
|
81
|
+
}
|
|
82
|
+
await Promise.all(promises);
|
|
83
|
+
this.pools.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
|
|
4
|
+
export interface HistoryEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
table_name: string;
|
|
7
|
+
entity_id: string;
|
|
8
|
+
action: "create" | "update" | "delete";
|
|
9
|
+
changed_fields: string[] | null;
|
|
10
|
+
values: Record<string, unknown> | null;
|
|
11
|
+
previous_values: Record<string, unknown> | null;
|
|
12
|
+
updated_by: string | null;
|
|
13
|
+
updated_at: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecordHistoryParams {
|
|
17
|
+
tableName: string;
|
|
18
|
+
entityId: string;
|
|
19
|
+
action: "create" | "update" | "delete";
|
|
20
|
+
values?: Record<string, unknown> | null;
|
|
21
|
+
previousValues?: Record<string, unknown> | null;
|
|
22
|
+
updatedBy?: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FetchHistoryOptions {
|
|
26
|
+
limit?: number;
|
|
27
|
+
offset?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface HistoryRetentionConfig {
|
|
31
|
+
/** Max entries per entity. Oldest pruned first. Default 200. */
|
|
32
|
+
maxEntries: number;
|
|
33
|
+
/** Entries older than this many days are pruned. Default 90. */
|
|
34
|
+
ttlDays: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_RETENTION: HistoryRetentionConfig = {
|
|
38
|
+
maxEntries: 200,
|
|
39
|
+
ttlDays: 90
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Service for recording and querying entity change history.
|
|
44
|
+
* Stores snapshots in the `rebase.entity_history` table.
|
|
45
|
+
*/
|
|
46
|
+
export class HistoryService {
|
|
47
|
+
public retention: HistoryRetentionConfig;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
private db: NodePgDatabase,
|
|
51
|
+
retention?: Partial<HistoryRetentionConfig>
|
|
52
|
+
) {
|
|
53
|
+
this.retention = { ...DEFAULT_RETENTION,
|
|
54
|
+
...retention };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Record a history entry for an entity change.
|
|
59
|
+
* This is intentionally fire-and-forget safe — errors are logged but never
|
|
60
|
+
* bubble up to block the main save/delete operation.
|
|
61
|
+
*
|
|
62
|
+
* After inserting, kicks off a non-blocking pruning pass for this entity.
|
|
63
|
+
*/
|
|
64
|
+
async recordHistory(params: RecordHistoryParams): Promise<void> {
|
|
65
|
+
const {
|
|
66
|
+
tableName,
|
|
67
|
+
entityId,
|
|
68
|
+
action,
|
|
69
|
+
values,
|
|
70
|
+
previousValues,
|
|
71
|
+
updatedBy
|
|
72
|
+
} = params;
|
|
73
|
+
|
|
74
|
+
const changedFields = previousValues && values
|
|
75
|
+
? findChangedFields(previousValues, values)
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
// Skip recording if this is an update with zero actual changes
|
|
80
|
+
|
|
81
|
+
if (action === "update" && (!changedFields || changedFields.length === 0)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await this.db.execute(sql`
|
|
87
|
+
INSERT INTO rebase.entity_history
|
|
88
|
+
(table_name, entity_id, action, changed_fields, "values", previous_values, updated_by)
|
|
89
|
+
VALUES (
|
|
90
|
+
${tableName},
|
|
91
|
+
${String(entityId)},
|
|
92
|
+
${action},
|
|
93
|
+
${changedFields ? sql`ARRAY[${sql.join(changedFields.map(f => sql`${f}`), sql`, `)}]::text[]` : sql`NULL`},
|
|
94
|
+
${values ? sql`${JSON.stringify(values)}::jsonb` : sql`NULL`},
|
|
95
|
+
${previousValues ? sql`${JSON.stringify(previousValues)}::jsonb` : sql`NULL`},
|
|
96
|
+
${updatedBy ?? null}
|
|
97
|
+
)
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
// Non-blocking prune for this specific entity
|
|
101
|
+
this.pruneEntity(tableName, entityId).catch(err =>
|
|
102
|
+
console.error("History prune failed:", err)
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.error("Failed to record entity history:", error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch history entries for an entity, ordered by most recent first.
|
|
111
|
+
*/
|
|
112
|
+
async fetchHistory(
|
|
113
|
+
tableName: string,
|
|
114
|
+
entityId: string,
|
|
115
|
+
options: FetchHistoryOptions = {}
|
|
116
|
+
): Promise<{ data: HistoryEntry[]; total: number }> {
|
|
117
|
+
const limit = options.limit ?? 20;
|
|
118
|
+
const offset = options.offset ?? 0;
|
|
119
|
+
|
|
120
|
+
const [countResult, dataResult] = await Promise.all([
|
|
121
|
+
this.db.execute(sql`
|
|
122
|
+
SELECT COUNT(*) as count
|
|
123
|
+
FROM rebase.entity_history
|
|
124
|
+
WHERE table_name = ${tableName}
|
|
125
|
+
AND entity_id = ${String(entityId)}
|
|
126
|
+
`),
|
|
127
|
+
this.db.execute(sql`
|
|
128
|
+
SELECT id, table_name, entity_id, action, changed_fields,
|
|
129
|
+
"values", previous_values, updated_by, updated_at
|
|
130
|
+
FROM rebase.entity_history
|
|
131
|
+
WHERE table_name = ${tableName}
|
|
132
|
+
AND entity_id = ${String(entityId)}
|
|
133
|
+
ORDER BY updated_at DESC
|
|
134
|
+
LIMIT ${limit}
|
|
135
|
+
OFFSET ${offset}
|
|
136
|
+
`)
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
const total = parseInt(
|
|
140
|
+
(countResult.rows[0] as Record<string, string>)?.count ?? "0",
|
|
141
|
+
10
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
data: dataResult.rows as unknown as HistoryEntry[],
|
|
146
|
+
total
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fetch a single history entry by ID.
|
|
152
|
+
*/
|
|
153
|
+
async fetchHistoryEntry(historyId: string): Promise<HistoryEntry | null> {
|
|
154
|
+
const result = await this.db.execute(sql`
|
|
155
|
+
SELECT id, table_name, entity_id, action, changed_fields,
|
|
156
|
+
"values", previous_values, updated_by, updated_at
|
|
157
|
+
FROM rebase.entity_history
|
|
158
|
+
WHERE id = ${historyId}
|
|
159
|
+
`);
|
|
160
|
+
|
|
161
|
+
if (result.rows.length === 0) return null;
|
|
162
|
+
return result.rows[0] as unknown as HistoryEntry;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ───────── Retention / Pruning ─────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Prune history for a single entity: enforce maxEntries and TTL.
|
|
169
|
+
*/
|
|
170
|
+
async pruneEntity(tableName: string, entityId: string): Promise<number> {
|
|
171
|
+
let deleted = 0;
|
|
172
|
+
|
|
173
|
+
// 1. TTL — delete entries older than ttlDays
|
|
174
|
+
const ttlResult = await this.db.execute(sql`
|
|
175
|
+
DELETE FROM rebase.entity_history
|
|
176
|
+
WHERE table_name = ${tableName}
|
|
177
|
+
AND entity_id = ${String(entityId)}
|
|
178
|
+
AND updated_at < NOW() - MAKE_INTERVAL(days => ${this.retention.ttlDays})
|
|
179
|
+
`);
|
|
180
|
+
deleted += ttlResult.rowCount ?? 0;
|
|
181
|
+
|
|
182
|
+
// 2. Max entries — keep the newest maxEntries, delete the rest
|
|
183
|
+
const maxResult = await this.db.execute(sql`
|
|
184
|
+
DELETE FROM rebase.entity_history
|
|
185
|
+
WHERE id IN (
|
|
186
|
+
SELECT id FROM rebase.entity_history
|
|
187
|
+
WHERE table_name = ${tableName}
|
|
188
|
+
AND entity_id = ${String(entityId)}
|
|
189
|
+
ORDER BY updated_at DESC
|
|
190
|
+
OFFSET ${this.retention.maxEntries}
|
|
191
|
+
)
|
|
192
|
+
`);
|
|
193
|
+
deleted += maxResult.rowCount ?? 0;
|
|
194
|
+
|
|
195
|
+
return deleted;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Global prune: enforce TTL across ALL entities in a single sweep.
|
|
200
|
+
* Intended to be called periodically (e.g. once per hour or daily).
|
|
201
|
+
*/
|
|
202
|
+
async pruneExpired(): Promise<number> {
|
|
203
|
+
const result = await this.db.execute(sql`
|
|
204
|
+
DELETE FROM rebase.entity_history
|
|
205
|
+
WHERE updated_at < NOW() - MAKE_INTERVAL(days => ${this.retention.ttlDays})
|
|
206
|
+
`);
|
|
207
|
+
return result.rowCount ?? 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Shallow comparison to find top-level keys that changed between two objects.
|
|
214
|
+
*/
|
|
215
|
+
export function findChangedFields(
|
|
216
|
+
oldValues: Record<string, unknown>,
|
|
217
|
+
newValues: Record<string, unknown>
|
|
218
|
+
): string[] | null {
|
|
219
|
+
const changed: string[] = [];
|
|
220
|
+
const allKeys = new Set([
|
|
221
|
+
...Object.keys(oldValues),
|
|
222
|
+
...Object.keys(newValues)
|
|
223
|
+
]);
|
|
224
|
+
|
|
225
|
+
for (const key of allKeys) {
|
|
226
|
+
const oldVal = oldValues[key];
|
|
227
|
+
const newVal = newValues[key];
|
|
228
|
+
|
|
229
|
+
// Skip internal metadata
|
|
230
|
+
if (key.startsWith("__")) continue;
|
|
231
|
+
|
|
232
|
+
if (oldVal !== newVal) {
|
|
233
|
+
// For objects/arrays, use JSON comparison
|
|
234
|
+
if (
|
|
235
|
+
typeof oldVal === "object" && oldVal !== null &&
|
|
236
|
+
typeof newVal === "object" && newVal !== null
|
|
237
|
+
) {
|
|
238
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
239
|
+
changed.push(key);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
changed.push(key);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return changed.length > 0 ? changed : null;
|
|
248
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auto-create the entity history table if it doesn't exist.
|
|
6
|
+
* This runs on startup when history is enabled, following the same
|
|
7
|
+
* pattern as `ensureAuthTablesExist`.
|
|
8
|
+
*/
|
|
9
|
+
export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void> {
|
|
10
|
+
console.log("🔍 Checking entity history table...");
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
// Create the rebase schema (idempotent — may already exist from auth init)
|
|
14
|
+
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
15
|
+
|
|
16
|
+
await db.execute(sql`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS rebase.entity_history (
|
|
18
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
19
|
+
table_name TEXT NOT NULL,
|
|
20
|
+
entity_id TEXT NOT NULL,
|
|
21
|
+
action TEXT NOT NULL,
|
|
22
|
+
changed_fields TEXT[],
|
|
23
|
+
"values" JSONB,
|
|
24
|
+
previous_values JSONB,
|
|
25
|
+
updated_by TEXT,
|
|
26
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
27
|
+
)
|
|
28
|
+
`);
|
|
29
|
+
|
|
30
|
+
await db.execute(sql`
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_history_entity
|
|
32
|
+
ON rebase.entity_history(table_name, entity_id)
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
await db.execute(sql`
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_history_time
|
|
37
|
+
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
console.log("✅ Entity history table ready");
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error("❌ Failed to create entity history table:", error);
|
|
43
|
+
console.warn("⚠️ Continuing without creating history table.");
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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 "./PostgresBootstrapper";
|
|
@@ -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
|
+
EntityCollection,
|
|
12
|
+
FilterValues,
|
|
13
|
+
WhereFilterOp,
|
|
14
|
+
DatabaseConnection,
|
|
15
|
+
QueryFilter,
|
|
16
|
+
FetchCollectionOptions,
|
|
17
|
+
SearchOptions,
|
|
18
|
+
CountOptions,
|
|
19
|
+
ConditionBuilder,
|
|
20
|
+
ConditionBuilderStatic,
|
|
21
|
+
EntityRepository,
|
|
22
|
+
CollectionSubscriptionConfig,
|
|
23
|
+
EntitySubscriptionConfig,
|
|
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
|
+
EntityRepository,
|
|
52
|
+
CollectionSubscriptionConfig,
|
|
53
|
+
EntitySubscriptionConfig,
|
|
54
|
+
RealtimeProvider,
|
|
55
|
+
CollectionRegistryInterface,
|
|
56
|
+
DataTransformer,
|
|
57
|
+
BackendConfig,
|
|
58
|
+
BackendInstance,
|
|
59
|
+
BackendFactory
|
|
60
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { pgSchema, varchar, uuid, timestamp, boolean, jsonb, primaryKey, unique } from "drizzle-orm/pg-core";
|
|
2
|
+
import { relations } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dedicated PostgreSQL schema for all Rebase internal tables.
|
|
6
|
+
* Keeps the user's `public` schema clean.
|
|
7
|
+
*/
|
|
8
|
+
export const rebaseSchema = pgSchema("rebase");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Users table - stores both email/password and OAuth users
|
|
12
|
+
*/
|
|
13
|
+
export const users = rebaseSchema.table("users", {
|
|
14
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
15
|
+
email: varchar("email", { length: 255 }).notNull().unique(),
|
|
16
|
+
passwordHash: varchar("password_hash", { length: 255 }), // NULL for OAuth-only users
|
|
17
|
+
displayName: varchar("display_name", { length: 255 }),
|
|
18
|
+
photoUrl: varchar("photo_url", { length: 500 }),
|
|
19
|
+
emailVerified: boolean("email_verified").default(false).notNull(),
|
|
20
|
+
emailVerificationToken: varchar("email_verification_token", { length: 255 }),
|
|
21
|
+
emailVerificationSentAt: timestamp("email_verification_sent_at"),
|
|
22
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
23
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Roles table - defines permission sets
|
|
28
|
+
*/
|
|
29
|
+
export const roles = rebaseSchema.table("roles", {
|
|
30
|
+
id: varchar("id", { length: 50 }).primaryKey(), // 'admin', 'editor', 'viewer'
|
|
31
|
+
name: varchar("name", { length: 100 }).notNull(),
|
|
32
|
+
isAdmin: boolean("is_admin").default(false).notNull(),
|
|
33
|
+
defaultPermissions: jsonb("default_permissions").$type<{
|
|
34
|
+
read?: boolean;
|
|
35
|
+
create?: boolean;
|
|
36
|
+
edit?: boolean;
|
|
37
|
+
delete?: boolean;
|
|
38
|
+
}>(),
|
|
39
|
+
collectionPermissions: jsonb("collection_permissions").$type<
|
|
40
|
+
Record<string, {
|
|
41
|
+
read?: boolean;
|
|
42
|
+
create?: boolean;
|
|
43
|
+
edit?: boolean;
|
|
44
|
+
delete?: boolean;
|
|
45
|
+
}>
|
|
46
|
+
>(),
|
|
47
|
+
config: jsonb("config").$type<{
|
|
48
|
+
createCollections?: boolean;
|
|
49
|
+
editCollections?: "own" | "all" | boolean;
|
|
50
|
+
deleteCollections?: "own" | "all" | boolean;
|
|
51
|
+
}>()
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* User-Role junction table
|
|
56
|
+
*/
|
|
57
|
+
export const userRoles = rebaseSchema.table("user_roles", {
|
|
58
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
59
|
+
roleId: varchar("role_id", { length: 50 }).notNull().references(() => roles.id, { onDelete: "cascade" })
|
|
60
|
+
}, (table) => ({
|
|
61
|
+
pk: primaryKey({ columns: [table.userId, table.roleId] })
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Refresh tokens for long-lived sessions
|
|
66
|
+
*/
|
|
67
|
+
export const refreshTokens = rebaseSchema.table("refresh_tokens", {
|
|
68
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
69
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
70
|
+
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
71
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
72
|
+
userAgent: varchar("user_agent", { length: 500 }),
|
|
73
|
+
ipAddress: varchar("ip_address", { length: 45 }),
|
|
74
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
75
|
+
}, (table) => ({
|
|
76
|
+
uniqueDeviceSession: unique("unique_device_session").on(table.userId, table.userAgent, table.ipAddress)
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Password reset tokens for forgot password flow
|
|
81
|
+
*/
|
|
82
|
+
export const passwordResetTokens = rebaseSchema.table("password_reset_tokens", {
|
|
83
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
84
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
85
|
+
tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
|
|
86
|
+
expiresAt: timestamp("expires_at").notNull(),
|
|
87
|
+
usedAt: timestamp("used_at"),
|
|
88
|
+
createdAt: timestamp("created_at").defaultNow().notNull()
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* App config - key/value store for custom settings
|
|
93
|
+
*/
|
|
94
|
+
export const appConfig = rebaseSchema.table("app_config", {
|
|
95
|
+
key: varchar("key", { length: 100 }).primaryKey(),
|
|
96
|
+
value: jsonb("value").notNull(),
|
|
97
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* User identities - maps external OAuth profiles back to local users
|
|
102
|
+
*/
|
|
103
|
+
export const userIdentities = rebaseSchema.table("user_identities", {
|
|
104
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
105
|
+
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
|
106
|
+
provider: varchar("provider", { length: 50 }).notNull(), // e.g. 'google', 'linkedin'
|
|
107
|
+
providerId: varchar("provider_id", { length: 255 }).notNull(),
|
|
108
|
+
profileData: jsonb("profile_data"),
|
|
109
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
110
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull()
|
|
111
|
+
}, (table) => ({
|
|
112
|
+
uniqueProviderId: unique("unique_provider_id").on(table.provider, table.providerId)
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
// Relations
|
|
116
|
+
export const usersRelations = relations(users, ({ many }) => ({
|
|
117
|
+
userRoles: many(userRoles),
|
|
118
|
+
refreshTokens: many(refreshTokens),
|
|
119
|
+
passwordResetTokens: many(passwordResetTokens),
|
|
120
|
+
userIdentities: many(userIdentities)
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
export const rolesRelations = relations(roles, ({ many }) => ({
|
|
124
|
+
userRoles: many(userRoles)
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
export const userRolesRelations = relations(userRoles, ({ one }) => ({
|
|
128
|
+
user: one(users, {
|
|
129
|
+
fields: [userRoles.userId],
|
|
130
|
+
references: [users.id]
|
|
131
|
+
}),
|
|
132
|
+
role: one(roles, {
|
|
133
|
+
fields: [userRoles.roleId],
|
|
134
|
+
references: [roles.id]
|
|
135
|
+
})
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
export const refreshTokensRelations = relations(refreshTokens, ({ one }) => ({
|
|
139
|
+
user: one(users, {
|
|
140
|
+
fields: [refreshTokens.userId],
|
|
141
|
+
references: [users.id]
|
|
142
|
+
})
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
export const passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({
|
|
146
|
+
user: one(users, {
|
|
147
|
+
fields: [passwordResetTokens.userId],
|
|
148
|
+
references: [users.id]
|
|
149
|
+
})
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
|
|
153
|
+
user: one(users, {
|
|
154
|
+
fields: [userIdentities.userId],
|
|
155
|
+
references: [users.id]
|
|
156
|
+
})
|
|
157
|
+
}));
|
|
158
|
+
|
|
159
|
+
// Type exports
|
|
160
|
+
export type User = typeof users.$inferSelect;
|
|
161
|
+
export type NewUser = typeof users.$inferInsert;
|
|
162
|
+
export type Role = typeof roles.$inferSelect;
|
|
163
|
+
export type NewRole = typeof roles.$inferInsert;
|
|
164
|
+
export type UserRole = typeof userRoles.$inferSelect;
|
|
165
|
+
export type RefreshToken = typeof refreshTokens.$inferSelect;
|
|
166
|
+
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
|
|
167
|
+
export type AppConfig = typeof appConfig.$inferSelect;
|
|
168
|
+
export type UserIdentity = typeof userIdentities.$inferSelect;
|
|
169
|
+
export type NewUserIdentity = typeof userIdentities.$inferInsert;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry point for `rebase doctor`.
|
|
4
|
+
* Invoked via tsx by the server-postgresql CLI plugin.
|
|
5
|
+
*/
|
|
6
|
+
import path from "path";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { runDoctor } from "./doctor";
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
|
|
12
|
+
const schemaArg = process.argv.find((a) => a.startsWith("--schema="));
|
|
13
|
+
|
|
14
|
+
const collectionsPath = collectionsArg?.split("=")[1] ?? path.join("..", "shared", "collections");
|
|
15
|
+
const schemaPath = schemaArg?.split("=")[1] ?? path.join("src", "schema.generated.ts");
|
|
16
|
+
|
|
17
|
+
// Load .env
|
|
18
|
+
try {
|
|
19
|
+
const dotenv = await import("dotenv");
|
|
20
|
+
const envPath = process.env.DOTENV_CONFIG_PATH;
|
|
21
|
+
if (envPath) {
|
|
22
|
+
dotenv.config({ path: envPath });
|
|
23
|
+
} else {
|
|
24
|
+
dotenv.config();
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// dotenv may not be installed
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
31
|
+
|
|
32
|
+
const report = await runDoctor({
|
|
33
|
+
collectionsPath: path.resolve(process.cwd(), collectionsPath),
|
|
34
|
+
schemaPath: path.resolve(process.cwd(), schemaPath),
|
|
35
|
+
databaseUrl: databaseUrl ?? undefined
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Exit with non-zero code if there are errors
|
|
39
|
+
if (report.summary.errors > 0) {
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main().catch((err) => {
|
|
45
|
+
console.error(chalk.red(" ✗ Doctor failed:"), err instanceof Error ? err.message : String(err));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
});
|