@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133
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 +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/cli-output.d.ts +34 -0
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1815 -1023
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +164 -23
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +132 -13
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +371 -161
- package/src/collections/buildRegistry.ts +3 -1
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +142 -28
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +156 -77
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +152 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +171 -0
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +253 -115
- package/src/services/PersistService.ts +82 -43
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +5 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +382 -118
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -2,8 +2,8 @@ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, S
|
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
|
|
4
4
|
import type { VectorSearchParams } from "@rebasepro/types";
|
|
5
|
-
import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
|
|
6
|
-
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
5
|
+
import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
|
|
6
|
+
import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
|
|
7
7
|
import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
|
|
8
8
|
import {
|
|
9
9
|
getCollectionByPath,
|
|
@@ -22,6 +22,7 @@ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query
|
|
|
22
22
|
import { DrizzleClient } from "../interfaces";
|
|
23
23
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
24
24
|
import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
|
|
25
|
+
import { visibleColumnProjection, hiddenColumnsOption } from "../schema/search-column";
|
|
25
26
|
import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
|
|
26
27
|
import { ApiError, logger } from "@rebasepro/server";
|
|
27
28
|
import { reachedDatabase } from "../utils/pg-error-utils";
|
|
@@ -119,6 +120,31 @@ export class FetchService {
|
|
|
119
120
|
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
120
121
|
* caller that hands over no collection to resolve against.
|
|
121
122
|
*/
|
|
123
|
+
/**
|
|
124
|
+
* The ORDER BY target, which may be relevance rather than a column.
|
|
125
|
+
*
|
|
126
|
+
* `_score` is only meaningful for a collection that declared a `search`
|
|
127
|
+
* block *and* for a request that carried a search string — ranking rows
|
|
128
|
+
* against no query ranks them all at zero. Outside those two conditions it
|
|
129
|
+
* is an unknown field and gets the same 400 as any other typo, which is the
|
|
130
|
+
* behaviour that matters: a sort that is silently dropped returns 200 with
|
|
131
|
+
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
132
|
+
*/
|
|
133
|
+
static readonly SCORE_FIELD = "_score";
|
|
134
|
+
|
|
135
|
+
private resolveOrderTarget(
|
|
136
|
+
table: PgTable<any>,
|
|
137
|
+
orderBy: string,
|
|
138
|
+
collection?: CollectionConfig,
|
|
139
|
+
searchString?: string
|
|
140
|
+
): AnyPgColumn | SQL | undefined {
|
|
141
|
+
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
142
|
+
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
143
|
+
if (rank) return rank;
|
|
144
|
+
}
|
|
145
|
+
return this.resolveOrderByField(table, orderBy, collection);
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
private resolveOrderByField(
|
|
123
149
|
table: PgTable<any>,
|
|
124
150
|
orderBy: string,
|
|
@@ -133,14 +159,21 @@ export class FetchService {
|
|
|
133
159
|
// Owning relation, resolved: the relation names its own local key.
|
|
134
160
|
const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
|
|
135
161
|
if (declaredRelation?.kind === "belongsTo") {
|
|
136
|
-
|
|
162
|
+
// `localKey` is the column; the table is keyed by the wire name.
|
|
163
|
+
const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
|
|
137
164
|
if (foreignKey) return foreignKey;
|
|
138
165
|
}
|
|
139
166
|
|
|
140
|
-
// No collection in hand — the
|
|
141
|
-
//
|
|
142
|
-
// `
|
|
143
|
-
|
|
167
|
+
// No collection in hand — the shapes an owning relation's key takes by
|
|
168
|
+
// default (e.g. `project` → `projectId`, `userProfile` →
|
|
169
|
+
// `userProfileId`), then the snake forms for a project that authored the
|
|
170
|
+
// property under its column name.
|
|
171
|
+
for (const guess of [
|
|
172
|
+
`${orderBy}Id`,
|
|
173
|
+
toWireKey(generateForeignKeyName(orderBy)),
|
|
174
|
+
`${orderBy}_id`,
|
|
175
|
+
generateForeignKeyName(orderBy)
|
|
176
|
+
]) {
|
|
144
177
|
const foreignKey = columnAt(guess);
|
|
145
178
|
if (foreignKey) return foreignKey;
|
|
146
179
|
}
|
|
@@ -300,6 +333,14 @@ export class FetchService {
|
|
|
300
333
|
);
|
|
301
334
|
}
|
|
302
335
|
} catch (e) {
|
|
336
|
+
// A relation that failed to load is not a relation that is absent.
|
|
337
|
+
// Without this the request answers 200 with the field quietly
|
|
338
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
339
|
+
// transaction, every later relation in the same request is
|
|
340
|
+
// swallowed too, so one failure becomes a response missing
|
|
341
|
+
// several fields. Same guard the four other catches in this file
|
|
342
|
+
// already use.
|
|
343
|
+
if (reachedDatabase(e)) throw e;
|
|
303
344
|
logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
|
|
304
345
|
}
|
|
305
346
|
});
|
|
@@ -378,6 +419,14 @@ export class FetchService {
|
|
|
378
419
|
}
|
|
379
420
|
}
|
|
380
421
|
} catch (e) {
|
|
422
|
+
// A relation that failed to load is not a relation that is absent.
|
|
423
|
+
// Without this the request answers 200 with the field quietly
|
|
424
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
425
|
+
// transaction, every later relation in the same request is
|
|
426
|
+
// swallowed too, so one failure becomes a response missing
|
|
427
|
+
// several fields. Same guard the four other catches in this file
|
|
428
|
+
// already use.
|
|
429
|
+
if (reachedDatabase(e)) throw e;
|
|
381
430
|
logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
|
|
382
431
|
}
|
|
383
432
|
}
|
|
@@ -407,6 +456,15 @@ export class FetchService {
|
|
|
407
456
|
): Record<string, unknown> {
|
|
408
457
|
const queryOpts: Record<string, unknown> = {};
|
|
409
458
|
|
|
459
|
+
// Same exclusion the `db.select` fallback applies, in the shape the
|
|
460
|
+
// relational query builder takes. Both paths serve the same request, so
|
|
461
|
+
// a row must not carry the search column down one and not the other.
|
|
462
|
+
const hidden = hiddenColumnsOption(
|
|
463
|
+
getTableColumns(table),
|
|
464
|
+
this.registry.getCollectionByPath(collectionPath) ?? undefined
|
|
465
|
+
);
|
|
466
|
+
if (hidden) queryOpts.columns = hidden;
|
|
467
|
+
|
|
410
468
|
if (withConfig) queryOpts.with = withConfig;
|
|
411
469
|
|
|
412
470
|
// Build where conditions
|
|
@@ -417,7 +475,7 @@ export class FetchService {
|
|
|
417
475
|
if (options.searchString) {
|
|
418
476
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
419
477
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
420
|
-
options.searchString, collection.properties, table
|
|
478
|
+
options.searchString, collection.properties, table, collection
|
|
421
479
|
);
|
|
422
480
|
if (searchConditions.length === 0) {
|
|
423
481
|
// Return options that will produce empty results
|
|
@@ -451,7 +509,7 @@ export class FetchService {
|
|
|
451
509
|
const orderExpressions: unknown[] = [];
|
|
452
510
|
if (options.orderBy) {
|
|
453
511
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
454
|
-
const orderByField = this.
|
|
512
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
455
513
|
if (orderByField) {
|
|
456
514
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
457
515
|
}
|
|
@@ -485,6 +543,21 @@ export class FetchService {
|
|
|
485
543
|
const cursor = options.startAfter;
|
|
486
544
|
|
|
487
545
|
if (options.orderBy) {
|
|
546
|
+
// Relevance is computed per query, not stored, so there is no value
|
|
547
|
+
// on the cursor row to compare a later page against — and two
|
|
548
|
+
// requests with different search strings would produce scores that
|
|
549
|
+
// are not on the same scale at all. Refusing is the only honest
|
|
550
|
+
// answer: a dropped cursor condition silently repeats and skips
|
|
551
|
+
// rows, which is precisely what paging exists to prevent.
|
|
552
|
+
if (options.orderBy === FetchService.SCORE_FIELD) {
|
|
553
|
+
throw ApiError.badRequest(
|
|
554
|
+
"Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
|
|
555
|
+
"Relevance is computed per query rather than stored, so it cannot key a cursor. " +
|
|
556
|
+
"Use `limit`/`offset` for relevance-ordered pages, or order by a column.",
|
|
557
|
+
"SCORE_CURSOR_UNSUPPORTED",
|
|
558
|
+
{ field: FetchService.SCORE_FIELD }
|
|
559
|
+
);
|
|
560
|
+
}
|
|
488
561
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
|
|
489
562
|
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
490
563
|
if (orderByField) {
|
|
@@ -607,9 +680,12 @@ idColumn };
|
|
|
607
680
|
try {
|
|
608
681
|
const withConfig = this.buildWithConfig(collection);
|
|
609
682
|
|
|
683
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
684
|
+
|
|
610
685
|
const row = await qb.findFirst({
|
|
611
686
|
where: eq(idField, parsedId),
|
|
612
|
-
with: withConfig
|
|
687
|
+
with: withConfig,
|
|
688
|
+
...(hidden ? { columns: hidden } : {})
|
|
613
689
|
} as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
|
|
614
690
|
|
|
615
691
|
if (!row) return undefined;
|
|
@@ -631,8 +707,9 @@ idColumn };
|
|
|
631
707
|
}
|
|
632
708
|
|
|
633
709
|
// Fallback: db.select + N+1 relation loading
|
|
710
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
634
711
|
const result = await this.db
|
|
635
|
-
.select()
|
|
712
|
+
.select(visibleOne as never)
|
|
636
713
|
.from(table)
|
|
637
714
|
.where(eq(idField, parsedId))
|
|
638
715
|
.limit(1);
|
|
@@ -673,6 +750,14 @@ idColumn };
|
|
|
673
750
|
values[key] = createRelationRef(e.id, e.path);
|
|
674
751
|
}
|
|
675
752
|
} catch (e) {
|
|
753
|
+
// A relation that failed to load is not a relation that is absent.
|
|
754
|
+
// Without this the request answers 200 with the field quietly
|
|
755
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
756
|
+
// transaction, every later relation in the same request is
|
|
757
|
+
// swallowed too, so one failure becomes a response missing
|
|
758
|
+
// several fields. Same guard the four other catches in this file
|
|
759
|
+
// already use.
|
|
760
|
+
if (reachedDatabase(e)) throw e;
|
|
676
761
|
logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
|
|
677
762
|
}
|
|
678
763
|
}
|
|
@@ -700,6 +785,7 @@ idColumn };
|
|
|
700
785
|
offset?: number;
|
|
701
786
|
startAfter?: Record<string, unknown>;
|
|
702
787
|
searchString?: string;
|
|
788
|
+
searchExplain?: boolean;
|
|
703
789
|
databaseId?: string;
|
|
704
790
|
vectorSearch?: VectorSearchParams;
|
|
705
791
|
logical?: LogicalCondition;
|
|
@@ -763,17 +849,41 @@ idColumn };
|
|
|
763
849
|
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
764
850
|
}
|
|
765
851
|
|
|
852
|
+
// A generated search column is an index in column form; `SELECT *`
|
|
853
|
+
// would ship it to every caller. The projection is undefined — and the
|
|
854
|
+
// SQL therefore unchanged — for any table without one.
|
|
855
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
856
|
+
|
|
857
|
+
// Relevance, alongside the row, exactly as `_distance` rides along with
|
|
858
|
+
// a vector search. Present only when the collection opted in and the
|
|
859
|
+
// request carried a search string, so a caller can order by it, show
|
|
860
|
+
// it, or blend it with a score of their own.
|
|
861
|
+
const rankSelect = options.searchString
|
|
862
|
+
? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
|
|
863
|
+
: undefined;
|
|
864
|
+
|
|
865
|
+
// Only when asked: a `ts_headline` per declared field per row.
|
|
866
|
+
const matchesSelect = options.searchString && options.searchExplain
|
|
867
|
+
? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
|
|
868
|
+
: undefined;
|
|
869
|
+
|
|
766
870
|
let query = vectorMeta
|
|
767
|
-
? this.db.select({ table_row: table,
|
|
871
|
+
? this.db.select({ table_row: (visible ?? table) as never,
|
|
768
872
|
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
769
|
-
:
|
|
873
|
+
: rankSelect
|
|
874
|
+
? this.db.select({
|
|
875
|
+
table_row: (visible ?? table) as never,
|
|
876
|
+
_score: rankSelect,
|
|
877
|
+
...(matchesSelect ? { _matches: matchesSelect } : {})
|
|
878
|
+
}).from(table).$dynamic()
|
|
879
|
+
: (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
|
|
770
880
|
const allConditions: SQL[] = [];
|
|
771
881
|
|
|
772
882
|
if (scopeCondition) allConditions.push(scopeCondition);
|
|
773
883
|
|
|
774
884
|
if (options.searchString) {
|
|
775
885
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
776
|
-
options.searchString, collection.properties, table
|
|
886
|
+
options.searchString, collection.properties, table, collection
|
|
777
887
|
);
|
|
778
888
|
if (searchConditions.length === 0) return [];
|
|
779
889
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -804,7 +914,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
804
914
|
if (vectorMeta) {
|
|
805
915
|
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
806
916
|
} else if (options.orderBy) {
|
|
807
|
-
const orderByField = this.
|
|
917
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
808
918
|
if (orderByField) {
|
|
809
919
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
810
920
|
}
|
|
@@ -838,7 +948,15 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
838
948
|
...r.table_row,
|
|
839
949
|
_distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
|
|
840
950
|
}))
|
|
841
|
-
|
|
951
|
+
// Same nested shape, unwrapped the same way, when a relevance
|
|
952
|
+
// score was selected instead.
|
|
953
|
+
: rankSelect
|
|
954
|
+
? (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
|
|
955
|
+
...r.table_row,
|
|
956
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
957
|
+
...(matchesSelect ? { _matches: r._matches ?? [] } : {})
|
|
958
|
+
}))
|
|
959
|
+
: rawResults as Record<string, unknown>[];
|
|
842
960
|
|
|
843
961
|
return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
|
|
844
962
|
}
|
|
@@ -911,6 +1029,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
911
1029
|
}
|
|
912
1030
|
});
|
|
913
1031
|
} catch (e) {
|
|
1032
|
+
// A relation that failed to load is not a relation that is absent.
|
|
1033
|
+
// Without this the request answers 200 with the field quietly
|
|
1034
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
1035
|
+
// transaction, every later relation in the same request is
|
|
1036
|
+
// swallowed too, so one failure becomes a response missing
|
|
1037
|
+
// several fields. Same guard the four other catches in this file
|
|
1038
|
+
// already use.
|
|
1039
|
+
if (reachedDatabase(e)) throw e;
|
|
914
1040
|
logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
|
|
915
1041
|
}
|
|
916
1042
|
}
|
|
@@ -938,6 +1064,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
938
1064
|
);
|
|
939
1065
|
});
|
|
940
1066
|
} catch (e) {
|
|
1067
|
+
// A relation that failed to load is not a relation that is absent.
|
|
1068
|
+
// Without this the request answers 200 with the field quietly
|
|
1069
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
1070
|
+
// transaction, every later relation in the same request is
|
|
1071
|
+
// swallowed too, so one failure becomes a response missing
|
|
1072
|
+
// several fields. Same guard the four other catches in this file
|
|
1073
|
+
// already use.
|
|
1074
|
+
if (reachedDatabase(e)) throw e;
|
|
941
1075
|
logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
|
|
942
1076
|
}
|
|
943
1077
|
}
|
|
@@ -954,6 +1088,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
954
1088
|
collectionPath: string,
|
|
955
1089
|
options: {
|
|
956
1090
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1091
|
+
/**
|
|
1092
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1093
|
+
*
|
|
1094
|
+
* `fetchRowsWithConditions` below has always applied this; it was
|
|
1095
|
+
* simply absent from this signature, so the only callers that could
|
|
1096
|
+
* pass one were the ones that went around this method. Realtime
|
|
1097
|
+
* came through here, which is why a subscription filtered by a
|
|
1098
|
+
* logical group was pushed every row in the table.
|
|
1099
|
+
*/
|
|
1100
|
+
logical?: LogicalCondition;
|
|
957
1101
|
orderBy?: string;
|
|
958
1102
|
order?: "desc" | "asc";
|
|
959
1103
|
limit?: number;
|
|
@@ -984,10 +1128,21 @@ relatedTo: hop });
|
|
|
984
1128
|
searchString: string,
|
|
985
1129
|
options: {
|
|
986
1130
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1131
|
+
/**
|
|
1132
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1133
|
+
*
|
|
1134
|
+
* `fetchRowsWithConditions` has always applied one; it was missing
|
|
1135
|
+
* from this signature, so a realtime search subscription carrying a
|
|
1136
|
+
* group could not pass it on and served every row matching the text
|
|
1137
|
+
* that RLS allowed.
|
|
1138
|
+
*/
|
|
1139
|
+
logical?: LogicalCondition;
|
|
987
1140
|
orderBy?: string;
|
|
988
1141
|
order?: "desc" | "asc";
|
|
989
1142
|
limit?: number;
|
|
990
1143
|
databaseId?: string;
|
|
1144
|
+
/** Ask each row which declared search field matched. */
|
|
1145
|
+
searchExplain?: boolean;
|
|
991
1146
|
} = {}
|
|
992
1147
|
): Promise<Record<string, unknown>[]> {
|
|
993
1148
|
return this.fetchRowsWithConditions<M>(collectionPath, {
|
|
@@ -1024,7 +1179,7 @@ relatedTo: hop });
|
|
|
1024
1179
|
|
|
1025
1180
|
if (options.searchString) {
|
|
1026
1181
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1027
|
-
options.searchString, collection.properties, table
|
|
1182
|
+
options.searchString, collection.properties, table, collection
|
|
1028
1183
|
);
|
|
1029
1184
|
if (searchConditions.length === 0) return 0;
|
|
1030
1185
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -1216,6 +1371,14 @@ relatedTo: hop }, include
|
|
|
1216
1371
|
}
|
|
1217
1372
|
}
|
|
1218
1373
|
} catch (e) {
|
|
1374
|
+
// A relation that failed to load is not a relation that is absent.
|
|
1375
|
+
// Without this the request answers 200 with the field quietly
|
|
1376
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
1377
|
+
// transaction, every later relation in the same request is
|
|
1378
|
+
// swallowed too, so one failure becomes a response missing
|
|
1379
|
+
// several fields. Same guard the four other catches in this file
|
|
1380
|
+
// already use.
|
|
1381
|
+
if (reachedDatabase(e)) throw e;
|
|
1219
1382
|
logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
|
|
1220
1383
|
}
|
|
1221
1384
|
}
|
|
@@ -1232,6 +1395,14 @@ relatedTo: hop }, include
|
|
|
1232
1395
|
(row as Record<string, unknown>)[key] = relatedList;
|
|
1233
1396
|
}
|
|
1234
1397
|
} catch (e) {
|
|
1398
|
+
// A relation that failed to load is not a relation that is absent.
|
|
1399
|
+
// Without this the request answers 200 with the field quietly
|
|
1400
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
1401
|
+
// transaction, every later relation in the same request is
|
|
1402
|
+
// swallowed too, so one failure becomes a response missing
|
|
1403
|
+
// several fields. Same guard the four other catches in this file
|
|
1404
|
+
// already use.
|
|
1405
|
+
if (reachedDatabase(e)) throw e;
|
|
1235
1406
|
logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
|
|
1236
1407
|
}
|
|
1237
1408
|
}
|
|
@@ -1298,8 +1469,9 @@ relatedTo: hop }, include
|
|
|
1298
1469
|
}
|
|
1299
1470
|
|
|
1300
1471
|
// Fallback: db.select + N+1 relation loading
|
|
1472
|
+
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
1301
1473
|
const result = await this.db
|
|
1302
|
-
.select()
|
|
1474
|
+
.select(visibleOne as never)
|
|
1303
1475
|
.from(table)
|
|
1304
1476
|
.where(eq(idField, parsedId))
|
|
1305
1477
|
.limit(1);
|
|
@@ -1339,6 +1511,14 @@ relatedTo: hop }, include
|
|
|
1339
1511
|
}));
|
|
1340
1512
|
}
|
|
1341
1513
|
} catch (e) {
|
|
1514
|
+
// A relation that failed to load is not a relation that is absent.
|
|
1515
|
+
// Without this the request answers 200 with the field quietly
|
|
1516
|
+
// missing — and because a Postgres error poisons the surrounding
|
|
1517
|
+
// transaction, every later relation in the same request is
|
|
1518
|
+
// swallowed too, so one failure becomes a response missing
|
|
1519
|
+
// several fields. Same guard the four other catches in this file
|
|
1520
|
+
// already use.
|
|
1521
|
+
if (reachedDatabase(e)) throw e;
|
|
1342
1522
|
logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
|
|
1343
1523
|
}
|
|
1344
1524
|
}
|
|
@@ -1353,12 +1533,26 @@ relatedTo: hop }, include
|
|
|
1353
1533
|
collectionPath: string,
|
|
1354
1534
|
options: {
|
|
1355
1535
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1536
|
+
/**
|
|
1537
|
+
* An `or(...)`/`and(...)` group, applied alongside `filter`.
|
|
1538
|
+
*
|
|
1539
|
+
* Declared *and applied*, because this is the path every REST search
|
|
1540
|
+
* and vector read takes: `fetchCollectionForRest` skips `db.query`
|
|
1541
|
+
* whenever a `searchString` or a `vectorSearch` is present. The
|
|
1542
|
+
* group arrived here on `options` from the very beginning and was
|
|
1543
|
+
* simply never read, so `?searchString=x&or=(...)` served every row
|
|
1544
|
+
* matching `x` that RLS allowed — while `count` (which does apply
|
|
1545
|
+
* it) reported the narrowed total, so `meta.total` and `data`
|
|
1546
|
+
* described different sets of rows.
|
|
1547
|
+
*/
|
|
1548
|
+
logical?: LogicalCondition;
|
|
1356
1549
|
orderBy?: string;
|
|
1357
1550
|
order?: "desc" | "asc";
|
|
1358
1551
|
limit?: number;
|
|
1359
1552
|
offset?: number;
|
|
1360
1553
|
startAfter?: Record<string, unknown>;
|
|
1361
1554
|
searchString?: string;
|
|
1555
|
+
searchExplain?: boolean;
|
|
1362
1556
|
vectorSearch?: VectorSearchParams;
|
|
1363
1557
|
relatedTo?: NestedPathHop;
|
|
1364
1558
|
} = {}
|
|
@@ -1374,17 +1568,41 @@ relatedTo: hop }, include
|
|
|
1374
1568
|
vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
1375
1569
|
}
|
|
1376
1570
|
|
|
1571
|
+
// A generated search column is an index in column form; `SELECT *`
|
|
1572
|
+
// would ship it to every caller. The projection is undefined — and the
|
|
1573
|
+
// SQL therefore unchanged — for any table without one.
|
|
1574
|
+
const visible = visibleColumnProjection(getTableColumns(table), collection);
|
|
1575
|
+
|
|
1576
|
+
// Relevance, alongside the row, exactly as `_distance` rides along with
|
|
1577
|
+
// a vector search. Present only when the collection opted in and the
|
|
1578
|
+
// request carried a search string, so a caller can order by it, show
|
|
1579
|
+
// it, or blend it with a score of their own.
|
|
1580
|
+
const rankSelect = options.searchString
|
|
1581
|
+
? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
|
|
1582
|
+
: undefined;
|
|
1583
|
+
|
|
1584
|
+
// Only when asked: a `ts_headline` per declared field per row.
|
|
1585
|
+
const matchesSelect = options.searchString && options.searchExplain
|
|
1586
|
+
? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
|
|
1587
|
+
: undefined;
|
|
1588
|
+
|
|
1377
1589
|
let query = vectorMeta
|
|
1378
|
-
? this.db.select({ table_row: table,
|
|
1590
|
+
? this.db.select({ table_row: (visible ?? table) as never,
|
|
1379
1591
|
_distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
1380
|
-
:
|
|
1592
|
+
: rankSelect
|
|
1593
|
+
? this.db.select({
|
|
1594
|
+
table_row: (visible ?? table) as never,
|
|
1595
|
+
_score: rankSelect,
|
|
1596
|
+
...(matchesSelect ? { _matches: matchesSelect } : {})
|
|
1597
|
+
}).from(table).$dynamic()
|
|
1598
|
+
: (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
|
|
1381
1599
|
const allConditions: SQL[] = [];
|
|
1382
1600
|
|
|
1383
1601
|
if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
|
|
1384
1602
|
|
|
1385
1603
|
if (options.searchString) {
|
|
1386
1604
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
|
|
1387
|
-
options.searchString, collection.properties, table
|
|
1605
|
+
options.searchString, collection.properties, table, collection
|
|
1388
1606
|
);
|
|
1389
1607
|
if (searchConditions.length === 0) return [];
|
|
1390
1608
|
allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
|
|
@@ -1395,6 +1613,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1395
1613
|
if (filterConditions.length > 0) allConditions.push(...filterConditions);
|
|
1396
1614
|
}
|
|
1397
1615
|
|
|
1616
|
+
if (options.logical) {
|
|
1617
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
1618
|
+
if (logicalCondition) allConditions.push(logicalCondition);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1398
1621
|
if (vectorMeta?.filter) {
|
|
1399
1622
|
allConditions.push(vectorMeta.filter);
|
|
1400
1623
|
}
|
|
@@ -1408,7 +1631,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1408
1631
|
if (vectorMeta) {
|
|
1409
1632
|
orderExpressions.push(asc(vectorMeta.orderBy));
|
|
1410
1633
|
} else if (options.orderBy) {
|
|
1411
|
-
const orderByField = this.
|
|
1634
|
+
const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
|
|
1412
1635
|
if (orderByField) {
|
|
1413
1636
|
orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
|
|
1414
1637
|
}
|
|
@@ -1433,6 +1656,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1433
1656
|
}));
|
|
1434
1657
|
}
|
|
1435
1658
|
|
|
1659
|
+
if (rankSelect) {
|
|
1660
|
+
return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
|
|
1661
|
+
...r.table_row,
|
|
1662
|
+
_score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
|
|
1663
|
+
...(matchesSelect ? { _matches: r._matches ?? [] } : {})
|
|
1664
|
+
}));
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1436
1667
|
return rawResults as Record<string, unknown>[];
|
|
1437
1668
|
}
|
|
1438
1669
|
|
|
@@ -1454,99 +1685,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
1454
1685
|
return !!this.getQueryBuilder(tableName);
|
|
1455
1686
|
}
|
|
1456
1687
|
|
|
1457
|
-
/**
|
|
1458
|
-
* Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
|
|
1459
|
-
* for efficient JOIN-based relation loading.
|
|
1460
|
-
* Returns null if the API is not available or the query fails.
|
|
1461
|
-
* Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
|
|
1462
|
-
*/
|
|
1463
|
-
private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
|
|
1464
|
-
collectionPath: string,
|
|
1465
|
-
collection: CollectionConfig,
|
|
1466
|
-
options: {
|
|
1467
|
-
filter?: FilterValues<Extract<keyof M, string>>;
|
|
1468
|
-
orderBy?: string;
|
|
1469
|
-
order?: "desc" | "asc";
|
|
1470
|
-
limit?: number;
|
|
1471
|
-
},
|
|
1472
|
-
include: string[],
|
|
1473
|
-
idInfo: { fieldName: string; type: "string" | "number" },
|
|
1474
|
-
idInfoArray?: { fieldName: string; type: "string" | "number" }[]
|
|
1475
|
-
): Promise<Record<string, unknown>[] | null> {
|
|
1476
|
-
try {
|
|
1477
|
-
|
|
1478
|
-
const table = getTableForCollection(collection, this.registry);
|
|
1479
|
-
const tableName = getTableName(table);
|
|
1480
|
-
const queryTarget = this.getQueryBuilder(tableName);
|
|
1481
|
-
|
|
1482
|
-
if (!queryTarget?.findMany) return null;
|
|
1483
|
-
|
|
1484
|
-
// Build the `with` config from include array
|
|
1485
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
1486
|
-
const withConfig: Record<string, boolean> = {};
|
|
1487
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
1488
|
-
if (include[0] === "*" || include.includes(key)) {
|
|
1489
|
-
// Use the Drizzle relation name (from the schema)
|
|
1490
|
-
const drizzleRelName = relation.relationName || key;
|
|
1491
|
-
withConfig[drizzleRelName] = true;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
// Build query options
|
|
1496
|
-
const queryOpts: Record<string, unknown> = { with: withConfig };
|
|
1497
|
-
if (options.limit) queryOpts.limit = options.limit;
|
|
1498
|
-
|
|
1499
|
-
// Build where clause
|
|
1500
|
-
if (options.filter) {
|
|
1501
|
-
const filterConditions = this.buildFilterConditions(
|
|
1502
|
-
options.filter, table, collectionPath
|
|
1503
|
-
);
|
|
1504
|
-
if (filterConditions.length > 0) {
|
|
1505
|
-
queryOpts.where = and(...filterConditions);
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
// Build orderBy
|
|
1510
|
-
if (options.orderBy) {
|
|
1511
|
-
const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
|
|
1512
|
-
if (orderByField) {
|
|
1513
|
-
queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
|
|
1514
|
-
}
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
|
|
1519
|
-
|
|
1520
|
-
// Inline the nested Drizzle results, columns only — no synthesized id.
|
|
1521
|
-
return results.map((row: Record<string, unknown>) => {
|
|
1522
|
-
const flat: Record<string, unknown> = {};
|
|
1523
|
-
for (const [k, v] of Object.entries(row)) {
|
|
1524
|
-
if (Array.isArray(v)) {
|
|
1525
|
-
// Many relation — inline each nested row
|
|
1526
|
-
flat[k] = v.map((item: Record<string, unknown>) => {
|
|
1527
|
-
// Junction table rows may have the target nested, unwrap those
|
|
1528
|
-
const keys = Object.keys(item);
|
|
1529
|
-
const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
|
|
1530
|
-
if (nestedObj && keys.length <= 3) {
|
|
1531
|
-
return { ...(item[nestedObj] as Record<string, unknown>) };
|
|
1532
|
-
}
|
|
1533
|
-
return { ...item };
|
|
1534
|
-
});
|
|
1535
|
-
} else if (typeof v === "object" && v !== null) {
|
|
1536
|
-
// One-to-one relation — inline the target's columns
|
|
1537
|
-
flat[k] = { ...(v as Record<string, unknown>) };
|
|
1538
|
-
} else {
|
|
1539
|
-
flat[k] = v;
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
return flat;
|
|
1543
|
-
});
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
|
|
1546
|
-
return null;
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
1688
|
/**
|
|
1551
1689
|
* Fallback path used when db.query is unavailable.
|
|
1552
1690
|
* The primary path uses db.query.findMany with `with` config, which
|