@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.
Files changed (70) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/{src-DlPBctw_.js → auth-users-columns-Dt9g712t.js} +721 -47
  3. package/dist/auth-users-columns-Dt9g712t.js.map +1 -0
  4. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  5. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  6. package/dist/cli-helpers.d.ts +57 -1
  7. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-CwYUliAa.js} +2 -2
  8. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-CwYUliAa.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DRxaUG96.js} +86 -15
  10. package/dist/ensure-collection-tables-DRxaUG96.js.map +1 -0
  11. package/dist/index.es.js +765 -223
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
  14. package/dist/policy-CPkCqVTz.js.map +1 -0
  15. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  16. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  17. package/dist/schema/auth-users-columns.d.ts +97 -0
  18. package/dist/schema/ensure-collection-tables.d.ts +2 -2
  19. package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
  20. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  21. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  22. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  23. package/dist/schema/search-column.d.ts +199 -0
  24. package/dist/security/rls-enforcement.d.ts +53 -2
  25. package/dist/services/FetchService.d.ts +25 -7
  26. package/dist/services/dataService.d.ts +3 -0
  27. package/dist/services/realtimeService.d.ts +27 -21
  28. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  29. package/dist/src-C_wvdMnl.js.map +1 -0
  30. package/dist/utils/drizzle-conditions.d.ts +71 -2
  31. package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
  32. package/dist/websocket-D0TBU3ia.js.map +1 -0
  33. package/package.json +9 -8
  34. package/src/PostgresBackendDriver.ts +165 -3
  35. package/src/PostgresBootstrapper.ts +41 -2
  36. package/src/auth/ensure-tables.ts +185 -86
  37. package/src/cli-helpers.ts +129 -10
  38. package/src/cli.ts +232 -30
  39. package/src/collections/validate-relations.ts +124 -17
  40. package/src/data-transformer.ts +31 -3
  41. package/src/history/ensure-history-table.ts +7 -0
  42. package/src/schema/auth-users-columns.ts +131 -0
  43. package/src/schema/doctor.ts +7 -5
  44. package/src/schema/ensure-collection-tables.ts +165 -20
  45. package/src/schema/generate-drizzle-schema-logic.ts +33 -3
  46. package/src/schema/generate-postgres-ddl-logic.ts +266 -15
  47. package/src/schema/generate-postgres-ddl.ts +25 -2
  48. package/src/schema/generated-schema-staleness.ts +169 -0
  49. package/src/schema/introspect-db-logic.ts +1 -1
  50. package/src/schema/non-sql-collections.test.ts +131 -0
  51. package/src/schema/rls-bootstrap-sql.ts +288 -0
  52. package/src/schema/search-column.ts +558 -0
  53. package/src/security/anonymous-grants.test.ts +4 -2
  54. package/src/security/rls-enforcement.ts +141 -3
  55. package/src/services/BranchService.ts +5 -0
  56. package/src/services/FetchService.ts +148 -108
  57. package/src/services/PersistService.ts +14 -1
  58. package/src/services/RelationService.ts +2 -1
  59. package/src/services/channel-history.ts +8 -0
  60. package/src/services/channel-presence.ts +6 -0
  61. package/src/services/dataService.ts +3 -0
  62. package/src/services/realtimeService.ts +46 -37
  63. package/src/utils/drizzle-conditions.ts +223 -2
  64. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  65. package/dist/policy-CeA1JcxP.js.map +0 -1
  66. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  67. package/dist/src-DlPBctw_.js.map +0 -1
  68. package/dist/src-DoU9yPqq.js.map +0 -1
  69. package/dist/websocket-B2LsrINK.js.map +0 -1
  70. 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?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext }
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?: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string }; authContext?: SubscriptionAuthContext },
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: { filter?: Record<string, unknown>; orderBy?: string; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record<string, unknown>; databaseId?: string; searchString?: string },
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
  */