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

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 (117) 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-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
  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 +57 -1
  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-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1815 -1023
  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 +61 -7
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  31. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  32. package/dist/schema/introspect-db-inference.d.ts +8 -1
  33. package/dist/schema/introspect-db-logic.d.ts +49 -0
  34. package/dist/schema/introspect-db-project.d.ts +21 -0
  35. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  36. package/dist/schema/search-column.d.ts +248 -0
  37. package/dist/security/policy-drift.d.ts +34 -0
  38. package/dist/security/rls-enforcement.d.ts +61 -5
  39. package/dist/services/FetchService.d.ts +34 -7
  40. package/dist/services/PersistService.d.ts +21 -17
  41. package/dist/services/RelationService.d.ts +9 -57
  42. package/dist/services/RelationWriteService.d.ts +82 -0
  43. package/dist/services/collection-helpers.d.ts +42 -0
  44. package/dist/services/dataService.d.ts +5 -0
  45. package/dist/services/junction-writes.d.ts +82 -0
  46. package/dist/services/realtimeService.d.ts +164 -23
  47. package/dist/services/write-denial.d.ts +36 -0
  48. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  49. package/dist/src-DCdn3Val.js.map +1 -0
  50. package/dist/utils/drizzle-conditions.d.ts +124 -2
  51. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  52. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  53. package/package.json +9 -8
  54. package/src/PostgresBackendDriver.ts +172 -6
  55. package/src/PostgresBootstrapper.ts +136 -11
  56. package/src/auth/ensure-tables.ts +212 -91
  57. package/src/auth/services.ts +82 -5
  58. package/src/backup/backup-cli.ts +59 -57
  59. package/src/cli-errors.ts +6 -6
  60. package/src/cli-helpers.ts +132 -13
  61. package/src/cli-output.ts +43 -0
  62. package/src/cli.ts +371 -161
  63. package/src/collections/buildRegistry.ts +3 -1
  64. package/src/collections/validate-relations.ts +124 -17
  65. package/src/data-transformer.ts +142 -28
  66. package/src/history/ensure-history-table.ts +9 -2
  67. package/src/schema/auth-schema.ts +17 -1
  68. package/src/schema/auth-users-columns.ts +131 -0
  69. package/src/schema/doctor-cli.ts +14 -65
  70. package/src/schema/doctor-policy-checks.ts +105 -0
  71. package/src/schema/doctor.ts +156 -77
  72. package/src/schema/ensure-collection-policies.ts +99 -6
  73. package/src/schema/ensure-collection-tables.ts +374 -32
  74. package/src/schema/generate-drizzle-schema-logic.ts +152 -66
  75. package/src/schema/generate-drizzle-schema.ts +11 -10
  76. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  77. package/src/schema/generate-postgres-ddl.ts +38 -14
  78. package/src/schema/generated-schema-staleness.ts +171 -0
  79. package/src/schema/introspect-db-inference.ts +9 -2
  80. package/src/schema/introspect-db-logic.ts +251 -75
  81. package/src/schema/introspect-db-project.ts +78 -0
  82. package/src/schema/introspect-db.ts +42 -25
  83. package/src/schema/introspect-runtime.ts +14 -2
  84. package/src/schema/non-sql-collections.test.ts +131 -0
  85. package/src/schema/rls-bootstrap-sql.ts +288 -0
  86. package/src/schema/search-column.ts +643 -0
  87. package/src/security/anonymous-grants.test.ts +4 -2
  88. package/src/security/policy-drift.test.ts +104 -3
  89. package/src/security/policy-drift.ts +129 -7
  90. package/src/security/rls-enforcement.ts +150 -7
  91. package/src/services/BranchService.ts +5 -0
  92. package/src/services/FetchService.ts +253 -115
  93. package/src/services/PersistService.ts +82 -43
  94. package/src/services/RelationService.ts +37 -696
  95. package/src/services/RelationWriteService.ts +653 -0
  96. package/src/services/cdc/trigger-cdc.ts +5 -1
  97. package/src/services/channel-history.ts +14 -0
  98. package/src/services/channel-presence.ts +13 -0
  99. package/src/services/collection-helpers.ts +89 -4
  100. package/src/services/dataService.ts +5 -0
  101. package/src/services/junction-writes.ts +295 -0
  102. package/src/services/pg-notify-listener.ts +1 -1
  103. package/src/services/realtimeService.ts +382 -118
  104. package/src/services/write-denial.ts +55 -0
  105. package/src/utils/drizzle-conditions.ts +433 -35
  106. package/src/utils/pg-error-utils.ts +8 -3
  107. package/src/websocket.ts +113 -16
  108. package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
  109. package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
  110. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  111. package/dist/policy-CeA1JcxP.js +0 -105
  112. package/dist/policy-CeA1JcxP.js.map +0 -1
  113. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  114. package/dist/src-DlPBctw_.js.map +0 -1
  115. package/dist/src-DoU9yPqq.js.map +0 -1
  116. package/dist/websocket-B2LsrINK.js.map +0 -1
  117. 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
  }
@@ -954,6 +1088,16 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
954
1088
  collectionPath: string,
955
1089
  options: {
956
1090
  filter?: FilterValues<Extract<keyof M, string>>;
1091
+ /**
1092
+ * An `or(...)`/`and(...)` group, applied alongside `filter`.
1093
+ *
1094
+ * `fetchRowsWithConditions` below has always applied this; it was
1095
+ * simply absent from this signature, so the only callers that could
1096
+ * pass one were the ones that went around this method. Realtime
1097
+ * came through here, which is why a subscription filtered by a
1098
+ * logical group was pushed every row in the table.
1099
+ */
1100
+ logical?: LogicalCondition;
957
1101
  orderBy?: string;
958
1102
  order?: "desc" | "asc";
959
1103
  limit?: number;
@@ -984,10 +1128,21 @@ relatedTo: hop });
984
1128
  searchString: string,
985
1129
  options: {
986
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;
987
1140
  orderBy?: string;
988
1141
  order?: "desc" | "asc";
989
1142
  limit?: number;
990
1143
  databaseId?: string;
1144
+ /** Ask each row which declared search field matched. */
1145
+ searchExplain?: boolean;
991
1146
  } = {}
992
1147
  ): Promise<Record<string, unknown>[]> {
993
1148
  return this.fetchRowsWithConditions<M>(collectionPath, {
@@ -1024,7 +1179,7 @@ relatedTo: hop });
1024
1179
 
1025
1180
  if (options.searchString) {
1026
1181
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1027
- options.searchString, collection.properties, table
1182
+ options.searchString, collection.properties, table, collection
1028
1183
  );
1029
1184
  if (searchConditions.length === 0) return 0;
1030
1185
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1216,6 +1371,14 @@ relatedTo: hop }, include
1216
1371
  }
1217
1372
  }
1218
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;
1219
1382
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
1220
1383
  }
1221
1384
  }
@@ -1232,6 +1395,14 @@ relatedTo: hop }, include
1232
1395
  (row as Record<string, unknown>)[key] = relatedList;
1233
1396
  }
1234
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;
1235
1406
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
1236
1407
  }
1237
1408
  }
@@ -1298,8 +1469,9 @@ relatedTo: hop }, include
1298
1469
  }
1299
1470
 
1300
1471
  // Fallback: db.select + N+1 relation loading
1472
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
1301
1473
  const result = await this.db
1302
- .select()
1474
+ .select(visibleOne as never)
1303
1475
  .from(table)
1304
1476
  .where(eq(idField, parsedId))
1305
1477
  .limit(1);
@@ -1339,6 +1511,14 @@ relatedTo: hop }, include
1339
1511
  }));
1340
1512
  }
1341
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;
1342
1522
  logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
1343
1523
  }
1344
1524
  }
@@ -1353,12 +1533,26 @@ relatedTo: hop }, include
1353
1533
  collectionPath: string,
1354
1534
  options: {
1355
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;
1356
1549
  orderBy?: string;
1357
1550
  order?: "desc" | "asc";
1358
1551
  limit?: number;
1359
1552
  offset?: number;
1360
1553
  startAfter?: Record<string, unknown>;
1361
1554
  searchString?: string;
1555
+ searchExplain?: boolean;
1362
1556
  vectorSearch?: VectorSearchParams;
1363
1557
  relatedTo?: NestedPathHop;
1364
1558
  } = {}
@@ -1374,17 +1568,41 @@ relatedTo: hop }, include
1374
1568
  vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
1375
1569
  }
1376
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
+
1377
1589
  let query = vectorMeta
1378
- ? this.db.select({ table_row: table,
1590
+ ? this.db.select({ table_row: (visible ?? table) as never,
1379
1591
  _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1380
- : 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());
1381
1599
  const allConditions: SQL[] = [];
1382
1600
 
1383
1601
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
1384
1602
 
1385
1603
  if (options.searchString) {
1386
1604
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(
1387
- options.searchString, collection.properties, table
1605
+ options.searchString, collection.properties, table, collection
1388
1606
  );
1389
1607
  if (searchConditions.length === 0) return [];
1390
1608
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions)!);
@@ -1395,6 +1613,11 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1395
1613
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
1396
1614
  }
1397
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
+
1398
1621
  if (vectorMeta?.filter) {
1399
1622
  allConditions.push(vectorMeta.filter);
1400
1623
  }
@@ -1408,7 +1631,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1408
1631
  if (vectorMeta) {
1409
1632
  orderExpressions.push(asc(vectorMeta.orderBy));
1410
1633
  } else if (options.orderBy) {
1411
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1634
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
1412
1635
  if (orderByField) {
1413
1636
  orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
1414
1637
  }
@@ -1433,6 +1656,14 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1433
1656
  }));
1434
1657
  }
1435
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
+
1436
1667
  return rawResults as Record<string, unknown>[];
1437
1668
  }
1438
1669
 
@@ -1454,99 +1685,6 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
1454
1685
  return !!this.getQueryBuilder(tableName);
1455
1686
  }
1456
1687
 
1457
- /**
1458
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
1459
- * for efficient JOIN-based relation loading.
1460
- * Returns null if the API is not available or the query fails.
1461
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
1462
- */
1463
- private async fetchWithDrizzleQuery<M extends Record<string, unknown>>(
1464
- collectionPath: string,
1465
- collection: CollectionConfig,
1466
- options: {
1467
- filter?: FilterValues<Extract<keyof M, string>>;
1468
- orderBy?: string;
1469
- order?: "desc" | "asc";
1470
- limit?: number;
1471
- },
1472
- include: string[],
1473
- idInfo: { fieldName: string; type: "string" | "number" },
1474
- idInfoArray?: { fieldName: string; type: "string" | "number" }[]
1475
- ): Promise<Record<string, unknown>[] | null> {
1476
- try {
1477
-
1478
- const table = getTableForCollection(collection, this.registry);
1479
- const tableName = getTableName(table);
1480
- const queryTarget = this.getQueryBuilder(tableName);
1481
-
1482
- if (!queryTarget?.findMany) return null;
1483
-
1484
- // Build the `with` config from include array
1485
- const resolvedRelations = resolveCollectionRelations(collection);
1486
- const withConfig: Record<string, boolean> = {};
1487
- for (const [key, relation] of Object.entries(resolvedRelations)) {
1488
- if (include[0] === "*" || include.includes(key)) {
1489
- // Use the Drizzle relation name (from the schema)
1490
- const drizzleRelName = relation.relationName || key;
1491
- withConfig[drizzleRelName] = true;
1492
- }
1493
- }
1494
-
1495
- // Build query options
1496
- const queryOpts: Record<string, unknown> = { with: withConfig };
1497
- if (options.limit) queryOpts.limit = options.limit;
1498
-
1499
- // Build where clause
1500
- if (options.filter) {
1501
- const filterConditions = this.buildFilterConditions(
1502
- options.filter, table, collectionPath
1503
- );
1504
- if (filterConditions.length > 0) {
1505
- queryOpts.where = and(...filterConditions);
1506
- }
1507
- }
1508
-
1509
- // Build orderBy
1510
- if (options.orderBy) {
1511
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
1512
- if (orderByField) {
1513
- queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
1514
- }
1515
- }
1516
-
1517
-
1518
- const results = await queryTarget.findMany(queryOpts as Parameters<NonNullable<typeof queryTarget>["findMany"]>[0]);
1519
-
1520
- // Inline the nested Drizzle results, columns only — no synthesized id.
1521
- return results.map((row: Record<string, unknown>) => {
1522
- const flat: Record<string, unknown> = {};
1523
- for (const [k, v] of Object.entries(row)) {
1524
- if (Array.isArray(v)) {
1525
- // Many relation — inline each nested row
1526
- flat[k] = v.map((item: Record<string, unknown>) => {
1527
- // Junction table rows may have the target nested, unwrap those
1528
- const keys = Object.keys(item);
1529
- const nestedObj = keys.find(nk => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
1530
- if (nestedObj && keys.length <= 3) {
1531
- return { ...(item[nestedObj] as Record<string, unknown>) };
1532
- }
1533
- return { ...item };
1534
- });
1535
- } else if (typeof v === "object" && v !== null) {
1536
- // One-to-one relation — inline the target's columns
1537
- flat[k] = { ...(v as Record<string, unknown>) };
1538
- } else {
1539
- flat[k] = v;
1540
- }
1541
- }
1542
- return flat;
1543
- });
1544
- } catch (e) {
1545
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
1546
- return null;
1547
- }
1548
- }
1549
-
1550
1688
  /**
1551
1689
  * Fallback path used when db.query is unavailable.
1552
1690
  * The primary path uses db.query.findMany with `with` config, which