@rebasepro/server-postgres 0.9.1-canary.a57c262 → 0.9.1-canary.a8dbf1c

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 (42) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +18 -0
  3. package/dist/PostgresBootstrapper.d.ts +17 -1
  4. package/dist/auth/services.d.ts +68 -52
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/index.es.js +914 -226
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
  9. package/dist/schema/auth-schema.d.ts +24 -24
  10. package/dist/schema/doctor.d.ts +1 -1
  11. package/dist/schema/introspect-db-logic.d.ts +0 -5
  12. package/dist/schema/introspect-db-naming.d.ts +10 -0
  13. package/dist/security/policy-drift.d.ts +30 -0
  14. package/dist/security/rls-enforcement.d.ts +2 -2
  15. package/dist/services/channel-history.d.ts +118 -0
  16. package/dist/services/realtimeService.d.ts +69 -2
  17. package/dist/services/row-pipeline.d.ts +5 -2
  18. package/package.json +8 -31
  19. package/src/PostgresBackendDriver.ts +74 -10
  20. package/src/PostgresBootstrapper.ts +69 -3
  21. package/src/auth/ensure-tables.ts +170 -28
  22. package/src/auth/services.ts +181 -150
  23. package/src/cli.ts +60 -0
  24. package/src/connection.ts +61 -1
  25. package/src/databasePoolManager.ts +2 -0
  26. package/src/schema/auth-bootstrap-sql.ts +7 -1
  27. package/src/schema/auth-schema.ts +13 -13
  28. package/src/schema/doctor.ts +45 -20
  29. package/src/schema/introspect-db-inference.ts +1 -1
  30. package/src/schema/introspect-db-logic.ts +1 -10
  31. package/src/schema/introspect-db-naming.ts +15 -0
  32. package/src/schema/introspect-db.ts +11 -1
  33. package/src/schema/introspect-runtime.ts +1 -1
  34. package/src/security/policy-drift.test.ts +106 -1
  35. package/src/security/policy-drift.ts +56 -0
  36. package/src/security/rls-enforcement.ts +11 -5
  37. package/src/services/PersistService.ts +9 -2
  38. package/src/services/channel-history.ts +343 -0
  39. package/src/services/realtimeService.ts +198 -10
  40. package/src/services/row-pipeline.ts +70 -7
  41. package/src/utils/drizzle-conditions.ts +13 -0
  42. package/src/websocket.ts +30 -11
package/dist/index.es.js CHANGED
@@ -71,16 +71,51 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
71
71
  var connection_exports = /* @__PURE__ */ __exportAll({
72
72
  createDirectDatabaseConnection: () => createDirectDatabaseConnection,
73
73
  createPostgresDatabaseConnection: () => createPostgresDatabaseConnection,
74
- createReadReplicaConnection: () => createReadReplicaConnection
74
+ createReadReplicaConnection: () => createReadReplicaConnection,
75
+ guardPoolAgainstDirtyRelease: () => guardPoolAgainstDirtyRelease
75
76
  });
76
77
  var DEFAULT_POOL = {
77
78
  max: 20,
78
79
  idleTimeoutMillis: 3e4,
79
80
  connectionTimeoutMillis: 1e4,
80
- queryTimeout: 3e4,
81
+ queryTimeout: 6e4,
81
82
  statementTimeout: 3e4,
82
83
  keepAlive: true
83
84
  };
85
+ /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
86
+ var TX_IDLE = "I";
87
+ /**
88
+ * Destroy pool clients that are released while still inside a transaction.
89
+ *
90
+ * pg-pool returns a client to the idle list whenever `release()` is called
91
+ * without an error — even if the connection is still mid-transaction (status
92
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
93
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
94
+ * (e.g. it was queued behind a statement that hit the client-side
95
+ * query_timeout), the client goes back dirty. The next checkout then runs
96
+ * its statements inside the zombie transaction — with the previous request's
97
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
98
+ * RLS-scoped ones (observed in production as registration failing with
99
+ * SQLSTATE 42501 under a leaked anonymous context).
100
+ *
101
+ * pg-pool emits `release` before it consults its private `_expired` set, so
102
+ * marking the client expired here makes `_release()` destroy it instead of
103
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
104
+ * private APIs — feature-detect and fall back to loud logging so an upstream
105
+ * change degrades to observability, never to silent corruption.
106
+ */
107
+ function guardPoolAgainstDirtyRelease(pool, label) {
108
+ pool.on("release", (err, client) => {
109
+ if (err) return;
110
+ const txStatus = client?._txStatus;
111
+ if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
112
+ const expired = pool._expired;
113
+ if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
114
+ expired.add(client);
115
+ logger.error(`[${label}] Client released back to the pool while still in a transaction (status '${txStatus}') — destroying it so the open transaction and its session state (RLS GUCs) cannot leak into the next request.`);
116
+ } else logger.error(`[${label}] Client released mid-transaction (status '${txStatus}') but the pool's internal expiry set is unavailable (pg-pool internals changed?). The connection may leak its open transaction into subsequent requests.`);
117
+ });
118
+ }
84
119
  /**
85
120
  * Create a Drizzle-backed Postgres connection with a production-grade
86
121
  * connection pool.
@@ -112,6 +147,7 @@ function createPostgresDatabaseConnection(connectionString, schema, poolConfig)
112
147
  logger.error("[pg-pool] Unexpected pool error", { detail: err.message });
113
148
  if (err.message.includes("ETIMEDOUT")) logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
114
149
  });
150
+ guardPoolAgainstDirtyRelease(pool, "pg-pool");
115
151
  return {
116
152
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
117
153
  pool,
@@ -144,6 +180,7 @@ function createDirectDatabaseConnection(connectionString, schema, poolConfig) {
144
180
  pool.on("error", (err) => {
145
181
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
146
182
  });
183
+ guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
147
184
  return {
148
185
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
149
186
  pool,
@@ -173,6 +210,7 @@ function createReadReplicaConnection(connectionString, schema, poolConfig) {
173
210
  pool.on("error", (err) => {
174
211
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
175
212
  });
213
+ guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
176
214
  return {
177
215
  db: schema ? drizzle(pool, { schema }) : drizzle(pool),
178
216
  pool,
@@ -315,7 +353,7 @@ function getDeclaredSubcollections(collection) {
315
353
  /**
316
354
  * The id a request without a logged-in user reports as `auth.uid()`.
317
355
  *
318
- * A user-context request always sets `app.user_id`: blank would read back as
356
+ * A user-context request always sets `app.uid`: blank would read back as
319
357
  * `NULL`, and `NULL` is how the trusted server context is recognised, so an
320
358
  * anonymous visitor would be promoted to server privileges. The driver
321
359
  * therefore substitutes this sentinel at the single chokepoint where the GUC
@@ -2096,7 +2134,7 @@ function getSubcollections(collection) {
2096
2134
  * It handles:
2097
2135
  * - `field = 'literal'`
2098
2136
  * - `field != 'literal'`
2099
- * - `field = current_setting('app.user_id')`
2137
+ * - `field = current_setting('app.uid')` (or the legacy `app.user_id`)
2100
2138
  * - `A AND B`, `A OR B` — only where the keyword is at the top level
2101
2139
  * - `true`
2102
2140
  * - `IN (...)` (as optimistic true)
@@ -2297,7 +2335,7 @@ function findAnonymousGrants(expr) {
2297
2335
  return found;
2298
2336
  }
2299
2337
  function parseOperand(str) {
2300
- if (/current_setting\s*\(\s*'app\.user_id'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
2338
+ if (/current_setting\s*\(\s*'app\.(uid|user_id)'\s*\)/i.test(str) || /auth\.uid\(\)/i.test(str)) return policy.authUid();
2301
2339
  const stringMatch = str.match(/^'(.+)'$/);
2302
2340
  if (stringMatch) return policy.literal(stringMatch[1]);
2303
2341
  if (/^\w+$/.test(str)) return policy.field(str);
@@ -5228,6 +5266,7 @@ var DrizzleConditionBuilder = class {
5228
5266
  static buildVectorSearchConditions(table, vectorSearch) {
5229
5267
  const column = table[vectorSearch.property];
5230
5268
  if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
5269
+ if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
5231
5270
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
5232
5271
  const distanceFn = vectorSearch.distance || "cosine";
5233
5272
  let operator;
@@ -6456,9 +6495,56 @@ function unwrapJunctionRow(item) {
6456
6495
  const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
6457
6496
  return nestedKey ? item[nestedKey] : item;
6458
6497
  }
6498
+ /**
6499
+ * Give back the number a `number` property was declared to be.
6500
+ *
6501
+ * Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
6502
+ * numeric can hold more precision than a double. But the collection declared
6503
+ * this property `number` and the OpenAPI spec this server publishes says
6504
+ * `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
6505
+ * asymmetrically, because a create answers with the number and the read that
6506
+ * follows answers with the string. Any client that multiplies a price works
6507
+ * until the first refresh.
6508
+ *
6509
+ * Declaring a property `number` already accepts double precision — that is what
6510
+ * the admin has always parsed it to. Columns REST serves that no property
6511
+ * declares are left exactly as the database returned them.
6512
+ */
6513
+ function coerceDeclaredNumber(value, property) {
6514
+ if (property?.type !== "number" || typeof value !== "string") return value;
6515
+ const parsed = parseFloat(value);
6516
+ return isNaN(parsed) ? null : parsed;
6517
+ }
6518
+ /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
6519
+ function coerceDeclaredNumbers(row, collection) {
6520
+ const properties = collection.properties;
6521
+ if (!properties) return row;
6522
+ const out = {};
6523
+ for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
6524
+ return out;
6525
+ }
6459
6526
  /** Render one target row in the requested style. */
6527
+ /**
6528
+ * Drop every column the collection marked `excludeFromApi`.
6529
+ *
6530
+ * Password hashes and verification tokens have to be readable server-side but
6531
+ * must never reach a client — and "never" has to mean every exit from this
6532
+ * pipeline, including relation targets, or a secret leaks through whichever
6533
+ * path was overlooked. Keyed by both the property name and its column name,
6534
+ * since a row can arrive keyed either way depending on the caller.
6535
+ */
6536
+ function stripExcluded(row, collection) {
6537
+ const properties = collection.properties;
6538
+ if (!properties) return row;
6539
+ for (const [key, property] of Object.entries(properties)) {
6540
+ if (!property?.excludeFromApi) continue;
6541
+ delete row[key];
6542
+ if (property.columnName) delete row[property.columnName];
6543
+ }
6544
+ return row;
6545
+ }
6460
6546
  function renderTarget(targetRow, targetCollection, style, registry) {
6461
- if (style === "inline") return { ...targetRow };
6547
+ if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
6462
6548
  const address = relationTargetAddress(targetRow, targetCollection, registry);
6463
6549
  const path = targetCollection.slug;
6464
6550
  return createRelationRefWithData(address, path, {
@@ -6500,14 +6586,17 @@ function toCmsRow(row, collection, registry) {
6500
6586
  normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
6501
6587
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
6502
6588
  }
6503
- return normalized;
6589
+ return stripExcluded(normalized, collection);
6504
6590
  }
6505
6591
  /**
6506
6592
  * The row REST serves: every column under its own name, with the value Postgres
6507
6593
  * returned, and relations inlined as the target's columns.
6508
6594
  *
6509
- * Deliberately not normalized: REST serves what the database holds, and JSON
6510
- * has its own opinions about dates that the admin's view-model does not share.
6595
+ * Values are the ones the database returned, except where that contradicts the
6596
+ * declared type: a `number` property is served as a number (see
6597
+ * {@link coerceDeclaredNumber}). Dates stay as the database returned them —
6598
+ * JSON has its own opinions about dates that the admin's view-model does not
6599
+ * share.
6511
6600
  *
6512
6601
  * Keyed by the row rather than by the relation list — a REST fetch only loads
6513
6602
  * the relations `include` asked for, so the row is the authority on which are
@@ -6520,9 +6609,9 @@ function toRestRow(row, collection, registry) {
6520
6609
  const relation = findRelation(resolvedRelations, key);
6521
6610
  if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
6522
6611
  else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
6523
- else flat[key] = value;
6612
+ else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
6524
6613
  }
6525
- return flat;
6614
+ return stripExcluded(flat, collection);
6526
6615
  }
6527
6616
  //#endregion
6528
6617
  //#region src/services/FetchService.ts
@@ -7625,7 +7714,7 @@ var PersistService = class {
7625
7714
  } catch (error) {
7626
7715
  throw this.toUserFriendlyError(error, collection.slug);
7627
7716
  }
7628
- const finalEntity = await this.fetchService.fetchOne(collection.slug, savedId, databaseId);
7717
+ const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
7629
7718
  if (!finalEntity) throw new Error("Could not fetch row after save.");
7630
7719
  return finalEntity;
7631
7720
  }
@@ -8093,7 +8182,7 @@ async function ensureAppRole(run, schemas) {
8093
8182
  * SECURITY: this function is only ever called on the **user** path (the server
8094
8183
  * context uses the base/owner driver and never calls it). The default policies
8095
8184
  * treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
8096
- * is `NULLIF(current_setting('app.user_id'), '')` — so an EMPTY user id would
8185
+ * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
8097
8186
  * be read as NULL and silently escalate a user request to server privileges.
8098
8187
  * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
8099
8188
  * rather than trusting every caller (e.g. realtime subscription auth) to do it.
@@ -8101,14 +8190,15 @@ async function ensureAppRole(run, schemas) {
8101
8190
  * semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
8102
8191
  */
8103
8192
  async function applyAuthContext(tx, auth, userRole) {
8104
- const userId = typeof auth.userId === "string" && auth.userId.trim() !== "" ? auth.userId : ANONYMOUS_USER_ID;
8193
+ const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
8105
8194
  const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
8106
8195
  await tx.execute(sql`
8107
8196
  SELECT
8108
- set_config('app.user_id', ${userId}, true),
8197
+ set_config('app.uid', ${uid}, true),
8198
+ set_config('app.user_id', ${uid}, true),
8109
8199
  set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
8110
8200
  set_config('app.jwt', ${JSON.stringify({
8111
- sub: userId,
8201
+ sub: uid,
8112
8202
  roles: auth.roles
8113
8203
  })}, true)
8114
8204
  `);
@@ -8254,6 +8344,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8254
8344
  executeSql: (...args) => this.executeSql(...args),
8255
8345
  fetchAvailableDatabases: () => this.fetchAvailableDatabases(),
8256
8346
  fetchAvailableRoles: () => this.fetchAvailableRoles(),
8347
+ fetchApplicationRoles: () => this.fetchApplicationRoles(),
8257
8348
  fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
8258
8349
  fetchUnmappedTables: (...args) => this.fetchUnmappedTables(...args),
8259
8350
  fetchTableMetadata: (...args) => this.fetchTableMetadata(...args),
@@ -8544,12 +8635,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8544
8635
  let updatedValues = values;
8545
8636
  const contextForCallback = this.buildCallContext();
8546
8637
  let previousValuesForHistory;
8547
- if (status === "existing" && id) {
8548
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8638
+ if (status === "existing" && id) try {
8639
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
8549
8640
  if (existing) {
8550
8641
  const { id: _existingId, ...existingValues } = existing;
8551
8642
  previousValuesForHistory = existingValues;
8552
8643
  }
8644
+ } catch (err) {
8645
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8553
8646
  }
8554
8647
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8555
8648
  if (globalCallbacks?.beforeSave) {
@@ -8905,6 +8998,45 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8905
8998
  async fetchAvailableRoles() {
8906
8999
  return (await this.executeSql("SELECT rolname FROM pg_roles WHERE pg_has_role(current_user, rolname, 'member') ORDER BY rolname;")).map((r) => r.rolname);
8907
9000
  }
9001
+ /**
9002
+ * Application-level roles actually in use in this project.
9003
+ *
9004
+ * Distinct from {@link fetchAvailableRoles}, which returns native
9005
+ * PostgreSQL roles from `pg_roles` (`postgres`, `rebase_user`, …). Those
9006
+ * are the roles the SQL editor can `SET ROLE` to. *These* are the strings
9007
+ * held in the users table's `roles` column, injected per-transaction as
9008
+ * `auth.roles()` and matched by `SecurityRule.roles`. Feeding the pg roles
9009
+ * into a `SecurityRule.roles` field produces a condition no user can ever
9010
+ * satisfy, so the two must not be conflated.
9011
+ *
9012
+ * Roles have no registry table — they were migrated out of
9013
+ * `rebase.user_roles` onto an inline `roles TEXT[]` column — so the live
9014
+ * set is derived from what is assigned. A role that is declared in a policy
9015
+ * but held by nobody yet cannot be discovered here; callers that need it
9016
+ * should union in the roles they already know about.
9017
+ */
9018
+ async fetchApplicationRoles() {
9019
+ const located = await this.executeSql(`
9020
+ SELECT table_schema, table_name
9021
+ FROM information_schema.columns
9022
+ WHERE column_name = 'roles'
9023
+ AND data_type = 'ARRAY'
9024
+ AND table_name = 'users'
9025
+ AND table_schema NOT IN ('information_schema', 'pg_catalog')
9026
+ ORDER BY (table_schema = 'rebase') DESC, table_schema
9027
+ LIMIT 1;
9028
+ `);
9029
+ if (located.length === 0) return [];
9030
+ const schema = located[0].table_schema;
9031
+ const table = located[0].table_name;
9032
+ const qualified = `"${schema.replace(/"/g, "\"\"")}"."${table.replace(/"/g, "\"\"")}"`;
9033
+ return (await this.executeSql(`
9034
+ SELECT DISTINCT unnest(roles) AS role
9035
+ FROM ${qualified}
9036
+ WHERE roles IS NOT NULL
9037
+ ORDER BY role;
9038
+ `)).map((r) => r.role).filter((r) => typeof r === "string" && r.length > 0);
9039
+ }
8908
9040
  async fetchCurrentDatabase() {
8909
9041
  return this.poolManager?.defaultDatabaseName;
8910
9042
  }
@@ -9046,15 +9178,15 @@ var AuthenticatedPostgresBackendDriver = class {
9046
9178
  async withTransaction(operation, options) {
9047
9179
  const pendingNotifications = [];
9048
9180
  const result = await this.delegate.db.transaction(async (tx) => {
9049
- let userId = this.user?.uid;
9050
- if (!userId) {
9181
+ let uid = this.user?.uid;
9182
+ if (!uid) {
9051
9183
  logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
9052
- userId = "anonymous";
9184
+ uid = "anonymous";
9053
9185
  }
9054
9186
  const userRoles = this.user?.roles ?? [];
9055
9187
  if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
9056
9188
  await applyAuthContext(tx, {
9057
- userId,
9189
+ uid,
9058
9190
  roles: userRoles
9059
9191
  }, this.delegate.rlsUserRole);
9060
9192
  const txEntityService = new DataService(tx, this.delegate.registry);
@@ -9081,7 +9213,7 @@ var AuthenticatedPostgresBackendDriver = class {
9081
9213
  */
9082
9214
  injectAuthContext(unsubscribe) {
9083
9215
  const authContext = {
9084
- userId: this.user?.uid || "anonymous",
9216
+ uid: this.user?.uid || "anonymous",
9085
9217
  roles: this.user?.roles ?? []
9086
9218
  };
9087
9219
  const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
@@ -9162,6 +9294,7 @@ var DatabasePoolManager = class {
9162
9294
  pool.on("error", (err) => {
9163
9295
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9164
9296
  });
9297
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9165
9298
  this.pools.set(databaseName, pool);
9166
9299
  return pool;
9167
9300
  }
@@ -9224,19 +9357,19 @@ function createAuthSchema(usersSchemaName = "rebase") {
9224
9357
  */
9225
9358
  const refreshTokens = tableCreator("refresh_tokens", {
9226
9359
  id: uuid("id").defaultRandom().primaryKey(),
9227
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9360
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9228
9361
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
9229
9362
  expiresAt: timestamp("expires_at").notNull(),
9230
9363
  userAgent: varchar("user_agent", { length: 500 }),
9231
9364
  ipAddress: varchar("ip_address", { length: 45 }),
9232
9365
  createdAt: timestamp("created_at").defaultNow().notNull()
9233
- }, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.userId, table.userAgent, table.ipAddress) }));
9366
+ }, (table) => ({ uniqueDeviceSession: unique("unique_device_session").on(table.uid, table.userAgent, table.ipAddress) }));
9234
9367
  /**
9235
9368
  * Password reset tokens for forgot password flow
9236
9369
  */
9237
9370
  const passwordResetTokens = tableCreator("password_reset_tokens", {
9238
9371
  id: uuid("id").defaultRandom().primaryKey(),
9239
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9372
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9240
9373
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
9241
9374
  expiresAt: timestamp("expires_at").notNull(),
9242
9375
  usedAt: timestamp("used_at"),
@@ -9255,7 +9388,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
9255
9388
  */
9256
9389
  const userIdentities = tableCreator("user_identities", {
9257
9390
  id: uuid("id").defaultRandom().primaryKey(),
9258
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9391
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9259
9392
  provider: varchar("provider", { length: 50 }).notNull(),
9260
9393
  providerId: varchar("provider_id", { length: 255 }).notNull(),
9261
9394
  profileData: jsonb("profile_data"),
@@ -9267,7 +9400,7 @@ function createAuthSchema(usersSchemaName = "rebase") {
9267
9400
  */
9268
9401
  const mfaFactors = tableCreator("mfa_factors", {
9269
9402
  id: uuid("id").defaultRandom().primaryKey(),
9270
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9403
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9271
9404
  factorType: varchar("factor_type", { length: 20 }).notNull(),
9272
9405
  secretEncrypted: varchar("secret_encrypted", { length: 500 }).notNull(),
9273
9406
  friendlyName: varchar("friendly_name", { length: 255 }),
@@ -9293,14 +9426,14 @@ function createAuthSchema(usersSchemaName = "rebase") {
9293
9426
  }),
9294
9427
  recoveryCodes: tableCreator("recovery_codes", {
9295
9428
  id: uuid("id").defaultRandom().primaryKey(),
9296
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9429
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9297
9430
  codeHash: varchar("code_hash", { length: 255 }).notNull(),
9298
9431
  usedAt: timestamp("used_at"),
9299
9432
  createdAt: timestamp("created_at").defaultNow().notNull()
9300
9433
  }),
9301
9434
  magicLinkTokens: tableCreator("magic_link_tokens", {
9302
9435
  id: uuid("id").defaultRandom().primaryKey(),
9303
- userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
9436
+ uid: uuid("uid").notNull().references(() => users.id, { onDelete: "cascade" }),
9304
9437
  tokenHash: varchar("token_hash", { length: 255 }).notNull().unique(),
9305
9438
  expiresAt: timestamp("expires_at").notNull(),
9306
9439
  usedAt: timestamp("used_at"),
@@ -9328,20 +9461,20 @@ var usersRelations = relations(users, ({ many }) => ({
9328
9461
  magicLinkTokens: many(magicLinkTokens)
9329
9462
  }));
9330
9463
  var refreshTokensRelations = relations(refreshTokens, ({ one }) => ({ user: one(users, {
9331
- fields: [refreshTokens.userId],
9464
+ fields: [refreshTokens.uid],
9332
9465
  references: [users.id]
9333
9466
  }) }));
9334
9467
  var passwordResetTokensRelations = relations(passwordResetTokens, ({ one }) => ({ user: one(users, {
9335
- fields: [passwordResetTokens.userId],
9468
+ fields: [passwordResetTokens.uid],
9336
9469
  references: [users.id]
9337
9470
  }) }));
9338
9471
  var userIdentitiesRelations = relations(userIdentities, ({ one }) => ({ user: one(users, {
9339
- fields: [userIdentities.userId],
9472
+ fields: [userIdentities.uid],
9340
9473
  references: [users.id]
9341
9474
  }) }));
9342
9475
  var mfaFactorsRelations = relations(mfaFactors, ({ one, many }) => ({
9343
9476
  user: one(users, {
9344
- fields: [mfaFactors.userId],
9477
+ fields: [mfaFactors.uid],
9345
9478
  references: [users.id]
9346
9479
  }),
9347
9480
  challenges: many(mfaChallenges)
@@ -9351,11 +9484,11 @@ var mfaChallengesRelations = relations(mfaChallenges, ({ one }) => ({ factor: on
9351
9484
  references: [mfaFactors.id]
9352
9485
  }) }));
9353
9486
  var recoveryCodesRelations = relations(recoveryCodes, ({ one }) => ({ user: one(users, {
9354
- fields: [recoveryCodes.userId],
9487
+ fields: [recoveryCodes.uid],
9355
9488
  references: [users.id]
9356
9489
  }) }));
9357
9490
  var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user: one(users, {
9358
- fields: [magicLinkTokens.userId],
9491
+ fields: [magicLinkTokens.uid],
9359
9492
  references: [users.id]
9360
9493
  }) }));
9361
9494
  //#endregion
@@ -10188,6 +10321,267 @@ var CdcListener = class CdcListener {
10188
10321
  }
10189
10322
  };
10190
10323
  //#endregion
10324
+ //#region src/services/channel-history.ts
10325
+ /**
10326
+ * Ordered, replayable per-channel message history.
10327
+ *
10328
+ * Broadcast on its own is fire-and-forget to whoever is connected at the
10329
+ * instant it is sent: fine for presence and for "someone saved" notifications,
10330
+ * not enough for op-based collaborative editing, where a client that blinks
10331
+ * out for two seconds has to resync a whole document rather than catch up on
10332
+ * the four operations it missed. This adds the missing half — every retained
10333
+ * broadcast gets a per-channel sequence number, and a client can ask for
10334
+ * everything after the last one it saw.
10335
+ *
10336
+ * Three decisions worth stating, because each rules out a simpler-looking one:
10337
+ *
10338
+ * - **Retention is server-side and opt-in.** A channel is created by whoever
10339
+ * names it, so a client-supplied history depth would let any visitor commit
10340
+ * the backend to unbounded storage. And presence channels — the common case
10341
+ * — must not pay for this: with no rules configured nothing is written, no
10342
+ * table is created, and `broadcast` runs exactly the code it ran before.
10343
+ *
10344
+ * - **Sequence numbers come from the database, not from a counter in this
10345
+ * process.** They have to survive a restart and be shared across instances;
10346
+ * an in-memory counter would restart at 1 after a deploy and hand a
10347
+ * reconnecting client a replay from the wrong era, silently.
10348
+ *
10349
+ * - **The cursor row outlives the messages it numbered.** Pruning is what
10350
+ * makes retention affordable, but pruning the cursor along with the messages
10351
+ * would restart the sequence and make `sinceSeq` mean something different
10352
+ * before and after — the worst kind of bug, because replay would still
10353
+ * return rows and they would look plausible. Cursors are tiny and are kept
10354
+ * forever; see {@link prune}, which touches only `channel_messages`.
10355
+ */
10356
+ /** How many messages a replay returns when the caller does not say. */
10357
+ var DEFAULT_REPLAY_LIMIT = 200;
10358
+ /**
10359
+ * Hard ceiling on one replay, whatever the caller asks for.
10360
+ *
10361
+ * A reconnecting client names its own `limit`, so this is the only thing
10362
+ * standing between a stale `sinceSeq` and a single frame carrying a channel's
10363
+ * entire retained history. A client that is further behind than this is told so
10364
+ * via `latestSeq` and can decide to resync wholesale instead of paging.
10365
+ */
10366
+ var MAX_REPLAY_LIMIT = 1e3;
10367
+ /** Minimum gap between two prunes of the same channel. */
10368
+ var PRUNE_THROTTLE_MS = 3e4;
10369
+ /**
10370
+ * Parse a retention TTL into milliseconds.
10371
+ *
10372
+ * Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
10373
+ * `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
10374
+ * caller treats as "no TTL" — a misspelt duration must not silently become an
10375
+ * aggressive one.
10376
+ */
10377
+ function parseTtlMs(ttl) {
10378
+ if (ttl === void 0 || ttl === null) return void 0;
10379
+ if (typeof ttl === "number") return Number.isFinite(ttl) && ttl > 0 ? ttl : void 0;
10380
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i.exec(ttl);
10381
+ if (!match) {
10382
+ logger.warn(`⚠️ [ChannelHistory] Ignoring unparseable retention ttl "${ttl}" — expected e.g. "30s", "15m", "24h", "7d".`);
10383
+ return;
10384
+ }
10385
+ const value = parseFloat(match[1]);
10386
+ const unit = match[2].toLowerCase();
10387
+ const ms = value * (unit === "ms" ? 1 : unit === "s" ? 1e3 : unit === "m" ? 6e4 : unit === "h" ? 36e5 : 864e5);
10388
+ return ms > 0 ? ms : void 0;
10389
+ }
10390
+ /**
10391
+ * Whether `channel` is covered by `rule`.
10392
+ *
10393
+ * Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
10394
+ * decides what reaches disk, and a pattern language whose reach is not obvious
10395
+ * at a glance is the wrong tool for that job.
10396
+ */
10397
+ function channelMatchesRule(channel, rule) {
10398
+ const pattern = rule.match;
10399
+ if (pattern === "*") return true;
10400
+ if (pattern.endsWith("*")) return channel.startsWith(pattern.slice(0, -1));
10401
+ return channel === pattern;
10402
+ }
10403
+ /**
10404
+ * Persistence and replay for retained channels.
10405
+ *
10406
+ * Inert unless constructed with at least one rule: {@link enabled} is false,
10407
+ * {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
10408
+ * for every channel, so the realtime service never reaches the SQL below.
10409
+ */
10410
+ var ChannelHistoryStore = class {
10411
+ db;
10412
+ rules;
10413
+ /** Resolved rule per channel name, so the match runs once per channel. */
10414
+ resolved = /* @__PURE__ */ new Map();
10415
+ /** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
10416
+ lastPruned = /* @__PURE__ */ new Map();
10417
+ tablesReady = false;
10418
+ constructor(db, rules = []) {
10419
+ this.db = db;
10420
+ this.rules = rules.filter((rule) => {
10421
+ if (!rule?.match) {
10422
+ logger.warn("⚠️ [ChannelHistory] Ignoring a retention rule with no `match`.");
10423
+ return false;
10424
+ }
10425
+ if (!(rule.limit !== void 0 || rule.ttl !== void 0)) {
10426
+ logger.warn(`⚠️ [ChannelHistory] Retention rule "${rule.match}" sets neither \`limit\` nor \`ttl\` — ignoring it, as it would retain forever.`);
10427
+ return false;
10428
+ }
10429
+ return true;
10430
+ });
10431
+ }
10432
+ /** Whether any channel retains anything at all. */
10433
+ get enabled() {
10434
+ return this.rules.length > 0;
10435
+ }
10436
+ /**
10437
+ * The retention that applies to `channel`, or undefined when none does.
10438
+ *
10439
+ * First matching rule wins, so callers order them most-specific first.
10440
+ */
10441
+ retentionFor(channel) {
10442
+ if (!this.enabled || !channel) return void 0;
10443
+ const cached = this.resolved.get(channel);
10444
+ if (cached !== void 0) return cached ?? void 0;
10445
+ const rule = this.rules.find((r) => channelMatchesRule(channel, r));
10446
+ const resolved = rule ? {
10447
+ limit: rule.limit,
10448
+ ttlMs: parseTtlMs(rule.ttl)
10449
+ } : null;
10450
+ this.resolved.set(channel, resolved);
10451
+ return resolved ?? void 0;
10452
+ }
10453
+ /**
10454
+ * Create the history tables. Idempotent, and a no-op when no rule is set —
10455
+ * a deployment that never retains anything gets no schema for it.
10456
+ */
10457
+ async ensureTables() {
10458
+ if (!this.enabled || this.tablesReady) return;
10459
+ await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
10460
+ await this.db.execute(sql`
10461
+ CREATE TABLE IF NOT EXISTS rebase.channel_messages (
10462
+ channel TEXT NOT NULL,
10463
+ seq BIGINT NOT NULL,
10464
+ event TEXT NOT NULL,
10465
+ payload JSONB,
10466
+ sender_id TEXT,
10467
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10468
+ PRIMARY KEY (channel, seq)
10469
+ )
10470
+ `);
10471
+ await this.db.execute(sql`
10472
+ CREATE INDEX IF NOT EXISTS idx_channel_messages_created
10473
+ ON rebase.channel_messages (created_at)
10474
+ `);
10475
+ await this.db.execute(sql`
10476
+ CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
10477
+ channel TEXT PRIMARY KEY,
10478
+ last_seq BIGINT NOT NULL
10479
+ )
10480
+ `);
10481
+ this.tablesReady = true;
10482
+ logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
10483
+ }
10484
+ /**
10485
+ * Append a broadcast and return the sequence number it was given.
10486
+ *
10487
+ * The sequence is allocated by the same statement that stores the message,
10488
+ * so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
10489
+ * takes a row lock on the channel's cursor, which is what makes concurrent
10490
+ * broadcasts to one channel line up in a single order — and what keeps
10491
+ * different channels from contending with each other at all.
10492
+ */
10493
+ async append(channel, event, payload, senderId) {
10494
+ const row = (await this.db.execute(sql`
10495
+ WITH next AS (
10496
+ INSERT INTO rebase.channel_cursors (channel, last_seq)
10497
+ VALUES (${channel}, 1)
10498
+ ON CONFLICT (channel)
10499
+ DO UPDATE SET last_seq = rebase.channel_cursors.last_seq + 1
10500
+ RETURNING last_seq
10501
+ )
10502
+ INSERT INTO rebase.channel_messages (channel, seq, event, payload, sender_id)
10503
+ SELECT ${channel}, next.last_seq, ${event}, ${JSON.stringify(payload ?? null)}::jsonb, ${senderId ?? null}
10504
+ FROM next
10505
+ RETURNING seq, created_at
10506
+ `)).rows[0];
10507
+ if (!row) throw new Error(`Failed to append to channel history for "${channel}"`);
10508
+ return {
10509
+ seq: Number(row.seq),
10510
+ at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
10511
+ };
10512
+ }
10513
+ /**
10514
+ * Everything retained for `channel` after `sinceSeq`, oldest first.
10515
+ *
10516
+ * `latestSeq` is reported whether or not the messages were capped, so a
10517
+ * client that is further behind than one page can tell.
10518
+ */
10519
+ async replay(channel, sinceSeq = 0, limit = DEFAULT_REPLAY_LIMIT) {
10520
+ const capped = Math.max(1, Math.min(Math.floor(limit) || DEFAULT_REPLAY_LIMIT, MAX_REPLAY_LIMIT));
10521
+ const after = Number.isFinite(sinceSeq) && sinceSeq > 0 ? Math.floor(sinceSeq) : 0;
10522
+ const messages = (await this.db.execute(sql`
10523
+ SELECT seq, event, payload, sender_id, created_at
10524
+ FROM rebase.channel_messages
10525
+ WHERE channel = ${channel} AND seq > ${after}
10526
+ ORDER BY seq ASC
10527
+ LIMIT ${capped}
10528
+ `)).rows.map((row) => ({
10529
+ seq: Number(row.seq),
10530
+ event: row.event,
10531
+ payload: row.payload,
10532
+ senderId: row.sender_id ?? void 0,
10533
+ at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
10534
+ }));
10535
+ const cursorRow = (await this.db.execute(sql`
10536
+ SELECT last_seq FROM rebase.channel_cursors WHERE channel = ${channel}
10537
+ `)).rows[0];
10538
+ return {
10539
+ messages,
10540
+ latestSeq: cursorRow ? Number(cursorRow.last_seq) : 0
10541
+ };
10542
+ }
10543
+ /**
10544
+ * Enforce a channel's retention bounds.
10545
+ *
10546
+ * Throttled per channel, so a burst of operations prunes once rather than
10547
+ * once per message — the cost then tracks elapsed time instead of write
10548
+ * volume, which is what makes retention affordable on a hot channel.
10549
+ */
10550
+ async prune(channel, retention) {
10551
+ const now = Date.now();
10552
+ if (now - (this.lastPruned.get(channel) ?? 0) < PRUNE_THROTTLE_MS) return 0;
10553
+ this.lastPruned.set(channel, now);
10554
+ let deleted = 0;
10555
+ if (retention.ttlMs !== void 0) {
10556
+ const result = await this.db.execute(sql`
10557
+ DELETE FROM rebase.channel_messages
10558
+ WHERE channel = ${channel}
10559
+ AND created_at < NOW() - MAKE_INTERVAL(secs => ${retention.ttlMs / 1e3})
10560
+ `);
10561
+ deleted += result.rowCount ?? 0;
10562
+ }
10563
+ if (retention.limit !== void 0 && retention.limit > 0) {
10564
+ const result = await this.db.execute(sql`
10565
+ DELETE FROM rebase.channel_messages
10566
+ WHERE channel = ${channel}
10567
+ AND seq <= (
10568
+ SELECT seq FROM rebase.channel_messages
10569
+ WHERE channel = ${channel}
10570
+ ORDER BY seq DESC
10571
+ OFFSET ${Math.floor(retention.limit)} LIMIT 1
10572
+ )
10573
+ `);
10574
+ deleted += result.rowCount ?? 0;
10575
+ }
10576
+ return deleted;
10577
+ }
10578
+ /** Forget throttle and match caches. Called on shutdown. */
10579
+ clear() {
10580
+ this.resolved.clear();
10581
+ this.lastPruned.clear();
10582
+ }
10583
+ };
10584
+ //#endregion
10191
10585
  //#region src/services/realtimeService.ts
10192
10586
  /** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
10193
10587
  var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
@@ -10203,6 +10597,26 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10203
10597
  clients = /* @__PURE__ */ new Map();
10204
10598
  channels = /* @__PURE__ */ new Map();
10205
10599
  presence = /* @__PURE__ */ new Map();
10600
+ /**
10601
+ * Ordered, replayable history for channels that opt into it.
10602
+ *
10603
+ * Undefined until {@link configureChannelHistory} is called, and inert even
10604
+ * then unless retention rules were supplied — so presence and ephemeral
10605
+ * notification channels never touch it. See `channel-history.ts`.
10606
+ */
10607
+ channelHistory;
10608
+ /**
10609
+ * One promise chain per retained channel, so that assigning a sequence
10610
+ * number and fanning the message out happen in the same order for every
10611
+ * message on that channel.
10612
+ *
10613
+ * Without it, two concurrent broadcasts can be numbered 4 and 5 by the
10614
+ * database and still reach subscribers as 5 then 4 — live order and replay
10615
+ * order would disagree, which is exactly the divergence sequence numbers
10616
+ * are supposed to rule out. Keyed by channel, so unrelated channels never
10617
+ * wait on each other.
10618
+ */
10619
+ channelSendQueues = /* @__PURE__ */ new Map();
10206
10620
  presenceInterval;
10207
10621
  static PRESENCE_TIMEOUT_MS = 3e4;
10208
10622
  dataService;
@@ -10379,6 +10793,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10379
10793
  case "broadcast":
10380
10794
  this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
10381
10795
  break;
10796
+ case "channel_history":
10797
+ await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
10798
+ break;
10382
10799
  case "presence_track":
10383
10800
  this.joinChannel(clientId, payload?.channel);
10384
10801
  this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
@@ -10589,12 +11006,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10589
11006
  if (this.driver) {
10590
11007
  const collection = this.registry.getCollectionByPath(notifyPath);
10591
11008
  const activeAuth = authContext || {
10592
- userId: "anon",
11009
+ uid: "anon",
10593
11010
  roles: ["anon"]
10594
11011
  };
10595
11012
  return await this.db.transaction(async (tx) => {
10596
11013
  await applyAuthContext(tx, {
10597
- userId: activeAuth.userId,
11014
+ uid: activeAuth.uid,
10598
11015
  roles: activeAuth.roles
10599
11016
  }, this.rlsUserRole);
10600
11017
  const txEntityService = new DataService(tx, this.registry);
@@ -10626,7 +11043,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10626
11043
  if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
10627
11044
  const contextForCallback = {
10628
11045
  user: {
10629
- uid: activeAuth.userId,
11046
+ uid: activeAuth.uid,
10630
11047
  roles: activeAuth.roles
10631
11048
  },
10632
11049
  driver: this.driver,
@@ -10718,12 +11135,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10718
11135
  if (this.driver) {
10719
11136
  const collection = this.registry.getCollectionByPath(notifyPath);
10720
11137
  const activeAuth = authContext || {
10721
- userId: "anon",
11138
+ uid: "anon",
10722
11139
  roles: ["anon"]
10723
11140
  };
10724
11141
  return await this.db.transaction(async (tx) => {
10725
11142
  await applyAuthContext(tx, {
10726
- userId: activeAuth.userId,
11143
+ uid: activeAuth.uid,
10727
11144
  roles: activeAuth.roles
10728
11145
  }, this.rlsUserRole);
10729
11146
  let processedEntity = await new DataService(tx, this.registry).fetchOne(notifyPath, id, collection?.databaseId);
@@ -10739,7 +11156,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10739
11156
  if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
10740
11157
  const contextForCallback = {
10741
11158
  user: {
10742
- uid: activeAuth.userId,
11159
+ uid: activeAuth.uid,
10743
11160
  roles: activeAuth.roles
10744
11161
  },
10745
11162
  driver: this.driver,
@@ -10865,15 +11282,67 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10865
11282
  }
10866
11283
  this.removePresence(clientId, channel);
10867
11284
  }
10868
- /** Broadcast a message to all clients in a channel except sender */
11285
+ /**
11286
+ * Broadcast a message to all clients in a channel except the sender.
11287
+ *
11288
+ * On a channel with no retention rule this is what it always was: a
11289
+ * synchronous fan-out to whoever is connected, with no sequence number, no
11290
+ * SQL and no await — the body below runs to completion before returning.
11291
+ *
11292
+ * On a retained channel the message is durably numbered first and only then
11293
+ * delivered, through a per-channel queue so that delivery order matches
11294
+ * sequence order. That ordering is the whole point: a client that catches up
11295
+ * with `sinceSeq` has to arrive at the same state as one that never
11296
+ * disconnected.
11297
+ */
10869
11298
  broadcastToChannel(clientId, channel, event, payload) {
11299
+ const retention = this.channelHistory?.retentionFor(channel);
11300
+ if (!retention) {
11301
+ this.fanOutBroadcast(clientId, channel, event, payload);
11302
+ return;
11303
+ }
11304
+ const next = (this.channelSendQueues.get(channel) ?? Promise.resolve()).catch(() => {}).then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
11305
+ this.channelSendQueues.set(channel, next);
11306
+ next.finally(() => {
11307
+ if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
11308
+ });
11309
+ }
11310
+ /**
11311
+ * Number a broadcast, store it, then deliver it.
11312
+ *
11313
+ * A message that cannot be stored is **not** delivered. Delivering it would
11314
+ * put it in front of live subscribers while leaving it absent from every
11315
+ * future replay — the two views of the channel would disagree permanently,
11316
+ * and no later message could repair the gap. Failing loudly to the sender
11317
+ * instead lets it retry, which for an operation stream is the only outcome
11318
+ * that keeps clients convergent.
11319
+ */
11320
+ async persistAndFanOut(clientId, channel, event, payload, retention) {
11321
+ let seq;
11322
+ try {
11323
+ ({seq} = await this.channelHistory.append(channel, event, payload, clientId));
11324
+ } catch (error) {
11325
+ logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
11326
+ this.sendError(clientId, `Could not persist broadcast on retained channel "${channel}"`, void 0, "CHANNEL_HISTORY_WRITE_FAILED");
11327
+ return;
11328
+ }
11329
+ this.fanOutBroadcast(clientId, channel, event, payload, seq);
11330
+ try {
11331
+ await this.channelHistory.prune(channel, retention);
11332
+ } catch (error) {
11333
+ logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
11334
+ }
11335
+ }
11336
+ /** Deliver a broadcast frame to every member of a channel but the sender. */
11337
+ fanOutBroadcast(clientId, channel, event, payload, seq) {
10870
11338
  const members = this.channels.get(channel);
10871
11339
  if (!members) return;
10872
11340
  const message = JSON.stringify({
10873
11341
  type: "broadcast",
10874
11342
  channel,
10875
11343
  event,
10876
- payload
11344
+ payload,
11345
+ ...seq !== void 0 ? { seq } : {}
10877
11346
  });
10878
11347
  for (const memberId of members) {
10879
11348
  if (memberId === clientId) continue;
@@ -10881,6 +11350,55 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10881
11350
  if (ws && ws.readyState === WebSocket.OPEN) ws.send(message);
10882
11351
  }
10883
11352
  }
11353
+ /**
11354
+ * Install retention rules and create the tables they need.
11355
+ *
11356
+ * Safe to call with no rules (and safe not to call at all): the store stays
11357
+ * inert, no schema is created, and broadcast keeps its original
11358
+ * fire-and-forget path.
11359
+ */
11360
+ async configureChannelHistory(rules) {
11361
+ this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
11362
+ if (!this.channelHistory.enabled) return;
11363
+ await this.channelHistory.ensureTables();
11364
+ }
11365
+ /** Whether any channel is configured to retain messages. */
11366
+ isChannelHistoryEnabled() {
11367
+ return this.channelHistory?.enabled ?? false;
11368
+ }
11369
+ /**
11370
+ * Answer a client's catch-up request.
11371
+ *
11372
+ * A channel with no retention rule is answered with `retained: false`
11373
+ * rather than an empty list, so the client can tell "you missed nothing"
11374
+ * apart from "this channel never keeps anything" — the second means its
11375
+ * reconnect strategy has to be a full resync, and silence would leave it
11376
+ * guessing.
11377
+ */
11378
+ async handleChannelHistoryRequest(clientId, channel, sinceSeq, limit) {
11379
+ if (!channel) return;
11380
+ if (!this.channelHistory?.retentionFor(channel)) {
11381
+ this.sendChannelHistory(clientId, channel, [], false);
11382
+ return;
11383
+ }
11384
+ try {
11385
+ const { messages, latestSeq } = await this.channelHistory.replay(channel, sinceSeq, limit);
11386
+ this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
11387
+ } catch (error) {
11388
+ logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
11389
+ this.sendError(clientId, `Could not replay history for channel "${channel}"`, void 0, "CHANNEL_HISTORY_READ_FAILED");
11390
+ }
11391
+ }
11392
+ sendChannelHistory(clientId, channel, messages, retained, latestSeq) {
11393
+ const ws = this.clients.get(clientId);
11394
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({
11395
+ type: "channel_history",
11396
+ channel,
11397
+ messages,
11398
+ retained,
11399
+ ...latestSeq !== void 0 ? { latestSeq } : {}
11400
+ }));
11401
+ }
10884
11402
  /** Track presence in a channel */
10885
11403
  trackPresence(clientId, channel, state) {
10886
11404
  if (!this.presence.has(channel)) this.presence.set(channel, /* @__PURE__ */ new Map());
@@ -10961,6 +11479,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
10961
11479
  this.subscriptionCallbacks.clear();
10962
11480
  this.channels.clear();
10963
11481
  this.presence.clear();
11482
+ await Promise.allSettled([...this.channelSendQueues.values()]);
11483
+ this.channelSendQueues.clear();
11484
+ this.channelHistory?.clear();
10964
11485
  if (this.presenceInterval) {
10965
11486
  clearInterval(this.presenceInterval);
10966
11487
  this.presenceInterval = void 0;
@@ -11302,20 +11823,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11302
11823
  if (authAdapter) try {
11303
11824
  const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
11304
11825
  if (adapterUser) verifiedUser = {
11305
- userId: adapterUser.uid,
11826
+ uid: adapterUser.uid,
11306
11827
  roles: adapterUser.roles,
11307
11828
  isAdmin: adapterUser.isAdmin
11308
11829
  };
11309
11830
  } catch {}
11310
11831
  else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
11311
- userId: "service",
11832
+ uid: "service",
11312
11833
  roles: ["admin"],
11313
11834
  isAdmin: true
11314
11835
  };
11315
11836
  else {
11316
11837
  const jwtPayload = extractUserFromToken(token);
11317
11838
  if (jwtPayload) verifiedUser = {
11318
- userId: jwtPayload.userId,
11839
+ uid: jwtPayload.uid,
11319
11840
  roles: jwtPayload.roles ?? [],
11320
11841
  isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin")
11321
11842
  };
@@ -11331,11 +11852,11 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11331
11852
  type: "AUTH_SUCCESS",
11332
11853
  requestId,
11333
11854
  payload: {
11334
- userId: verifiedUser.userId,
11855
+ uid: verifiedUser.uid,
11335
11856
  roles: verifiedUser.roles
11336
11857
  }
11337
11858
  }));
11338
- wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
11859
+ wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
11339
11860
  } else {
11340
11861
  wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
11341
11862
  sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
@@ -11373,7 +11894,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11373
11894
  const session = clientSessions.get(clientId);
11374
11895
  if (typeof driver.withAuth === "function") try {
11375
11896
  const userForAuth = session?.user ? {
11376
- uid: session.user.userId,
11897
+ uid: session.user.uid,
11377
11898
  displayName: null,
11378
11899
  email: null,
11379
11900
  photoURL: null,
@@ -11507,7 +12028,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11507
12028
  sql: typeof sql === "string" ? sql.substring(0, 500) : sql,
11508
12029
  options,
11509
12030
  resultRows: Array.isArray(result) ? result.length : "unknown",
11510
- userId: auditSession?.user?.userId ?? "unknown",
12031
+ uid: auditSession?.user?.uid ?? "unknown",
11511
12032
  roles: auditSession?.user?.roles ?? [],
11512
12033
  isAdmin: auditSession?.user?.isAdmin ?? false
11513
12034
  }));
@@ -11552,6 +12073,21 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11552
12073
  ws.send(JSON.stringify(response));
11553
12074
  }
11554
12075
  break;
12076
+ case "FETCH_APPLICATION_ROLES":
12077
+ {
12078
+ wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
12079
+ const admin = (await getScopedDelegate()).admin;
12080
+ let roles = [];
12081
+ if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
12082
+ wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
12083
+ const response = {
12084
+ type: "FETCH_APPLICATION_ROLES_SUCCESS",
12085
+ payload: { roles },
12086
+ requestId
12087
+ };
12088
+ ws.send(JSON.stringify(response));
12089
+ }
12090
+ break;
11555
12091
  case "FETCH_CURRENT_DATABASE":
11556
12092
  {
11557
12093
  wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
@@ -11658,14 +12194,15 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
11658
12194
  case "broadcast":
11659
12195
  case "presence_track":
11660
12196
  case "presence_untrack":
11661
- case "presence_state": {
12197
+ case "presence_state":
12198
+ case "channel_history": {
11662
12199
  wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
11663
12200
  const session = clientSessions.get(clientId);
11664
12201
  const authContext = session?.user ? {
11665
- userId: session.user.userId,
12202
+ uid: session.user.uid,
11666
12203
  roles: session.user.roles ?? []
11667
12204
  } : {
11668
- userId: "anon",
12205
+ uid: "anon",
11669
12206
  roles: ["anon"]
11670
12207
  };
11671
12208
  await realtimeService.handleClientMessage(clientId, {
@@ -20621,9 +21158,65 @@ async function ensureAuthTablesExist(db, collection) {
20621
21158
  )
20622
21159
  `);
20623
21160
  await db.execute(sql`
21161
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
21162
+ BEGIN
21163
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
21164
+ NEW.uid := NEW.user_id;
21165
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
21166
+ NEW.user_id := NEW.uid;
21167
+ END IF;
21168
+ RETURN NEW;
21169
+ END $$ LANGUAGE plpgsql
21170
+ `);
21171
+ for (const authTable of [
21172
+ "user_identities",
21173
+ "refresh_tokens",
21174
+ "password_reset_tokens",
21175
+ "magic_link_tokens",
21176
+ "mfa_factors",
21177
+ "recovery_codes"
21178
+ ]) {
21179
+ const qualified = `"${authSchema}"."${authTable}"`;
21180
+ await db.execute(sql`
21181
+ DO $$
21182
+ DECLARE
21183
+ has_legacy boolean;
21184
+ has_uid boolean;
21185
+ BEGIN
21186
+ SELECT
21187
+ bool_or(column_name = 'user_id'),
21188
+ bool_or(column_name = 'uid')
21189
+ INTO has_legacy, has_uid
21190
+ FROM information_schema.columns
21191
+ WHERE table_schema = ${sql.raw(`'${authSchema}'`)}
21192
+ AND table_name = ${sql.raw(`'${authTable}'`)};
21193
+
21194
+ -- Table absent, or already uid-only (a fresh install, or
21195
+ -- phase 2 already run): nothing to do.
21196
+ IF has_legacy IS NOT TRUE THEN
21197
+ RETURN;
21198
+ END IF;
21199
+
21200
+ IF has_uid IS NOT TRUE THEN
21201
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ADD COLUMN uid ${userIdType} REFERENCES ${usersTableName}(id) ON DELETE CASCADE'`)};
21202
+ EXECUTE ${sql.raw(`'UPDATE ${qualified} SET uid = user_id WHERE uid IS NULL'`)};
21203
+ EXECUTE ${sql.raw(`'CREATE INDEX IF NOT EXISTS idx_${authTable}_uid ON ${qualified}(uid)'`)};
21204
+ END IF;
21205
+
21206
+ -- New code inserts uid and never user_id, so the legacy
21207
+ -- column can no longer be NOT NULL. The trigger below
21208
+ -- backfills it, but the constraint is checked first.
21209
+ EXECUTE ${sql.raw(`'ALTER TABLE ${qualified} ALTER COLUMN user_id DROP NOT NULL'`)};
21210
+
21211
+ EXECUTE ${sql.raw(`'DROP TRIGGER IF EXISTS sync_uid_user_id ON ${qualified}'`)};
21212
+ EXECUTE ${sql.raw(`'CREATE TRIGGER sync_uid_user_id BEFORE INSERT OR UPDATE ON ${qualified} FOR EACH ROW EXECUTE FUNCTION "${authSchema}".sync_uid_user_id()'`)};
21213
+ END $$
21214
+ `);
21215
+ }
21216
+ await db.execute(sql`
20624
21217
  CREATE TABLE IF NOT EXISTS ${sql.raw(userIdentitiesTable)} (
20625
21218
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20626
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21219
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20627
21220
  provider TEXT NOT NULL,
20628
21221
  provider_id TEXT NOT NULL,
20629
21222
  profile_data JSONB,
@@ -20634,18 +21227,18 @@ async function ensureAuthTablesExist(db, collection) {
20634
21227
  `);
20635
21228
  await db.execute(sql`
20636
21229
  CREATE INDEX IF NOT EXISTS idx_user_identities_user
20637
- ON ${sql.raw(userIdentitiesTable)}(user_id)
21230
+ ON ${sql.raw(userIdentitiesTable)}(uid)
20638
21231
  `);
20639
21232
  await db.execute(sql`
20640
21233
  CREATE TABLE IF NOT EXISTS ${sql.raw(refreshTokensTableName)} (
20641
21234
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20642
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21235
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20643
21236
  token_hash TEXT NOT NULL UNIQUE,
20644
21237
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
20645
21238
  user_agent TEXT,
20646
21239
  ip_address TEXT,
20647
21240
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
20648
- CONSTRAINT unique_device_session UNIQUE (user_id, user_agent, ip_address)
21241
+ CONSTRAINT unique_device_session UNIQUE (uid, user_agent, ip_address)
20649
21242
  )
20650
21243
  `);
20651
21244
  await db.execute(sql`
@@ -20654,12 +21247,12 @@ async function ensureAuthTablesExist(db, collection) {
20654
21247
  `);
20655
21248
  await db.execute(sql`
20656
21249
  CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user
20657
- ON ${sql.raw(refreshTokensTableName)}(user_id)
21250
+ ON ${sql.raw(refreshTokensTableName)}(uid)
20658
21251
  `);
20659
21252
  await db.execute(sql`
20660
21253
  CREATE TABLE IF NOT EXISTS ${sql.raw(passwordResetTokensTableName)} (
20661
21254
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20662
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21255
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20663
21256
  token_hash TEXT NOT NULL UNIQUE,
20664
21257
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
20665
21258
  used_at TIMESTAMP WITH TIME ZONE,
@@ -20672,13 +21265,13 @@ async function ensureAuthTablesExist(db, collection) {
20672
21265
  `);
20673
21266
  await db.execute(sql`
20674
21267
  CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user
20675
- ON ${sql.raw(passwordResetTokensTableName)}(user_id)
21268
+ ON ${sql.raw(passwordResetTokensTableName)}(uid)
20676
21269
  `);
20677
21270
  const magicLinkTokensTableName = `"${authSchema}"."magic_link_tokens"`;
20678
21271
  await db.execute(sql`
20679
21272
  CREATE TABLE IF NOT EXISTS ${sql.raw(magicLinkTokensTableName)} (
20680
21273
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20681
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21274
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20682
21275
  token_hash TEXT NOT NULL UNIQUE,
20683
21276
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
20684
21277
  used_at TIMESTAMP WITH TIME ZONE,
@@ -20691,7 +21284,7 @@ async function ensureAuthTablesExist(db, collection) {
20691
21284
  `);
20692
21285
  await db.execute(sql`
20693
21286
  CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user
20694
- ON ${sql.raw(magicLinkTokensTableName)}(user_id)
21287
+ ON ${sql.raw(magicLinkTokensTableName)}(uid)
20695
21288
  `);
20696
21289
  await db.execute(sql`
20697
21290
  CREATE TABLE IF NOT EXISTS ${sql.raw(appConfigTableName)} (
@@ -20705,7 +21298,10 @@ async function ensureAuthTablesExist(db, collection) {
20705
21298
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
20706
21299
  await tx.execute(sql`
20707
21300
  CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
20708
- SELECT NULLIF(current_setting('app.user_id', true), '');
21301
+ SELECT COALESCE(
21302
+ NULLIF(current_setting('app.uid', true), ''),
21303
+ NULLIF(current_setting('app.user_id', true), '')
21304
+ );
20709
21305
  $$ LANGUAGE sql STABLE
20710
21306
  `);
20711
21307
  await tx.execute(sql`
@@ -20722,14 +21318,22 @@ async function ensureAuthTablesExist(db, collection) {
20722
21318
  $$ LANGUAGE sql STABLE
20723
21319
  `);
20724
21320
  });
20725
- await db.execute(sql`
20726
- ALTER TABLE ${sql.raw(usersTableName)}
20727
- ADD COLUMN IF NOT EXISTS is_anonymous BOOLEAN DEFAULT FALSE
20728
- `);
20729
- await db.execute(sql`
20730
- ALTER TABLE ${sql.raw(usersTableName)}
20731
- ADD COLUMN IF NOT EXISTS roles TEXT[] DEFAULT '{}' NOT NULL
20732
- `);
21321
+ for (const columnDef of [
21322
+ "display_name VARCHAR(255)",
21323
+ "photo_url VARCHAR(500)",
21324
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
21325
+ "password_hash VARCHAR(255)",
21326
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
21327
+ "email_verification_token VARCHAR(255)",
21328
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
21329
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
21330
+ "metadata JSONB DEFAULT '{}' NOT NULL",
21331
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
21332
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
21333
+ ]) await db.execute(sql`
21334
+ ALTER TABLE ${sql.raw(usersTableName)}
21335
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
21336
+ `);
20733
21337
  try {
20734
21338
  if ((await db.execute(sql`
20735
21339
  SELECT EXISTS (
@@ -20760,7 +21364,7 @@ async function ensureAuthTablesExist(db, collection) {
20760
21364
  await db.execute(sql`
20761
21365
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaFactorsTableName)} (
20762
21366
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20763
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21367
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20764
21368
  factor_type TEXT NOT NULL DEFAULT 'totp',
20765
21369
  secret_encrypted TEXT NOT NULL,
20766
21370
  friendly_name TEXT,
@@ -20771,7 +21375,7 @@ async function ensureAuthTablesExist(db, collection) {
20771
21375
  `);
20772
21376
  await db.execute(sql`
20773
21377
  CREATE INDEX IF NOT EXISTS idx_mfa_factors_user
20774
- ON ${sql.raw(mfaFactorsTableName)}(user_id)
21378
+ ON ${sql.raw(mfaFactorsTableName)}(uid)
20775
21379
  `);
20776
21380
  await db.execute(sql`
20777
21381
  CREATE TABLE IF NOT EXISTS ${sql.raw(mfaChallengesTableName)} (
@@ -20790,7 +21394,7 @@ async function ensureAuthTablesExist(db, collection) {
20790
21394
  await db.execute(sql`
20791
21395
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
20792
21396
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
20793
- user_id ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
21397
+ uid ${sql.raw(userIdType)} NOT NULL REFERENCES ${sql.raw(usersTableName)}(id) ON DELETE CASCADE,
20794
21398
  code_hash TEXT NOT NULL,
20795
21399
  used_at TIMESTAMP WITH TIME ZONE,
20796
21400
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -20798,8 +21402,36 @@ async function ensureAuthTablesExist(db, collection) {
20798
21402
  `);
20799
21403
  await db.execute(sql`
20800
21404
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20801
- ON ${sql.raw(recoveryCodesTableName)}(user_id)
21405
+ ON ${sql.raw(recoveryCodesTableName)}(uid)
20802
21406
  `);
21407
+ try {
21408
+ const authTablePairs = [
21409
+ [usersSchema, resolvedTable],
21410
+ [authSchema, "user_identities"],
21411
+ [authSchema, "refresh_tokens"],
21412
+ [authSchema, "password_reset_tokens"],
21413
+ [authSchema, "app_config"],
21414
+ [authSchema, "mfa_factors"],
21415
+ [authSchema, "mfa_challenges"],
21416
+ [authSchema, "recovery_codes"]
21417
+ ];
21418
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
21419
+ SELECT 1
21420
+ FROM pg_class c
21421
+ JOIN pg_namespace n ON n.oid = c.relnamespace
21422
+ WHERE n.nspname = ${schemaName}
21423
+ AND c.relname = ${tableName}
21424
+ AND c.relforcerowsecurity
21425
+ `)).rows.length > 0) {
21426
+ await db.execute(sql`
21427
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
21428
+ NO FORCE ROW LEVEL SECURITY
21429
+ `);
21430
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
21431
+ }
21432
+ } catch (rlsReconcileError) {
21433
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
21434
+ }
20803
21435
  logger.info("✅ Auth tables ready");
20804
21436
  } catch (error) {
20805
21437
  logger.error("❌ Failed to create auth tables", { error });
@@ -20847,6 +21479,32 @@ var UserService = class {
20847
21479
  const name = getTableName(this.usersTable);
20848
21480
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20849
21481
  }
21482
+ /**
21483
+ * Run a privileged auth write with an explicitly cleared RLS context.
21484
+ *
21485
+ * The auth services run on the base/owner connection, which by design
21486
+ * carries a NULL `app.uid` so the `auth.uid() IS NULL` server-escape
21487
+ * in the default policies applies. That NULL is normally guaranteed by
21488
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
21489
+ * GUC that survives on a pooled connection (or a connection role that
21490
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
21491
+ * turns the trusted write into an RLS-scoped one and denies it with
21492
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
21493
+ * single chokepoint, makes the server context deterministic instead of
21494
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
21495
+ * NULL via NULLIF, so '' is the server context.
21496
+ */
21497
+ async withServerContext(fn) {
21498
+ return await this.db.transaction(async (tx) => {
21499
+ await tx.execute(sql`
21500
+ SELECT set_config('app.uid', '', true),
21501
+ set_config('app.user_id', '', true),
21502
+ set_config('app.user_roles', '', true),
21503
+ set_config('app.jwt', '', true)
21504
+ `);
21505
+ return await fn(tx);
21506
+ });
21507
+ }
20850
21508
  mapRowToUser(row) {
20851
21509
  if (!row) return row;
20852
21510
  const id = row.id ?? row.uid;
@@ -20944,7 +21602,7 @@ var UserService = class {
20944
21602
  }
20945
21603
  async createUser(data) {
20946
21604
  const payload = this.mapPayload(data);
20947
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21605
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20948
21606
  return this.mapRowToUser(row);
20949
21607
  }
20950
21608
  async getUserById(id) {
@@ -20962,19 +21620,19 @@ var UserService = class {
20962
21620
  async getUserByIdentity(provider, providerId) {
20963
21621
  const userIdCol = getColumn(this.usersTable, "id");
20964
21622
  if (!userIdCol) return null;
20965
- const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.userId)).where(sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`).limit(1);
21623
+ const result = await this.db.select({ user: this.usersTable }).from(this.usersTable).innerJoin(this.userIdentitiesTable, eq(userIdCol, this.userIdentitiesTable.uid)).where(sql`${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`).limit(1);
20966
21624
  if (result.length === 0) return null;
20967
21625
  return this.mapRowToUser(result[0].user);
20968
21626
  }
20969
- async getUserIdentities(userId) {
21627
+ async getUserIdentities(uid) {
20970
21628
  const schema = getTableConfig(this.userIdentitiesTable).schema || "public";
20971
21629
  return (await this.db.execute(sql`
20972
- SELECT id, user_id, provider, provider_id, profile_data, created_at, updated_at
21630
+ SELECT id, uid, provider, provider_id, profile_data, created_at, updated_at
20973
21631
  FROM ${sql.raw(`"${schema}"."user_identities"`)}
20974
- WHERE user_id = ${userId}
21632
+ WHERE uid = ${uid}
20975
21633
  `)).rows.map((row) => ({
20976
21634
  id: row.id,
20977
- userId: row.user_id,
21635
+ uid: row.uid,
20978
21636
  provider: row.provider,
20979
21637
  providerId: row.provider_id,
20980
21638
  profileData: row.profile_data ?? null,
@@ -20982,13 +21640,13 @@ var UserService = class {
20982
21640
  updatedAt: row.updated_at
20983
21641
  }));
20984
21642
  }
20985
- async linkUserIdentity(userId, provider, providerId, profileData) {
20986
- await this.db.insert(this.userIdentitiesTable).values({
20987
- userId,
21643
+ async linkUserIdentity(uid, provider, providerId, profileData) {
21644
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
21645
+ uid,
20988
21646
  provider,
20989
21647
  providerId,
20990
21648
  profileData: profileData || null
20991
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21649
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20992
21650
  }
20993
21651
  async updateUser(id, data) {
20994
21652
  const idCol = getColumn(this.usersTable, "id");
@@ -20996,13 +21654,13 @@ var UserService = class {
20996
21654
  const payload = this.mapPayload(data);
20997
21655
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20998
21656
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
20999
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21657
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
21000
21658
  return row ? this.mapRowToUser(row) : null;
21001
21659
  }
21002
21660
  async deleteUser(id) {
21003
21661
  const idCol = getColumn(this.usersTable, "id");
21004
21662
  if (!idCol) return;
21005
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21663
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
21006
21664
  }
21007
21665
  async listUsers() {
21008
21666
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -21056,10 +21714,10 @@ var UserService = class {
21056
21714
  if (!idCol) return;
21057
21715
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
21058
21716
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21059
- await this.db.update(this.usersTable).set({
21717
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21060
21718
  [passwordHashColKey]: passwordHash,
21061
21719
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21062
- }).where(eq(idCol, id));
21720
+ }).where(eq(idCol, id)));
21063
21721
  }
21064
21722
  /**
21065
21723
  * Set email verification status
@@ -21070,11 +21728,11 @@ var UserService = class {
21070
21728
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
21071
21729
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21072
21730
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21073
- await this.db.update(this.usersTable).set({
21731
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21074
21732
  [emailVerifiedColKey]: verified,
21075
21733
  [emailVerificationTokenColKey]: null,
21076
21734
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21077
- }).where(eq(idCol, id));
21735
+ }).where(eq(idCol, id)));
21078
21736
  }
21079
21737
  /**
21080
21738
  * Set email verification token
@@ -21085,11 +21743,11 @@ var UserService = class {
21085
21743
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21086
21744
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
21087
21745
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21088
- await this.db.update(this.usersTable).set({
21746
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21089
21747
  [emailVerificationTokenColKey]: token,
21090
21748
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
21091
21749
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21092
- }).where(eq(idCol, id));
21750
+ }).where(eq(idCol, id)));
21093
21751
  }
21094
21752
  /**
21095
21753
  * Find user by email verification token
@@ -21103,10 +21761,10 @@ var UserService = class {
21103
21761
  /**
21104
21762
  * Get roles for a user from database (inline TEXT[] column)
21105
21763
  */
21106
- async getUserRoles(userId) {
21764
+ async getUserRoles(uid) {
21107
21765
  const usersTableName = this.getQualifiedUsersTableName();
21108
21766
  const result = await this.db.execute(sql`
21109
- SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
21767
+ SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
21110
21768
  `);
21111
21769
  if (result.rows.length === 0) return [];
21112
21770
  return (result.rows[0].roles ?? []).map((id) => ({
@@ -21120,10 +21778,10 @@ var UserService = class {
21120
21778
  /**
21121
21779
  * Get role IDs for a user
21122
21780
  */
21123
- async getUserRoleIds(userId) {
21781
+ async getUserRoleIds(uid) {
21124
21782
  const usersTableName = this.getQualifiedUsersTableName();
21125
21783
  const result = await this.db.execute(sql`
21126
- SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
21784
+ SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${uid}
21127
21785
  `);
21128
21786
  if (result.rows.length === 0) return [];
21129
21787
  return result.rows[0].roles ?? [];
@@ -21131,35 +21789,35 @@ var UserService = class {
21131
21789
  /**
21132
21790
  * Set roles for a user (replaces existing roles)
21133
21791
  */
21134
- async setUserRoles(userId, roleIds) {
21792
+ async setUserRoles(uid, roleIds) {
21135
21793
  const usersTableName = this.getQualifiedUsersTableName();
21136
21794
  const rolesArray = `{${roleIds.join(",")}}`;
21137
- await this.db.execute(sql`
21795
+ await this.withServerContext(async (db) => db.execute(sql`
21138
21796
  UPDATE ${sql.raw(usersTableName)}
21139
21797
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21140
- WHERE id = ${userId}
21141
- `);
21798
+ WHERE id = ${uid}
21799
+ `));
21142
21800
  }
21143
21801
  /**
21144
21802
  * Assign a specific role to new user (appends if not present)
21145
21803
  */
21146
- async assignDefaultRole(userId, roleId) {
21804
+ async assignDefaultRole(uid, roleId) {
21147
21805
  const usersTableName = this.getQualifiedUsersTableName();
21148
- await this.db.execute(sql`
21806
+ await this.withServerContext(async (db) => db.execute(sql`
21149
21807
  UPDATE ${sql.raw(usersTableName)}
21150
21808
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21151
- WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21152
- `);
21809
+ WHERE id = ${uid} AND NOT (${roleId} = ANY(roles))
21810
+ `));
21153
21811
  }
21154
21812
  /**
21155
21813
  * Get user with their roles
21156
21814
  */
21157
- async getUserWithRoles(userId) {
21158
- const user = await this.getUserById(userId);
21815
+ async getUserWithRoles(uid) {
21816
+ const user = await this.getUserById(uid);
21159
21817
  if (!user) return null;
21160
21818
  return {
21161
21819
  user,
21162
- roles: await this.getUserRoles(userId)
21820
+ roles: await this.getUserRoles(uid)
21163
21821
  };
21164
21822
  }
21165
21823
  };
@@ -21171,18 +21829,18 @@ var RefreshTokenService = class {
21171
21829
  if (tableOrTables && (tableOrTables.refreshTokens || tableOrTables.users)) this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
21172
21830
  else this.refreshTokensTable = tableOrTables || refreshTokens;
21173
21831
  }
21174
- async createToken(userId, tokenHash, expiresAt, userAgent, ipAddress) {
21832
+ async createToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
21175
21833
  const safeUserAgent = userAgent || "";
21176
21834
  const safeIpAddress = ipAddress || "";
21177
21835
  await this.db.insert(this.refreshTokensTable).values({
21178
- userId,
21836
+ uid,
21179
21837
  tokenHash,
21180
21838
  expiresAt,
21181
21839
  userAgent: safeUserAgent,
21182
21840
  ipAddress: safeIpAddress
21183
21841
  }).onConflictDoUpdate({
21184
21842
  target: [
21185
- this.refreshTokensTable.userId,
21843
+ this.refreshTokensTable.uid,
21186
21844
  this.refreshTokensTable.userAgent,
21187
21845
  this.refreshTokensTable.ipAddress
21188
21846
  ],
@@ -21195,7 +21853,7 @@ var RefreshTokenService = class {
21195
21853
  async findByHash(tokenHash) {
21196
21854
  const [token] = await this.db.select({
21197
21855
  id: this.refreshTokensTable.id,
21198
- userId: this.refreshTokensTable.userId,
21856
+ uid: this.refreshTokensTable.uid,
21199
21857
  tokenHash: this.refreshTokensTable.tokenHash,
21200
21858
  expiresAt: this.refreshTokensTable.expiresAt,
21201
21859
  createdAt: this.refreshTokensTable.createdAt,
@@ -21207,22 +21865,22 @@ var RefreshTokenService = class {
21207
21865
  async deleteByHash(tokenHash) {
21208
21866
  await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
21209
21867
  }
21210
- async deleteAllForUser(userId) {
21211
- await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.userId, userId));
21868
+ async deleteAllForUser(uid) {
21869
+ await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
21212
21870
  }
21213
- async listForUser(userId) {
21871
+ async listForUser(uid) {
21214
21872
  return await this.db.select({
21215
21873
  id: this.refreshTokensTable.id,
21216
- userId: this.refreshTokensTable.userId,
21874
+ uid: this.refreshTokensTable.uid,
21217
21875
  tokenHash: this.refreshTokensTable.tokenHash,
21218
21876
  expiresAt: this.refreshTokensTable.expiresAt,
21219
21877
  createdAt: this.refreshTokensTable.createdAt,
21220
21878
  userAgent: this.refreshTokensTable.userAgent,
21221
21879
  ipAddress: this.refreshTokensTable.ipAddress
21222
- }).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.userId, userId)).orderBy(this.refreshTokensTable.createdAt);
21880
+ }).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
21223
21881
  }
21224
- async deleteById(id, userId) {
21225
- await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.userId} = ${userId}`);
21882
+ async deleteById(id, uid) {
21883
+ await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
21226
21884
  }
21227
21885
  };
21228
21886
  /**
@@ -21243,14 +21901,14 @@ var PasswordResetTokenService = class {
21243
21901
  /**
21244
21902
  * Create a password reset token
21245
21903
  */
21246
- async createToken(userId, tokenHash, expiresAt) {
21904
+ async createToken(uid, tokenHash, expiresAt) {
21247
21905
  const tableName = this.getQualifiedPasswordResetTokensTableName();
21248
21906
  await this.db.execute(sql`
21249
21907
  DELETE FROM ${sql.raw(tableName)}
21250
- WHERE user_id = ${userId} AND used_at IS NULL
21908
+ WHERE uid = ${uid} AND used_at IS NULL
21251
21909
  `);
21252
21910
  await this.db.insert(this.passwordResetTokensTable).values({
21253
- userId,
21911
+ uid,
21254
21912
  tokenHash,
21255
21913
  expiresAt
21256
21914
  });
@@ -21260,13 +21918,13 @@ var PasswordResetTokenService = class {
21260
21918
  */
21261
21919
  async findValidByHash(tokenHash) {
21262
21920
  const [token] = await this.db.select({
21263
- userId: this.passwordResetTokensTable.userId,
21921
+ uid: this.passwordResetTokensTable.uid,
21264
21922
  expiresAt: this.passwordResetTokensTable.expiresAt
21265
21923
  }).from(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.tokenHash, tokenHash));
21266
21924
  if (!token) return null;
21267
21925
  const tableName = this.getQualifiedPasswordResetTokensTableName();
21268
21926
  const result = await this.db.execute(sql`
21269
- SELECT user_id, expires_at
21927
+ SELECT uid, expires_at
21270
21928
  FROM ${sql.raw(tableName)}
21271
21929
  WHERE token_hash = ${tokenHash}
21272
21930
  AND used_at IS NULL
@@ -21275,7 +21933,7 @@ var PasswordResetTokenService = class {
21275
21933
  if (result.rows.length === 0) return null;
21276
21934
  const row = result.rows[0];
21277
21935
  return {
21278
- userId: row.user_id,
21936
+ uid: row.uid,
21279
21937
  expiresAt: new Date(row.expires_at)
21280
21938
  };
21281
21939
  }
@@ -21288,8 +21946,8 @@ var PasswordResetTokenService = class {
21288
21946
  /**
21289
21947
  * Delete all tokens for a user
21290
21948
  */
21291
- async deleteAllForUser(userId) {
21292
- await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.userId, userId));
21949
+ async deleteAllForUser(uid) {
21950
+ await this.db.delete(this.passwordResetTokensTable).where(eq(this.passwordResetTokensTable.uid, uid));
21293
21951
  }
21294
21952
  /**
21295
21953
  * Clean up expired tokens
@@ -21317,14 +21975,14 @@ var MagicLinkTokenService = class {
21317
21975
  const name = getTableName(this.magicLinkTokensTable);
21318
21976
  return `"${getTableConfig(this.magicLinkTokensTable).schema || "public"}"."${name}"`;
21319
21977
  }
21320
- async createToken(userId, tokenHash, expiresAt) {
21978
+ async createToken(uid, tokenHash, expiresAt) {
21321
21979
  const tableName = this.getQualifiedTableName();
21322
21980
  await this.db.execute(sql`
21323
21981
  DELETE FROM ${sql.raw(tableName)}
21324
- WHERE user_id = ${userId} AND used_at IS NULL
21982
+ WHERE uid = ${uid} AND used_at IS NULL
21325
21983
  `);
21326
21984
  await this.db.insert(this.magicLinkTokensTable).values({
21327
- userId,
21985
+ uid,
21328
21986
  tokenHash,
21329
21987
  expiresAt
21330
21988
  });
@@ -21332,7 +21990,7 @@ var MagicLinkTokenService = class {
21332
21990
  async findValidByHash(tokenHash) {
21333
21991
  const tableName = this.getQualifiedTableName();
21334
21992
  const result = await this.db.execute(sql`
21335
- SELECT user_id, expires_at
21993
+ SELECT uid, expires_at
21336
21994
  FROM ${sql.raw(tableName)}
21337
21995
  WHERE token_hash = ${tokenHash}
21338
21996
  AND used_at IS NULL
@@ -21341,7 +21999,7 @@ var MagicLinkTokenService = class {
21341
21999
  if (result.rows.length === 0) return null;
21342
22000
  const row = result.rows[0];
21343
22001
  return {
21344
- userId: row.user_id,
22002
+ uid: row.uid,
21345
22003
  expiresAt: new Date(row.expires_at)
21346
22004
  };
21347
22005
  }
@@ -21364,8 +22022,8 @@ var PostgresTokenRepository = class {
21364
22022
  this.passwordResetTokenService = new PasswordResetTokenService(db, tableOrTables);
21365
22023
  this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
21366
22024
  }
21367
- async createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress) {
21368
- await this.refreshTokenService.createToken(userId, tokenHash, expiresAt, userAgent, ipAddress);
22025
+ async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
22026
+ await this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
21369
22027
  }
21370
22028
  async findRefreshTokenByHash(tokenHash) {
21371
22029
  return this.refreshTokenService.findByHash(tokenHash);
@@ -21373,17 +22031,17 @@ var PostgresTokenRepository = class {
21373
22031
  async deleteRefreshToken(tokenHash) {
21374
22032
  await this.refreshTokenService.deleteByHash(tokenHash);
21375
22033
  }
21376
- async deleteAllRefreshTokensForUser(userId) {
21377
- await this.refreshTokenService.deleteAllForUser(userId);
22034
+ async deleteAllRefreshTokensForUser(uid) {
22035
+ await this.refreshTokenService.deleteAllForUser(uid);
21378
22036
  }
21379
- async listRefreshTokensForUser(userId) {
21380
- return this.refreshTokenService.listForUser(userId);
22037
+ async listRefreshTokensForUser(uid) {
22038
+ return this.refreshTokenService.listForUser(uid);
21381
22039
  }
21382
- async deleteRefreshTokenById(id, userId) {
21383
- await this.refreshTokenService.deleteById(id, userId);
22040
+ async deleteRefreshTokenById(id, uid) {
22041
+ await this.refreshTokenService.deleteById(id, uid);
21384
22042
  }
21385
- async createPasswordResetToken(userId, tokenHash, expiresAt) {
21386
- await this.passwordResetTokenService.createToken(userId, tokenHash, expiresAt);
22043
+ async createPasswordResetToken(uid, tokenHash, expiresAt) {
22044
+ await this.passwordResetTokenService.createToken(uid, tokenHash, expiresAt);
21387
22045
  }
21388
22046
  async findValidPasswordResetToken(tokenHash) {
21389
22047
  return this.passwordResetTokenService.findValidByHash(tokenHash);
@@ -21391,14 +22049,14 @@ var PostgresTokenRepository = class {
21391
22049
  async markPasswordResetTokenUsed(tokenHash) {
21392
22050
  await this.passwordResetTokenService.markAsUsed(tokenHash);
21393
22051
  }
21394
- async deleteAllPasswordResetTokensForUser(userId) {
21395
- await this.passwordResetTokenService.deleteAllForUser(userId);
22052
+ async deleteAllPasswordResetTokensForUser(uid) {
22053
+ await this.passwordResetTokenService.deleteAllForUser(uid);
21396
22054
  }
21397
22055
  async deleteExpiredTokens() {
21398
22056
  await this.passwordResetTokenService.deleteExpired();
21399
22057
  }
21400
- async createMagicLinkToken(userId, tokenHash, expiresAt) {
21401
- await this.magicLinkTokenService.createToken(userId, tokenHash, expiresAt);
22058
+ async createMagicLinkToken(uid, tokenHash, expiresAt) {
22059
+ await this.magicLinkTokenService.createToken(uid, tokenHash, expiresAt);
21402
22060
  }
21403
22061
  async findValidMagicLinkToken(tokenHash) {
21404
22062
  return this.magicLinkTokenService.findValidByHash(tokenHash);
@@ -21433,11 +22091,11 @@ var PostgresAuthRepository = class {
21433
22091
  async getUserByIdentity(provider, providerId) {
21434
22092
  return this.userService.getUserByIdentity(provider, providerId);
21435
22093
  }
21436
- async getUserIdentities(userId) {
21437
- return this.userService.getUserIdentities(userId);
22094
+ async getUserIdentities(uid) {
22095
+ return this.userService.getUserIdentities(uid);
21438
22096
  }
21439
- async linkUserIdentity(userId, provider, providerId, profileData) {
21440
- return this.userService.linkUserIdentity(userId, provider, providerId, profileData);
22097
+ async linkUserIdentity(uid, provider, providerId, profileData) {
22098
+ return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
21441
22099
  }
21442
22100
  async updateUser(id, data) {
21443
22101
  return this.userService.updateUser(id, data);
@@ -21463,20 +22121,20 @@ var PostgresAuthRepository = class {
21463
22121
  async getUserByVerificationToken(token) {
21464
22122
  return this.userService.getUserByVerificationToken(token);
21465
22123
  }
21466
- async getUserRoles(userId) {
21467
- return this.userService.getUserRoles(userId);
22124
+ async getUserRoles(uid) {
22125
+ return this.userService.getUserRoles(uid);
21468
22126
  }
21469
- async getUserRoleIds(userId) {
21470
- return this.userService.getUserRoleIds(userId);
22127
+ async getUserRoleIds(uid) {
22128
+ return this.userService.getUserRoleIds(uid);
21471
22129
  }
21472
- async setUserRoles(userId, roleIds) {
21473
- await this.userService.setUserRoles(userId, roleIds);
22130
+ async setUserRoles(uid, roleIds) {
22131
+ await this.userService.setUserRoles(uid, roleIds);
21474
22132
  }
21475
- async assignDefaultRole(userId, roleId) {
21476
- await this.userService.assignDefaultRole(userId, roleId);
22133
+ async assignDefaultRole(uid, roleId) {
22134
+ await this.userService.assignDefaultRole(uid, roleId);
21477
22135
  }
21478
- async getUserWithRoles(userId) {
21479
- return this.userService.getUserWithRoles(userId);
22136
+ async getUserWithRoles(uid) {
22137
+ return this.userService.getUserWithRoles(uid);
21480
22138
  }
21481
22139
  async getRoleById(id) {
21482
22140
  return {
@@ -21531,8 +22189,8 @@ var PostgresAuthRepository = class {
21531
22189
  };
21532
22190
  }
21533
22191
  async deleteRole(_id) {}
21534
- async createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress) {
21535
- await this.tokenRepository.createRefreshToken(userId, tokenHash, expiresAt, userAgent, ipAddress);
22192
+ async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress) {
22193
+ await this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress);
21536
22194
  }
21537
22195
  async findRefreshTokenByHash(tokenHash) {
21538
22196
  return this.tokenRepository.findRefreshTokenByHash(tokenHash);
@@ -21540,17 +22198,17 @@ var PostgresAuthRepository = class {
21540
22198
  async deleteRefreshToken(tokenHash) {
21541
22199
  await this.tokenRepository.deleteRefreshToken(tokenHash);
21542
22200
  }
21543
- async deleteAllRefreshTokensForUser(userId) {
21544
- await this.tokenRepository.deleteAllRefreshTokensForUser(userId);
22201
+ async deleteAllRefreshTokensForUser(uid) {
22202
+ await this.tokenRepository.deleteAllRefreshTokensForUser(uid);
21545
22203
  }
21546
- async listRefreshTokensForUser(userId) {
21547
- return this.tokenRepository.listRefreshTokensForUser(userId);
22204
+ async listRefreshTokensForUser(uid) {
22205
+ return this.tokenRepository.listRefreshTokensForUser(uid);
21548
22206
  }
21549
- async deleteRefreshTokenById(id, userId) {
21550
- await this.tokenRepository.deleteRefreshTokenById(id, userId);
22207
+ async deleteRefreshTokenById(id, uid) {
22208
+ await this.tokenRepository.deleteRefreshTokenById(id, uid);
21551
22209
  }
21552
- async createPasswordResetToken(userId, tokenHash, expiresAt) {
21553
- await this.tokenRepository.createPasswordResetToken(userId, tokenHash, expiresAt);
22210
+ async createPasswordResetToken(uid, tokenHash, expiresAt) {
22211
+ await this.tokenRepository.createPasswordResetToken(uid, tokenHash, expiresAt);
21554
22212
  }
21555
22213
  async findValidPasswordResetToken(tokenHash) {
21556
22214
  return this.tokenRepository.findValidPasswordResetToken(tokenHash);
@@ -21558,14 +22216,14 @@ var PostgresAuthRepository = class {
21558
22216
  async markPasswordResetTokenUsed(tokenHash) {
21559
22217
  await this.tokenRepository.markPasswordResetTokenUsed(tokenHash);
21560
22218
  }
21561
- async deleteAllPasswordResetTokensForUser(userId) {
21562
- await this.tokenRepository.deleteAllPasswordResetTokensForUser(userId);
22219
+ async deleteAllPasswordResetTokensForUser(uid) {
22220
+ await this.tokenRepository.deleteAllPasswordResetTokensForUser(uid);
21563
22221
  }
21564
22222
  async deleteExpiredTokens() {
21565
22223
  await this.tokenRepository.deleteExpiredTokens();
21566
22224
  }
21567
- async createMagicLinkToken(userId, tokenHash, expiresAt) {
21568
- await this.tokenRepository.createMagicLinkToken(userId, tokenHash, expiresAt);
22225
+ async createMagicLinkToken(uid, tokenHash, expiresAt) {
22226
+ await this.tokenRepository.createMagicLinkToken(uid, tokenHash, expiresAt);
21569
22227
  }
21570
22228
  async findValidMagicLinkToken(tokenHash) {
21571
22229
  return this.tokenRepository.findValidMagicLinkToken(tokenHash);
@@ -21578,11 +22236,11 @@ var PostgresAuthRepository = class {
21578
22236
  if (!this._mfaService) this._mfaService = new MfaService(this.db);
21579
22237
  return this._mfaService;
21580
22238
  }
21581
- async createMfaFactor(userId, factorType, secretEncrypted, friendlyName) {
21582
- return this.getMfaService().createMfaFactor(userId, factorType, secretEncrypted, friendlyName);
22239
+ async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
22240
+ return this.getMfaService().createMfaFactor(uid, factorType, secretEncrypted, friendlyName);
21583
22241
  }
21584
- async getMfaFactors(userId) {
21585
- return this.getMfaService().getMfaFactors(userId);
22242
+ async getMfaFactors(uid) {
22243
+ return this.getMfaService().getMfaFactors(uid);
21586
22244
  }
21587
22245
  async getMfaFactorById(factorId) {
21588
22246
  return this.getMfaService().getMfaFactorById(factorId);
@@ -21590,8 +22248,8 @@ var PostgresAuthRepository = class {
21590
22248
  async verifyMfaFactor(factorId) {
21591
22249
  return this.getMfaService().verifyMfaFactor(factorId);
21592
22250
  }
21593
- async deleteMfaFactor(factorId, userId) {
21594
- return this.getMfaService().deleteMfaFactor(factorId, userId);
22251
+ async deleteMfaFactor(factorId, uid) {
22252
+ return this.getMfaService().deleteMfaFactor(factorId, uid);
21595
22253
  }
21596
22254
  async createMfaChallenge(factorId, ipAddress) {
21597
22255
  return this.getMfaService().createMfaChallenge(factorId, ipAddress);
@@ -21602,20 +22260,20 @@ var PostgresAuthRepository = class {
21602
22260
  async verifyMfaChallenge(challengeId) {
21603
22261
  return this.getMfaService().verifyMfaChallenge(challengeId);
21604
22262
  }
21605
- async createRecoveryCodes(userId, codeHashes) {
21606
- return this.getMfaService().createRecoveryCodes(userId, codeHashes);
22263
+ async createRecoveryCodes(uid, codeHashes) {
22264
+ return this.getMfaService().createRecoveryCodes(uid, codeHashes);
21607
22265
  }
21608
- async useRecoveryCode(userId, codeHash) {
21609
- return this.getMfaService().useRecoveryCode(userId, codeHash);
22266
+ async useRecoveryCode(uid, codeHash) {
22267
+ return this.getMfaService().useRecoveryCode(uid, codeHash);
21610
22268
  }
21611
- async getUnusedRecoveryCodeCount(userId) {
21612
- return this.getMfaService().getUnusedRecoveryCodeCount(userId);
22269
+ async getUnusedRecoveryCodeCount(uid) {
22270
+ return this.getMfaService().getUnusedRecoveryCodeCount(uid);
21613
22271
  }
21614
- async deleteAllRecoveryCodes(userId) {
21615
- return this.getMfaService().deleteAllRecoveryCodes(userId);
22272
+ async deleteAllRecoveryCodes(uid) {
22273
+ return this.getMfaService().deleteAllRecoveryCodes(uid);
21616
22274
  }
21617
- async hasVerifiedMfaFactors(userId) {
21618
- return this.getMfaService().hasVerifiedMfaFactors(userId);
22275
+ async hasVerifiedMfaFactors(uid) {
22276
+ return this.getMfaService().hasVerifiedMfaFactors(uid);
21619
22277
  }
21620
22278
  };
21621
22279
  /**
@@ -21632,16 +22290,16 @@ var MfaService = class {
21632
22290
  qualify(tableName) {
21633
22291
  return `"${this.schemaName}"."${tableName}"`;
21634
22292
  }
21635
- async createMfaFactor(userId, factorType, secretEncrypted, friendlyName) {
22293
+ async createMfaFactor(uid, factorType, secretEncrypted, friendlyName) {
21636
22294
  const tableName = this.qualify("mfa_factors");
21637
22295
  const row = (await this.db.execute(sql`
21638
- INSERT INTO ${sql.raw(tableName)} (user_id, factor_type, secret_encrypted, friendly_name)
21639
- VALUES (${userId}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
21640
- RETURNING id, user_id, factor_type, friendly_name, verified, created_at, updated_at
22296
+ INSERT INTO ${sql.raw(tableName)} (uid, factor_type, secret_encrypted, friendly_name)
22297
+ VALUES (${uid}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
22298
+ RETURNING id, uid, factor_type, friendly_name, verified, created_at, updated_at
21641
22299
  `)).rows[0];
21642
22300
  return {
21643
22301
  id: row.id,
21644
- userId: row.user_id,
22302
+ uid: row.uid,
21645
22303
  factorType: row.factor_type,
21646
22304
  friendlyName: row.friendly_name ?? void 0,
21647
22305
  verified: row.verified,
@@ -21649,16 +22307,16 @@ var MfaService = class {
21649
22307
  updatedAt: new Date(row.updated_at)
21650
22308
  };
21651
22309
  }
21652
- async getMfaFactors(userId) {
22310
+ async getMfaFactors(uid) {
21653
22311
  const tableName = this.qualify("mfa_factors");
21654
22312
  return (await this.db.execute(sql`
21655
- SELECT id, user_id, factor_type, friendly_name, verified, created_at, updated_at
22313
+ SELECT id, uid, factor_type, friendly_name, verified, created_at, updated_at
21656
22314
  FROM ${sql.raw(tableName)}
21657
- WHERE user_id = ${userId}
22315
+ WHERE uid = ${uid}
21658
22316
  ORDER BY created_at
21659
22317
  `)).rows.map((row) => ({
21660
22318
  id: row.id,
21661
- userId: row.user_id,
22319
+ uid: row.uid,
21662
22320
  factorType: row.factor_type,
21663
22321
  friendlyName: row.friendly_name ?? void 0,
21664
22322
  verified: row.verified,
@@ -21669,7 +22327,7 @@ var MfaService = class {
21669
22327
  async getMfaFactorById(factorId) {
21670
22328
  const tableName = this.qualify("mfa_factors");
21671
22329
  const result = await this.db.execute(sql`
21672
- SELECT id, user_id, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
22330
+ SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
21673
22331
  FROM ${sql.raw(tableName)}
21674
22332
  WHERE id = ${factorId}
21675
22333
  `);
@@ -21677,7 +22335,7 @@ var MfaService = class {
21677
22335
  const row = result.rows[0];
21678
22336
  return {
21679
22337
  id: row.id,
21680
- userId: row.user_id,
22338
+ uid: row.uid,
21681
22339
  factorType: row.factor_type,
21682
22340
  secretEncrypted: row.secret_encrypted,
21683
22341
  friendlyName: row.friendly_name ?? void 0,
@@ -21694,11 +22352,11 @@ var MfaService = class {
21694
22352
  WHERE id = ${factorId}
21695
22353
  `);
21696
22354
  }
21697
- async deleteMfaFactor(factorId, userId) {
22355
+ async deleteMfaFactor(factorId, uid) {
21698
22356
  const tableName = this.qualify("mfa_factors");
21699
22357
  await this.db.execute(sql`
21700
22358
  DELETE FROM ${sql.raw(tableName)}
21701
- WHERE id = ${factorId} AND user_id = ${userId}
22359
+ WHERE id = ${factorId} AND uid = ${uid}
21702
22360
  `);
21703
22361
  }
21704
22362
  async createMfaChallenge(factorId, ipAddress) {
@@ -21742,43 +22400,43 @@ var MfaService = class {
21742
22400
  WHERE id = ${challengeId}
21743
22401
  `);
21744
22402
  }
21745
- async createRecoveryCodes(userId, codeHashes) {
22403
+ async createRecoveryCodes(uid, codeHashes) {
21746
22404
  const tableName = this.qualify("recovery_codes");
21747
22405
  await this.db.execute(sql`
21748
- DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
22406
+ DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
21749
22407
  `);
21750
22408
  for (const hash of codeHashes) await this.db.execute(sql`
21751
- INSERT INTO ${sql.raw(tableName)} (user_id, code_hash)
21752
- VALUES (${userId}, ${hash})
22409
+ INSERT INTO ${sql.raw(tableName)} (uid, code_hash)
22410
+ VALUES (${uid}, ${hash})
21753
22411
  `);
21754
22412
  }
21755
- async useRecoveryCode(userId, codeHash) {
22413
+ async useRecoveryCode(uid, codeHash) {
21756
22414
  const tableName = this.qualify("recovery_codes");
21757
22415
  return (await this.db.execute(sql`
21758
22416
  UPDATE ${sql.raw(tableName)}
21759
22417
  SET used_at = NOW()
21760
- WHERE user_id = ${userId} AND code_hash = ${codeHash} AND used_at IS NULL
22418
+ WHERE uid = ${uid} AND code_hash = ${codeHash} AND used_at IS NULL
21761
22419
  RETURNING id
21762
22420
  `)).rows.length > 0;
21763
22421
  }
21764
- async getUnusedRecoveryCodeCount(userId) {
22422
+ async getUnusedRecoveryCodeCount(uid) {
21765
22423
  const tableName = this.qualify("recovery_codes");
21766
22424
  return (await this.db.execute(sql`
21767
22425
  SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
21768
- WHERE user_id = ${userId} AND used_at IS NULL
22426
+ WHERE uid = ${uid} AND used_at IS NULL
21769
22427
  `)).rows[0].count;
21770
22428
  }
21771
- async deleteAllRecoveryCodes(userId) {
22429
+ async deleteAllRecoveryCodes(uid) {
21772
22430
  const tableName = this.qualify("recovery_codes");
21773
22431
  await this.db.execute(sql`
21774
- DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
22432
+ DELETE FROM ${sql.raw(tableName)} WHERE uid = ${uid}
21775
22433
  `);
21776
22434
  }
21777
- async hasVerifiedMfaFactors(userId) {
22435
+ async hasVerifiedMfaFactors(uid) {
21778
22436
  const tableName = this.qualify("mfa_factors");
21779
22437
  return (await this.db.execute(sql`
21780
22438
  SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
21781
- WHERE user_id = ${userId} AND verified = TRUE
22439
+ WHERE uid = ${uid} AND verified = TRUE
21782
22440
  `)).rows[0].count > 0;
21783
22441
  }
21784
22442
  };
@@ -22018,6 +22676,20 @@ function patchPgArrayNullSafety(tables) {
22018
22676
  if (patchedCount > 0) logger.debug(`[PgArray] Patched ${patchedCount} array column(s) for null-safety`);
22019
22677
  }
22020
22678
  //#endregion
22679
+ //#region src/schema/introspect-db-naming.ts
22680
+ /**
22681
+ * Naming helpers shared by the introspection modules. These live apart from
22682
+ * `introspect-db-logic.ts` because the inference pass needs them too, and
22683
+ * importing them from there would close a cycle back through this module.
22684
+ */
22685
+ /**
22686
+ * Convert a snake_case name to a human-readable Title Case label.
22687
+ * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
22688
+ */
22689
+ function humanize(snakeName) {
22690
+ return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
22691
+ }
22692
+ //#endregion
22021
22693
  //#region src/schema/introspect-db-logic.ts
22022
22694
  var IRREGULAR_SINGULARS = {
22023
22695
  people: "person",
@@ -22077,13 +22749,6 @@ function singularize(word) {
22077
22749
  if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
22078
22750
  return word;
22079
22751
  }
22080
- /**
22081
- * Convert a snake_case name to a human-readable Title Case label.
22082
- * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
22083
- */
22084
- function humanize(snakeName) {
22085
- return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
22086
- }
22087
22752
  function getIconForTable(tableName) {
22088
22753
  const table = tableName.toLowerCase();
22089
22754
  if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
@@ -22604,6 +23269,11 @@ function createPostgresBootstrapper(pgConfig) {
22604
23269
  } catch (err) {
22605
23270
  logger.warn("⚠️ Could not initialize branch metadata table", { error: err });
22606
23271
  }
23272
+ try {
23273
+ await realtimeService.configureChannelHistory(pgConfig.realtime?.channels);
23274
+ } catch (err) {
23275
+ logger.warn("⚠️ Could not initialize channel history tables — retained channels will not replay", { error: err });
23276
+ }
22607
23277
  const directUrl = process.env.DATABASE_DIRECT_URL || pgConfig.connectionString;
22608
23278
  const validModes = new Set([
22609
23279
  "auto",
@@ -22619,6 +23289,7 @@ function createPostgresBootstrapper(pgConfig) {
22619
23289
  const wantsCdc = cdcMode !== "off";
22620
23290
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22621
23291
  let cdcEnabled = false;
23292
+ let provisionCdcForTables;
22622
23293
  if (wantsCdc && !directUrl) {
22623
23294
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22624
23295
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22635,6 +23306,9 @@ function createPostgresBootstrapper(pgConfig) {
22635
23306
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22636
23307
  await realtimeService.enableCdc(directUrl);
22637
23308
  cdcEnabled = true;
23309
+ provisionCdcForTables = async (tables) => {
23310
+ await provisionTriggerCdc(cdcRunSql, tables);
23311
+ };
22638
23312
  logger.info(`📡 [CDC] Realtime source = database-level change capture (mode: ${cdcMode === "wal" ? "wal→trigger" : "trigger"}). All writes now emit realtime events regardless of origin.`);
22639
23313
  } catch (err) {
22640
23314
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22659,13 +23333,14 @@ function createPostgresBootstrapper(pgConfig) {
22659
23333
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22660
23334
  const missing = [];
22661
23335
  for (const col of registeredCollections) {
23336
+ if (col.auth?.enabled) continue;
22662
23337
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22663
23338
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22664
23339
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22665
23340
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22666
23341
  if (!dbTables.has(fullCheckName)) missing.push({
22667
23342
  slug: col.slug,
22668
- table: checkName
23343
+ table: fullCheckName
22669
23344
  });
22670
23345
  }
22671
23346
  if (missing.length > 0) {
@@ -22699,7 +23374,8 @@ function createPostgresBootstrapper(pgConfig) {
22699
23374
  registry,
22700
23375
  realtimeService,
22701
23376
  driver,
22702
- poolManager
23377
+ poolManager,
23378
+ provisionCdcForTables
22703
23379
  }
22704
23380
  };
22705
23381
  },
@@ -22711,6 +23387,18 @@ function createPostgresBootstrapper(pgConfig) {
22711
23387
  const registry = internals.registry;
22712
23388
  const authCollection = authConfig.collection;
22713
23389
  await ensureAuthTablesExist(db, authCollection);
23390
+ if (authCollection && internals.provisionCdcForTables) {
23391
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
23392
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
23393
+ if (authTable) try {
23394
+ await internals.provisionCdcForTables([{
23395
+ schema: authSchema,
23396
+ table: authTable
23397
+ }]);
23398
+ } catch (err) {
23399
+ logger.warn(`⚠️ [CDC] Could not attach change-capture to the auth table "${authSchema}.${authTable}" — writes to it won't emit database-level events.`, { detail: err instanceof Error ? err.message : String(err) });
23400
+ }
23401
+ }
22714
23402
  let emailService;
22715
23403
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22716
23404
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22781,6 +23469,6 @@ function createPostgresAdapter(pgConfig) {
22781
23469
  };
22782
23470
  }
22783
23471
  //#endregion
22784
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
23472
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, DatabasePoolManager, DrizzleConditionBuilder, PostgresBackendDriver, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgRestoreArgs, checkToolServerCompatibility, createAuthSchema, createBackupCron, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, ensureDatabaseExists, generateSchema, getServerVersionMajor, guardPoolAgainstDirtyRelease, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveConnectionString, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, withDatabaseName };
22785
23473
 
22786
23474
  //# sourceMappingURL=index.es.js.map