@rebasepro/server-postgres 0.14.1-canary.g7e666eb → 0.14.1

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.
package/dist/index.es.js CHANGED
@@ -11,7 +11,7 @@ import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBac
11
11
  import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-69hYT8nr.js";
12
12
  import { Client, Pool } from "pg";
13
13
  import { drizzle } from "drizzle-orm/node-postgres";
14
- import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
14
+ import { ApiError, createDdlBootstrapper, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
15
15
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
16
16
  import { PgArray, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
17
17
  import fs, { promises } from "fs";
@@ -602,6 +602,31 @@ function getUnknownFilterFieldsMode() {
602
602
  return defaultUnknownFilterFieldsMode;
603
603
  }
604
604
  /**
605
+ * Split `metadata->address->>city` into its column and its path.
606
+ *
607
+ * The arrows are PostgREST's spelling and Postgres's own, so the filter reads
608
+ * the same as the SQL it becomes — and, more usefully, the same as what someone
609
+ * would have written by hand in the SQL console while working out what to ask
610
+ * for. A field with no arrow is not a JSON path and returns `undefined`, which
611
+ * leaves every existing filter on exactly the path it took before.
612
+ *
613
+ * Both arrows are accepted and mean the same thing here: the extraction is
614
+ * always compiled to `->>` (text) at the leaf, because that is the only form a
615
+ * comparison can be made against. `->` is allowed because people write it out
616
+ * of habit, and refusing it would be pedantry about a distinction this layer
617
+ * erases anyway.
618
+ */
619
+ function parseJsonFieldPath(field) {
620
+ if (!field.includes("->")) return void 0;
621
+ const segments = field.split(/->>?/).map((s) => s.trim()).filter(Boolean);
622
+ if (segments.length < 2) return void 0;
623
+ const [columnKey, ...path] = segments;
624
+ return {
625
+ columnKey,
626
+ path
627
+ };
628
+ }
629
+ /**
605
630
  * Filter values may arrive as relation wire objects — `EntityRelation`
606
631
  * instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
607
632
  * the admin filters a relation column. SQL comparisons need the raw id, so
@@ -762,6 +787,19 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
762
787
  kind: "column",
763
788
  column: direct
764
789
  };
790
+ const jsonPath = parseJsonFieldPath(field);
791
+ if (jsonPath) {
792
+ const base = columnAt(jsonPath.columnKey);
793
+ if (base) {
794
+ const meta = getColumnMeta(base);
795
+ if (meta.dataType !== "json" && meta.columnType !== "PgJsonb" && meta.columnType !== "PgJson") throw ApiError.badRequest(`Cannot filter inside "${jsonPath.columnKey}" — it is not a json or jsonb column.`, "INVALID_FILTER_FIELD");
796
+ return {
797
+ kind: "json",
798
+ column: base,
799
+ path: jsonPath.path
800
+ };
801
+ }
802
+ }
765
803
  if (collection) {
766
804
  const relation = resolveCollectionRelations(collection)[field];
767
805
  if (relation?.kind === "belongsTo") {
@@ -841,7 +879,71 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
841
879
  }
842
880
  /** Dispatch a resolved filter field onto the shape it actually compiles to. */
843
881
  static compileFilterTarget(target, op, value, field, collectionPath) {
844
- return target.kind === "column" ? this.buildSingleFilterCondition(target.column, op, value) : this.buildRelationFilterCondition(target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath);
882
+ if (target.kind === "column") return this.buildSingleFilterCondition(target.column, op, value);
883
+ if (target.kind === "json") return this.buildJsonPathCondition(target.column, target.path, op, value);
884
+ return this.buildRelationFilterCondition(target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath);
885
+ }
886
+ /**
887
+ * A comparison against a value extracted from a json/jsonb column.
888
+ *
889
+ * The path is walked with `->` and the leaf taken with `->>`, so what comes
890
+ * out is always **text**. That is the whole of the type story, and it is
891
+ * the part worth being explicit about, because the alternatives are all
892
+ * worse:
893
+ *
894
+ * - text comparison alone makes `["<", 100]` compare lexically, where
895
+ * `"9"` is greater than `"100"`;
896
+ * - casting unconditionally makes every filter on a non-numeric value a
897
+ * runtime `invalid input syntax for type numeric` — a 500 on a row whose
898
+ * JSON simply holds a string.
899
+ *
900
+ * So the *filter value* decides. A number on an ordering comparison casts
901
+ * both sides to numeric; everything else compares as text, with booleans
902
+ * rendered the way `->>` renders them (`"true"` / `"false"`). A row whose
903
+ * JSON holds a non-numeric value at a path being compared numerically is
904
+ * excluded rather than fatal, which is what `IS NOT NULL`-style filtering
905
+ * means everywhere else in this file.
906
+ *
907
+ * The path segments are bound as parameters, never interpolated: they come
908
+ * from a query string, and `->>` takes a text parameter perfectly well.
909
+ */
910
+ static buildJsonPathCondition(column, path, op, value) {
911
+ let expr = sql`${column}`;
912
+ for (const key of path.slice(0, -1)) expr = sql`${expr} -> ${key}`;
913
+ const leaf = sql`${expr} ->> ${path[path.length - 1]}`;
914
+ if (typeof value === "number" && (op === ">" || op === ">=" || op === "<" || op === "<=")) {
915
+ const numeric = sql`CASE WHEN ${leaf} ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN (${leaf})::numeric END`;
916
+ switch (op) {
917
+ case ">": return sql`${numeric} > ${value}`;
918
+ case ">=": return sql`${numeric} >= ${value}`;
919
+ case "<": return sql`${numeric} < ${value}`;
920
+ case "<=": return sql`${numeric} <= ${value}`;
921
+ }
922
+ }
923
+ const asText = (v) => typeof v === "boolean" ? String(v) : String(v);
924
+ switch (op) {
925
+ case "==": return value === null || value === void 0 ? sql`${leaf} IS NULL` : sql`${leaf} = ${asText(value)}`;
926
+ case "!=": return value === null || value === void 0 ? sql`${leaf} IS NOT NULL` : sql`${leaf} != ${asText(value)}`;
927
+ case ">": return sql`${leaf} > ${asText(value)}`;
928
+ case ">=": return sql`${leaf} >= ${asText(value)}`;
929
+ case "<": return sql`${leaf} < ${asText(value)}`;
930
+ case "<=": return sql`${leaf} <= ${asText(value)}`;
931
+ case "like": return sql`${leaf} LIKE ${asText(value)}`;
932
+ case "ilike": return sql`${leaf} ILIKE ${asText(value)}`;
933
+ case "not-like": return sql`${leaf} NOT LIKE ${asText(value)}`;
934
+ case "not-ilike": return sql`${leaf} NOT ILIKE ${asText(value)}`;
935
+ case "is-null": return sql`${leaf} IS NULL`;
936
+ case "is-not-null": return sql`${leaf} IS NOT NULL`;
937
+ case "in":
938
+ case "not-in": {
939
+ if (value === null || value === void 0) return op === "in" ? sql`${leaf} IS NULL` : sql`${leaf} IS NOT NULL`;
940
+ const values = toMembershipList(value).map(asText);
941
+ if (values.length === 0) return op === "in" ? sql`FALSE` : sql`TRUE`;
942
+ const list = sql.join(values.map((v) => sql`${v}`), sql`, `);
943
+ return op === "in" ? sql`${leaf} IN (${list})` : sql`${leaf} NOT IN (${list})`;
944
+ }
945
+ default: throw ApiError.badRequest(`Operator "${op}" is not supported on a JSON path. Use it on the column itself.`, "INVALID_FILTER_OPERATOR");
946
+ }
845
947
  }
846
948
  /**
847
949
  * A filter on a relation that owns no column on this row — `EXISTS` over
@@ -3830,6 +3932,92 @@ var FetchService = class FetchService {
3830
3932
  return Number(result[0]?.count || 0);
3831
3933
  }
3832
3934
  /**
3935
+ * `count`/`sum`/`avg`/`min`/`max`, optionally grouped.
3936
+ *
3937
+ * The gap this fills is narrow and constant: every dashboard wants "revenue
3938
+ * by status" and "orders per day", and without it the options were a custom
3939
+ * function holding hand-written SQL, or fetching every row and reducing in
3940
+ * JavaScript — which is wrong at any size that matters, and silently wrong
3941
+ * under a `limit`.
3942
+ *
3943
+ * It runs through the same request-scoped handle as every other read, so
3944
+ * **RLS applies to the rows being aggregated**. That is the property worth
3945
+ * protecting here: an aggregate is an effective way to read data you cannot
3946
+ * select, and `count(*)` over a table whose policies would return nothing
3947
+ * has to be zero.
3948
+ */
3949
+ async aggregate(collectionPath, options) {
3950
+ const collection = getCollectionByPath(collectionPath, this.registry);
3951
+ const table = getTableForCollection(collection, this.registry);
3952
+ const columns = getTableColumns(table);
3953
+ const columnFor = (field, forWhat) => {
3954
+ const column = columns[field];
3955
+ if (!column) throw ApiError.badRequest(`Unknown field '${field}' in ${forWhat}. Valid fields: ${Object.keys(columns).sort().join(", ")}`, "UNKNOWN_AGGREGATE_FIELD");
3956
+ return column;
3957
+ };
3958
+ const selection = {};
3959
+ for (const aggregate of options.aggregates) {
3960
+ if (aggregate.fn === "count" && !aggregate.field) {
3961
+ selection[aggregate.alias] = sql`count(*)`;
3962
+ continue;
3963
+ }
3964
+ const column = columnFor(aggregate.field, `${aggregate.fn}()`);
3965
+ switch (aggregate.fn) {
3966
+ case "count":
3967
+ selection[aggregate.alias] = sql`count(${column})`;
3968
+ break;
3969
+ case "sum":
3970
+ selection[aggregate.alias] = sql`sum(${column})::numeric`;
3971
+ break;
3972
+ case "avg":
3973
+ selection[aggregate.alias] = sql`avg(${column})::numeric`;
3974
+ break;
3975
+ case "min":
3976
+ selection[aggregate.alias] = sql`min(${column})`;
3977
+ break;
3978
+ case "max":
3979
+ selection[aggregate.alias] = sql`max(${column})`;
3980
+ break;
3981
+ }
3982
+ }
3983
+ const groupColumns = (options.groupBy ?? []).map((field) => ({
3984
+ field,
3985
+ column: columnFor(field, "groupBy")
3986
+ }));
3987
+ for (const group of groupColumns) selection[group.field] = sql`${group.column}`;
3988
+ let query = this.db.select(selection).from(table).$dynamic();
3989
+ const conditions = [];
3990
+ if (options.searchString) {
3991
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3992
+ if (searchConditions.length === 0) return [];
3993
+ conditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3994
+ }
3995
+ if (options.filter) conditions.push(...this.buildFilterConditions(options.filter, table, collectionPath));
3996
+ if (options.logical) {
3997
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
3998
+ if (logicalCondition) conditions.push(logicalCondition);
3999
+ }
4000
+ if (conditions.length > 0) {
4001
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
4002
+ if (finalCondition) query = query.where(finalCondition);
4003
+ }
4004
+ if (groupColumns.length > 0) {
4005
+ query = query.groupBy(...groupColumns.map((g) => g.column));
4006
+ if (options.limit) query = query.limit(options.limit);
4007
+ }
4008
+ const rows = await query;
4009
+ const numericAliases = new Set(options.aggregates.filter((a) => a.fn === "count" || a.fn === "sum" || a.fn === "avg").map((a) => a.alias));
4010
+ return rows.map((row) => {
4011
+ const out = { ...row };
4012
+ for (const alias of numericAliases) {
4013
+ if (out[alias] === null || out[alias] === void 0) continue;
4014
+ const parsed = Number(out[alias]);
4015
+ if (!Number.isNaN(parsed)) out[alias] = parsed;
4016
+ }
4017
+ return out;
4018
+ });
4019
+ }
4020
+ /**
3833
4021
  * Check if a field value is unique
3834
4022
  */
3835
4023
  async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
@@ -7305,6 +7493,29 @@ var CdcListener = class {
7305
7493
  }
7306
7494
  };
7307
7495
  //#endregion
7496
+ //#region src/schema/drizzle-ddl.ts
7497
+ /**
7498
+ * The server's DDL bootstrapper, over a Drizzle handle.
7499
+ *
7500
+ * `createDdlBootstrapper` in `@rebasepro/server` wants a plain
7501
+ * `(sql: string) => Promise<rows>`; the driver's internal stores hold a Drizzle
7502
+ * database. This is the adapter between them, and it exists so the retry policy
7503
+ * has exactly one definition. A second copy of the SQLSTATE list living in the
7504
+ * driver is how the two drift apart, and the drift is invisible: both versions
7505
+ * work perfectly on every single-instance deployment.
7506
+ */
7507
+ /**
7508
+ * A {@link DdlBootstrapper} that runs its statements through `db.execute`.
7509
+ *
7510
+ * @param db the Drizzle handle the calling store already holds
7511
+ * @param scope log prefix identifying the caller, e.g. `"channel-presence"`
7512
+ */
7513
+ function drizzleDdlBootstrapper(db, scope) {
7514
+ return createDdlBootstrapper(async (statement) => {
7515
+ return (await db.execute(sql.raw(statement))).rows ?? [];
7516
+ }, scope);
7517
+ }
7518
+ //#endregion
7308
7519
  //#region src/services/channel-history.ts
7309
7520
  /**
7310
7521
  * Ordered, replayable per-channel message history.
@@ -7440,8 +7651,9 @@ var ChannelHistoryStore = class {
7440
7651
  */
7441
7652
  async ensureTables() {
7442
7653
  if (!this.enabled || this.tablesReady) return;
7443
- await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
7444
- await this.db.execute(sql`
7654
+ const ddl = drizzleDdlBootstrapper(this.db, "channel-history");
7655
+ await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
7656
+ await ddl.ensureObject("channel_messages table", `
7445
7657
  CREATE TABLE IF NOT EXISTS rebase.channel_messages (
7446
7658
  channel TEXT NOT NULL,
7447
7659
  seq BIGINT NOT NULL,
@@ -7452,18 +7664,23 @@ var ChannelHistoryStore = class {
7452
7664
  PRIMARY KEY (channel, seq)
7453
7665
  )
7454
7666
  `);
7455
- await this.db.execute(sql`
7667
+ await ddl.ensureObject("channel_messages created_at index", `
7456
7668
  CREATE INDEX IF NOT EXISTS idx_channel_messages_created
7457
7669
  ON rebase.channel_messages (created_at)
7458
7670
  `);
7459
- await this.db.execute(sql`
7671
+ await ddl.ensureObject("channel_cursors table", `
7460
7672
  CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
7461
7673
  channel TEXT PRIMARY KEY,
7462
7674
  last_seq BIGINT NOT NULL
7463
7675
  )
7464
7676
  `);
7465
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
7466
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
7677
+ const [messagesReady, cursorsReady] = await Promise.all([ddl.isReadable("rebase.channel_messages"), ddl.isReadable("rebase.channel_cursors")]);
7678
+ if (messagesReady) await ddl.step("channel_messages revoke", () => this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages"))));
7679
+ if (cursorsReady) await ddl.step("channel_cursors revoke", () => this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors"))));
7680
+ if (!messagesReady || !cursorsReady) {
7681
+ logger.warn("[ChannelHistory] Retained-channel tables are not both present; history is not ready yet.");
7682
+ return;
7683
+ }
7467
7684
  this.tablesReady = true;
7468
7685
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
7469
7686
  }
@@ -7629,11 +7846,27 @@ var ChannelPresenceStore = class {
7629
7846
  this.db = db;
7630
7847
  this.instanceId = instanceId;
7631
7848
  }
7632
- /** Create the roster table. Idempotent. */
7849
+ /**
7850
+ * Create the roster table. Idempotent, and safe to run on every instance at
7851
+ * once.
7852
+ *
7853
+ * Written as separate contained steps rather than one straight sequence for
7854
+ * a reason that only bites with more than one replica, which is exactly the
7855
+ * deployment shape this table exists to serve: `CREATE … IF NOT EXISTS`
7856
+ * reads the catalog and then writes to it non-atomically, so peers booting
7857
+ * together collide, and the loser used to abandon everything after it —
7858
+ * including the trailing `REVOKE`. That revoke is the only thing keeping the
7859
+ * roster off the end-user role, so losing a boot race silently left the
7860
+ * whole channel roster readable by every signed-in user.
7861
+ *
7862
+ * `tablesReady` is now set from a probe of what exists, not from having been
7863
+ * the instance that created it.
7864
+ */
7633
7865
  async ensureTables() {
7634
7866
  if (this.tablesReady) return;
7635
- await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
7636
- await this.db.execute(sql`
7867
+ const ddl = drizzleDdlBootstrapper(this.db, "channel-presence");
7868
+ await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
7869
+ await ddl.ensureObject("channel_presence table", `
7637
7870
  CREATE TABLE IF NOT EXISTS rebase.channel_presence (
7638
7871
  channel TEXT NOT NULL,
7639
7872
  client_id TEXT NOT NULL,
@@ -7643,12 +7876,14 @@ var ChannelPresenceStore = class {
7643
7876
  PRIMARY KEY (channel, client_id)
7644
7877
  )
7645
7878
  `);
7646
- await this.db.execute(sql`
7879
+ await ddl.ensureObject("channel_presence last_seen index", `
7647
7880
  CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
7648
7881
  ON rebase.channel_presence (last_seen)
7649
7882
  `);
7650
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
7651
- this.tablesReady = true;
7883
+ if (await ddl.isReadable("rebase.channel_presence")) {
7884
+ await ddl.step("channel_presence revoke", () => this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence"))));
7885
+ this.tablesReady = true;
7886
+ }
7652
7887
  }
7653
7888
  /** Record (or refresh) a client's presence. */
7654
7889
  async track(channel, clientId, state) {
@@ -8983,7 +9218,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8983
9218
  if (this.memoryBusWarned) return;
8984
9219
  if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
8985
9220
  this.memoryBusWarned = true;
8986
- logger.warn("⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another instance have been seen — this deployment runs more than one process. Broadcast and presence reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) to make channels cross-instance.");
9221
+ logger.warn("⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another instance have been seen — this deployment runs more than one process. Broadcast and presence reach only the clients connected to this one. Set `realtime.bus` (or REALTIME_CHANNEL_BUS=postgres) to make channels cross-instance.");
8987
9222
  }
8988
9223
  /** Leave a broadcast channel */
8989
9224
  leaveChannel(clientId, channel) {
@@ -12848,6 +13083,43 @@ function resolveDriftCheckName(col, registeredTableNames) {
12848
13083
  return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
12849
13084
  }
12850
13085
  /**
13086
+ * Why the tables this backend serves are not in the database — the part of the
13087
+ * drift warning that has to be true rather than merely plausible.
13088
+ *
13089
+ * The three answers need three different actions, and only the caller knows
13090
+ * which one applies. This warning used to assert the first ("this runtime
13091
+ * applies the collection schema at boot unless REBASE_MIGRATE_ON_BOOT=none")
13092
+ * and then point at that variable and at driver-version skew. For an app whose
13093
+ * boot path contained no provisioning step at all, every word of that was a
13094
+ * dead end: nothing read the variable, and the driver was current. The advice
13095
+ * cost an investigation, which is a strictly worse outcome than saying less.
13096
+ *
13097
+ * Exported for its own test: the surrounding check needs a live pool and a real
13098
+ * database, and this is the part that was wrong.
13099
+ */
13100
+ function describeSchemaDriftCause(provisioning) {
13101
+ if (provisioning === void 0) return [
13102
+ " This runtime could not determine whether a schema-creation step ran",
13103
+ " before this check (the caller predates that signal).",
13104
+ " • Look for a \"Collection schema:\" line above. No such line at all",
13105
+ " means nothing tried to create these tables in this process."
13106
+ ];
13107
+ if (provisioning.attempted) return [
13108
+ " A schema-creation step DID run this boot and these tables are still",
13109
+ " missing, so it did not create them — check the \"schema:\" lines above",
13110
+ " for what it did instead, and for DDL errors.",
13111
+ " • A collection routed to another engine or data source is not",
13112
+ " created here; that is reported separately at boot.",
13113
+ " • Otherwise this is a bug worth reporting, with those lines."
13114
+ ];
13115
+ return [
13116
+ " No schema-creation step ran this boot:",
13117
+ ` ${provisioning.reason ?? "no reason was given."}`,
13118
+ " Resolve that reason — the drift is its consequence, not a separate",
13119
+ " problem, and re-running a migration tool will not change it."
13120
+ ];
13121
+ }
13122
+ /**
12851
13123
  * Is this the local database `rebase init` scaffolds — i.e. the one case where
12852
13124
  * "you are connected as a superuser" is not news?
12853
13125
  *
@@ -12897,10 +13169,34 @@ function isScaffoldedLocalDatabase(connectionString) {
12897
13169
  */
12898
13170
  function createPostgresBootstrapper(pgConfig) {
12899
13171
  if (pgConfig.unknownFilterFields) configureUnknownFilterFields(pgConfig.unknownFilterFields);
13172
+ /**
13173
+ * The handle the schema/policy hooks issue their DDL through.
13174
+ *
13175
+ * Both hooks run BEFORE `initializeDriver`, so `driverResult` is a stand-in
13176
+ * the caller may not have: the bundle path can synthesize one from the
13177
+ * connection its coordinator opened, but an application that built this
13178
+ * adapter itself never handed the framework a connection — it handed it to
13179
+ * *us*, as `pgConfig.connection`. Falling back to that is what lets a
13180
+ * self-built adapter provision at all; requiring the argument is what left
13181
+ * those apps with no tables and a 500 on every data route.
13182
+ *
13183
+ * Either handle is equivalent here. The driver's `schemaAwareDb` differs
13184
+ * only by the drizzle schema object registered on it — relevant to the query
13185
+ * builder, not to `execute(sql.raw(...))` — and every statement these hooks
13186
+ * emit is schema-qualified DDL, so neither depends on `search_path`.
13187
+ */
13188
+ const provisioningQueryable = (driverResult) => {
13189
+ const db = (driverResult?.internals)?.db ?? pgConfig.connection;
13190
+ if (!db) throw new Error("Cannot provision the collection schema: this Postgres adapter was created without a `connection`, and no initialized driver was supplied to fall back on. Pass `connection` to `createPostgresAdapter` (see `createPostgresDatabaseConnection`).");
13191
+ return { async query(text) {
13192
+ const result = await db.execute(sql.raw(text));
13193
+ return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
13194
+ } };
13195
+ };
12900
13196
  return {
12901
13197
  type: "postgres",
12902
13198
  async initializeDriver(config) {
12903
- const { collections, collectionRegistry, introspectCollections, baas } = config;
13199
+ const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config;
12904
13200
  const unprotectedTables = baas?.unprotectedTables ?? "exclude";
12905
13201
  const connection = pgConfig.connection;
12906
13202
  const rawClient = connection && typeof connection === "object" && "$client" in connection ? connection.$client : connection;
@@ -13117,22 +13413,18 @@ function createPostgresBootstrapper(pgConfig) {
13117
13413
  " (`?options=-c%20search_path%3Dpublic`).",
13118
13414
  ""
13119
13415
  ];
13416
+ const cause = describeSchemaDriftCause(schemaProvisioning);
13120
13417
  logger.warn([
13121
13418
  "",
13122
13419
  "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
13123
13420
  ...lines,
13124
13421
  "",
13125
13422
  ...misplacedHelp,
13126
- " This runtime applies the collection schema at boot unless",
13127
- " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
13128
- " log lines above — this drift means that step was off, skipped, or failed.",
13129
- " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
13130
- " \"ensure\"; the runtime applies the schema to the tenant DB.",
13131
- " If the log above says the driver does not implement collection-table",
13132
- " creation, THIS driver is too old to do it. A driver is installed from",
13133
- " your bundle's dependencies, not supplied by the platform image, so a",
13134
- " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
13135
- " in your project's package.json and redeploy.",
13423
+ ...cause,
13424
+ "",
13425
+ " To apply this project's schema:",
13426
+ " • Managed cloud: the runtime creates tables and RLS at boot. `rebase db",
13427
+ " push` cannot reach a tenant's in-cluster database redeploy instead.",
13136
13428
  " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
13137
13429
  " against DATABASE_URL.",
13138
13430
  ""
@@ -13220,12 +13512,8 @@ function createPostgresBootstrapper(pgConfig) {
13220
13512
  * proved it can bootstrap with.
13221
13513
  */
13222
13514
  async ensureCollectionSchema(collections, driverResult, log) {
13223
- const internals = driverResult.internals;
13224
- const { ensureCollectionTables } = await import("./ensure-collection-tables-B1qKdXA4.js");
13225
- const plan = await ensureCollectionTables({ async query(text) {
13226
- const result = await internals.db.execute(sql.raw(text));
13227
- return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
13228
- } }, collections, log);
13515
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-DT2eq859.js");
13516
+ const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
13229
13517
  for (const failure of plan.failures) logger.warn(failure.kind === "comment-column" ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, but a later change to the \`search\` block will not be detected: ${failure.error}` : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
13230
13518
  return { applied: plan.actions.length - plan.failures.length };
13231
13519
  },
@@ -13245,12 +13533,9 @@ function createPostgresBootstrapper(pgConfig) {
13245
13533
  * stays RLS-enabled, so it denies rather than leaks.
13246
13534
  */
13247
13535
  async ensureCollectionPolicies(collections, driverResult, log) {
13248
- const internals = driverResult.internals;
13249
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-BedO2aNX.js");
13250
- const outcome = await ensureCollectionPolicies({ async query(text) {
13251
- const result = await internals.db.execute(sql.raw(text));
13252
- return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
13253
- } }, collections, log);
13536
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-DoHwhVf8.js");
13537
+ const queryable = provisioningQueryable(driverResult);
13538
+ const outcome = await ensureCollectionPolicies(queryable, collections, log);
13254
13539
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
13255
13540
  for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`);
13256
13541
  const unrevoked = outcome.unsecured.filter((u) => !u.grantWithdrawn);
@@ -13258,9 +13543,7 @@ function createPostgresBootstrapper(pgConfig) {
13258
13543
  if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
13259
13544
  try {
13260
13545
  const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-69hYT8nr.js").then((n) => n.n);
13261
- await dropLegacyAuthSchema(async (text) => {
13262
- return (await internals.db.execute(sql.raw(text))).rows ?? [];
13263
- }, {
13546
+ await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
13264
13547
  info: (m) => logger.info(m),
13265
13548
  warn: (m) => logger.warn(m)
13266
13549
  });
@@ -13314,6 +13597,6 @@ function createPostgresAdapter(pgConfig) {
13314
13597
  };
13315
13598
  }
13316
13599
  //#endregion
13317
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
13600
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
13318
13601
 
13319
13602
  //# sourceMappingURL=index.es.js.map