@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
@@ -1,10 +1,10 @@
1
- import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
6
  import { generateForeignKeyName } from "@rebasepro/utils";
7
- import { DrizzleConditionBuilder, type FilterCompilationOptions } from "../utils/drizzle-conditions";
7
+ import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
8
  import {
9
9
  getCollectionByPath,
10
10
  getTableForCollection,
@@ -12,6 +12,7 @@ import {
12
12
  requirePrimaryKeys,
13
13
  deriveRowAddress,
14
14
  parseIdValues,
15
+ idCanAddressTable,
15
16
  buildCompositeId,
16
17
  COMPOSITE_ID_SEPARATOR
17
18
  } from "./collection-helpers";
@@ -20,9 +21,11 @@ import { RelationService } from "./RelationService";
20
21
  import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
21
22
  import { DrizzleClient } from "../interfaces";
22
23
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
23
- import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
24
+ import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
25
+ import { visibleColumnProjection, hiddenColumnsOption } from "../schema/search-column";
24
26
  import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
25
- import { logger } from "@rebasepro/server";
27
+ import { ApiError, logger } from "@rebasepro/server";
28
+ import { reachedDatabase } from "../utils/pg-error-utils";
26
29
 
27
30
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
28
31
  type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
@@ -117,6 +120,31 @@ export class FetchService {
117
120
  * and skips rows rather than erroring. The guesses stay, last, for a
118
121
  * caller that hands over no collection to resolve against.
119
122
  */
123
+ /**
124
+ * The ORDER BY target, which may be relevance rather than a column.
125
+ *
126
+ * `_score` is only meaningful for a collection that declared a `search`
127
+ * block *and* for a request that carried a search string — ranking rows
128
+ * against no query ranks them all at zero. Outside those two conditions it
129
+ * is an unknown field and gets the same 400 as any other typo, which is the
130
+ * behaviour that matters: a sort that is silently dropped returns 200 with
131
+ * rows in arbitrary order, and paging over that repeats and skips rows.
132
+ */
133
+ static readonly SCORE_FIELD = "_score";
134
+
135
+ private resolveOrderTarget(
136
+ table: PgTable<any>,
137
+ orderBy: string,
138
+ collection?: CollectionConfig,
139
+ searchString?: string
140
+ ): AnyPgColumn | SQL | undefined {
141
+ if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
142
+ const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
143
+ if (rank) return rank;
144
+ }
145
+ return this.resolveOrderByField(table, orderBy, collection);
146
+ }
147
+
120
148
  private resolveOrderByField(
121
149
  table: PgTable<any>,
122
150
  orderBy: string,
@@ -129,12 +157,10 @@ export class FetchService {
129
157
  if (direct) return direct;
130
158
 
131
159
  // Owning relation, resolved: the relation names its own local key.
132
- if (collection) {
133
- const relation = resolveCollectionRelations(collection)[orderBy];
134
- if (relation?.kind === "belongsTo") {
135
- const foreignKey = columnAt(relation.localKey);
136
- if (foreignKey) return foreignKey;
137
- }
160
+ const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
161
+ if (declaredRelation?.kind === "belongsTo") {
162
+ const foreignKey = columnAt(declaredRelation.localKey);
163
+ if (foreignKey) return foreignKey;
138
164
  }
139
165
 
140
166
  // No collection in hand — the two shapes an owning relation's key takes
@@ -145,7 +171,61 @@ export class FetchService {
145
171
  if (foreignKey) return foreignKey;
146
172
  }
147
173
 
148
- return undefined;
174
+ // Nothing resolved. Returning `undefined` is exactly what the docblock
175
+ // above describes: the caller drops the ORDER BY and hands back rows in
176
+ // whatever order Postgres pleases, while the requester believes they
177
+ // are sorted. `?orderBy=titel` answered 200 with unsorted data and no
178
+ // hint that the sort had been ignored.
179
+ //
180
+ // A *filter* naming a field that does not exist is already refused for
181
+ // precisely this reason — it "used to widen results silently". An
182
+ // unresolvable sort field is the same drift between a query and the
183
+ // schema, so it answers the same way and honours the same switch: one
184
+ // knob, because a deployment that wants the lenient behaviour wants it
185
+ // for both.
186
+ const collectionName = collection?.slug ?? collection?.name;
187
+ const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
188
+
189
+ // A declared to-many relation is a different mistake from a typo, and
190
+ // saying "unknown field" about a field the collection plainly declares
191
+ // sends the reader looking for a spelling error that is not there.
192
+ // There is simply no single value per row to order by — `posts.tags` is
193
+ // a set — so no ORDER BY exists to write, with or without a typo.
194
+ if (declaredRelation && declaredRelation.kind !== "belongsTo") {
195
+ throw ApiError.badRequest(
196
+ `Cannot sort by '${orderBy}'${onCollection}: it is a to-many relation ` +
197
+ `(${declaredRelation.kind}), which has no single value per row to order by.`,
198
+ "ORDER_BY_FIELD_NOT_SORTABLE",
199
+ { field: orderBy, kind: declaredRelation.kind, ...(collectionName && { collection: collectionName }) }
200
+ );
201
+ }
202
+
203
+ if (getUnknownFilterFieldsMode() === "warn") {
204
+ logger.warn(
205
+ `Sorting by field '${orderBy}'${onCollection}, but it does not exist in the table — ` +
206
+ "the ORDER BY was dropped and these rows are unsorted."
207
+ );
208
+ return undefined;
209
+ }
210
+
211
+ let validFields: string[] = [];
212
+ try {
213
+ validFields = Object.keys(getTableColumns(table)).sort();
214
+ } catch {
215
+ // A table stand-in without Drizzle's column symbols — the message
216
+ // is worth less without the list, but not worth failing over.
217
+ }
218
+
219
+ throw ApiError.badRequest(
220
+ `Unknown orderBy field '${orderBy}'${onCollection}` +
221
+ (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
222
+ "UNKNOWN_ORDER_BY_FIELD",
223
+ {
224
+ field: orderBy,
225
+ ...(collectionName && { collection: collectionName }),
226
+ ...(validFields.length > 0 && { validFields })
227
+ }
228
+ );
149
229
  }
150
230
 
151
231
  /**
@@ -353,6 +433,15 @@ export class FetchService {
353
433
  ): Record<string, unknown> {
354
434
  const queryOpts: Record<string, unknown> = {};
355
435
 
436
+ // Same exclusion the `db.select` fallback applies, in the shape the
437
+ // relational query builder takes. Both paths serve the same request, so
438
+ // a row must not carry the search column down one and not the other.
439
+ const hidden = hiddenColumnsOption(
440
+ getTableColumns(table),
441
+ this.registry.getCollectionByPath(collectionPath) ?? undefined
442
+ );
443
+ if (hidden) queryOpts.columns = hidden;
444
+
356
445
  if (withConfig) queryOpts.with = withConfig;
357
446
 
358
447
  // Build where conditions
@@ -363,7 +452,7 @@ export class FetchService {
363
452
  if (options.searchString) {
364
453
  const collection = getCollectionByPath(collectionPath, this.registry);
365
454
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
366
- options.searchString, collection.properties, table
455
+ options.searchString, collection.properties, table, collection
367
456
  );
368
457
  if (searchConditions.length === 0) {
369
458
  // Return options that will produce empty results
@@ -397,7 +486,7 @@ export class FetchService {
397
486
  const orderExpressions: unknown[] = [];
398
487
  if (options.orderBy) {
399
488
  const collection = getCollectionByPath(collectionPath, this.registry);
400
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
489
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
401
490
  if (orderByField) {
402
491
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
403
492
  }
@@ -431,6 +520,21 @@ export class FetchService {
431
520
  const cursor = options.startAfter;
432
521
 
433
522
  if (options.orderBy) {
523
+ // Relevance is computed per query, not stored, so there is no value
524
+ // on the cursor row to compare a later page against — and two
525
+ // requests with different search strings would produce scores that
526
+ // are not on the same scale at all. Refusing is the only honest
527
+ // answer: a dropped cursor condition silently repeats and skips
528
+ // rows, which is precisely what paging exists to prevent.
529
+ if (options.orderBy === FetchService.SCORE_FIELD) {
530
+ throw ApiError.badRequest(
531
+ "Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
532
+ "Relevance is computed per query rather than stored, so it cannot key a cursor. " +
533
+ "Use `limit`/`offset` for relevance-ordered pages, or order by a column.",
534
+ "SCORE_CURSOR_UNSUPPORTED",
535
+ { field: FetchService.SCORE_FIELD }
536
+ );
537
+ }
434
538
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
435
539
  const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
436
540
  if (orderByField) {
@@ -536,6 +640,11 @@ idColumn };
536
640
  throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
537
641
  }
538
642
 
643
+ // An address the key columns cannot hold names no row — the same answer
644
+ // as a well-formed id nobody has. Asking Postgres instead raises 22P02
645
+ // and aborts the transaction around this read.
646
+ if (!idCanAddressTable(id, table, idInfoArray)) return undefined;
647
+
539
648
  const parsedIdObj = parseIdValues(id, idInfoArray);
540
649
  const parsedId = parsedIdObj[idInfo.fieldName];
541
650
 
@@ -548,14 +657,17 @@ idColumn };
548
657
  try {
549
658
  const withConfig = this.buildWithConfig(collection);
550
659
 
660
+ const hidden = hiddenColumnsOption(getTableColumns(table), collection);
661
+
551
662
  const row = await qb.findFirst({
552
663
  where: eq(idField, parsedId),
553
- with: withConfig
664
+ with: withConfig,
665
+ ...(hidden ? { columns: hidden } : {})
554
666
  } as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
555
667
 
556
668
  if (!row) return undefined;
557
669
 
558
- const flatRow = toCmsRow(row, collection, this.registry);
670
+ const flatRow = toFlatRow(row, collection, this.registry);
559
671
 
560
672
  // Post-fetch joinPath relations that Drizzle's `with` can't express
561
673
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -566,13 +678,15 @@ idColumn };
566
678
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
567
679
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
568
680
  }
681
+ if (reachedDatabase(e)) throw e;
569
682
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
570
683
  }
571
684
  }
572
685
 
573
686
  // Fallback: db.select + N+1 relation loading
687
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
574
688
  const result = await this.db
575
- .select()
689
+ .select(visibleOne as never)
576
690
  .from(table)
577
691
  .where(eq(idField, parsedId))
578
692
  .limit(1);
@@ -640,6 +754,7 @@ idColumn };
640
754
  offset?: number;
641
755
  startAfter?: Record<string, unknown>;
642
756
  searchString?: string;
757
+ searchExplain?: boolean;
643
758
  databaseId?: string;
644
759
  vectorSearch?: VectorSearchParams;
645
760
  logical?: LogicalCondition;
@@ -682,7 +797,7 @@ idColumn };
682
797
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
683
798
 
684
799
  const rows = (results as Record<string, unknown>[]).map(row =>
685
- toCmsRow(row, collection, this.registry)
800
+ toFlatRow(row, collection, this.registry)
686
801
  );
687
802
 
688
803
  return rows;
@@ -691,6 +806,7 @@ idColumn };
691
806
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
692
807
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
693
808
  }
809
+ if (reachedDatabase(e)) throw e;
694
810
  logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
695
811
  }
696
812
  }
@@ -702,17 +818,41 @@ idColumn };
702
818
  vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
703
819
  }
704
820
 
821
+ // A generated search column is an index in column form; `SELECT *`
822
+ // would ship it to every caller. The projection is undefined — and the
823
+ // SQL therefore unchanged — for any table without one.
824
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
825
+
826
+ // Relevance, alongside the row, exactly as `_distance` rides along with
827
+ // a vector search. Present only when the collection opted in and the
828
+ // request carried a search string, so a caller can order by it, show
829
+ // it, or blend it with a score of their own.
830
+ const rankSelect = options.searchString
831
+ ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
832
+ : undefined;
833
+
834
+ // Only when asked: a `ts_headline` per declared field per row.
835
+ const matchesSelect = options.searchString && options.searchExplain
836
+ ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
837
+ : undefined;
838
+
705
839
  let query = vectorMeta
706
- ? this.db.select({ table_row: table,
840
+ ? this.db.select({ table_row: (visible ?? table) as never,
707
841
  _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
708
- : this.db.select().from(table).$dynamic();
842
+ : rankSelect
843
+ ? this.db.select({
844
+ table_row: (visible ?? table) as never,
845
+ _score: rankSelect,
846
+ ...(matchesSelect ? { _matches: matchesSelect } : {})
847
+ }).from(table).$dynamic()
848
+ : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
709
849
  const allConditions: SQL[] = [];
710
850
 
711
851
  if (scopeCondition) allConditions.push(scopeCondition);
712
852
 
713
853
  if (options.searchString) {
714
854
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
715
- options.searchString, collection.properties, table
855
+ options.searchString, collection.properties, table, collection
716
856
  );
717
857
  if (searchConditions.length === 0) return [];
718
858
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -743,7 +883,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
743
883
  if (vectorMeta) {
744
884
  orderExpressions.push(asc(vectorMeta.orderBy));
745
885
  } else if (options.orderBy) {
746
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
886
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
747
887
  if (orderByField) {
748
888
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
749
889
  }
@@ -777,7 +917,15 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
777
917
  ...r.table_row,
778
918
  _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
779
919
  }))
780
- : rawResults as Record<string, unknown>[];
920
+ // Same nested shape, unwrapped the same way, when a relevance
921
+ // score was selected instead.
922
+ : rankSelect
923
+ ? (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
924
+ ...r.table_row,
925
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
926
+ ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
927
+ }))
928
+ : rawResults as Record<string, unknown>[];
781
929
 
782
930
  return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
783
931
  }
@@ -785,7 +933,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
785
933
  /**
786
934
  * Fallback path used when db.query is unavailable.
787
935
  *
788
- * The primary path runs the results through `toCmsRow`, which maps
936
+ * The primary path runs the results through `toFlatRow`, which maps
789
937
  * relations from what drizzle already nested — no query per row. This one
790
938
  * has no nesting to read, so it resolves relations itself, in batches.
791
939
  *
@@ -893,6 +1041,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
893
1041
  collectionPath: string,
894
1042
  options: {
895
1043
  filter?: FilterValues<Extract<keyof M, string>>;
1044
+ /**
1045
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1046
+ *
1047
+ * `fetchRowsWithConditions` below has always applied this; it was
1048
+ * simply absent from this signature, so the only callers that could
1049
+ * pass one were the ones that went around this method. Realtime
1050
+ * came through here, which is why a subscription filtered by a
1051
+ * logical group was pushed every row in the table.
1052
+ */
1053
+ logical?: LogicalCondition;
896
1054
  orderBy?: string;
897
1055
  order?: "desc" | "asc";
898
1056
  limit?: number;
@@ -923,10 +1081,21 @@ relatedTo: hop });
923
1081
  searchString: string,
924
1082
  options: {
925
1083
  filter?: FilterValues<Extract<keyof M, string>>;
1084
+ /**
1085
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1086
+ *
1087
+ * `fetchRowsWithConditions` has always applied one; it was missing
1088
+ * from this signature, so a realtime search subscription carrying a
1089
+ * group could not pass it on and served every row matching the text
1090
+ * that RLS allowed.
1091
+ */
1092
+ logical?: LogicalCondition;
926
1093
  orderBy?: string;
927
1094
  order?: "desc" | "asc";
928
1095
  limit?: number;
929
1096
  databaseId?: string;
1097
+ /** Ask each row which declared search field matched. */
1098
+ searchExplain?: boolean;
930
1099
  } = {}
931
1100
  ): Promise<Record<string, unknown>[]> {
932
1101
  return this.fetchRowsWithConditions<M>(collectionPath, {
@@ -942,6 +1111,7 @@ relatedTo: hop });
942
1111
  collectionPath: string,
943
1112
  options: {
944
1113
  filter?: FilterValues<Extract<keyof M, string>>;
1114
+ logical?: LogicalCondition;
945
1115
  searchString?: string;
946
1116
  databaseId?: string;
947
1117
  } = {}
@@ -962,7 +1132,7 @@ relatedTo: hop });
962
1132
 
963
1133
  if (options.searchString) {
964
1134
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
965
- options.searchString, collection.properties, table
1135
+ options.searchString, collection.properties, table, collection
966
1136
  );
967
1137
  if (searchConditions.length === 0) return 0;
968
1138
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -973,6 +1143,13 @@ relatedTo: hop });
973
1143
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
974
1144
  }
975
1145
 
1146
+ if (options.logical) {
1147
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
1148
+ options.logical, table, effectivePath, this.filterContext(effectivePath, table)
1149
+ );
1150
+ if (logicalCondition) allConditions.push(logicalCondition);
1151
+ }
1152
+
976
1153
  if (allConditions.length > 0) {
977
1154
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
978
1155
  if (finalCondition) query = query.where(finalCondition);
@@ -1043,6 +1220,8 @@ relatedTo: hop });
1043
1220
  collectionPath: string,
1044
1221
  options: {
1045
1222
  filter?: FilterValues<Extract<keyof M, string>>;
1223
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
1224
+ logical?: LogicalCondition;
1046
1225
  orderBy?: string;
1047
1226
  order?: "desc" | "asc";
1048
1227
  limit?: number;
@@ -1111,6 +1290,7 @@ relatedTo: hop }, include
1111
1290
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1112
1291
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1113
1292
  }
1293
+ if (reachedDatabase(e)) throw e;
1114
1294
  logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
1115
1295
  }
1116
1296
  }
@@ -1184,6 +1364,9 @@ relatedTo: hop }, include
1184
1364
  const idInfo = idInfoArray[0];
1185
1365
  const idField = table[idInfo.fieldName as keyof typeof table] as AnyPgColumn;
1186
1366
 
1367
+ // See `fetchOne`: an unaddressable id is a 404, not a database error.
1368
+ if (!idCanAddressTable(id, table, idInfoArray)) return null;
1369
+
1187
1370
  const parsedIdObj = parseIdValues(id, idInfoArray);
1188
1371
  const parsedId = parsedIdObj[idInfo.fieldName];
1189
1372
 
@@ -1217,13 +1400,15 @@ relatedTo: hop }, include
1217
1400
  logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
1218
1401
  logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
1219
1402
  }
1403
+ if (reachedDatabase(e)) throw e;
1220
1404
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
1221
1405
  }
1222
1406
  }
1223
1407
 
1224
1408
  // Fallback: db.select + N+1 relation loading
1409
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
1225
1410
  const result = await this.db
1226
- .select()
1411
+ .select(visibleOne as never)
1227
1412
  .from(table)
1228
1413
  .where(eq(idField, parsedId))
1229
1414
  .limit(1);
@@ -1277,12 +1462,26 @@ relatedTo: hop }, include
1277
1462
  collectionPath: string,
1278
1463
  options: {
1279
1464
  filter?: FilterValues<Extract<keyof M, string>>;
1465
+ /**
1466
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1467
+ *
1468
+ * Declared *and applied*, because this is the path every REST search
1469
+ * and vector read takes: `fetchCollectionForRest` skips `db.query`
1470
+ * whenever a `searchString` or a `vectorSearch` is present. The
1471
+ * group arrived here on `options` from the very beginning and was
1472
+ * simply never read, so `?searchString=x&or=(...)` served every row
1473
+ * matching `x` that RLS allowed — while `count` (which does apply
1474
+ * it) reported the narrowed total, so `meta.total` and `data`
1475
+ * described different sets of rows.
1476
+ */
1477
+ logical?: LogicalCondition;
1280
1478
  orderBy?: string;
1281
1479
  order?: "desc" | "asc";
1282
1480
  limit?: number;
1283
1481
  offset?: number;
1284
1482
  startAfter?: Record<string, unknown>;
1285
1483
  searchString?: string;
1484
+ searchExplain?: boolean;
1286
1485
  vectorSearch?: VectorSearchParams;
1287
1486
  relatedTo?: NestedPathHop;
1288
1487
  } = {}
@@ -1298,17 +1497,41 @@ relatedTo: hop }, include
1298
1497
  vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1299
1498
  }
1300
1499
 
1500
+ // A generated search column is an index in column form; `SELECT *`
1501
+ // would ship it to every caller. The projection is undefined — and the
1502
+ // SQL therefore unchanged — for any table without one.
1503
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
1504
+
1505
+ // Relevance, alongside the row, exactly as `_distance` rides along with
1506
+ // a vector search. Present only when the collection opted in and the
1507
+ // request carried a search string, so a caller can order by it, show
1508
+ // it, or blend it with a score of their own.
1509
+ const rankSelect = options.searchString
1510
+ ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
1511
+ : undefined;
1512
+
1513
+ // Only when asked: a `ts_headline` per declared field per row.
1514
+ const matchesSelect = options.searchString && options.searchExplain
1515
+ ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
1516
+ : undefined;
1517
+
1301
1518
  let query = vectorMeta
1302
- ? this.db.select({ table_row: table,
1519
+ ? this.db.select({ table_row: (visible ?? table) as never,
1303
1520
  _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1304
- : this.db.select().from(table).$dynamic();
1521
+ : rankSelect
1522
+ ? this.db.select({
1523
+ table_row: (visible ?? table) as never,
1524
+ _score: rankSelect,
1525
+ ...(matchesSelect ? { _matches: matchesSelect } : {})
1526
+ }).from(table).$dynamic()
1527
+ : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
1305
1528
  const allConditions: SQL[] = [];
1306
1529
 
1307
1530
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
1308
1531
 
1309
1532
  if (options.searchString) {
1310
1533
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1311
- options.searchString, collection.properties, table
1534
+ options.searchString, collection.properties, table, collection
1312
1535
  );
1313
1536
  if (searchConditions.length === 0) return [];
1314
1537
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1319,6 +1542,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1319
1542
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
1320
1543
  }
1321
1544
 
1545
+ if (options.logical) {
1546
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
1547
+ if (logicalCondition) allConditions.push(logicalCondition);
1548
+ }
1549
+
1322
1550
  if (vectorMeta?.filter) {
1323
1551
  allConditions.push(vectorMeta.filter);
1324
1552
  }
@@ -1332,7 +1560,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1332
1560
  if (vectorMeta) {
1333
1561
  orderExpressions.push(asc(vectorMeta.orderBy));
1334
1562
  } else if (options.orderBy) {
1335
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1563
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
1336
1564
  if (orderByField) {
1337
1565
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1338
1566
  }
@@ -1357,6 +1585,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1357
1585
  }));
1358
1586
  }
1359
1587
 
1588
+ if (rankSelect) {
1589
+ return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
1590
+ ...r.table_row,
1591
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
1592
+ ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
1593
+ }));
1594
+ }
1595
+
1360
1596
  return rawResults as Record<string, unknown>[];
1361
1597
  }
1362
1598
 
@@ -1378,99 +1614,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1378
1614
  return !!this.getQueryBuilder(tableName);
1379
1615
  }
1380
1616
 
1381
- /**
1382
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
1383
- * for efficient JOIN-based relation loading.
1384
- * Returns null if the API is not available or the query fails.
1385
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1386
- */
1387
- private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
1388
- collectionPath: string,
1389
- collection: CollectionConfig,
1390
- options: {
1391
- filter?: FilterValues<Extract<keyof M, string>>;
1392
- orderBy?: string;
1393
- order?: "desc" | "asc";
1394
- limit?: number;
1395
- },
1396
- include: string[],
1397
- idInfo: { fieldName: string; type: "string" | "number" },
1398
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1399
- ): Promise<Record<string, unknown>[] | null> {
1400
- try {
1401
-
1402
- const table = getTableForCollection(collection, this.registry);
1403
- const tableName = getTableName(table);
1404
- const queryTarget = this.getQueryBuilder(tableName);
1405
-
1406
- if (!queryTarget?.findMany) return null;
1407
-
1408
- // Build the `with` config from include array
1409
- const resolvedRelations = resolveCollectionRelations(collection);
1410
- const withConfig: Record<string, boolean> = {};
1411
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1412
- if (include[0] === "*" || include.includes(key)) {
1413
- // Use the Drizzle relation name (from the schema)
1414
- const drizzleRelName = relation.relationName || key;
1415
- withConfig[drizzleRelName] = true;
1416
- }
1417
- }
1418
-
1419
- // Build query options
1420
- const queryOpts: Record<string, unknown> = { with: withConfig };
1421
- if (options.limit) queryOpts.limit = options.limit;
1422
-
1423
- // Build where clause
1424
- if (options.filter) {
1425
- const filterConditions = this.buildFilterConditions(
1426
- options.filter, table, collectionPath
1427
- );
1428
- if (filterConditions.length > 0) {
1429
- queryOpts.where = and(...filterConditions);
1430
- }
1431
- }
1432
-
1433
- // Build orderBy
1434
- if (options.orderBy) {
1435
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1436
- if (orderByField) {
1437
- queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1438
- }
1439
- }
1440
-
1441
-
1442
- const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1443
-
1444
- // Inline the nested Drizzle results, columns only — no synthesized id.
1445
- return results.map((row: Record<string, unknown>) => {
1446
- const flat: Record<string, unknown> = {};
1447
- for (const [k, v] of Object.entries(row)) {
1448
- if (Array.isArray(v)) {
1449
- // Many relation — inline each nested row
1450
- flat[k] = v.map((item: Record<string, unknown>) => {
1451
- // Junction table rows may have the target nested, unwrap those
1452
- const keys = Object.keys(item);
1453
- const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1454
- if (nestedObj && keys.length <= 3) {
1455
- return { ...(item[nestedObj] as Record<string, unknown>) };
1456
- }
1457
- return { ...item };
1458
- });
1459
- } else if (typeof v === "object" && v !== null) {
1460
- // One-to-one relation — inline the target's columns
1461
- flat[k] = { ...(v as Record<string, unknown>) };
1462
- } else {
1463
- flat[k] = v;
1464
- }
1465
- }
1466
- return flat;
1467
- });
1468
- } catch (e) {
1469
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
1470
- return null;
1471
- }
1472
- }
1473
-
1474
1617
  /**
1475
1618
  * Fallback path used when db.query is unavailable.
1476
1619
  * The primary path uses db.query.findMany with `with` config, which