@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g249daa1
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/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
- package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
- package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
- package/dist/index.es.js +765 -223
- package/dist/index.es.js.map +1 -1
- package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/ensure-collection-tables.d.ts +2 -2
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +199 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- package/dist/services/FetchService.d.ts +25 -7
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/realtimeService.d.ts +27 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
- package/dist/websocket-D0TBU3ia.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +165 -3
- package/src/PostgresBootstrapper.ts +41 -2
- package/src/auth/ensure-tables.ts +185 -86
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +31 -3
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-tables.ts +165 -20
- package/src/schema/generate-drizzle-schema-logic.ts +33 -3
- package/src/schema/generate-postgres-ddl-logic.ts +266 -15
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +1 -1
- 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 +558 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +148 -108
- package/src/services/PersistService.ts +14 -1
- package/src/services/RelationService.ts +2 -1
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- package/src/services/dataService.ts +3 -0
- package/src/services/realtimeService.ts +46 -37
- package/src/utils/drizzle-conditions.ts +223 -2
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- 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
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
import { sql } from "drizzle-orm";
|
|
30
30
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
31
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
31
32
|
|
|
32
33
|
/** A tracked client, as any instance sees it. */
|
|
33
34
|
export interface PresenceRow {
|
|
@@ -71,6 +72,11 @@ export class ChannelPresenceStore {
|
|
|
71
72
|
ON rebase.channel_presence (last_seen)
|
|
72
73
|
`);
|
|
73
74
|
|
|
75
|
+
// The roster of every client on every channel, with no RLS — presence
|
|
76
|
+
// authorization is a channel rule, not a row policy. Revoke the
|
|
77
|
+
// schema-wide grant the driver handed out before this table existed.
|
|
78
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
|
|
79
|
+
|
|
74
80
|
this.tablesReady = true;
|
|
75
81
|
}
|
|
76
82
|
|
|
@@ -60,6 +60,8 @@ export class DataService implements DataRepository {
|
|
|
60
60
|
collectionPath: string,
|
|
61
61
|
options: {
|
|
62
62
|
filter?: FilterValues<Extract<keyof M, string>>;
|
|
63
|
+
/** An `or(...)`/`and(...)` group, applied alongside `filter`. */
|
|
64
|
+
logical?: LogicalCondition;
|
|
63
65
|
orderBy?: string;
|
|
64
66
|
order?: "desc" | "asc";
|
|
65
67
|
limit?: number;
|
|
@@ -85,6 +87,7 @@ export class DataService implements DataRepository {
|
|
|
85
87
|
order?: "desc" | "asc";
|
|
86
88
|
limit?: number;
|
|
87
89
|
databaseId?: string;
|
|
90
|
+
searchExplain?: boolean;
|
|
88
91
|
} = {}
|
|
89
92
|
): Promise<Record<string, unknown>[]> {
|
|
90
93
|
return this.fetchService.searchRows<M>(collectionPath, searchString, options);
|
|
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { DataService } from "./dataService";
|
|
6
6
|
|
|
7
|
-
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
7
|
+
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
10
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
@@ -41,6 +41,30 @@ type RealTimeListenCollectionProps = ListenCollectionProps & {
|
|
|
41
41
|
subscriptionId: string
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The narrowing a collection subscription was created with, kept so that every
|
|
46
|
+
* refetch answers the same query the initial fetch did.
|
|
47
|
+
*
|
|
48
|
+
* Named once because it used to be written out inline in five places, and a
|
|
49
|
+
* field missing from one of them is accepted over the wire and then silently
|
|
50
|
+
* ignored: `offset` was declared on the incoming props and never stored, so a
|
|
51
|
+
* live list on page three served page one, and `logical` was never stored
|
|
52
|
+
* either, so an `or(...)` subscription was pushed every row in the table.
|
|
53
|
+
*/
|
|
54
|
+
type StoredCollectionRequest = {
|
|
55
|
+
filter?: Record<string, unknown>;
|
|
56
|
+
logical?: LogicalCondition;
|
|
57
|
+
orderBy?: string;
|
|
58
|
+
order?: "desc" | "asc";
|
|
59
|
+
limit?: number;
|
|
60
|
+
offset?: number;
|
|
61
|
+
startAfter?: Record<string, unknown>;
|
|
62
|
+
databaseId?: string;
|
|
63
|
+
searchString?: string;
|
|
64
|
+
/** Ask each row which declared search field matched — populates `_matches`. */
|
|
65
|
+
searchExplain?: boolean;
|
|
66
|
+
};
|
|
67
|
+
|
|
44
68
|
type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
|
|
45
69
|
|
|
46
70
|
/**
|
|
@@ -119,16 +143,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
119
143
|
path: string;
|
|
120
144
|
id?: string | number;
|
|
121
145
|
// Store full collection request parameters for proper refetching
|
|
122
|
-
collectionRequest?:
|
|
123
|
-
filter?: Record<string, unknown>;
|
|
124
|
-
orderBy?: string;
|
|
125
|
-
order?: "desc" | "asc";
|
|
126
|
-
limit?: number;
|
|
127
|
-
offset?: number;
|
|
128
|
-
startAfter?: Record<string, unknown>;
|
|
129
|
-
databaseId?: string;
|
|
130
|
-
searchString?: string;
|
|
131
|
-
};
|
|
146
|
+
collectionRequest?: StoredCollectionRequest;
|
|
132
147
|
// Auth context for RLS — when set, refetches run in a transaction
|
|
133
148
|
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
134
149
|
authContext?: SubscriptionAuthContext;
|
|
@@ -212,16 +227,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
212
227
|
type: "collection" | "single";
|
|
213
228
|
path: string;
|
|
214
229
|
id?: string | number;
|
|
215
|
-
collectionRequest?:
|
|
216
|
-
filter?: Record<string, unknown>;
|
|
217
|
-
orderBy?: string;
|
|
218
|
-
order?: "desc" | "asc";
|
|
219
|
-
limit?: number;
|
|
220
|
-
offset?: number;
|
|
221
|
-
startAfter?: Record<string, unknown>;
|
|
222
|
-
databaseId?: string;
|
|
223
|
-
searchString?: string;
|
|
224
|
-
};
|
|
230
|
+
collectionRequest?: StoredCollectionRequest;
|
|
225
231
|
authContext?: SubscriptionAuthContext;
|
|
226
232
|
}) {
|
|
227
233
|
this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
|
|
@@ -262,7 +268,8 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
262
268
|
limit: config.limit,
|
|
263
269
|
startAfter: config.startAfter as Record<string, unknown> | undefined,
|
|
264
270
|
databaseId: config.databaseId,
|
|
265
|
-
searchString: config.searchString
|
|
271
|
+
searchString: config.searchString,
|
|
272
|
+
searchExplain: config.searchExplain
|
|
266
273
|
}
|
|
267
274
|
});
|
|
268
275
|
|
|
@@ -448,27 +455,25 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
448
455
|
path: request.path,
|
|
449
456
|
collectionRequest: {
|
|
450
457
|
filter: request.filter,
|
|
458
|
+
logical: request.logical,
|
|
451
459
|
orderBy: request.orderBy,
|
|
452
460
|
order: request.order,
|
|
453
461
|
limit: boundedLimit,
|
|
462
|
+
offset: request.offset,
|
|
454
463
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
455
464
|
databaseId: request.collection?.databaseId,
|
|
456
|
-
searchString: request.searchString
|
|
465
|
+
searchString: request.searchString,
|
|
466
|
+
searchExplain: request.searchExplain
|
|
457
467
|
},
|
|
458
468
|
authContext
|
|
459
469
|
});
|
|
460
470
|
|
|
461
|
-
// Send initial data
|
|
471
|
+
// Send initial data. Built from the request the subscription just
|
|
472
|
+
// stored, so the first answer and every refetch after it cannot
|
|
473
|
+
// describe different queries.
|
|
462
474
|
const rows = await this.fetchCollectionWithAuth(
|
|
463
475
|
request.path,
|
|
464
|
-
|
|
465
|
-
filter: request.filter,
|
|
466
|
-
orderBy: request.orderBy,
|
|
467
|
-
order: request.order,
|
|
468
|
-
limit: boundedLimit,
|
|
469
|
-
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
470
|
-
searchString: request.searchString
|
|
471
|
-
},
|
|
476
|
+
this._subscriptions.get(subscriptionId)!.collectionRequest!,
|
|
472
477
|
authContext
|
|
473
478
|
);
|
|
474
479
|
|
|
@@ -679,7 +684,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
679
684
|
private debouncedCollectionRefetch(
|
|
680
685
|
subscriptionId: string,
|
|
681
686
|
notifyPath: string,
|
|
682
|
-
subscription: { clientId: string; collectionRequest?:
|
|
687
|
+
subscription: { clientId: string; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext }
|
|
683
688
|
) {
|
|
684
689
|
const timerKey = `ws_${subscriptionId}`;
|
|
685
690
|
const existing = this.refetchTimers.get(timerKey);
|
|
@@ -705,7 +710,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
705
710
|
private debouncedDriverRefetch(
|
|
706
711
|
subscriptionId: string,
|
|
707
712
|
notifyPath: string,
|
|
708
|
-
subscription: { collectionRequest?:
|
|
713
|
+
subscription: { collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext },
|
|
709
714
|
callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
|
|
710
715
|
) {
|
|
711
716
|
const timerKey = `drv_${subscriptionId}`;
|
|
@@ -731,7 +736,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
731
736
|
*/
|
|
732
737
|
private async fetchCollectionWithAuth(
|
|
733
738
|
notifyPath: string,
|
|
734
|
-
collectionRequest:
|
|
739
|
+
collectionRequest: StoredCollectionRequest,
|
|
735
740
|
authContext?: SubscriptionAuthContext
|
|
736
741
|
): Promise<Record<string, unknown>[]> {
|
|
737
742
|
if (this.driver) {
|
|
@@ -740,12 +745,14 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
740
745
|
path: notifyPath,
|
|
741
746
|
collection: collection,
|
|
742
747
|
filter: collectionRequest.filter as FetchCollectionProps["filter"],
|
|
748
|
+
logical: collectionRequest.logical,
|
|
743
749
|
orderBy: collectionRequest.orderBy,
|
|
744
750
|
order: collectionRequest.order,
|
|
745
751
|
limit: collectionRequest.limit,
|
|
746
752
|
offset: collectionRequest.offset,
|
|
747
753
|
startAfter: collectionRequest.startAfter,
|
|
748
|
-
searchString: collectionRequest.searchString
|
|
754
|
+
searchString: collectionRequest.searchString,
|
|
755
|
+
searchExplain: collectionRequest.searchExplain
|
|
749
756
|
});
|
|
750
757
|
|
|
751
758
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
@@ -766,12 +773,14 @@ roles: ["anon"] };
|
|
|
766
773
|
orderBy: collectionRequest.orderBy,
|
|
767
774
|
order: collectionRequest.order,
|
|
768
775
|
limit: collectionRequest.limit,
|
|
769
|
-
databaseId: collectionRequest.databaseId
|
|
776
|
+
databaseId: collectionRequest.databaseId,
|
|
777
|
+
searchExplain: collectionRequest.searchExplain
|
|
770
778
|
}
|
|
771
779
|
);
|
|
772
780
|
} else {
|
|
773
781
|
fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
774
782
|
filter: collectionRequest.filter as FilterValues<string>,
|
|
783
|
+
logical: collectionRequest.logical,
|
|
775
784
|
orderBy: collectionRequest.orderBy,
|
|
776
785
|
order: collectionRequest.order,
|
|
777
786
|
limit: collectionRequest.limit,
|
|
@@ -9,6 +9,13 @@ import {
|
|
|
9
9
|
getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations
|
|
10
10
|
} from "@rebasepro/common";
|
|
11
11
|
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
12
|
+
/**
|
|
13
|
+
* Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
|
|
14
|
+
* because the fuzzy predicate has to know when the index-backed operator agrees
|
|
15
|
+
* with the collection's declared threshold and when it would narrow too far.
|
|
16
|
+
*/
|
|
17
|
+
const PG_TRGM_WORD_SIMILARITY_DEFAULT = 0.6;
|
|
18
|
+
import { buildSearchColumnSpec, SEARCH_UNACCENT_FN, type SearchColumnSpec } from "../schema/search-column";
|
|
12
19
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
13
20
|
import { ConditionBuilderStatic } from "../interfaces";
|
|
14
21
|
import { ApiError, logger } from "@rebasepro/server";
|
|
@@ -1275,15 +1282,37 @@ whereConditions };
|
|
|
1275
1282
|
}
|
|
1276
1283
|
|
|
1277
1284
|
/**
|
|
1278
|
-
* Build search conditions for text fields
|
|
1285
|
+
* Build search conditions for text fields.
|
|
1286
|
+
*
|
|
1287
|
+
* Two shapes, chosen by whether the collection declared a `search` block:
|
|
1288
|
+
*
|
|
1289
|
+
* - **Declared** — one `@@ websearch_to_tsquery` against the generated
|
|
1290
|
+
* `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
|
|
1291
|
+
* inside JSONB and arrays, and uses the GIN index.
|
|
1292
|
+
* - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
|
|
1293
|
+
* string properties, unchanged.
|
|
1294
|
+
*
|
|
1295
|
+
* The second is the default and stays the default. A collection that has
|
|
1296
|
+
* not opted in compiles to exactly the SQL it compiled to before this
|
|
1297
|
+
* branch existed, which is the only reason it is safe to have added it.
|
|
1298
|
+
*
|
|
1299
|
+
* `collection` is optional so that the callers which genuinely have no
|
|
1300
|
+
* collection in hand — nested paths, derived views — keep working; without
|
|
1301
|
+
* one there is no `search` block to read and the ILIKE path is correct.
|
|
1279
1302
|
*/
|
|
1280
1303
|
static buildSearchConditions(
|
|
1281
1304
|
searchString: string,
|
|
1282
1305
|
properties: Record<string, unknown>,
|
|
1283
|
-
table: PgTable<any
|
|
1306
|
+
table: PgTable<any>,
|
|
1307
|
+
collection?: CollectionConfig
|
|
1284
1308
|
): SQL[] {
|
|
1285
1309
|
const searchConditions: SQL[] = [];
|
|
1286
1310
|
|
|
1311
|
+
const ftsCondition = collection
|
|
1312
|
+
? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection)
|
|
1313
|
+
: undefined;
|
|
1314
|
+
if (ftsCondition) return [ftsCondition];
|
|
1315
|
+
|
|
1287
1316
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1288
1317
|
const p = prop as Record<string, unknown>;
|
|
1289
1318
|
// Only include string properties that don't have enum defined
|
|
@@ -1307,6 +1336,198 @@ whereConditions };
|
|
|
1307
1336
|
return searchConditions;
|
|
1308
1337
|
}
|
|
1309
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* The `@@` predicate for a collection that declared a `search` block, or
|
|
1341
|
+
* undefined for one that did not.
|
|
1342
|
+
*
|
|
1343
|
+
* The query is normalized exactly as the indexed content was — same text
|
|
1344
|
+
* search configuration, same accent folding. Skipping that on the query
|
|
1345
|
+
* side is the subtle way to get a search that matches nothing: the column
|
|
1346
|
+
* would hold `gestion` while the query asked for `gestión`.
|
|
1347
|
+
*
|
|
1348
|
+
* `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
|
|
1349
|
+
* one that behaves the way a search box looks like it should — quoted
|
|
1350
|
+
* phrases, `or`, and a leading `-` to exclude — and because it never throws
|
|
1351
|
+
* on user input, which `to_tsquery` does on so much as a stray parenthesis.
|
|
1352
|
+
*/
|
|
1353
|
+
static buildFullTextCondition(
|
|
1354
|
+
searchString: string,
|
|
1355
|
+
table: PgTable<any>,
|
|
1356
|
+
collection: CollectionConfig
|
|
1357
|
+
): SQL | undefined {
|
|
1358
|
+
let spec: SearchColumnSpec | undefined;
|
|
1359
|
+
try {
|
|
1360
|
+
spec = buildSearchColumnSpec(collection);
|
|
1361
|
+
} catch {
|
|
1362
|
+
// Reported at boot. Falling back to ILIKE here keeps reads serving.
|
|
1363
|
+
return undefined;
|
|
1364
|
+
}
|
|
1365
|
+
if (!spec) return undefined;
|
|
1366
|
+
|
|
1367
|
+
const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1368
|
+
if (!column) {
|
|
1369
|
+
// The block is declared but the column is not on the table yet —
|
|
1370
|
+
// a database that has not been migrated. ILIKE still answers.
|
|
1371
|
+
return undefined;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1375
|
+
const exact = sql`${column} @@ ${query}`;
|
|
1376
|
+
|
|
1377
|
+
if (!spec.fuzzy) return exact;
|
|
1378
|
+
|
|
1379
|
+
const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1380
|
+
if (!fuzzyColumn) return exact;
|
|
1381
|
+
|
|
1382
|
+
const needle = spec.unaccent
|
|
1383
|
+
? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
|
|
1384
|
+
: sql`${searchString}`;
|
|
1385
|
+
|
|
1386
|
+
// `word_similarity(query, document)`, not `similarity`. `similarity`
|
|
1387
|
+
// scores two strings as wholes, so a short query against a whole row's
|
|
1388
|
+
// text scores near zero however well it matches part of it — measured:
|
|
1389
|
+
// "iso 14001 auditor" against one candidate's concatenated fields
|
|
1390
|
+
// scores 0.228 by `similarity` and 0.783 by `word_similarity`. The
|
|
1391
|
+
// first is below any usable threshold, which would have made `fuzzy`
|
|
1392
|
+
// a setting that quietly did nothing.
|
|
1393
|
+
//
|
|
1394
|
+
// Argument order matters: the first operand is the needle, and the
|
|
1395
|
+
// score is its similarity to the best-matching extent of the second.
|
|
1396
|
+
//
|
|
1397
|
+
// Both the function and the operator are schema-qualified: pg_trgm is
|
|
1398
|
+
// installed into `public`, and an unqualified reference resolves
|
|
1399
|
+
// through `search_path`, which does not necessarily reach it.
|
|
1400
|
+
const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
|
|
1401
|
+
// `<%` is the index-backed form, but it tests against the session's
|
|
1402
|
+
// `pg_trgm.word_similarity_threshold` (0.6), not ours. Above that
|
|
1403
|
+
// default the operator narrows using the trigram index and the explicit
|
|
1404
|
+
// score refines; at or below it, the operator would exclude rows the
|
|
1405
|
+
// declared threshold admits, so the score stands alone and the planner
|
|
1406
|
+
// scans — correct either way, and only the faster path is conditional.
|
|
1407
|
+
const fuzzy = spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT
|
|
1408
|
+
? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})`
|
|
1409
|
+
: similar;
|
|
1410
|
+
|
|
1411
|
+
return sql`(${exact} OR ${fuzzy})`;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* `websearch_to_tsquery(<config>, <normalized search string>)`.
|
|
1416
|
+
*
|
|
1417
|
+
* Split out because the ranking expression needs the identical query — a
|
|
1418
|
+
* row ranked against a different tsquery than it was matched against is a
|
|
1419
|
+
* ranking of something else.
|
|
1420
|
+
*/
|
|
1421
|
+
static normalizedTsQuery(searchString: string, spec: SearchColumnSpec): SQL {
|
|
1422
|
+
const normalized = spec.unaccent
|
|
1423
|
+
? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
|
|
1424
|
+
: sql`${searchString}`;
|
|
1425
|
+
return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* A JSONB array of `{ field, snippet }` naming which declared fields matched
|
|
1430
|
+
* and showing the text around each hit — what backs `_matches`.
|
|
1431
|
+
*
|
|
1432
|
+
* A ranked list answers "which rows", never "why this row". For a talent
|
|
1433
|
+
* pool that difference is the product: a candidate surfacing for
|
|
1434
|
+
* "iso 14001" on a *certification* is a different candidate from one whose
|
|
1435
|
+
* bio happens to mention the standard, and the score cannot tell them apart.
|
|
1436
|
+
*
|
|
1437
|
+
* Built as a correlated subquery over a `VALUES` list of the declared
|
|
1438
|
+
* fields, rather than one `CASE` per field, so the shape does not change
|
|
1439
|
+
* with the number of fields and the empty result is a plain `[]`.
|
|
1440
|
+
*
|
|
1441
|
+
* `ts_headline` runs over the same normalized text that was indexed. Over
|
|
1442
|
+
* the *original* text it would find nothing to mark whenever `unaccent` is
|
|
1443
|
+
* on — the query's lexemes are folded and the document's are not — and
|
|
1444
|
+
* would return the text silently unhighlighted. Folded-but-marked beats
|
|
1445
|
+
* pretty-but-inert.
|
|
1446
|
+
*
|
|
1447
|
+
* Undefined when the collection has not opted in, when the column is not on
|
|
1448
|
+
* the table yet, or when the caller did not ask: this costs a `ts_headline`
|
|
1449
|
+
* per field per row and `ts_headline` re-parses the document.
|
|
1450
|
+
*/
|
|
1451
|
+
static buildSearchMatchesExpression(
|
|
1452
|
+
searchString: string,
|
|
1453
|
+
table: PgTable<any>,
|
|
1454
|
+
collection: CollectionConfig
|
|
1455
|
+
): SQL | undefined {
|
|
1456
|
+
let spec: SearchColumnSpec | undefined;
|
|
1457
|
+
try {
|
|
1458
|
+
spec = buildSearchColumnSpec(collection);
|
|
1459
|
+
} catch {
|
|
1460
|
+
return undefined;
|
|
1461
|
+
}
|
|
1462
|
+
if (!spec || spec.fields.length === 0) return undefined;
|
|
1463
|
+
if (!table[spec.column as keyof typeof table]) return undefined;
|
|
1464
|
+
|
|
1465
|
+
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1466
|
+
const config = sql`${spec.language}`;
|
|
1467
|
+
|
|
1468
|
+
// `ord` keeps the author's declared field order in the output, so the
|
|
1469
|
+
// most important field they named reads first rather than whichever
|
|
1470
|
+
// Postgres aggregated first.
|
|
1471
|
+
const rows = spec.fields.map((f, i) =>
|
|
1472
|
+
sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
return sql`(
|
|
1476
|
+
SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
|
|
1477
|
+
FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
|
|
1478
|
+
CROSS JOIN LATERAL (
|
|
1479
|
+
SELECT jsonb_build_object(
|
|
1480
|
+
'field', f.path,
|
|
1481
|
+
'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
|
|
1482
|
+
'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
|
|
1483
|
+
) AS m
|
|
1484
|
+
WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
|
|
1485
|
+
) s
|
|
1486
|
+
)`;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
/**
|
|
1490
|
+
* `ts_rank(<column>, <query>)` for the collection, or undefined when it has
|
|
1491
|
+
* not opted in. This is what backs `orderBy: ["_score", "desc"]`.
|
|
1492
|
+
*/
|
|
1493
|
+
static buildSearchRankExpression(
|
|
1494
|
+
searchString: string,
|
|
1495
|
+
table: PgTable<any>,
|
|
1496
|
+
collection: CollectionConfig
|
|
1497
|
+
): SQL | undefined {
|
|
1498
|
+
let spec: SearchColumnSpec | undefined;
|
|
1499
|
+
try {
|
|
1500
|
+
spec = buildSearchColumnSpec(collection);
|
|
1501
|
+
} catch {
|
|
1502
|
+
return undefined;
|
|
1503
|
+
}
|
|
1504
|
+
if (!spec) return undefined;
|
|
1505
|
+
const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1506
|
+
if (!column) return undefined;
|
|
1507
|
+
|
|
1508
|
+
const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
|
|
1509
|
+
if (!spec.fuzzy) return rank;
|
|
1510
|
+
|
|
1511
|
+
const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
|
|
1512
|
+
if (!fuzzyColumn) return rank;
|
|
1513
|
+
|
|
1514
|
+
// With `fuzzy` on, `ts_rank` alone is not a ranking — it is zero for
|
|
1515
|
+
// every row the trigram path matched and the exact path did not, which
|
|
1516
|
+
// is the whole population of a typo'd query. Measured on the real
|
|
1517
|
+
// sustentalent pool: "auditor de iso14000" matches four candidates,
|
|
1518
|
+
// every one of them at ts_rank 0, so ordering by rank alone returned
|
|
1519
|
+
// the best match in whatever order the table felt like.
|
|
1520
|
+
//
|
|
1521
|
+
// Summed rather than blended with tuned constants: a row that matched
|
|
1522
|
+
// exactly contributes both terms, so it outranks a fuzzy-only row of
|
|
1523
|
+
// equal similarity without needing a coefficient to say so. Both terms
|
|
1524
|
+
// are non-negative and monotonic, which is all the ordering needs.
|
|
1525
|
+
const needle = spec.unaccent
|
|
1526
|
+
? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
|
|
1527
|
+
: sql`${searchString}`;
|
|
1528
|
+
return sql`(${rank} + public.word_similarity(${needle}, ${fuzzyColumn}))`;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1310
1531
|
/**
|
|
1311
1532
|
* Build a unique field check condition
|
|
1312
1533
|
*/
|