@rebasepro/server-postgres 0.0.1-canary.fc811d7 → 0.9.1-canary.0de22e0

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 (66) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +43 -2
  3. package/dist/PostgresBootstrapper.d.ts +17 -1
  4. package/dist/auth/services.d.ts +16 -0
  5. package/dist/collections/buildRegistry.d.ts +27 -0
  6. package/dist/connection.d.ts +21 -0
  7. package/dist/data-transformer.d.ts +9 -2
  8. package/dist/index.es.js +2502 -2624
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/module-dir.d.ts +1 -0
  11. package/dist/schema/doctor.d.ts +1 -1
  12. package/dist/schema/introspect-db-logic.d.ts +0 -5
  13. package/dist/schema/introspect-db-naming.d.ts +10 -0
  14. package/dist/security/policy-drift.d.ts +46 -5
  15. package/dist/security/rls-enforcement.d.ts +28 -3
  16. package/dist/services/FetchService.d.ts +4 -24
  17. package/dist/services/PersistService.d.ts +27 -1
  18. package/dist/services/RelationService.d.ts +34 -1
  19. package/dist/services/channel-history.d.ts +118 -0
  20. package/dist/services/collection-helpers.d.ts +79 -14
  21. package/dist/services/dataService.d.ts +3 -1
  22. package/dist/services/index.d.ts +1 -1
  23. package/dist/services/realtimeService.d.ts +76 -2
  24. package/dist/services/row-pipeline.d.ts +63 -0
  25. package/package.json +15 -40
  26. package/src/PostgresBackendDriver.ts +183 -18
  27. package/src/PostgresBootstrapper.ts +86 -27
  28. package/src/auth/ensure-tables.ts +73 -11
  29. package/src/auth/services.ts +49 -19
  30. package/src/cli-helpers.ts +2 -20
  31. package/src/cli.ts +60 -0
  32. package/src/collections/buildRegistry.ts +59 -0
  33. package/src/connection.ts +61 -1
  34. package/src/data-transformer.ts +11 -9
  35. package/src/databasePoolManager.ts +2 -0
  36. package/src/module-dir.ts +7 -0
  37. package/src/schema/doctor-cli.ts +5 -1
  38. package/src/schema/doctor.ts +45 -20
  39. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  40. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  41. package/src/schema/introspect-db-inference.ts +1 -1
  42. package/src/schema/introspect-db-logic.ts +1 -10
  43. package/src/schema/introspect-db-naming.ts +15 -0
  44. package/src/schema/introspect-db.ts +19 -2
  45. package/src/schema/introspect-runtime.ts +1 -1
  46. package/src/security/anonymous-grants.test.ts +71 -0
  47. package/src/security/policy-drift.test.ts +153 -14
  48. package/src/security/policy-drift.ts +128 -10
  49. package/src/security/rls-enforcement.ts +67 -6
  50. package/src/services/BranchService.ts +42 -10
  51. package/src/services/FetchService.ts +65 -270
  52. package/src/services/PersistService.ts +130 -14
  53. package/src/services/RelationService.ts +153 -94
  54. package/src/services/channel-history.ts +343 -0
  55. package/src/services/collection-helpers.ts +164 -47
  56. package/src/services/dataService.ts +3 -2
  57. package/src/services/index.ts +1 -0
  58. package/src/services/realtimeService.ts +237 -28
  59. package/src/services/row-pipeline.ts +239 -0
  60. package/src/utils/drizzle-conditions.ts +13 -0
  61. package/src/websocket.ts +34 -12
  62. package/dist/chunk-DSJWtz9O.js +0 -40
  63. package/dist/schema/auth-default-policies.d.ts +0 -10
  64. package/dist/src-Eh-CZosp.js +0 -595
  65. package/dist/src-Eh-CZosp.js.map +0 -1
  66. package/src/schema/auth-default-policies.ts +0 -125
package/README.md CHANGED
@@ -76,6 +76,27 @@ process.on("SIGTERM", async () => {
76
76
  | `statementTimeout` | 30,000 |
77
77
  | `keepAlive` | true |
78
78
 
79
+ ## Testing
80
+
81
+ This package runs **two test runners**, split by directory. This is deliberate — check which half you are in before running anything.
82
+
83
+ | Tests | Runner | Config | Command |
84
+ |-------|--------|--------|---------|
85
+ | `test/*.ts` (unit) | jest | [`jest.config.cjs`](./jest.config.cjs) | `pnpm test` |
86
+ | `test/e2e/**` (integration) | vitest | [`vitest.e2e.config.ts`](./vitest.e2e.config.ts) | `pnpm test:e2e` |
87
+
88
+ The unit tests use jest's injected globals (`describe`/`it`/`expect`) and `jest.mock`. The e2e tests import explicitly from `vitest` and need Docker (testcontainers spins up a real Postgres).
89
+
90
+ **Running a single unit test:**
91
+
92
+ ```bash
93
+ npx jest test/auth-services.test.ts
94
+ ```
95
+
96
+ Pointing `vitest` at a unit test is the easy mistake here — it produces a bare `ReferenceError: jest is not defined` and a "no tests" result, which looks exactly like a dead or broken test file rather than the wrong runner. [`vitest.config.ts`](./vitest.config.ts) exists solely to intercept that and print the command you actually wanted.
97
+
98
+ `pnpm test` runs jest **without** `--passWithNoTests`: if the unit suite ever stops being collected, CI fails instead of going quietly green.
99
+
79
100
  ## Related Packages
80
101
 
81
102
  | Package | Role |
@@ -3,7 +3,7 @@ import { BranchService } from "./services/BranchService";
3
3
  import { RealtimeService } from "./services/realtimeService";
4
4
  import { DatabasePoolManager } from "./databasePoolManager";
5
5
  import { DrizzleClient } from "./interfaces";
6
- import { DatabaseAdmin, DataDriver, DeleteProps, CollectionConfig, FetchCollectionProps, FetchOneProps, ListenCollectionProps, ListenOneProps, RebaseClient, RebaseSdkData, RestFetchService, SaveProps, TableMetadata, User } from "@rebasepro/types";
6
+ import { DatabaseAdmin, DataDriver, DeleteProps, CollectionConfig, FetchCollectionProps, FetchOneProps, ListenCollectionProps, ListenOneProps, RebaseClient, RebaseSdkData, RestFetchService, SaveManyProps, SaveProps, TableMetadata, User } from "@rebasepro/types";
7
7
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
8
8
  import { HistoryService } from "./history/HistoryService";
9
9
  export declare class PostgresBackendDriver implements DataDriver {
@@ -88,7 +88,20 @@ export declare class PostgresBackendDriver implements DataDriver {
88
88
  listenCollection<M extends Record<string, unknown>>({ path, collection, filter, limit, offset, startAfter, orderBy, searchString, order, onUpdate, onError }: ListenCollectionProps<M>): () => void;
89
89
  fetchOne<M extends Record<string, unknown>>({ path, id, databaseId, collection }: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
90
90
  listenOne<M extends Record<string, unknown>>({ path, id, collection, onUpdate, onError }: ListenOneProps<M>): () => void;
91
- save<M extends Record<string, unknown>>({ path, id, values, collection, status }: SaveProps<M>): Promise<Record<string, unknown>>;
91
+ save<M extends Record<string, unknown>>({ path, id, values, collection, status, upsert }: SaveProps<M>): Promise<Record<string, unknown>>;
92
+ /**
93
+ * Write many rows through the same pipeline as {@link save}.
94
+ *
95
+ * The batch runs in one transaction of its own, so a failure part-way leaves
96
+ * nothing behind — the point of a batch is that a re-run starts from a known
97
+ * state. When this driver is already inside a transaction (the authenticated
98
+ * path, via `withTransaction`) the nested call becomes a savepoint, which is
99
+ * still atomic and still commits once.
100
+ *
101
+ * Rows are applied in order, so a batch that touches the same key twice ends
102
+ * with the last write winning, exactly as separate calls would.
103
+ */
104
+ saveMany<M extends Record<string, unknown>>({ path, rows, collection, upsert }: SaveManyProps<M>): Promise<Record<string, unknown>[]>;
92
105
  delete<M extends Record<string, unknown>>({ row, collection }: DeleteProps<M>): Promise<void>;
93
106
  deleteAll(path: string): Promise<void>;
94
107
  checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
@@ -101,6 +114,24 @@ export declare class PostgresBackendDriver implements DataDriver {
101
114
  }): Promise<Record<string, unknown>[]>;
102
115
  fetchAvailableDatabases(): Promise<string[]>;
103
116
  fetchAvailableRoles(): Promise<string[]>;
117
+ /**
118
+ * Application-level roles actually in use in this project.
119
+ *
120
+ * Distinct from {@link fetchAvailableRoles}, which returns native
121
+ * PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
122
+ * are the roles the SQL editor can `SET ROLE` to. *These* are the strings
123
+ * held in the users table's `roles` column, injected per-transaction as
124
+ * `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
125
+ * into a `SecurityRule.roles` field produces a condition no user can ever
126
+ * satisfy, so the two must not be conflated.
127
+ *
128
+ * Roles have no registry table — they were migrated out of
129
+ * `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
130
+ * set is derived from what is assigned. A role that is declared in a policy
131
+ * but held by nobody yet cannot be discovered here; callers that need it
132
+ * should union in the roles they already know about.
133
+ */
134
+ fetchApplicationRoles(): Promise<string[]>;
104
135
  fetchCurrentDatabase(): Promise<string | undefined>;
105
136
  /**
106
137
  * Fetch public tables that are not yet mapped to a collection.
@@ -143,6 +174,16 @@ export declare class AuthenticatedPostgresBackendDriver implements DataDriver {
143
174
  fetchOne<M extends Record<string, unknown>>(props: FetchOneProps<M>): Promise<Record<string, unknown> | undefined>;
144
175
  listenOne<M extends Record<string, unknown>>(props: ListenOneProps<M>): () => void;
145
176
  save<M extends Record<string, unknown>>(props: SaveProps<M>): Promise<Record<string, unknown>>;
177
+ /**
178
+ * One transaction for the whole batch, rather than one per row.
179
+ *
180
+ * This is the point of the method: `save` opens a transaction per call, so
181
+ * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
182
+ * round trips). Here the RLS context is established once and every row lands
183
+ * or none does. Realtime notifications are already deferred to commit by
184
+ * `withTransaction`, so a batch does not flood subscribers mid-flight.
185
+ */
186
+ saveMany<M extends Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]>;
146
187
  delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void>;
147
188
  deleteAll(path: string): Promise<void>;
148
189
  checkUniqueField(path: string, name: string, value: unknown, id?: string, collection?: CollectionConfig): Promise<boolean>;
@@ -4,11 +4,12 @@
4
4
  * Implements the `BackendBootstrapper` interface for PostgreSQL.
5
5
  */
6
6
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
7
- import { BackendBootstrapper } from "@rebasepro/types";
7
+ import { BackendBootstrapper, type RealtimeChannelsConfig } from "@rebasepro/types";
8
8
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
9
9
  import { RealtimeService } from "./services/realtimeService";
10
10
  import { DatabasePoolManager } from "./databasePoolManager";
11
11
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
12
+ import { type CdcTableRef } from "./services/cdc/trigger-cdc";
12
13
  export interface PostgresDriverConfig {
13
14
  connectionString?: string;
14
15
  adminConnectionString?: string;
@@ -24,6 +25,12 @@ export interface PostgresDriverConfig {
24
25
  * (BaaS mode). Defaults to `public`.
25
26
  */
26
27
  introspectionSchema?: string;
28
+ /**
29
+ * Realtime options. Currently only channel retention, which is opt-in:
30
+ * without rules here no channel keeps any history and broadcast stays
31
+ * fire-and-forget. See {@link ChannelRetentionRule}.
32
+ */
33
+ realtime?: RealtimeChannelsConfig;
27
34
  }
28
35
  /**
29
36
  * Opaque internals bag that PostgresBootstrapper stores during `initializeDriver()`
@@ -36,6 +43,15 @@ export interface PostgresDriverInternals {
36
43
  realtimeService: RealtimeService;
37
44
  driver: PostgresBackendDriver;
38
45
  poolManager?: DatabasePoolManager;
46
+ /**
47
+ * Attach CDC triggers to tables that did not exist when the driver
48
+ * bootstrapped. Only set when database-level capture is actually active.
49
+ *
50
+ * Auth owns its own tables and creates them later in boot, so at driver
51
+ * bootstrap they are legitimately missing and get skipped; without this
52
+ * they would stay uninstrumented until the next restart.
53
+ */
54
+ provisionCdcForTables?: (tables: CdcTableRef[]) => Promise<void>;
39
55
  }
40
56
  /**
41
57
  * Default PostgreSQL bootstrapper.
@@ -19,6 +19,22 @@ export declare class UserService implements UserRepository {
19
19
  private userIdentitiesTable;
20
20
  constructor(db: NodePgDatabase, tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>);
21
21
  private getQualifiedUsersTableName;
22
+ /**
23
+ * Run a privileged auth write with an explicitly cleared RLS context.
24
+ *
25
+ * The auth services run on the base/owner connection, which by design
26
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
27
+ * in the default policies applies. That NULL is normally guaranteed by
28
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
29
+ * GUC that survives on a pooled connection (or a connection role that
30
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
31
+ * turns the trusted write into an RLS-scoped one and denies it with
32
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
33
+ * single chokepoint, makes the server context deterministic instead of
34
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
35
+ * NULL via NULLIF, so '' is the server context.
36
+ */
37
+ private withServerContext;
22
38
  private mapRowToUser;
23
39
  private mapPayload;
24
40
  createUser(data: CreateUserData): Promise<UserData>;
@@ -0,0 +1,27 @@
1
+ import { Relations } from "drizzle-orm";
2
+ import { PgEnum } from "drizzle-orm/pg-core";
3
+ import { CollectionConfig } from "@rebasepro/types";
4
+ import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
5
+ /**
6
+ * Everything a registry is built from: the collections, and the drizzle schema
7
+ * they are backed by. In BaaS mode all of it is introspected from the live
8
+ * database; in CMS mode it comes from the config and the generated schema.
9
+ */
10
+ export interface RegistrySchema {
11
+ collections?: CollectionConfig[];
12
+ tables?: Record<string, unknown>;
13
+ enums?: Record<string, PgEnum<[string, ...string[]]>>;
14
+ relations?: Record<string, Relations>;
15
+ }
16
+ /**
17
+ * Build the collection registry for a driver.
18
+ *
19
+ * The order matters and is the reason this is one function rather than a run of
20
+ * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
21
+ * anything that inspects them has to run *after* the tables are registered —
22
+ * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
23
+ * collection whose table it cannot look up is one it has nothing to say about.
24
+ * Warned too early, it would skip every collection and report nothing, which
25
+ * reads exactly like having nothing to report.
26
+ */
27
+ export declare function buildCollectionRegistry(schema: RegistrySchema): PostgresCollectionRegistry;
@@ -19,6 +19,27 @@ export interface PostgresPoolConfig {
19
19
  /** Enable TCP keep-alive (default: true) */
20
20
  keepAlive?: boolean;
21
21
  }
22
+ /**
23
+ * Destroy pool clients that are released while still inside a transaction.
24
+ *
25
+ * pg-pool returns a client to the idle list whenever `release()` is called
26
+ * without an error — even if the connection is still mid-transaction (status
27
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
28
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
29
+ * (e.g. it was queued behind a statement that hit the client-side
30
+ * query_timeout), the client goes back dirty. The next checkout then runs
31
+ * its statements inside the zombie transaction — with the previous request's
32
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
33
+ * RLS-scoped ones (observed in production as registration failing with
34
+ * SQLSTATE 42501 under a leaked anonymous context).
35
+ *
36
+ * pg-pool emits `release` before it consults its private `_expired` set, so
37
+ * marking the client expired here makes `_release()` destroy it instead of
38
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
39
+ * private APIs — feature-detect and fall back to loud logging so an upstream
40
+ * change degrades to observability, never to silent corruption.
41
+ */
42
+ export declare function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void;
22
43
  /**
23
44
  * Create a Drizzle-backed Postgres connection with a production-grade
24
45
  * connection pool.
@@ -12,12 +12,19 @@ import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegi
12
12
  export interface SerializedEntityData {
13
13
  /** Scalar column values ready for INSERT/UPDATE. */
14
14
  scalarData: Record<string, unknown>;
15
- /** Inverse relation updates that must be applied to target tables. */
15
+ /**
16
+ * Inverse relation updates that must be applied to target tables.
17
+ *
18
+ * No address here: the row being written does not know its own. These are
19
+ * applied by `PersistService`, which addresses them with the id it holds —
20
+ * the one it was given for an update, or the one the INSERT returned — and
21
+ * that is the authority. This item used to carry a `currentId` derived from
22
+ * the *input* values, which nothing ever read.
23
+ */
16
24
  inverseRelationUpdates: Array<{
17
25
  relationKey: string;
18
26
  relation: Relation;
19
27
  newValue: unknown;
20
- currentId?: string | number;
21
28
  }>;
22
29
  /** JoinPath relation updates that require multi-hop writes. */
23
30
  joinPathRelationUpdates: Array<{