@rebasepro/server-postgres 0.12.1-canary.gcfa333c → 0.12.1-canary.gddbd489

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 (81) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +6 -1
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/{backup-service-DH9kPg-E.js → backup-service-CD8o_1Sl.js} +144 -11
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/{connection-B5Wndbr1.js → connection-BuZ97wsr.js} +57 -3
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/{ensure-collection-policies-Vl3Cv1Q9.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  13. package/dist/{ensure-collection-policies-Vl3Cv1Q9.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  14. package/dist/{ensure-collection-tables-DsDsNl6o.js → ensure-collection-tables-CBQdOETu.js} +72 -12
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +990 -677
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/ensure-collection-tables.d.ts +21 -1
  21. package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -0
  22. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  23. package/dist/schema/introspect-db-logic.d.ts +94 -5
  24. package/dist/schema/introspect-db-queries.d.ts +119 -0
  25. package/dist/schema/introspect-db-structure.d.ts +263 -0
  26. package/dist/schema/introspect-db-types.d.ts +11 -0
  27. package/dist/services/FetchService.d.ts +4 -1
  28. package/dist/services/RelationService.d.ts +24 -1
  29. package/dist/services/channel-bus/index.d.ts +1 -7
  30. package/dist/services/collection-helpers.d.ts +36 -2
  31. package/dist/services/dataService.d.ts +3 -1
  32. package/dist/services/row-pipeline.d.ts +1 -1
  33. package/dist/{src-DihrDFuP.js → src-DlPBctw_.js} +217 -101
  34. package/dist/src-DlPBctw_.js.map +1 -0
  35. package/dist/utils/connection-string.d.ts +29 -0
  36. package/dist/utils/drizzle-conditions.d.ts +5 -4
  37. package/dist/utils/pg-error-utils.d.ts +16 -0
  38. package/dist/{websocket-BKcGvILX.js → websocket-B2LsrINK.js} +8 -6
  39. package/dist/websocket-B2LsrINK.js.map +1 -0
  40. package/package.json +6 -6
  41. package/src/PostgresAdapter.ts +7 -2
  42. package/src/PostgresBackendDriver.ts +4 -0
  43. package/src/PostgresBootstrapper.ts +83 -22
  44. package/src/auth/services.ts +6 -3
  45. package/src/backup/backup-cli.ts +41 -2
  46. package/src/backup/backup-service.ts +38 -5
  47. package/src/backup/pg-tools.ts +96 -3
  48. package/src/cli-helpers.ts +70 -0
  49. package/src/cli.ts +44 -26
  50. package/src/collections/validate-relations.ts +15 -0
  51. package/src/connection.ts +73 -0
  52. package/src/data-transformer.ts +9 -3
  53. package/src/databasePoolManager.ts +5 -2
  54. package/src/schema/ensure-collection-tables.ts +81 -10
  55. package/src/schema/generate-drizzle-schema-logic.ts +35 -3
  56. package/src/schema/generate-postgres-ddl-logic.ts +50 -6
  57. package/src/schema/introspect-db-constraints.ts +385 -0
  58. package/src/schema/introspect-db-inference.ts +18 -8
  59. package/src/schema/introspect-db-logic.ts +385 -71
  60. package/src/schema/introspect-db-queries.ts +326 -0
  61. package/src/schema/introspect-db-structure.ts +670 -0
  62. package/src/schema/introspect-db-types.ts +56 -0
  63. package/src/schema/introspect-db.ts +37 -80
  64. package/src/security/policy-drift.test.ts +11 -3
  65. package/src/services/FetchService.ts +90 -14
  66. package/src/services/PersistService.ts +20 -6
  67. package/src/services/RelationService.ts +211 -45
  68. package/src/services/channel-bus/index.ts +0 -9
  69. package/src/services/collection-helpers.ts +69 -3
  70. package/src/services/dataService.ts +3 -1
  71. package/src/services/realtimeService.ts +3 -3
  72. package/src/services/row-pipeline.ts +1 -1
  73. package/src/utils/connection-string.ts +58 -0
  74. package/src/utils/drizzle-conditions.ts +31 -6
  75. package/src/utils/pg-error-utils.ts +19 -0
  76. package/src/websocket.ts +18 -9
  77. package/dist/backup-service-DH9kPg-E.js.map +0 -1
  78. package/dist/connection-B5Wndbr1.js.map +0 -1
  79. package/dist/ensure-collection-tables-DsDsNl6o.js.map +0 -1
  80. package/dist/src-DihrDFuP.js.map +0 -1
  81. package/dist/websocket-BKcGvILX.js.map +0 -1
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Connection-string rewrites for the tools Rebase shells out to.
3
+ *
4
+ * Pure and dependency-free: `pg-tools` builds argument vectors without a live
5
+ * server, and these have to be usable from there.
6
+ */
7
+ /**
8
+ * Make a connection string safe to hand to a libpq program.
9
+ *
10
+ * `sslmode=no-verify` is a node-postgres convention — encrypt, but do not check
11
+ * the certificate — and libpq does not have it. It does not degrade or warn:
12
+ *
13
+ * psql: error: invalid sslmode value: "no-verify"
14
+ *
15
+ * which means a `DATABASE_URL` that works perfectly for the app makes `psql`,
16
+ * `pg_dump`, `pg_restore` and Atlas all refuse to start, with an error that
17
+ * points at the value rather than at the convention it comes from. It has cost
18
+ * this project time more than once.
19
+ *
20
+ * `require` is the honest translation: libpq's `require` encrypts and does not
21
+ * verify the certificate either, which is exactly what `no-verify` asks for.
22
+ * Nothing is relaxed by the rewrite — `verify-ca` and `verify-full` are left
23
+ * alone, so a connection string that asked for verification still gets it.
24
+ *
25
+ * Only `sslmode` is touched, and only when it is a value libpq would reject.
26
+ * Anything unparseable is returned as given: a connection that works
27
+ * unverified beats one this corrupted.
28
+ */
29
+ export declare function forLibpq(connectionString: string): string;
@@ -87,10 +87,11 @@ export declare class DrizzleConditionBuilder {
87
87
  */
88
88
  static buildRelationScopeCondition(relation: ResolvedRelation,
89
89
  /**
90
- * Lazy: only `via` and `belongsTo` need the parent's own table. A
91
- * foreign key on the target and a junction are both expressible from
92
- * the parent's *id* alone, and requiring the table for them would make
93
- * a child listing fail on a parent whose table isn't registered.
90
+ * Lazy: `via`, `belongsTo`, and a foreign key that points at a
91
+ * `sourceKey` need the parent's own table. A junction and a plain
92
+ * foreign key are expressible from the parent's *id* alone, and
93
+ * requiring the table for them would make a child listing fail on a
94
+ * parent whose table isn't registered.
94
95
  */
95
96
  parent: () => {
96
97
  table: PgTable<any>;
@@ -24,6 +24,22 @@ export interface PostgresError extends Error {
24
24
  * error code (5-char alphanumeric, e.g. `42P01`).
25
25
  */
26
26
  export declare function extractPgError(error: unknown): PostgresError | null;
27
+ /**
28
+ * Whether the failure came back from Postgres rather than from building the
29
+ * query — which decides whether a fallback query is worth issuing.
30
+ *
31
+ * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
32
+ * RLS). Once a statement raises, that transaction is aborted, and every later
33
+ * statement on it returns `25P02` — "current transaction is aborted, commands
34
+ * ignored until end of transaction block". So a retry after a database error
35
+ * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
36
+ * for type uuid") with a generic one. Rethrow instead.
37
+ *
38
+ * A query the driver could not even build — a missing reciprocal relation, say
39
+ * — never reached Postgres, leaves the transaction usable, and is exactly what
40
+ * the fallback paths exist for.
41
+ */
42
+ export declare function reachedDatabase(error: unknown): boolean;
27
43
  /**
28
44
  * Walk the error cause chain and return the deepest meaningful message.
29
45
  */
@@ -1,9 +1,10 @@
1
1
  import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
- import { s as __exportAll } from "./connection-B5Wndbr1.js";
4
+ import { c as __exportAll } from "./connection-BuZ97wsr.js";
5
5
  import "./src-DoU9yPqq.js";
6
- import { extractUserFromToken, logger, safeCompare } from "@rebasepro/server";
6
+ import { t as ANONYMOUS_USER_ID } from "./policy-CeA1JcxP.js";
7
+ import { extractUserFromToken, logger, resolveRequireAuth, safeCompare } from "@rebasepro/server";
7
8
  import { WebSocketServer } from "ws";
8
9
  import { inspect } from "util";
9
10
  //#region ../types/src/types/backend.ts
@@ -73,7 +74,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
73
74
  if (err.code === "EADDRINUSE") return;
74
75
  logger.error("❌ [WebSocket Server] Error", { error: err });
75
76
  });
76
- const requireAuth = authAdapter ? true : authConfig?.requireAuth !== false && !!authConfig?.jwtSecret;
77
+ const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
78
+ if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) logger.warn("🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or serviceKey is configured — no client can complete AUTH, so every realtime message will be refused with UNAUTHORIZED.");
77
79
  wss.on("connection", (ws) => {
78
80
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
79
81
  wsDebug(`WebSocket client connected: ${clientId}`);
@@ -193,7 +195,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
193
195
  isAnonymous: false,
194
196
  roles: session.user.roles ?? []
195
197
  } : {
196
- uid: "anon",
198
+ uid: ANONYMOUS_USER_ID,
197
199
  displayName: null,
198
200
  email: null,
199
201
  photoURL: null,
@@ -493,7 +495,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
493
495
  uid: session.user.uid,
494
496
  roles: session.user.roles ?? []
495
497
  } : {
496
- uid: "anon",
498
+ uid: ANONYMOUS_USER_ID,
497
499
  roles: ["anon"]
498
500
  };
499
501
  await realtimeService.handleClientMessage(clientId, {
@@ -525,4 +527,4 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
525
527
  //#endregion
526
528
  export { websocket_exports as n, createPostgresWebSocket as t };
527
529
 
528
- //# sourceMappingURL=websocket-BKcGvILX.js.map
530
+ //# sourceMappingURL=websocket-B2LsrINK.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"websocket-B2LsrINK.js","names":[],"sources":["../../types/src/types/backend.ts","../src/websocket.ts"],"sourcesContent":["import type { CollectionConfig, FilterValues, WhereFilterOp } from \"./collections\";\nimport type { LogicalCondition } from \"../controllers/data\";\nimport type { AuthAdapter } from \"./auth_adapter\";\nimport type { HistoryConfig } from \"../controllers/client\";\nimport type { ChannelBusSetting } from \"./channel_bus\";\n\n// =============================================================================\n// DATABASE CONNECTION INTERFACES\n// =============================================================================\n\n/**\n * Abstract database connection interface.\n * Represents a connection to any database system.\n */\nexport interface DatabaseConnection {\n /**\n * Type identifier for this database (e.g., 'postgres', 'mongodb', 'mysql')\n */\n readonly type: string;\n\n /**\n * Whether the connection is currently active\n */\n readonly isConnected?: boolean;\n\n /**\n * Close the database connection and release resources.\n */\n close?(): Promise<void>;\n}\n\n// =============================================================================\n// QUERY BUILDING INTERFACES\n// =============================================================================\n\n/**\n * A single filter condition for database queries\n */\nexport interface QueryFilter {\n field: string;\n operator: WhereFilterOp;\n value: unknown;\n}\n\n/**\n * Options for fetching a collection of entities\n */\nexport interface FetchCollectionOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n orderBy?: string;\n order?: \"desc\" | \"asc\";\n limit?: number;\n offset?: number;\n startAfter?: unknown;\n searchString?: string;\n databaseId?: string;\n collection?: CollectionConfig;\n}\n\n/**\n * Options for searching entities\n */\nexport interface SearchOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n orderBy?: string;\n order?: \"desc\" | \"asc\";\n limit?: number;\n databaseId?: string;\n collection?: CollectionConfig;\n}\n\n/**\n * Options for counting entities\n */\nexport interface CountOptions<M extends Record<string, unknown> = Record<string, unknown>> {\n filter?: FilterValues<Extract<keyof M, string>>;\n /**\n * An `or(...)`/`and(...)` group, alongside `filter`.\n *\n * Counted as well as fetched, or `total` describes a different set of rows\n * from the one that was served — the same reason `filter` is here.\n */\n logical?: LogicalCondition;\n searchString?: string;\n databaseId?: string;\n}\n\n/**\n * Abstract condition builder interface.\n * Implementations translate Rebase filter conditions to database-specific queries.\n *\n * Note: This interface can be implemented as instance methods or as a class with static methods.\n * For static implementations (like DrizzleConditionBuilder), use the ConditionBuilderStatic type.\n *\n * @template T The type of condition returned by the builder (e.g., SQL for PostgreSQL, Filter<Document> for MongoDB)\n */\nexport interface ConditionBuilder<T = unknown> {\n /**\n * Build filter conditions from Rebase FilterValues\n */\n buildFilterConditions<M extends Record<string, unknown>>(\n filter: FilterValues<Extract<keyof M, string>>,\n collectionPath: string,\n ...args: unknown[]\n ): T[];\n\n /**\n * Build search conditions for text search\n */\n buildSearchConditions(\n searchString: string,\n properties: Record<string, unknown>,\n ...args: unknown[]\n ): T[];\n\n /**\n * Combine multiple conditions with AND operator\n */\n combineConditionsWithAnd(conditions: T[]): T | undefined;\n\n /**\n * Combine multiple conditions with OR operator\n */\n combineConditionsWithOr(conditions: T[]): T | undefined;\n}\n\n/**\n * Static condition builder type for implementations using static methods.\n * Use this type when the class provides static methods rather than instance methods.\n *\n * @example\n * // DrizzleConditionBuilder satisfies this type\n * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;\n */\nexport type ConditionBuilderStatic<T = unknown> = {\n buildFilterConditions<M extends Record<string, unknown>>(\n filter: FilterValues<Extract<keyof M, string>>,\n ...args: unknown[]\n ): T[];\n buildSearchConditions(\n searchString: string,\n properties: Record<string, unknown>,\n ...args: unknown[]\n ): T[];\n combineConditionsWithAnd(conditions: T[]): T | undefined;\n combineConditionsWithOr(conditions: T[]): T | undefined;\n};\n\n// =============================================================================\n// ENTITY REPOSITORY INTERFACES\n// =============================================================================\n\n/**\n * Abstract entity repository interface.\n * Handles all CRUD operations for entities in the database.\n *\n * Implementations should handle:\n * - Entity serialization/deserialization\n * - Relation resolution\n * - ID generation and conversion\n */\nexport interface DataRepository {\n /**\n * Fetch a single entity by ID\n */\n fetchOne<M extends Record<string, unknown>>(\n collectionPath: string,\n id: string | number,\n databaseId?: string\n ): Promise<Record<string, unknown> | undefined>;\n\n /**\n * Fetch a collection of entities with optional filtering, ordering, and pagination\n */\n fetchCollection<M extends Record<string, unknown>>(\n collectionPath: string,\n options?: FetchCollectionOptions<M>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Search entities by text\n */\n searchRows<M extends Record<string, unknown>>(\n collectionPath: string,\n searchString: string,\n options?: SearchOptions<M>\n ): Promise<Record<string, unknown>[]>;\n\n /**\n * Count entities in a collection\n */\n count<M extends Record<string, unknown>>(\n collectionPath: string,\n options?: CountOptions<M>\n ): Promise<number>;\n\n /**\n * Save a entity (create or update)\n */\n save<M extends Record<string, unknown>>(\n collectionPath: string,\n values: Partial<M>,\n id?: string | number,\n databaseId?: string\n ): Promise<Record<string, unknown>>;\n\n /**\n * Delete a entity by ID\n */\n delete(\n collectionPath: string,\n id: string | number,\n databaseId?: string\n ): Promise<void>;\n\n /**\n * Check if a field value is unique in a collection\n */\n checkUniqueField(\n collectionPath: string,\n fieldName: string,\n value: unknown,\n excludeEntityId?: string,\n databaseId?: string\n ): Promise<boolean>;\n\n}\n\n// =============================================================================\n// REALTIME INTERFACES\n// =============================================================================\n\n/**\n * Configuration for subscribing to a collection\n */\nexport interface CollectionSubscriptionConfig {\n clientId: string;\n path: string;\n filter?: unknown;\n orderBy?: string;\n order?: \"desc\" | \"asc\";\n limit?: number;\n startAfter?: unknown;\n databaseId?: string;\n searchString?: string;\n}\n\n/**\n * Configuration for subscribing to a single entity\n */\nexport interface SingleSubscriptionConfig {\n clientId: string;\n path: string;\n id: string | number;\n}\n\n/**\n * Opt-in retention for one set of broadcast channels.\n *\n * Retention is configured on the server and nowhere else. A channel is created\n * by whoever names it, so letting a client ask for its own history depth would\n * let any visitor commit the backend to unbounded storage; and presence-only or\n * notification-only channels — the overwhelming majority — must not pay for a\n * feature they never use. With no rules configured nothing is written, no table\n * is created, and broadcast behaves exactly as it did before history existed.\n */\nexport interface ChannelRetentionRule {\n /**\n * Channel name to match. Either exact (`\"doc:42\"`) or a trailing-`*` prefix\n * (`\"doc:*\"`). Deliberately not a full glob or RegExp: this decides what\n * gets written to disk, and a rule whose blast radius is not obvious at a\n * glance is the wrong shape for that.\n */\n match: string;\n /** Keep at most this many of the most recent messages per channel. */\n limit?: number;\n /**\n * Keep messages for at most this long. Accepts a millisecond count or a\n * short duration string (`\"30s\"`, `\"15m\"`, `\"24h\"`, `\"7d\"`).\n */\n ttl?: number | string;\n}\n\n/**\n * Server-side realtime options.\n *\n * The channel bus contract and its config live in `./channel_bus` so that a\n * transport shipped as its own package depends on the contract alone.\n */\nexport interface RealtimeChannelsConfig {\n /**\n * Retention rules, most specific first — the first match wins. Omitted or\n * empty means no channel retains anything.\n */\n channels?: ChannelRetentionRule[];\n /**\n * How channel broadcast and presence reach other backend instances.\n * Defaults to `{ type: \"memory\" }` — i.e. they don't.\n */\n bus?: ChannelBusSetting;\n}\n\n/**\n * Abstract realtime provider interface.\n * Handles real-time subscriptions and notifications for entity changes.\n */\nexport interface RealtimeProvider {\n /**\n * Subscribe to collection changes\n */\n subscribeToCollection(\n subscriptionId: string,\n config: CollectionSubscriptionConfig,\n callback?: (rows: Record<string, unknown>[]) => void\n ): void;\n\n /**\n * Subscribe to single entity changes\n */\n subscribeToOne(\n subscriptionId: string,\n config: SingleSubscriptionConfig,\n callback?: (row: Record<string, unknown> | null) => void\n ): void;\n\n /**\n * Unsubscribe from a subscription\n */\n unsubscribe(subscriptionId: string): void;\n\n /**\n * Notify all relevant subscribers of a entity update\n */\n notifyUpdate(\n path: string,\n id: string,\n row: Record<string, unknown> | null,\n databaseId?: string\n ): Promise<void>;\n\n /**\n * Called when the HTTP server is ready and listening.\n * Useful for providers that need the server address for callbacks.\n */\n onServerReady?(serverInfo: { port: number; hostname?: string }): void;\n\n /**\n * Gracefully shut down the realtime provider.\n * Called during server shutdown to clean up resources.\n */\n destroy?(): Promise<void>;\n\n /**\n * Stop the internal LISTEN client (e.g., PostgreSQL LISTEN/NOTIFY).\n * Called during graceful shutdown before closing database connections.\n */\n stopListening?(): Promise<void>;\n}\n\n// =============================================================================\n// COLLECTION REGISTRY INTERFACES\n// =============================================================================\n\n/**\n * Abstract collection registry interface.\n * Manages registration and lookup of entity collections.\n */\nexport interface CollectionRegistryInterface {\n /**\n * Register a collection\n */\n register(collection: CollectionConfig): void;\n\n /**\n * Get a collection by its path\n */\n getCollectionByPath(path: string): CollectionConfig | undefined;\n\n /**\n * Get all registered collections\n */\n getCollections(): CollectionConfig[];\n\n /**\n * Get the currently registered global callbacks, if any.\n */\n getGlobalCallbacks(): any | undefined;\n}\n\n// =============================================================================\n// DATA TRANSFORMER INTERFACES\n// =============================================================================\n\n/**\n * Abstract data transformer interface.\n * Handles serialization/deserialization between frontend and database formats.\n */\nexport interface DataTransformer {\n /**\n * Transform entity data for storage in the database\n */\n serializeToDatabase<M extends Record<string, unknown>>(\n entity: M,\n collection: CollectionConfig\n ): Record<string, unknown>;\n\n /**\n * Transform database data back to entity format\n */\n deserializeFromDatabase<M extends Record<string, unknown>>(\n data: Record<string, unknown>,\n collection: CollectionConfig\n ): Promise<M>;\n}\n\n// =============================================================================\n// DATABASE ADMIN — CAPABILITY-SPECIFIC INTERFACES (1.3)\n// =============================================================================\n\n/**\n * Administrative operations for SQL-based databases (PostgreSQL, MySQL, etc.).\n * Used by the SQL Editor, RLS Editor, and schema browser.\n *\n * @group Admin\n */\nexport interface SQLAdmin {\n /**\n * Execute raw SQL against the database.\n */\n executeSql(sql: string, options?: { database?: string; role?: string; params?: unknown[] }): Promise<Record<string, unknown>[]>;\n\n /**\n * Fetch the available databases on the server.\n */\n fetchAvailableDatabases?(): Promise<string[]>;\n\n /**\n * Fetch the available *native PostgreSQL* database roles (from `pg_roles`).\n *\n * These are connection-level roles — what the SQL editor can `SET ROLE` to,\n * and what `SecurityRule.pgRoles` targets. They are NOT application roles;\n * for those use {@link fetchApplicationRoles}.\n */\n fetchAvailableRoles?(): Promise<string[]>;\n\n /**\n * Fetch the *application-level* roles in use in this project.\n *\n * These are the strings stored on the users table's `roles` column and\n * exposed to policies as `auth.roles()` — what `SecurityRule.roles`\n * matches against. Distinct from {@link fetchAvailableRoles}; the two are\n * not interchangeable.\n */\n fetchApplicationRoles?(): Promise<string[]>;\n\n /**\n * Fetch the current database name.\n */\n fetchCurrentDatabase?(): Promise<string | undefined>;\n}\n\n/**\n * Administrative operations for document-based databases (MongoDB, Firestore, etc.).\n * Used by future document administration tools.\n *\n * @group Admin\n */\nexport interface DocumentAdmin {\n /**\n * Execute an aggregation pipeline or equivalent query.\n */\n executeAggregate?(pipeline: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;\n\n /**\n * Fetch statistics for a collection (document count, size, etc.).\n */\n fetchCollectionStats?(collectionName: string): Promise<{ count: number; sizeBytes?: number }>;\n}\n\n/**\n * Administrative operations for schema management.\n * Shared across SQL and document databases.\n *\n * @group Admin\n */\nexport interface SchemaAdmin {\n /**\n * Fetch database tables/collections not yet mapped to a Rebase collection.\n */\n fetchUnmappedTables?(mappedPaths?: string[]): Promise<string[]>;\n\n /**\n * Fetch column/field metadata for a single table/collection.\n * The return type is generic — SQL backends return TableMetadata,\n * document backends may return a different shape.\n */\n fetchTableMetadata?(tableName: string): Promise<unknown>;\n}\n\n/**\n * Metadata for a database branch.\n * @group Admin\n */\nexport interface BranchInfo {\n /** Branch name (without prefix). */\n name: string;\n /** The database this branch was created from. */\n parentDatabase: string;\n /** When the branch was created. */\n createdAt: Date;\n /** Size in bytes, if available from the server. */\n sizeBytes?: number;\n}\n\n/**\n * Administrative operations for database branching.\n * Allows creating isolated database copies for development/preview workflows.\n *\n * @group Admin\n */\nexport interface BranchAdmin {\n /** Create a new branch (database copy) from the current or specified source database. */\n createBranch(name: string, options?: { source?: string }): Promise<BranchInfo>;\n\n /** Delete a branch database. Cannot delete the main/default database. */\n deleteBranch(name: string): Promise<void>;\n\n /** List all branches (databases that were created via branching). */\n listBranches(): Promise<BranchInfo[]>;\n\n /** Get info about a specific branch. */\n getBranchInfo(name: string): Promise<BranchInfo | undefined>;\n}\n\n/**\n * Union type for all admin capabilities.\n * A backend may implement any combination of these interfaces.\n *\n * Use type guards (`isSQLAdmin`, `isDocumentAdmin`, `isSchemaAdmin`, `isBranchAdmin`)\n * to safely narrow the type before calling methods.\n *\n * @group Admin\n */\nexport type DatabaseAdmin = Partial<SQLAdmin> & Partial<DocumentAdmin> & Partial<SchemaAdmin> & Partial<BranchAdmin>;\n\n/**\n * Type guard: does this admin support SQL operations?\n * @group Admin\n */\nexport function isSQLAdmin(admin: DatabaseAdmin | undefined): admin is SQLAdmin {\n return !!admin && typeof (admin as SQLAdmin).executeSql === \"function\";\n}\n\n/**\n * Type guard: does this admin support document operations?\n * @group Admin\n */\nexport function isDocumentAdmin(admin: DatabaseAdmin | undefined): admin is DocumentAdmin {\n return !!admin && (\n typeof (admin as DocumentAdmin).executeAggregate === \"function\" ||\n typeof (admin as DocumentAdmin).fetchCollectionStats === \"function\"\n );\n}\n\n/**\n * Type guard: does this admin support schema management?\n * @group Admin\n */\nexport function isSchemaAdmin(admin: DatabaseAdmin | undefined): admin is SchemaAdmin {\n return !!admin && (\n typeof (admin as SchemaAdmin).fetchUnmappedTables === \"function\" ||\n typeof (admin as SchemaAdmin).fetchTableMetadata === \"function\"\n );\n}\n\n/**\n * Type guard: does this admin support database branching?\n * @group Admin\n */\nexport function isBranchAdmin(admin: DatabaseAdmin | undefined): admin is BranchAdmin {\n return !!admin && typeof (admin as BranchAdmin).createBranch === \"function\";\n}\n\n// =============================================================================\n// LIFECYCLE INTERFACES (1.4)\n// =============================================================================\n\n/**\n * Health check result returned by `healthCheck()`.\n * @group Lifecycle\n */\nexport interface HealthCheckResult {\n /** Whether the backend is healthy and able to serve requests. */\n healthy: boolean;\n /** Round-trip latency to the database in milliseconds. */\n latencyMs: number;\n /** Optional details (e.g., pool stats, replication lag). */\n details?: Record<string, unknown>;\n}\n\n/**\n * Lifecycle contract for backend components that hold resources\n * (database connections, WebSocket pools, timers, etc.).\n *\n * All methods are optional — simple backends (e.g., in-memory) can skip them.\n * @group Lifecycle\n */\nexport interface BackendLifecycle {\n /**\n * Initialize the backend: open connections, run migrations, seed data.\n * Called once during startup. Idempotent.\n */\n initialize?(): Promise<void>;\n\n /**\n * Check whether the backend is healthy and reachable.\n * Should be fast (< 1 s) and safe to call frequently.\n */\n healthCheck?(): Promise<HealthCheckResult>;\n\n /**\n * Gracefully shut down: close connections, flush buffers, cancel timers.\n * After calling `destroy()`, no other methods should be called.\n */\n destroy?(): Promise<void>;\n}\n\n// =============================================================================\n// BACKEND FACTORY INTERFACES\n// =============================================================================\n\n/**\n * Configuration for creating a database backend\n */\nexport interface BackendConfig {\n /**\n * Type of database backend\n */\n type: string;\n\n /**\n * Database connection (implementation-specific)\n */\n connection: unknown;\n\n /**\n * Schema definition (implementation-specific, e.g., Drizzle schema for PostgreSQL)\n */\n schema?: unknown;\n}\n\n/**\n * A complete backend instance with all required services.\n *\n * Now includes optional lifecycle management and admin capabilities.\n */\nexport interface BackendInstance extends BackendLifecycle {\n /**\n * Entity repository for CRUD operations\n */\n entityRepository: DataRepository;\n\n /**\n * Realtime provider for subscriptions\n */\n realtimeProvider: RealtimeProvider;\n\n /**\n * Collection registry\n */\n collectionRegistry: CollectionRegistryInterface;\n\n /**\n * The underlying database connection\n */\n connection: DatabaseConnection;\n\n /**\n * Administrative operations (SQL, schema, documents).\n * What's available depends on the backend type — use type guards\n * (`isSQLAdmin`, `isSchemaAdmin`, etc.) to narrow.\n */\n admin?: DatabaseAdmin;\n}\n\n/**\n * Factory function type for creating backend instances\n */\nexport type BackendFactory<TConfig extends BackendConfig = BackendConfig> =\n (config: TConfig) => BackendInstance;\n\n// =============================================================================\n// BACKEND BOOTSTRAPPER (1.2)\n// =============================================================================\n\n/**\n * A `BackendBootstrapper` encapsulates all driver-specific initialization logic.\n *\n * Instead of hard-coding Postgres setup into `initializeRebaseBackend()`,\n * each database backend provides its own bootstrapper that knows how to:\n * - Create the DataDriver from a config object\n * - Optionally initialize auth tables\n * - Optionally create a realtime service\n * - Mount driver-specific API routes\n *\n * The main `initializeRebaseBackend()` becomes a **coordinator** that iterates\n * registered bootstrappers, calls their hooks, and wires the results together.\n *\n * @group Backend\n *\n * @example\n * ```typescript\n * // Third-party MySQL bootstrapper\n * const mysqlBootstrapper: BackendBootstrapper = {\n * type: \"mysql\",\n * initializeDriver: async (config) => new MySQLDataDriver(config.connection),\n * initializeRealtime: async (config) => new MySQLChangeStreamRealtime(config.connection),\n * };\n *\n * initializeRebaseBackend({\n * ...config,\n * bootstrappers: [postgresBootstrapper, mysqlBootstrapper]\n * });\n * ```\n */\nexport interface BackendBootstrapper {\n /**\n * Which driver type this bootstrapper handles.\n * Must match the `type` field on the driver config object\n * (e.g., `\"postgres\"`, `\"mongodb\"`, `\"mysql\"`).\n */\n type: string;\n\n /**\n * Unique identifier for this bootstrapper instance.\n * Used to register the driver in the driver registry.\n * Defaults to `type` if not set.\n */\n id?: string;\n\n /**\n * Whether this bootstrapper provides the default driver.\n * When true, the coordinator uses this driver as the primary one.\n */\n isDefault?: boolean;\n\n /**\n * Run database migrations for this driver.\n * Called by the coordinator after all drivers are initialized.\n */\n runMigrations?(config: unknown, driverResult: InitializedDriver): Promise<void>;\n\n /**\n * Create a DataDriver from the given config.\n * This is the only **required** method.\n */\n initializeDriver(config: unknown): Promise<InitializedDriver>;\n\n /**\n * Initialize auth tables / services if this driver supports them.\n * Return undefined if auth is not supported by this backend.\n */\n initializeAuth?(config: unknown, driverResult: InitializedDriver): Promise<BootstrappedAuth | undefined>;\n\n /**\n * Initialize history tables / services if this driver supports them.\n * Return undefined if history is not supported by this backend.\n */\n initializeHistory?(config: HistoryConfig, driverResult: InitializedDriver): Promise<{ historyService: unknown } | undefined>;\n\n /**\n * Create a realtime provider for this driver.\n * Return undefined if the driver does not support realtime.\n */\n initializeRealtime?(config: unknown, driverResult: InitializedDriver): Promise<RealtimeProvider | undefined>;\n\n /**\n * Mount any driver-specific HTTP routes (e.g., custom admin endpoints).\n * Called after all drivers are initialized.\n */\n mountRoutes?(app: unknown, basePath: string, driverResult: InitializedDriver): void;\n\n /**\n * Return admin capabilities for this driver.\n */\n getAdmin?(driverResult: InitializedDriver): DatabaseAdmin | undefined;\n\n /**\n * Bring the database's collection tables up to date, additively.\n *\n * Optional because it is only meaningful for schema-ful drivers. A managed\n * runtime boots a compiled project against a database it has never seen; auth\n * tables are ensured on boot but collection tables were created by nothing,\n * so every data request answered 500 on a missing relation. The CLI's `db\n * push` cannot fill the gap — it needs Atlas, and the runtime image ships no\n * CLI.\n *\n * Implementations MUST be additive-only: create missing tables, columns and\n * enum types, and never drop, narrow or rewrite anything. This runs\n * unattended against live customer data with nobody reading a diff, so the\n * destructive half stays a deliberate migration.\n */\n ensureCollectionSchema?(\n collections: unknown[],\n driverResult: InitializedDriver,\n log?: (message: string) => void\n ): Promise<{ applied: number }>;\n\n /**\n * Apply the collections' row-level-security policies, additively and\n * idempotently — the companion to {@link ensureCollectionSchema}.\n *\n * That method creates the tables; a table with RLS disabled and no policies\n * is not servable, because authenticated requests run as a restricted role:\n * a read with no `SELECT` policy returns nothing (a public collection\n * answers 401) and a write with no `INSERT`/`UPDATE` policy is denied. The\n * `db push` CLI applies these from the same collections, but it cannot reach\n * a managed tenant's in-cluster database — the runtime, already connected,\n * is the only thing that can.\n *\n * MUST be idempotent (re-run on every boot) and MUST NOT be destructive.\n * Runs after auth initialization, because the generated policies call the\n * `auth.*` helper functions and `CREATE POLICY` validates they exist.\n */\n ensureCollectionPolicies?(\n collections: unknown[],\n driverResult: InitializedDriver,\n log?: (message: string) => void\n ): Promise<{ applied: number }>;\n\n /**\n * Initialize WebSocket server for realtime operations.\n */\n initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: import(\"../controllers/data_driver\").DataDriver, config?: unknown, authAdapter?: AuthAdapter): Promise<void> | void;\n}\n\n/**\n * Result of `BackendBootstrapper.initializeDriver()`.\n * @group Backend\n */\nexport interface InitializedDriver {\n /** The DataDriver instance, ready for use. */\n driver: import(\"../controllers/data_driver\").DataDriver;\n\n /** The realtime service, if the driver created one during init. */\n realtimeProvider?: RealtimeProvider;\n\n /** A collection registry to register schema / tables into. */\n collectionRegistry?: CollectionRegistryInterface;\n\n /**\n * Collections the driver derived from the live database schema.\n *\n * Set by drivers that introspect in `baas` mode; the server serves these\n * instead of collections loaded from config files.\n */\n collections?: import(\"./collections\").CollectionConfig[];\n\n /** The underlying database connection (for lifecycle management). */\n connection?: DatabaseConnection;\n\n /**\n * Opaque handle that the bootstrapper can use in subsequent hooks\n * (e.g., `initializeAuth`, `mountRoutes`) to access driver internals.\n * Not used by the coordinator.\n */\n internals?: unknown;\n}\n\n/**\n * Result of `BackendBootstrapper.initializeAuth()`.\n * @group Backend\n */\nexport interface BootstrappedAuth {\n /** User management service. */\n userService: unknown;\n /** Role management service (optional, roles are now simple strings). */\n roleService?: unknown;\n /** Email service (optional). */\n emailService?: unknown;\n /** Combined Auth Repository for unified token and user management. */\n authRepository?: unknown;\n /**\n * Whether the auth schema in the database is one this runtime can serve.\n *\n * Folded into `healthCheck()` so a schema mismatch shows up as a degraded\n * health response. Without it, a server whose auth is entirely broken still\n * reports healthy — the database connection it probes is fine, and the\n * mismatch is only discovered one failed login at a time.\n */\n schemaHealthCheck?(): Promise<AuthSchemaHealth>;\n}\n\n/**\n * Result of {@link BootstrappedAuth.schemaHealthCheck}.\n * @group Lifecycle\n */\nexport interface AuthSchemaHealth {\n /** False when this runtime cannot be trusted to serve auth against this database. */\n healthy: boolean;\n /** Human-readable descriptions of each mismatch found. Empty when healthy. */\n problems: string[];\n /** Auth schema version recorded in the database, when it records one. */\n databaseVersion?: number | null;\n /** Auth schema version this runtime expects. */\n runtimeVersion?: number;\n}\n","import { RealtimeService } from \"./services/realtimeService\";\nimport { PostgresBackendDriver } from \"./PostgresBackendDriver\";\nimport type { DataDriver, DeleteProps, FetchCollectionProps, FetchOneProps, SaveProps, TableMetadata, BranchInfo, AuthAdapter } from \"@rebasepro/types\";\nimport { ANONYMOUS_USER_ID, isSQLAdmin, isSchemaAdmin } from \"@rebasepro/types\";\nimport type { User } from \"@rebasepro/types\";\n\nimport { WebSocketServer, WebSocket } from \"ws\";\nimport { Server } from \"http\";\nimport { inspect } from \"util\";\nimport { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth } from \"@rebasepro/server\";\nimport { logger } from \"@rebasepro/server\";\n\n/** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */\ninterface WsAuthConfig {\n requireAuth?: boolean;\n jwtSecret?: string;\n /**\n * Same static server-to-server secret the HTTP middleware accepts. Without\n * it here, a service key authenticates over HTTP but not over the socket —\n * so any SDK client using one (scripts, cron, server-to-server) connects,\n * fails realtime auth with \"jwt malformed\", and silently gets no events.\n */\n serviceKey?: string;\n}\n\n/**\n * Normalized user identity for WebSocket sessions.\n */\ninterface WsUserIdentity {\n uid: string;\n roles: string[];\n isAdmin: boolean;\n}\n\ninterface ClientSession {\n ws: WebSocket;\n user?: WsUserIdentity;\n authenticated: boolean;\n /** Sliding window message counter for rate limiting */\n messageCount: number;\n messageWindowStart: number;\n}\n\n\n/** Maximum messages per client per window */\nconst WS_RATE_LIMIT = 2000;\n/** Rate limit window in milliseconds (60 seconds) */\nconst WS_RATE_WINDOW_MS = 60_000;\n\n/** Admin-only WebSocket message types */\nconst ADMIN_ONLY_TYPES = new Set([\n \"EXECUTE_SQL\",\n \"FETCH_DATABASES\",\n \"FETCH_ROLES\",\n \"FETCH_UNMAPPED_TABLES\",\n \"FETCH_TABLE_METADATA\",\n \"FETCH_CURRENT_DATABASE\",\n \"CREATE_BRANCH\",\n \"DELETE_BRANCH\",\n \"LIST_BRANCHES\"\n]);\n\n/**\n * Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).\n */\nfunction extractErrorMessage(error: unknown): string {\n if (!error) return \"Unknown error\";\n if (error instanceof Error) {\n if (\"cause\" in error && error.cause) {\n return extractErrorMessage(error.cause);\n }\n return error.message;\n }\n if (typeof error === \"object\" && \"message\" in error && typeof (error as { message: unknown }).message === \"string\") {\n return (error as { message: string }).message;\n }\n return String(error);\n}\n\n/**\n * Check if the current session belongs to an admin user.\n */\nfunction isAdminSession(session: ClientSession | undefined): boolean {\n if (!session?.user) return false;\n // Fast path: new adapter-aware sessions set isAdmin directly\n if (session.user.isAdmin) return true;\n if (!session.user.roles) return false;\n return session.user.roles.some((r) => r === \"admin\");\n}\n\nexport function createPostgresWebSocket(\n server: Server,\n realtimeService: RealtimeService,\n driver: PostgresBackendDriver,\n authConfig?: WsAuthConfig,\n authAdapter?: AuthAdapter\n) {\n // Session map scoped to this factory invocation — prevents stale sessions\n // leaking across hot reloads or multiple factory calls.\n const clientSessions = new Map<string, ClientSession>();\n\n const isProduction = process.env.NODE_ENV === \"production\";\n /** Debug logger that is suppressed in production to prevent PII/data leaks */\n const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };\n const wss = new WebSocketServer({ server });\n\n // Handle errors on the WSS so that EADDRINUSE from the underlying HTTP\n // server doesn't surface as an unhandled 'error' event and crash the\n // process. The dev-mode `listenWithPortRetry` utility handles retry\n // logic on the HTTP server side — we just need the WSS not to throw.\n wss.on(\"error\", (err: NodeJS.ErrnoException) => {\n if (err.code === \"EADDRINUSE\") {\n // Silently absorbed — listenWithPortRetry will retry the next port\n return;\n }\n logger.error(\"❌ [WebSocket Server] Error\", { error: err });\n });\n\n // The same predicate the HTTP data routes use, from the same function —\n // this socket is the other enforcement point for one product decision, and\n // while it computed the answer itself it computed a different one. See\n // `resolveRequireAuth` for what its local copy got wrong and why a `false`\n // here grants access rather than skipping a check.\n const requireAuth = !!authAdapter || resolveRequireAuth(authConfig as never);\n\n if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) {\n logger.warn(\n \"🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or \" +\n \"serviceKey is configured — no client can complete AUTH, so every realtime \" +\n \"message will be refused with UNAUTHORIZED.\"\n );\n }\n\n wss.on(\"connection\", (ws) => {\n const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;\n wsDebug(`WebSocket client connected: ${clientId}`);\n\n // Initialize client session\n clientSessions.set(clientId, { ws,\nauthenticated: !requireAuth,\nmessageCount: 0,\nmessageWindowStart: Date.now() });\n realtimeService.addClient(clientId, ws);\n\n ws.on(\"close\", () => {\n wsDebug(`WebSocket client disconnected: ${clientId}`);\n clientSessions.delete(clientId);\n });\n\n // Route all messages through RealtimeService for unified handling\n ws.on(\"message\", async (message) => {\n let requestId: string | undefined;\n try {\n const {\n type,\n payload,\n requestId: reqId\n } = JSON.parse(message.toString());\n requestId = reqId; // Capture requestId for use in catch block\n\n wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : \"\");\n\n // Handle authentication first\n // Helper: send a canonical error frame\n const sendError = (errType: \"ERROR\" | \"AUTH_ERROR\", code: string, msg: string) => {\n ws.send(JSON.stringify({\n type: errType,\n requestId,\n payload: { error: { message: msg,\ncode } }\n }));\n };\n\n if (type === \"AUTHENTICATE\") {\n const { token } = payload || {};\n if (!token) {\n sendError(\"AUTH_ERROR\", \"INVALID_INPUT\", \"Token is required\");\n return;\n }\n\n // Use the auth adapter when available (custom auth, Clerk, etc.)\n // Fall back to JWT extraction otherwise.\n let verifiedUser: WsUserIdentity | null = null;\n\n if (authAdapter) {\n try {\n const adapterUser = authAdapter.verifyToken\n ? await authAdapter.verifyToken(token)\n : await authAdapter.verifyRequest(new Request(\"http://localhost/_ws_auth\", {\n headers: { Authorization: `Bearer ${token}` }\n }));\n\n if (adapterUser) {\n verifiedUser = {\n uid: adapterUser.uid,\n roles: adapterUser.roles,\n isAdmin: adapterUser.isAdmin\n };\n }\n } catch {\n // Adapter threw — treat as invalid token\n }\n } else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) {\n // Service key: a static secret, not a JWT. Checked\n // before verification, mirroring the HTTP middleware —\n // verifying it as a JWT can only ever fail.\n verifiedUser = { uid: \"service\", roles: [\"admin\"], isAdmin: true };\n } else {\n // Standard JWT path\n const jwtPayload = extractUserFromToken(token);\n if (jwtPayload) {\n verifiedUser = {\n uid: jwtPayload.uid,\n roles: jwtPayload.roles ?? [],\n isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === \"admin\")\n };\n }\n }\n\n if (verifiedUser) {\n const session = clientSessions.get(clientId);\n if (session) {\n session.user = verifiedUser;\n session.authenticated = true;\n }\n wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);\n ws.send(JSON.stringify({\n type: \"AUTH_SUCCESS\",\n requestId,\n payload: { uid: verifiedUser.uid,\nroles: verifiedUser.roles }\n }));\n wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);\n } else {\n wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);\n sendError(\"AUTH_ERROR\", \"INVALID_TOKEN\", \"Invalid or expired token\");\n }\n return;\n }\n\n // Check authentication for protected operations\n if (requireAuth) {\n const session = clientSessions.get(clientId);\n if (!session?.authenticated) {\n sendError(\"ERROR\", \"UNAUTHORIZED\", \"Authentication required\");\n return;\n }\n }\n\n // Rate limiting: reject if client exceeds message limit\n {\n const session = clientSessions.get(clientId);\n if (session) {\n const now = Date.now();\n if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {\n session.messageCount = 0;\n session.messageWindowStart = now;\n }\n session.messageCount++;\n if (session.messageCount > WS_RATE_LIMIT) {\n sendError(\"ERROR\", \"RATE_LIMITED\", \"Too many requests. Please slow down.\");\n return;\n }\n }\n }\n\n // Admin-only operations require admin role\n if (ADMIN_ONLY_TYPES.has(type)) {\n const session = clientSessions.get(clientId);\n if (!isAdminSession(session)) {\n sendError(\"ERROR\", \"FORBIDDEN\", \"Admin access required for this operation\");\n return;\n }\n }\n\n // Helper to get correctly scoped delegate for the current request\n const getScopedDelegate = async (): Promise<DataDriver> => {\n const session = clientSessions.get(clientId);\n // Check if the driver supports RLS-scoped delegates\n if (typeof driver.withAuth === \"function\") {\n try {\n const userForAuth: User = session?.user\n ? {\n uid: session.user.uid,\n displayName: null,\n email: null,\n photoURL: null,\n providerId: \"websocket\",\n isAnonymous: false,\n roles: session.user.roles ?? []\n }\n : {\n uid: ANONYMOUS_USER_ID,\n displayName: null,\n email: null,\n photoURL: null,\n providerId: \"websocket\",\n isAnonymous: true,\n roles: [\"anon\"]\n };\n return await driver.withAuth(userForAuth);\n } catch (e) {\n logger.error(\"Failed to create RLS scoped delegate for WS request\", { error: e });\n throw new Error(\"Internal authentication error\");\n }\n }\n return driver;\n };\n\n switch (type) {\n case \"FETCH_COLLECTION\": {\n wsDebug(\"📋 [WebSocket Server] Processing FETCH_COLLECTION request\");\n const request: FetchCollectionProps = payload;\n const delegate = await getScopedDelegate();\n const rows = await delegate.fetchCollection(request);\n wsDebug(\"📋 [WebSocket Server] FETCH_COLLECTION result - rows count:\", rows.length);\n const response = {\n type: \"FETCH_COLLECTION_SUCCESS\",\n payload: { rows },\n requestId\n };\n wsDebug(\"📋 [WebSocket Server] Sending FETCH_COLLECTION_SUCCESS response\");\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"FETCH_ONE\": {\n wsDebug(\"📄 [WebSocket Server] Processing FETCH_ENTITY request\");\n const request: FetchOneProps = payload;\n const delegate = await getScopedDelegate();\n const row = await delegate.fetchOne(request);\n wsDebug(\"📄 [WebSocket Server] FETCH_ENTITY result:\", row);\n const response = {\n type: \"FETCH_ONE_SUCCESS\",\n payload: { row: row ?? null },\n requestId\n };\n wsDebug(\"📄 [WebSocket Server] Sending FETCH_ENTITY_SUCCESS response\");\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"SAVE\": {\n wsDebug(\"💾 [WebSocket Server] Processing SAVE_ENTITY request\");\n const request: SaveProps = payload;\n wsDebug(\"💾 [WebSocket Server] Saving row with request:\", inspect(request, { depth: null,\ncolors: true }));\n const delegate = await getScopedDelegate();\n const row = await delegate.save(request);\n wsDebug(\"💾 [WebSocket Server] SAVE_ENTITY result:\", inspect(row, { depth: null,\ncolors: true }));\n const response = {\n type: \"SAVE_SUCCESS\",\n payload: { row },\n requestId\n };\n wsDebug(\"💾 [WebSocket Server] Sending SAVE_ENTITY_SUCCESS response\");\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"DELETE\": {\n wsDebug(\"🗑️ [WebSocket Server] Processing DELETE_ENTITY request\");\n const request: DeleteProps = payload;\n wsDebug(\"🗑️ [WebSocket Server] Deleting row:\", request.row);\n const delegate = await getScopedDelegate();\n await delegate.delete(request);\n wsDebug(\"🗑️ [WebSocket Server] DELETE_ENTITY completed successfully\");\n const response = {\n type: \"DELETE_SUCCESS\",\n payload: { success: true },\n requestId\n };\n wsDebug(\"🗑️ [WebSocket Server] Sending DELETE_ENTITY_SUCCESS response\");\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"CHECK_UNIQUE_FIELD\": {\n wsDebug(\"🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request\");\n const {\n path,\n name,\n value,\n id,\n collection\n } = payload;\n const delegate = await getScopedDelegate();\n const isUnique = await delegate.checkUniqueField(path, name, value, id, collection);\n wsDebug(\"🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:\", isUnique);\n const response = {\n type: \"CHECK_UNIQUE_FIELD_SUCCESS\",\n payload: { isUnique },\n requestId\n };\n wsDebug(\"🔍 [WebSocket Server] Sending CHECK_UNIQUE_FIELD_SUCCESS response\");\n ws.send(JSON.stringify(response));\n }\n break;\n\n\n case \"COUNT\": {\n const request: FetchCollectionProps = payload;\n const delegate = await getScopedDelegate();\n const count = await delegate.count!(request);\n const response = {\n type: \"COUNT_SUCCESS\",\n payload: { count },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"EXECUTE_SQL\": {\n const { sql, options } = payload;\n try {\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n if (!isSQLAdmin(admin)) {\n sendError(\"ERROR\", \"NOT_SUPPORTED\", \"SQL execution is not available for this driver.\");\n break;\n }\n const result = await admin.executeSql(sql, options);\n if (process.env.NODE_ENV !== \"production\") {\n wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : \"non-array\"} rows.`);\n }\n const auditSession = clientSessions.get(clientId);\n console.log(\"[SQL Audit] WebSocket SQL execution\", JSON.stringify({\n sql: typeof sql === \"string\" ? sql.substring(0, 500) : sql,\n options,\n resultRows: Array.isArray(result) ? result.length : \"unknown\",\n uid: auditSession?.user?.uid ?? \"unknown\",\n roles: auditSession?.user?.roles ?? [],\n isAdmin: auditSession?.user?.isAdmin ?? false,\n }));\n const response = {\n type: \"EXECUTE_SQL_SUCCESS\",\n payload: { result },\n requestId\n };\n ws.send(JSON.stringify(response));\n } catch (sqlError: unknown) {\n // This is a query execution error (e.g., syntax error, permission denied).\n // We return it cleanly to the client without logging a server stack trace.\n const errMsg = extractErrorMessage(sqlError);\n sendError(\"ERROR\", \"SQL_ERROR\", errMsg);\n }\n }\n break;\n\n case \"FETCH_DATABASES\": {\n wsDebug(\"📚 [WebSocket Server] Processing FETCH_DATABASES request\");\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n let databases: string[] = [];\n if (isSQLAdmin(admin) && admin.fetchAvailableDatabases) {\n databases = await admin.fetchAvailableDatabases();\n }\n wsDebug(`📚 [WebSocket Server] Fetched ${databases.length} databases.`);\n const response = {\n type: \"FETCH_DATABASES_SUCCESS\",\n payload: { databases },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"FETCH_ROLES\": {\n wsDebug(\"👤 [WebSocket Server] Processing FETCH_ROLES request\");\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n let roles: string[] = [];\n if (isSQLAdmin(admin) && admin.fetchAvailableRoles) {\n roles = await admin.fetchAvailableRoles();\n }\n wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} roles.`);\n const response = {\n type: \"FETCH_ROLES_SUCCESS\",\n payload: { roles },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"FETCH_APPLICATION_ROLES\": {\n wsDebug(\"👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request\");\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n let roles: string[] = [];\n if (isSQLAdmin(admin) && admin.fetchApplicationRoles) {\n roles = await admin.fetchApplicationRoles();\n }\n wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);\n const response = {\n type: \"FETCH_APPLICATION_ROLES_SUCCESS\",\n payload: { roles },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"FETCH_CURRENT_DATABASE\": {\n wsDebug(\"📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request\");\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n let database: string | undefined = undefined;\n if (isSQLAdmin(admin) && admin.fetchCurrentDatabase) {\n database = await admin.fetchCurrentDatabase();\n }\n const response = {\n type: \"FETCH_CURRENT_DATABASE_SUCCESS\",\n payload: { database },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"FETCH_UNMAPPED_TABLES\": {\n wsDebug(\"📋 [WebSocket Server] Processing FETCH_UNMAPPED_TABLES request\");\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n let tables: string[] = [];\n if (isSchemaAdmin(admin) && admin.fetchUnmappedTables) {\n tables = await admin.fetchUnmappedTables(payload?.mappedPaths);\n }\n wsDebug(`📋 [WebSocket Server] Fetched ${tables.length} unmapped tables.`);\n const response = {\n type: \"FETCH_UNMAPPED_TABLES_SUCCESS\",\n payload: { tables },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"FETCH_TABLE_METADATA\": {\n wsDebug(\"📋 [WebSocket Server] Processing FETCH_TABLE_METADATA request\");\n const { tableName } = payload;\n const delegate = await getScopedDelegate();\n const admin = delegate.admin;\n let metadata: TableMetadata | undefined;\n if (isSchemaAdmin(admin) && admin.fetchTableMetadata) {\n metadata = await admin.fetchTableMetadata(tableName) as TableMetadata;\n }\n wsDebug(`📋 [WebSocket Server] Fetched metadata for table '${tableName}'. (${metadata?.columns?.length ?? 0} columns)`);\n const response = {\n type: \"FETCH_TABLE_METADATA_SUCCESS\",\n payload: { metadata },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"CREATE_BRANCH\": {\n wsDebug(\"🌿 [WebSocket Server] Processing CREATE_BRANCH request\");\n const { name, options } = payload;\n const delegate = await getScopedDelegate();\n if (!delegate.admin?.createBranch) {\n sendError(\"ERROR\", \"NOT_SUPPORTED\", \"Database branching is not available. Configure adminConnectionString.\");\n break;\n }\n const branch: BranchInfo = await delegate.admin.createBranch(name, options);\n wsDebug(`🌿 [WebSocket Server] Branch created: ${branch.name}`);\n const response = {\n type: \"CREATE_BRANCH_SUCCESS\",\n payload: { branch },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"DELETE_BRANCH\": {\n wsDebug(\"🗑️ [WebSocket Server] Processing DELETE_BRANCH request\");\n const { name: branchName } = payload;\n const delegate = await getScopedDelegate();\n if (!delegate.admin?.deleteBranch) {\n sendError(\"ERROR\", \"NOT_SUPPORTED\", \"Database branching is not available.\");\n break;\n }\n await delegate.admin.deleteBranch(branchName);\n wsDebug(`🗑️ [WebSocket Server] Branch deleted: ${branchName}`);\n const response = {\n type: \"DELETE_BRANCH_SUCCESS\",\n payload: { success: true },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n case \"LIST_BRANCHES\": {\n wsDebug(\"🌿 [WebSocket Server] Processing LIST_BRANCHES request\");\n const delegate = await getScopedDelegate();\n let branches: BranchInfo[] = [];\n if (delegate.admin?.listBranches) {\n branches = await delegate.admin.listBranches();\n }\n wsDebug(`🌿 [WebSocket Server] Listed ${branches.length} branches.`);\n const response = {\n type: \"LIST_BRANCHES_SUCCESS\",\n payload: { branches },\n requestId\n };\n ws.send(JSON.stringify(response));\n }\n break;\n\n // Route subscription messages, broadcast channels, and presence to RealtimeService\n case \"subscribe_collection\":\n case \"subscribe_one\":\n case \"unsubscribe\":\n case \"join_channel\":\n case \"leave_channel\":\n case \"broadcast\":\n case \"presence_track\":\n case \"presence_untrack\":\n case \"presence_state\":\n case \"channel_history\": {\n wsDebug(\"🔄 [WebSocket Server] Routing realtime message to RealtimeService:\", type);\n // Attach auth context from the WS session so RLS-aware refetches work\n const session = clientSessions.get(clientId);\n const authContext = session?.user\n ? { uid: session.user.uid,\nroles: session.user.roles ?? [] }\n : { uid: ANONYMOUS_USER_ID,\nroles: [\"anon\"] };\n // Let RealtimeService handle these messages\n await realtimeService.handleClientMessage(clientId, {\n type,\n payload,\n subscriptionId: payload?.subscriptionId\n }, authContext);\n break;\n }\n\n default:\n logger.error(\"❌ [WebSocket Server] Unknown message type\", { detail: type });\n }\n } catch (error: unknown) {\n logger.error(\"💥 [WebSocket Server] Error handling message\", { error: error });\n if (error instanceof Error) {\n logger.error(\"Stack trace\", { detail: error.stack });\n }\n // Unwrap the cause chain: a Drizzle failure reports itself as\n // \"Failed query: <sql> params:\", which tells the user nothing and\n // echoes the statement back at them. The reason is in the cause.\n const errorMessage = process.env.NODE_ENV === \"production\"\n ? \"An unexpected error occurred\"\n : extractErrorMessage(error);\n const errorResponse = {\n type: \"ERROR\",\n requestId,\n payload: {\n error: {\n message: errorMessage,\n code: \"INTERNAL_ERROR\"\n }\n }\n };\n ws.send(JSON.stringify(errorResponse));\n }\n });\n });\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.12.1-canary.gcfa333c",
4
+ "version": "0.12.1-canary.gddbd489",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -47,11 +47,11 @@
47
47
  "execa": "^9.6.1",
48
48
  "pg": "^8.22.0",
49
49
  "ws": "^8.21.1",
50
- "@rebasepro/codegen": "0.12.1-canary.gcfa333c",
51
- "@rebasepro/common": "0.12.1-canary.gcfa333c",
52
- "@rebasepro/server": "0.12.1-canary.gcfa333c",
53
- "@rebasepro/utils": "0.12.1-canary.gcfa333c",
54
- "@rebasepro/types": "0.12.1-canary.gcfa333c"
50
+ "@rebasepro/codegen": "0.12.1-canary.gddbd489",
51
+ "@rebasepro/server": "0.12.1-canary.gddbd489",
52
+ "@rebasepro/common": "0.12.1-canary.gddbd489",
53
+ "@rebasepro/utils": "0.12.1-canary.gddbd489",
54
+ "@rebasepro/types": "0.12.1-canary.gddbd489"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@hono/node-server": "^2.0.12",
@@ -36,9 +36,14 @@ export function createPostgresAdapter(pgConfig: PostgresDriverConfig): DatabaseA
36
36
  return undefined;
37
37
  },
38
38
 
39
- initializeWebsockets(server, realtimeService, driver, config) {
39
+ // `adapter` is forwarded for the same reason the schema hooks below are:
40
+ // dropping an argument here is invisible at the call site and silent at
41
+ // runtime. This one decided whether the realtime socket authenticates at
42
+ // all — without it, a server whose auth comes from an AuthAdapter fell
43
+ // back to "is a jwtSecret set?", answered no, and admitted every client.
44
+ initializeWebsockets(server, realtimeService, driver, config, adapter) {
40
45
  if (bootstrapper.initializeWebsockets) {
41
- return bootstrapper.initializeWebsockets(server, realtimeService, driver, config);
46
+ return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter);
42
47
  }
43
48
  },
44
49
 
@@ -1025,12 +1025,16 @@ export class PostgresBackendDriver implements DataDriver {
1025
1025
  path,
1026
1026
  collection,
1027
1027
  filter,
1028
+ logical,
1028
1029
  searchString
1029
1030
  }: FetchCollectionProps<M>): Promise<number> {
1030
1031
  return this.dataService.count(
1031
1032
  path,
1032
1033
  {
1033
1034
  filter,
1035
+ // Counted as well as filtered, or `meta.total` describes a
1036
+ // different set of rows from the `data` beside it.
1037
+ logical,
1034
1038
  searchString
1035
1039
  }
1036
1040
  );
@@ -104,6 +104,39 @@ export interface PostgresDriverInternals {
104
104
  import { isEconnrefused } from "./cli-errors";
105
105
  import { classifyConnectFailure } from "./utils/pg-error-utils";
106
106
 
107
+ /**
108
+ * Which table name the boot-time drift check should look for, for one collection.
109
+ *
110
+ * A declared `table` IS the table name, not a hint to be second-guessed. This
111
+ * used to ask the registry whether it had indexed the declared name and fall
112
+ * back to the SLUG when it had not — but "the registry does not know this table"
113
+ * is exactly the condition the drift check exists to report, so the fallback
114
+ * fired precisely when it was most harmful.
115
+ *
116
+ * A collection with `slug: "usage-daily", table: "usage_daily"` was reported as
117
+ * missing table `usage-daily`: a name that does not exist, should never exist,
118
+ * and that nobody can find by looking. Worse, the remediation the caller prints
119
+ * says to run `rebase db push` — which would then CREATE that invented table
120
+ * beside the correct one, the same "second copy" hazard the misplaced-schema
121
+ * branch further down exists to prevent. Seen in production, where a correctly
122
+ * migrated database reported drift on every boot.
123
+ *
124
+ * The slug is used only when nothing was declared, which is the config shape
125
+ * where the slug genuinely is the table name.
126
+ *
127
+ * Exported for its own test: the caller needs a live pool and a real database,
128
+ * and this is the part that was wrong.
129
+ */
130
+ export function resolveDriftCheckName(
131
+ col: CollectionConfig,
132
+ registeredTableNames: string[]
133
+ ): string {
134
+ const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
135
+ return declaredTable
136
+ ?? registeredTableNames.find((k) => k === col.slug)
137
+ ?? col.slug;
138
+ }
139
+
107
140
  /**
108
141
  * Default PostgreSQL bootstrapper.
109
142
  *
@@ -524,22 +557,28 @@ table: link.table });
524
557
  try {
525
558
  const registeredCollections = registry.getCollections();
526
559
  if (registeredCollections.length > 0) {
527
- const schemasToCheck = Array.from(new Set(
528
- registeredCollections.map(c => "schema" in c && c.schema ? c.schema : "public")
529
- ));
530
- const schemasList = schemasToCheck.map(s => `'${s}'`).join(",");
560
+ // Deliberately unfiltered by schema: a table that is missing
561
+ // from where the collection says it lives is very often
562
+ // sitting in another schema entirely (see the misplaced-table
563
+ // report below), and knowing *which* is the whole answer.
531
564
  const result = await schemaAwareDb.execute(sql.raw(`
532
565
  SELECT table_name, table_schema
533
566
  FROM information_schema.tables
534
- WHERE table_schema IN (${schemasList})
567
+ WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
535
568
  AND table_type = 'BASE TABLE'
536
569
  `));
570
+ const tablesByName = new Map<string, string[]>();
571
+ for (const row of result.rows as Array<{ table_name: string; table_schema: string }>) {
572
+ const schemas = tablesByName.get(row.table_name) ?? [];
573
+ schemas.push(row.table_schema);
574
+ tablesByName.set(row.table_name, schemas);
575
+ }
537
576
  const dbTables = new Set(
538
577
  (result.rows as Array<{ table_name: string; table_schema: string }>).map(r =>
539
578
  r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
540
579
  )
541
580
  );
542
- const missing: Array<{ slug: string; table: string }> = [];
581
+ const missing: Array<{ slug: string; table: string; foundIn: string[] }> = [];
543
582
  for (const col of registeredCollections) {
544
583
  // Auth owns its table and creates it later in this same
545
584
  // boot (initializeAuth → ensureAuthTablesExist), so it is
@@ -550,30 +589,46 @@ table: link.table });
550
589
  if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
551
590
 
552
591
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
553
- const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
554
- const tableName = registry.hasTableForCollection(
555
- declaredTable ?? col.slug
556
- )
557
- ? (declaredTable ?? col.slug)
558
- : col.slug;
559
- // Resolve the actual table name the registry stored
560
- const resolvedTable = registry.getTableNames().find((k) =>
561
- k === tableName ||
562
- k === col.slug
563
- );
564
- const checkName = resolvedTable ?? tableName;
592
+ const checkName = resolveDriftCheckName(col, registry.getTableNames());
565
593
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
566
594
  if (!dbTables.has(fullCheckName)) {
567
595
  // Report what was actually looked up: an unqualified
568
596
  // "users" sends people hunting for public.users.
569
597
  missing.push({ slug: col.slug,
570
- table: fullCheckName });
598
+ table: fullCheckName,
599
+ foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
571
600
  }
572
601
  }
573
602
  if (missing.length > 0) {
574
603
  const lines = missing.map(
575
- m => ` • collection "${m.slug}" → table "${m.table}"`
604
+ m => ` • collection "${m.slug}" → table "${m.table}"` +
605
+ (m.foundIn.length > 0
606
+ ? ` — but a table of that name exists in ${m.foundIn.map(s => `"${s}"`).join(", ")}`
607
+ : "")
576
608
  );
609
+ // A table that exists under the same name in another
610
+ // schema is not ordinary drift: it is almost always this
611
+ // framework's own `search_path` hazard. Postgres resolves
612
+ // unqualified SQL through `"$user", public`, and this
613
+ // project creates a schema named `rebase` while every
614
+ // template names the database role `rebase` too — so an
615
+ // unqualified CREATE TABLE (a hand-written migration, the
616
+ // SQL editor, drizzle-kit) lands in `rebase`, and the
617
+ // runtime, which now pins `search_path=public`, cannot see
618
+ // it. Say so, because "missing table" sends people to
619
+ // re-run a push that will create a *second* copy.
620
+ const misplaced = missing.filter(m => m.foundIn.length > 0);
621
+ const misplacedHelp = misplaced.length === 0 ? [] : [
622
+ " Those tables exist — in the wrong schema. Unqualified SQL run by a",
623
+ " role whose name matches a schema (`rebase` is both, by default)",
624
+ " resolves through search_path's \"$user\" and creates there. Move them:",
625
+ ...misplaced.map(m =>
626
+ ` ALTER TABLE "${m.foundIn[0]}"."${m.table.split(".").pop()}" SET SCHEMA "${m.table.includes(".") ? m.table.split(".")[0] : "public"}";`
627
+ ),
628
+ " and qualify the SQL that created them, or pin search_path in DATABASE_URL",
629
+ " (`?options=-c%20search_path%3Dpublic`).",
630
+ ""
631
+ ];
577
632
  // This runtime creates collection tables (and their RLS)
578
633
  // at boot unless REBASE_MIGRATE_ON_BOOT=none, so a drift
579
634
  // this late means that step was disabled, could not run,
@@ -587,11 +642,17 @@ table: fullCheckName });
587
642
  "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
588
643
  ...lines,
589
644
  "",
645
+ ...misplacedHelp,
590
646
  " This runtime applies the collection schema at boot unless",
591
647
  " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
592
648
  " log lines above — this drift means that step was off, skipped, or failed.",
593
- " • Managed cloud: redeploy (or restart) with REBASE_MIGRATE_ON_BOOT",
594
- " unset or \"ensure\"; the runtime applies the schema to the tenant DB.",
649
+ " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
650
+ " \"ensure\"; the runtime applies the schema to the tenant DB.",
651
+ " If the log above says the driver does not implement collection-table",
652
+ " creation, THIS driver is too old to do it. A driver is installed from",
653
+ " your bundle's dependencies, not supplied by the platform image, so a",
654
+ " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
655
+ " in your project's package.json and redeploy.",
595
656
  " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
596
657
  " against DATABASE_URL.",
597
658
  ""
@@ -68,10 +68,13 @@ function getColumn(table: RebasePgTable | undefined, ...keys: string[]): RebaseP
68
68
  *
69
69
  * Whitespace goes too: a trailing space survives the fold and reproduces the
70
70
  * problem exactly.
71
+ *
72
+ * Re-exported rather than defined here: `@rebasepro/server` and
73
+ * `@rebasepro/server-mongo` write this column too, and a second copy of this
74
+ * rule is the defect it exists to prevent.
71
75
  */
72
- export function normalizeEmail<T>(email: T): T | string {
73
- return typeof email === "string" ? email.trim().toLowerCase() : email;
74
- }
76
+ import { normalizeEmail } from "@rebasepro/common";
77
+ export { normalizeEmail };
75
78
 
76
79
  /**
77
80
  * PostgreSQL implementation of UserRepository.
@@ -100,6 +100,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
100
100
  "--out": String,
101
101
  "--exclude-schema": [String],
102
102
  "--no-owner": Boolean,
103
+ "--enable-row-security": Boolean,
104
+ "--row-security-role": String,
103
105
  "-o": "--out"
104
106
  },
105
107
  { argv: rawArgs.slice(2), permissive: true }
@@ -129,6 +131,23 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
129
131
  }
130
132
  logger.info(chalk.gray(` Using pg_dump ${pf.toolMajor} against server ${pf.serverMajor}.`));
131
133
 
134
+ // Opt-in, and loud. With row security on, pg_dump stops refusing to read
135
+ // rows it cannot see and starts leaving them out — an exit-0 backup that
136
+ // is quietly short. Anyone choosing that should know they chose it.
137
+ const rowSecurity = args["--enable-row-security"]
138
+ ? { uid: "rebase-db-backup", roles: [args["--row-security-role"] || "admin"] }
139
+ : undefined;
140
+
141
+ if (rowSecurity) {
142
+ logger.warn("");
143
+ logger.warn(chalk.yellow(" ⚠ Dumping with row-level security ON."));
144
+ logger.warn(chalk.gray(` Reading as roles [${rowSecurity.roles.join(", ")}], which satisfies the generated`));
145
+ logger.warn(chalk.gray(" `admin_full_access` policy. This backup contains exactly the rows those"));
146
+ logger.warn(chalk.gray(" policies admit — a table whose policies have no admin rule comes out short,"));
147
+ logger.warn(chalk.gray(" and pg_dump will not say so. Prefer granting the dumping role BYPASSRLS."));
148
+ logger.warn("");
149
+ }
150
+
132
151
  try {
133
152
  if (dest.kind === "local") {
134
153
  // Honour an explicit `…/name.dump` path; otherwise treat it as a
@@ -141,7 +160,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
141
160
  fileName: explicitFile ? path.basename(dest.path) : undefined,
142
161
  excludeSchemas: args["--exclude-schema"],
143
162
  noOwner: args["--no-owner"],
144
- inheritStdio: true
163
+ inheritStdio: true,
164
+ rowSecurity
145
165
  });
146
166
  await assertDumpValid(dump.localFile);
147
167
  logger.info("");
@@ -156,7 +176,8 @@ export async function backupCommand(rawArgs: string[]): Promise<void> {
156
176
  dbName,
157
177
  excludeSchemas: args["--exclude-schema"],
158
178
  noOwner: args["--no-owner"],
159
- inheritStdio: true
179
+ inheritStdio: true,
180
+ rowSecurity
160
181
  });
161
182
  try {
162
183
  await assertDumpValid(dump.localFile);
@@ -395,6 +416,24 @@ ${chalk.green.bold("Options")}
395
416
  ${chalk.blue("--out, -o")} <dest> Local path or s3://…/gs://… URL (default: ./backups)
396
417
  ${chalk.blue("--exclude-schema")} <s> Exclude a schema (repeatable)
397
418
  ${chalk.blue("--no-owner")} Omit ownership commands from the dump
419
+ ${chalk.blue("--enable-row-security")} Dump as an admin subject instead of failing on RLS
420
+ ${chalk.red("(may produce a partial dump — see below)")}
421
+ ${chalk.blue("--row-security-role")} <r> Role to read as with the flag above (default: admin)
422
+
423
+ ${chalk.green.bold("Row-level security")}
424
+ On a managed Postgres the dumping role usually owns nothing and has no
425
+ BYPASSRLS, so pg_dump refuses:
426
+
427
+ ERROR: query would be affected by row-level security policy for table "..."
428
+
429
+ That refusal is the safe behaviour. --enable-row-security replaces it by
430
+ reading as an admin subject: Rebase sets app.uid/app.user_roles so the
431
+ generated admin_full_access policy admits the dump. The dump then contains
432
+ exactly what those policies admit ${chalk.red("and no error is raised for what they do not")} —
433
+ a table whose policies lack an admin rule comes out short, silently.
434
+
435
+ Granting the dumping role BYPASSRLS is the option that keeps a backup
436
+ meaning "every row".
398
437
 
399
438
  ${chalk.green.bold("Notes")}
400
439
  Backups may contain secrets and PII. Use private storage destinations and