@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 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 (120) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +40 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
  5. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +96 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/data-transformer.d.ts +7 -2
  15. package/dist/data_driver-ULAyJEi9.js +193 -0
  16. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  17. package/dist/ensure-collection-policies-B_JMGa5K.js +57 -0
  18. package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
  19. package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
  20. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  21. package/dist/index.es.js +2460 -10233
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  24. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  25. package/dist/schema/auth-schema.d.ts +185 -144
  26. package/dist/schema/auth-users-columns.d.ts +97 -0
  27. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  28. package/dist/schema/doctor.d.ts +23 -25
  29. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  30. package/dist/schema/ensure-collection-tables.d.ts +99 -3
  31. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  32. package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
  33. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  34. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  35. package/dist/schema/introspect-db-logic.d.ts +94 -5
  36. package/dist/schema/introspect-db-queries.d.ts +119 -0
  37. package/dist/schema/introspect-db-structure.d.ts +263 -0
  38. package/dist/schema/introspect-db-types.d.ts +11 -0
  39. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  40. package/dist/schema/search-column.d.ts +248 -0
  41. package/dist/security/rls-enforcement.d.ts +61 -5
  42. package/dist/services/FetchService.d.ts +38 -8
  43. package/dist/services/RelationService.d.ts +54 -1
  44. package/dist/services/channel-bus/index.d.ts +1 -7
  45. package/dist/services/collection-helpers.d.ts +62 -2
  46. package/dist/services/dataService.d.ts +8 -1
  47. package/dist/services/realtimeService.d.ts +131 -21
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  50. package/dist/src-C_wvdMnl.js.map +1 -0
  51. package/dist/utils/connection-string.d.ts +29 -0
  52. package/dist/utils/drizzle-conditions.d.ts +129 -6
  53. package/dist/utils/pg-error-utils.d.ts +35 -0
  54. package/dist/websocket-D1qbmLZ2.js +587 -0
  55. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  56. package/package.json +17 -16
  57. package/src/PostgresAdapter.ts +21 -2
  58. package/src/PostgresBackendDriver.ts +176 -6
  59. package/src/PostgresBootstrapper.ts +245 -37
  60. package/src/auth/ensure-tables.ts +357 -81
  61. package/src/auth/services.ts +93 -7
  62. package/src/backup/backup-cli.ts +41 -2
  63. package/src/backup/backup-service.ts +38 -5
  64. package/src/backup/pg-tools.ts +96 -3
  65. package/src/cli-helpers.ts +199 -10
  66. package/src/cli.ts +276 -56
  67. package/src/collections/validate-relations.ts +136 -14
  68. package/src/connection.ts +73 -0
  69. package/src/data-transformer.ts +129 -20
  70. package/src/databasePoolManager.ts +5 -2
  71. package/src/history/ensure-history-table.ts +7 -0
  72. package/src/schema/auth-schema.ts +46 -19
  73. package/src/schema/auth-users-columns.ts +131 -0
  74. package/src/schema/doctor-cli.ts +12 -63
  75. package/src/schema/doctor-policy-checks.ts +105 -0
  76. package/src/schema/doctor.ts +135 -76
  77. package/src/schema/ensure-collection-policies.ts +105 -0
  78. package/src/schema/ensure-collection-tables.test.ts +105 -9
  79. package/src/schema/ensure-collection-tables.ts +572 -42
  80. package/src/schema/generate-drizzle-schema-logic.ts +169 -43
  81. package/src/schema/generate-postgres-ddl-logic.ts +670 -29
  82. package/src/schema/generate-postgres-ddl.ts +25 -2
  83. package/src/schema/generated-schema-staleness.ts +169 -0
  84. package/src/schema/introspect-db-constraints.ts +385 -0
  85. package/src/schema/introspect-db-inference.ts +18 -8
  86. package/src/schema/introspect-db-logic.ts +446 -100
  87. package/src/schema/introspect-db-queries.ts +326 -0
  88. package/src/schema/introspect-db-structure.ts +670 -0
  89. package/src/schema/introspect-db-types.ts +56 -0
  90. package/src/schema/introspect-db.ts +37 -80
  91. package/src/schema/introspect-runtime.test.ts +56 -8
  92. package/src/schema/introspect-runtime.ts +31 -9
  93. package/src/schema/non-sql-collections.test.ts +131 -0
  94. package/src/schema/rls-bootstrap-sql.ts +288 -0
  95. package/src/schema/search-column.ts +643 -0
  96. package/src/security/anonymous-grants.test.ts +4 -2
  97. package/src/security/policy-drift.test.ts +11 -3
  98. package/src/security/rls-enforcement.ts +149 -6
  99. package/src/services/BranchService.ts +71 -28
  100. package/src/services/FetchService.ts +265 -122
  101. package/src/services/PersistService.ts +57 -7
  102. package/src/services/RelationService.ts +358 -114
  103. package/src/services/channel-bus/index.ts +0 -9
  104. package/src/services/channel-history.ts +14 -0
  105. package/src/services/channel-presence.ts +13 -0
  106. package/src/services/collection-helpers.ts +123 -4
  107. package/src/services/dataService.ts +8 -1
  108. package/src/services/realtimeService.ts +346 -81
  109. package/src/services/row-pipeline.ts +1 -1
  110. package/src/utils/connection-string.ts +58 -0
  111. package/src/utils/drizzle-conditions.ts +396 -29
  112. package/src/utils/pg-error-utils.ts +73 -3
  113. package/src/websocket.ts +130 -24
  114. package/dist/chunk-DSJWtz9O.js +0 -40
  115. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  116. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  117. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  118. package/dist/src-BbFOPJ1S.js.map +0 -1
  119. package/dist/src-Zqwaw3P5.js.map +0 -1
  120. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Connection-string rewrites for the tools Rebase shells out to.
3
+ *
4
+ * Pure and dependency-free: `pg-tools` builds argument vectors without a live
5
+ * server, and these have to be usable from there.
6
+ */
7
+
8
+ /**
9
+ * The `sslmode` values libpq accepts. `no-verify` is conspicuously not one.
10
+ */
11
+ const LIBPQ_SSLMODES = new Set(["disable", "allow", "prefer", "require", "verify-ca", "verify-full"]);
12
+
13
+ /**
14
+ * Make a connection string safe to hand to a libpq program.
15
+ *
16
+ * `sslmode=no-verify` is a node-postgres convention — encrypt, but do not check
17
+ * the certificate — and libpq does not have it. It does not degrade or warn:
18
+ *
19
+ * psql: error: invalid sslmode value: "no-verify"
20
+ *
21
+ * which means a `DATABASE_URL` that works perfectly for the app makes `psql`,
22
+ * `pg_dump`, `pg_restore` and Atlas all refuse to start, with an error that
23
+ * points at the value rather than at the convention it comes from. It has cost
24
+ * this project time more than once.
25
+ *
26
+ * `require` is the honest translation: libpq's `require` encrypts and does not
27
+ * verify the certificate either, which is exactly what `no-verify` asks for.
28
+ * Nothing is relaxed by the rewrite — `verify-ca` and `verify-full` are left
29
+ * alone, so a connection string that asked for verification still gets it.
30
+ *
31
+ * Only `sslmode` is touched, and only when it is a value libpq would reject.
32
+ * Anything unparseable is returned as given: a connection that works
33
+ * unverified beats one this corrupted.
34
+ */
35
+ export function forLibpq(connectionString: string): string {
36
+ let url: URL;
37
+ try {
38
+ url = new URL(connectionString);
39
+ } catch {
40
+ // Key/value DSNs and anything else we cannot parse. A `sslmode` in one
41
+ // of those is space-separated rather than a query parameter, and
42
+ // rewriting it with a regex is how a password containing "sslmode="
43
+ // gets mangled.
44
+ return connectionString;
45
+ }
46
+ if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return connectionString;
47
+
48
+ const sslmode = url.searchParams.get("sslmode");
49
+ if (sslmode === null || LIBPQ_SSLMODES.has(sslmode)) return connectionString;
50
+
51
+ url.searchParams.set("sslmode", "require");
52
+ // Re-serialize by hand for the reason `pinSearchPath` does: URLSearchParams
53
+ // writes a space as `+`, which node-postgres decodes and libpq does not.
54
+ url.search = Array.from(url.searchParams.entries())
55
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
56
+ .join("&");
57
+ return url.toString();
58
+ }
@@ -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
  *
@@ -175,10 +245,11 @@ export class DrizzleConditionBuilder {
175
245
  static buildRelationScopeCondition(
176
246
  relation: ResolvedRelation,
177
247
  /**
178
- * Lazy: only `via` and `belongsTo` need the parent's own table. A
179
- * foreign key on the target and a junction are both expressible from
180
- * the parent's *id* alone, and requiring the table for them would make
181
- * a child listing fail on a parent whose table isn't registered.
248
+ * Lazy: `via`, `belongsTo`, and a foreign key that points at a
249
+ * `sourceKey` need the parent's own table. A junction and a plain
250
+ * foreign key are expressible from the parent's *id* alone, and
251
+ * requiring the table for them would make a child listing fail on a
252
+ * parent whose table isn't registered.
182
253
  */
183
254
  parent: () => { table: PgTable<any>; idColumn: AnyPgColumn },
184
255
  parentId: string | number,
@@ -239,7 +310,16 @@ export class DrizzleConditionBuilder {
239
310
  `relation '${relation.relationName}'.`
240
311
  );
241
312
  }
242
- return eq(fkColumn, parentId);
313
+ if (!relation.sourceKey) return eq(fkColumn, parentId);
314
+
315
+ // A link on a natural key: the foreign key holds a column of the
316
+ // parent row, not its id, so the parent has to be read. As a
317
+ // subquery rather than a prior SELECT, for the same reason
318
+ // `belongsTo` below is — one statement sees one snapshot, and a
319
+ // scope condition that read the key separately could be built
320
+ // from a value the very next statement no longer agrees with.
321
+ const { table, idColumn } = parent();
322
+ return sql`${fkColumn} = (SELECT ${sql.identifier(relation.sourceKey)} FROM ${table} WHERE ${idColumn} = ${parentId})`;
243
323
  }
244
324
 
245
325
  case "belongsTo": {
@@ -385,7 +465,22 @@ export class DrizzleConditionBuilder {
385
465
  // inverse, so reversing it into a filter is a different problem
386
466
  // from the two shapes below rather than a third case of them.
387
467
  if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
388
- return { kind: "relation", relation, registry, sourceIdColumn };
468
+ // The `EXISTS` correlates the target's foreign key with a column
469
+ // on *this* table, and that is the primary key only when the
470
+ // link joins on it. A `sourceKey` names a different one, and
471
+ // correlating on the id anyway silently matches nothing —
472
+ // "filter by this relation" would quietly return zero rows.
473
+ const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey
474
+ ? columnAt(relation.sourceKey)
475
+ : sourceIdColumn;
476
+ if (!correlationColumn) {
477
+ throw new Error(
478
+ `\`sourceKey: "${(relation as ResolvedForeignKeyOnTarget).sourceKey}"\` on relation ` +
479
+ `'${relation.relationName}' is not a column on '${collectionPath}', so a filter on ` +
480
+ "that relation has nothing to correlate against."
481
+ );
482
+ }
483
+ return { kind: "relation", relation, registry, sourceIdColumn: correlationColumn };
389
484
  }
390
485
  }
391
486
 
@@ -440,11 +535,9 @@ export class DrizzleConditionBuilder {
440
535
  const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
441
536
  if (!target) continue;
442
537
 
443
- const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0])
444
- ? (filterParam as [WhereFilterOp, any][])
445
- : [filterParam as [WhereFilterOp, any]];
446
-
447
- 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)) {
448
541
  const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
449
542
  if (condition) {
450
543
  conditions.push(condition);
@@ -1250,38 +1343,261 @@ whereConditions };
1250
1343
  }
1251
1344
 
1252
1345
  /**
1253
- * 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.
1254
1364
  */
1255
1365
  static buildSearchConditions(
1256
1366
  searchString: string,
1257
1367
  properties: Record<string, unknown>,
1258
- table: PgTable<any>
1368
+ table: PgTable<any>,
1369
+ collection?: CollectionConfig
1259
1370
  ): SQL[] {
1260
1371
  const searchConditions: SQL[] = [];
1261
1372
 
1373
+ const ftsCondition = collection
1374
+ ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection)
1375
+ : undefined;
1376
+ if (ftsCondition) return [ftsCondition];
1377
+
1378
+ let declaredStringProperties = 0;
1379
+
1262
1380
  for (const [key, prop] of Object.entries(properties)) {
1263
1381
  const p = prop as Record<string, unknown>;
1264
1382
  // Only include string properties that don't have enum defined
1265
1383
  // PostgreSQL enum and uuid columns don't support ILIKE, so we skip them
1266
1384
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1385
+ declaredStringProperties++;
1267
1386
  const fieldColumn = table[key as keyof typeof table] as AnyPgColumn;
1268
- if (fieldColumn) {
1269
- // Verify that the underlying database column supports string pattern-matching
1270
- const supportsILike =
1271
- fieldColumn instanceof PgVarchar ||
1272
- fieldColumn instanceof PgText ||
1273
- fieldColumn instanceof PgChar ||
1274
- (fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn));
1275
- if (supportsILike) {
1276
- searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
1277
- }
1387
+ if (fieldColumn && supportsILike(fieldColumn)) {
1388
+ searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
1278
1389
  }
1279
1390
  }
1280
1391
  }
1281
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
+
1282
1406
  return searchConditions;
1283
1407
  }
1284
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
+
1285
1601
  /**
1286
1602
  * Build a unique field check condition
1287
1603
  */
@@ -1513,6 +1829,16 @@ whereConditions };
1513
1829
  * - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
1514
1830
  * - `filter`: optional WHERE clause for distance threshold
1515
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.
1516
1842
  */
1517
1843
  static buildVectorSearchConditions(
1518
1844
  table: PgTable<any>,
@@ -1523,10 +1849,7 @@ whereConditions };
1523
1849
  threshold?: number;
1524
1850
  }
1525
1851
  ): { orderBy: SQL; filter?: SQL; distanceSelect: SQL } {
1526
- const column = table[vectorSearch.property as keyof typeof table] as AnyPgColumn;
1527
- if (!column) {
1528
- throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
1529
- }
1852
+ const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
1530
1853
 
1531
1854
  // The vector is interpolated as a raw SQL literal below (pgvector has no
1532
1855
  // bind form for the `::vector` cast), so every element must be a finite
@@ -1567,8 +1890,52 @@ whereConditions };
1567
1890
  distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
1568
1891
  };
1569
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
+ }
1570
1927
  }
1571
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
+
1572
1939
  /**
1573
1940
  * Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
1574
1941
  * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
@@ -79,6 +79,25 @@ export function extractPgError(error: unknown): PostgresError | null {
79
79
  return null;
80
80
  }
81
81
 
82
+ /**
83
+ * Whether the failure came back from Postgres rather than from building the
84
+ * query — which decides whether a fallback query is worth issuing.
85
+ *
86
+ * Reads here run inside a transaction (that is where `SET LOCAL ROLE` binds
87
+ * RLS). Once a statement raises, that transaction is aborted, and every later
88
+ * statement on it returns `25P02` — "current transaction is aborted, commands
89
+ * ignored until end of transaction block". So a retry after a database error
90
+ * cannot succeed, and it replaces a precise diagnosis ("invalid input syntax
91
+ * for type uuid") with a generic one. Rethrow instead.
92
+ *
93
+ * A query the driver could not even build — a missing reciprocal relation, say
94
+ * — never reached Postgres, leaves the transaction usable, and is exactly what
95
+ * the fallback paths exist for.
96
+ */
97
+ export function reachedDatabase(error: unknown): boolean {
98
+ return extractPgError(error) !== null;
99
+ }
100
+
82
101
  /**
83
102
  * Walk the error cause chain and return the deepest meaningful message.
84
103
  */
@@ -97,6 +116,52 @@ export function extractCauseMessage(error: unknown): string | null {
97
116
  return null;
98
117
  }
99
118
 
119
+ /**
120
+ * Codes that mean "this connection will never work as configured".
121
+ *
122
+ * A wrong password or a database that does not exist is a settled fact about
123
+ * the connection string, not a transient fault — retrying produces the same
124
+ * answer forever.
125
+ */
126
+ const UNRECOVERABLE_CONNECT_CODES = new Set([
127
+ "28P01", // invalid_password
128
+ "28000", // invalid_authorization_specification
129
+ "3D000", // invalid_catalog_name — the database does not exist
130
+ "42501" // insufficient_privilege
131
+ ]);
132
+
133
+ export interface ConnectFailure {
134
+ /** True when retrying cannot help: the connection string itself is wrong. */
135
+ fatal: boolean;
136
+ /** The deepest message available — the Postgres one where there is one. */
137
+ reason: string;
138
+ /** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */
139
+ code?: string;
140
+ }
141
+
142
+ /**
143
+ * Describe a failed connection attempt in terms a developer can act on.
144
+ *
145
+ * The error a caller catches is Drizzle's wrapper: its message is
146
+ * `Failed query: SELECT 1` and its stack runs through drizzle internals, while
147
+ * the sentence that says what is actually wrong — "password authentication
148
+ * failed for user …", "database … does not exist" — sits in `.cause`. Logging
149
+ * the wrapper, as the bootstrapper used to, tells a developer with a typo in
150
+ * their `DATABASE_URL` nothing at all.
151
+ */
152
+ export function classifyConnectFailure(error: unknown): ConnectFailure {
153
+ const pgError = extractPgError(error);
154
+ const reason =
155
+ pgError?.message ??
156
+ extractCauseMessage(error) ??
157
+ (error instanceof Error ? error.message : String(error));
158
+ return {
159
+ fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
160
+ reason,
161
+ code: pgError?.code
162
+ };
163
+ }
164
+
100
165
  /**
101
166
  * Detect whether an error is specifically a role-switching permission failure
102
167
  * (e.g. "permission denied to set role" or "must be member of role"),
@@ -249,9 +314,14 @@ export function sanitizeErrorForClient(error: unknown, context: string): { messa
249
314
  column: pgError.column,
250
315
  table: pgError.table,
251
316
  constraint: pgError.constraint,
252
- dataType: pgError.dataType,
253
- // Also log the outer Drizzle wrapper message for full context
254
- 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.
255
325
  });
256
326
  return pgErrorToFriendlyMessage(pgError, context);
257
327
  }