@rebasepro/server-postgresql 0.1.2 → 0.2.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.
Files changed (68) hide show
  1. package/LICENSE +22 -6
  2. package/dist/common/src/util/entities.d.ts +2 -2
  3. package/dist/common/src/util/relations.d.ts +1 -1
  4. package/dist/index.es.js +1160 -612
  5. package/dist/index.es.js.map +1 -1
  6. package/dist/index.umd.js +1158 -610
  7. package/dist/index.umd.js.map +1 -1
  8. package/dist/server-postgresql/src/PostgresAdapter.d.ts +6 -0
  9. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
  10. package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +0 -5
  11. package/dist/server-postgresql/src/auth/ensure-tables.d.ts +2 -1
  12. package/dist/server-postgresql/src/auth/services.d.ts +37 -15
  13. package/dist/server-postgresql/src/index.d.ts +1 -0
  14. package/dist/server-postgresql/src/schema/auth-schema.d.ts +43 -856
  15. package/dist/server-postgresql/src/schema/default-collections.d.ts +2 -0
  16. package/dist/server-postgresql/src/schema/doctor.d.ts +10 -1
  17. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +1 -0
  18. package/dist/server-postgresql/src/services/entity-helpers.d.ts +1 -1
  19. package/dist/server-postgresql/src/services/realtimeService.d.ts +12 -0
  20. package/dist/server-postgresql/src/websocket.d.ts +2 -1
  21. package/dist/types/src/controllers/auth.d.ts +9 -8
  22. package/dist/types/src/controllers/client.d.ts +3 -0
  23. package/dist/types/src/types/auth_adapter.d.ts +356 -0
  24. package/dist/types/src/types/collections.d.ts +67 -2
  25. package/dist/types/src/types/database_adapter.d.ts +94 -0
  26. package/dist/types/src/types/entity_actions.d.ts +7 -1
  27. package/dist/types/src/types/entity_callbacks.d.ts +1 -1
  28. package/dist/types/src/types/entity_views.d.ts +36 -1
  29. package/dist/types/src/types/index.d.ts +2 -0
  30. package/dist/types/src/types/plugins.d.ts +1 -1
  31. package/dist/types/src/types/properties.d.ts +24 -5
  32. package/dist/types/src/types/property_config.d.ts +6 -2
  33. package/dist/types/src/types/relations.d.ts +1 -1
  34. package/dist/types/src/types/translations.d.ts +8 -0
  35. package/dist/types/src/users/user.d.ts +5 -0
  36. package/package.json +21 -15
  37. package/src/PostgresAdapter.ts +59 -0
  38. package/src/PostgresBackendDriver.ts +57 -8
  39. package/src/PostgresBootstrapper.ts +35 -15
  40. package/src/auth/ensure-tables.ts +82 -189
  41. package/src/auth/services.ts +421 -170
  42. package/src/cli.ts +44 -13
  43. package/src/data-transformer.ts +78 -8
  44. package/src/history/HistoryService.ts +25 -2
  45. package/src/index.ts +1 -0
  46. package/src/schema/auth-schema.ts +130 -98
  47. package/src/schema/default-collections.ts +68 -0
  48. package/src/schema/doctor-cli.ts +5 -1
  49. package/src/schema/doctor.ts +85 -8
  50. package/src/schema/generate-drizzle-schema-logic.ts +74 -27
  51. package/src/schema/generate-drizzle-schema.ts +13 -3
  52. package/src/schema/introspect-db-inference.ts +5 -5
  53. package/src/schema/introspect-db-logic.ts +9 -2
  54. package/src/schema/introspect-db.ts +14 -3
  55. package/src/services/EntityFetchService.ts +5 -5
  56. package/src/services/RelationService.ts +2 -2
  57. package/src/services/entity-helpers.ts +1 -1
  58. package/src/services/realtimeService.ts +145 -136
  59. package/src/utils/drizzle-conditions.ts +16 -2
  60. package/src/websocket.ts +113 -37
  61. package/test/auth-services.test.ts +163 -74
  62. package/test/data-transformer-hardening.test.ts +57 -0
  63. package/test/data-transformer.test.ts +43 -0
  64. package/test/generate-drizzle-schema.test.ts +7 -5
  65. package/test/introspect-db-utils.test.ts +4 -1
  66. package/test/postgresDataDriver.test.ts +17 -0
  67. package/test/realtimeService.test.ts +7 -7
  68. package/test/websocket.test.ts +139 -0
package/src/websocket.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { RealtimeService } from "./services/realtimeService";
2
2
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
3
- import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
3
+ import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin, AuthAdapter } from "@rebasepro/types";
4
4
  import { WebSocketServer, WebSocket } from "ws";
5
5
  import { Server } from "http";
6
6
  import { inspect } from "util";
@@ -9,9 +9,18 @@ import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core
9
9
  // @ts-ignore
10
10
  import { AuthConfig } from "@rebasepro/server-core";
11
11
 
12
+ /**
13
+ * Normalized user identity for WebSocket sessions.
14
+ */
15
+ interface WsUserIdentity {
16
+ userId: string;
17
+ roles: string[];
18
+ isAdmin: boolean;
19
+ }
20
+
12
21
  interface ClientSession {
13
22
  ws: WebSocket;
14
- user?: AccessTokenPayload;
23
+ user?: WsUserIdentity;
15
24
  authenticated: boolean;
16
25
  /** Sliding window message counter for rate limiting */
17
26
  messageCount: number;
@@ -38,11 +47,31 @@ const ADMIN_ONLY_TYPES = new Set([
38
47
  "LIST_BRANCHES"
39
48
  ]);
40
49
 
50
+ /**
51
+ * Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
52
+ */
53
+ function extractErrorMessage(error: unknown): string {
54
+ 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;
62
+ }
63
+ }
64
+ return String(error);
65
+ }
66
+
41
67
  /**
42
68
  * Check if the current session belongs to an admin user.
43
69
  */
44
70
  function isAdminSession(session: ClientSession | undefined): boolean {
45
- if (!session?.user?.roles) return false;
71
+ if (!session?.user) return false;
72
+ // Fast path: new adapter-aware sessions set isAdmin directly
73
+ if (session.user.isAdmin) return true;
74
+ if (!session.user.roles) return false;
46
75
  return session.user.roles.some((r: unknown) => {
47
76
  if (typeof r === "string") return r === "admin";
48
77
  if (r && typeof r === "object" && "isAdmin" in r) return (r as { isAdmin: boolean }).isAdmin;
@@ -55,7 +84,8 @@ export function createPostgresWebSocket(
55
84
  server: Server,
56
85
  realtimeService: RealtimeService,
57
86
  driver: PostgresBackendDriver,
58
- authConfig?: AuthConfig
87
+ authConfig?: AuthConfig,
88
+ authAdapter?: AuthAdapter
59
89
  ) {
60
90
  const isProduction = process.env.NODE_ENV === "production";
61
91
  /** Debug logger that is suppressed in production to prevent PII/data leaks */
@@ -74,7 +104,11 @@ export function createPostgresWebSocket(
74
104
  console.error("❌ [WebSocket Server] Error:", err);
75
105
  });
76
106
 
77
- const requireAuth = authConfig?.requireAuth !== false && authConfig?.jwtSecret;
107
+ // Auth is required when either: an adapter is present (secure by default),
108
+ // OR the config has a jwtSecret and requireAuth !== false.
109
+ const requireAuth = authAdapter
110
+ ? true
111
+ : (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
78
112
 
79
113
  wss.on("connection", (ws) => {
80
114
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
@@ -123,21 +157,53 @@ code } }
123
157
  return;
124
158
  }
125
159
 
126
- const user = extractUserFromToken(token);
127
- if (user) {
160
+ // Use the auth adapter when available (custom auth, Clerk, etc.)
161
+ // Fall back to JWT extraction otherwise.
162
+ let verifiedUser: WsUserIdentity | null = null;
163
+
164
+ if (authAdapter) {
165
+ try {
166
+ const adapterUser = authAdapter.verifyToken
167
+ ? await authAdapter.verifyToken(token)
168
+ : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
169
+ headers: { Authorization: `Bearer ${token}` },
170
+ }));
171
+
172
+ if (adapterUser) {
173
+ verifiedUser = {
174
+ userId: adapterUser.uid,
175
+ roles: adapterUser.roles,
176
+ isAdmin: adapterUser.isAdmin,
177
+ };
178
+ }
179
+ } catch {
180
+ // Adapter threw — treat as invalid token
181
+ }
182
+ } else {
183
+ // Standard JWT path
184
+ const jwtPayload = extractUserFromToken(token);
185
+ if (jwtPayload) {
186
+ verifiedUser = {
187
+ userId: jwtPayload.userId,
188
+ roles: jwtPayload.roles ?? [],
189
+ isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin"),
190
+ };
191
+ }
192
+ }
193
+
194
+ if (verifiedUser) {
128
195
  const session = clientSessions.get(clientId);
129
196
  if (session) {
130
- session.user = user;
197
+ session.user = verifiedUser;
131
198
  session.authenticated = true;
132
199
  }
133
200
  wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
134
201
  ws.send(JSON.stringify({
135
202
  type: "AUTH_SUCCESS",
136
203
  requestId,
137
- payload: { userId: user.userId,
138
- roles: user.roles }
204
+ payload: { userId: verifiedUser.userId, roles: verifiedUser.roles }
139
205
  }));
140
- wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${user.userId}`);
206
+ wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
141
207
  } else {
142
208
  wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
143
209
  sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
@@ -183,17 +249,21 @@ roles: user.roles }
183
249
  // Helper to get correctly scoped delegate for the current request
184
250
  const getScopedDelegate = async (): Promise<DataDriver> => {
185
251
  const session = clientSessions.get(clientId);
186
- if (session?.user && "withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
252
+ if ("withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
187
253
  try {
188
- // Map AccessTokenPayload back to User interface for withAuth (roles are already string IDs from JWT)
189
- const userForAuth: Record<string, unknown> = {
190
- uid: session.user.userId,
191
- roles: session.user.roles ?? []
192
- };
254
+ const userForAuth = session?.user
255
+ ? {
256
+ uid: session.user.userId,
257
+ roles: session.user.roles ?? []
258
+ }
259
+ : {
260
+ uid: "anon",
261
+ roles: ["anon"]
262
+ };
193
263
  return await (driver as unknown as { withAuth: (user: Record<string, unknown>) => Promise<DataDriver> }).withAuth(userForAuth);
194
264
  } catch (e) {
195
- console.error("Failed to create authenticated delegate for WS request", e);
196
- return driver;
265
+ console.error("Failed to create RLS scoped delegate for WS request", e);
266
+ throw new Error("Internal authentication error");
197
267
  }
198
268
  }
199
269
  return driver;
@@ -306,22 +376,29 @@ colors: true }));
306
376
 
307
377
  case "EXECUTE_SQL": {
308
378
  const { sql, options } = payload;
309
- const delegate = await getScopedDelegate();
310
- const admin = delegate.admin;
311
- if (!isSQLAdmin(admin)) {
312
- sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
313
- break;
314
- }
315
- const result = await admin.executeSql(sql, options);
316
- if (process.env.NODE_ENV !== "production") {
317
- wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
379
+ try {
380
+ const delegate = await getScopedDelegate();
381
+ const admin = delegate.admin;
382
+ if (!isSQLAdmin(admin)) {
383
+ sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
384
+ break;
385
+ }
386
+ const result = await admin.executeSql(sql, options);
387
+ if (process.env.NODE_ENV !== "production") {
388
+ wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
389
+ }
390
+ const response = {
391
+ type: "EXECUTE_SQL_SUCCESS",
392
+ payload: { result },
393
+ requestId
394
+ };
395
+ ws.send(JSON.stringify(response));
396
+ } catch (sqlError: unknown) {
397
+ // This is a query execution error (e.g., syntax error, permission denied).
398
+ // We return it cleanly to the client without logging a server stack trace.
399
+ const errMsg = extractErrorMessage(sqlError);
400
+ sendError("ERROR", "SQL_ERROR", errMsg);
318
401
  }
319
- const response = {
320
- type: "EXECUTE_SQL_SUCCESS",
321
- payload: { result },
322
- requestId
323
- };
324
- ws.send(JSON.stringify(response));
325
402
  }
326
403
  break;
327
404
 
@@ -478,9 +555,8 @@ colors: true }));
478
555
  // Attach auth context from the WS session so RLS-aware refetches work
479
556
  const session = clientSessions.get(clientId);
480
557
  const authContext = session?.user
481
- ? { userId: session.user.userId,
482
- roles: session.user.roles ?? [] }
483
- : undefined;
558
+ ? { userId: session.user.userId, roles: session.user.roles ?? [] }
559
+ : { userId: "anon", roles: ["anon"] };
484
560
  // Let RealtimeService handle these messages
485
561
  await realtimeService.handleClientMessage(clientId, {
486
562
  type,
@@ -1,19 +1,45 @@
1
1
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
2
  import { UserService, RoleService, RefreshTokenService, PasswordResetTokenService, Role } from "../src/auth/services";
3
- import { users, refreshTokens, passwordResetTokens, User } from "../src/schema/auth-schema";
3
+ import { users, refreshTokens, passwordResetTokens } from "../src/schema/auth-schema";
4
+ import { UserData } from "@rebasepro/server-core";
4
5
 
5
6
  // Mock the drizzle-orm functions
6
- jest.mock("drizzle-orm", () => ({
7
- eq: jest.fn((field, value) => ({ field,
8
- value,
9
- type: "eq" })),
10
- sql: jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
11
- strings,
12
- values,
13
- type: "sql"
14
- })),
15
- relations: jest.fn(() => ({}))
16
- }));
7
+ jest.mock("drizzle-orm", () => {
8
+ const actual = jest.requireActual("drizzle-orm");
9
+ return {
10
+ ...actual,
11
+ eq: jest.fn((field, value) => ({ field, value, type: "eq" })),
12
+ sql: Object.assign(
13
+ jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
14
+ strings,
15
+ values,
16
+ type: "sql"
17
+ })),
18
+ {
19
+ raw: jest.fn((val: string) => ({ val, type: "sql-raw" })),
20
+ join: jest.fn((parts: unknown[], separator: unknown) => ({ parts, separator, type: "sql-join" }))
21
+ }
22
+ ),
23
+ relations: jest.fn(() => ({}))
24
+ };
25
+ });
26
+
27
+ function mockUserData(overrides: Partial<UserData>): UserData {
28
+ return {
29
+ id: "user-123",
30
+ email: "test@example.com",
31
+ passwordHash: null,
32
+ displayName: null,
33
+ photoUrl: null,
34
+ emailVerified: false,
35
+ emailVerificationToken: null,
36
+ emailVerificationSentAt: null,
37
+ createdAt: expect.any(Date),
38
+ updatedAt: expect.any(Date),
39
+ metadata: {},
40
+ ...overrides
41
+ };
42
+ }
17
43
 
18
44
  describe("Auth Services", () => {
19
45
  let db: jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
@@ -37,9 +63,7 @@ describe("Auth Services", () => {
37
63
  });
38
64
 
39
65
  mockSelectWhere = jest.fn().mockResolvedValue([]);
40
- mockSelectFrom = jest.fn().mockReturnValue({
41
- where: mockSelectWhere
42
- });
66
+ mockSelectFrom = jest.fn();
43
67
 
44
68
  mockUpdateReturning = jest.fn().mockResolvedValue([]);
45
69
  mockUpdateWhere = jest.fn().mockReturnValue({ returning: mockUpdateReturning });
@@ -49,13 +73,50 @@ describe("Auth Services", () => {
49
73
 
50
74
  mockExecute = jest.fn().mockResolvedValue({ rows: [] });
51
75
 
76
+ // Set up chainable mock for db.select()
77
+ const mockChain: any = {};
78
+ mockChain.from = jest.fn().mockImplementation((...args) => {
79
+ const result = mockSelectFrom(...args);
80
+ if (result && typeof result.then === "function") {
81
+ return result; // If listUsers mocks selectFrom to return a promise, return it directly
82
+ }
83
+ return mockChain;
84
+ });
85
+ mockChain.innerJoin = jest.fn().mockReturnValue(mockChain);
86
+ mockChain.where = jest.fn().mockImplementation((...args) => {
87
+ mockChain.wherePromise = mockSelectWhere(...args);
88
+ return mockChain;
89
+ });
90
+ mockChain.limit = jest.fn().mockReturnValue(mockChain);
91
+ mockChain.offset = jest.fn().mockReturnValue(mockChain);
92
+ mockChain.orderBy = jest.fn().mockReturnValue(mockChain);
93
+ mockChain.then = jest.fn().mockImplementation(async (onFulfilled) => {
94
+ let val;
95
+ if (mockChain.wherePromise) {
96
+ val = await mockChain.wherePromise;
97
+ mockChain.wherePromise = null;
98
+ } else if (mockSelectWhere.mock.calls.length > 0) {
99
+ const result = mockSelectWhere.mock.results[mockSelectWhere.mock.results.length - 1];
100
+ val = result.type === "return" ? result.value : undefined;
101
+ if (val && typeof val.then === "function") {
102
+ val = await val;
103
+ }
104
+ } else {
105
+ val = [];
106
+ }
107
+ return onFulfilled(val || []);
108
+ });
109
+
52
110
  db = {
53
111
  insert: jest.fn().mockReturnValue({ values: mockInsertValues }),
54
- select: jest.fn().mockReturnValue({ from: mockSelectFrom }),
112
+ select: jest.fn().mockReturnValue(mockChain),
55
113
  update: jest.fn().mockReturnValue({ set: mockUpdateSet }),
56
114
  delete: jest.fn().mockReturnValue({ where: mockDeleteWhere }),
57
115
  execute: mockExecute
58
116
  } as unknown as jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
117
+
118
+ // Set default return value for mockSelectFrom to return mockChain (chainable)
119
+ mockSelectFrom.mockReturnValue(mockChain);
59
120
  });
60
121
 
61
122
  describe("UserService", () => {
@@ -71,30 +132,34 @@ describe("Auth Services", () => {
71
132
  email: "test@example.com",
72
133
  displayName: "Test User"
73
134
  };
74
- const createdUser = { id: "user-123",
75
- ...newUser,
76
- createdAt: new Date(),
77
- updatedAt: new Date() };
78
- mockInsertReturning.mockResolvedValueOnce([createdUser]);
135
+ const dbReturnedUser = {
136
+ id: "user-123",
137
+ ...newUser,
138
+ createdAt: new Date(),
139
+ updatedAt: new Date()
140
+ };
141
+ mockInsertReturning.mockResolvedValueOnce([dbReturnedUser]);
79
142
 
80
143
  const result = await userService.createUser(newUser);
81
144
 
82
145
  expect(db.insert).toHaveBeenCalledWith(users);
83
- expect(mockInsertValues).toHaveBeenCalledWith(newUser);
84
- expect(result).toEqual(createdUser);
146
+ expect(mockInsertValues).toHaveBeenCalledWith({
147
+ ...newUser,
148
+ metadata: {}
149
+ });
150
+ expect(result).toEqual(mockUserData({ displayName: "Test User" }));
85
151
  });
86
152
  });
87
153
 
88
154
  describe("getUserById", () => {
89
155
  it("should return user when found", async () => {
90
- const mockUser = { id: "user-123",
91
- email: "test@example.com" };
156
+ const mockUser = { id: "user-123", email: "test@example.com" };
92
157
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
93
158
 
94
159
  const result = await userService.getUserById("user-123");
95
160
 
96
161
  expect(db.select).toHaveBeenCalled();
97
- expect(result).toEqual(mockUser);
162
+ expect(result).toEqual(mockUserData({}));
98
163
  });
99
164
 
100
165
  it("should return null when user not found", async () => {
@@ -108,13 +173,12 @@ email: "test@example.com" };
108
173
 
109
174
  describe("getUserByEmail", () => {
110
175
  it("should return user when found by email", async () => {
111
- const mockUser = { id: "user-123",
112
- email: "test@example.com" };
176
+ const mockUser = { id: "user-123", email: "test@example.com" };
113
177
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
114
178
 
115
179
  const result = await userService.getUserByEmail("test@example.com");
116
180
 
117
- expect(result).toEqual(mockUser);
181
+ expect(result).toEqual(mockUserData({}));
118
182
  });
119
183
 
120
184
  it("should lowercase email for lookup", async () => {
@@ -128,14 +192,14 @@ email: "test@example.com" };
128
192
  });
129
193
 
130
194
  describe("getUserByIdentity", () => {
131
- it("should execute sql for identity lookup", async () => {
132
- const mockUser = { id: "user-123" };
133
- // execute mock instead of select
134
- mockExecute.mockResolvedValueOnce({ rows: [mockUser] });
195
+ it("should fetch user by identity", async () => {
196
+ const mockUser = { id: "user-123", email: "test@example.com" };
197
+ mockSelectWhere.mockResolvedValueOnce([{ user: mockUser }]);
135
198
 
136
199
  const result = await userService.getUserByIdentity("google", "google-abc");
137
200
 
138
- expect(mockExecute).toHaveBeenCalled();
201
+ expect(db.select).toHaveBeenCalled();
202
+ expect(result).toEqual(expect.objectContaining({ id: "user-123", email: "test@example.com" }));
139
203
  });
140
204
  });
141
205
 
@@ -158,9 +222,11 @@ email: "test@example.com" };
158
222
 
159
223
  describe("updateUser", () => {
160
224
  it("should update user and return updated record", async () => {
161
- const updatedUser = { id: "user-123",
162
- email: "test@example.com",
163
- displayName: "Updated Name" };
225
+ const updatedUser = {
226
+ id: "user-123",
227
+ email: "test@example.com",
228
+ displayName: "Updated Name"
229
+ };
164
230
  mockUpdateReturning.mockResolvedValueOnce([updatedUser]);
165
231
 
166
232
  const result = await userService.updateUser("user-123", { displayName: "Updated Name" });
@@ -170,7 +236,7 @@ displayName: "Updated Name" };
170
236
  displayName: "Updated Name",
171
237
  updatedAt: expect.any(Date)
172
238
  }));
173
- expect(result).toEqual(updatedUser);
239
+ expect(result).toEqual(mockUserData({ displayName: "Updated Name" }));
174
240
  });
175
241
 
176
242
  it("should return null when user not found", async () => {
@@ -194,17 +260,18 @@ displayName: "Updated Name" };
194
260
  describe("listUsers", () => {
195
261
  it("should return all users", async () => {
196
262
  const mockUsers = [
197
- { id: "user-1",
198
- email: "user1@example.com" },
199
- { id: "user-2",
200
- email: "user2@example.com" }
263
+ { id: "user-1", email: "user1@example.com" },
264
+ { id: "user-2", email: "user2@example.com" }
201
265
  ];
202
266
  mockSelectFrom.mockReturnValueOnce(Promise.resolve(mockUsers));
203
267
 
204
268
  const result = await userService.listUsers();
205
269
 
206
270
  expect(db.select).toHaveBeenCalled();
207
- expect(result).toEqual(mockUsers);
271
+ expect(result).toEqual([
272
+ mockUserData({ id: "user-1", email: "user1@example.com" }),
273
+ mockUserData({ id: "user-2", email: "user2@example.com" })
274
+ ]);
208
275
  });
209
276
  });
210
277
 
@@ -264,13 +331,12 @@ email: "user2@example.com" }
264
331
 
265
332
  describe("getUserByVerificationToken", () => {
266
333
  it("should find user by verification token", async () => {
267
- const mockUser = { id: "user-123",
268
- email: "test@example.com" };
334
+ const mockUser = { id: "user-123", email: "test@example.com" };
269
335
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
270
336
 
271
337
  const result = await userService.getUserByVerificationToken("token-abc");
272
338
 
273
- expect(result).toEqual(mockUser);
339
+ expect(result).toEqual(mockUserData({}));
274
340
  });
275
341
  });
276
342
 
@@ -279,17 +345,17 @@ email: "test@example.com" };
279
345
  mockExecute.mockResolvedValueOnce({
280
346
  rows: [
281
347
  { id: "admin",
282
- name: "Admin",
283
- is_admin: true,
284
- default_permissions: null,
285
- collection_permissions: null,
286
- config: null },
348
+ name: "Admin",
349
+ is_admin: true,
350
+ default_permissions: null,
351
+ collection_permissions: null,
352
+ config: null },
287
353
  { id: "editor",
288
- name: "Editor",
289
- is_admin: false,
290
- default_permissions: { edit: true },
291
- collection_permissions: null,
292
- config: null }
354
+ name: "Editor",
355
+ is_admin: false,
356
+ default_permissions: { edit: true },
357
+ collection_permissions: null,
358
+ config: null }
293
359
  ]
294
360
  });
295
361
 
@@ -312,11 +378,11 @@ config: null }
312
378
  mockExecute.mockResolvedValueOnce({
313
379
  rows: [
314
380
  { id: "admin",
315
- name: "Admin",
316
- is_admin: true,
317
- default_permissions: null,
318
- collection_permissions: null,
319
- config: null }
381
+ name: "Admin",
382
+ is_admin: true,
383
+ default_permissions: null,
384
+ collection_permissions: null,
385
+ config: null }
320
386
  ]
321
387
  });
322
388
 
@@ -353,28 +419,27 @@ config: null }
353
419
 
354
420
  describe("getUserWithRoles", () => {
355
421
  it("should return user with roles", async () => {
356
- const mockUser = { id: "user-123",
357
- email: "test@example.com" };
422
+ const mockUser = { id: "user-123", email: "test@example.com" };
358
423
  mockSelectWhere.mockResolvedValueOnce([mockUser]);
359
424
  mockExecute.mockResolvedValueOnce({
360
425
  rows: [{ id: "admin",
361
- name: "Admin",
362
- is_admin: true,
363
- default_permissions: null,
364
- collection_permissions: null,
365
- config: null }]
426
+ name: "Admin",
427
+ is_admin: true,
428
+ default_permissions: null,
429
+ collection_permissions: null,
430
+ config: null }]
366
431
  });
367
432
 
368
433
  const result = await userService.getUserWithRoles("user-123");
369
434
 
370
435
  expect(result).toEqual({
371
- user: mockUser,
436
+ user: mockUserData({}),
372
437
  roles: [{ id: "admin",
373
- name: "Admin",
374
- isAdmin: true,
375
- defaultPermissions: null,
376
- collectionPermissions: null,
377
- config: null }]
438
+ name: "Admin",
439
+ isAdmin: true,
440
+ defaultPermissions: null,
441
+ collectionPermissions: null,
442
+ config: null }]
378
443
  });
379
444
  });
380
445
 
@@ -386,6 +451,30 @@ config: null }]
386
451
  expect(result).toBeNull();
387
452
  });
388
453
  });
454
+
455
+ describe("listUsersPaginated", () => {
456
+ it("should return paginated and filtered users list", async () => {
457
+ mockExecute
458
+ .mockResolvedValueOnce({ rows: [{ total: 1 }] })
459
+ .mockResolvedValueOnce({ rows: [{ id: "user-123", email: "test@example.com" }] });
460
+
461
+ const result = await userService.listUsersPaginated({
462
+ limit: 10,
463
+ offset: 0,
464
+ search: "test",
465
+ orderBy: "email",
466
+ orderDir: "asc"
467
+ });
468
+
469
+ expect(mockExecute).toHaveBeenCalledTimes(2);
470
+ expect(result).toEqual({
471
+ users: [mockUserData({ id: "user-123", email: "test@example.com" })],
472
+ total: 1,
473
+ limit: 10,
474
+ offset: 0
475
+ });
476
+ });
477
+ });
389
478
  });
390
479
 
391
480
  describe("RoleService", () => {
@@ -415,3 +415,60 @@ describe("structural dunder guard", () => {
415
415
  expect(dunderKeys).toEqual([]);
416
416
  });
417
417
  });
418
+
419
+ // ─────────────────────────────────────────────────────────────
420
+ // parsePropertyFromServer — vector parsing
421
+ // ─────────────────────────────────────────────────────────────
422
+ describe("parsePropertyFromServer vector parsing", () => {
423
+ const targetCollection = makeCollection("items", {
424
+ embedding: {
425
+ type: "vector",
426
+ dimensions: 3,
427
+ name: "Embedding"
428
+ } as unknown as Property
429
+ });
430
+
431
+ it("parses string representation '[0.1,-0.2,3.14]' from postgres into wrapped vector", () => {
432
+ const property = targetCollection.properties.embedding as Property;
433
+ const result = parsePropertyFromServer("[0.1,-0.2,3.14]", property, targetCollection, "embedding");
434
+ expect(result).toEqual({
435
+ __type: "Vector",
436
+ value: [0.1, -0.2, 3.14]
437
+ });
438
+ });
439
+
440
+ it("parses numeric array [0.1,-0.2,3.14] into wrapped vector", () => {
441
+ const property = targetCollection.properties.embedding as Property;
442
+ const result = parsePropertyFromServer([0.1, -0.2, 3.14], property, targetCollection, "embedding");
443
+ expect(result).toEqual({
444
+ __type: "Vector",
445
+ value: [0.1, -0.2, 3.14]
446
+ });
447
+ });
448
+ });
449
+
450
+ // ─────────────────────────────────────────────────────────────
451
+ // parsePropertyFromServer — binary parsing
452
+ // ─────────────────────────────────────────────────────────────
453
+ describe("parsePropertyFromServer binary parsing", () => {
454
+ const targetCollection = makeCollection("items", {
455
+ data: {
456
+ type: "binary",
457
+ name: "Data"
458
+ } as unknown as Property
459
+ });
460
+
461
+ it("parses database Buffer into a base64 data URL string", () => {
462
+ const property = targetCollection.properties.data as Property;
463
+ const buffer = Buffer.from("hello");
464
+ const result = parsePropertyFromServer(buffer, property, targetCollection, "data");
465
+ expect(result).toBe("data:application/octet-stream;base64,aGVsbG8=");
466
+ });
467
+
468
+ it("parses Buffer object (JSON serialization) into a base64 data URL string", () => {
469
+ const property = targetCollection.properties.data as Property;
470
+ const bufferObj = { type: "Buffer", data: Array.from(Buffer.from("hello")) };
471
+ const result = parsePropertyFromServer(bufferObj, property, targetCollection, "data");
472
+ expect(result).toBe("data:application/octet-stream;base64,aGVsbG8=");
473
+ });
474
+ });