@rebasepro/server-postgresql 0.4.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.
Files changed (168) hide show
  1. package/README.md +69 -89
  2. package/dist/{server-postgresql/src/PostgresAdapter.d.ts → PostgresAdapter.d.ts} +1 -1
  3. package/dist/{server-postgresql/src/PostgresBackendDriver.d.ts → PostgresBackendDriver.d.ts} +2 -2
  4. package/dist/{server-postgresql/src/PostgresBootstrapper.d.ts → PostgresBootstrapper.d.ts} +11 -1
  5. package/dist/{server-postgresql/src/auth → auth}/services.d.ts +11 -11
  6. package/dist/{server-postgresql/src/collections → collections}/PostgresCollectionRegistry.d.ts +4 -0
  7. package/dist/{server-postgresql/src/data-transformer.d.ts → data-transformer.d.ts} +0 -3
  8. package/dist/{server-postgresql/src/databasePoolManager.d.ts → databasePoolManager.d.ts} +1 -1
  9. package/dist/index.es.js +10174 -11184
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/index.umd.js +10735 -11462
  12. package/dist/index.umd.js.map +1 -1
  13. package/dist/{server-postgresql/src/services → services}/EntityPersistService.d.ts +0 -14
  14. package/dist/types.d.ts +3 -0
  15. package/dist/utils/pg-error-utils.d.ts +55 -0
  16. package/dist/{server-postgresql/src/websocket.d.ts → websocket.d.ts} +8 -3
  17. package/package.json +24 -21
  18. package/src/PostgresAdapter.ts +9 -10
  19. package/src/PostgresBackendDriver.ts +135 -122
  20. package/src/PostgresBootstrapper.ts +90 -16
  21. package/src/auth/ensure-tables.ts +28 -5
  22. package/src/auth/services.ts +56 -45
  23. package/src/cli.ts +140 -110
  24. package/src/collections/PostgresCollectionRegistry.ts +7 -0
  25. package/src/connection.ts +11 -6
  26. package/src/data-transformer.ts +73 -109
  27. package/src/databasePoolManager.ts +5 -3
  28. package/src/history/HistoryService.ts +3 -2
  29. package/src/history/ensure-history-table.ts +5 -4
  30. package/src/schema/auth-schema.ts +1 -2
  31. package/src/schema/doctor-cli.ts +2 -1
  32. package/src/schema/doctor.ts +40 -37
  33. package/src/schema/generate-drizzle-schema-logic.ts +56 -18
  34. package/src/schema/generate-drizzle-schema.ts +11 -11
  35. package/src/schema/introspect-db-inference.ts +25 -25
  36. package/src/schema/introspect-db-logic.ts +38 -38
  37. package/src/schema/introspect-db.ts +28 -27
  38. package/src/services/BranchService.ts +14 -0
  39. package/src/services/EntityFetchService.ts +28 -25
  40. package/src/services/EntityPersistService.ts +11 -124
  41. package/src/services/RelationService.ts +57 -37
  42. package/src/services/entity-helpers.ts +6 -2
  43. package/src/services/realtimeService.ts +45 -32
  44. package/src/types.ts +4 -0
  45. package/src/utils/drizzle-conditions.ts +31 -15
  46. package/src/utils/pg-error-utils.ts +211 -0
  47. package/src/websocket.ts +51 -33
  48. package/test/auth-services.test.ts +36 -19
  49. package/test/batch-many-to-many-regression.test.ts +119 -39
  50. package/test/data-transformer-hardening.test.ts +67 -33
  51. package/test/data-transformer.test.ts +4 -2
  52. package/test/doctor.test.ts +10 -5
  53. package/test/drizzle-conditions.test.ts +59 -6
  54. package/test/generate-drizzle-schema.test.ts +65 -40
  55. package/test/introspect-db-generation.test.ts +179 -81
  56. package/test/introspect-db-utils.test.ts +92 -37
  57. package/test/mocks/chalk.cjs +7 -0
  58. package/test/pg-error-utils.test.ts +221 -0
  59. package/test/postgresDataDriver.test.ts +14 -5
  60. package/test/property-ordering.test.ts +126 -79
  61. package/test/realtimeService.test.ts +6 -2
  62. package/test/relation-pipeline-gaps.test.ts +84 -36
  63. package/test/relations.test.ts +247 -0
  64. package/test/unmapped-tables-safety.test.ts +14 -6
  65. package/test/websocket.test.ts +1 -1
  66. package/tsconfig.json +5 -0
  67. package/tsconfig.prod.json +3 -0
  68. package/vite.config.ts +5 -5
  69. package/dist/common/src/collections/CollectionRegistry.d.ts +0 -56
  70. package/dist/common/src/collections/default-collections.d.ts +0 -9
  71. package/dist/common/src/collections/index.d.ts +0 -2
  72. package/dist/common/src/data/buildRebaseData.d.ts +0 -14
  73. package/dist/common/src/data/query_builder.d.ts +0 -55
  74. package/dist/common/src/index.d.ts +0 -4
  75. package/dist/common/src/util/builders.d.ts +0 -57
  76. package/dist/common/src/util/callbacks.d.ts +0 -6
  77. package/dist/common/src/util/collections.d.ts +0 -11
  78. package/dist/common/src/util/common.d.ts +0 -2
  79. package/dist/common/src/util/conditions.d.ts +0 -26
  80. package/dist/common/src/util/entities.d.ts +0 -58
  81. package/dist/common/src/util/enums.d.ts +0 -3
  82. package/dist/common/src/util/index.d.ts +0 -16
  83. package/dist/common/src/util/navigation_from_path.d.ts +0 -34
  84. package/dist/common/src/util/navigation_utils.d.ts +0 -20
  85. package/dist/common/src/util/parent_references_from_path.d.ts +0 -6
  86. package/dist/common/src/util/paths.d.ts +0 -14
  87. package/dist/common/src/util/permissions.d.ts +0 -6
  88. package/dist/common/src/util/references.d.ts +0 -2
  89. package/dist/common/src/util/relations.d.ts +0 -22
  90. package/dist/common/src/util/resolutions.d.ts +0 -72
  91. package/dist/common/src/util/storage.d.ts +0 -24
  92. package/dist/types/src/controllers/analytics_controller.d.ts +0 -7
  93. package/dist/types/src/controllers/auth.d.ts +0 -104
  94. package/dist/types/src/controllers/client.d.ts +0 -168
  95. package/dist/types/src/controllers/collection_registry.d.ts +0 -46
  96. package/dist/types/src/controllers/customization_controller.d.ts +0 -60
  97. package/dist/types/src/controllers/data.d.ts +0 -207
  98. package/dist/types/src/controllers/data_driver.d.ts +0 -218
  99. package/dist/types/src/controllers/database_admin.d.ts +0 -11
  100. package/dist/types/src/controllers/dialogs_controller.d.ts +0 -36
  101. package/dist/types/src/controllers/effective_role.d.ts +0 -4
  102. package/dist/types/src/controllers/email.d.ts +0 -36
  103. package/dist/types/src/controllers/index.d.ts +0 -18
  104. package/dist/types/src/controllers/local_config_persistence.d.ts +0 -20
  105. package/dist/types/src/controllers/navigation.d.ts +0 -225
  106. package/dist/types/src/controllers/registry.d.ts +0 -63
  107. package/dist/types/src/controllers/side_dialogs_controller.d.ts +0 -67
  108. package/dist/types/src/controllers/side_entity_controller.d.ts +0 -97
  109. package/dist/types/src/controllers/snackbar.d.ts +0 -24
  110. package/dist/types/src/controllers/storage.d.ts +0 -171
  111. package/dist/types/src/index.d.ts +0 -4
  112. package/dist/types/src/rebase_context.d.ts +0 -122
  113. package/dist/types/src/types/auth_adapter.d.ts +0 -301
  114. package/dist/types/src/types/backend.d.ts +0 -536
  115. package/dist/types/src/types/backend_hooks.d.ts +0 -172
  116. package/dist/types/src/types/builders.d.ts +0 -15
  117. package/dist/types/src/types/chips.d.ts +0 -5
  118. package/dist/types/src/types/collections.d.ts +0 -941
  119. package/dist/types/src/types/component_ref.d.ts +0 -47
  120. package/dist/types/src/types/cron.d.ts +0 -102
  121. package/dist/types/src/types/data_source.d.ts +0 -64
  122. package/dist/types/src/types/database_adapter.d.ts +0 -94
  123. package/dist/types/src/types/entities.d.ts +0 -145
  124. package/dist/types/src/types/entity_actions.d.ts +0 -104
  125. package/dist/types/src/types/entity_callbacks.d.ts +0 -173
  126. package/dist/types/src/types/entity_link_builder.d.ts +0 -7
  127. package/dist/types/src/types/entity_overrides.d.ts +0 -10
  128. package/dist/types/src/types/entity_views.d.ts +0 -87
  129. package/dist/types/src/types/export_import.d.ts +0 -21
  130. package/dist/types/src/types/formex.d.ts +0 -40
  131. package/dist/types/src/types/index.d.ts +0 -28
  132. package/dist/types/src/types/locales.d.ts +0 -4
  133. package/dist/types/src/types/modify_collections.d.ts +0 -5
  134. package/dist/types/src/types/plugins.d.ts +0 -282
  135. package/dist/types/src/types/properties.d.ts +0 -1181
  136. package/dist/types/src/types/property_config.d.ts +0 -74
  137. package/dist/types/src/types/relations.d.ts +0 -336
  138. package/dist/types/src/types/slots.d.ts +0 -262
  139. package/dist/types/src/types/translations.d.ts +0 -900
  140. package/dist/types/src/types/user_management_delegate.d.ts +0 -86
  141. package/dist/types/src/types/websockets.d.ts +0 -78
  142. package/dist/types/src/users/index.d.ts +0 -1
  143. package/dist/types/src/users/user.d.ts +0 -50
  144. package/drizzle.test.config.ts +0 -10
  145. /package/dist/{server-postgresql/src/auth → auth}/ensure-tables.d.ts +0 -0
  146. /package/dist/{server-postgresql/src/cli.d.ts → cli.d.ts} +0 -0
  147. /package/dist/{server-postgresql/src/connection.d.ts → connection.d.ts} +0 -0
  148. /package/dist/{server-postgresql/src/history → history}/HistoryService.d.ts +0 -0
  149. /package/dist/{server-postgresql/src/history → history}/ensure-history-table.d.ts +0 -0
  150. /package/dist/{server-postgresql/src/index.d.ts → index.d.ts} +0 -0
  151. /package/dist/{server-postgresql/src/interfaces.d.ts → interfaces.d.ts} +0 -0
  152. /package/dist/{server-postgresql/src/schema → schema}/auth-schema.d.ts +0 -0
  153. /package/dist/{server-postgresql/src/schema → schema}/doctor-cli.d.ts +0 -0
  154. /package/dist/{server-postgresql/src/schema → schema}/doctor.d.ts +0 -0
  155. /package/dist/{server-postgresql/src/schema → schema}/generate-drizzle-schema-logic.d.ts +0 -0
  156. /package/dist/{server-postgresql/src/schema → schema}/generate-drizzle-schema.d.ts +0 -0
  157. /package/dist/{server-postgresql/src/schema → schema}/introspect-db-inference.d.ts +0 -0
  158. /package/dist/{server-postgresql/src/schema → schema}/introspect-db-logic.d.ts +0 -0
  159. /package/dist/{server-postgresql/src/schema → schema}/introspect-db.d.ts +0 -0
  160. /package/dist/{server-postgresql/src/schema → schema}/test-schema.d.ts +0 -0
  161. /package/dist/{server-postgresql/src/services → services}/BranchService.d.ts +0 -0
  162. /package/dist/{server-postgresql/src/services → services}/EntityFetchService.d.ts +0 -0
  163. /package/dist/{server-postgresql/src/services → services}/RelationService.d.ts +0 -0
  164. /package/dist/{server-postgresql/src/services → services}/entity-helpers.d.ts +0 -0
  165. /package/dist/{server-postgresql/src/services → services}/entityService.d.ts +0 -0
  166. /package/dist/{server-postgresql/src/services → services}/index.d.ts +0 -0
  167. /package/dist/{server-postgresql/src/services → services}/realtimeService.d.ts +0 -0
  168. /package/dist/{server-postgresql/src/utils → utils}/drizzle-conditions.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
- console.warn(`Filtering by field '${field}', but it does not exist in table for collection '${collectionPath}'`);
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
- console.warn(`Filtering by field '${cond.column}', but it does not exist in table for collection '${collectionPath}'`);
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
- case "array-contains-any":
136
- // For JSONB arrays: checks if the column contains any of the given values
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
- // Use the ?| operator for JSONB overlap with text array
139
- const textValues = value.map(v => String(v));
140
- return sql`${column} ?| array[${sql.join(textValues.map(v => sql`${v}`), sql`, `)}]`;
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
- console.warn(`Unsupported filter operation: ${op}`);
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
- console.error("🔍 [buildRelationConditions] Failed to find junction table info and no foreign key specified");
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
- console.error(`🔍 [findCorrespondingJunctionTable] Error finding corresponding junction table for relation '${relation.relationName}':`, error);
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,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
@@ -1,13 +1,19 @@
1
1
  import { RealtimeService } from "./services/realtimeService";
2
2
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
3
- import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin, AuthAdapter } from "@rebasepro/types";
3
+ import type { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, AuthAdapter } from "@rebasepro/types";
4
+ import { isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
5
+ import type { User } from "@rebasepro/types";
4
6
  import { WebSocketServer, WebSocket } from "ws";
5
7
  import { Server } from "http";
6
8
  import { inspect } from "util";
7
- // @ts-ignore
8
9
  import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core";
9
- // @ts-ignore
10
- import { AuthConfig } from "@rebasepro/server-core";
10
+ import { logger } from "@rebasepro/server-core";
11
+
12
+ /** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
13
+ interface WsAuthConfig {
14
+ requireAuth?: boolean;
15
+ jwtSecret?: string;
16
+ }
11
17
 
12
18
  /**
13
19
  * Normalized user identity for WebSocket sessions.
@@ -27,7 +33,6 @@ interface ClientSession {
27
33
  messageWindowStart: number;
28
34
  }
29
35
 
30
- const clientSessions = new Map<string, ClientSession>();
31
36
 
32
37
  /** Maximum messages per client per window */
33
38
  const WS_RATE_LIMIT = 2000;
@@ -52,14 +57,14 @@ const ADMIN_ONLY_TYPES = new Set([
52
57
  */
53
58
  function extractErrorMessage(error: unknown): string {
54
59
  if (!error) return "Unknown error";
55
- if (typeof error === "object") {
56
- const err = error as Record<string, unknown> & { cause?: unknown; message?: string };
57
- if (err.cause) {
58
- return extractErrorMessage(err.cause);
59
- }
60
- if (typeof err.message === "string") {
61
- return err.message;
60
+ if (error instanceof Error) {
61
+ if ("cause" in error && error.cause) {
62
+ return extractErrorMessage(error.cause);
62
63
  }
64
+ return error.message;
65
+ }
66
+ if (typeof error === "object" && "message" in error && typeof (error as { message: unknown }).message === "string") {
67
+ return (error as { message: string }).message;
63
68
  }
64
69
  return String(error);
65
70
  }
@@ -72,21 +77,20 @@ function isAdminSession(session: ClientSession | undefined): boolean {
72
77
  // Fast path: new adapter-aware sessions set isAdmin directly
73
78
  if (session.user.isAdmin) return true;
74
79
  if (!session.user.roles) return false;
75
- return session.user.roles.some((r: unknown) => {
76
- if (typeof r === "string") return r === "admin";
77
- if (r && typeof r === "object" && "isAdmin" in r) return (r as { isAdmin: boolean }).isAdmin;
78
- if (r && typeof r === "object" && "id" in r) return (r as { id: string }).id === "admin";
79
- return false;
80
- });
80
+ return session.user.roles.some((r) => r === "admin");
81
81
  }
82
82
 
83
83
  export function createPostgresWebSocket(
84
84
  server: Server,
85
85
  realtimeService: RealtimeService,
86
86
  driver: PostgresBackendDriver,
87
- authConfig?: AuthConfig,
87
+ authConfig?: WsAuthConfig,
88
88
  authAdapter?: AuthAdapter
89
89
  ) {
90
+ // Session map scoped to this factory invocation — prevents stale sessions
91
+ // leaking across hot reloads or multiple factory calls.
92
+ const clientSessions = new Map<string, ClientSession>();
93
+
90
94
  const isProduction = process.env.NODE_ENV === "production";
91
95
  /** Debug logger that is suppressed in production to prevent PII/data leaks */
92
96
  const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };
@@ -101,7 +105,7 @@ export function createPostgresWebSocket(
101
105
  // Silently absorbed — listenWithPortRetry will retry the next port
102
106
  return;
103
107
  }
104
- console.error("❌ [WebSocket Server] Error:", err);
108
+ logger.error("❌ [WebSocket Server] Error", { error: err });
105
109
  });
106
110
 
107
111
  // Auth is required when either: an adapter is present (secure by default),
@@ -166,14 +170,14 @@ code } }
166
170
  const adapterUser = authAdapter.verifyToken
167
171
  ? await authAdapter.verifyToken(token)
168
172
  : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
169
- headers: { Authorization: `Bearer ${token}` },
173
+ headers: { Authorization: `Bearer ${token}` }
170
174
  }));
171
175
 
172
176
  if (adapterUser) {
173
177
  verifiedUser = {
174
178
  userId: adapterUser.uid,
175
179
  roles: adapterUser.roles,
176
- isAdmin: adapterUser.isAdmin,
180
+ isAdmin: adapterUser.isAdmin
177
181
  };
178
182
  }
179
183
  } catch {
@@ -186,7 +190,7 @@ code } }
186
190
  verifiedUser = {
187
191
  userId: jwtPayload.userId,
188
192
  roles: jwtPayload.roles ?? [],
189
- isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin"),
193
+ isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
190
194
  };
191
195
  }
192
196
  }
@@ -201,7 +205,8 @@ code } }
201
205
  ws.send(JSON.stringify({
202
206
  type: "AUTH_SUCCESS",
203
207
  requestId,
204
- payload: { userId: verifiedUser.userId, roles: verifiedUser.roles }
208
+ payload: { userId: verifiedUser.userId,
209
+ roles: verifiedUser.roles }
205
210
  }));
206
211
  wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
207
212
  } else {
@@ -249,20 +254,31 @@ code } }
249
254
  // Helper to get correctly scoped delegate for the current request
250
255
  const getScopedDelegate = async (): Promise<DataDriver> => {
251
256
  const session = clientSessions.get(clientId);
252
- if ("withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
257
+ // Check if the driver supports RLS-scoped delegates
258
+ if (typeof driver.withAuth === "function") {
253
259
  try {
254
- const userForAuth = session?.user
260
+ const userForAuth: User = session?.user
255
261
  ? {
256
262
  uid: session.user.userId,
263
+ displayName: null,
264
+ email: null,
265
+ photoURL: null,
266
+ providerId: "websocket",
267
+ isAnonymous: false,
257
268
  roles: session.user.roles ?? []
258
269
  }
259
270
  : {
260
271
  uid: "anon",
272
+ displayName: null,
273
+ email: null,
274
+ photoURL: null,
275
+ providerId: "websocket",
276
+ isAnonymous: true,
261
277
  roles: ["anon"]
262
278
  };
263
- return await (driver as unknown as { withAuth: (user: Record<string, unknown>) => Promise<DataDriver> }).withAuth(userForAuth);
279
+ return await driver.withAuth(userForAuth);
264
280
  } catch (e) {
265
- console.error("Failed to create RLS scoped delegate for WS request", e);
281
+ logger.error("Failed to create RLS scoped delegate for WS request", { error: e });
266
282
  throw new Error("Internal authentication error");
267
283
  }
268
284
  }
@@ -561,8 +577,10 @@ colors: true }));
561
577
  // Attach auth context from the WS session so RLS-aware refetches work
562
578
  const session = clientSessions.get(clientId);
563
579
  const authContext = session?.user
564
- ? { userId: session.user.userId, roles: session.user.roles ?? [] }
565
- : { userId: "anon", roles: ["anon"] };
580
+ ? { userId: session.user.userId,
581
+ roles: session.user.roles ?? [] }
582
+ : { userId: "anon",
583
+ roles: ["anon"] };
566
584
  // Let RealtimeService handle these messages
567
585
  await realtimeService.handleClientMessage(clientId, {
568
586
  type,
@@ -573,12 +591,12 @@ colors: true }));
573
591
  }
574
592
 
575
593
  default:
576
- console.error("❌ [WebSocket Server] Unknown message type:", type);
594
+ logger.error("❌ [WebSocket Server] Unknown message type", { detail: type });
577
595
  }
578
596
  } catch (error: unknown) {
579
- console.error("💥 [WebSocket Server] Error handling message:", error);
597
+ logger.error("💥 [WebSocket Server] Error handling message", { error: error });
580
598
  if (error instanceof Error) {
581
- console.error("Stack trace:", error.stack);
599
+ logger.error("Stack trace", { detail: error.stack });
582
600
  }
583
601
  const errorMessage = process.env.NODE_ENV === "production"
584
602
  ? "An unexpected error occurred"
@@ -8,7 +8,9 @@ jest.mock("drizzle-orm", () => {
8
8
  const actual = jest.requireActual("drizzle-orm");
9
9
  return {
10
10
  ...actual,
11
- eq: jest.fn((field, value) => ({ field, value, type: "eq" })),
11
+ eq: jest.fn((field, value) => ({ field,
12
+ value,
13
+ type: "eq" })),
12
14
  sql: Object.assign(
13
15
  jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
14
16
  strings,
@@ -16,8 +18,11 @@ jest.mock("drizzle-orm", () => {
16
18
  type: "sql"
17
19
  })),
18
20
  {
19
- raw: jest.fn((val: string) => ({ val, type: "sql-raw" })),
20
- join: jest.fn((parts: unknown[], separator: unknown) => ({ parts, separator, type: "sql-join" }))
21
+ raw: jest.fn((val: string) => ({ val,
22
+ type: "sql-raw" })),
23
+ join: jest.fn((parts: unknown[], separator: unknown) => ({ parts,
24
+ separator,
25
+ type: "sql-join" }))
21
26
  }
22
27
  ),
23
28
  relations: jest.fn(() => ({}))
@@ -133,11 +138,11 @@ describe("Auth Services", () => {
133
138
  email: "test@example.com",
134
139
  displayName: "Test User"
135
140
  };
136
- const dbReturnedUser = {
141
+ const dbReturnedUser = {
137
142
  id: "user-123",
138
143
  ...newUser,
139
144
  createdAt: new Date(),
140
- updatedAt: new Date()
145
+ updatedAt: new Date()
141
146
  };
142
147
  mockInsertReturning.mockResolvedValueOnce([dbReturnedUser]);
143
148
 
@@ -154,7 +159,8 @@ describe("Auth Services", () => {
154
159
 
155
160
  describe("getUserById", () => {
156
161
  it("should return user when found", async () => {
157
- const mockUser = { id: "user-123", email: "test@example.com" };
162
+ const mockUser = { id: "user-123",
163
+ email: "test@example.com" };
158
164
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
159
165
 
160
166
  const result = await userService.getUserById("user-123");
@@ -174,7 +180,8 @@ describe("Auth Services", () => {
174
180
 
175
181
  describe("getUserByEmail", () => {
176
182
  it("should return user when found by email", async () => {
177
- const mockUser = { id: "user-123", email: "test@example.com" };
183
+ const mockUser = { id: "user-123",
184
+ email: "test@example.com" };
178
185
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
179
186
 
180
187
  const result = await userService.getUserByEmail("test@example.com");
@@ -194,13 +201,15 @@ describe("Auth Services", () => {
194
201
 
195
202
  describe("getUserByIdentity", () => {
196
203
  it("should fetch user by identity", async () => {
197
- const mockUser = { id: "user-123", email: "test@example.com" };
204
+ const mockUser = { id: "user-123",
205
+ email: "test@example.com" };
198
206
  mockSelectWhere.mockResolvedValueOnce([{ user: mockUser }]);
199
207
 
200
208
  const result = await userService.getUserByIdentity("google", "google-abc");
201
209
 
202
210
  expect(db.select).toHaveBeenCalled();
203
- expect(result).toEqual(expect.objectContaining({ id: "user-123", email: "test@example.com" }));
211
+ expect(result).toEqual(expect.objectContaining({ id: "user-123",
212
+ email: "test@example.com" }));
204
213
  });
205
214
  });
206
215
 
@@ -223,10 +232,10 @@ describe("Auth Services", () => {
223
232
 
224
233
  describe("updateUser", () => {
225
234
  it("should update user and return updated record", async () => {
226
- const updatedUser = {
235
+ const updatedUser = {
227
236
  id: "user-123",
228
237
  email: "test@example.com",
229
- displayName: "Updated Name"
238
+ displayName: "Updated Name"
230
239
  };
231
240
  mockUpdateReturning.mockResolvedValueOnce([updatedUser]);
232
241
 
@@ -261,8 +270,10 @@ describe("Auth Services", () => {
261
270
  describe("listUsers", () => {
262
271
  it("should return all users", async () => {
263
272
  const mockUsers = [
264
- { id: "user-1", email: "user1@example.com" },
265
- { id: "user-2", email: "user2@example.com" }
273
+ { id: "user-1",
274
+ email: "user1@example.com" },
275
+ { id: "user-2",
276
+ email: "user2@example.com" }
266
277
  ];
267
278
  mockSelectFrom.mockReturnValueOnce(Promise.resolve(mockUsers));
268
279
 
@@ -270,8 +281,10 @@ describe("Auth Services", () => {
270
281
 
271
282
  expect(db.select).toHaveBeenCalled();
272
283
  expect(result).toEqual([
273
- mockUserData({ id: "user-1", email: "user1@example.com" }),
274
- mockUserData({ id: "user-2", email: "user2@example.com" })
284
+ mockUserData({ id: "user-1",
285
+ email: "user1@example.com" }),
286
+ mockUserData({ id: "user-2",
287
+ email: "user2@example.com" })
275
288
  ]);
276
289
  });
277
290
  });
@@ -332,7 +345,8 @@ describe("Auth Services", () => {
332
345
 
333
346
  describe("getUserByVerificationToken", () => {
334
347
  it("should find user by verification token", async () => {
335
- const mockUser = { id: "user-123", email: "test@example.com" };
348
+ const mockUser = { id: "user-123",
349
+ email: "test@example.com" };
336
350
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
337
351
 
338
352
  const result = await userService.getUserByVerificationToken("token-abc");
@@ -396,7 +410,8 @@ describe("Auth Services", () => {
396
410
 
397
411
  describe("getUserWithRoles", () => {
398
412
  it("should return user with roles", async () => {
399
- const mockUser = { id: "user-123", email: "test@example.com" };
413
+ const mockUser = { id: "user-123",
414
+ email: "test@example.com" };
400
415
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
401
416
  mockExecute.mockResolvedValueOnce({
402
417
  rows: [{ roles: ["admin"] }]
@@ -427,7 +442,8 @@ describe("Auth Services", () => {
427
442
  it("should return paginated and filtered users list", async () => {
428
443
  mockExecute
429
444
  .mockResolvedValueOnce({ rows: [{ total: 1 }] })
430
- .mockResolvedValueOnce({ rows: [{ id: "user-123", email: "test@example.com" }] });
445
+ .mockResolvedValueOnce({ rows: [{ id: "user-123",
446
+ email: "test@example.com" }] });
431
447
 
432
448
  const result = await userService.listUsersPaginated({
433
449
  limit: 10,
@@ -439,7 +455,8 @@ describe("Auth Services", () => {
439
455
 
440
456
  expect(mockExecute).toHaveBeenCalledTimes(2);
441
457
  expect(result).toEqual({
442
- users: [mockUserData({ id: "user-123", email: "test@example.com" })],
458
+ users: [mockUserData({ id: "user-123",
459
+ email: "test@example.com" })],
443
460
  total: 1,
444
461
  limit: 10,
445
462
  offset: 0