@rebasepro/server-postgres 0.9.1-canary.73476f2 → 0.9.1-canary.74adfbe

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 (62) 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 +68 -52
  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 +2185 -775
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
  11. package/dist/schema/auth-schema.d.ts +24 -24
  12. package/dist/schema/doctor.d.ts +1 -1
  13. package/dist/schema/introspect-db-logic.d.ts +0 -5
  14. package/dist/schema/introspect-db-naming.d.ts +10 -0
  15. package/dist/security/policy-drift.d.ts +30 -0
  16. package/dist/security/rls-enforcement.d.ts +2 -2
  17. package/dist/services/FetchService.d.ts +4 -24
  18. package/dist/services/PersistService.d.ts +27 -1
  19. package/dist/services/RelationService.d.ts +34 -1
  20. package/dist/services/channel-history.d.ts +118 -0
  21. package/dist/services/collection-helpers.d.ts +79 -14
  22. package/dist/services/dataService.d.ts +3 -1
  23. package/dist/services/index.d.ts +1 -1
  24. package/dist/services/realtimeService.d.ts +76 -2
  25. package/dist/services/row-pipeline.d.ts +63 -0
  26. package/package.json +13 -34
  27. package/src/PostgresBackendDriver.ts +183 -18
  28. package/src/PostgresBootstrapper.ts +80 -26
  29. package/src/auth/ensure-tables.ts +170 -28
  30. package/src/auth/services.ts +181 -150
  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/schema/auth-bootstrap-sql.ts +7 -1
  37. package/src/schema/auth-schema.ts +13 -13
  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/policy-drift.test.ts +106 -1
  47. package/src/security/policy-drift.ts +56 -0
  48. package/src/security/rls-enforcement.ts +11 -5
  49. package/src/services/BranchService.ts +42 -10
  50. package/src/services/FetchService.ts +65 -270
  51. package/src/services/PersistService.ts +130 -14
  52. package/src/services/RelationService.ts +153 -94
  53. package/src/services/channel-history.ts +343 -0
  54. package/src/services/collection-helpers.ts +164 -47
  55. package/src/services/dataService.ts +3 -2
  56. package/src/services/index.ts +1 -0
  57. package/src/services/realtimeService.ts +238 -29
  58. package/src/services/row-pipeline.ts +239 -0
  59. package/src/utils/drizzle-conditions.ts +13 -0
  60. package/src/websocket.ts +34 -12
  61. package/dist/schema/auth-default-policies.d.ts +0 -10
  62. package/src/schema/auth-default-policies.ts +0 -132
@@ -4,12 +4,13 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
4
4
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
6
6
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
+ import type { ChannelRetentionRule } from "@rebasepro/types";
7
8
  /**
8
9
  * Auth context stored per-subscription so real-time refetches respect RLS.
9
10
  * Mirrors the session variables set by PostgresBackendDriver.withAuth().
10
11
  */
11
12
  export interface SubscriptionAuthContext {
12
- userId: string;
13
+ uid: string;
13
14
  roles: string[];
14
15
  }
15
16
  /**
@@ -24,6 +25,26 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
24
25
  private clients;
25
26
  private channels;
26
27
  private presence;
28
+ /**
29
+ * Ordered, replayable history for channels that opt into it.
30
+ *
31
+ * Undefined until {@link configureChannelHistory} is called, and inert even
32
+ * then unless retention rules were supplied — so presence and ephemeral
33
+ * notification channels never touch it. See `channel-history.ts`.
34
+ */
35
+ private channelHistory?;
36
+ /**
37
+ * One promise chain per retained channel, so that assigning a sequence
38
+ * number and fanning the message out happen in the same order for every
39
+ * message on that channel.
40
+ *
41
+ * Without it, two concurrent broadcasts can be numbered 4 and 5 by the
42
+ * database and still reach subscribers as 5 then 4 — live order and replay
43
+ * order would disagree, which is exactly the divergence sequence numbers
44
+ * are supposed to rule out. Keyed by channel, so unrelated channels never
45
+ * wait on each other.
46
+ */
47
+ private channelSendQueues;
27
48
  private presenceInterval?;
28
49
  private static readonly PRESENCE_TIMEOUT_MS;
29
50
  private dataService;
@@ -177,8 +198,15 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
177
198
  /**
178
199
  * Send a lightweight row-level patch to a collection subscriber.
179
200
  * The client can merge this into its cached data for instant feedback.
201
+ *
202
+ * The key columns ride along: the patch names a row by address, and the
203
+ * client has to find that row among the ones it cached — which carry
204
+ * columns and no address. The SDK holds no collection config to derive one
205
+ * from, so this is the only place the mapping can come from.
180
206
  */
181
207
  private sendCollectionPatch;
208
+ /** The key columns of the collection at `path`, if they can be resolved. */
209
+ private primaryKeysForPath;
182
210
  private sendError;
183
211
  private sendMessage;
184
212
  /**
@@ -190,8 +218,54 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
190
218
  joinChannel(clientId: string, channel: string): void;
191
219
  /** Leave a broadcast channel */
192
220
  leaveChannel(clientId: string, channel: string): void;
193
- /** Broadcast a message to all clients in a channel except sender */
221
+ /**
222
+ * Broadcast a message to all clients in a channel except the sender.
223
+ *
224
+ * On a channel with no retention rule this is what it always was: a
225
+ * synchronous fan-out to whoever is connected, with no sequence number, no
226
+ * SQL and no await — the body below runs to completion before returning.
227
+ *
228
+ * On a retained channel the message is durably numbered first and only then
229
+ * delivered, through a per-channel queue so that delivery order matches
230
+ * sequence order. That ordering is the whole point: a client that catches up
231
+ * with `sinceSeq` has to arrive at the same state as one that never
232
+ * disconnected.
233
+ */
194
234
  broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void;
235
+ /**
236
+ * Number a broadcast, store it, then deliver it.
237
+ *
238
+ * A message that cannot be stored is **not** delivered. Delivering it would
239
+ * put it in front of live subscribers while leaving it absent from every
240
+ * future replay — the two views of the channel would disagree permanently,
241
+ * and no later message could repair the gap. Failing loudly to the sender
242
+ * instead lets it retry, which for an operation stream is the only outcome
243
+ * that keeps clients convergent.
244
+ */
245
+ private persistAndFanOut;
246
+ /** Deliver a broadcast frame to every member of a channel but the sender. */
247
+ private fanOutBroadcast;
248
+ /**
249
+ * Install retention rules and create the tables they need.
250
+ *
251
+ * Safe to call with no rules (and safe not to call at all): the store stays
252
+ * inert, no schema is created, and broadcast keeps its original
253
+ * fire-and-forget path.
254
+ */
255
+ configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void>;
256
+ /** Whether any channel is configured to retain messages. */
257
+ isChannelHistoryEnabled(): boolean;
258
+ /**
259
+ * Answer a client's catch-up request.
260
+ *
261
+ * A channel with no retention rule is answered with `retained: false`
262
+ * rather than an empty list, so the client can tell "you missed nothing"
263
+ * apart from "this channel never keeps anything" — the second means its
264
+ * reconnect strategy has to be a full resync, and silence would leave it
265
+ * guessing.
266
+ */
267
+ private handleChannelHistoryRequest;
268
+ private sendChannelHistory;
195
269
  /** Track presence in a channel */
196
270
  trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
197
271
  /** Remove presence from a channel */
@@ -0,0 +1,63 @@
1
+ import { CollectionConfig, Relation } from "@rebasepro/types";
2
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
3
+ /**
4
+ * Turning a drizzle result into a row we serve.
5
+ *
6
+ * There are two shapes, and they are the same walk. Both take a row whose
7
+ * relation fields hold nested objects, both unwrap junction rows to reach the
8
+ * target behind them, and both leave every other column alone. They differ only
9
+ * in what they put where the relation was:
10
+ *
11
+ * - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
12
+ * target's values. This is what the admin renders.
13
+ * - `"inline"` — the target's own columns, flat. This is what REST serves.
14
+ *
15
+ * They used to be two functions that happened to agree, and the agreement was
16
+ * not enforced by anything: the row-identity bug had to be fixed five times
17
+ * across differently-shaped copies of this walk, and one of the copies was
18
+ * dead code nobody had noticed. Whatever the next cross-cutting change is, it
19
+ * is one edit here.
20
+ */
21
+ export type RelationStyle = "ref" | "inline";
22
+ /**
23
+ * Whether a many-relation reaches its target through a junction table.
24
+ *
25
+ * Also used to build the drizzle `with` config, which is why it is exported:
26
+ * the query has to nest one level deeper for a junction, and the row walk has
27
+ * to unwrap that same level back out.
28
+ */
29
+ export declare function isJunctionRelation(relation: Relation): boolean;
30
+ /**
31
+ * The address a relation ref points at.
32
+ *
33
+ * The whole key, not its first column: a composite-keyed target addressed by
34
+ * `tenant_id` alone points at every row that shares it. A target whose key
35
+ * cannot be resolved at all used to throw here — reading `[0]` of an empty
36
+ * array — taking down the parent's fetch over a relation it may not even have
37
+ * asked for. The first column is a guess, but a ref that resolves to nothing
38
+ * beats no rows at all.
39
+ */
40
+ export declare function relationTargetAddress(targetRow: Record<string, unknown>, targetCollection: CollectionConfig, registry: PostgresCollectionRegistry): string;
41
+ /**
42
+ * The row the admin renders: every column, with relations as references.
43
+ *
44
+ * Values are normalized (dates, numbers, NaN) because the admin's view-model
45
+ * expects real types. The row's own address is *not* among the columns — it is
46
+ * derived by the consumer from the collection's primary keys.
47
+ */
48
+ export declare function toCmsRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
49
+ /**
50
+ * The row REST serves: every column under its own name, with the value Postgres
51
+ * returned, and relations inlined as the target's columns.
52
+ *
53
+ * Values are the ones the database returned, except where that contradicts the
54
+ * declared type: a `number` property is served as a number (see
55
+ * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
56
+ * JSON has its own opinions about dates that the admin's view-model does not
57
+ * share.
58
+ *
59
+ * Keyed by the row rather than by the relation list — a REST fetch only loads
60
+ * the relations `include` asked for, so the row is the authority on which are
61
+ * actually there.
62
+ */
63
+ export declare function toRestRow(row: Record<string, unknown>, collection: CollectionConfig, registry: PostgresCollectionRegistry): Record<string, unknown>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.73476f2",
4
+ "version": "0.9.1-canary.74adfbe",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -29,30 +29,6 @@
29
29
  "backend",
30
30
  "rebase"
31
31
  ],
32
- "jest": {
33
- "transform": {
34
- "^.+\\.tsx?$": "ts-jest"
35
- },
36
- "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
37
- "testPathIgnorePatterns": [
38
- "/node_modules/",
39
- "test/e2e/"
40
- ],
41
- "moduleFileExtensions": [
42
- "ts",
43
- "tsx",
44
- "js",
45
- "jsx",
46
- "json",
47
- "node"
48
- ],
49
- "moduleNameMapper": {
50
- "^chalk$": "<rootDir>/test/mocks/chalk.cjs",
51
- "^\\.{1,2}/module-dir$": "<rootDir>/test/mocks/module-dir.cjs",
52
- "^@rebasepro/([a-z0-9-]+)$": "<rootDir>/../$1/src/index.ts",
53
- "^(\\.{1,2}/.*)\\.js$": "$1"
54
- }
55
- },
56
32
  "exports": {
57
33
  ".": {
58
34
  "types": "./dist/index.d.ts",
@@ -69,21 +45,23 @@
69
45
  "dotenv": "^17.4.2",
70
46
  "drizzle-orm": "^0.45.2",
71
47
  "execa": "^9.6.1",
72
- "hono": "^4.12.25",
73
48
  "pg": "^8.21.0",
74
49
  "ws": "^8.21.0",
75
- "@rebasepro/common": "0.9.1-canary.73476f2",
76
- "@rebasepro/codegen": "0.9.1-canary.73476f2",
77
- "@rebasepro/types": "0.9.1-canary.73476f2",
78
- "@rebasepro/server": "0.9.1-canary.73476f2",
79
- "@rebasepro/utils": "0.9.1-canary.73476f2"
50
+ "@rebasepro/codegen": "0.9.1-canary.74adfbe",
51
+ "@rebasepro/common": "0.9.1-canary.74adfbe",
52
+ "@rebasepro/types": "0.9.1-canary.74adfbe",
53
+ "@rebasepro/utils": "0.9.1-canary.74adfbe",
54
+ "@rebasepro/server": "0.9.1-canary.74adfbe"
80
55
  },
81
56
  "devDependencies": {
57
+ "@hono/node-server": "^2.0.9",
58
+ "@jest/globals": "^30.4.1",
82
59
  "@types/jest": "^30.0.0",
83
60
  "@types/node": "^25.9.3",
84
61
  "@types/pg": "^8.20.0",
85
62
  "@types/ws": "^8.18.1",
86
63
  "@vitejs/plugin-react": "^6.0.2",
64
+ "hono": "^4.12.25",
87
65
  "jest": "^30.4.2",
88
66
  "ts-jest": "^29.4.11",
89
67
  "typescript": "^6.0.3",
@@ -100,10 +78,11 @@
100
78
  ],
101
79
  "scripts": {
102
80
  "watch": "vite build --watch",
103
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
81
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
104
82
  "test:lint": "eslint \"src/**\" --quiet",
105
- "test": "jest --passWithNoTests",
83
+ "test": "jest",
106
84
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
107
- "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f"
85
+ "clean": "rm -rf dist && find ./src -name '*.js' -type f | xargs rm -f",
86
+ "smoke:baas": "tsx scripts/smoke-baas.ts"
108
87
  }
109
88
  }
@@ -17,6 +17,7 @@ import {
17
17
  RebaseData,
18
18
  RebaseSdkData,
19
19
  RestFetchService,
20
+ SaveManyProps,
20
21
  SaveProps,
21
22
  TableColumnInfo,
22
23
  TableForeignKeyInfo,
@@ -28,6 +29,7 @@ import {
28
29
  import { sql as drizzleSql } from "drizzle-orm";
29
30
  import { buildPropertyCallbacks, buildSdkData, resolveCollectionRelations, updateDateAutoValues } from "@rebasepro/common";
30
31
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
32
+ import { deriveRowAddress } from "./services/collection-helpers";
31
33
  import { HistoryService } from "./history/HistoryService";
32
34
  import { mergeDeep } from "@rebasepro/utils";
33
35
  import { logger } from "@rebasepro/server";
@@ -109,6 +111,7 @@ export class PostgresBackendDriver implements DataDriver {
109
111
  executeSql: (...args: Parameters<NonNullable<DatabaseAdmin["executeSql"]>>) => this.executeSql(...args),
110
112
  fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
111
113
  fetchAvailableRoles: () => this.fetchAvailableRoles(),
114
+ fetchApplicationRoles: () => this.fetchApplicationRoles(),
112
115
  fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
113
116
  fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
114
117
  fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
@@ -532,7 +535,8 @@ export class PostgresBackendDriver implements DataDriver {
532
535
  id,
533
536
  values,
534
537
  collection,
535
- status
538
+ status,
539
+ upsert
536
540
  }: SaveProps<M>): Promise<Record<string, unknown>> {
537
541
 
538
542
  const {
@@ -545,13 +549,26 @@ export class PostgresBackendDriver implements DataDriver {
545
549
  let updatedValues = values;
546
550
  const contextForCallback = this.buildCallContext();
547
551
 
548
- // Fetch previous values for callbacks AND history recording
552
+ // Fetch previous values for callbacks AND history recording. Same walk
553
+ // as the saved row the callbacks receive (`fetchOneForRest`), so
554
+ // `values` and `previousValues` compare like with like — a Date on one
555
+ // side and its ISO string on the other reads as a change that never
556
+ // happened.
549
557
  let previousValuesForHistory: Partial<M> | undefined;
550
558
  if (status === "existing" && id) {
551
- const existing = await this.dataService.fetchOne<M>(path, id, resolvedCollection?.databaseId);
552
- if (existing) {
553
- const { id: _existingId, ...existingValues } = existing;
554
- previousValuesForHistory = existingValues as Partial<M>;
559
+ try {
560
+ const existing = await this.dataService.getFetchService()
561
+ .fetchOneForRest(path, id, undefined, resolvedCollection?.databaseId);
562
+ if (existing) {
563
+ const { id: _existingId, ...existingValues } = existing;
564
+ previousValuesForHistory = existingValues as Partial<M>;
565
+ }
566
+ } catch (err) {
567
+ // Best-effort enrichment: callbacks and history run without
568
+ // previous values rather than the save failing on a read the
569
+ // write itself does not need (e.g. a collection whose key the
570
+ // registry cannot resolve).
571
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
555
572
  }
556
573
  }
557
574
 
@@ -616,7 +633,8 @@ export class PostgresBackendDriver implements DataDriver {
616
633
  path,
617
634
  updatedValues,
618
635
  id,
619
- resolvedCollection?.databaseId
636
+ resolvedCollection?.databaseId,
637
+ { upsert }
620
638
  );
621
639
 
622
640
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
@@ -649,8 +667,19 @@ export class PostgresBackendDriver implements DataDriver {
649
667
  }
650
668
  }
651
669
 
652
- const savedId = savedRow.id as string | number;
653
- const { id: _savedId, ...savedValues } = savedRow;
670
+ // The row is exactly its columns, so its address is derived, not read
671
+ // off it: `savedRow.id` is undefined for every table whose key is not
672
+ // literally named `id`, and is ordinary data for a table that has such
673
+ // a column without it being the key.
674
+ const savedId = deriveRowAddress(
675
+ savedRow,
676
+ (resolvedCollection ?? collection) as CollectionConfig,
677
+ this.registry
678
+ );
679
+ // `values` are the row's columns — all of them. For an `id`-keyed table
680
+ // that includes `id`, which used to be stripped here because it was the
681
+ // synthesized address rather than the column it now is.
682
+ const savedValues = savedRow;
654
683
 
655
684
  if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
656
685
  // 1. Global callbacks first
@@ -695,7 +724,7 @@ export class PostgresBackendDriver implements DataDriver {
695
724
  if (this.historyService && resolvedCollection?.history) {
696
725
  this.historyService.recordHistory({
697
726
  tableName: path,
698
- id: savedId.toString(),
727
+ id: savedId,
699
728
  action: status === "new" ? "create" : "update",
700
729
  values: savedValues as Record<string, unknown>,
701
730
  previousValues: previousValuesForHistory as Record<string, unknown> | undefined,
@@ -707,14 +736,14 @@ export class PostgresBackendDriver implements DataDriver {
707
736
  if (this._deferNotifications) {
708
737
  this._pendingNotifications.push({
709
738
  path,
710
- id: savedId.toString(),
739
+ id: savedId,
711
740
  row: savedRow,
712
741
  databaseId: resolvedCollection?.databaseId
713
742
  });
714
743
  } else {
715
744
  await this.realtimeService.notifyUpdate(
716
745
  path,
717
- savedId.toString(),
746
+ savedId,
718
747
  savedRow,
719
748
  resolvedCollection?.databaseId
720
749
  );
@@ -764,13 +793,86 @@ export class PostgresBackendDriver implements DataDriver {
764
793
  }
765
794
  }
766
795
 
796
+ /**
797
+ * Write many rows through the same pipeline as {@link save}.
798
+ *
799
+ * The batch runs in one transaction of its own, so a failure part-way leaves
800
+ * nothing behind — the point of a batch is that a re-run starts from a known
801
+ * state. When this driver is already inside a transaction (the authenticated
802
+ * path, via `withTransaction`) the nested call becomes a savepoint, which is
803
+ * still atomic and still commits once.
804
+ *
805
+ * Rows are applied in order, so a batch that touches the same key twice ends
806
+ * with the last write winning, exactly as separate calls would.
807
+ */
808
+ async saveMany<M extends Record<string, unknown>>({
809
+ path,
810
+ rows,
811
+ collection,
812
+ upsert
813
+ }: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
814
+ return this.db.transaction(async (tx) => {
815
+ // Bind the whole batch to the transaction handle. Without this the
816
+ // rows would be written through `this.db` and survive a rollback.
817
+ const txDriver = new PostgresBackendDriver(
818
+ tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
819
+ );
820
+ txDriver.dataService = new DataService(tx, this.registry);
821
+ txDriver.client = this.client;
822
+ // Carry the caller's notification batching through, so a bulk write
823
+ // nested in an outer transaction still holds its events until commit.
824
+ txDriver._deferNotifications = this._deferNotifications;
825
+ txDriver._pendingNotifications = this._pendingNotifications;
826
+
827
+ const saved: Record<string, unknown>[] = [];
828
+
829
+ for (let i = 0; i < rows.length; i++) {
830
+ const values = rows[i];
831
+ const id = (values as Record<string, unknown>)?.id as string | number | undefined;
832
+ try {
833
+ saved.push(await txDriver.save<M>({
834
+ path,
835
+ values,
836
+ // No `id` argument, deliberately: passing one selects the
837
+ // UPDATE path, and an import's rows usually carry a natural
838
+ // key for a row that does not exist yet — which would 404 on
839
+ // every one. Leaving the key inside `values` is what
840
+ // single-row `create(data, id)` does, and it inserts.
841
+ // Callers who want existing rows overwritten pass `upsert`.
842
+ collection,
843
+ status: "new",
844
+ upsert
845
+ }));
846
+ } catch (error) {
847
+ // One bad row in ten thousand is impossible to find from a
848
+ // message that only says the batch failed. Say which row, and
849
+ // keep the original error as the cause so its status survives.
850
+ const label = id !== undefined ? `id ${JSON.stringify(id)}` : "no id";
851
+ throw Object.assign(
852
+ new Error(`Row ${i} of ${rows.length} (${label}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
853
+ {
854
+ statusCode: (error as { statusCode?: number })?.statusCode,
855
+ code: (error as { code?: string })?.code,
856
+ name: (error as Error)?.name
857
+ }
858
+ );
859
+ }
860
+ }
861
+
862
+ return saved;
863
+ });
864
+ }
865
+
767
866
  async delete<M extends Record<string, unknown>>({
768
867
  row,
769
868
  collection
770
869
  }: DeleteProps<M>): Promise<void> {
771
870
 
772
871
  const targetPath = row.path;
773
- const targetRow: Record<string, unknown> = { id: row.id, ...(row.values ?? {}) };
872
+ // The callbacks' `row` is the row: its columns, nothing else. The address
873
+ // travels beside it as `id`, so merging it in here only ever invented an
874
+ // `id` field for tables that have no such column.
875
+ const targetRow: Record<string, unknown> = { ...(row.values ?? {}) };
774
876
 
775
877
  // Resolve from backend registry to restore callbacks lost during WebSocket serialization
776
878
  const {
@@ -1070,6 +1172,56 @@ export class PostgresBackendDriver implements DataDriver {
1070
1172
  return result.map((r: Record<string, unknown>) => r.rolname as string);
1071
1173
  }
1072
1174
 
1175
+ /**
1176
+ * Application-level roles actually in use in this project.
1177
+ *
1178
+ * Distinct from {@link fetchAvailableRoles}, which returns native
1179
+ * PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
1180
+ * are the roles the SQL editor can `SET ROLE` to. *These* are the strings
1181
+ * held in the users table's `roles` column, injected per-transaction as
1182
+ * `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
1183
+ * into a `SecurityRule.roles` field produces a condition no user can ever
1184
+ * satisfy, so the two must not be conflated.
1185
+ *
1186
+ * Roles have no registry table — they were migrated out of
1187
+ * `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
1188
+ * set is derived from what is assigned. A role that is declared in a policy
1189
+ * but held by nobody yet cannot be discovered here; callers that need it
1190
+ * should union in the roles they already know about.
1191
+ */
1192
+ async fetchApplicationRoles(): Promise<string[]> {
1193
+ // The users table lives in `rebase` for a default (public) setup, but
1194
+ // follows the configured schema otherwise — locate it rather than
1195
+ // assuming. The `roles` ARRAY column is what makes it the auth table.
1196
+ const located = await this.executeSql(`
1197
+ SELECT table_schema, table_name
1198
+ FROM information_schema.columns
1199
+ WHERE column_name = 'roles'
1200
+ AND data_type = 'ARRAY'
1201
+ AND table_name = 'users'
1202
+ AND table_schema NOT IN ('information_schema', 'pg_catalog')
1203
+ ORDER BY (table_schema = 'rebase') DESC, table_schema
1204
+ LIMIT 1;
1205
+ `);
1206
+ if (located.length === 0) return [];
1207
+
1208
+ const schema = located[0].table_schema as string;
1209
+ const table = located[0].table_name as string;
1210
+ // Identifiers come from information_schema, not user input, but they
1211
+ // are still interpolated — quote them so odd-but-legal names survive.
1212
+ const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
1213
+
1214
+ const rows = await this.executeSql(`
1215
+ SELECT DISTINCT unnest(roles) AS role
1216
+ FROM ${qualified}
1217
+ WHERE roles IS NOT NULL
1218
+ ORDER BY role;
1219
+ `);
1220
+ return rows
1221
+ .map((r) => r.role as string)
1222
+ .filter((r): r is string => typeof r === "string" && r.length > 0);
1223
+ }
1224
+
1073
1225
  async fetchCurrentDatabase(): Promise<string | undefined> {
1074
1226
  return this.poolManager?.defaultDatabaseName;
1075
1227
  }
@@ -1273,10 +1425,10 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1273
1425
  const pendingNotifications: PostgresBackendDriver["_pendingNotifications"] = [];
1274
1426
 
1275
1427
  const result = await this.delegate.db.transaction(async (tx) => {
1276
- let userId = this.user?.uid;
1277
- if (!userId) {
1428
+ let uid = this.user?.uid;
1429
+ if (!uid) {
1278
1430
  logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
1279
- userId = "anonymous";
1431
+ uid = "anonymous";
1280
1432
  }
1281
1433
 
1282
1434
  const userRoles = this.user?.roles ?? [];
@@ -1295,7 +1447,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1295
1447
  //
1296
1448
  // Fails closed: if the switch cannot be performed, the transaction
1297
1449
  // aborts rather than falling back to an RLS-bypassing connection.
1298
- await applyAuthContext(tx, { userId, roles: userRoles }, this.delegate.rlsUserRole);
1450
+ await applyAuthContext(tx, { uid, roles: userRoles }, this.delegate.rlsUserRole);
1299
1451
 
1300
1452
  const txEntityService = new DataService(tx, this.delegate.registry);
1301
1453
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
@@ -1334,7 +1486,7 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1334
1486
  */
1335
1487
  private injectAuthContext(unsubscribe: () => void): () => void {
1336
1488
  const authContext = {
1337
- userId: this.user?.uid || "anonymous",
1489
+ uid: this.user?.uid || "anonymous",
1338
1490
  roles: this.user?.roles ?? []
1339
1491
  };
1340
1492
  const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
@@ -1362,6 +1514,19 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1362
1514
  return this.withTransaction((delegate) => delegate.save(props));
1363
1515
  }
1364
1516
 
1517
+ /**
1518
+ * One transaction for the whole batch, rather than one per row.
1519
+ *
1520
+ * This is the point of the method: `save` opens a transaction per call, so
1521
+ * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
1522
+ * round trips). Here the RLS context is established once and every row lands
1523
+ * or none does. Realtime notifications are already deferred to commit by
1524
+ * `withTransaction`, so a batch does not flood subscribers mid-flight.
1525
+ */
1526
+ async saveMany<M extends Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
1527
+ return this.withTransaction((delegate) => delegate.saveMany(props));
1528
+ }
1529
+
1365
1530
  async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
1366
1531
  return this.withTransaction((delegate) => delegate.delete(props));
1367
1532
  }