@rebasepro/server-postgresql 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{server-postgresql/src/PostgresAdapter.d.ts → PostgresAdapter.d.ts} +1 -1
- package/dist/{server-postgresql/src/PostgresBackendDriver.d.ts → PostgresBackendDriver.d.ts} +2 -2
- package/dist/{server-postgresql/src/PostgresBootstrapper.d.ts → PostgresBootstrapper.d.ts} +11 -1
- package/dist/{server-postgresql/src/collections → collections}/PostgresCollectionRegistry.d.ts +4 -0
- package/dist/index.es.js +10168 -11145
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +10735 -11429
- package/dist/index.umd.js.map +1 -1
- package/dist/{server-postgresql/src/services → services}/EntityPersistService.d.ts +0 -14
- package/dist/utils/pg-error-utils.d.ts +55 -0
- package/package.json +24 -21
- package/src/PostgresAdapter.ts +9 -10
- package/src/PostgresBackendDriver.ts +134 -121
- package/src/PostgresBootstrapper.ts +86 -13
- package/src/auth/ensure-tables.ts +28 -5
- package/src/auth/services.ts +28 -18
- package/src/cli.ts +99 -96
- package/src/collections/PostgresCollectionRegistry.ts +7 -0
- package/src/connection.ts +11 -6
- package/src/data-transformer.ts +16 -14
- package/src/databasePoolManager.ts +3 -2
- package/src/history/HistoryService.ts +3 -2
- package/src/history/ensure-history-table.ts +5 -4
- package/src/schema/auth-schema.ts +1 -2
- package/src/schema/doctor-cli.ts +2 -1
- package/src/schema/doctor.ts +40 -37
- package/src/schema/generate-drizzle-schema-logic.ts +56 -18
- package/src/schema/generate-drizzle-schema.ts +11 -11
- package/src/schema/introspect-db-inference.ts +25 -25
- package/src/schema/introspect-db-logic.ts +38 -38
- package/src/schema/introspect-db.ts +28 -27
- package/src/services/BranchService.ts +14 -0
- package/src/services/EntityFetchService.ts +28 -25
- package/src/services/EntityPersistService.ts +11 -141
- package/src/services/RelationService.ts +57 -37
- package/src/services/entity-helpers.ts +6 -2
- package/src/services/realtimeService.ts +45 -32
- package/src/utils/drizzle-conditions.ts +31 -15
- package/src/utils/pg-error-utils.ts +211 -0
- package/src/websocket.ts +15 -12
- package/test/auth-services.test.ts +36 -19
- package/test/batch-many-to-many-regression.test.ts +119 -39
- package/test/data-transformer-hardening.test.ts +67 -33
- package/test/data-transformer.test.ts +4 -2
- package/test/doctor.test.ts +10 -5
- package/test/drizzle-conditions.test.ts +59 -6
- package/test/generate-drizzle-schema.test.ts +65 -40
- package/test/introspect-db-generation.test.ts +179 -81
- package/test/introspect-db-utils.test.ts +92 -37
- package/test/mocks/chalk.cjs +7 -0
- package/test/pg-error-utils.test.ts +221 -0
- package/test/postgresDataDriver.test.ts +14 -5
- package/test/property-ordering.test.ts +126 -79
- package/test/realtimeService.test.ts +6 -2
- package/test/relation-pipeline-gaps.test.ts +84 -36
- package/test/relations.test.ts +247 -0
- package/test/unmapped-tables-safety.test.ts +14 -6
- package/test/websocket.test.ts +1 -1
- package/tsconfig.json +5 -0
- package/tsconfig.prod.json +3 -0
- package/vite.config.ts +5 -5
- package/dist/common/src/collections/CollectionRegistry.d.ts +0 -56
- package/dist/common/src/collections/default-collections.d.ts +0 -9
- package/dist/common/src/collections/index.d.ts +0 -2
- package/dist/common/src/data/buildRebaseData.d.ts +0 -14
- package/dist/common/src/data/query_builder.d.ts +0 -55
- package/dist/common/src/index.d.ts +0 -4
- package/dist/common/src/util/builders.d.ts +0 -57
- package/dist/common/src/util/callbacks.d.ts +0 -6
- package/dist/common/src/util/collections.d.ts +0 -11
- package/dist/common/src/util/common.d.ts +0 -2
- package/dist/common/src/util/conditions.d.ts +0 -26
- package/dist/common/src/util/entities.d.ts +0 -58
- package/dist/common/src/util/enums.d.ts +0 -3
- package/dist/common/src/util/index.d.ts +0 -16
- package/dist/common/src/util/navigation_from_path.d.ts +0 -34
- package/dist/common/src/util/navigation_utils.d.ts +0 -20
- package/dist/common/src/util/parent_references_from_path.d.ts +0 -6
- package/dist/common/src/util/paths.d.ts +0 -14
- package/dist/common/src/util/permissions.d.ts +0 -14
- package/dist/common/src/util/references.d.ts +0 -2
- package/dist/common/src/util/relations.d.ts +0 -22
- package/dist/common/src/util/resolutions.d.ts +0 -72
- package/dist/common/src/util/storage.d.ts +0 -24
- package/dist/types/src/controllers/analytics_controller.d.ts +0 -7
- package/dist/types/src/controllers/auth.d.ts +0 -104
- package/dist/types/src/controllers/client.d.ts +0 -168
- package/dist/types/src/controllers/collection_registry.d.ts +0 -46
- package/dist/types/src/controllers/customization_controller.d.ts +0 -60
- package/dist/types/src/controllers/data.d.ts +0 -207
- package/dist/types/src/controllers/data_driver.d.ts +0 -218
- package/dist/types/src/controllers/database_admin.d.ts +0 -11
- package/dist/types/src/controllers/dialogs_controller.d.ts +0 -36
- package/dist/types/src/controllers/effective_role.d.ts +0 -4
- package/dist/types/src/controllers/email.d.ts +0 -36
- package/dist/types/src/controllers/index.d.ts +0 -18
- package/dist/types/src/controllers/local_config_persistence.d.ts +0 -20
- package/dist/types/src/controllers/navigation.d.ts +0 -225
- package/dist/types/src/controllers/registry.d.ts +0 -63
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +0 -67
- package/dist/types/src/controllers/side_entity_controller.d.ts +0 -97
- package/dist/types/src/controllers/snackbar.d.ts +0 -24
- package/dist/types/src/controllers/storage.d.ts +0 -171
- package/dist/types/src/index.d.ts +0 -4
- package/dist/types/src/rebase_context.d.ts +0 -122
- package/dist/types/src/types/auth_adapter.d.ts +0 -301
- package/dist/types/src/types/backend.d.ts +0 -571
- package/dist/types/src/types/backend_hooks.d.ts +0 -172
- package/dist/types/src/types/builders.d.ts +0 -15
- package/dist/types/src/types/chips.d.ts +0 -5
- package/dist/types/src/types/collections.d.ts +0 -961
- package/dist/types/src/types/component_ref.d.ts +0 -47
- package/dist/types/src/types/cron.d.ts +0 -102
- package/dist/types/src/types/data_source.d.ts +0 -64
- package/dist/types/src/types/database_adapter.d.ts +0 -94
- package/dist/types/src/types/entities.d.ts +0 -145
- package/dist/types/src/types/entity_actions.d.ts +0 -104
- package/dist/types/src/types/entity_callbacks.d.ts +0 -173
- package/dist/types/src/types/entity_link_builder.d.ts +0 -7
- package/dist/types/src/types/entity_overrides.d.ts +0 -10
- package/dist/types/src/types/entity_views.d.ts +0 -87
- package/dist/types/src/types/export_import.d.ts +0 -21
- package/dist/types/src/types/formex.d.ts +0 -40
- package/dist/types/src/types/index.d.ts +0 -28
- package/dist/types/src/types/locales.d.ts +0 -4
- package/dist/types/src/types/modify_collections.d.ts +0 -5
- package/dist/types/src/types/plugins.d.ts +0 -282
- package/dist/types/src/types/properties.d.ts +0 -1173
- package/dist/types/src/types/property_config.d.ts +0 -74
- package/dist/types/src/types/relations.d.ts +0 -336
- package/dist/types/src/types/slots.d.ts +0 -262
- package/dist/types/src/types/translations.d.ts +0 -900
- package/dist/types/src/types/user_management_delegate.d.ts +0 -86
- package/dist/types/src/types/websockets.d.ts +0 -78
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -50
- /package/dist/{server-postgresql/src/auth → auth}/ensure-tables.d.ts +0 -0
- /package/dist/{server-postgresql/src/auth → auth}/services.d.ts +0 -0
- /package/dist/{server-postgresql/src/cli.d.ts → cli.d.ts} +0 -0
- /package/dist/{server-postgresql/src/connection.d.ts → connection.d.ts} +0 -0
- /package/dist/{server-postgresql/src/data-transformer.d.ts → data-transformer.d.ts} +0 -0
- /package/dist/{server-postgresql/src/databasePoolManager.d.ts → databasePoolManager.d.ts} +0 -0
- /package/dist/{server-postgresql/src/history → history}/HistoryService.d.ts +0 -0
- /package/dist/{server-postgresql/src/history → history}/ensure-history-table.d.ts +0 -0
- /package/dist/{server-postgresql/src/index.d.ts → index.d.ts} +0 -0
- /package/dist/{server-postgresql/src/interfaces.d.ts → interfaces.d.ts} +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/auth-schema.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/doctor-cli.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/doctor.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/generate-drizzle-schema-logic.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/generate-drizzle-schema.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/introspect-db-inference.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/introspect-db-logic.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/introspect-db.d.ts +0 -0
- /package/dist/{server-postgresql/src/schema → schema}/test-schema.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/BranchService.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/EntityFetchService.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/RelationService.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/entity-helpers.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/entityService.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/index.d.ts +0 -0
- /package/dist/{server-postgresql/src/services → services}/realtimeService.d.ts +0 -0
- /package/dist/{server-postgresql/src/types.d.ts → types.d.ts} +0 -0
- /package/dist/{server-postgresql/src/utils → utils}/drizzle-conditions.d.ts +0 -0
- /package/dist/{server-postgresql/src/websocket.d.ts → websocket.d.ts} +0 -0
package/src/data-transformer.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { getTableName, resolveCollectionRelations, findRelation, createRelationR
|
|
|
6
6
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
7
7
|
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
8
8
|
import { getPrimaryKeys, buildCompositeId } from "./services/entity-helpers";
|
|
9
|
+
import { logger } from "@rebasepro/server-core";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Data transformation utilities for converting between frontend and database formats.
|
|
@@ -90,7 +91,9 @@ export function serializeDataToServer<M extends Record<string, unknown>>(
|
|
|
90
91
|
collection?: EntityCollection,
|
|
91
92
|
registry?: PostgresCollectionRegistry
|
|
92
93
|
): SerializedEntityData {
|
|
93
|
-
if (!entity || !properties) return { scalarData: entity ?? {},
|
|
94
|
+
if (!entity || !properties) return { scalarData: entity ?? {},
|
|
95
|
+
inverseRelationUpdates: [],
|
|
96
|
+
joinPathRelationUpdates: [] };
|
|
94
97
|
|
|
95
98
|
const result: Record<string, unknown> = {};
|
|
96
99
|
|
|
@@ -128,7 +131,6 @@ export function serializeDataToServer<M extends Record<string, unknown>>(
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
|
|
131
|
-
|
|
132
134
|
// Handle relation properties specially
|
|
133
135
|
if (property.type === "relation" && collection) {
|
|
134
136
|
const relation = findRelation(resolvedRelations, key);
|
|
@@ -320,7 +322,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
320
322
|
const targetCollection = relation.target();
|
|
321
323
|
result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
|
|
322
324
|
} catch (e) {
|
|
323
|
-
|
|
325
|
+
logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
|
|
324
326
|
}
|
|
325
327
|
}
|
|
326
328
|
} else if (relation.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) {
|
|
@@ -350,7 +352,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
350
352
|
} else {
|
|
351
353
|
// One-to-many: return array of relation objects
|
|
352
354
|
const targetPks = getPrimaryKeys(targetCollection, registry!);
|
|
353
|
-
result[propKey] = relatedEntities.map((entity: Record<string, unknown>) =>
|
|
355
|
+
result[propKey] = relatedEntities.map((entity: Record<string, unknown>) =>
|
|
354
356
|
createRelationRef(buildCompositeId(entity, targetPks), targetCollection.slug)
|
|
355
357
|
);
|
|
356
358
|
}
|
|
@@ -358,7 +360,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
358
360
|
}
|
|
359
361
|
}
|
|
360
362
|
} catch (e) {
|
|
361
|
-
|
|
363
|
+
logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
|
|
362
364
|
}
|
|
363
365
|
} else if (relation.direction === "inverse" && relation.joinPath && db && registry) {
|
|
364
366
|
// Join path relation: Multi-hop relation using joins
|
|
@@ -371,7 +373,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
371
373
|
// Build the join query following the join path
|
|
372
374
|
const sourceTable = registry.getTable(getTableName(collection));
|
|
373
375
|
if (!sourceTable) {
|
|
374
|
-
|
|
376
|
+
logger.warn(`Source table not found for collection: ${collection.slug}`);
|
|
375
377
|
continue;
|
|
376
378
|
}
|
|
377
379
|
|
|
@@ -382,7 +384,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
382
384
|
for (const join of relation.joinPath) {
|
|
383
385
|
const joinTable = registry.getTable(join.table);
|
|
384
386
|
if (!joinTable) {
|
|
385
|
-
|
|
387
|
+
logger.warn(`Join table not found: ${join.table}`);
|
|
386
388
|
break;
|
|
387
389
|
}
|
|
388
390
|
|
|
@@ -400,7 +402,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
400
402
|
const toCol = joinTable[toColName as keyof typeof joinTable] as AnyPgColumn;
|
|
401
403
|
|
|
402
404
|
if (!fromCol || !toCol) {
|
|
403
|
-
|
|
405
|
+
logger.warn(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
404
406
|
break;
|
|
405
407
|
}
|
|
406
408
|
|
|
@@ -414,7 +416,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
414
416
|
query = query.where(eq(sourceIdField, currentEntityId)) as typeof query;
|
|
415
417
|
} else {
|
|
416
418
|
// For composite keys, we would need to map the split parts. For now log a warning.
|
|
417
|
-
|
|
419
|
+
logger.warn(`Join path resolution for composite primary keys is not yet fully supported: ${collection.slug}`);
|
|
418
420
|
}
|
|
419
421
|
|
|
420
422
|
// Build additional conditions array
|
|
@@ -453,7 +455,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
453
455
|
}
|
|
454
456
|
}
|
|
455
457
|
} catch (e) {
|
|
456
|
-
|
|
458
|
+
logger.warn(`Could not resolve join path relation property: ${propKey}`, { error: e });
|
|
457
459
|
}
|
|
458
460
|
}
|
|
459
461
|
}
|
|
@@ -519,13 +521,13 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
519
521
|
|
|
520
522
|
case "string": {
|
|
521
523
|
if (typeof value === "string") return value;
|
|
522
|
-
|
|
524
|
+
|
|
523
525
|
// Handle Buffer objects (e.g. from PostgreSQL bytea columns)
|
|
524
526
|
const buf = tryResolveBuffer(value);
|
|
525
527
|
if (buf) {
|
|
526
528
|
return bufferToStringOrBase64(buf);
|
|
527
529
|
}
|
|
528
|
-
|
|
530
|
+
|
|
529
531
|
if (typeof value === "object" && value !== null) {
|
|
530
532
|
try {
|
|
531
533
|
return JSON.stringify(value);
|
|
@@ -549,7 +551,7 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
549
551
|
}
|
|
550
552
|
|
|
551
553
|
if (!relationDef) {
|
|
552
|
-
|
|
554
|
+
logger.warn(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
553
555
|
return value;
|
|
554
556
|
}
|
|
555
557
|
|
|
@@ -557,7 +559,7 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
557
559
|
const targetCollection = relationDef.target();
|
|
558
560
|
return createRelationRef(value.toString(), targetCollection.slug);
|
|
559
561
|
} catch (e) {
|
|
560
|
-
|
|
562
|
+
logger.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, { error: e });
|
|
561
563
|
return value;
|
|
562
564
|
}
|
|
563
565
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Pool } from "pg";
|
|
2
2
|
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
+
import { logger } from "@rebasepro/server-core";
|
|
4
5
|
|
|
5
6
|
export class DatabasePoolManager {
|
|
6
7
|
private pools: Map<string, Pool> = new Map();
|
|
@@ -47,7 +48,7 @@ export class DatabasePoolManager {
|
|
|
47
48
|
|
|
48
49
|
// Prevent idle client errors from crashing the Node.js process
|
|
49
50
|
pool.on("error", (err) => {
|
|
50
|
-
|
|
51
|
+
logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
|
|
51
52
|
});
|
|
52
53
|
|
|
53
54
|
this.pools.set(databaseName, pool);
|
|
@@ -76,7 +77,7 @@ export class DatabasePoolManager {
|
|
|
76
77
|
public async shutdown(): Promise<void> {
|
|
77
78
|
const promises = [];
|
|
78
79
|
for (const [dbName, pool] of this.pools.entries()) {
|
|
79
|
-
|
|
80
|
+
logger.info(`[DatabasePoolManager] Shutting down pool for ${dbName}`);
|
|
80
81
|
promises.push(pool.end());
|
|
81
82
|
}
|
|
82
83
|
await Promise.all(promises);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { logger } from "@rebasepro/server-core";
|
|
3
4
|
|
|
4
5
|
export interface HistoryEntry {
|
|
5
6
|
id: string;
|
|
@@ -99,10 +100,10 @@ export class HistoryService {
|
|
|
99
100
|
|
|
100
101
|
// Non-blocking prune for this specific entity
|
|
101
102
|
this.pruneEntity(tableName, entityId).catch(err =>
|
|
102
|
-
|
|
103
|
+
logger.error("History prune failed", { error: err })
|
|
103
104
|
);
|
|
104
105
|
} catch (error) {
|
|
105
|
-
|
|
106
|
+
logger.error("Failed to record entity history", { error: error });
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
109
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { logger } from "@rebasepro/server-core";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Auto-create the entity history table if it doesn't exist.
|
|
@@ -7,7 +8,7 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
|
7
8
|
* pattern as `ensureAuthTablesExist`.
|
|
8
9
|
*/
|
|
9
10
|
export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void> {
|
|
10
|
-
|
|
11
|
+
logger.info("🔍 Checking entity history table...");
|
|
11
12
|
|
|
12
13
|
try {
|
|
13
14
|
// Create the rebase schema (idempotent — may already exist from auth init)
|
|
@@ -37,9 +38,9 @@ export async function ensureHistoryTableExists(db: NodePgDatabase): Promise<void
|
|
|
37
38
|
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
38
39
|
`);
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
logger.info("✅ Entity history table ready");
|
|
41
42
|
} catch (error) {
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
logger.error("❌ Failed to create entity history table", { error: error });
|
|
44
|
+
logger.warn("⚠️ Continuing without creating history table.");
|
|
44
45
|
}
|
|
45
46
|
}
|
|
@@ -4,7 +4,7 @@ import { relations } from "drizzle-orm";
|
|
|
4
4
|
/**
|
|
5
5
|
* Factory function to dynamically create the auth tables bound to the specified schema names.
|
|
6
6
|
*/
|
|
7
|
-
export function createAuthSchema(usersSchemaName
|
|
7
|
+
export function createAuthSchema(usersSchemaName = "rebase") {
|
|
8
8
|
const usersSchema = usersSchemaName === "public" ? null : pgSchema(usersSchemaName);
|
|
9
9
|
|
|
10
10
|
const tableCreator = (usersSchema ? usersSchema.table.bind(usersSchema) : pgTable) as typeof pgTable;
|
|
@@ -30,7 +30,6 @@ export function createAuthSchema(usersSchemaName: string = "rebase") {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
|
|
34
33
|
/**
|
|
35
34
|
* Refresh tokens for long-lived sessions
|
|
36
35
|
*/
|
package/src/schema/doctor-cli.ts
CHANGED
|
@@ -7,6 +7,7 @@ import path from "path";
|
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import fs from "fs";
|
|
9
9
|
import { runDoctor } from "./doctor";
|
|
10
|
+
import { logger } from "@rebasepro/server-core";
|
|
10
11
|
|
|
11
12
|
async function main() {
|
|
12
13
|
const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
|
|
@@ -46,6 +47,6 @@ async function main() {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
main().catch((err) => {
|
|
49
|
-
|
|
50
|
+
logger.error(chalk.red(" ✗ Doctor failed"), { error: err });
|
|
50
51
|
process.exit(1);
|
|
51
52
|
});
|
package/src/schema/doctor.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
|
17
17
|
import { generateTypedefs } from "@rebasepro/sdk-generator";
|
|
18
18
|
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
19
19
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
|
+
import { logger } from "@rebasepro/server-core";
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Resolve the SQL column name for a property.
|
|
@@ -149,7 +150,7 @@ export async function loadCollections(collectionsPath: string): Promise<EntityCo
|
|
|
149
150
|
}
|
|
150
151
|
} catch (err: unknown) {
|
|
151
152
|
const message = err instanceof Error ? err.message : String(err);
|
|
152
|
-
|
|
153
|
+
logger.error(chalk.yellow(` ⚠ Could not load ${file}: ${message}`));
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
}
|
|
@@ -244,7 +245,8 @@ export async function checkCollectionsVsSdk(
|
|
|
244
245
|
message: `Generated SDK typedefs file does not exist at "${sdkFilePath}".`,
|
|
245
246
|
fix: "Run `rebase generate-sdk`"
|
|
246
247
|
});
|
|
247
|
-
return { passed: false,
|
|
248
|
+
return { passed: false,
|
|
249
|
+
issues };
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
try {
|
|
@@ -277,7 +279,8 @@ export async function checkCollectionsVsSdk(
|
|
|
277
279
|
});
|
|
278
280
|
}
|
|
279
281
|
|
|
280
|
-
return { passed: issues.length === 0,
|
|
282
|
+
return { passed: issues.length === 0,
|
|
283
|
+
issues };
|
|
281
284
|
}
|
|
282
285
|
|
|
283
286
|
// ── Phase 2: Collections ↔ Database ──────────────────────────────────────
|
|
@@ -326,7 +329,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
326
329
|
WHERE table_schema = ANY($1) AND table_type = 'BASE TABLE'`,
|
|
327
330
|
[schemas]
|
|
328
331
|
);
|
|
329
|
-
const existingTables = new Set(tablesResult.rows.map((r) =>
|
|
332
|
+
const existingTables = new Set(tablesResult.rows.map((r) =>
|
|
330
333
|
r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
|
|
331
334
|
));
|
|
332
335
|
|
|
@@ -457,8 +460,8 @@ export async function checkCollectionsVsDatabase(
|
|
|
457
460
|
targetSchemaName = targetColl.schema || "public";
|
|
458
461
|
} catch { /* ignore */ }
|
|
459
462
|
|
|
460
|
-
const hasFk = tableFks.some((fk) =>
|
|
461
|
-
fk.column_name === fkColName &&
|
|
463
|
+
const hasFk = tableFks.some((fk) =>
|
|
464
|
+
fk.column_name === fkColName &&
|
|
462
465
|
fk.foreign_table_name === targetTableName &&
|
|
463
466
|
fk.foreign_table_schema === targetSchemaName
|
|
464
467
|
);
|
|
@@ -615,10 +618,10 @@ issues };
|
|
|
615
618
|
// ── Report Rendering ─────────────────────────────────────────────────────
|
|
616
619
|
|
|
617
620
|
export function renderReport(report: DoctorReport): void {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
621
|
+
logger.info("");
|
|
622
|
+
logger.info(chalk.bold(" 🩺 Rebase Schema Doctor"));
|
|
623
|
+
logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
624
|
+
logger.info("");
|
|
622
625
|
|
|
623
626
|
// Phase 1
|
|
624
627
|
renderPhase(
|
|
@@ -642,7 +645,7 @@ export function renderReport(report: DoctorReport): void {
|
|
|
642
645
|
);
|
|
643
646
|
|
|
644
647
|
// Summary
|
|
645
|
-
|
|
648
|
+
logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
646
649
|
const { passed, warnings, errors } = report.summary;
|
|
647
650
|
|
|
648
651
|
const parts: string[] = [];
|
|
@@ -650,50 +653,50 @@ export function renderReport(report: DoctorReport): void {
|
|
|
650
653
|
if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
|
|
651
654
|
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
652
655
|
|
|
653
|
-
|
|
654
|
-
|
|
656
|
+
logger.info(` Summary: ${parts.join(", ")}`);
|
|
657
|
+
logger.info("");
|
|
655
658
|
|
|
656
659
|
if (errors > 0) {
|
|
657
|
-
|
|
660
|
+
logger.info(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
|
|
658
661
|
} else if (warnings > 0) {
|
|
659
|
-
|
|
662
|
+
logger.info(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
|
|
660
663
|
} else {
|
|
661
|
-
|
|
664
|
+
logger.info(chalk.green.bold(" ✓ All schemas are in sync!"));
|
|
662
665
|
}
|
|
663
|
-
|
|
666
|
+
logger.info("");
|
|
664
667
|
}
|
|
665
668
|
|
|
666
669
|
function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
|
|
667
670
|
if (passed) {
|
|
668
|
-
|
|
671
|
+
logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
669
672
|
} else {
|
|
670
673
|
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
671
674
|
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
672
675
|
const parts: string[] = [];
|
|
673
676
|
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
674
677
|
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
675
|
-
|
|
678
|
+
logger.info(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
|
|
676
679
|
}
|
|
677
|
-
|
|
680
|
+
logger.info("");
|
|
678
681
|
|
|
679
682
|
for (const issue of issues) {
|
|
680
683
|
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
681
684
|
const categoryLabel = formatCategory(issue.category);
|
|
682
|
-
|
|
685
|
+
logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
683
686
|
|
|
684
687
|
if (issue.table) {
|
|
685
688
|
const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
|
|
686
|
-
|
|
689
|
+
logger.info(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
|
|
687
690
|
}
|
|
688
691
|
|
|
689
692
|
if (issue.expected && issue.actual) {
|
|
690
|
-
|
|
693
|
+
logger.info(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
|
|
691
694
|
}
|
|
692
695
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
696
|
+
logger.info(` ${chalk.gray("│")} ${issue.message}`);
|
|
697
|
+
logger.info(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
|
|
698
|
+
logger.info(` ${chalk.gray("└" + "─".repeat(48))}`);
|
|
699
|
+
logger.info("");
|
|
697
700
|
}
|
|
698
701
|
}
|
|
699
702
|
|
|
@@ -720,33 +723,33 @@ export async function runDoctor(options: {
|
|
|
720
723
|
sdkPath: string;
|
|
721
724
|
databaseUrl?: string;
|
|
722
725
|
}): Promise<DoctorReport> {
|
|
723
|
-
|
|
724
|
-
|
|
726
|
+
logger.info("");
|
|
727
|
+
logger.info(chalk.bold(" 🩺 Loading collections..."));
|
|
725
728
|
const collections = await loadCollections(options.collectionsPath);
|
|
726
729
|
if (collections.length === 0) {
|
|
727
|
-
|
|
730
|
+
logger.error(chalk.red(" ✗ No collections found."));
|
|
728
731
|
process.exit(1);
|
|
729
732
|
}
|
|
730
|
-
|
|
731
|
-
|
|
733
|
+
logger.info(chalk.gray(` Found ${collections.length} collection(s)`));
|
|
734
|
+
logger.info("");
|
|
732
735
|
|
|
733
736
|
// Phase 1: Collections ↔ Generated Schema
|
|
734
|
-
|
|
737
|
+
logger.info(chalk.gray(" Checking Collections → Generated Schema..."));
|
|
735
738
|
const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
|
|
736
739
|
|
|
737
740
|
// Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
|
|
738
741
|
let schemaToDatabase: { passed: boolean; issues: DoctorIssue[] } = { passed: true,
|
|
739
742
|
issues: [] };
|
|
740
743
|
if (options.databaseUrl) {
|
|
741
|
-
|
|
744
|
+
logger.info(chalk.gray(" Checking Collections → Database..."));
|
|
742
745
|
schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
|
|
743
746
|
} else {
|
|
744
|
-
|
|
745
|
-
|
|
747
|
+
logger.info(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
|
|
748
|
+
logger.info(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
|
|
746
749
|
}
|
|
747
750
|
|
|
748
751
|
// Phase 3: Collections ↔ SDK Types
|
|
749
|
-
|
|
752
|
+
logger.info(chalk.gray(" Checking Collections → SDK Types..."));
|
|
750
753
|
const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
|
|
751
754
|
|
|
752
755
|
const allIssues = [...collectionsToSchema.issues, ...schemaToDatabase.issues, ...collectionsToSdk.issues];
|
|
@@ -3,6 +3,7 @@ import { getPrimaryKeys } from "../services/entity-helpers";
|
|
|
3
3
|
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
4
4
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
5
5
|
import { createHash } from "crypto";
|
|
6
|
+
import { logger } from "@rebasepro/server-core";
|
|
6
7
|
// --- Helper Functions ---
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -41,6 +42,31 @@ type: "string",
|
|
|
41
42
|
isUuid: isUuid ?? false };
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Given a raw DB column name (e.g. "client_id"), find the Drizzle property key
|
|
47
|
+
* on the collection that maps to that column. A property matches if:
|
|
48
|
+
* (a) it has an explicit `columnName` equal to the given column, OR
|
|
49
|
+
* (b) its snake_case form equals the given column.
|
|
50
|
+
*
|
|
51
|
+
* Returns the property key (the Drizzle object key) if found, or the original
|
|
52
|
+
* column name as a fallback.
|
|
53
|
+
*/
|
|
54
|
+
const resolvePropertyKeyForColumn = (collection: EntityCollection, column: string): string => {
|
|
55
|
+
if (!collection.properties) return column;
|
|
56
|
+
for (const [propKey, prop] of Object.entries(collection.properties)) {
|
|
57
|
+
const p = prop as Property;
|
|
58
|
+
// Explicit columnName match
|
|
59
|
+
if ("columnName" in p && typeof (p as unknown as Record<string, unknown>).columnName === "string") {
|
|
60
|
+
if ((p as unknown as Record<string, unknown>).columnName === column) return propKey;
|
|
61
|
+
}
|
|
62
|
+
// Convention match: snake_case(propKey) === column
|
|
63
|
+
if (toSnakeCase(propKey) === column) return propKey;
|
|
64
|
+
// Exact match (propKey is already the column name)
|
|
65
|
+
if (propKey === column) return propKey;
|
|
66
|
+
}
|
|
67
|
+
return column;
|
|
68
|
+
};
|
|
69
|
+
|
|
44
70
|
const isNumericId = (collection: EntityCollection): boolean => {
|
|
45
71
|
return getPrimaryKeyProp(collection).type === "number";
|
|
46
72
|
};
|
|
@@ -143,7 +169,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
|
|
|
143
169
|
}
|
|
144
170
|
// autoValue: database-level default for initial value on INSERT
|
|
145
171
|
if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") {
|
|
146
|
-
columnDefinition +=
|
|
172
|
+
columnDefinition += ".default(sql`now()`)";
|
|
147
173
|
}
|
|
148
174
|
break;
|
|
149
175
|
}
|
|
@@ -206,7 +232,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
|
|
|
206
232
|
|
|
207
233
|
// The localKey property is the source of truth for the FK column name.
|
|
208
234
|
if (!relation.localKey) {
|
|
209
|
-
|
|
235
|
+
logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
|
|
210
236
|
return null;
|
|
211
237
|
}
|
|
212
238
|
|
|
@@ -296,7 +322,7 @@ const resolveRawSql = (expression: string): string => {
|
|
|
296
322
|
*/
|
|
297
323
|
const wrapWithRoleCheck = (clause: string, roles: string[]): string => {
|
|
298
324
|
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
299
|
-
const roleCondition = `string_to_array(auth.roles(), ',')
|
|
325
|
+
const roleCondition = `string_to_array(auth.roles(), ',') && ${rolesArrayString}`;
|
|
300
326
|
return `sql\`(${unwrapSql(clause)}) AND (${roleCondition})\``;
|
|
301
327
|
};
|
|
302
328
|
|
|
@@ -410,13 +436,13 @@ const generateSinglePolicyCode = (collection: EntityCollection, rule: SecurityRu
|
|
|
410
436
|
} else if (needsUsing) {
|
|
411
437
|
// Roles-only rule (e.g. { operation: "select", roles: ["admin"] })
|
|
412
438
|
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
413
|
-
usingClause = `sql\`string_to_array(auth.roles(), ',')
|
|
439
|
+
usingClause = `sql\`string_to_array(auth.roles(), ',') && ${rolesArrayString}\``;
|
|
414
440
|
}
|
|
415
441
|
if (withCheckClause) {
|
|
416
442
|
withCheckClause = wrapWithRoleCheck(withCheckClause, roles);
|
|
417
443
|
} else if (needsWithCheck) {
|
|
418
444
|
const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
|
|
419
|
-
withCheckClause = `sql\`string_to_array(auth.roles(), ',')
|
|
445
|
+
withCheckClause = `sql\`string_to_array(auth.roles(), ',') && ${rolesArrayString}\``;
|
|
420
446
|
}
|
|
421
447
|
}
|
|
422
448
|
|
|
@@ -466,15 +492,22 @@ const computeSharedRelationName = (
|
|
|
466
492
|
|
|
467
493
|
// --- owning one (belongs-to) ---
|
|
468
494
|
if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
|
|
469
|
-
|
|
495
|
+
// Normalise the localKey to the actual Drizzle property name so that
|
|
496
|
+
// the owning side produces the same relation name as the inverse side
|
|
497
|
+
// (which resolves foreignKeyOnTarget via the same helper).
|
|
498
|
+
const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
|
|
499
|
+
return `${getTableName(sourceCollection)}_${normalisedKey}`;
|
|
470
500
|
}
|
|
471
501
|
|
|
472
502
|
// --- inverse many (one-to-many has-many) ---
|
|
473
503
|
if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
|
|
474
|
-
// The owning table is the *target*, the FK column is foreignKeyOnTarget
|
|
504
|
+
// The owning table is the *target*, the FK column is foreignKeyOnTarget.
|
|
505
|
+
// Resolve to the Drizzle property key on the target so it matches the
|
|
506
|
+
// owning side's normalised localKey.
|
|
475
507
|
try {
|
|
476
508
|
const targetCollection = rel.target();
|
|
477
|
-
|
|
509
|
+
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
510
|
+
return `${getTableName(targetCollection)}_${normalisedFK}`;
|
|
478
511
|
} catch {
|
|
479
512
|
return fallback;
|
|
480
513
|
}
|
|
@@ -483,10 +516,11 @@ const computeSharedRelationName = (
|
|
|
483
516
|
// --- inverse one (one-to-one inverse) ---
|
|
484
517
|
if (rel.direction === "inverse" && rel.cardinality === "one") {
|
|
485
518
|
if (rel.foreignKeyOnTarget) {
|
|
486
|
-
// FK lives on the target table
|
|
519
|
+
// FK lives on the target table — resolve to Drizzle property key
|
|
487
520
|
try {
|
|
488
521
|
const targetCollection = rel.target();
|
|
489
|
-
|
|
522
|
+
const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
|
|
523
|
+
return `${getTableName(targetCollection)}_${normalisedFK}`;
|
|
490
524
|
} catch {
|
|
491
525
|
return fallback;
|
|
492
526
|
}
|
|
@@ -521,7 +555,6 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
|
|
|
521
555
|
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
522
556
|
|
|
523
557
|
|
|
524
|
-
|
|
525
558
|
const hasUuid = collections.some(c =>
|
|
526
559
|
c.properties && Object.values(c.properties).some(
|
|
527
560
|
(p: Property) => p.type === "string" && ((p as unknown as Record<string, unknown>).autoValue === "uuid" || (p as unknown as Record<string, unknown>).isId === "uuid")
|
|
@@ -812,23 +845,23 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
|
|
|
812
845
|
const junctionTableVar = getTableVarName(correspondingRelation.through.table);
|
|
813
846
|
tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
|
|
814
847
|
} else {
|
|
815
|
-
|
|
848
|
+
logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
|
|
816
849
|
}
|
|
817
850
|
} catch (e) {
|
|
818
|
-
|
|
851
|
+
logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
|
|
819
852
|
}
|
|
820
853
|
}
|
|
821
854
|
// joinPath relations don't generate Drizzle relations - they use existing user tables
|
|
822
855
|
}
|
|
823
856
|
} catch (e) {
|
|
824
|
-
|
|
857
|
+
logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
|
|
825
858
|
}
|
|
826
859
|
}
|
|
827
860
|
|
|
828
861
|
// Synthesize missing reciprocal relations
|
|
829
862
|
for (const otherCollection of collections) {
|
|
830
863
|
if (otherCollection.slug === collection.slug) continue;
|
|
831
|
-
|
|
864
|
+
|
|
832
865
|
const otherRelations = resolveCollectionRelations(otherCollection);
|
|
833
866
|
for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
|
|
834
867
|
if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
|
|
@@ -837,11 +870,16 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
|
|
|
837
870
|
if (otherTarget.slug === collection.slug) {
|
|
838
871
|
const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
|
|
839
872
|
const deduplicationKey = `${drizzleRelationName}::owning`;
|
|
840
|
-
|
|
873
|
+
|
|
841
874
|
if (!emittedRelationNames.has(deduplicationKey)) {
|
|
842
875
|
const otherTableVar = getTableVarName(getTableName(otherCollection));
|
|
843
|
-
|
|
844
|
-
|
|
876
|
+
// Resolve foreignKeyOnTarget to the Drizzle property key
|
|
877
|
+
// on THIS collection (the owning table). The raw FK column
|
|
878
|
+
// name (e.g. "client_id") may differ from the property key
|
|
879
|
+
// (e.g. "clientId") when `columnName` is set.
|
|
880
|
+
const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
|
|
881
|
+
const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
|
|
882
|
+
tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${getPrimaryKeyName(otherCollection)}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
845
883
|
emittedRelationNames.add(deduplicationKey);
|
|
846
884
|
}
|
|
847
885
|
}
|