@rebasepro/server-postgresql 0.7.0 → 0.8.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 (56) hide show
  1. package/build-errors.txt +37 -0
  2. package/dist/PostgresAdapter.d.ts +6 -0
  3. package/dist/PostgresBackendDriver.d.ts +118 -0
  4. package/dist/PostgresBootstrapper.d.ts +46 -0
  5. package/dist/auth/ensure-tables.d.ts +10 -0
  6. package/dist/auth/services.d.ts +251 -0
  7. package/dist/cli-errors.d.ts +29 -0
  8. package/dist/cli-helpers.d.ts +7 -0
  9. package/dist/cli.d.ts +1 -0
  10. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  11. package/dist/connection.d.ts +65 -0
  12. package/dist/data-transformer.d.ts +55 -0
  13. package/dist/databasePoolManager.d.ts +20 -0
  14. package/dist/history/HistoryService.d.ts +71 -0
  15. package/dist/history/ensure-history-table.d.ts +7 -0
  16. package/dist/index.d.ts +14 -0
  17. package/dist/index.es.js +13560 -0
  18. package/dist/index.es.js.map +1 -0
  19. package/dist/interfaces.d.ts +18 -0
  20. package/dist/schema/auth-default-policies.d.ts +12 -0
  21. package/dist/schema/auth-schema.d.ts +2376 -0
  22. package/dist/schema/doctor-cli.d.ts +2 -0
  23. package/dist/schema/doctor.d.ts +52 -0
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  25. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  27. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  28. package/dist/schema/introspect-db-inference.d.ts +5 -0
  29. package/dist/schema/introspect-db-logic.d.ts +118 -0
  30. package/dist/schema/introspect-db.d.ts +1 -0
  31. package/dist/schema/test-schema.d.ts +24 -0
  32. package/dist/services/BranchService.d.ts +47 -0
  33. package/dist/services/EntityFetchService.d.ts +214 -0
  34. package/dist/services/EntityPersistService.d.ts +40 -0
  35. package/dist/services/RelationService.d.ts +98 -0
  36. package/dist/services/entity-helpers.d.ts +38 -0
  37. package/dist/services/entityService.d.ts +110 -0
  38. package/dist/services/index.d.ts +4 -0
  39. package/dist/services/realtimeService.d.ts +220 -0
  40. package/dist/types.d.ts +3 -0
  41. package/dist/utils/drizzle-conditions.d.ts +138 -0
  42. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  43. package/dist/utils/pg-error-utils.d.ts +65 -0
  44. package/dist/utils/table-classification.d.ts +8 -0
  45. package/dist/websocket.d.ts +11 -0
  46. package/package.json +17 -17
  47. package/src/PostgresBackendDriver.ts +135 -24
  48. package/src/cli.ts +73 -1
  49. package/src/collections/PostgresCollectionRegistry.ts +6 -6
  50. package/src/data-transformer.ts +2 -2
  51. package/src/schema/auth-default-policies.ts +13 -6
  52. package/src/schema/generate-drizzle-schema-logic.ts +16 -79
  53. package/src/schema/generate-postgres-ddl-logic.ts +14 -51
  54. package/src/services/realtimeService.ts +26 -2
  55. package/test/entity-callbacks-redaction.test.ts +86 -0
  56. package/test/postgresDataDriver.test.ts +2 -1
@@ -1,6 +1,6 @@
1
1
  import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/entity-helpers";
3
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
4
4
  import { toSnakeCase } from "@rebasepro/utils";
5
5
  import { createHash } from "crypto";
6
6
  import { logger } from "@rebasepro/server-core";
@@ -308,62 +308,9 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: EntityCo
308
308
  };
309
309
 
310
310
  /**
311
- * Resolves a raw SQL string, replacing `{column_name}` with `${table.column_name}`.
312
- * The result is wrapped in a Drizzle sql`` template literal.
311
+ * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
313
312
  */
314
- const resolveRawSql = (expression: string): string => {
315
- // Replace {column_name} with column_name directly (so Drizzle-kit can parse it as a static string)
316
- const resolved = expression.replace(/\{(\w+)\}/g, (_, col) => col);
317
- return `sql\`${resolved}\``;
318
- };
319
-
320
- /**
321
- * Wraps a SQL clause with a role check using AND.
322
- * Generates: `(<clause>) AND (string_to_array(auth.roles(), ',') && ARRAY['<role1>','<role2>'])`
323
- */
324
- const wrapWithRoleCheck = (clause: string, roles: string[]): string => {
325
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
326
- const roleCondition = `string_to_array(auth.roles(), ',') && ${rolesArrayString}`;
327
- return `sql\`(${unwrapSql(clause)}) AND (${roleCondition})\``;
328
- };
329
-
330
- /**
331
- * Extracts the inner expression from a `sql\`...\`` wrapper.
332
- */
333
- const unwrapSql = (sqlExpr: string): string => {
334
- const match = sqlExpr.match(/^sql`(.*)`$/s);
335
- return match ? match[1] : sqlExpr;
336
- };
337
-
338
- /**
339
- * Builds the USING clause for a policy based on shortcuts or raw SQL.
340
- */
341
- const buildUsingClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
342
- if (rule.using) {
343
- return resolveRawSql(rule.using);
344
- }
345
- if (rule.access === "public") {
346
- return "sql`true`";
347
- }
348
- if (rule.ownerField) {
349
- const prop = collection.properties?.[rule.ownerField];
350
- const colName = resolveColumnName(rule.ownerField, prop);
351
- return `sql\`${colName} = auth.uid()\``;
352
- }
353
- return null;
354
- };
355
-
356
- /**
357
- * Builds the WITH CHECK clause for a policy based on shortcuts or raw SQL.
358
- * Falls back to the USING clause if not explicitly provided.
359
- */
360
- const buildWithCheckClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
361
- if (rule.withCheck) {
362
- return resolveRawSql(rule.withCheck);
363
- }
364
- // For insert/update/all, fall back to using clause if withCheck not specified
365
- return buildUsingClause(rule, collection);
366
- };
313
+ const wrapSql = (clause: string): string => `sql\`${clause}\``;
367
314
 
368
315
  /**
369
316
  * Generates a deterministic hash based on the rule configuration.
@@ -378,7 +325,9 @@ const getPolicyNameHash = (rule: SecurityRule): string => {
378
325
  rol: rule.roles?.slice().sort(),
379
326
  pg: rule.pgRoles?.slice().sort(),
380
327
  u: rule.using,
381
- w: rule.withCheck
328
+ w: rule.withCheck,
329
+ c: rule.condition,
330
+ ch: rule.check
382
331
  });
383
332
  return createHash("sha1").update(data).digest("hex").substring(0, 7);
384
333
  };
@@ -417,7 +366,6 @@ const generatePolicyCode = (collection: EntityCollection, rule: SecurityRule, in
417
366
  */
418
367
  const generateSinglePolicyCode = (collection: EntityCollection, rule: SecurityRule, operation: SecurityOperation, policyName: string): string => {
419
368
  const mode = rule.mode ?? "permissive";
420
- const roles = rule.roles ? [...rule.roles].sort() : undefined;
421
369
 
422
370
  // Determine which clauses this operation needs:
423
371
  // SELECT, DELETE → USING only
@@ -426,26 +374,13 @@ const generateSinglePolicyCode = (collection: EntityCollection, rule: SecurityRu
426
374
  const needsUsing = operation !== "insert";
427
375
  const needsWithCheck = operation !== "select" && operation !== "delete";
428
376
 
429
- let usingClause = needsUsing ? buildUsingClause(rule, collection) : null;
430
- let withCheckClause = needsWithCheck ? buildWithCheckClause(rule, collection) : null;
431
-
432
- // If roles are specified, wrap existing clauses with role check,
433
- // or generate a roles-only clause.
434
- if (roles && roles.length > 0) {
435
- if (usingClause) {
436
- usingClause = wrapWithRoleCheck(usingClause, roles);
437
- } else if (needsUsing) {
438
- // Roles-only rule (e.g. { operation: "select", roles: ["admin"] })
439
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
440
- usingClause = `sql\`string_to_array(auth.roles(), ',') && ${rolesArrayString}\``;
441
- }
442
- if (withCheckClause) {
443
- withCheckClause = wrapWithRoleCheck(withCheckClause, roles);
444
- } else if (needsWithCheck) {
445
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
446
- withCheckClause = `sql\`string_to_array(auth.roles(), ',') && ${rolesArrayString}\``;
447
- }
448
- }
377
+ // Desugar the rule (access / ownerField / roles / structured condition / raw
378
+ // SQL) into the shared PolicyExpression model, then compile to SQL — the same
379
+ // normalization the DDL generator and the client-side evaluator use.
380
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
381
+
382
+ let usingClause = needsUsing && usingExpr ? wrapSql(policyToPostgres(usingExpr, collection)) : null;
383
+ let withCheckClause = needsWithCheck && withCheckExpr ? wrapSql(policyToPostgres(withCheckExpr, collection)) : null;
449
384
 
450
385
  // Fallback: if we still have no clauses, deny all (safety net)
451
386
  if (!usingClause && needsUsing) {
@@ -617,7 +552,9 @@ export const generateSchema = async (collections: EntityCollection[], stripPolic
617
552
  const enumVarName = getEnumVarName(collectionPath, propName);
618
553
  const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
619
554
  const values = Array.isArray(prop.enum)
620
- ? prop.enum.map(v => String(v.id ?? v))
555
+ ? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
556
+ String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
557
+ )
621
558
  : Object.keys(prop.enum);
622
559
  if (values.length > 0) {
623
560
  schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map(v => `'${v}'`).join(", ")}]);\n`;
@@ -1,5 +1,5 @@
1
1
  import { EntityCollection, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollection, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  import { createHash } from "crypto";
5
5
  import { getEffectiveSecurityRules } from "./auth-default-policies";
@@ -44,36 +44,6 @@ const isIdProperty = (propName: string, prop: Property, collection: EntityCollec
44
44
  return !hasExplicitId && propName === "id";
45
45
  };
46
46
 
47
- const resolveRawSql = (expression: string): string => {
48
- return expression.replace(/\{(\w+)\}/g, (_, col) => col);
49
- };
50
-
51
- const unwrapSql = (sqlExpr: string): string => {
52
- return sqlExpr;
53
- };
54
-
55
- const buildUsingClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
56
- if (rule.using) {
57
- return resolveRawSql(rule.using);
58
- }
59
- if (rule.access === "public") {
60
- return "true";
61
- }
62
- if (rule.ownerField) {
63
- const prop = collection.properties?.[rule.ownerField];
64
- const colName = resolveColumnName(rule.ownerField, prop);
65
- return `${colName} = auth.uid()`;
66
- }
67
- return null;
68
- };
69
-
70
- const buildWithCheckClause = (rule: SecurityRule, collection: EntityCollection): string | null => {
71
- if (rule.withCheck) {
72
- return resolveRawSql(rule.withCheck);
73
- }
74
- return buildUsingClause(rule, collection);
75
- };
76
-
77
47
  const getPolicyNameHash = (rule: SecurityRule): string => {
78
48
  const data = JSON.stringify({
79
49
  a: rule.access,
@@ -84,7 +54,9 @@ const getPolicyNameHash = (rule: SecurityRule): string => {
84
54
  rol: rule.roles?.slice().sort(),
85
55
  pg: rule.pgRoles?.slice().sort(),
86
56
  u: rule.using,
87
- w: rule.withCheck
57
+ w: rule.withCheck,
58
+ c: rule.condition,
59
+ ch: rule.check
88
60
  });
89
61
  return createHash("sha1").update(data).digest("hex").substring(0, 7);
90
62
  };
@@ -111,29 +83,18 @@ const generateSinglePolicyDdl = (collection: EntityCollection, rule: SecurityRul
111
83
  const tableName = getTableName(collection);
112
84
  const mode = (rule.mode ?? "permissive").toUpperCase();
113
85
  const operationUpper = operation.toUpperCase();
114
- const roles = rule.roles ? [...rule.roles].sort() : undefined;
115
86
  const pgRoles = rule.pgRoles ? [...rule.pgRoles].sort() : ["public"];
116
87
 
117
88
  const needsUsing = operation !== "insert";
118
89
  const needsWithCheck = operation !== "select" && operation !== "delete";
119
90
 
120
- let usingClause = needsUsing ? buildUsingClause(rule, collection) : null;
121
- let withCheckClause = needsWithCheck ? buildWithCheckClause(rule, collection) : null;
91
+ // Desugar the rule (access / ownerField / roles / structured condition / raw
92
+ // SQL) into the shared PolicyExpression model, then compile to SQL. This is
93
+ // the same normalization the client-side evaluator uses, so DDL and UI agree.
94
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
122
95
 
123
- if (roles && roles.length > 0) {
124
- const rolesArrayString = `ARRAY[${roles.map(r => `'${r}'`).join(",")}]`;
125
- const roleCondition = `string_to_array(auth.roles(), ',') && ${rolesArrayString}`;
126
- if (usingClause) {
127
- usingClause = `(${usingClause}) AND (${roleCondition})`;
128
- } else if (needsUsing) {
129
- usingClause = roleCondition;
130
- }
131
- if (withCheckClause) {
132
- withCheckClause = `(${withCheckClause}) AND (${roleCondition})`;
133
- } else if (needsWithCheck) {
134
- withCheckClause = roleCondition;
135
- }
136
- }
96
+ let usingClause = needsUsing && usingExpr ? policyToPostgres(usingExpr, collection) : null;
97
+ let withCheckClause = needsWithCheck && withCheckExpr ? policyToPostgres(withCheckExpr, collection) : null;
137
98
 
138
99
  if (!usingClause && needsUsing) {
139
100
  usingClause = "false";
@@ -273,7 +234,9 @@ export const generatePostgresDdl = async (
273
234
  if (("enum" in prop) && (prop.type === "string" || prop.type === "number") && prop.enum) {
274
235
  const enumDbName = `${collectionTable}_${resolveColumnName(propName, prop)}`;
275
236
  const values = Array.isArray(prop.enum)
276
- ? prop.enum.map(v => String(v.id ?? v))
237
+ ? (prop.enum as (string | number | { id: string | number })[]).map((v: string | number | { id: string | number }) =>
238
+ String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
239
+ )
277
240
  : Object.keys(prop.enum);
278
241
  if (values.length > 0) {
279
242
  ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(v => `'${v}'`).join(", ")});\n`;
@@ -421,7 +384,7 @@ export const generatePostgresDdl = async (
421
384
  colDef += " PRIMARY KEY";
422
385
  }
423
386
 
424
- if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true) {
387
+ if ("isId" in prop && prop.isId !== "manual" && prop.isId !== true && prop.isId !== "increment") {
425
388
  if (prop.isId === "uuid") {
426
389
  colDef += " DEFAULT gen_random_uuid()";
427
390
  } else if (prop.isId === "cuid") {
@@ -651,9 +651,10 @@ roles: activeAuth.roles })}, true)`);
651
651
  ...registryCollection } as EntityCollection : registryCollection as EntityCollection;
652
652
 
653
653
  const callbacks = resolvedCollection?.callbacks;
654
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
654
655
  const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
655
656
 
656
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
657
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
657
658
  const contextForCallback = {
658
659
  user: { uid: activeAuth.userId,
659
660
  roles: activeAuth.roles },
@@ -663,6 +664,16 @@ roles: activeAuth.roles },
663
664
 
664
665
  return await Promise.all(fetchedEntities.map(async (entity) => {
665
666
  let processedEntity = entity;
667
+ // 1. Global callbacks first
668
+ if (globalCallbacks?.afterRead) {
669
+ processedEntity = await globalCallbacks.afterRead({
670
+ collection: resolvedCollection,
671
+ path: notifyPath,
672
+ entity: processedEntity,
673
+ context: contextForCallback
674
+ }) ?? processedEntity;
675
+ }
676
+ // 2. Collection callbacks second
666
677
  if (callbacks?.afterRead) {
667
678
  processedEntity = await callbacks.afterRead({
668
679
  collection: resolvedCollection,
@@ -671,6 +682,7 @@ roles: activeAuth.roles },
671
682
  context: contextForCallback
672
683
  }) ?? processedEntity;
673
684
  }
685
+ // 3. Property callbacks third
674
686
  if (propertyCallbacks?.afterRead) {
675
687
  processedEntity = await propertyCallbacks.afterRead({
676
688
  collection: resolvedCollection,
@@ -797,9 +809,10 @@ roles: activeAuth.roles })}, true)`);
797
809
  ...registryCollection } as EntityCollection : registryCollection as EntityCollection;
798
810
 
799
811
  const callbacks = resolvedCollection?.callbacks;
812
+ const globalCallbacks = this.registry?.getGlobalCallbacks();
800
813
  const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
801
814
 
802
- if (callbacks?.afterRead || propertyCallbacks?.afterRead) {
815
+ if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
803
816
  const contextForCallback = {
804
817
  user: { uid: activeAuth.userId,
805
818
  roles: activeAuth.roles },
@@ -807,6 +820,16 @@ roles: activeAuth.roles },
807
820
  data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
808
821
  } as unknown as RebaseCallContext;
809
822
 
823
+ // 1. Global callbacks first
824
+ if (globalCallbacks?.afterRead) {
825
+ processedEntity = await globalCallbacks.afterRead({
826
+ collection: resolvedCollection,
827
+ path: notifyPath,
828
+ entity: processedEntity,
829
+ context: contextForCallback
830
+ }) ?? processedEntity;
831
+ }
832
+ // 2. Collection callbacks second
810
833
  if (callbacks?.afterRead) {
811
834
  processedEntity = await callbacks.afterRead({
812
835
  collection: resolvedCollection,
@@ -815,6 +838,7 @@ roles: activeAuth.roles },
815
838
  context: contextForCallback
816
839
  }) ?? processedEntity;
817
840
  }
841
+ // 3. Property callbacks third
818
842
  if (propertyCallbacks?.afterRead) {
819
843
  processedEntity = await propertyCallbacks.afterRead({
820
844
  collection: resolvedCollection,
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect } from "@jest/globals";
2
+ import type { Entity, EntityAfterReadProps, EntityCallbacks } from "@rebasepro/types";
3
+
4
+ /**
5
+ * Regression guard for the PII-redaction pattern.
6
+ *
7
+ * PII masking is defined in per-collection {@link EntityCallbacks.afterRead},
8
+ * which runs inside the DataDriver on every read path — REST, server-side
9
+ * `rebase.data`, and realtime.
10
+ *
11
+ * This suite proves the redaction contract:
12
+ * 1. the `afterRead` masker redacts values (and does not mutate the source), and
13
+ * 2. it is assignable to `EntityCallbacks.afterRead`, i.e. it is a valid
14
+ * driver/realtime callback.
15
+ *
16
+ * That the driver invokes `afterRead` on `fetchCollection` (REST list /
17
+ * `rebase.data.find`) and `fetchEntity` (REST get / `rebase.data.findById`) is
18
+ * already covered by the "storageSource in Callbacks" suite in
19
+ * `postgresDataDriver.test.ts`; the realtime refetch invokes the identical
20
+ * callback in `realtimeService.ts` (`fetchCollectionWithAuth` /
21
+ * `fetchEntityWithAuth`).
22
+ */
23
+
24
+ interface CustomerValues extends Record<string, unknown> {
25
+ email: string;
26
+ first_name: string;
27
+ phone: string;
28
+ }
29
+
30
+ const maskEmail = (email: string): string => {
31
+ const [local, domain] = email.split("@");
32
+ return local && domain ? `${local[0]}***@${domain}` : email;
33
+ };
34
+
35
+ /**
36
+ * A representative per-collection redactor. Typed to require only `entity`
37
+ * (a supertype of the full props), so it is assignable to
38
+ * `EntityCallbacks.afterRead` yet callable in isolation without constructing a
39
+ * full `RebaseCallContext`.
40
+ */
41
+ const redactCustomer = (
42
+ { entity }: Pick<EntityAfterReadProps<CustomerValues>, "entity">
43
+ ): Entity<CustomerValues> => ({
44
+ ...entity,
45
+ values: {
46
+ ...entity.values,
47
+ email: maskEmail(entity.values.email),
48
+ phone: "***"
49
+ }
50
+ });
51
+
52
+ // Compile-time proof: the redactor is a valid EntityCallbacks.afterRead, so the
53
+ // driver and realtime service accept and invoke it on every read path.
54
+ const customerCallbacks: EntityCallbacks<CustomerValues> = { afterRead: redactCustomer };
55
+ void customerCallbacks;
56
+
57
+ describe("EntityCallbacks.afterRead PII redaction contract", () => {
58
+
59
+ const customer: Entity<CustomerValues> = {
60
+ id: "c1",
61
+ path: "customers",
62
+ values: { email: "jane.doe@acme.com", first_name: "Jane", phone: "+15551234567" }
63
+ };
64
+
65
+ it("masks redacted fields in the returned entity", () => {
66
+ const result = redactCustomer({ entity: customer });
67
+
68
+ expect(result.values.email).toBe("j***@acme.com");
69
+ expect(result.values.phone).toBe("***");
70
+ });
71
+
72
+ it("leaves non-redacted fields untouched", () => {
73
+ const result = redactCustomer({ entity: customer });
74
+
75
+ expect(result.values.first_name).toBe("Jane");
76
+ expect(result.id).toBe("c1");
77
+ expect(result.path).toBe("customers");
78
+ });
79
+
80
+ it("does not mutate the source entity", () => {
81
+ redactCustomer({ entity: customer });
82
+
83
+ expect(customer.values.email).toBe("jane.doe@acme.com");
84
+ expect(customer.values.phone).toBe("+15551234567");
85
+ });
86
+ });
@@ -39,7 +39,8 @@ describe("PostgresBackendDriver", () => {
39
39
  getCollectionByPath: jest.fn().mockReturnValue({ slug: "test_coll",
40
40
  properties: {} }),
41
41
  getCollections: jest.fn().mockReturnValue([]),
42
- getTable: jest.fn().mockReturnValue({})
42
+ getTable: jest.fn().mockReturnValue({}),
43
+ getGlobalCallbacks: jest.fn().mockReturnValue(undefined)
43
44
  } as any;
44
45
  delegate = new PostgresBackendDriver(mockDb, mockRealtimeService, mockRegistry);
45
46
  });