@rebasepro/server-postgresql 0.4.0 → 0.5.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.
@@ -272,41 +272,19 @@ export function serializePropertyToServer(value: unknown, property: Property): u
272
272
  return value;
273
273
  }
274
274
 
275
- case "binary":
276
- if (typeof value === "string") {
277
- if (value.startsWith("data:application/octet-stream;base64,")) {
278
- const base64Data = value.split(",")[1];
279
- if (base64Data) {
280
- return Buffer.from(base64Data, "base64");
281
- }
282
- }
283
- }
284
- if (Buffer.isBuffer(value)) {
285
- return value;
286
- }
275
+ case "binary": {
276
+ const decoded = tryDecodeBase64DataUrl(value);
277
+ if (decoded) return decoded;
278
+ if (Buffer.isBuffer(value)) return value;
287
279
  return value;
280
+ }
288
281
 
289
282
  case "string":
290
- if (typeof value === "string") {
291
- if (value.startsWith("data:application/octet-stream;base64,")) {
292
- const base64Data = value.split(",")[1];
293
- if (base64Data) {
294
- return Buffer.from(base64Data, "base64");
295
- }
296
- }
297
- }
298
- return value;
299
-
300
- default:
301
- if (typeof value === "string") {
302
- if (value.startsWith("data:application/octet-stream;base64,")) {
303
- const base64Data = value.split(",")[1];
304
- if (base64Data) {
305
- return Buffer.from(base64Data, "base64");
306
- }
307
- }
308
- }
283
+ default: {
284
+ const decoded = tryDecodeBase64DataUrl(value);
285
+ if (decoded) return decoded;
309
286
  return value;
287
+ }
310
288
  }
311
289
  }
312
290
 
@@ -486,24 +464,53 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
486
464
  }
487
465
 
488
466
  /**
489
- * Parse a single property value from database format to frontend format
467
+ * Try to decode a `data:application/octet-stream;base64,...` data URL string
468
+ * into a Buffer. Returns null if the value is not a matching data URL.
490
469
  */
491
- export function parsePropertyFromServer(value: unknown, property: Property, collection: EntityCollection, propertyKey?: string): unknown {
492
- if (value === null || value === undefined) {
493
- return value;
470
+ function tryDecodeBase64DataUrl(value: unknown): Buffer | null {
471
+ if (typeof value !== "string") return null;
472
+ if (!value.startsWith("data:application/octet-stream;base64,")) return null;
473
+ const base64Data = value.split(",")[1];
474
+ return base64Data ? Buffer.from(base64Data, "base64") : null;
475
+ }
476
+
477
+ /**
478
+ * Try to resolve an unknown value into a Buffer.
479
+ * Handles native Buffers and `{ type: "Buffer", data: number[] }` objects (from JSON deserialization).
480
+ * Returns null if the value is not a buffer.
481
+ */
482
+ function tryResolveBuffer(value: unknown): Buffer | null {
483
+ if (Buffer.isBuffer(value)) return value;
484
+ if (typeof value === "object" && value !== null) {
485
+ const rawVal = value as Record<string, unknown>;
486
+ if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
487
+ return Buffer.from(rawVal.data as number[]);
488
+ }
494
489
  }
490
+ return null;
491
+ }
492
+
493
+ /**
494
+ * Convert a Buffer to a UTF-8 string if all bytes are printable ASCII,
495
+ * otherwise return a base64 data URL.
496
+ */
497
+ function bufferToStringOrBase64(buf: Buffer): string {
498
+ for (let i = 0; i < buf.length; i++) {
499
+ const b = buf[i];
500
+ // Allow standard printable ASCII + common whitespace (\r, \n, \t)
501
+ if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
502
+ return `data:application/octet-stream;base64,${buf.toString("base64")}`;
503
+ }
504
+ }
505
+ return buf.toString("utf8");
506
+ }
507
+
508
+ export function parsePropertyFromServer(value: unknown, property: Property, collection: EntityCollection, propertyKey?: string): unknown {
509
+ if (value === null || value === undefined) return value;
495
510
 
496
511
  switch (property.type) {
497
512
  case "binary": {
498
- let buf: Buffer | null = null;
499
- if (Buffer.isBuffer(value)) {
500
- buf = value;
501
- } else if (typeof value === "object" && value !== null) {
502
- const rawVal = value as Record<string, unknown>;
503
- if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
504
- buf = Buffer.from(rawVal.data as number[]);
505
- }
506
- }
513
+ const buf = tryResolveBuffer(value);
507
514
  if (buf) {
508
515
  return `data:application/octet-stream;base64,${buf.toString("base64")}`;
509
516
  }
@@ -514,32 +521,9 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
514
521
  if (typeof value === "string") return value;
515
522
 
516
523
  // Handle Buffer objects (e.g. from PostgreSQL bytea columns)
517
- let isBuffer = false;
518
- let buf: Buffer | null = null;
519
-
520
- if (Buffer.isBuffer(value)) {
521
- isBuffer = true;
522
- buf = value;
523
- } else if (typeof value === "object" && value !== null) {
524
- const rawVal = value as Record<string, unknown>;
525
- if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
526
- isBuffer = true;
527
- buf = Buffer.from(rawVal.data as number[]);
528
- }
529
- }
530
-
531
- if (isBuffer && buf) {
532
- // Heuristic: if all bytes are printable ASCII, return utf8, else base64
533
- let isPrintable = true;
534
- for (let i = 0; i < buf.length; i++) {
535
- const b = buf[i];
536
- // Allow standard printable ASCII + common whitespace (\r, \n, \t)
537
- if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
538
- isPrintable = false;
539
- break;
540
- }
541
- }
542
- return isPrintable ? buf.toString("utf8") : `data:application/octet-stream;base64,${buf.toString("base64")}`;
524
+ const buf = tryResolveBuffer(value);
525
+ if (buf) {
526
+ return bufferToStringOrBase64(buf);
543
527
  }
544
528
 
545
529
  if (typeof value === "object" && value !== null) {
@@ -665,32 +649,10 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
665
649
 
666
650
  default: {
667
651
  // Fallback for buffers in case they are mapped to something other than string
668
- let isBuffer = false;
669
- let buf: Buffer | null = null;
670
-
671
- if (Buffer.isBuffer(value)) {
672
- isBuffer = true;
673
- buf = value;
674
- } else if (typeof value === "object" && value !== null) {
675
- const rawVal = value as Record<string, unknown>;
676
- if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) {
677
- isBuffer = true;
678
- buf = Buffer.from(rawVal.data as number[]);
679
- }
680
- }
681
-
682
- if (isBuffer && buf) {
683
- let isPrintable = true;
684
- for (let i = 0; i < buf.length; i++) {
685
- const b = buf[i];
686
- if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) {
687
- isPrintable = false;
688
- break;
689
- }
690
- }
691
- return isPrintable ? buf.toString("utf8") : `data:application/octet-stream;base64,${buf.toString("base64")}`;
652
+ const buf = tryResolveBuffer(value);
653
+ if (buf) {
654
+ return bufferToStringOrBase64(buf);
692
655
  }
693
-
694
656
  return value;
695
657
  }
696
658
  }
@@ -18,7 +18,7 @@ export class DatabasePoolManager {
18
18
  }
19
19
  }
20
20
 
21
- public getDrizzle(databaseName: string): NodePgDatabase<any> {
21
+ public getDrizzle(databaseName: string): NodePgDatabase<Record<string, never>> {
22
22
  const existing = this.drizzleInstances.get(databaseName);
23
23
  if (existing) {
24
24
  return existing;
@@ -81,5 +81,6 @@ export class DatabasePoolManager {
81
81
  }
82
82
  await Promise.all(promises);
83
83
  this.pools.clear();
84
+ this.drizzleInstances.clear();
84
85
  }
85
86
  }
@@ -17,6 +17,18 @@ import { EntityFetchService } from "./EntityFetchService";
17
17
  import { DrizzleClient } from "../interfaces";
18
18
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
19
19
 
20
+ /** Shape of PostgreSQL errors with diagnostic metadata. */
21
+ interface PostgresError extends Error {
22
+ code?: string;
23
+ detail?: string;
24
+ hint?: string;
25
+ constraint?: string;
26
+ column?: string;
27
+ table?: string;
28
+ dataType?: string;
29
+ cause?: unknown;
30
+ }
31
+
20
32
  /**
21
33
  * Service for handling all entity write operations.
22
34
  * Handles saving, deleting, and updating entities.
@@ -391,14 +403,14 @@ export class EntityPersistService {
391
403
  */
392
404
  private extractCauseMessage(error: unknown): string | null {
393
405
  if (!error || typeof error !== "object") return null;
394
- const err = error as Error & { cause?: unknown };
406
+ if (!(error instanceof Error)) return null;
395
407
 
396
- if (err.cause && typeof err.cause === "object") {
397
- const deeper = this.extractCauseMessage(err.cause);
408
+ if (error.cause && typeof error.cause === "object") {
409
+ const deeper = this.extractCauseMessage(error.cause);
398
410
  if (deeper) return deeper;
399
411
  // The cause itself has a message
400
- if (err.cause instanceof Error && err.cause.message) {
401
- return err.cause.message;
412
+ if (error.cause instanceof Error && error.cause.message) {
413
+ return error.cause.message;
402
414
  }
403
415
  }
404
416
  return null;
@@ -420,19 +432,24 @@ export class EntityPersistService {
420
432
  * Extract the underlying PostgreSQL error from a Drizzle wrapper.
421
433
  * Drizzle wraps PG errors in a `cause` property.
422
434
  */
423
- private extractPgError(error: unknown): (Error & { code?: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown }) | null {
435
+ private extractPgError(error: unknown): PostgresError | null {
424
436
  if (!error || typeof error !== "object") return null;
425
-
426
- const err = error as Error & { code?: string; cause?: unknown; detail?: unknown };
437
+ if (!(error instanceof Error)) {
438
+ // Check non-Error objects for a cause chain (Drizzle sometimes wraps oddly)
439
+ if ("cause" in error && (error as Record<string, unknown>).cause && typeof (error as Record<string, unknown>).cause === "object") {
440
+ return this.extractPgError((error as Record<string, unknown>).cause);
441
+ }
442
+ return null;
443
+ }
427
444
 
428
445
  // Check if the error itself has a PG error code
429
- if (err.code && /^[0-9A-Z]{5}$/.test(err.code)) {
430
- return err as Error & { code: string; detail?: unknown; hint?: unknown; constraint?: unknown; column?: unknown; table?: unknown; dataType?: unknown };
446
+ if ("code" in error && typeof (error as PostgresError).code === "string" && /^[0-9A-Z]{5}$/.test((error as PostgresError).code!)) {
447
+ return error as PostgresError;
431
448
  }
432
449
 
433
450
  // Check the cause chain (Drizzle wraps PG errors)
434
- if (err.cause && typeof err.cause === "object") {
435
- return this.extractPgError(err.cause);
451
+ if (error.cause && typeof error.cause === "object") {
452
+ return this.extractPgError(error.cause);
436
453
  }
437
454
 
438
455
  return null;
package/src/types.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
+
3
+ /** Drizzle PgTable with column access by name. Runtime Drizzle tables satisfy this shape. */
4
+ export type RebasePgTable = PgTable & Record<string, AnyPgColumn>;
package/src/websocket.ts CHANGED
@@ -1,13 +1,18 @@
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
+
11
+ /** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
12
+ interface WsAuthConfig {
13
+ requireAuth?: boolean;
14
+ jwtSecret?: string;
15
+ }
11
16
 
12
17
  /**
13
18
  * Normalized user identity for WebSocket sessions.
@@ -27,7 +32,7 @@ interface ClientSession {
27
32
  messageWindowStart: number;
28
33
  }
29
34
 
30
- const clientSessions = new Map<string, ClientSession>();
35
+
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); };
@@ -249,18 +253,29 @@ code } }
249
253
  // Helper to get correctly scoped delegate for the current request
250
254
  const getScopedDelegate = async (): Promise<DataDriver> => {
251
255
  const session = clientSessions.get(clientId);
252
- if ("withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
256
+ // Check if the driver supports RLS-scoped delegates
257
+ if (typeof driver.withAuth === "function") {
253
258
  try {
254
- const userForAuth = session?.user
259
+ const userForAuth: User = session?.user
255
260
  ? {
256
261
  uid: session.user.userId,
262
+ displayName: null,
263
+ email: null,
264
+ photoURL: null,
265
+ providerId: "websocket",
266
+ isAnonymous: false,
257
267
  roles: session.user.roles ?? []
258
268
  }
259
269
  : {
260
270
  uid: "anon",
271
+ displayName: null,
272
+ email: null,
273
+ photoURL: null,
274
+ providerId: "websocket",
275
+ isAnonymous: true,
261
276
  roles: ["anon"]
262
277
  };
263
- return await (driver as unknown as { withAuth: (user: Record<string, unknown>) => Promise<DataDriver> }).withAuth(userForAuth);
278
+ return await driver.withAuth(userForAuth);
264
279
  } catch (e) {
265
280
  console.error("Failed to create RLS scoped delegate for WS request", e);
266
281
  throw new Error("Internal authentication error");
@@ -1,10 +0,0 @@
1
- import { defineConfig } from "drizzle-kit";
2
-
3
- export default defineConfig({
4
- schema: "./test-schema-no-policies.ts",
5
- out: "./drizzle-test-out",
6
- dialect: "postgresql",
7
- dbCredentials: {
8
- url: process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/postgres"
9
- }
10
- });