@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -2,8 +2,8 @@ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, S
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
- import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
- import { generateForeignKeyName } from "@rebasepro/utils";
5
+ import { resolveCollectionRelations, findRelation, fieldKeyForColumn, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
+ import { generateForeignKeyName, toWireKey } from "@rebasepro/utils";
7
7
  import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
8
  import {
9
9
  getCollectionByPath,
@@ -22,6 +22,7 @@ import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query
22
22
  import { DrizzleClient } from "../interfaces";
23
23
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
24
24
  import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
25
+ import { visibleColumnProjection, hiddenColumnsOption } from "../schema/search-column";
25
26
  import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
26
27
  import { ApiError, logger } from "@rebasepro/server";
27
28
  import { reachedDatabase } from "../utils/pg-error-utils";
@@ -119,6 +120,31 @@ export class FetchService {
119
120
  * and skips rows rather than erroring. The guesses stay, last, for a
120
121
  * caller that hands over no collection to resolve against.
121
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
+
122
148
  private resolveOrderByField(
123
149
  table: PgTable<any>,
124
150
  orderBy: string,
@@ -133,14 +159,21 @@ export class FetchService {
133
159
  // Owning relation, resolved: the relation names its own local key.
134
160
  const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
135
161
  if (declaredRelation?.kind === "belongsTo") {
136
- const foreignKey = columnAt(declaredRelation.localKey);
162
+ // `localKey` is the column; the table is keyed by the wire name.
163
+ const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
137
164
  if (foreignKey) return foreignKey;
138
165
  }
139
166
 
140
- // No collection in hand — the two shapes an owning relation's key takes
141
- // by default (e.g. `project` → `project_id`, `userProfile` →
142
- // `user_profile_id`).
143
- for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
167
+ // No collection in hand — the shapes an owning relation's key takes by
168
+ // default (e.g. `project` → `projectId`, `userProfile` →
169
+ // `userProfileId`), then the snake forms for a project that authored the
170
+ // property under its column name.
171
+ for (const guess of [
172
+ `${orderBy}Id`,
173
+ toWireKey(generateForeignKeyName(orderBy)),
174
+ `${orderBy}_id`,
175
+ generateForeignKeyName(orderBy)
176
+ ]) {
144
177
  const foreignKey = columnAt(guess);
145
178
  if (foreignKey) return foreignKey;
146
179
  }
@@ -300,6 +333,14 @@ export class FetchService {
300
333
  );
301
334
  }
302
335
  } catch (e) {
336
+ // A relation that failed to load is not a relation that is absent.
337
+ // Without this the request answers 200 with the field quietly
338
+ // missing — and because a Postgres error poisons the surrounding
339
+ // transaction, every later relation in the same request is
340
+ // swallowed too, so one failure becomes a response missing
341
+ // several fields. Same guard the four other catches in this file
342
+ // already use.
343
+ if (reachedDatabase(e)) throw e;
303
344
  logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
304
345
  }
305
346
  });
@@ -378,6 +419,14 @@ export class FetchService {
378
419
  }
379
420
  }
380
421
  } catch (e) {
422
+ // A relation that failed to load is not a relation that is absent.
423
+ // Without this the request answers 200 with the field quietly
424
+ // missing — and because a Postgres error poisons the surrounding
425
+ // transaction, every later relation in the same request is
426
+ // swallowed too, so one failure becomes a response missing
427
+ // several fields. Same guard the four other catches in this file
428
+ // already use.
429
+ if (reachedDatabase(e)) throw e;
381
430
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
382
431
  }
383
432
  }
@@ -407,6 +456,15 @@ export class FetchService {
407
456
  ): Record<string, unknown> {
408
457
  const queryOpts: Record<string, unknown> = {};
409
458
 
459
+ // Same exclusion the `db.select` fallback applies, in the shape the
460
+ // relational query builder takes. Both paths serve the same request, so
461
+ // a row must not carry the search column down one and not the other.
462
+ const hidden = hiddenColumnsOption(
463
+ getTableColumns(table),
464
+ this.registry.getCollectionByPath(collectionPath) ?? undefined
465
+ );
466
+ if (hidden) queryOpts.columns = hidden;
467
+
410
468
  if (withConfig) queryOpts.with = withConfig;
411
469
 
412
470
  // Build where conditions
@@ -417,7 +475,7 @@ export class FetchService {
417
475
  if (options.searchString) {
418
476
  const collection = getCollectionByPath(collectionPath, this.registry);
419
477
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
420
- options.searchString, collection.properties, table
478
+ options.searchString, collection.properties, table, collection
421
479
  );
422
480
  if (searchConditions.length === 0) {
423
481
  // Return options that will produce empty results
@@ -451,7 +509,7 @@ export class FetchService {
451
509
  const orderExpressions: unknown[] = [];
452
510
  if (options.orderBy) {
453
511
  const collection = getCollectionByPath(collectionPath, this.registry);
454
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
512
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
455
513
  if (orderByField) {
456
514
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
457
515
  }
@@ -485,6 +543,21 @@ export class FetchService {
485
543
  const cursor = options.startAfter;
486
544
 
487
545
  if (options.orderBy) {
546
+ // Relevance is computed per query, not stored, so there is no value
547
+ // on the cursor row to compare a later page against — and two
548
+ // requests with different search strings would produce scores that
549
+ // are not on the same scale at all. Refusing is the only honest
550
+ // answer: a dropped cursor condition silently repeats and skips
551
+ // rows, which is precisely what paging exists to prevent.
552
+ if (options.orderBy === FetchService.SCORE_FIELD) {
553
+ throw ApiError.badRequest(
554
+ "Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. " +
555
+ "Relevance is computed per query rather than stored, so it cannot key a cursor. " +
556
+ "Use `limit`/`offset` for relevance-ordered pages, or order by a column.",
557
+ "SCORE_CURSOR_UNSUPPORTED",
558
+ { field: FetchService.SCORE_FIELD }
559
+ );
560
+ }
488
561
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : undefined;
489
562
  const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
490
563
  if (orderByField) {
@@ -607,9 +680,12 @@ idColumn };
607
680
  try {
608
681
  const withConfig = this.buildWithConfig(collection);
609
682
 
683
+ const hidden = hiddenColumnsOption(getTableColumns(table), collection);
684
+
610
685
  const row = await qb.findFirst({
611
686
  where: eq(idField, parsedId),
612
- with: withConfig
687
+ with: withConfig,
688
+ ...(hidden ? { columns: hidden } : {})
613
689
  } as Parameters<NonNullable<typeof qb>["findFirst"]>[0]);
614
690
 
615
691
  if (!row) return undefined;
@@ -631,8 +707,9 @@ idColumn };
631
707
  }
632
708
 
633
709
  // Fallback: db.select + N+1 relation loading
710
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
634
711
  const result = await this.db
635
- .select()
712
+ .select(visibleOne as never)
636
713
  .from(table)
637
714
  .where(eq(idField, parsedId))
638
715
  .limit(1);
@@ -673,6 +750,14 @@ idColumn };
673
750
  values[key] = createRelationRef(e.id, e.path);
674
751
  }
675
752
  } catch (e) {
753
+ // A relation that failed to load is not a relation that is absent.
754
+ // Without this the request answers 200 with the field quietly
755
+ // missing — and because a Postgres error poisons the surrounding
756
+ // transaction, every later relation in the same request is
757
+ // swallowed too, so one failure becomes a response missing
758
+ // several fields. Same guard the four other catches in this file
759
+ // already use.
760
+ if (reachedDatabase(e)) throw e;
676
761
  logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
677
762
  }
678
763
  }
@@ -700,6 +785,7 @@ idColumn };
700
785
  offset?: number;
701
786
  startAfter?: Record<string, unknown>;
702
787
  searchString?: string;
788
+ searchExplain?: boolean;
703
789
  databaseId?: string;
704
790
  vectorSearch?: VectorSearchParams;
705
791
  logical?: LogicalCondition;
@@ -763,17 +849,41 @@ idColumn };
763
849
  vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
764
850
  }
765
851
 
852
+ // A generated search column is an index in column form; `SELECT *`
853
+ // would ship it to every caller. The projection is undefined — and the
854
+ // SQL therefore unchanged — for any table without one.
855
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
856
+
857
+ // Relevance, alongside the row, exactly as `_distance` rides along with
858
+ // a vector search. Present only when the collection opted in and the
859
+ // request carried a search string, so a caller can order by it, show
860
+ // it, or blend it with a score of their own.
861
+ const rankSelect = options.searchString
862
+ ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
863
+ : undefined;
864
+
865
+ // Only when asked: a `ts_headline` per declared field per row.
866
+ const matchesSelect = options.searchString && options.searchExplain
867
+ ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
868
+ : undefined;
869
+
766
870
  let query = vectorMeta
767
- ? this.db.select({ table_row: table,
871
+ ? this.db.select({ table_row: (visible ?? table) as never,
768
872
  _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
769
- : this.db.select().from(table).$dynamic();
873
+ : rankSelect
874
+ ? this.db.select({
875
+ table_row: (visible ?? table) as never,
876
+ _score: rankSelect,
877
+ ...(matchesSelect ? { _matches: matchesSelect } : {})
878
+ }).from(table).$dynamic()
879
+ : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
770
880
  const allConditions: SQL[] = [];
771
881
 
772
882
  if (scopeCondition) allConditions.push(scopeCondition);
773
883
 
774
884
  if (options.searchString) {
775
885
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
776
- options.searchString, collection.properties, table
886
+ options.searchString, collection.properties, table, collection
777
887
  );
778
888
  if (searchConditions.length === 0) return [];
779
889
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -804,7 +914,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
804
914
  if (vectorMeta) {
805
915
  orderExpressions.push(asc(vectorMeta.orderBy));
806
916
  } else if (options.orderBy) {
807
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
917
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
808
918
  if (orderByField) {
809
919
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
810
920
  }
@@ -838,7 +948,15 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
838
948
  ...r.table_row,
839
949
  _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
840
950
  }))
841
- : rawResults as Record<string, unknown>[];
951
+ // Same nested shape, unwrapped the same way, when a relevance
952
+ // score was selected instead.
953
+ : rankSelect
954
+ ? (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
955
+ ...r.table_row,
956
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
957
+ ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
958
+ }))
959
+ : rawResults as Record<string, unknown>[];
842
960
 
843
961
  return this.processRowResults<M>(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
844
962
  }
@@ -911,6 +1029,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
911
1029
  }
912
1030
  });
913
1031
  } catch (e) {
1032
+ // A relation that failed to load is not a relation that is absent.
1033
+ // Without this the request answers 200 with the field quietly
1034
+ // missing — and because a Postgres error poisons the surrounding
1035
+ // transaction, every later relation in the same request is
1036
+ // swallowed too, so one failure becomes a response missing
1037
+ // several fields. Same guard the four other catches in this file
1038
+ // already use.
1039
+ if (reachedDatabase(e)) throw e;
914
1040
  logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
915
1041
  }
916
1042
  }
@@ -938,6 +1064,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
938
1064
  );
939
1065
  });
940
1066
  } catch (e) {
1067
+ // A relation that failed to load is not a relation that is absent.
1068
+ // Without this the request answers 200 with the field quietly
1069
+ // missing — and because a Postgres error poisons the surrounding
1070
+ // transaction, every later relation in the same request is
1071
+ // swallowed too, so one failure becomes a response missing
1072
+ // several fields. Same guard the four other catches in this file
1073
+ // already use.
1074
+ if (reachedDatabase(e)) throw e;
941
1075
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
942
1076
  }
943
1077
  }
@@ -994,10 +1128,21 @@ relatedTo: hop });
994
1128
  searchString: string,
995
1129
  options: {
996
1130
  filter?: FilterValues<Extract<keyof M, string>>;
1131
+ /**
1132
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1133
+ *
1134
+ * `fetchRowsWithConditions` has always applied one; it was missing
1135
+ * from this signature, so a realtime search subscription carrying a
1136
+ * group could not pass it on and served every row matching the text
1137
+ * that RLS allowed.
1138
+ */
1139
+ logical?: LogicalCondition;
997
1140
  orderBy?: string;
998
1141
  order?: "desc" | "asc";
999
1142
  limit?: number;
1000
1143
  databaseId?: string;
1144
+ /** Ask each row which declared search field matched. */
1145
+ searchExplain?: boolean;
1001
1146
  } = {}
1002
1147
  ): Promise<Record<string, unknown>[]> {
1003
1148
  return this.fetchRowsWithConditions<M>(collectionPath, {
@@ -1034,7 +1179,7 @@ relatedTo: hop });
1034
1179
 
1035
1180
  if (options.searchString) {
1036
1181
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1037
- options.searchString, collection.properties, table
1182
+ options.searchString, collection.properties, table, collection
1038
1183
  );
1039
1184
  if (searchConditions.length === 0) return 0;
1040
1185
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1226,6 +1371,14 @@ relatedTo: hop }, include
1226
1371
  }
1227
1372
  }
1228
1373
  } catch (e) {
1374
+ // A relation that failed to load is not a relation that is absent.
1375
+ // Without this the request answers 200 with the field quietly
1376
+ // missing — and because a Postgres error poisons the surrounding
1377
+ // transaction, every later relation in the same request is
1378
+ // swallowed too, so one failure becomes a response missing
1379
+ // several fields. Same guard the four other catches in this file
1380
+ // already use.
1381
+ if (reachedDatabase(e)) throw e;
1229
1382
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
1230
1383
  }
1231
1384
  }
@@ -1242,6 +1395,14 @@ relatedTo: hop }, include
1242
1395
  (row as Record<string, unknown>)[key] = relatedList;
1243
1396
  }
1244
1397
  } catch (e) {
1398
+ // A relation that failed to load is not a relation that is absent.
1399
+ // Without this the request answers 200 with the field quietly
1400
+ // missing — and because a Postgres error poisons the surrounding
1401
+ // transaction, every later relation in the same request is
1402
+ // swallowed too, so one failure becomes a response missing
1403
+ // several fields. Same guard the four other catches in this file
1404
+ // already use.
1405
+ if (reachedDatabase(e)) throw e;
1245
1406
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
1246
1407
  }
1247
1408
  }
@@ -1308,8 +1469,9 @@ relatedTo: hop }, include
1308
1469
  }
1309
1470
 
1310
1471
  // Fallback: db.select + N+1 relation loading
1472
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
1311
1473
  const result = await this.db
1312
- .select()
1474
+ .select(visibleOne as never)
1313
1475
  .from(table)
1314
1476
  .where(eq(idField, parsedId))
1315
1477
  .limit(1);
@@ -1349,6 +1511,14 @@ relatedTo: hop }, include
1349
1511
  }));
1350
1512
  }
1351
1513
  } catch (e) {
1514
+ // A relation that failed to load is not a relation that is absent.
1515
+ // Without this the request answers 200 with the field quietly
1516
+ // missing — and because a Postgres error poisons the surrounding
1517
+ // transaction, every later relation in the same request is
1518
+ // swallowed too, so one failure becomes a response missing
1519
+ // several fields. Same guard the four other catches in this file
1520
+ // already use.
1521
+ if (reachedDatabase(e)) throw e;
1352
1522
  logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
1353
1523
  }
1354
1524
  }
@@ -1363,12 +1533,26 @@ relatedTo: hop }, include
1363
1533
  collectionPath: string,
1364
1534
  options: {
1365
1535
  filter?: FilterValues<Extract<keyof M, string>>;
1536
+ /**
1537
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1538
+ *
1539
+ * Declared *and applied*, because this is the path every REST search
1540
+ * and vector read takes: `fetchCollectionForRest` skips `db.query`
1541
+ * whenever a `searchString` or a `vectorSearch` is present. The
1542
+ * group arrived here on `options` from the very beginning and was
1543
+ * simply never read, so `?searchString=x&or=(...)` served every row
1544
+ * matching `x` that RLS allowed — while `count` (which does apply
1545
+ * it) reported the narrowed total, so `meta.total` and `data`
1546
+ * described different sets of rows.
1547
+ */
1548
+ logical?: LogicalCondition;
1366
1549
  orderBy?: string;
1367
1550
  order?: "desc" | "asc";
1368
1551
  limit?: number;
1369
1552
  offset?: number;
1370
1553
  startAfter?: Record<string, unknown>;
1371
1554
  searchString?: string;
1555
+ searchExplain?: boolean;
1372
1556
  vectorSearch?: VectorSearchParams;
1373
1557
  relatedTo?: NestedPathHop;
1374
1558
  } = {}
@@ -1384,17 +1568,41 @@ relatedTo: hop }, include
1384
1568
  vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1385
1569
  }
1386
1570
 
1571
+ // A generated search column is an index in column form; `SELECT *`
1572
+ // would ship it to every caller. The projection is undefined — and the
1573
+ // SQL therefore unchanged — for any table without one.
1574
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
1575
+
1576
+ // Relevance, alongside the row, exactly as `_distance` rides along with
1577
+ // a vector search. Present only when the collection opted in and the
1578
+ // request carried a search string, so a caller can order by it, show
1579
+ // it, or blend it with a score of their own.
1580
+ const rankSelect = options.searchString
1581
+ ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection)
1582
+ : undefined;
1583
+
1584
+ // Only when asked: a `ts_headline` per declared field per row.
1585
+ const matchesSelect = options.searchString && options.searchExplain
1586
+ ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection)
1587
+ : undefined;
1588
+
1387
1589
  let query = vectorMeta
1388
- ? this.db.select({ table_row: table,
1590
+ ? this.db.select({ table_row: (visible ?? table) as never,
1389
1591
  _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1390
- : this.db.select().from(table).$dynamic();
1592
+ : rankSelect
1593
+ ? this.db.select({
1594
+ table_row: (visible ?? table) as never,
1595
+ _score: rankSelect,
1596
+ ...(matchesSelect ? { _matches: matchesSelect } : {})
1597
+ }).from(table).$dynamic()
1598
+ : (visible ? this.db.select(visible as never).from(table).$dynamic() : this.db.select().from(table).$dynamic());
1391
1599
  const allConditions: SQL[] = [];
1392
1600
 
1393
1601
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
1394
1602
 
1395
1603
  if (options.searchString) {
1396
1604
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1397
- options.searchString, collection.properties, table
1605
+ options.searchString, collection.properties, table, collection
1398
1606
  );
1399
1607
  if (searchConditions.length === 0) return [];
1400
1608
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1405,6 +1613,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1405
1613
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
1406
1614
  }
1407
1615
 
1616
+ if (options.logical) {
1617
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
1618
+ if (logicalCondition) allConditions.push(logicalCondition);
1619
+ }
1620
+
1408
1621
  if (vectorMeta?.filter) {
1409
1622
  allConditions.push(vectorMeta.filter);
1410
1623
  }
@@ -1418,7 +1631,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1418
1631
  if (vectorMeta) {
1419
1632
  orderExpressions.push(asc(vectorMeta.orderBy));
1420
1633
  } else if (options.orderBy) {
1421
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1634
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
1422
1635
  if (orderByField) {
1423
1636
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1424
1637
  }
@@ -1443,6 +1656,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1443
1656
  }));
1444
1657
  }
1445
1658
 
1659
+ if (rankSelect) {
1660
+ return (rawResults as { table_row: Record<string, unknown>; _score: unknown; _matches?: unknown }[]).map(r => ({
1661
+ ...r.table_row,
1662
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
1663
+ ...(matchesSelect ? { _matches: r._matches ?? [] } : {})
1664
+ }));
1665
+ }
1666
+
1446
1667
  return rawResults as Record<string, unknown>[];
1447
1668
  }
1448
1669