@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g18cfeb7

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 (112) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-Do9mw5Y5.js} +602 -200
  5. package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +40 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/ensure-collection-policies-DMUdRQdM.js +57 -0
  15. package/dist/ensure-collection-policies-DMUdRQdM.js.map +1 -0
  16. package/dist/ensure-collection-tables-DSIxvLLD.js +694 -0
  17. package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
  18. package/dist/index.es.js +1701 -10167
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/policy-CPkCqVTz.js +105 -0
  21. package/dist/policy-CPkCqVTz.js.map +1 -0
  22. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  24. package/dist/schema/auth-schema.d.ts +83 -144
  25. package/dist/schema/auth-users-columns.d.ts +97 -0
  26. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  27. package/dist/schema/ensure-collection-tables.d.ts +45 -3
  28. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  29. package/dist/schema/generate-postgres-ddl-logic.d.ts +137 -3
  30. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  31. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  32. package/dist/schema/introspect-db-logic.d.ts +94 -5
  33. package/dist/schema/introspect-db-queries.d.ts +119 -0
  34. package/dist/schema/introspect-db-structure.d.ts +263 -0
  35. package/dist/schema/introspect-db-types.d.ts +11 -0
  36. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  37. package/dist/security/rls-enforcement.d.ts +53 -2
  38. package/dist/services/FetchService.d.ts +14 -8
  39. package/dist/services/RelationService.d.ts +24 -1
  40. package/dist/services/channel-bus/index.d.ts +1 -7
  41. package/dist/services/collection-helpers.d.ts +36 -2
  42. package/dist/services/dataService.d.ts +5 -1
  43. package/dist/services/realtimeService.d.ts +25 -21
  44. package/dist/services/row-pipeline.d.ts +1 -1
  45. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  46. package/dist/src-C_wvdMnl.js.map +1 -0
  47. package/dist/utils/connection-string.d.ts +29 -0
  48. package/dist/utils/drizzle-conditions.d.ts +5 -4
  49. package/dist/utils/pg-error-utils.d.ts +35 -0
  50. package/dist/websocket-D0TBU3ia.js +530 -0
  51. package/dist/websocket-D0TBU3ia.js.map +1 -0
  52. package/package.json +17 -16
  53. package/src/PostgresAdapter.ts +21 -2
  54. package/src/PostgresBackendDriver.ts +169 -3
  55. package/src/PostgresBootstrapper.ts +233 -35
  56. package/src/auth/ensure-tables.ts +335 -81
  57. package/src/auth/services.ts +24 -2
  58. package/src/backup/backup-cli.ts +41 -2
  59. package/src/backup/backup-service.ts +38 -5
  60. package/src/backup/pg-tools.ts +96 -3
  61. package/src/cli-helpers.ts +92 -9
  62. package/src/cli.ts +219 -56
  63. package/src/collections/validate-relations.ts +136 -14
  64. package/src/connection.ts +73 -0
  65. package/src/data-transformer.ts +22 -6
  66. package/src/databasePoolManager.ts +5 -2
  67. package/src/history/ensure-history-table.ts +7 -0
  68. package/src/schema/auth-schema.ts +30 -19
  69. package/src/schema/auth-users-columns.ts +131 -0
  70. package/src/schema/doctor.ts +7 -5
  71. package/src/schema/ensure-collection-policies.ts +105 -0
  72. package/src/schema/ensure-collection-tables.test.ts +105 -9
  73. package/src/schema/ensure-collection-tables.ts +301 -44
  74. package/src/schema/generate-drizzle-schema-logic.ts +49 -7
  75. package/src/schema/generate-postgres-ddl-logic.ts +474 -27
  76. package/src/schema/generated-schema-staleness.ts +169 -0
  77. package/src/schema/introspect-db-constraints.ts +385 -0
  78. package/src/schema/introspect-db-inference.ts +18 -8
  79. package/src/schema/introspect-db-logic.ts +385 -71
  80. package/src/schema/introspect-db-queries.ts +326 -0
  81. package/src/schema/introspect-db-structure.ts +670 -0
  82. package/src/schema/introspect-db-types.ts +56 -0
  83. package/src/schema/introspect-db.ts +37 -80
  84. package/src/schema/introspect-runtime.test.ts +56 -8
  85. package/src/schema/introspect-runtime.ts +31 -9
  86. package/src/schema/non-sql-collections.test.ts +131 -0
  87. package/src/schema/rls-bootstrap-sql.ts +288 -0
  88. package/src/security/anonymous-grants.test.ts +4 -2
  89. package/src/security/policy-drift.test.ts +11 -3
  90. package/src/security/rls-enforcement.ts +141 -3
  91. package/src/services/BranchService.ts +71 -28
  92. package/src/services/FetchService.ts +100 -107
  93. package/src/services/PersistService.ts +34 -7
  94. package/src/services/RelationService.ts +249 -48
  95. package/src/services/channel-bus/index.ts +0 -9
  96. package/src/services/channel-history.ts +8 -0
  97. package/src/services/channel-presence.ts +6 -0
  98. package/src/services/collection-helpers.ts +69 -3
  99. package/src/services/dataService.ts +5 -1
  100. package/src/services/realtimeService.ts +38 -35
  101. package/src/services/row-pipeline.ts +1 -1
  102. package/src/utils/connection-string.ts +58 -0
  103. package/src/utils/drizzle-conditions.ts +31 -6
  104. package/src/utils/pg-error-utils.ts +65 -0
  105. package/src/websocket.ts +18 -9
  106. package/dist/chunk-DSJWtz9O.js +0 -40
  107. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  108. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  109. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  110. package/dist/src-BbFOPJ1S.js.map +0 -1
  111. package/dist/src-Zqwaw3P5.js.map +0 -1
  112. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
4
4
  import { randomUUID } from "crypto";
5
5
  import { DataService } from "./dataService";
6
6
 
7
- import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
7
+ import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
8
8
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
9
9
  import { sql as drizzleSql } from "drizzle-orm";
10
10
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
@@ -41,6 +41,28 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
41
41
  subscriptionId: string
42
42
  };
43
43
 
44
+ /**
45
+ * The narrowing a collection subscription was created with, kept so that every
46
+ * refetch answers the same query the initial fetch did.
47
+ *
48
+ * Named once because it used to be written out inline in five places, and a
49
+ * field missing from one of them is accepted over the wire and then silently
50
+ * ignored: `offset` was declared on the incoming props and never stored, so a
51
+ * live list on page three served page one, and `logical` was never stored
52
+ * either, so an `or(...)` subscription was pushed every row in the table.
53
+ */
54
+ type StoredCollectionRequest = {
55
+ filter?: Record<string, unknown>;
56
+ logical?: LogicalCondition;
57
+ orderBy?: string;
58
+ order?: "desc" | "asc";
59
+ limit?: number;
60
+ offset?: number;
61
+ startAfter?: Record<string, unknown>;
62
+ databaseId?: string;
63
+ searchString?: string;
64
+ };
65
+
44
66
  type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
45
67
 
46
68
  /**
@@ -119,16 +141,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
119
141
  path: string;
120
142
  id?: string | number;
121
143
  // Store full collection request parameters for proper refetching
122
- collectionRequest?: {
123
- filter?: Record<string, unknown>;
124
- orderBy?: string;
125
- order?: "desc" | "asc";
126
- limit?: number;
127
- offset?: number;
128
- startAfter?: Record<string, unknown>;
129
- databaseId?: string;
130
- searchString?: string;
131
- };
144
+ collectionRequest?: StoredCollectionRequest;
132
145
  // Auth context for RLS — when set, refetches run in a transaction
133
146
  // with set_config('app.uid', ...) / set_config('app.user_roles', ...)
134
147
  authContext?: SubscriptionAuthContext;
@@ -212,16 +225,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
212
225
  type: "collection" | "single";
213
226
  path: string;
214
227
  id?: string | number;
215
- collectionRequest?: {
216
- filter?: Record<string, unknown>;
217
- orderBy?: string;
218
- order?: "desc" | "asc";
219
- limit?: number;
220
- offset?: number;
221
- startAfter?: Record<string, unknown>;
222
- databaseId?: string;
223
- searchString?: string;
224
- };
228
+ collectionRequest?: StoredCollectionRequest;
225
229
  authContext?: SubscriptionAuthContext;
226
230
  }) {
227
231
  this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
@@ -448,9 +452,11 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
448
452
  path: request.path,
449
453
  collectionRequest: {
450
454
  filter: request.filter,
455
+ logical: request.logical,
451
456
  orderBy: request.orderBy,
452
457
  order: request.order,
453
458
  limit: boundedLimit,
459
+ offset: request.offset,
454
460
  startAfter: request.startAfter as Record<string, unknown> | undefined,
455
461
  databaseId: request.collection?.databaseId,
456
462
  searchString: request.searchString
@@ -458,17 +464,12 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
458
464
  authContext
459
465
  });
460
466
 
461
- // Send initial data
467
+ // Send initial data. Built from the request the subscription just
468
+ // stored, so the first answer and every refetch after it cannot
469
+ // describe different queries.
462
470
  const rows = await this.fetchCollectionWithAuth(
463
471
  request.path,
464
- {
465
- filter: request.filter,
466
- orderBy: request.orderBy,
467
- order: request.order,
468
- limit: boundedLimit,
469
- startAfter: request.startAfter as Record<string, unknown> | undefined,
470
- searchString: request.searchString
471
- },
472
+ this._subscriptions.get(subscriptionId)!.collectionRequest!,
472
473
  authContext
473
474
  );
474
475
 
@@ -679,7 +680,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
679
680
  private debouncedCollectionRefetch(
680
681
  subscriptionId: string,
681
682
  notifyPath: string,
682
- subscription: { clientId: string; collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext }
683
+ subscription: { clientId: string; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext }
683
684
  ) {
684
685
  const timerKey = `ws_${subscriptionId}`;
685
686
  const existing = this.refetchTimers.get(timerKey);
@@ -705,7 +706,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
705
706
  private debouncedDriverRefetch(
706
707
  subscriptionId: string,
707
708
  notifyPath: string,
708
- subscription: { collectionRequest?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext },
709
+ subscription: { collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext },
709
710
  callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
710
711
  ) {
711
712
  const timerKey = `drv_${subscriptionId}`;
@@ -731,7 +732,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
731
732
  */
732
733
  private async fetchCollectionWithAuth(
733
734
  notifyPath: string,
734
- collectionRequest: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string },
735
+ collectionRequest: StoredCollectionRequest,
735
736
  authContext?: SubscriptionAuthContext
736
737
  ): Promise<Record<string, unknown>[]> {
737
738
  if (this.driver) {
@@ -740,6 +741,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
740
741
  path: notifyPath,
741
742
  collection: collection,
742
743
  filter: collectionRequest.filter as FetchCollectionProps["filter"],
744
+ logical: collectionRequest.logical,
743
745
  orderBy: collectionRequest.orderBy,
744
746
  order: collectionRequest.order,
745
747
  limit: collectionRequest.limit,
@@ -751,7 +753,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
751
753
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
752
754
  // Refetches are reads: apply the same GUCs + reader-role downgrade as the
753
755
  // driver's read path, so realtime cannot leak rows the initial fetch hid.
754
- const activeAuth = authContext || { uid: "anon",
756
+ const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
755
757
  roles: ["anon"] };
756
758
  return await this.db.transaction(async (tx) => {
757
759
  await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
@@ -772,6 +774,7 @@ roles: ["anon"] };
772
774
  } else {
773
775
  fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
774
776
  filter: collectionRequest.filter as FilterValues<string>,
777
+ logical: collectionRequest.logical,
775
778
  orderBy: collectionRequest.orderBy,
776
779
  order: collectionRequest.order,
777
780
  limit: collectionRequest.limit,
@@ -931,7 +934,7 @@ roles: activeAuth.roles },
931
934
 
932
935
  // Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
933
936
  // Same read isolation as collection refetches: GUCs + reader-role downgrade.
934
- const activeAuth = authContext || { uid: "anon",
937
+ const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
935
938
  roles: ["anon"] };
936
939
  return await this.db.transaction(async (tx) => {
937
940
  await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
@@ -164,7 +164,7 @@ export function relationTargetAddress(
164
164
  * expects real types. The row's own address is *not* among the columns — it is
165
165
  * derived by the consumer from the collection's primary keys.
166
166
  */
167
- export function toCmsRow(
167
+ export function toFlatRow(
168
168
  row: Record<string, unknown>,
169
169
  collection: CollectionConfig,
170
170
  registry: PostgresCollectionRegistry
@@ -0,0 +1,58 @@
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
+ /**
9
+ * The `sslmode` values libpq accepts. `no-verify` is conspicuously not one.
10
+ */
11
+ const LIBPQ_SSLMODES = new Set(["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]);
12
+
13
+ /**
14
+ * Make a connection string safe to hand to a libpq program.
15
+ *
16
+ * `sslmode=no-verify` is a node-postgres convention — encrypt, but do not check
17
+ * the certificate — and libpq does not have it. It does not degrade or warn:
18
+ *
19
+ * psql: error: invalid sslmode value: "no-verify"
20
+ *
21
+ * which means a `DATABASE_URL` that works perfectly for the app makes `psql`,
22
+ * `pg_dump`, `pg_restore` and Atlas all refuse to start, with an error that
23
+ * points at the value rather than at the convention it comes from. It has cost
24
+ * this project time more than once.
25
+ *
26
+ * `require` is the honest translation: libpq's `require` encrypts and does not
27
+ * verify the certificate either, which is exactly what `no-verify` asks for.
28
+ * Nothing is relaxed by the rewrite — `verify-ca` and `verify-full` are left
29
+ * alone, so a connection string that asked for verification still gets it.
30
+ *
31
+ * Only `sslmode` is touched, and only when it is a value libpq would reject.
32
+ * Anything unparseable is returned as given: a connection that works
33
+ * unverified beats one this corrupted.
34
+ */
35
+ export function forLibpq(connectionString: string): string {
36
+ let url: URL;
37
+ try {
38
+ url = new URL(connectionString);
39
+ } catch {
40
+ // Key/value DSNs and anything else we cannot parse. A `sslmode` in one
41
+ // of those is space-separated rather than a query parameter, and
42
+ // rewriting it with a regex is how a password containing "sslmode="
43
+ // gets mangled.
44
+ return connectionString;
45
+ }
46
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
47
+
48
+ const sslmode = url.searchParams.get("sslmode");
49
+ if (sslmode === null || LIBPQ_SSLMODES.has(sslmode)) return connectionString;
50
+
51
+ url.searchParams.set("sslmode", "require");
52
+ // Re-serialize by hand for the reason `pinSearchPath` does: URLSearchParams
53
+ // writes a space as `+`, which node-postgres decodes and libpq does not.
54
+ url.search = Array.from(url.searchParams.entries())
55
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
56
+ .join("&");
57
+ return url.toString();
58
+ }
@@ -175,10 +175,11 @@ export class DrizzleConditionBuilder {
175
175
  static buildRelationScopeCondition(
176
176
  relation: ResolvedRelation,
177
177
  /**
178
- * Lazy: only `via` and `belongsTo` need the parent's own table. A
179
- * foreign key on the target and a junction are both expressible from
180
- * the parent's *id* alone, and requiring the table for them would make
181
- * a child listing fail on a parent whose table isn't registered.
178
+ * Lazy: `via`, `belongsTo`, and a foreign key that points at a
179
+ * `sourceKey` need the parent's own table. A junction and a plain
180
+ * foreign key are expressible from the parent's *id* alone, and
181
+ * requiring the table for them would make a child listing fail on a
182
+ * parent whose table isn't registered.
182
183
  */
183
184
  parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
184
185
  parentId: string | number,
@@ -239,7 +240,16 @@ export class DrizzleConditionBuilder {
239
240
  `relation '${relation.relationName}'.`
240
241
  );
241
242
  }
242
- return eq(fkColumn, parentId);
243
+ if (!relation.sourceKey) return eq(fkColumn, parentId);
244
+
245
+ // A link on a natural key: the foreign key holds a column of the
246
+ // parent row, not its id, so the parent has to be read. As a
247
+ // subquery rather than a prior SELECT, for the same reason
248
+ // `belongsTo` below is — one statement sees one snapshot, and a
249
+ // scope condition that read the key separately could be built
250
+ // from a value the very next statement no longer agrees with.
251
+ const { table, idColumn } = parent();
252
+ return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
243
253
  }
244
254
 
245
255
  case "belongsTo": {
@@ -385,7 +395,22 @@ export class DrizzleConditionBuilder {
385
395
  // inverse, so reversing it into a filter is a different problem
386
396
  // from the two shapes below rather than a third case of them.
387
397
  if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
388
- return { kind: "relation", relation, registry, sourceIdColumn };
398
+ // The `EXISTS` correlates the target's foreign key with a column
399
+ // on *this* table, and that is the primary key only when the
400
+ // link joins on it. A `sourceKey` names a different one, and
401
+ // correlating on the id anyway silently matches nothing —
402
+ // "filter by this relation" would quietly return zero rows.
403
+ const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey
404
+ ? columnAt(relation.sourceKey)
405
+ : sourceIdColumn;
406
+ if (!correlationColumn) {
407
+ throw new Error(
408
+ `\`sourceKey: "${(relation as ResolvedForeignKeyOnTarget).sourceKey}"\` on relation ` +
409
+ `'${relation.relationName}' is not a column on '${collectionPath}', so a filter on ` +
410
+ "that relation has nothing to correlate against."
411
+ );
412
+ }
413
+ return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
389
414
  }
390
415
  }
391
416
 
@@ -79,6 +79,25 @@ export function extractPgError(error: unknown): PostgresError | null {
79
79
  return null;
80
80
  }
81
81
 
82
+ /**
83
+ * Whether the failure came back from Postgres rather than from building the
84
+ * query — which decides whether a fallback query is worth issuing.
85
+ *
86
+ * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
87
+ * RLS). Once a statement raises, that transaction is aborted, and every later
88
+ * statement on it returns `25P02` — "current transaction is aborted, commands
89
+ * ignored until end of transaction block". So a retry after a database error
90
+ * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
91
+ * for type uuid") with a generic one. Rethrow instead.
92
+ *
93
+ * A query the driver could not even build — a missing reciprocal relation, say
94
+ * — never reached Postgres, leaves the transaction usable, and is exactly what
95
+ * the fallback paths exist for.
96
+ */
97
+ export function reachedDatabase(error: unknown): boolean {
98
+ return extractPgError(error) !== null;
99
+ }
100
+
82
101
  /**
83
102
  * Walk the error cause chain and return the deepest meaningful message.
84
103
  */
@@ -97,6 +116,52 @@ export function extractCauseMessage(error: unknown): string | null {
97
116
  return null;
98
117
  }
99
118
 
119
+ /**
120
+ * Codes that mean "this connection will never work as configured".
121
+ *
122
+ * A wrong password or a database that does not exist is a settled fact about
123
+ * the connection string, not a transient fault — retrying produces the same
124
+ * answer forever.
125
+ */
126
+ const UNRECOVERABLE_CONNECT_CODES = new Set([
127
+ "28P01", // invalid_password
128
+ "28000", // invalid_authorization_specification
129
+ "3D000", // invalid_catalog_name — the database does not exist
130
+ "42501" // insufficient_privilege
131
+ ]);
132
+
133
+ export interface ConnectFailure {
134
+ /** True when retrying cannot help: the connection string itself is wrong. */
135
+ fatal: boolean;
136
+ /** The deepest message available — the Postgres one where there is one. */
137
+ reason: string;
138
+ /** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */
139
+ code?: string;
140
+ }
141
+
142
+ /**
143
+ * Describe a failed connection attempt in terms a developer can act on.
144
+ *
145
+ * The error a caller catches is Drizzle's wrapper: its message is
146
+ * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
147
+ * the sentence that says what is actually wrong — "password authentication
148
+ * failed for user …", "database … does not exist" — sits in `.cause`. Logging
149
+ * the wrapper, as the bootstrapper used to, tells a developer with a typo in
150
+ * their `DATABASE_URL` nothing at all.
151
+ */
152
+ export function classifyConnectFailure(error: unknown): ConnectFailure {
153
+ const pgError = extractPgError(error);
154
+ const reason =
155
+ pgError?.message ??
156
+ extractCauseMessage(error) ??
157
+ (error instanceof Error ? error.message : String(error));
158
+ return {
159
+ fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
160
+ reason,
161
+ code: pgError?.code
162
+ };
163
+ }
164
+
100
165
  /**
101
166
  * Detect whether an error is specifically a role-switching permission failure
102
167
  * (e.g. "permission denied to set role" or "must be member of role"),
package/src/websocket.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { RealtimeService } from "./services/realtimeService";
2
2
  import { PostgresBackendDriver } from "./PostgresBackendDriver";
3
3
  import type { DataDriver, DeleteProps, FetchCollectionProps, FetchOneProps, SaveProps, TableMetadata, BranchInfo, AuthAdapter } from "@rebasepro/types";
4
- import { isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
4
+ import { ANONYMOUS_USER_ID, isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
5
5
  import type { User } from "@rebasepro/types";
6
6
 
7
7
  import { WebSocketServer, WebSocket } from "ws";
8
8
  import { Server } from "http";
9
9
  import { inspect } from "util";
10
- import { extractUserFromToken, AccessTokenPayload, safeCompare } from "@rebasepro/server";
10
+ import { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth } from "@rebasepro/server";
11
11
  import { logger } from "@rebasepro/server";
12
12
 
13
13
  /** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
@@ -116,11 +116,20 @@ export function createPostgresWebSocket(
116
116
  logger.error("❌ [WebSocket Server] Error", { error: err });
117
117
  });
118
118
 
119
- // Auth is required when either: an adapter is present (secure by default),
120
- // OR the config has a jwtSecret and requireAuth !== false.
121
- const requireAuth = authAdapter
122
- ? true
123
- : (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
119
+ // The same predicate the HTTP data routes use, from the same function —
120
+ // this socket is the other enforcement point for one product decision, and
121
+ // while it computed the answer itself it computed a different one. See
122
+ // `resolveRequireAuth` for what its local copy got wrong and why a `false`
123
+ // here grants access rather than skipping a check.
124
+ const requireAuth = !!authAdapter || resolveRequireAuth(authConfig as never);
125
+
126
+ if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) {
127
+ logger.warn(
128
+ "🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or " +
129
+ "serviceKey is configured — no client can complete AUTH, so every realtime " +
130
+ "message will be refused with UNAUTHORIZED."
131
+ );
132
+ }
124
133
 
125
134
  wss.on("connection", (ws) => {
126
135
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
@@ -281,7 +290,7 @@ roles: verifiedUser.roles }
281
290
  roles: session.user.roles ?? []
282
291
  }
283
292
  : {
284
- uid: "anon",
293
+ uid: ANONYMOUS_USER_ID,
285
294
  displayName: null,
286
295
  email: null,
287
296
  photoURL: null,
@@ -620,7 +629,7 @@ colors: true }));
620
629
  const authContext = session?.user
621
630
  ? { uid: session.user.uid,
622
631
  roles: session.user.roles ?? [] }
623
- : { uid: "anon",
632
+ : { uid: ANONYMOUS_USER_ID,
624
633
  roles: ["anon"] };
625
634
  // Let RealtimeService handle these messages
626
635
  await realtimeService.handleClientMessage(clientId, {
@@ -1,40 +0,0 @@
1
- import { createRequire as __createRequire } from "module";
2
- import "process";
3
- const require = __createRequire(import.meta.url);
4
- //#region \0rolldown/runtime.js
5
- var __create = Object.create;
6
- var __defProp = Object.defineProperty;
7
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
8
- var __getOwnPropNames = Object.getOwnPropertyNames;
9
- var __getProtoOf = Object.getPrototypeOf;
10
- var __hasOwnProp = Object.prototype.hasOwnProperty;
11
- var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
12
- var __exportAll = (all, no_symbols) => {
13
- let target = {};
14
- for (var name in all) __defProp(target, name, {
15
- get: all[name],
16
- enumerable: true
17
- });
18
- if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
19
- return target;
20
- };
21
- var __copyProps = (to, from, except, desc) => {
22
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
23
- key = keys[i];
24
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
25
- get: ((k) => from[k]).bind(null, key),
26
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
27
- });
28
- }
29
- return to;
30
- };
31
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
32
- value: mod,
33
- enumerable: true
34
- }) : target, mod));
35
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
36
- if (typeof require !== "undefined") return require.apply(this, arguments);
37
- throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
38
- });
39
- //#endregion
40
- export { __toESM as i, __exportAll as n, __require as r, __commonJSMin as t };