@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.
@@ -137,6 +137,52 @@ export function resolveDriftCheckName(
137
137
  ?? col.slug;
138
138
  }
139
139
 
140
+ /**
141
+ * Why the tables this backend serves are not in the database — the part of the
142
+ * drift warning that has to be true rather than merely plausible.
143
+ *
144
+ * The three answers need three different actions, and only the caller knows
145
+ * which one applies. This warning used to assert the first ("this runtime
146
+ * applies the collection schema at boot unless REBASE_MIGRATE_ON_BOOT=none")
147
+ * and then point at that variable and at driver-version skew. For an app whose
148
+ * boot path contained no provisioning step at all, every word of that was a
149
+ * dead end: nothing read the variable, and the driver was current. The advice
150
+ * cost an investigation, which is a strictly worse outcome than saying less.
151
+ *
152
+ * Exported for its own test: the surrounding check needs a live pool and a real
153
+ * database, and this is the part that was wrong.
154
+ */
155
+ export function describeSchemaDriftCause(
156
+ provisioning: { attempted: boolean; reason?: string } | undefined
157
+ ): string[] {
158
+ // A caller too old to send the signal gets no claim either way — just where
159
+ // to look. Guessing is what got this wrong the first time.
160
+ if (provisioning === undefined) {
161
+ return [
162
+ " This runtime could not determine whether a schema-creation step ran",
163
+ " before this check (the caller predates that signal).",
164
+ " • Look for a \"Collection schema:\" line above. No such line at all",
165
+ " means nothing tried to create these tables in this process."
166
+ ];
167
+ }
168
+ if (provisioning.attempted) {
169
+ return [
170
+ " A schema-creation step DID run this boot and these tables are still",
171
+ " missing, so it did not create them — check the \"schema:\" lines above",
172
+ " for what it did instead, and for DDL errors.",
173
+ " • A collection routed to another engine or data source is not",
174
+ " created here; that is reported separately at boot.",
175
+ " • Otherwise this is a bug worth reporting, with those lines."
176
+ ];
177
+ }
178
+ return [
179
+ " No schema-creation step ran this boot:",
180
+ ` ${provisioning.reason ?? "no reason was given."}`,
181
+ " Resolve that reason — the drift is its consequence, not a separate",
182
+ " problem, and re-running a migration tool will not change it."
183
+ ];
184
+ }
185
+
140
186
  /**
141
187
  * Is this the local database `rebase init` scaffolds — i.e. the one case where
142
188
  * "you are connected as a superuser" is not news?
@@ -200,17 +246,53 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
200
246
  configureUnknownFilterFields(pgConfig.unknownFilterFields);
201
247
  }
202
248
 
249
+ /**
250
+ * The handle the schema/policy hooks issue their DDL through.
251
+ *
252
+ * Both hooks run BEFORE `initializeDriver`, so `driverResult` is a stand-in
253
+ * the caller may not have: the bundle path can synthesize one from the
254
+ * connection its coordinator opened, but an application that built this
255
+ * adapter itself never handed the framework a connection — it handed it to
256
+ * *us*, as `pgConfig.connection`. Falling back to that is what lets a
257
+ * self-built adapter provision at all; requiring the argument is what left
258
+ * those apps with no tables and a 500 on every data route.
259
+ *
260
+ * Either handle is equivalent here. The driver's `schemaAwareDb` differs
261
+ * only by the drizzle schema object registered on it — relevant to the query
262
+ * builder, not to `execute(sql.raw(...))` — and every statement these hooks
263
+ * emit is schema-qualified DDL, so neither depends on `search_path`.
264
+ */
265
+ const provisioningQueryable = (driverResult?: InitializedDriver) => {
266
+ const internals = driverResult?.internals as PostgresDriverInternals | undefined;
267
+ const db = internals?.db ?? (pgConfig.connection as PostgresDriverInternals["db"] | undefined);
268
+ if (!db) {
269
+ throw new Error(
270
+ "Cannot provision the collection schema: this Postgres adapter was created without a " +
271
+ "`connection`, and no initialized driver was supplied to fall back on. Pass `connection` " +
272
+ "to `createPostgresAdapter` (see `createPostgresDatabaseConnection`)."
273
+ );
274
+ }
275
+ return {
276
+ async query<T>(text: string): Promise<{ rows: T[] }> {
277
+ const result = await db.execute(sql.raw(text));
278
+ const rows = (result as unknown as { rows?: T[] }).rows;
279
+ return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
280
+ }
281
+ };
282
+ };
283
+
203
284
  return {
204
285
  type: "postgres",
205
286
 
206
287
  async initializeDriver(config: unknown): Promise<InitializedDriver> {
207
288
  // config is passed from coordinator, we merge it with our internal pgConfig if needed
208
289
  // Currently config from init.ts is `{ collections, collectionRegistry, mode }`
209
- const { collections, collectionRegistry, introspectCollections, baas } = config as {
290
+ const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config as {
210
291
  collections?: CollectionConfig[];
211
292
  collectionRegistry?: unknown;
212
293
  introspectCollections?: boolean;
213
294
  baas?: { unprotectedTables?: "exclude" | "serve" };
295
+ schemaProvisioning?: { attempted: boolean; reason?: string };
214
296
  };
215
297
  // Secure by default: a table with no RLS is not served.
216
298
  const unprotectedTables = baas?.unprotectedTables ?? "exclude";
@@ -695,30 +777,29 @@ foundIn: (tablesByName.get(checkName) ?? []).filter(s => s !== schemaName) });
695
777
  " (`?options=-c%20search_path%3Dpublic`).",
696
778
  ""
697
779
  ];
698
- // This runtime creates collection tables (and their RLS)
699
- // at boot unless REBASE_MIGRATE_ON_BOOT=none, so a drift
700
- // this late means that step was disabled, could not run,
701
- // or failed the guidance names a path for both a managed
702
- // tenant (whose in-cluster database `pnpm db:push` cannot
703
- // reach) and a self-host. Naming only the pnpm scripts, as
704
- // this once did, told a managed operator to run a command
705
- // that structurally cannot touch their database.
780
+ // What to tell the operator depends entirely on whether a
781
+ // create step ran in this process, and the caller is the
782
+ // only thing that knows. This warning used to assert that
783
+ // it had ("this runtime applies the collection schema at
784
+ // boot unless REBASE_MIGRATE_ON_BOOT=none") and send
785
+ // people to that variable and to driver-version skew. For
786
+ // an app whose boot path contained no provisioning step,
787
+ // both were dead ends: nothing read that variable, and the
788
+ // driver was current. Say which case this is instead of
789
+ // guessing, and say nothing when the caller is too old to
790
+ // tell us.
791
+ const cause = describeSchemaDriftCause(schemaProvisioning);
706
792
  logger.warn([
707
793
  "",
708
794
  "⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
709
795
  ...lines,
710
796
  "",
711
797
  ...misplacedHelp,
712
- " This runtime applies the collection schema at boot unless",
713
- " REBASE_MIGRATE_ON_BOOT=none. Check the \"Collection schema\" / \"policies\"",
714
- " log lines above — this drift means that step was off, skipped, or failed.",
715
- " • Managed cloud: redeploy with REBASE_MIGRATE_ON_BOOT unset or",
716
- " \"ensure\"; the runtime applies the schema to the tenant DB.",
717
- " If the log above says the driver does not implement collection-table",
718
- " creation, THIS driver is too old to do it. A driver is installed from",
719
- " your bundle's dependencies, not supplied by the platform image, so a",
720
- " newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
721
- " in your project's package.json and redeploy.",
798
+ ...cause,
799
+ "",
800
+ " To apply this project's schema:",
801
+ " • Managed cloud: the runtime creates tables and RLS at boot. `rebase db",
802
+ " push` cannot reach a tenant's in-cluster database redeploy instead.",
722
803
  " • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
723
804
  " against DATABASE_URL.",
724
805
  ""
@@ -862,23 +943,16 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
862
943
  */
863
944
  async ensureCollectionSchema(
864
945
  collections: unknown[],
865
- driverResult: InitializedDriver,
946
+ driverResult?: InitializedDriver,
866
947
  log?: (message: string) => void
867
948
  ): Promise<{ applied: number }> {
868
- const internals = driverResult.internals as PostgresDriverInternals;
869
949
  const { ensureCollectionTables } = await import("./schema/ensure-collection-tables");
870
950
  // Runs through the drizzle handle the driver already bootstrapped
871
951
  // with, so it uses exactly the connection and privileges that were
872
952
  // proven to work. Every statement is DDL or a catalogue read with no
873
953
  // bindable values (schema names are identifiers), and the module
874
954
  // validates them before they reach a string.
875
- const queryable = {
876
- async query<T>(text: string): Promise<{ rows: T[] }> {
877
- const result = await internals.db.execute(sql.raw(text));
878
- const rows = (result as unknown as { rows?: T[] }).rows;
879
- return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
880
- }
881
- };
955
+ const queryable = provisioningQueryable(driverResult);
882
956
  const plan = await ensureCollectionTables(
883
957
  queryable,
884
958
  collections as Parameters<typeof ensureCollectionTables>[1],
@@ -917,18 +991,11 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
917
991
  */
918
992
  async ensureCollectionPolicies(
919
993
  collections: unknown[],
920
- driverResult: InitializedDriver,
994
+ driverResult?: InitializedDriver,
921
995
  log?: (message: string) => void
922
996
  ): Promise<{ applied: number }> {
923
- const internals = driverResult.internals as PostgresDriverInternals;
924
997
  const { ensureCollectionPolicies } = await import("./schema/ensure-collection-policies");
925
- const queryable = {
926
- async query<T>(text: string): Promise<{ rows: T[] }> {
927
- const result = await internals.db.execute(sql.raw(text));
928
- const rows = (result as unknown as { rows?: T[] }).rows;
929
- return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
930
- }
931
- };
998
+ const queryable = provisioningQueryable(driverResult);
932
999
  const outcome = await ensureCollectionPolicies(
933
1000
  queryable,
934
1001
  collections as CollectionConfig[],
@@ -982,10 +1049,7 @@ schemaHealthCheck: () => probeAuthSchema(db, resolveAuthSchema(authCollection))
982
1049
  try {
983
1050
  const { dropLegacyAuthSchema } = await import("./schema/rls-bootstrap-sql");
984
1051
  await dropLegacyAuthSchema(
985
- async (text) => {
986
- const res = await internals.db.execute(sql.raw(text));
987
- return (res.rows ?? []) as Record<string, unknown>[];
988
- },
1052
+ async (text) => (await queryable.query<Record<string, unknown>>(text)).rows,
989
1053
  { info: (m) => logger.info(m), warn: (m) => logger.warn(m) }
990
1054
  );
991
1055
  } catch (err) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The server's DDL bootstrapper, over a Drizzle handle.
3
+ *
4
+ * `createDdlBootstrapper` in `@rebasepro/server` wants a plain
5
+ * `(sql: string) => Promise<rows>`; the driver's internal stores hold a Drizzle
6
+ * database. This is the adapter between them, and it exists so the retry policy
7
+ * has exactly one definition. A second copy of the SQLSTATE list living in the
8
+ * driver is how the two drift apart, and the drift is invisible: both versions
9
+ * work perfectly on every single-instance deployment.
10
+ */
11
+ import { sql } from "drizzle-orm";
12
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
13
+ import { createDdlBootstrapper, type DdlBootstrapper } from "@rebasepro/server";
14
+
15
+ /**
16
+ * A {@link DdlBootstrapper} that runs its statements through `db.execute`.
17
+ *
18
+ * @param db the Drizzle handle the calling store already holds
19
+ * @param scope log prefix identifying the caller, e.g. `"channel-presence"`
20
+ */
21
+ export function drizzleDdlBootstrapper(
22
+ db: NodePgDatabase<Record<string, unknown>>,
23
+ scope: string
24
+ ): DdlBootstrapper {
25
+ return createDdlBootstrapper(async (statement: string) => {
26
+ // `sql.raw`, because everything reaching this path is DDL assembled from
27
+ // identifiers that were validated before they got here — there is no
28
+ // parameter to bind, and Drizzle's tagged template would treat the whole
29
+ // statement as one.
30
+ const result = await db.execute(sql.raw(statement));
31
+ return (result as unknown as { rows?: Record<string, unknown>[] }).rows ?? [];
32
+ }, scope);
33
+ }
@@ -250,3 +250,102 @@ describe("applying the plan", () => {
250
250
  expect(plan.actions).toEqual([]);
251
251
  });
252
252
  });
253
+
254
+ /**
255
+ * Two instances booting into the same fresh database.
256
+ *
257
+ * These inject the error at the statement level rather than driving two real
258
+ * boots, and that is deliberate: the end state after a real race is usually
259
+ * correct anyway — one instance always finishes — so a test that only asserts
260
+ * the end state passes against the broken code as well as the fixed code. The
261
+ * defect was never "the table is missing"; it was that the throw abandoned
262
+ * every remaining action, so the loser skipped work it had not attempted yet.
263
+ */
264
+ describe("a simultaneous boot", () => {
265
+ /** A driver error shaped the way node-postgres reports one, through drizzle. */
266
+ function pgError(code: string, extra: Record<string, unknown> = {}): Error {
267
+ const inner = Object.assign(new Error(`pg error ${code}`), { code, ...extra });
268
+ // Drizzle wraps the driver error; nothing useful is ever on the top level.
269
+ return Object.assign(new Error("Failed query"), { cause: inner });
270
+ }
271
+
272
+ /** Records every statement, and fails the first match of `failOn` once. */
273
+ function racingClient(
274
+ failOn: RegExp,
275
+ error: Error,
276
+ options: { forever?: boolean } = {}
277
+ ): { client: Queryable; executed: string[] } {
278
+ const executed: string[] = [];
279
+ let thrown = false;
280
+ return {
281
+ executed,
282
+ client: {
283
+ async query<T>(sql: string): Promise<{ rows: T[] }> {
284
+ executed.push(sql);
285
+ if (failOn.test(sql) && (options.forever || !thrown)) {
286
+ thrown = true;
287
+ throw error;
288
+ }
289
+ return { rows: [] as T[] };
290
+ }
291
+ }
292
+ };
293
+ }
294
+
295
+ it("carries on to the remaining actions when it loses a CREATE TABLE race", async () => {
296
+ // 42P07 duplicate_table: a peer created it between our catalog read and
297
+ // our write. The table exists; everything after it still has to run.
298
+ const { client, executed } = racingClient(/^CREATE TABLE/, pgError("42P07"), { forever: true });
299
+
300
+ const plan = await ensureCollectionTables(client, [posts]);
301
+
302
+ expect(plan.actions.length).toBeGreaterThan(0);
303
+ // The proof: statements that come *after* the losing one were attempted.
304
+ expect(executed.some(s => s.startsWith("ALTER TABLE"))).toBe(true);
305
+ });
306
+
307
+ it("treats a catalog unique violation as the object already existing", async () => {
308
+ // The one measured in practice: `CREATE TYPE` has no IF NOT EXISTS, so
309
+ // the loser gets 23505 on pg_type's own index.
310
+ const { client, executed } = racingClient(
311
+ /^CREATE TYPE/,
312
+ pgError("23505", { constraint: "pg_type_typname_nsp_index" }),
313
+ { forever: true }
314
+ );
315
+
316
+ await ensureCollectionTables(client, [posts]);
317
+
318
+ expect(executed.some(s => s.startsWith("CREATE TABLE"))).toBe(true);
319
+ });
320
+
321
+ it("retries a deadlock and then succeeds", async () => {
322
+ // 40P01: two boots taking catalog locks in step. Unlike a duplicate, the
323
+ // statement did nothing at all, so it must actually be run again.
324
+ const { client, executed } = racingClient(/^CREATE TABLE/, pgError("40P01"));
325
+
326
+ await ensureCollectionTables(client, [posts]);
327
+
328
+ expect(executed.filter(s => s.startsWith("CREATE TABLE")).length).toBeGreaterThan(1);
329
+ });
330
+
331
+ it("still fails loudly on a unique violation from the customer's own data", async () => {
332
+ // A named constraint, not a `pg_` catalog index — this is a real problem
333
+ // with real rows and must not be swallowed as "someone beat me to it".
334
+ // It is still retried first, because 23505 is in the retryable set and
335
+ // this shape cannot be told from a race until the constraint name is
336
+ // read; what matters is that it ends in a throw rather than a shrug.
337
+ const { client } = racingClient(
338
+ /^CREATE TABLE/,
339
+ pgError("23505", { constraint: "posts_slug_key" }),
340
+ { forever: true }
341
+ );
342
+
343
+ await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
344
+ });
345
+
346
+ it("still fails loudly on a permission error", async () => {
347
+ const { client } = racingClient(/^CREATE TABLE/, pgError("42501"), { forever: true });
348
+
349
+ await expect(ensureCollectionTables(client, [posts])).rejects.toThrow(/public\.posts/);
350
+ });
351
+ });
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
29
29
  import { getTableName, relationalCollections } from "@rebasepro/common";
30
- import { logger } from "@rebasepro/server";
30
+ import { logger, isConcurrentDdlRace, isDuplicateObjectRace } from "@rebasepro/server";
31
31
  import {
32
32
  assertSearchIsPostgresOnly,
33
33
  buildSearchColumnSpec,
@@ -800,8 +800,11 @@ export async function ensureCollectionTables(
800
800
 
801
801
  for (const action of plan.actions) {
802
802
  try {
803
- await client.query(action.sql);
804
- log?.(`${action.kind}: ${action.target}`);
803
+ if (await applyAction(client, action)) {
804
+ log?.(`${action.kind}: ${action.target}`);
805
+ } else {
806
+ log?.(`${action.kind}: ${action.target} (already created by a peer)`);
807
+ }
805
808
  } catch (err) {
806
809
  const message = err instanceof Error ? err.message : String(err);
807
810
  // A foreign key is the only action that can fail on the customer's
@@ -825,3 +828,62 @@ export async function ensureCollectionTables(
825
828
  }
826
829
  return { ...plan, failures };
827
830
  }
831
+
832
+ /** Attempts per action, including the first. Matches the server's bootstraps. */
833
+ const DDL_ATTEMPTS = 4;
834
+
835
+ /**
836
+ * Run one planned statement, surviving a simultaneous boot.
837
+ *
838
+ * Every statement in a plan is written to be idempotent, and that is not the
839
+ * same as being safe to run concurrently: `CREATE … IF NOT EXISTS` reads the
840
+ * catalog and then writes to it as two steps, so peers starting together both
841
+ * see "absent" and the loser gets a duplicate key on a *catalog* index. Measured
842
+ * against Postgres 18: five instances, 8 of 10 calls lost. `CREATE TYPE` is
843
+ * worse, because Postgres has no `IF NOT EXISTS` for it at all.
844
+ *
845
+ * What made that fatal here rather than merely noisy is the loop this sits in.
846
+ * A losing statement threw, and the throw abandoned **every remaining action in
847
+ * the plan** — so a replica that lost one race came up missing tables it never
848
+ * attempted, and the boot log blamed the one statement that failed.
849
+ *
850
+ * @returns `true` if this process applied the statement, `false` if a peer had
851
+ * already created the object. The distinction is only for the log; both mean
852
+ * the object is now there.
853
+ * @throws the original error for anything that is not a race — a syntax error, a
854
+ * permission failure, a unique constraint the customer's own rows violate.
855
+ */
856
+ async function applyAction(
857
+ client: Queryable,
858
+ action: EnsureAction
859
+ ): Promise<boolean> {
860
+ for (let attempt = 1; ; attempt++) {
861
+ try {
862
+ await client.query(action.sql);
863
+ return true;
864
+ } catch (err) {
865
+ // Already there. Not "retry" — the end state this statement wanted
866
+ // is the end state the database is in, so carry on to the next
867
+ // action rather than spending three more attempts proving it.
868
+ if (isDuplicateObjectRace(err)) {
869
+ logger.debug(
870
+ `[schema] ${action.kind} ${action.target}: already created by another instance`
871
+ );
872
+ return false;
873
+ }
874
+ // Retryable but not yet satisfied — a deadlock between two boots
875
+ // taking catalog locks in step. The statement did nothing; run it
876
+ // again after a jittered pause so peers that collided once do not
877
+ // collide again in lockstep.
878
+ if (isConcurrentDdlRace(err) && attempt < DDL_ATTEMPTS) {
879
+ logger.debug(
880
+ `[schema] ${action.kind} ${action.target}: lost a race with another instance ` +
881
+ `(attempt ${attempt}/${DDL_ATTEMPTS}) — retrying`
882
+ );
883
+ await new Promise(resolve => setTimeout(resolve, 40 * attempt * (1 + Math.random())));
884
+ continue;
885
+ }
886
+ throw err;
887
+ }
888
+ }
889
+ }
@@ -1,4 +1,4 @@
1
- import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, isNotNull, isNull, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, isNotNull, isNull, lt, or, sql, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
@@ -1326,6 +1326,131 @@ relatedTo: hop });
1326
1326
  return Number(result[0]?.count || 0);
1327
1327
  }
1328
1328
 
1329
+ /**
1330
+ * `count`/`sum`/`avg`/`min`/`max`, optionally grouped.
1331
+ *
1332
+ * The gap this fills is narrow and constant: every dashboard wants "revenue
1333
+ * by status" and "orders per day", and without it the options were a custom
1334
+ * function holding hand-written SQL, or fetching every row and reducing in
1335
+ * JavaScript — which is wrong at any size that matters, and silently wrong
1336
+ * under a `limit`.
1337
+ *
1338
+ * It runs through the same request-scoped handle as every other read, so
1339
+ * **RLS applies to the rows being aggregated**. That is the property worth
1340
+ * protecting here: an aggregate is an effective way to read data you cannot
1341
+ * select, and `count(*)` over a table whose policies would return nothing
1342
+ * has to be zero.
1343
+ */
1344
+ async aggregate<M extends Record<string, unknown>>(
1345
+ collectionPath: string,
1346
+ options: {
1347
+ aggregates: { fn: "count" | "sum" | "avg" | "min" | "max"; field?: string; alias: string }[];
1348
+ groupBy?: string[];
1349
+ filter?: FilterValues<Extract<keyof M, string>>;
1350
+ logical?: LogicalCondition;
1351
+ searchString?: string;
1352
+ limit?: number;
1353
+ }
1354
+ ): Promise<Record<string, unknown>[]> {
1355
+ const collection = getCollectionByPath(collectionPath, this.registry);
1356
+ const table = getTableForCollection(collection, this.registry);
1357
+ const columns = getTableColumns(table);
1358
+
1359
+ const columnFor = (field: string, forWhat: string): AnyPgColumn => {
1360
+ const column = columns[field as keyof typeof columns] as AnyPgColumn | undefined;
1361
+ if (!column) {
1362
+ throw ApiError.badRequest(
1363
+ `Unknown field '${field}' in ${forWhat}. Valid fields: ${Object.keys(columns).sort().join(", ")}`,
1364
+ "UNKNOWN_AGGREGATE_FIELD"
1365
+ );
1366
+ }
1367
+ return column;
1368
+ };
1369
+
1370
+ const selection: Record<string, SQL> = {};
1371
+
1372
+ for (const aggregate of options.aggregates) {
1373
+ if (aggregate.fn === "count" && !aggregate.field) {
1374
+ selection[aggregate.alias] = sql`count(*)`;
1375
+ continue;
1376
+ }
1377
+ const column = columnFor(aggregate.field as string, `${aggregate.fn}()`);
1378
+ switch (aggregate.fn) {
1379
+ case "count": selection[aggregate.alias] = sql`count(${column})`; break;
1380
+ // Cast through numeric so what comes back is a string this
1381
+ // method parses, rather than a float whose precision depends on
1382
+ // the column type — `avg` over an integer column is otherwise
1383
+ // one shape here and another there.
1384
+ case "sum": selection[aggregate.alias] = sql`sum(${column})::numeric`; break;
1385
+ case "avg": selection[aggregate.alias] = sql`avg(${column})::numeric`; break;
1386
+ case "min": selection[aggregate.alias] = sql`min(${column})`; break;
1387
+ case "max": selection[aggregate.alias] = sql`max(${column})`; break;
1388
+ }
1389
+ }
1390
+
1391
+ const groupColumns = (options.groupBy ?? []).map(field => ({
1392
+ field,
1393
+ column: columnFor(field, "groupBy")
1394
+ }));
1395
+ for (const group of groupColumns) {
1396
+ selection[group.field] = sql`${group.column}`;
1397
+ }
1398
+
1399
+ let query = this.db.select(selection).from(table).$dynamic();
1400
+
1401
+ const conditions: SQL[] = [];
1402
+ if (options.searchString) {
1403
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1404
+ options.searchString, collection.properties, table, collection
1405
+ );
1406
+ // No searchable field means no row matches — the same impossible
1407
+ // WHERE the listing uses, rather than an unfiltered aggregate.
1408
+ if (searchConditions.length === 0) return [];
1409
+ conditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions) as SQL);
1410
+ }
1411
+ if (options.filter) {
1412
+ conditions.push(...this.buildFilterConditions(options.filter, table, collectionPath));
1413
+ }
1414
+ if (options.logical) {
1415
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
1416
+ options.logical, table, collectionPath, this.filterContext(collectionPath, table)
1417
+ );
1418
+ if (logicalCondition) conditions.push(logicalCondition);
1419
+ }
1420
+ if (conditions.length > 0) {
1421
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
1422
+ if (finalCondition) query = query.where(finalCondition);
1423
+ }
1424
+
1425
+ if (groupColumns.length > 0) {
1426
+ query = query.groupBy(...groupColumns.map(g => g.column));
1427
+ // Bounded for the same reason a listing is: grouping by a
1428
+ // high-cardinality column is a whole table's worth of rows in one
1429
+ // response.
1430
+ if (options.limit) query = query.limit(options.limit);
1431
+ }
1432
+
1433
+ const rows = await query as Record<string, unknown>[];
1434
+
1435
+ // `count`, `sum` and `avg` arrive as strings: Postgres returns bigint
1436
+ // and numeric that way because they do not fit a JS number in general.
1437
+ // They do fit for every aggregate anyone puts on a dashboard, and a
1438
+ // caller handed `"12"` where they expected `12` has to find that out
1439
+ // for themselves. Parsed once, here.
1440
+ const numericAliases = new Set(
1441
+ options.aggregates.filter(a => a.fn === "count" || a.fn === "sum" || a.fn === "avg").map(a => a.alias)
1442
+ );
1443
+ return rows.map(row => {
1444
+ const out: Record<string, unknown> = { ...row };
1445
+ for (const alias of numericAliases) {
1446
+ if (out[alias] === null || out[alias] === undefined) continue;
1447
+ const parsed = Number(out[alias]);
1448
+ if (!Number.isNaN(parsed)) out[alias] = parsed;
1449
+ }
1450
+ return out;
1451
+ });
1452
+ }
1453
+
1329
1454
  /**
1330
1455
  * Check if a field value is unique
1331
1456
  */
@@ -35,6 +35,7 @@ import { NodePgDatabase } from "drizzle-orm/node-postgres";
35
35
  import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
36
36
  import { logger } from "@rebasepro/server";
37
37
  import { revokeInternalTableSql } from "@rebasepro/common";
38
+ import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
38
39
 
39
40
  /** How many messages a replay returns when the caller does not say. */
40
41
  const DEFAULT_REPLAY_LIMIT = 200;
@@ -167,12 +168,19 @@ export class ChannelHistoryStore {
167
168
  async ensureTables(): Promise<void> {
168
169
  if (!this.enabled || this.tablesReady) return;
169
170
 
170
- await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
171
+ // Contained, retrying steps rather than one straight sequence — see the
172
+ // note on `ChannelPresenceStore.ensureTables`. The failure mode here is
173
+ // the same and the stakes are the same: the two `REVOKE`s at the end are
174
+ // what keep retained broadcasts off the end-user role, and a lost create
175
+ // race used to skip them.
176
+ const ddl = drizzleDdlBootstrapper(this.db, "channel-history");
177
+
178
+ await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
171
179
 
172
180
  // The primary key is exactly the replay query's access path
173
181
  // (`channel = $1 AND seq > $2 ORDER BY seq`), so it needs no further
174
182
  // index of its own.
175
- await this.db.execute(sql`
183
+ await ddl.ensureObject("channel_messages table", `
176
184
  CREATE TABLE IF NOT EXISTS rebase.channel_messages (
177
185
  channel TEXT NOT NULL,
178
186
  seq BIGINT NOT NULL,
@@ -185,14 +193,14 @@ export class ChannelHistoryStore {
185
193
  `);
186
194
 
187
195
  // Only for the TTL arm of pruning; the limit arm rides the primary key.
188
- await this.db.execute(sql`
196
+ await ddl.ensureObject("channel_messages created_at index", `
189
197
  CREATE INDEX IF NOT EXISTS idx_channel_messages_created
190
198
  ON rebase.channel_messages (created_at)
191
199
  `);
192
200
 
193
201
  // Never pruned — see the note at the top of this file. One row per
194
202
  // channel that has ever retained a message.
195
- await this.db.execute(sql`
203
+ await ddl.ensureObject("channel_cursors table", `
196
204
  CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
197
205
  channel TEXT PRIMARY KEY,
198
206
  last_seq BIGINT NOT NULL
@@ -209,8 +217,32 @@ export class ChannelHistoryStore {
209
217
  // `docs/channel-authorization.md`. The driver's schema-wide
210
218
  // grant reaches these (created here, after it ran), so take the
211
219
  // privilege back.
212
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
213
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
220
+ //
221
+ // Driven off a probe of what exists rather than off who won each create.
222
+ const [messagesReady, cursorsReady] = await Promise.all([
223
+ ddl.isReadable("rebase.channel_messages"),
224
+ ddl.isReadable("rebase.channel_cursors")
225
+ ]);
226
+ if (messagesReady) {
227
+ await ddl.step("channel_messages revoke", () =>
228
+ this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")))
229
+ );
230
+ }
231
+ if (cursorsReady) {
232
+ await ddl.step("channel_cursors revoke", () =>
233
+ this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")))
234
+ );
235
+ }
236
+
237
+ if (!messagesReady || !cursorsReady) {
238
+ // Left un-ready on purpose so the next call retries. Announcing
239
+ // "ready" here is what would turn a half-created schema into replays
240
+ // that answer empty forever.
241
+ logger.warn(
242
+ "[ChannelHistory] Retained-channel tables are not both present; history is not ready yet."
243
+ );
244
+ return;
245
+ }
214
246
 
215
247
  this.tablesReady = true;
216
248
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);