@rebasepro/server-postgres 0.14.0 → 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.
Files changed (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +19 -0
  3. package/dist/auth/services.d.ts +10 -0
  4. package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
  5. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  6. package/dist/data_driver-ULAyJEi9.js.map +1 -1
  7. package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-DoHwhVf8.js} +3 -3
  8. package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
  10. package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
  11. package/dist/index.es.js +543 -105
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
  14. package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
  15. package/dist/schema/drizzle-ddl.d.ts +9 -0
  16. package/dist/services/FetchService.d.ts +81 -5
  17. package/dist/services/RelationService.d.ts +3 -3
  18. package/dist/services/channel-presence.d.ts +16 -1
  19. package/dist/services/dataService.d.ts +6 -4
  20. package/dist/services/realtimeService.d.ts +54 -10
  21. package/dist/src-DCdn3Val.js.map +1 -1
  22. package/dist/utils/drizzle-conditions.d.ts +25 -0
  23. package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
  24. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  25. package/package.json +6 -6
  26. package/src/PostgresBackendDriver.ts +7 -3
  27. package/src/PostgresBootstrapper.ts +105 -41
  28. package/src/auth/services.ts +26 -5
  29. package/src/schema/drizzle-ddl.ts +33 -0
  30. package/src/schema/ensure-collection-tables.test.ts +99 -0
  31. package/src/schema/ensure-collection-tables.ts +65 -3
  32. package/src/schema/generate-drizzle-schema-logic.ts +19 -1
  33. package/src/services/FetchService.ts +310 -63
  34. package/src/services/RelationService.ts +3 -3
  35. package/src/services/channel-history.ts +38 -6
  36. package/src/services/channel-presence.ts +31 -7
  37. package/src/services/dataService.ts +6 -4
  38. package/src/services/pg-notify-listener.ts +14 -0
  39. package/src/services/realtimeService.ts +161 -41
  40. package/src/utils/drizzle-conditions.ts +155 -5
  41. package/src/websocket.ts +44 -1
  42. package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
  43. package/dist/websocket-C8ZqVBiV.js.map +0 -1
@@ -1,8 +1,8 @@
1
- import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, 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
- import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
3
+ import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
- import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
5
+ import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData, normalizeDriverOrderBy } from "@rebasepro/common";
6
6
  import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
7
7
  import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
8
  import {
@@ -30,6 +30,9 @@ import { reachedDatabase } from "../utils/pg-error-utils";
30
30
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
31
31
  type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
32
32
 
33
+ /** One sort key, with the column or expression it was resolved to. */
34
+ type ResolvedOrderKey = { field: string; direction: "asc" | "desc"; target: AnyPgColumn | SQL };
35
+
33
36
  /**
34
37
  * Service for handling all row read operations.
35
38
  * Handles fetching, searching, counting, and filtering rows.
@@ -145,6 +148,48 @@ export class FetchService {
145
148
  return this.resolveOrderByField(table, orderBy, collection);
146
149
  }
147
150
 
151
+ /**
152
+ * Resolve every sort key to the expression it orders by, in order of
153
+ * significance.
154
+ *
155
+ * A key that resolves to nothing is dropped rather than skipping the rest:
156
+ * `resolveOrderByField` only *returns* undefined under the lenient
157
+ * unknown-field mode, where dropping is the configured answer, and dropping
158
+ * one key of several still honours the ones that did resolve.
159
+ */
160
+ private resolveOrderKeys(
161
+ table: PgTable<any>,
162
+ keys: OrderByTuple[],
163
+ collection?: CollectionConfig,
164
+ searchString?: string
165
+ ): ResolvedOrderKey[] {
166
+ const resolved: ResolvedOrderKey[] = [];
167
+ for (const [field, direction] of keys) {
168
+ const target = this.resolveOrderTarget(table, field, collection, searchString);
169
+ if (target) resolved.push({ field,
170
+ direction,
171
+ target });
172
+ }
173
+ return resolved;
174
+ }
175
+
176
+ /**
177
+ * The full `ORDER BY`: the caller's keys, then the id.
178
+ *
179
+ * The id is always last and always descending. It is not decoration — it is
180
+ * what makes the ordering *total*, and a cursor over a non-total order
181
+ * repeats and skips rows among the ties. Every keyset comparison built by
182
+ * {@link buildCursorConditions} ends on the same `id DESC`, and the two have
183
+ * to agree: they did not, and an ascending sort paged with `id >` against an
184
+ * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
185
+ * every page after the first.
186
+ */
187
+ private buildOrderExpressions(keys: ResolvedOrderKey[], idField: AnyPgColumn): SQL[] {
188
+ const expressions = keys.map(({ direction, target }) => direction === "asc" ? asc(target) : desc(target));
189
+ expressions.push(desc(idField));
190
+ return expressions as SQL[];
191
+ }
192
+
148
193
  private resolveOrderByField(
149
194
  table: PgTable<any>,
150
195
  orderBy: string,
@@ -442,7 +487,7 @@ export class FetchService {
442
487
  idInfo: { fieldName: string; type: "string" | "number" },
443
488
  options: {
444
489
  filter?: FilterValues<Extract<keyof M, string>>;
445
- orderBy?: string;
490
+ orderBy?: string | OrderByTuple[];
446
491
  order?: "desc" | "asc";
447
492
  limit?: number;
448
493
  offset?: number;
@@ -506,18 +551,11 @@ export class FetchService {
506
551
  }
507
552
 
508
553
  // OrderBy
509
- const orderExpressions: unknown[] = [];
510
- if (options.orderBy) {
511
- const collection = getCollectionByPath(collectionPath, this.registry);
512
- const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
513
- if (orderByField) {
514
- orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
515
- }
516
- }
517
- orderExpressions.push(desc(idField));
518
- if (orderExpressions.length > 0) {
519
- queryOpts.orderBy = orderExpressions;
520
- }
554
+ const orderKeys = normalizeDriverOrderBy(options.orderBy, options.order);
555
+ const resolvedOrder = orderKeys
556
+ ? this.resolveOrderKeys(table, orderKeys, getCollectionByPath(collectionPath, this.registry), options.searchString)
557
+ : [];
558
+ queryOpts.orderBy = this.buildOrderExpressions(resolvedOrder, idField);
521
559
 
522
560
  // Limit
523
561
  const limitValue = options.searchString ? (options.limit || 50) : options.limit;
@@ -531,25 +569,31 @@ export class FetchService {
531
569
 
532
570
  /**
533
571
  * Extract cursor pagination conditions from startAfter options.
572
+ *
573
+ * "Every row that sorts after this one", written out as a comparison over
574
+ * the same keys the `ORDER BY` uses and ending on the same `id DESC`. With
575
+ * one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
576
+ * several it nests, each key's tie handing the decision to the next.
534
577
  */
535
578
  private buildCursorConditions(
536
579
  table: PgTable<any>,
537
580
  idField: AnyPgColumn,
538
581
  idInfo: { fieldName: string; type: "string" | "number" },
539
- options: { orderBy?: string; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
582
+ options: { orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; startAfter?: Record<string, unknown> },
540
583
  collectionPath?: string
541
584
  ): SQL[] {
542
585
  if (!options.startAfter) return [];
543
586
  const cursor = options.startAfter;
587
+ const keys = normalizeDriverOrderBy(options.orderBy, options.order);
544
588
 
545
- if (options.orderBy) {
589
+ if (keys) {
546
590
  // Relevance is computed per query, not stored, so there is no value
547
591
  // on the cursor row to compare a later page against — and two
548
592
  // requests with different search strings would produce scores that
549
593
  // are not on the same scale at all. Refusing is the only honest
550
594
  // answer: a dropped cursor condition silently repeats and skips
551
595
  // rows, which is precisely what paging exists to prevent.
552
- if (options.orderBy === FetchService.SCORE_FIELD) {
596
+ if (keys.some(([field]) => field === FetchService.SCORE_FIELD)) {
553
597
  throw ApiError.badRequest(
554
598
  "Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
555
599
  "Relevance is computed per query rather than stored, so it cannot key a cursor. " +
@@ -559,23 +603,25 @@ export class FetchService {
559
603
  );
560
604
  }
561
605
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
562
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
563
- if (orderByField) {
564
- const startAfterOrderValue = (cursor.values as Record<string, unknown> | undefined)?.[options.orderBy] ?? cursor[options.orderBy];
565
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
566
-
567
- if (startAfterOrderValue !== undefined && startAfterId !== undefined) {
568
- if (options.order === "asc") {
569
- return [or(
570
- gt(orderByField, startAfterOrderValue),
571
- and(eq(orderByField, startAfterOrderValue), gt(idField, startAfterId))
572
- )!];
573
- } else {
574
- return [or(
575
- lt(orderByField, startAfterOrderValue),
576
- and(eq(orderByField, startAfterOrderValue), lt(idField, startAfterId))
577
- )!];
578
- }
606
+ const resolved = this.resolveOrderKeys(table, keys, collection);
607
+ const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
608
+
609
+ if (resolved.length > 0 && startAfterId !== undefined) {
610
+ const cursorValues = cursor.values as Record<string, unknown> | undefined;
611
+ // `in`, not `??`: a cursor row whose sort value is genuinely
612
+ // NULL is a row this has to be able to page past, and `??`
613
+ // read it as "the cursor did not carry this key" and dropped
614
+ // the whole condition.
615
+ const values = resolved.map(({ field }) => (cursorValues && field in cursorValues)
616
+ ? cursorValues[field]
617
+ : cursor[field]);
618
+ // Every key needs a value from the cursor row. A missing one
619
+ // cannot be guessed, and a comparison built from the keys that
620
+ // happen to be present is not the same comparison — so this
621
+ // falls through to no cursor condition, which is what a single
622
+ // missing sort value has always done here.
623
+ if (values.every((value) => value !== undefined)) {
624
+ return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
579
625
  }
580
626
  }
581
627
  } else {
@@ -590,6 +636,54 @@ export class FetchService {
590
636
  return [];
591
637
  }
592
638
 
639
+ /**
640
+ * "Sorts strictly after the cursor row", over `keys` and then the id.
641
+ *
642
+ * Built by recursion rather than as a row-value comparison — `(a, b) > (x, y)`
643
+ * would be shorter, but it is only correct when every key runs the same
644
+ * direction, and `roles ASC, created_at DESC` is exactly the case this
645
+ * exists to serve.
646
+ *
647
+ * NULLs are compared by the rule Postgres sorts them under (last ascending,
648
+ * first descending) rather than by `>`/`<`, which answer *unknown* against
649
+ * NULL and therefore match nothing. Ordering by a nullable column and paging
650
+ * used to drop every row whose sort value was NULL from page two onward.
651
+ */
652
+ private buildKeysetComparison(
653
+ keys: ResolvedOrderKey[],
654
+ values: unknown[],
655
+ idField: AnyPgColumn,
656
+ cursorId: unknown,
657
+ index = 0
658
+ ): SQL {
659
+ // Past the last key, the id settles it. It is ordered `DESC`, so "after"
660
+ // the cursor row means a smaller id.
661
+ if (index >= keys.length) return lt(idField, cursorId);
662
+
663
+ const { direction } = keys[index];
664
+ // A column, not an expression: the one target that is an expression is
665
+ // `_score`, and a cursor over relevance is refused before this is
666
+ // reached. Drizzle's comparison helpers are typed per operand kind, so
667
+ // the union has to be resolved here rather than at the call site.
668
+ const target = keys[index].target as AnyPgColumn;
669
+ const value = values[index];
670
+ const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
671
+
672
+ if (value === null) {
673
+ // The cursor row sorts among the NULLs.
674
+ return direction === "asc"
675
+ // NULLS LAST: nothing non-null is left, so only later NULLs.
676
+ ? and(isNull(target), rest)!
677
+ // NULLS FIRST: every non-null row is still ahead, plus later NULLs.
678
+ : or(isNotNull(target), and(isNull(target), rest))!;
679
+ }
680
+
681
+ return direction === "asc"
682
+ // NULLS LAST, so the NULLs are still ahead of a non-null cursor row.
683
+ ? or(gt(target, value), isNull(target), and(eq(target, value), rest))!
684
+ : or(lt(target, value), and(eq(target, value), rest))!;
685
+ }
686
+
593
687
  /**
594
688
  * Compile "rows reachable from this parent" into a `WHERE` condition on the
595
689
  * target table, so a nested listing can run as an ordinary collection query.
@@ -779,7 +873,7 @@ idColumn };
779
873
  collectionPath: string,
780
874
  options: {
781
875
  filter?: FilterValues<Extract<keyof M, string>>;
782
- orderBy?: string;
876
+ orderBy?: string | OrderByTuple[];
783
877
  order?: "desc" | "asc";
784
878
  limit?: number;
785
879
  offset?: number;
@@ -909,18 +1003,19 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
909
1003
  if (finalCondition) query = query.where(finalCondition);
910
1004
  }
911
1005
 
912
- const orderExpressions = [];
913
1006
  // Vector search overrides ORDER BY with distance (ascending = closest first)
914
- if (vectorMeta) {
915
- orderExpressions.push(asc(vectorMeta.orderBy));
916
- } else if (options.orderBy) {
917
- const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
918
- if (orderByField) {
919
- orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
920
- }
921
- }
922
- orderExpressions.push(desc(idField));
923
- if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
1007
+ const orderExpressions = vectorMeta
1008
+ ? [asc(vectorMeta.orderBy), desc(idField)]
1009
+ : this.buildOrderExpressions(
1010
+ this.resolveOrderKeys(
1011
+ table,
1012
+ normalizeDriverOrderBy(options.orderBy, options.order) ?? [],
1013
+ collection,
1014
+ options.searchString
1015
+ ),
1016
+ idField
1017
+ );
1018
+ query = query.orderBy(...orderExpressions);
924
1019
 
925
1020
  if (options.startAfter) {
926
1021
  const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
@@ -1098,7 +1193,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1098
1193
  * logical group was pushed every row in the table.
1099
1194
  */
1100
1195
  logical?: LogicalCondition;
1101
- orderBy?: string;
1196
+ orderBy?: string | OrderByTuple[];
1102
1197
  order?: "desc" | "asc";
1103
1198
  limit?: number;
1104
1199
  offset?: number;
@@ -1137,7 +1232,7 @@ relatedTo: hop });
1137
1232
  * that RLS allowed.
1138
1233
  */
1139
1234
  logical?: LogicalCondition;
1140
- orderBy?: string;
1235
+ orderBy?: string | OrderByTuple[];
1141
1236
  order?: "desc" | "asc";
1142
1237
  limit?: number;
1143
1238
  databaseId?: string;
@@ -1161,6 +1256,14 @@ relatedTo: hop });
1161
1256
  logical?: LogicalCondition;
1162
1257
  searchString?: string;
1163
1258
  databaseId?: string;
1259
+ /**
1260
+ * Only the `threshold` half of a vector search narrows a count: the
1261
+ * distance ordering and the `_distance` column change which rows
1262
+ * come back first, not how many there are. Omitting it here left
1263
+ * `meta.total` counting rows the threshold had excluded, so a
1264
+ * request that was served three rows was told there were nine.
1265
+ */
1266
+ vectorSearch?: VectorSearchParams;
1164
1267
  } = {}
1165
1268
  ): Promise<number> {
1166
1269
  // Same narrowing as the listing — and, unlike the count it replaces,
@@ -1197,6 +1300,23 @@ relatedTo: hop });
1197
1300
  if (logicalCondition) allConditions.push(logicalCondition);
1198
1301
  }
1199
1302
 
1303
+ // A `threshold` genuinely narrows the row set on the fetch path, and
1304
+ // this count did not apply it — so a similarity-filtered listing
1305
+ // reported the size of the *unfiltered* set, and `hasMore` stayed true
1306
+ // over pages that were already empty. Only the threshold narrows it:
1307
+ // the ORDER BY and the `_distance` projection change which rows come
1308
+ // first and what rides along with them, not how many there are.
1309
+ if (options.vectorSearch) {
1310
+ // Built for any vector search rather than only a thresholded one,
1311
+ // because this is also where an unknown or non-vector
1312
+ // `vector_search` property is refused with a 400. Without a
1313
+ // threshold it contributes no filter, so the count is unchanged and
1314
+ // what is gained is that `/count` refuses the request the listing
1315
+ // refuses instead of answering it with a number.
1316
+ const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1317
+ if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
1318
+ }
1319
+
1200
1320
  if (allConditions.length > 0) {
1201
1321
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
1202
1322
  if (finalCondition) query = query.where(finalCondition);
@@ -1206,6 +1326,131 @@ relatedTo: hop });
1206
1326
  return Number(result[0]?.count || 0);
1207
1327
  }
1208
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
+
1209
1454
  /**
1210
1455
  * Check if a field value is unique
1211
1456
  */
@@ -1269,7 +1514,7 @@ relatedTo: hop });
1269
1514
  filter?: FilterValues<Extract<keyof M, string>>;
1270
1515
  /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
1271
1516
  logical?: LogicalCondition;
1272
- orderBy?: string;
1517
+ orderBy?: string | OrderByTuple[];
1273
1518
  order?: "desc" | "asc";
1274
1519
  limit?: number;
1275
1520
  offset?: number;
@@ -1546,7 +1791,7 @@ relatedTo: hop }, include
1546
1791
  * described different sets of rows.
1547
1792
  */
1548
1793
  logical?: LogicalCondition;
1549
- orderBy?: string;
1794
+ orderBy?: string | OrderByTuple[];
1550
1795
  order?: "desc" | "asc";
1551
1796
  limit?: number;
1552
1797
  offset?: number;
@@ -1627,17 +1872,19 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1627
1872
  if (finalCondition) query = query.where(finalCondition);
1628
1873
  }
1629
1874
 
1630
- const orderExpressions = [];
1631
- if (vectorMeta) {
1632
- orderExpressions.push(asc(vectorMeta.orderBy));
1633
- } else if (options.orderBy) {
1634
- const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
1635
- if (orderByField) {
1636
- orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1637
- }
1638
- }
1639
- orderExpressions.push(desc(idField));
1640
- if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
1875
+ // Vector search overrides ORDER BY with distance (ascending = closest first)
1876
+ const orderExpressions = vectorMeta
1877
+ ? [asc(vectorMeta.orderBy), desc(idField)]
1878
+ : this.buildOrderExpressions(
1879
+ this.resolveOrderKeys(
1880
+ table,
1881
+ normalizeDriverOrderBy(options.orderBy, options.order) ?? [],
1882
+ collection,
1883
+ options.searchString
1884
+ ),
1885
+ idField
1886
+ );
1887
+ query = query.orderBy(...orderExpressions);
1641
1888
 
1642
1889
  const limitValue = options.vectorSearch
1643
1890
  ? (options.limit || 10)
@@ -1,7 +1,7 @@
1
1
  import { and, eq, inArray, notInArray, or, sql, SQL, getTableName as drizzleTableName } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { DrizzleClient } from "../interfaces";
4
- import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
4
+ import { CollectionConfig, FilterValues, OrderByTuple, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
5
5
  import { getTableName, resolveCollectionRelations, findRelation, fieldKeyForColumn } from "@rebasepro/common";
6
6
  import { hasForeignKeyOnTarget, isManyToMany, type ResolvedVia } from "@rebasepro/types";
7
7
  import { DrizzleConditionBuilder } from "../utils/drizzle-conditions";
@@ -306,7 +306,7 @@ export class RelationService {
306
306
  relationKey: string,
307
307
  options: {
308
308
  filter?: FilterValues<Extract<keyof M, string>>;
309
- orderBy?: string;
309
+ orderBy?: string | OrderByTuple[];
310
310
  order?: "desc" | "asc";
311
311
  limit?: number;
312
312
  startAfter?: Record<string, unknown>;
@@ -335,7 +335,7 @@ export class RelationService {
335
335
  relation: ResolvedRelation,
336
336
  options: {
337
337
  filter?: FilterValues<Extract<keyof M, string>>;
338
- orderBy?: string;
338
+ orderBy?: string | OrderByTuple[];
339
339
  order?: "desc" | "asc";
340
340
  limit?: number;
341
341
  startAfter?: Record<string, unknown>;
@@ -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)).`);