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

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.
@@ -9,6 +9,7 @@ import { PostgresBackendDriver } from "./PostgresBackendDriver";
9
9
  import { RealtimeService } from "./services/realtimeService";
10
10
  import { DatabasePoolManager } from "./databasePoolManager";
11
11
  import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
12
+ import { type CdcTableRef } from "./services/cdc/trigger-cdc";
12
13
  export interface PostgresDriverConfig {
13
14
  connectionString?: string;
14
15
  adminConnectionString?: string;
@@ -36,6 +37,15 @@ export interface PostgresDriverInternals {
36
37
  realtimeService: RealtimeService;
37
38
  driver: PostgresBackendDriver;
38
39
  poolManager?: DatabasePoolManager;
40
+ /**
41
+ * Attach CDC triggers to tables that did not exist when the driver
42
+ * bootstrapped. Only set when database-level capture is actually active.
43
+ *
44
+ * Auth owns its own tables and creates them later in boot, so at driver
45
+ * bootstrap they are legitimately missing and get skipped; without this
46
+ * they would stay uninstrumented until the next restart.
47
+ */
48
+ provisionCdcForTables?: (tables: CdcTableRef[]) => Promise<void>;
39
49
  }
40
50
  /**
41
51
  * Default PostgreSQL bootstrapper.
@@ -19,6 +19,22 @@ export declare class UserService implements UserRepository {
19
19
  private userIdentitiesTable;
20
20
  constructor(db: NodePgDatabase, tableOrTables?: RebasePgTable | Partial<AuthSchemaTables>);
21
21
  private getQualifiedUsersTableName;
22
+ /**
23
+ * Run a privileged auth write with an explicitly cleared RLS context.
24
+ *
25
+ * The auth services run on the base/owner connection, which by design
26
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
27
+ * in the default policies applies. That NULL is normally guaranteed by
28
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
29
+ * GUC that survives on a pooled connection (or a connection role that
30
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
31
+ * turns the trusted write into an RLS-scoped one and denies it with
32
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
33
+ * single chokepoint, makes the server context deterministic instead of
34
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
35
+ * NULL via NULLIF, so '' is the server context.
36
+ */
37
+ private withServerContext;
22
38
  private mapRowToUser;
23
39
  private mapPayload;
24
40
  createUser(data: CreateUserData): Promise<UserData>;
@@ -19,6 +19,27 @@ export interface PostgresPoolConfig {
19
19
  /** Enable TCP keep-alive (default: true) */
20
20
  keepAlive?: boolean;
21
21
  }
22
+ /**
23
+ * Destroy pool clients that are released while still inside a transaction.
24
+ *
25
+ * pg-pool returns a client to the idle list whenever `release()` is called
26
+ * without an error — even if the connection is still mid-transaction (status
27
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
28
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
29
+ * (e.g. it was queued behind a statement that hit the client-side
30
+ * query_timeout), the client goes back dirty. The next checkout then runs
31
+ * its statements inside the zombie transaction — with the previous request's
32
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
33
+ * RLS-scoped ones (observed in production as registration failing with
34
+ * SQLSTATE 42501 under a leaked anonymous context).
35
+ *
36
+ * pg-pool emits `release` before it consults its private `_expired` set, so
37
+ * marking the client expired here makes `_release()` destroy it instead of
38
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
39
+ * private APIs — feature-detect and fall back to loud logging so an upstream
40
+ * change degrades to observability, never to silent corruption.
41
+ */
42
+ export declare function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void;
22
43
  /**
23
44
  * Create a Drizzle-backed Postgres connection with a production-grade
24
45
  * connection pool.
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,
@@ -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
  }
@@ -8544,12 +8633,14 @@ var PostgresBackendDriver = class PostgresBackendDriver {
8544
8633
  let updatedValues = values;
8545
8634
  const contextForCallback = this.buildCallContext();
8546
8635
  let previousValuesForHistory;
8547
- if (status === "existing" && id) {
8548
- const existing = await this.dataService.fetchOne(path, id, resolvedCollection?.databaseId);
8636
+ if (status === "existing" && id) try {
8637
+ const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
8549
8638
  if (existing) {
8550
8639
  const { id: _existingId, ...existingValues } = existing;
8551
8640
  previousValuesForHistory = existingValues;
8552
8641
  }
8642
+ } catch (err) {
8643
+ logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
8553
8644
  }
8554
8645
  if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
8555
8646
  if (globalCallbacks?.beforeSave) {
@@ -9162,6 +9253,7 @@ var DatabasePoolManager = class {
9162
9253
  pool.on("error", (err) => {
9163
9254
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
9164
9255
  });
9256
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
9165
9257
  this.pools.set(databaseName, pool);
9166
9258
  return pool;
9167
9259
  }
@@ -20722,14 +20814,22 @@ async function ensureAuthTablesExist(db, collection) {
20722
20814
  $$ LANGUAGE sql STABLE
20723
20815
  `);
20724
20816
  });
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
- `);
20817
+ for (const columnDef of [
20818
+ "display_name VARCHAR(255)",
20819
+ "photo_url VARCHAR(500)",
20820
+ "roles TEXT[] DEFAULT '{}' NOT NULL",
20821
+ "password_hash VARCHAR(255)",
20822
+ "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
20823
+ "email_verification_token VARCHAR(255)",
20824
+ "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
20825
+ "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
20826
+ "metadata JSONB DEFAULT '{}' NOT NULL",
20827
+ "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
20828
+ "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
20829
+ ]) await db.execute(sql`
20830
+ ALTER TABLE ${sql.raw(usersTableName)}
20831
+ ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
20832
+ `);
20733
20833
  try {
20734
20834
  if ((await db.execute(sql`
20735
20835
  SELECT EXISTS (
@@ -20800,6 +20900,34 @@ async function ensureAuthTablesExist(db, collection) {
20800
20900
  CREATE INDEX IF NOT EXISTS idx_recovery_codes_user
20801
20901
  ON ${sql.raw(recoveryCodesTableName)}(user_id)
20802
20902
  `);
20903
+ try {
20904
+ const authTablePairs = [
20905
+ [usersSchema, resolvedTable],
20906
+ [authSchema, "user_identities"],
20907
+ [authSchema, "refresh_tokens"],
20908
+ [authSchema, "password_reset_tokens"],
20909
+ [authSchema, "app_config"],
20910
+ [authSchema, "mfa_factors"],
20911
+ [authSchema, "mfa_challenges"],
20912
+ [authSchema, "recovery_codes"]
20913
+ ];
20914
+ for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
20915
+ SELECT 1
20916
+ FROM pg_class c
20917
+ JOIN pg_namespace n ON n.oid = c.relnamespace
20918
+ WHERE n.nspname = ${schemaName}
20919
+ AND c.relname = ${tableName}
20920
+ AND c.relforcerowsecurity
20921
+ `)).rows.length > 0) {
20922
+ await db.execute(sql`
20923
+ ALTER TABLE ${sql.raw(`"${schemaName}"."${tableName}"`)}
20924
+ NO FORCE ROW LEVEL SECURITY
20925
+ `);
20926
+ logger.warn(`🔧 Cleared stale FORCE ROW LEVEL SECURITY on "${schemaName}"."${tableName}" (legacy RLS model — it binds the owner connection and breaks privileged auth writes)`);
20927
+ }
20928
+ } catch (rlsReconcileError) {
20929
+ logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
20930
+ }
20803
20931
  logger.info("✅ Auth tables ready");
20804
20932
  } catch (error) {
20805
20933
  logger.error("❌ Failed to create auth tables", { error });
@@ -20847,6 +20975,31 @@ var UserService = class {
20847
20975
  const name = getTableName(this.usersTable);
20848
20976
  return `"${getTableConfig(this.usersTable).schema || "public"}"."${name}"`;
20849
20977
  }
20978
+ /**
20979
+ * Run a privileged auth write with an explicitly cleared RLS context.
20980
+ *
20981
+ * The auth services run on the base/owner connection, which by design
20982
+ * carries a NULL `app.user_id` so the `auth.uid() IS NULL` server-escape
20983
+ * in the default policies applies. That NULL is normally guaranteed by
20984
+ * `set_config(..., is_local = true)` resetting at transaction end — but a
20985
+ * GUC that survives on a pooled connection (or a connection role that
20986
+ * doesn't bypass RLS: FORCE ROW LEVEL SECURITY, or a non-owner role)
20987
+ * turns the trusted write into an RLS-scoped one and denies it with
20988
+ * SQLSTATE 42501. Clearing the GUCs here, transaction-locally at the
20989
+ * single chokepoint, makes the server context deterministic instead of
20990
+ * trusting whatever state the pool hands us. `auth.uid()` reads '' as
20991
+ * NULL via NULLIF, so '' is the server context.
20992
+ */
20993
+ async withServerContext(fn) {
20994
+ return await this.db.transaction(async (tx) => {
20995
+ await tx.execute(sql`
20996
+ SELECT set_config('app.user_id', '', true),
20997
+ set_config('app.user_roles', '', true),
20998
+ set_config('app.jwt', '', true)
20999
+ `);
21000
+ return await fn(tx);
21001
+ });
21002
+ }
20850
21003
  mapRowToUser(row) {
20851
21004
  if (!row) return row;
20852
21005
  const id = row.id ?? row.uid;
@@ -20944,7 +21097,7 @@ var UserService = class {
20944
21097
  }
20945
21098
  async createUser(data) {
20946
21099
  const payload = this.mapPayload(data);
20947
- const [row] = await this.db.insert(this.usersTable).values(payload).returning();
21100
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
20948
21101
  return this.mapRowToUser(row);
20949
21102
  }
20950
21103
  async getUserById(id) {
@@ -20983,12 +21136,12 @@ var UserService = class {
20983
21136
  }));
20984
21137
  }
20985
21138
  async linkUserIdentity(userId, provider, providerId, profileData) {
20986
- await this.db.insert(this.userIdentitiesTable).values({
21139
+ await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
20987
21140
  userId,
20988
21141
  provider,
20989
21142
  providerId,
20990
21143
  profileData: profileData || null
20991
- }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] });
21144
+ }).onConflictDoNothing({ target: [this.userIdentitiesTable.provider, this.userIdentitiesTable.providerId] }));
20992
21145
  }
20993
21146
  async updateUser(id, data) {
20994
21147
  const idCol = getColumn(this.usersTable, "id");
@@ -20996,13 +21149,13 @@ var UserService = class {
20996
21149
  const payload = this.mapPayload(data);
20997
21150
  const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
20998
21151
  payload[updatedAtKey] = /* @__PURE__ */ new Date();
20999
- const [row] = await this.db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning();
21152
+ const [row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
21000
21153
  return row ? this.mapRowToUser(row) : null;
21001
21154
  }
21002
21155
  async deleteUser(id) {
21003
21156
  const idCol = getColumn(this.usersTable, "id");
21004
21157
  if (!idCol) return;
21005
- await this.db.delete(this.usersTable).where(eq(idCol, id));
21158
+ await this.withServerContext(async (db) => db.delete(this.usersTable).where(eq(idCol, id)));
21006
21159
  }
21007
21160
  async listUsers() {
21008
21161
  return (await this.db.select().from(this.usersTable)).map((row) => this.mapRowToUser(row));
@@ -21056,10 +21209,10 @@ var UserService = class {
21056
21209
  if (!idCol) return;
21057
21210
  const passwordHashColKey = getColumnKey(this.usersTable, "passwordHash", "password_hash") || "passwordHash";
21058
21211
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21059
- await this.db.update(this.usersTable).set({
21212
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21060
21213
  [passwordHashColKey]: passwordHash,
21061
21214
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21062
- }).where(eq(idCol, id));
21215
+ }).where(eq(idCol, id)));
21063
21216
  }
21064
21217
  /**
21065
21218
  * Set email verification status
@@ -21070,11 +21223,11 @@ var UserService = class {
21070
21223
  const emailVerifiedColKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
21071
21224
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21072
21225
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21073
- await this.db.update(this.usersTable).set({
21226
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21074
21227
  [emailVerifiedColKey]: verified,
21075
21228
  [emailVerificationTokenColKey]: null,
21076
21229
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21077
- }).where(eq(idCol, id));
21230
+ }).where(eq(idCol, id)));
21078
21231
  }
21079
21232
  /**
21080
21233
  * Set email verification token
@@ -21085,11 +21238,11 @@ var UserService = class {
21085
21238
  const emailVerificationTokenColKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
21086
21239
  const emailVerificationSentAtColKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
21087
21240
  const updatedAtColKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
21088
- await this.db.update(this.usersTable).set({
21241
+ await this.withServerContext(async (db) => db.update(this.usersTable).set({
21089
21242
  [emailVerificationTokenColKey]: token,
21090
21243
  [emailVerificationSentAtColKey]: token ? /* @__PURE__ */ new Date() : null,
21091
21244
  [updatedAtColKey]: /* @__PURE__ */ new Date()
21092
- }).where(eq(idCol, id));
21245
+ }).where(eq(idCol, id)));
21093
21246
  }
21094
21247
  /**
21095
21248
  * Find user by email verification token
@@ -21134,22 +21287,22 @@ var UserService = class {
21134
21287
  async setUserRoles(userId, roleIds) {
21135
21288
  const usersTableName = this.getQualifiedUsersTableName();
21136
21289
  const rolesArray = `{${roleIds.join(",")}}`;
21137
- await this.db.execute(sql`
21290
+ await this.withServerContext(async (db) => db.execute(sql`
21138
21291
  UPDATE ${sql.raw(usersTableName)}
21139
21292
  SET roles = ${rolesArray}::text[], updated_at = NOW()
21140
21293
  WHERE id = ${userId}
21141
- `);
21294
+ `));
21142
21295
  }
21143
21296
  /**
21144
21297
  * Assign a specific role to new user (appends if not present)
21145
21298
  */
21146
21299
  async assignDefaultRole(userId, roleId) {
21147
21300
  const usersTableName = this.getQualifiedUsersTableName();
21148
- await this.db.execute(sql`
21301
+ await this.withServerContext(async (db) => db.execute(sql`
21149
21302
  UPDATE ${sql.raw(usersTableName)}
21150
21303
  SET roles = array_append(roles, ${roleId}), updated_at = NOW()
21151
21304
  WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
21152
- `);
21305
+ `));
21153
21306
  }
21154
21307
  /**
21155
21308
  * Get user with their roles
@@ -22619,6 +22772,7 @@ function createPostgresBootstrapper(pgConfig) {
22619
22772
  const wantsCdc = cdcMode !== "off";
22620
22773
  const explicitCdc = cdcMode === "trigger" || cdcMode === "wal";
22621
22774
  let cdcEnabled = false;
22775
+ let provisionCdcForTables;
22622
22776
  if (wantsCdc && !directUrl) {
22623
22777
  const reason = "no direct database connection is available for the realtime LISTEN client (set DATABASE_DIRECT_URL)";
22624
22778
  if (explicitCdc) logger.warn(`⚠️ [CDC] REALTIME_CDC=${cdcMode} but ${reason} — using app-level realtime.`);
@@ -22635,6 +22789,9 @@ function createPostgresBootstrapper(pgConfig) {
22635
22789
  })).filter((t) => Boolean(t.table) && registry.hasTableForCollection(t.table)));
22636
22790
  await realtimeService.enableCdc(directUrl);
22637
22791
  cdcEnabled = true;
22792
+ provisionCdcForTables = async (tables) => {
22793
+ await provisionTriggerCdc(cdcRunSql, tables);
22794
+ };
22638
22795
  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
22796
  } catch (err) {
22640
22797
  if (explicitCdc) logger.warn("⚠️ [CDC] Could not enable database-level change capture — falling back to app-level realtime.", { error: err });
@@ -22659,13 +22816,14 @@ function createPostgresBootstrapper(pgConfig) {
22659
22816
  const dbTables = new Set(result.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
22660
22817
  const missing = [];
22661
22818
  for (const col of registeredCollections) {
22819
+ if (col.auth?.enabled) continue;
22662
22820
  const schemaName = "schema" in col && col.schema ? col.schema : "public";
22663
22821
  const tableName = registry.hasTableForCollection(col.table ?? col.slug) ? col.table ?? col.slug : col.slug;
22664
22822
  const checkName = registry.getTableNames().find((k) => k === tableName || k === col.slug) ?? tableName;
22665
22823
  const fullCheckName = schemaName === "public" ? checkName : `${schemaName}.${checkName}`;
22666
22824
  if (!dbTables.has(fullCheckName)) missing.push({
22667
22825
  slug: col.slug,
22668
- table: checkName
22826
+ table: fullCheckName
22669
22827
  });
22670
22828
  }
22671
22829
  if (missing.length > 0) {
@@ -22699,7 +22857,8 @@ function createPostgresBootstrapper(pgConfig) {
22699
22857
  registry,
22700
22858
  realtimeService,
22701
22859
  driver,
22702
- poolManager
22860
+ poolManager,
22861
+ provisionCdcForTables
22703
22862
  }
22704
22863
  };
22705
22864
  },
@@ -22711,6 +22870,18 @@ function createPostgresBootstrapper(pgConfig) {
22711
22870
  const registry = internals.registry;
22712
22871
  const authCollection = authConfig.collection;
22713
22872
  await ensureAuthTablesExist(db, authCollection);
22873
+ if (authCollection && internals.provisionCdcForTables) {
22874
+ const authSchema = "schema" in authCollection && typeof authCollection.schema === "string" ? authCollection.schema : "rebase";
22875
+ const authTable = "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug;
22876
+ if (authTable) try {
22877
+ await internals.provisionCdcForTables([{
22878
+ schema: authSchema,
22879
+ table: authTable
22880
+ }]);
22881
+ } catch (err) {
22882
+ 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) });
22883
+ }
22884
+ }
22714
22885
  let emailService;
22715
22886
  if (authConfig.email) emailService = createEmailService(authConfig.email);
22716
22887
  const tableName = authCollection ? "table" in authCollection && typeof authCollection.table === "string" ? authCollection.table : authCollection.slug : void 0;
@@ -22781,6 +22952,6 @@ function createPostgresAdapter(pgConfig) {
22781
22952
  };
22782
22953
  }
22783
22954
  //#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 };
22955
+ 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
22956
 
22786
22957
  //# sourceMappingURL=index.es.js.map