@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.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/auth/services.d.ts +10 -0
- package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
- package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -1
- package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-BedO2aNX.js} +3 -3
- package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-BedO2aNX.js.map} +1 -1
- package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-B1qKdXA4.js} +2 -2
- package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-B1qKdXA4.js.map} +1 -1
- package/dist/index.es.js +220 -65
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
- package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
- package/dist/services/FetchService.d.ts +54 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/dataService.d.ts +6 -4
- package/dist/services/realtimeService.d.ts +54 -10
- package/dist/src-DCdn3Val.js.map +1 -1
- package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
- package/dist/websocket-D2jXv0Ds.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/auth/services.ts +26 -5
- package/src/schema/generate-drizzle-schema-logic.ts +19 -1
- package/src/services/FetchService.ts +185 -63
- package/src/services/RelationService.ts +3 -3
- package/src/services/dataService.ts +6 -4
- package/src/services/pg-notify-listener.ts +14 -0
- package/src/services/realtimeService.ts +160 -40
- package/src/websocket.ts +44 -1
- package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
- 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
|
}
|
package/src/auth/services.ts
CHANGED
|
@@ -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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 =>
|
|
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
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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;
|