@rebasepro/server-postgresql 0.5.0 → 0.6.1
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 +10207 -11145
- package/dist/index.es.js.map +1 -1
- package/dist/{server-postgresql/src/services → services}/EntityPersistService.d.ts +0 -14
- package/dist/utils/pg-array-null-patch.d.ts +16 -0
- 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 +100 -30
- 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 +25 -15
- 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 +19 -149
- 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-array-null-patch.ts +42 -0
- package/src/utils/pg-error-utils.ts +211 -0
- package/src/websocket.ts +15 -12
- package/test/array-null-safety.test.ts +335 -0
- 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 +57 -4
- 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-array-null-patch.test.ts +65 -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 +12 -10
- 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/index.umd.js +0 -11749
- package/dist/index.umd.js.map +0 -1
- 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
|
@@ -4,6 +4,8 @@ import { FilterValues, WhereFilterOp, Relation, JoinStep, LogicalCondition, Filt
|
|
|
4
4
|
import { getColumnName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
5
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
6
6
|
import { ConditionBuilderStatic } from "../interfaces";
|
|
7
|
+
import { logger } from "@rebasepro/server-core";
|
|
8
|
+
import { getColumnMeta } from "../services/entity-helpers";
|
|
7
9
|
|
|
8
10
|
/** Drizzle dynamic query builder — accepts innerJoin + where chaining */
|
|
9
11
|
|
|
@@ -48,7 +50,7 @@ export class DrizzleConditionBuilder {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
if (!fieldColumn) {
|
|
51
|
-
|
|
53
|
+
logger.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -90,7 +92,7 @@ export class DrizzleConditionBuilder {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
if (!fieldColumn) {
|
|
93
|
-
|
|
95
|
+
logger.warn(`Filtering by field '${cond.column}', but it does not exist in table for collection '${collectionPath}'`);
|
|
94
96
|
return null;
|
|
95
97
|
}
|
|
96
98
|
return this.buildSingleFilterCondition(fieldColumn, cond.operator as WhereFilterOp, cond.value);
|
|
@@ -129,25 +131,39 @@ export class DrizzleConditionBuilder {
|
|
|
129
131
|
return inArray(column, value);
|
|
130
132
|
}
|
|
131
133
|
return null;
|
|
132
|
-
case "array-contains":
|
|
134
|
+
case "array-contains": {
|
|
135
|
+
const meta = getColumnMeta(column);
|
|
136
|
+
if (meta.dataType === "array" || meta.columnType === "PgArray") {
|
|
137
|
+
return sql`${column} @> ARRAY[${value}]`;
|
|
138
|
+
}
|
|
133
139
|
// For JSONB arrays: checks if the column contains the given value
|
|
134
140
|
return sql`${column} @> ${JSON.stringify([value])}`;
|
|
135
|
-
|
|
136
|
-
|
|
141
|
+
}
|
|
142
|
+
case "array-contains-any": {
|
|
143
|
+
const meta = getColumnMeta(column);
|
|
144
|
+
const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
|
|
137
145
|
if (Array.isArray(value) && value.length > 0) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
146
|
+
if (isNativeArray) {
|
|
147
|
+
return sql`${column} && ARRAY[${sql.join(value.map(v => sql`${v}`), sql`, `)}]`;
|
|
148
|
+
} else {
|
|
149
|
+
// Use the ?| operator for JSONB overlap with text array
|
|
150
|
+
const textValues = value.map(v => String(v));
|
|
151
|
+
return sql`${column} ?| array[${sql.join(textValues.map(v => sql`${v}`), sql`, `)}]`;
|
|
152
|
+
}
|
|
141
153
|
}
|
|
142
154
|
// Single value fallback: treat as array-contains
|
|
155
|
+
if (isNativeArray) {
|
|
156
|
+
return sql`${column} @> ARRAY[${value}]`;
|
|
157
|
+
}
|
|
143
158
|
return sql`${column} @> ${JSON.stringify([value])}`;
|
|
159
|
+
}
|
|
144
160
|
case "not-in":
|
|
145
161
|
if (Array.isArray(value) && value.length > 0) {
|
|
146
162
|
return sql`${column} NOT IN (${sql.join(value.map(v => sql`${v}`), sql`, `)})`;
|
|
147
163
|
}
|
|
148
164
|
return null;
|
|
149
165
|
default:
|
|
150
|
-
|
|
166
|
+
logger.warn(`Unsupported filter operation: ${op}`);
|
|
151
167
|
return null;
|
|
152
168
|
}
|
|
153
169
|
}
|
|
@@ -247,7 +263,7 @@ export class DrizzleConditionBuilder {
|
|
|
247
263
|
);
|
|
248
264
|
whereConditions.push(simpleCondition);
|
|
249
265
|
} else {
|
|
250
|
-
|
|
266
|
+
logger.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
|
|
251
267
|
throw new Error(`Cannot resolve inverse many relation '${relation.relationName}'. Either specify 'through' property, ensure corresponding owning relation exists with junction table configuration, or specify 'foreignKeyOnTarget' for one-to-many relationships.`);
|
|
252
268
|
}
|
|
253
269
|
} else {
|
|
@@ -711,9 +727,9 @@ export class DrizzleConditionBuilder {
|
|
|
711
727
|
const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
|
|
712
728
|
if (fieldColumn) {
|
|
713
729
|
// Verify that the underlying database column supports string pattern-matching
|
|
714
|
-
const supportsILike =
|
|
715
|
-
fieldColumn instanceof PgVarchar ||
|
|
716
|
-
fieldColumn instanceof PgText ||
|
|
730
|
+
const supportsILike =
|
|
731
|
+
fieldColumn instanceof PgVarchar ||
|
|
732
|
+
fieldColumn instanceof PgText ||
|
|
717
733
|
fieldColumn instanceof PgChar ||
|
|
718
734
|
(fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn));
|
|
719
735
|
if (supportsILike) {
|
|
@@ -1058,7 +1074,7 @@ export class DrizzleConditionBuilder {
|
|
|
1058
1074
|
console.debug("🔍 [findCorrespondingJunctionTable] Returning junction info:", result);
|
|
1059
1075
|
return result;
|
|
1060
1076
|
} catch (error) {
|
|
1061
|
-
|
|
1077
|
+
logger.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}'`, { error: error });
|
|
1062
1078
|
return null;
|
|
1063
1079
|
}
|
|
1064
1080
|
}
|
|
@@ -1108,7 +1124,7 @@ export class DrizzleConditionBuilder {
|
|
|
1108
1124
|
filter: vectorSearch.threshold != null
|
|
1109
1125
|
? sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)}) < ${vectorSearch.threshold}`
|
|
1110
1126
|
: undefined,
|
|
1111
|
-
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})
|
|
1127
|
+
distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
|
|
1112
1128
|
};
|
|
1113
1129
|
}
|
|
1114
1130
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getTableColumns } from "drizzle-orm";
|
|
2
|
+
import { PgArray, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { logger } from "@rebasepro/server-core";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Patches all PgArray columns on the given tables to handle NULL values safely.
|
|
7
|
+
*
|
|
8
|
+
* Drizzle ORM's `PgArray.mapFromDriverValue` calls `value.map(...)` without
|
|
9
|
+
* guarding against `null`. When a PostgreSQL native array column (`text[]`,
|
|
10
|
+
* `integer[]`, etc.) contains NULL, the pg driver returns `null` in JavaScript,
|
|
11
|
+
* and `null.map(...)` throws `TypeError: value.map is not a function`.
|
|
12
|
+
*
|
|
13
|
+
* This function walks every column of every registered table and, for any
|
|
14
|
+
* `PgArray` column, wraps its `mapFromDriverValue` to return `null` when the
|
|
15
|
+
* database value is nullish.
|
|
16
|
+
*
|
|
17
|
+
* This is a workaround for a known Drizzle ORM issue. Should be removed once
|
|
18
|
+
* Drizzle handles nullable arrays natively.
|
|
19
|
+
*/
|
|
20
|
+
export function patchPgArrayNullSafety(tables: Record<string, unknown>): void {
|
|
21
|
+
let patchedCount = 0;
|
|
22
|
+
|
|
23
|
+
for (const tableOrRelation of Object.values(tables)) {
|
|
24
|
+
if (!(tableOrRelation instanceof PgTable)) continue;
|
|
25
|
+
|
|
26
|
+
const columns = getTableColumns(tableOrRelation);
|
|
27
|
+
for (const column of Object.values(columns)) {
|
|
28
|
+
if (column instanceof PgArray) {
|
|
29
|
+
const original = column.mapFromDriverValue.bind(column);
|
|
30
|
+
column.mapFromDriverValue = function (value: unknown) {
|
|
31
|
+
if (value == null) return null;
|
|
32
|
+
return original(value as string | unknown[]);
|
|
33
|
+
};
|
|
34
|
+
patchedCount++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (patchedCount > 0) {
|
|
40
|
+
logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared PostgreSQL error extraction and user-friendly message formatting.
|
|
3
|
+
*
|
|
4
|
+
* Drizzle wraps native PG errors in a `.cause` chain. These utilities
|
|
5
|
+
* unwrap that chain to get the real PostgreSQL error (identified by a
|
|
6
|
+
* 5-character alphanumeric `code` such as `42P01`) and translate it into
|
|
7
|
+
* a message that is safe and helpful to show to end-users.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logger } from "@rebasepro/server-core";
|
|
11
|
+
|
|
12
|
+
/** Shape of PostgreSQL errors with diagnostic metadata. */
|
|
13
|
+
export interface PostgresError extends Error {
|
|
14
|
+
code?: string;
|
|
15
|
+
detail?: string;
|
|
16
|
+
hint?: string;
|
|
17
|
+
constraint?: string;
|
|
18
|
+
column?: string;
|
|
19
|
+
table?: string;
|
|
20
|
+
dataType?: string;
|
|
21
|
+
cause?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract the underlying PostgreSQL error from a Drizzle wrapper.
|
|
26
|
+
* Drizzle wraps PG errors in a `cause` property — this function
|
|
27
|
+
* recursively walks the chain until it finds an object with a PG
|
|
28
|
+
* error code (5-char alphanumeric, e.g. `42P01`).
|
|
29
|
+
*/
|
|
30
|
+
export function extractPgError(error: unknown): PostgresError | null {
|
|
31
|
+
if (!error || typeof error !== "object") return null;
|
|
32
|
+
if (!(error instanceof Error)) {
|
|
33
|
+
// Check non-Error objects for a cause chain (Drizzle sometimes wraps oddly)
|
|
34
|
+
if ("cause" in error && (error as Record<string, unknown>).cause && typeof (error as Record<string, unknown>).cause === "object") {
|
|
35
|
+
return extractPgError((error as Record<string, unknown>).cause);
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check if the error itself has a PG error code
|
|
41
|
+
if ("code" in error && typeof (error as PostgresError).code === "string" && /^[0-9A-Z]{5}$/.test((error as PostgresError).code!)) {
|
|
42
|
+
return error as PostgresError;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check the cause chain (Drizzle wraps PG errors)
|
|
46
|
+
if (error.cause && typeof error.cause === "object") {
|
|
47
|
+
return extractPgError(error.cause);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walk the error cause chain and return the deepest meaningful message.
|
|
55
|
+
*/
|
|
56
|
+
export function extractCauseMessage(error: unknown): string | null {
|
|
57
|
+
if (!error || typeof error !== "object") return null;
|
|
58
|
+
if (!(error instanceof Error)) return null;
|
|
59
|
+
|
|
60
|
+
if (error.cause && typeof error.cause === "object") {
|
|
61
|
+
const deeper = extractCauseMessage(error.cause);
|
|
62
|
+
if (deeper) return deeper;
|
|
63
|
+
// The cause itself has a message
|
|
64
|
+
if (error.cause instanceof Error && error.cause.message) {
|
|
65
|
+
return error.cause.message;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Translate a raw PostgreSQL error into a user-friendly message.
|
|
73
|
+
*
|
|
74
|
+
* @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
|
|
75
|
+
* @param context - A human-readable context string (e.g. collection slug or path)
|
|
76
|
+
* @returns An object with a `message` safe for the client and the PG `code`.
|
|
77
|
+
*/
|
|
78
|
+
export function pgErrorToFriendlyMessage(pgError: PostgresError, context: string): { message: string; code: string } {
|
|
79
|
+
const detail = pgError.detail as string | undefined;
|
|
80
|
+
const hint = pgError.hint as string | undefined;
|
|
81
|
+
const constraint = pgError.constraint as string | undefined;
|
|
82
|
+
const column = pgError.column as string | undefined;
|
|
83
|
+
const table = pgError.table as string | undefined;
|
|
84
|
+
const dataType = pgError.dataType as string | undefined;
|
|
85
|
+
const pgMessage = pgError.message || "Unknown database error";
|
|
86
|
+
const code = pgError.code || "UNKNOWN";
|
|
87
|
+
|
|
88
|
+
const suffix = hint ? ` Hint: ${hint}` : "";
|
|
89
|
+
const tableRef = table ?? context;
|
|
90
|
+
|
|
91
|
+
switch (pgError.code) {
|
|
92
|
+
case "23503": // foreign_key_violation
|
|
93
|
+
return {
|
|
94
|
+
message: detail
|
|
95
|
+
? `Foreign key constraint violated: ${detail}${suffix}`
|
|
96
|
+
: `Cannot complete operation: a foreign key constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
97
|
+
code
|
|
98
|
+
};
|
|
99
|
+
case "23505": // unique_violation
|
|
100
|
+
return {
|
|
101
|
+
message: detail
|
|
102
|
+
? `Duplicate value: ${detail}${suffix}`
|
|
103
|
+
: `Cannot complete operation: a unique constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
104
|
+
code
|
|
105
|
+
};
|
|
106
|
+
case "23502": // not_null_violation
|
|
107
|
+
return {
|
|
108
|
+
message: `Missing required field: "${column ?? "unknown"}" in "${tableRef}" cannot be empty.${suffix}`,
|
|
109
|
+
code
|
|
110
|
+
};
|
|
111
|
+
case "23514": // check_violation
|
|
112
|
+
return {
|
|
113
|
+
message: `Validation failed: a check constraint${constraint ? ` (${constraint})` : ""} was violated in "${context}".${suffix}`,
|
|
114
|
+
code
|
|
115
|
+
};
|
|
116
|
+
case "22P02": // invalid_text_representation (e.g. invalid UUID, wrong enum value)
|
|
117
|
+
return {
|
|
118
|
+
message: `Invalid data format in "${context}": ${pgMessage}${suffix}`,
|
|
119
|
+
code
|
|
120
|
+
};
|
|
121
|
+
case "22001": // string_data_right_truncation (value too long)
|
|
122
|
+
return {
|
|
123
|
+
message: `Value too long for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
|
|
124
|
+
code
|
|
125
|
+
};
|
|
126
|
+
case "22003": // numeric_value_out_of_range
|
|
127
|
+
return {
|
|
128
|
+
message: `Numeric value out of range for column "${column ?? "unknown"}" in "${tableRef}": ${pgMessage}${suffix}`,
|
|
129
|
+
code
|
|
130
|
+
};
|
|
131
|
+
case "42703": // undefined_column
|
|
132
|
+
return {
|
|
133
|
+
message: `Unknown column in "${tableRef}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
134
|
+
code
|
|
135
|
+
};
|
|
136
|
+
case "42P01": // undefined_table
|
|
137
|
+
return {
|
|
138
|
+
message: `Table not found for "${context}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
139
|
+
code
|
|
140
|
+
};
|
|
141
|
+
case "42501": // insufficient_privilege
|
|
142
|
+
return {
|
|
143
|
+
message: `Permission denied on "${tableRef}". Check your database credentials and RLS policies.${suffix}`,
|
|
144
|
+
code
|
|
145
|
+
};
|
|
146
|
+
case "28000": // invalid_authorization_specification
|
|
147
|
+
return {
|
|
148
|
+
message: `Authorization failed for "${context}". Check your database credentials.${suffix}`,
|
|
149
|
+
code
|
|
150
|
+
};
|
|
151
|
+
default: {
|
|
152
|
+
// Unhandled PG code — still surface the actual database message
|
|
153
|
+
const parts = [`Database error in "${context}" [${code}]: ${pgMessage}`];
|
|
154
|
+
if (detail) parts.push(`Detail: ${detail}`);
|
|
155
|
+
if (column) parts.push(`Column: ${column}`);
|
|
156
|
+
if (dataType) parts.push(`Data type: ${dataType}`);
|
|
157
|
+
if (constraint) parts.push(`Constraint: ${constraint}`);
|
|
158
|
+
if (hint) parts.push(`Hint: ${hint}`);
|
|
159
|
+
return { message: parts.join(". "), code };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Sanitize any error into a message safe and helpful for the client.
|
|
166
|
+
*
|
|
167
|
+
* Extracts the PG error from the Drizzle cause chain when possible;
|
|
168
|
+
* falls back to a generic message that doesn't leak SQL.
|
|
169
|
+
*
|
|
170
|
+
* @param error - The raw caught error
|
|
171
|
+
* @param context - A human-readable context string (e.g. collection path)
|
|
172
|
+
* @returns An object with `message` (user-friendly) and optional `code` (PG code).
|
|
173
|
+
*/
|
|
174
|
+
export function sanitizeErrorForClient(error: unknown, context: string): { message: string; code?: string } {
|
|
175
|
+
// ── Always log the full, unsanitized error server-side ──────────
|
|
176
|
+
const pgError = extractPgError(error);
|
|
177
|
+
|
|
178
|
+
if (pgError) {
|
|
179
|
+
logger.error(`[PG ${pgError.code}] Error in "${context}"`, {
|
|
180
|
+
code: pgError.code,
|
|
181
|
+
message: pgError.message,
|
|
182
|
+
detail: pgError.detail,
|
|
183
|
+
hint: pgError.hint,
|
|
184
|
+
column: pgError.column,
|
|
185
|
+
table: pgError.table,
|
|
186
|
+
constraint: pgError.constraint,
|
|
187
|
+
dataType: pgError.dataType,
|
|
188
|
+
// Also log the outer Drizzle wrapper message for full context
|
|
189
|
+
drizzleMessage: error instanceof Error ? error.message : String(error)
|
|
190
|
+
});
|
|
191
|
+
return pgErrorToFriendlyMessage(pgError, context);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// No PG error found — log the raw error as-is
|
|
195
|
+
logger.error(`Database error in "${context}" (no PG error extracted)`, {
|
|
196
|
+
error: error instanceof Error ? error.message : String(error),
|
|
197
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
198
|
+
cause: error instanceof Error && error.cause
|
|
199
|
+
? (error.cause instanceof Error ? error.cause.message : String(error.cause))
|
|
200
|
+
: undefined
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Try to get the deepest cause message
|
|
204
|
+
const causeMessage = extractCauseMessage(error);
|
|
205
|
+
if (causeMessage) {
|
|
206
|
+
return { message: `Database error in "${context}": ${causeMessage}` };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Last resort — generic message, never leak raw SQL
|
|
210
|
+
return { message: `Could not load data for "${context}". Check server logs for details.` };
|
|
211
|
+
}
|
package/src/websocket.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
|
|
7
7
|
import { Server } from "http";
|
|
8
8
|
import { inspect } from "util";
|
|
9
9
|
import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core";
|
|
10
|
+
import { logger } from "@rebasepro/server-core";
|
|
10
11
|
|
|
11
12
|
/** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
|
|
12
13
|
interface WsAuthConfig {
|
|
@@ -33,7 +34,6 @@ interface ClientSession {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
|
|
36
|
-
|
|
37
37
|
/** Maximum messages per client per window */
|
|
38
38
|
const WS_RATE_LIMIT = 2000;
|
|
39
39
|
/** Rate limit window in milliseconds (60 seconds) */
|
|
@@ -105,7 +105,7 @@ export function createPostgresWebSocket(
|
|
|
105
105
|
// Silently absorbed — listenWithPortRetry will retry the next port
|
|
106
106
|
return;
|
|
107
107
|
}
|
|
108
|
-
|
|
108
|
+
logger.error("❌ [WebSocket Server] Error", { error: err });
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
// Auth is required when either: an adapter is present (secure by default),
|
|
@@ -170,14 +170,14 @@ code } }
|
|
|
170
170
|
const adapterUser = authAdapter.verifyToken
|
|
171
171
|
? await authAdapter.verifyToken(token)
|
|
172
172
|
: await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
|
|
173
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
173
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
174
174
|
}));
|
|
175
175
|
|
|
176
176
|
if (adapterUser) {
|
|
177
177
|
verifiedUser = {
|
|
178
178
|
userId: adapterUser.uid,
|
|
179
179
|
roles: adapterUser.roles,
|
|
180
|
-
isAdmin: adapterUser.isAdmin
|
|
180
|
+
isAdmin: adapterUser.isAdmin
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
183
|
} catch {
|
|
@@ -190,7 +190,7 @@ code } }
|
|
|
190
190
|
verifiedUser = {
|
|
191
191
|
userId: jwtPayload.userId,
|
|
192
192
|
roles: jwtPayload.roles ?? [],
|
|
193
|
-
isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
|
|
193
|
+
isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
}
|
|
@@ -205,7 +205,8 @@ code } }
|
|
|
205
205
|
ws.send(JSON.stringify({
|
|
206
206
|
type: "AUTH_SUCCESS",
|
|
207
207
|
requestId,
|
|
208
|
-
payload: { userId: verifiedUser.userId,
|
|
208
|
+
payload: { userId: verifiedUser.userId,
|
|
209
|
+
roles: verifiedUser.roles }
|
|
209
210
|
}));
|
|
210
211
|
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
|
|
211
212
|
} else {
|
|
@@ -277,7 +278,7 @@ code } }
|
|
|
277
278
|
};
|
|
278
279
|
return await driver.withAuth(userForAuth);
|
|
279
280
|
} catch (e) {
|
|
280
|
-
|
|
281
|
+
logger.error("Failed to create RLS scoped delegate for WS request", { error: e });
|
|
281
282
|
throw new Error("Internal authentication error");
|
|
282
283
|
}
|
|
283
284
|
}
|
|
@@ -576,8 +577,10 @@ colors: true }));
|
|
|
576
577
|
// Attach auth context from the WS session so RLS-aware refetches work
|
|
577
578
|
const session = clientSessions.get(clientId);
|
|
578
579
|
const authContext = session?.user
|
|
579
|
-
? { userId: session.user.userId,
|
|
580
|
-
|
|
580
|
+
? { userId: session.user.userId,
|
|
581
|
+
roles: session.user.roles ?? [] }
|
|
582
|
+
: { userId: "anon",
|
|
583
|
+
roles: ["anon"] };
|
|
581
584
|
// Let RealtimeService handle these messages
|
|
582
585
|
await realtimeService.handleClientMessage(clientId, {
|
|
583
586
|
type,
|
|
@@ -588,12 +591,12 @@ colors: true }));
|
|
|
588
591
|
}
|
|
589
592
|
|
|
590
593
|
default:
|
|
591
|
-
|
|
594
|
+
logger.error("❌ [WebSocket Server] Unknown message type", { detail: type });
|
|
592
595
|
}
|
|
593
596
|
} catch (error: unknown) {
|
|
594
|
-
|
|
597
|
+
logger.error("💥 [WebSocket Server] Error handling message", { error: error });
|
|
595
598
|
if (error instanceof Error) {
|
|
596
|
-
|
|
599
|
+
logger.error("Stack trace", { detail: error.stack });
|
|
597
600
|
}
|
|
598
601
|
const errorMessage = process.env.NODE_ENV === "production"
|
|
599
602
|
? "An unexpected error occurred"
|