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

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 (32) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/auth/services.d.ts +10 -0
  3. package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
  4. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  5. package/dist/data_driver-ULAyJEi9.js.map +1 -1
  6. package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-BedO2aNX.js} +3 -3
  7. package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-BedO2aNX.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-B1qKdXA4.js} +2 -2
  9. package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-B1qKdXA4.js.map} +1 -1
  10. package/dist/index.es.js +220 -65
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
  13. package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
  14. package/dist/services/FetchService.d.ts +54 -5
  15. package/dist/services/RelationService.d.ts +3 -3
  16. package/dist/services/dataService.d.ts +6 -4
  17. package/dist/services/realtimeService.d.ts +54 -10
  18. package/dist/src-DCdn3Val.js.map +1 -1
  19. package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
  20. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  21. package/package.json +6 -6
  22. package/src/PostgresBackendDriver.ts +7 -3
  23. package/src/auth/services.ts +26 -5
  24. package/src/schema/generate-drizzle-schema-logic.ts +19 -1
  25. package/src/services/FetchService.ts +185 -63
  26. package/src/services/RelationService.ts +3 -3
  27. package/src/services/dataService.ts +6 -4
  28. package/src/services/pg-notify-listener.ts +14 -0
  29. package/src/services/realtimeService.ts +160 -40
  30. package/src/websocket.ts +44 -1
  31. package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
  32. package/dist/websocket-C8ZqVBiV.js.map +0 -1
@@ -1188,16 +1188,20 @@ export class PostgresBackendDriver implements DataDriver {
1188
1188
  collection,
1189
1189
  filter,
1190
1190
  logical,
1191
- searchString
1191
+ searchString,
1192
+ vectorSearch
1192
1193
  }: FetchCollectionProps<M>): Promise<number> {
1193
1194
  return this.dataService.count(
1194
1195
  path,
1195
1196
  {
1196
1197
  filter,
1197
1198
  // Counted as well as filtered, or `meta.total` describes a
1198
- // different set of rows from the `data` beside it.
1199
+ // different set of rows from the `data` beside it. The same
1200
+ // held for a `vectorSearch` carrying a `threshold`: it narrows
1201
+ // the fetch, so it has to narrow the count.
1199
1202
  logical,
1200
- searchString
1203
+ searchString,
1204
+ vectorSearch
1201
1205
  }
1202
1206
  );
1203
1207
  }
@@ -22,10 +22,12 @@ import {
22
22
  PaginatedUsersResult,
23
23
  MfaFactor,
24
24
  MfaChallengeInfo,
25
- RoleData as Role
25
+ RoleData as Role,
26
+ ApiError
26
27
  } from "@rebasepro/server";
27
28
  import { toSnakeCase, camelCase } from "@rebasepro/utils";
28
29
  import { escapeLikePattern } from "../utils/drizzle-conditions";
30
+ import { extractPgError } from "../utils/pg-error-utils";
29
31
 
30
32
  export type { Role };
31
33
 
@@ -249,12 +251,31 @@ export class UserService implements UserRepository {
249
251
  return payload;
250
252
  }
251
253
 
254
+ /**
255
+ * @see UserRepository.createUser — an email already in use is a 409.
256
+ *
257
+ * The route checks first and answers 409; this is the same answer for the
258
+ * requests that get past the check, which two clicks on a signup button
259
+ * are enough to produce. `PersistService` has mapped `23505` to a conflict
260
+ * for collection writes since the layer that holds the SQLSTATE was made
261
+ * responsible for saying whose fault a failure is; the auth writes never
262
+ * got the same treatment and reached the client as "Internal Server Error".
263
+ */
252
264
  async createUser(data: CreateUserData): Promise<UserData> {
253
265
  const payload = this.mapPayload(data);
254
- const [row] = await this.withServerContext(async (db) =>
255
- (await db.insert(this.usersTable).values(payload).returning()) as Record<string, unknown>[]
256
- );
257
- return this.mapRowToUser(row);
266
+ try {
267
+ const [row] = await this.withServerContext(async (db) =>
268
+ (await db.insert(this.usersTable).values(payload).returning()) as Record<string, unknown>[]
269
+ );
270
+ return this.mapRowToUser(row);
271
+ } catch (error) {
272
+ // Drizzle wraps the pg error, so the SQLSTATE is down the `cause`
273
+ // chain rather than on the error itself.
274
+ if (extractPgError(error)?.code === "23505") {
275
+ throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
276
+ }
277
+ throw error;
278
+ }
258
279
  }
259
280
 
260
281
  async getUserById(id: string): Promise<UserData | null> {
@@ -379,8 +379,26 @@ export const getDrizzleColumn = (propName: string, prop: Property, collection: C
379
379
 
380
380
  /**
381
381
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
382
+ *
383
+ * The clause is SQL being written into a TypeScript file, so it has to survive
384
+ * being read back as a template literal. Three characters do not:
385
+ *
386
+ * - `` ` `` closes the template early, and the rest of the clause becomes code.
387
+ * - `${` opens an interpolation — the file stops compiling, or worse, compiles
388
+ * against whatever identifier happens to be in scope.
389
+ * - `\` is an escape, and Drizzle's `sql` tag reads the *cooked* strings, not
390
+ * `.raw`. So a policy written as `email ~ '^admin\.user@corp\.com$'` reaches
391
+ * the database as `^admin.user@corp.com$`, where every `\.` now matches any
392
+ * character. A `USING` clause is a security boundary and that one silently
393
+ * widened it — the SQL file emitted by the DDL generator kept the backslashes
394
+ * while this path dropped them, so the two disagreed about who could read the
395
+ * table.
396
+ *
397
+ * Escaping here rather than in the compiler: the clause is correct SQL, and it
398
+ * is only this destination that has an opinion about backslashes.
382
399
  */
383
- const wrapSql = (clause: string): string => `sql\`${clause}\``;
400
+ const wrapSql = (clause: string): string =>
401
+ `sql\`${clause.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``;
384
402
 
385
403
  /**
386
404
  * Generates a deterministic hash based on the rule configuration.
@@ -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, 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);
@@ -1269,7 +1389,7 @@ relatedTo: hop });
1269
1389
  filter?: FilterValues<Extract<keyof M, string>>;
1270
1390
  /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
1271
1391
  logical?: LogicalCondition;
1272
- orderBy?: string;
1392
+ orderBy?: string | OrderByTuple[];
1273
1393
  order?: "desc" | "asc";
1274
1394
  limit?: number;
1275
1395
  offset?: number;
@@ -1546,7 +1666,7 @@ relatedTo: hop }, include
1546
1666
  * described different sets of rows.
1547
1667
  */
1548
1668
  logical?: LogicalCondition;
1549
- orderBy?: string;
1669
+ orderBy?: string | OrderByTuple[];
1550
1670
  order?: "desc" | "asc";
1551
1671
  limit?: number;
1552
1672
  offset?: number;
@@ -1627,17 +1747,19 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1627
1747
  if (finalCondition) query = query.where(finalCondition);
1628
1748
  }
1629
1749
 
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);
1750
+ // Vector search overrides ORDER BY with distance (ascending = closest first)
1751
+ const orderExpressions = vectorMeta
1752
+ ? [asc(vectorMeta.orderBy), desc(idField)]
1753
+ : this.buildOrderExpressions(
1754
+ this.resolveOrderKeys(
1755
+ table,
1756
+ normalizeDriverOrderBy(options.orderBy, options.order) ?? [],
1757
+ collection,
1758
+ options.searchString
1759
+ ),
1760
+ idField
1761
+ );
1762
+ query = query.orderBy(...orderExpressions);
1641
1763
 
1642
1764
  const limitValue = options.vectorSearch
1643
1765
  ? (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>;
@@ -1,5 +1,5 @@
1
1
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { FilterValues, LogicalCondition } from "@rebasepro/types";
2
+ import { FilterValues, LogicalCondition, OrderByTuple } from "@rebasepro/types";
3
3
  import type { VectorSearchParams } from "@rebasepro/types";
4
4
  import { FetchService } from "./FetchService";
5
5
  import { PersistService } from "./PersistService";
@@ -62,7 +62,7 @@ export class DataService implements DataRepository {
62
62
  filter?: FilterValues<Extract<keyof M, string>>;
63
63
  /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
64
64
  logical?: LogicalCondition;
65
- orderBy?: string;
65
+ orderBy?: string | OrderByTuple[];
66
66
  order?: "desc" | "asc";
67
67
  limit?: number;
68
68
  offset?: number;
@@ -85,7 +85,7 @@ export class DataService implements DataRepository {
85
85
  filter?: FilterValues<Extract<keyof M, string>>;
86
86
  /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
87
87
  logical?: LogicalCondition;
88
- orderBy?: string;
88
+ orderBy?: string | OrderByTuple[];
89
89
  order?: "desc" | "asc";
90
90
  limit?: number;
91
91
  databaseId?: string;
@@ -106,6 +106,8 @@ export class DataService implements DataRepository {
106
106
  logical?: LogicalCondition;
107
107
  searchString?: string;
108
108
  databaseId?: string;
109
+ /** Only the `threshold` narrows the count — see `FetchService.count`. */
110
+ vectorSearch?: VectorSearchParams;
109
111
  } = {}
110
112
  ): Promise<number> {
111
113
  return this.fetchService.count<M>(collectionPath, options);
@@ -133,7 +135,7 @@ export class DataService implements DataRepository {
133
135
  relationKey: string,
134
136
  options: {
135
137
  filter?: FilterValues<Extract<keyof M, string>>;
136
- orderBy?: string;
138
+ orderBy?: string | OrderByTuple[];
137
139
  order?: "desc" | "asc";
138
140
  limit?: number;
139
141
  startAfter?: Record<string, unknown>;
@@ -84,8 +84,16 @@ export class PgNotifyListener {
84
84
 
85
85
  private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
86
86
  const { connectionString, channel, onPayload, logLabel } = this.options;
87
+ // Held here rather than only inside the `try` so the failure path can
88
+ // still reach it: everything below `connect()` can throw, and until
89
+ // `this.client` is assigned nothing else in this class knows the
90
+ // connection exists. Left unreleased it stays open on the server while
91
+ // `scheduleReconnect` opens another — one leaked backend per attempt,
92
+ // every few seconds, for as long as the failure lasts.
93
+ let pending: PgClient | undefined;
87
94
  try {
88
95
  const client = new PgClient({ connectionString });
96
+ pending = client;
89
97
 
90
98
  client.on("error", (err) => {
91
99
  logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
@@ -111,8 +119,14 @@ export class PgNotifyListener {
111
119
  await client.connect();
112
120
  await client.query(`LISTEN ${channel}`);
113
121
  this.client = client;
122
+ // Adopted: `stop()` and `scheduleReconnect` will close it now.
123
+ pending = undefined;
114
124
  logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
115
125
  } catch (err) {
126
+ // Never adopted, so nothing else will ever close it.
127
+ if (pending) {
128
+ try { await pending.end(); } catch { /* already dead */ }
129
+ }
116
130
  // Surface the initial failure so callers can choose to fall back;
117
131
  // for reconnects, keep retrying quietly in the background.
118
132
  if (initial) throw err;