@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b

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 (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -1,14 +1,21 @@
1
1
  import { and, eq, or, sql, SQL, ilike, inArray, getTableColumns } from "drizzle-orm";
2
- import { AnyPgColumn, PgTable, PgVarchar, PgText, PgChar } from "drizzle-orm/pg-core";
2
+ import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import {
4
4
  CollectionConfig, FilterValues, WhereFilterOp, JoinStep, LogicalCondition, FilterCondition,
5
5
  ResolvedRelation, ResolvedBelongsTo, ResolvedHasOne, ResolvedHasMany,
6
6
  ResolvedForeignKeyOnTarget, ResolvedManyToMany, hasForeignKeyOnTarget, isManyToMany
7
7
  } from "@rebasepro/types";
8
8
  import {
9
- getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations
9
+ getColumnName, getTableName, normalizeToEntityRelation, resolveCollectionRelations, toFilterTuples
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";
@@ -28,6 +35,69 @@ import { getColumnMeta } from "../services/collection-helpers";
28
35
  */
29
36
  export type UnknownFilterFieldsMode = "error" | "warn";
30
37
 
38
+ /**
39
+ * A user's search term, made safe to drop inside a `%…%` LIKE pattern.
40
+ *
41
+ * The term is already a bind parameter, so this is not about injection. It is
42
+ * about the two things a LIKE metacharacter does when it arrives from a search
43
+ * box:
44
+ *
45
+ * 1. **It changes the query.** `%` and `_` are wildcards, so searching for
46
+ * `50%` returned every row and `a_c` matched `abc`. Nothing the caller
47
+ * could type would find a literal `%`.
48
+ * 2. **It is a cost the caller chooses.** Postgres matches LIKE by
49
+ * backtracking: each `%` re-tries every remaining offset, so
50
+ * `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
51
+ * exponent — evaluated per row, OR-ed across every string property of the
52
+ * collection, on a sequential scan (a leading `%` cannot use an index), and
53
+ * the page limit does not bound it because the scan happens first.
54
+ *
55
+ * This is the server-side half of the pattern `like-pattern-redos.test.ts`
56
+ * hardened the offline evaluator against; that test's own note ("the same
57
+ * translation in the Mongo driver hands the expression to the database, where
58
+ * it occupies a server thread instead") describes this call site.
59
+ *
60
+ * Backslash is the default `ESCAPE` character for LIKE, and the pattern is
61
+ * bound rather than interpolated, so a single backslash here reaches the
62
+ * matcher as one. Escaping the escape character first is what keeps a term
63
+ * ending in `\` from swallowing the closing `%`.
64
+ *
65
+ * Note this is a *substring search*, not the `like` filter operator: a caller
66
+ * who wants wildcards has `?title=like.foo%` for that, where the pattern is the
67
+ * documented input.
68
+ */
69
+ export const escapeLikePattern = (value: string): string =>
70
+ value.replace(/[\\%_]/g, ch => `\\${ch}`);
71
+
72
+ /** Column types `ILIKE '%…%'` is defined on. */
73
+ const ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
74
+
75
+ /**
76
+ * Can this column be matched with `ILIKE`?
77
+ *
78
+ * Asked of the column's *declared SQL type*, never with `instanceof`. The
79
+ * previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
80
+ * and `instanceof` compares class identity: it is only true when the column was
81
+ * constructed by the very same copy of `drizzle-orm` that this module imported.
82
+ *
83
+ * An application's generated schema builds its tables with the app's own
84
+ * `drizzle-orm`, and this driver declares its own dependency on one. When the
85
+ * two ranges do not overlap — an app scaffolded against `^0.44` with a driver
86
+ * asking for `^0.45` — a strict installer gives the driver a second copy, every
87
+ * check returns false, no condition is produced, and the caller compiles that
88
+ * into an impossible `WHERE`. The result is a 200 with an empty page for every
89
+ * search on every collection without a `search` block: the failure looks
90
+ * exactly like "nothing matched". Observed in production, not theorised.
91
+ *
92
+ * `getSQLType()` is a value the column reports about itself, so it crosses
93
+ * module instances the way a class identity cannot. It also happens to fix
94
+ * `citext`, which the `instanceof` list never covered.
95
+ */
96
+ const supportsILike = (column: AnyPgColumn): boolean => {
97
+ const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
98
+ return ILIKE_SQL_TYPES.test(sqlType);
99
+ };
100
+
31
101
  /**
32
102
  * Process-wide default, set once when the driver is constructed.
33
103
  *
@@ -465,11 +535,9 @@ export class DrizzleConditionBuilder {
465
535
  const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
466
536
  if (!target) continue;
467
537
 
468
- const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0])
469
- ? (filterParam as [WhereFilterOp, any][])
470
- : [filterParam as [WhereFilterOp, any]];
471
-
472
- for (const [op, value] of paramsList) {
538
+ // One tuple or an array of them the grammar, read the same way by
539
+ // every compiler. See `toFilterTuples`.
540
+ for (const [op, value] of toFilterTuples(filterParam)) {
473
541
  const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
474
542
  if (condition) {
475
543
  conditions.push(condition);
@@ -1275,38 +1343,261 @@ whereConditions };
1275
1343
  }
1276
1344
 
1277
1345
  /**
1278
- * Build search conditions for text fields
1346
+ * Build search conditions for text fields.
1347
+ *
1348
+ * Two shapes, chosen by whether the collection declared a `search` block:
1349
+ *
1350
+ * - **Declared** — one `@@ websearch_to_tsquery` against the generated
1351
+ * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
1352
+ * inside JSONB and arrays, and uses the GIN index.
1353
+ * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
1354
+ * string properties, with the term escaped (see {@link escapeLikePattern})
1355
+ * so it is matched as the literal text the user typed.
1356
+ *
1357
+ * The second is the default and stays the default. A collection that has
1358
+ * not opted in compiles to exactly the SQL it compiled to before this
1359
+ * branch existed, which is the only reason it is safe to have added it.
1360
+ *
1361
+ * `collection` is optional so that the callers which genuinely have no
1362
+ * collection in hand — nested paths, derived views — keep working; without
1363
+ * one there is no `search` block to read and the ILIKE path is correct.
1279
1364
  */
1280
1365
  static buildSearchConditions(
1281
1366
  searchString: string,
1282
1367
  properties: Record<string, unknown>,
1283
- table: PgTable<any>
1368
+ table: PgTable<any>,
1369
+ collection?: CollectionConfig
1284
1370
  ): SQL[] {
1285
1371
  const searchConditions: SQL[] = [];
1286
1372
 
1373
+ const ftsCondition = collection
1374
+ ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection)
1375
+ : undefined;
1376
+ if (ftsCondition) return [ftsCondition];
1377
+
1378
+ let declaredStringProperties = 0;
1379
+
1287
1380
  for (const [key, prop] of Object.entries(properties)) {
1288
1381
  const p = prop as Record<string, unknown>;
1289
1382
  // Only include string properties that don't have enum defined
1290
1383
  // PostgreSQL enum and uuid columns don't support ILIKE, so we skip them
1291
1384
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1385
+ declaredStringProperties++;
1292
1386
  const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
1293
- if (fieldColumn) {
1294
- // Verify that the underlying database column supports string pattern-matching
1295
- const supportsILike =
1296
- fieldColumn instanceof PgVarchar ||
1297
- fieldColumn instanceof PgText ||
1298
- fieldColumn instanceof PgChar ||
1299
- (fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn));
1300
- if (supportsILike) {
1301
- searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
1302
- }
1387
+ if (fieldColumn && supportsILike(fieldColumn)) {
1388
+ searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
1303
1389
  }
1304
1390
  }
1305
1391
  }
1306
1392
 
1393
+ // Every string property was rejected, so the caller is about to turn an
1394
+ // empty condition list into "match nothing" — a 200 with an empty page,
1395
+ // which reads as "no such row" rather than as the breakage it is. Say so
1396
+ // once per query: this is how the `instanceof` version of
1397
+ // {@link supportsILike} failed silently in the field for months.
1398
+ if (declaredStringProperties > 0 && searchConditions.length === 0) {
1399
+ logger.warn(
1400
+ `[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string ` +
1401
+ "property(ies) but none compiled to a searchable column, so this search can only return nothing. " +
1402
+ "Check that the generated schema's column types are text/varchar/char."
1403
+ );
1404
+ }
1405
+
1307
1406
  return searchConditions;
1308
1407
  }
1309
1408
 
1409
+ /**
1410
+ * The `@@` predicate for a collection that declared a `search` block, or
1411
+ * undefined for one that did not.
1412
+ *
1413
+ * The query is normalized exactly as the indexed content was — same text
1414
+ * search configuration, same accent folding. Skipping that on the query
1415
+ * side is the subtle way to get a search that matches nothing: the column
1416
+ * would hold `gestion` while the query asked for `gestión`.
1417
+ *
1418
+ * `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
1419
+ * one that behaves the way a search box looks like it should — quoted
1420
+ * phrases, `or`, and a leading `-` to exclude — and because it never throws
1421
+ * on user input, which `to_tsquery` does on so much as a stray parenthesis.
1422
+ */
1423
+ static buildFullTextCondition(
1424
+ searchString: string,
1425
+ table: PgTable<any>,
1426
+ collection: CollectionConfig
1427
+ ): SQL | undefined {
1428
+ let spec: SearchColumnSpec | undefined;
1429
+ try {
1430
+ spec = buildSearchColumnSpec(collection);
1431
+ } catch {
1432
+ // Reported at boot. Falling back to ILIKE here keeps reads serving.
1433
+ return undefined;
1434
+ }
1435
+ if (!spec) return undefined;
1436
+
1437
+ const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
1438
+ if (!column) {
1439
+ // The block is declared but the column is not on the table yet —
1440
+ // a database that has not been migrated. ILIKE still answers.
1441
+ return undefined;
1442
+ }
1443
+
1444
+ const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
1445
+ const exact = sql`${column} @@ ${query}`;
1446
+
1447
+ if (!spec.fuzzy) return exact;
1448
+
1449
+ const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
1450
+ if (!fuzzyColumn) return exact;
1451
+
1452
+ const needle = spec.unaccent
1453
+ ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
1454
+ : sql`${searchString}`;
1455
+
1456
+ // `word_similarity(query, document)`, not `similarity`. `similarity`
1457
+ // scores two strings as wholes, so a short query against a whole row's
1458
+ // text scores near zero however well it matches part of it — measured:
1459
+ // "iso 14001 auditor" against one candidate's concatenated fields
1460
+ // scores 0.228 by `similarity` and 0.783 by `word_similarity`. The
1461
+ // first is below any usable threshold, which would have made `fuzzy`
1462
+ // a setting that quietly did nothing.
1463
+ //
1464
+ // Argument order matters: the first operand is the needle, and the
1465
+ // score is its similarity to the best-matching extent of the second.
1466
+ //
1467
+ // Both the function and the operator are schema-qualified: pg_trgm is
1468
+ // installed into `public`, and an unqualified reference resolves
1469
+ // through `search_path`, which does not necessarily reach it.
1470
+ const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
1471
+ // `<%` is the index-backed form, but it tests against the session's
1472
+ // `pg_trgm.word_similarity_threshold` (0.6), not ours. Above that
1473
+ // default the operator narrows using the trigram index and the explicit
1474
+ // score refines; at or below it, the operator would exclude rows the
1475
+ // declared threshold admits, so the score stands alone and the planner
1476
+ // scans — correct either way, and only the faster path is conditional.
1477
+ const fuzzy = spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT
1478
+ ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})`
1479
+ : similar;
1480
+
1481
+ return sql`(${exact} OR ${fuzzy})`;
1482
+ }
1483
+
1484
+ /**
1485
+ * `websearch_to_tsquery(<config>, <normalized search string>)`.
1486
+ *
1487
+ * Split out because the ranking expression needs the identical query — a
1488
+ * row ranked against a different tsquery than it was matched against is a
1489
+ * ranking of something else.
1490
+ */
1491
+ static normalizedTsQuery(searchString: string, spec: SearchColumnSpec): SQL {
1492
+ const normalized = spec.unaccent
1493
+ ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
1494
+ : sql`${searchString}`;
1495
+ return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
1496
+ }
1497
+
1498
+ /**
1499
+ * A JSONB array of `{ field, snippet }` naming which declared fields matched
1500
+ * and showing the text around each hit — what backs `_matches`.
1501
+ *
1502
+ * A ranked list answers "which rows", never "why this row". For a talent
1503
+ * pool that difference is the product: a candidate surfacing for
1504
+ * "iso 14001" on a *certification* is a different candidate from one whose
1505
+ * bio happens to mention the standard, and the score cannot tell them apart.
1506
+ *
1507
+ * Built as a correlated subquery over a `VALUES` list of the declared
1508
+ * fields, rather than one `CASE` per field, so the shape does not change
1509
+ * with the number of fields and the empty result is a plain `[]`.
1510
+ *
1511
+ * `ts_headline` runs over the same normalized text that was indexed. Over
1512
+ * the *original* text it would find nothing to mark whenever `unaccent` is
1513
+ * on — the query's lexemes are folded and the document's are not — and
1514
+ * would return the text silently unhighlighted. Folded-but-marked beats
1515
+ * pretty-but-inert.
1516
+ *
1517
+ * Undefined when the collection has not opted in, when the column is not on
1518
+ * the table yet, or when the caller did not ask: this costs a `ts_headline`
1519
+ * per field per row and `ts_headline` re-parses the document.
1520
+ */
1521
+ static buildSearchMatchesExpression(
1522
+ searchString: string,
1523
+ table: PgTable<any>,
1524
+ collection: CollectionConfig
1525
+ ): SQL | undefined {
1526
+ let spec: SearchColumnSpec | undefined;
1527
+ try {
1528
+ spec = buildSearchColumnSpec(collection);
1529
+ } catch {
1530
+ return undefined;
1531
+ }
1532
+ if (!spec || spec.fields.length === 0) return undefined;
1533
+ if (!table[spec.column as keyof typeof table]) return undefined;
1534
+
1535
+ const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
1536
+ const config = sql`${spec.language}`;
1537
+
1538
+ // `ord` keeps the author's declared field order in the output, so the
1539
+ // most important field they named reads first rather than whichever
1540
+ // Postgres aggregated first.
1541
+ const rows = spec.fields.map((f, i) =>
1542
+ sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`
1543
+ );
1544
+
1545
+ return sql`(
1546
+ SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
1547
+ FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
1548
+ CROSS JOIN LATERAL (
1549
+ SELECT jsonb_build_object(
1550
+ 'field', f.path,
1551
+ 'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
1552
+ 'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
1553
+ ) AS m
1554
+ WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
1555
+ ) s
1556
+ )`;
1557
+ }
1558
+
1559
+ /**
1560
+ * `ts_rank(<column>, <query>)` for the collection, or undefined when it has
1561
+ * not opted in. This is what backs `orderBy: ["_score", "desc"]`.
1562
+ */
1563
+ static buildSearchRankExpression(
1564
+ searchString: string,
1565
+ table: PgTable<any>,
1566
+ collection: CollectionConfig
1567
+ ): SQL | undefined {
1568
+ let spec: SearchColumnSpec | undefined;
1569
+ try {
1570
+ spec = buildSearchColumnSpec(collection);
1571
+ } catch {
1572
+ return undefined;
1573
+ }
1574
+ if (!spec) return undefined;
1575
+ const column = table[spec.column as keyof typeof table] as AnyPgColumn | undefined;
1576
+ if (!column) return undefined;
1577
+
1578
+ const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1579
+ if (!spec.fuzzy) return rank;
1580
+
1581
+ const fuzzyColumn = table[spec.fuzzy.column as keyof typeof table] as AnyPgColumn | undefined;
1582
+ if (!fuzzyColumn) return rank;
1583
+
1584
+ // With `fuzzy` on, `ts_rank` alone is not a ranking — it is zero for
1585
+ // every row the trigram path matched and the exact path did not, which
1586
+ // is the whole population of a typo'd query. Measured on the real
1587
+ // sustentalent pool: "auditor de iso14000" matches four candidates,
1588
+ // every one of them at ts_rank 0, so ordering by rank alone returned
1589
+ // the best match in whatever order the table felt like.
1590
+ //
1591
+ // Summed rather than blended with tuned constants: a row that matched
1592
+ // exactly contributes both terms, so it outranks a fuzzy-only row of
1593
+ // equal similarity without needing a coefficient to say so. Both terms
1594
+ // are non-negative and monotonic, which is all the ordering needs.
1595
+ const needle = spec.unaccent
1596
+ ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})`
1597
+ : sql`${searchString}`;
1598
+ return sql`(${rank} + public.word_similarity(${needle}, ${fuzzyColumn}))`;
1599
+ }
1600
+
1310
1601
  /**
1311
1602
  * Build a unique field check condition
1312
1603
  */
@@ -1538,6 +1829,16 @@ whereConditions };
1538
1829
  * - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
1539
1830
  * - `filter`: optional WHERE clause for distance threshold
1540
1831
  * - `distanceSelect`: SQL expression for selecting the distance as `_distance`
1832
+ *
1833
+ * `property` is `?vector_search=` off the querystring, so it is an untrusted
1834
+ * *name*, and it used to be looked up straight in the drizzle table object.
1835
+ * Two ways that went wrong, both answering 500 to a malformed request:
1836
+ * `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
1837
+ * database rejects with "operator does not exist"; and a table object also
1838
+ * carries non-column keys (`_`, methods), which passed the `if (!column)`
1839
+ * guard and compiled to nonsense. The name is resolved against the table's
1840
+ * actual columns and required to be a `vector` — anything else is the
1841
+ * caller's mistake and gets a 400 that says so.
1541
1842
  */
1542
1843
  static buildVectorSearchConditions(
1543
1844
  table: PgTable<any>,
@@ -1548,10 +1849,7 @@ whereConditions };
1548
1849
  threshold?: number;
1549
1850
  }
1550
1851
  ): { orderBy: SQL; filter?: SQL; distanceSelect: SQL } {
1551
- const column = table[vectorSearch.property as keyof typeof table] as AnyPgColumn;
1552
- if (!column) {
1553
- throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
1554
- }
1852
+ const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
1555
1853
 
1556
1854
  // The vector is interpolated as a raw SQL literal below (pgvector has no
1557
1855
  // bind form for the `::vector` cast), so every element must be a finite
@@ -1592,8 +1890,52 @@ whereConditions };
1592
1890
  distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
1593
1891
  };
1594
1892
  }
1893
+
1894
+ /**
1895
+ * The `vector` column a request named, or a 400 explaining what it named.
1896
+ *
1897
+ * `getTableColumns` rather than a key lookup: it returns only the columns,
1898
+ * so `_`, `getSQL` and every other property of a drizzle table stop looking
1899
+ * like candidates. The type check is on the *physical* column
1900
+ * (`vector(1536)`) rather than on the declared property, so it holds for an
1901
+ * introspected collection too, where the property carries no Rebase type.
1902
+ */
1903
+ private static resolveVectorColumn(table: PgTable<any>, property: string): AnyPgColumn {
1904
+ const columns = getTableColumns(table) as Record<string, AnyPgColumn> | undefined;
1905
+ const column = columns?.[property];
1906
+ if (!column) {
1907
+ const known = Object.entries(columns ?? {})
1908
+ .filter(([, c]) => isVectorColumn(c))
1909
+ .map(([name]) => name);
1910
+ throw ApiError.badRequest(
1911
+ `Unknown vector property "${property}". ` +
1912
+ (known.length > 0
1913
+ ? `This collection's vector properties are: ${known.join(", ")}.`
1914
+ : "This collection declares no `vector` property to search."),
1915
+ "UNKNOWN_VECTOR_PROPERTY"
1916
+ );
1917
+ }
1918
+ if (!isVectorColumn(column)) {
1919
+ throw ApiError.badRequest(
1920
+ `Property "${property}" is not a vector column (it is \`${columnSqlType(column) || "unknown"}\`), ` +
1921
+ "so it has no distance operator. Name the property declared as `{ type: \"vector\" }`.",
1922
+ "UNKNOWN_VECTOR_PROPERTY"
1923
+ );
1924
+ }
1925
+ return column;
1926
+ }
1595
1927
  }
1596
1928
 
1929
+ /** The column's SQL type, for a value that may not be a drizzle column at all. */
1930
+ const columnSqlType = (column: unknown): string => {
1931
+ const getSQLType = (column as { getSQLType?: () => string })?.getSQLType;
1932
+ return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
1933
+ };
1934
+
1935
+ /** True for `vector(1536)` and its pgvector siblings, whatever the width. */
1936
+ const isVectorColumn = (column: unknown): boolean =>
1937
+ /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
1938
+
1597
1939
  /**
1598
1940
  * Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
1599
1941
  * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
@@ -314,9 +314,14 @@ export function sanitizeErrorForClient(error: unknown, context: string): { messa
314
314
  column: pgError.column,
315
315
  table: pgError.table,
316
316
  constraint: pgError.constraint,
317
- dataType: pgError.dataType,
318
- // Also log the outer Drizzle wrapper message for full context
319
- drizzleMessage: error instanceof Error ? error.message : String(error)
317
+ dataType: pgError.dataType
318
+ // The outer Drizzle wrapper message used to be logged here "for
319
+ // full context": it is `Failed query: <sql>\nparams: <values>`, so
320
+ // it published the statement and every bound value (an email, a
321
+ // password hash) on every realtime data failure. The SQLSTATE,
322
+ // detail, table, column and constraint above are the diagnostic
323
+ // value; the wrapper added only the leak. `logger` strips the
324
+ // wrapper as well, but the field itself carried nothing else.
320
325
  });
321
326
  return pgErrorToFriendlyMessage(pgError, context);
322
327
  }