@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.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +19 -0
- 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-DoHwhVf8.js} +3 -3
- package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
- package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
- package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
- package/dist/index.es.js +543 -105
- 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/schema/drizzle-ddl.d.ts +9 -0
- package/dist/services/FetchService.d.ts +81 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/channel-presence.d.ts +16 -1
- 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/utils/drizzle-conditions.d.ts +25 -0
- 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/PostgresBootstrapper.ts +105 -41
- package/src/auth/services.ts +26 -5
- package/src/schema/drizzle-ddl.ts +33 -0
- package/src/schema/ensure-collection-tables.test.ts +99 -0
- package/src/schema/ensure-collection-tables.ts +65 -3
- package/src/schema/generate-drizzle-schema-logic.ts +19 -1
- package/src/services/FetchService.ts +310 -63
- package/src/services/RelationService.ts +3 -3
- package/src/services/channel-history.ts +38 -6
- package/src/services/channel-presence.ts +31 -7
- package/src/services/dataService.ts +6 -4
- package/src/services/pg-notify-listener.ts +14 -0
- package/src/services/realtimeService.ts +161 -41
- package/src/utils/drizzle-conditions.ts +155 -5
- package/src/websocket.ts +44 -1
- package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
- 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
|
|
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);
|
|
@@ -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
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
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)).`);
|