@rebasepro/server-postgres 0.14.2-canary.gca521e9 → 0.15.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 (25) hide show
  1. package/dist/{auth-users-columns-DLwxDqYb.js → auth-users-columns-JJ8ngvy5.js} +16 -6
  2. package/dist/{auth-users-columns-DLwxDqYb.js.map → auth-users-columns-JJ8ngvy5.js.map} +1 -1
  3. package/dist/{backup-service-BH0Dzo_h.js → backup-service-czK-OAuG.js} +2 -2
  4. package/dist/{backup-service-BH0Dzo_h.js.map → backup-service-czK-OAuG.js.map} +1 -1
  5. package/dist/{ensure-collection-policies-KzpXpHEL.js → ensure-collection-policies-D5PtQLyR.js} +3 -3
  6. package/dist/{ensure-collection-policies-KzpXpHEL.js.map → ensure-collection-policies-D5PtQLyR.js.map} +1 -1
  7. package/dist/{ensure-collection-tables-Dnb7scJ1.js → ensure-collection-tables-BHUjQ-z4.js} +3 -3
  8. package/dist/{ensure-collection-tables-Dnb7scJ1.js.map → ensure-collection-tables-BHUjQ-z4.js.map} +1 -1
  9. package/dist/index.es.js +448 -17
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/{rls-bootstrap-sql-69hYT8nr.js → rls-bootstrap-sql-B5Sajku6.js} +2 -2
  12. package/dist/{rls-bootstrap-sql-69hYT8nr.js.map → rls-bootstrap-sql-B5Sajku6.js.map} +1 -1
  13. package/dist/{rls-enforcement-Kz0NpF8J.js → rls-enforcement-BDBfuTD4.js} +4 -3
  14. package/dist/rls-enforcement-BDBfuTD4.js.map +1 -0
  15. package/dist/services/FetchService.d.ts +22 -0
  16. package/dist/{src-DCdn3Val.js → src-BBFsDaeA.js} +60 -2
  17. package/dist/src-BBFsDaeA.js.map +1 -0
  18. package/dist/utils/drizzle-conditions.d.ts +168 -1
  19. package/dist/{websocket-D2jXv0Ds.js → websocket-BVgDVO-V.js} +2 -2
  20. package/dist/{websocket-D2jXv0Ds.js.map → websocket-BVgDVO-V.js.map} +1 -1
  21. package/package.json +6 -6
  22. package/src/services/FetchService.ts +155 -15
  23. package/src/utils/drizzle-conditions.ts +594 -1
  24. package/dist/rls-enforcement-Kz0NpF8J.js.map +0 -1
  25. package/dist/src-DCdn3Val.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -3,12 +3,12 @@ import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
5
5
  import { n as resolveClientListLimit, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
6
- import { $ as mergeDeep, A as fieldKeyForColumn, B as parseIdValues, C as getJunctionCollectionConfig, D as policyToPostgres, E as getEffectiveSecurityRules, F as getTableVarName, G as updateDateAutoValues, H as createRelationRef, I as resolveCollectionRelations, J as legacyForeignKeyName, K as firstFreeKey, L as buildCompositeId, M as getColumnName, N as getEnumVarName, O as securityRuleToConditions, P as getTableName$1, Q as isPrototypePollutingKey, R as getDeclaredPrimaryKeys, S as resolveStringColumnLength, T as resolveJunctionSpecs, U as createRelationRefWithData, V as sortCollectionsBySlug, W as normalizeToEntityRelation, X as toWireKey, Z as getPolicyNamesForRule, _ as OrderBySpecError, at as isManyToMany, b as CollectionRegistry, et as camelCase, g as buildSdkData, h as visibleColumnProjection, it as hasForeignKeyOnTarget, j as findRelation, l as buildSearchColumnSpec, ot as Vector, q as generateForeignKeyName, r as authUsersColumnSql, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as toSnakeCase, u as hiddenColumnsOption, v as normalizeDriverOrderBy, w as getJunctionSecurityRules, x as relationalCollections, y as parseOrderBySpecStrict, z as isAddressableId } from "./auth-users-columns-DLwxDqYb.js";
7
- import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, l as isRelationalCollectionConfig } from "./src-DCdn3Val.js";
8
- import { t as createPostgresWebSocket } from "./websocket-D2jXv0Ds.js";
9
- import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-Kz0NpF8J.js";
10
- import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-BH0Dzo_h.js";
11
- import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-69hYT8nr.js";
6
+ import { $ as mergeDeep, A as fieldKeyForColumn, B as parseIdValues, C as getJunctionCollectionConfig, D as policyToPostgres, E as getEffectiveSecurityRules, F as getTableVarName, G as updateDateAutoValues, H as createRelationRef, I as resolveCollectionRelations, J as legacyForeignKeyName, K as firstFreeKey, L as buildCompositeId, M as getColumnName, N as getEnumVarName, O as securityRuleToConditions, P as getTableName$1, Q as isPrototypePollutingKey, R as getDeclaredPrimaryKeys, S as resolveStringColumnLength, T as resolveJunctionSpecs, U as createRelationRefWithData, V as sortCollectionsBySlug, W as normalizeToEntityRelation, X as toWireKey, Z as getPolicyNamesForRule, _ as OrderBySpecError, at as isManyToMany, b as CollectionRegistry, et as camelCase, g as buildSdkData, h as visibleColumnProjection, it as hasForeignKeyOnTarget, j as findRelation, l as buildSearchColumnSpec, ot as Vector, q as generateForeignKeyName, r as authUsersColumnSql, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as toSnakeCase, u as hiddenColumnsOption, v as normalizeDriverOrderBy, w as getJunctionSecurityRules, x as relationalCollections, y as parseOrderBySpecStrict, z as isAddressableId } from "./auth-users-columns-JJ8ngvy5.js";
7
+ import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, g as encodeRelationAggregateSort, l as isRelationalCollectionConfig, v as parseRelationAggregateSort } from "./src-BBFsDaeA.js";
8
+ import { t as createPostgresWebSocket } from "./websocket-BVgDVO-V.js";
9
+ import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-BDBfuTD4.js";
10
+ import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBackupDestination, E as joinStorageKey, M as serverVersionNumToMajor, N as splitGlobalsStatements, O as parseBackupTimestamp, P as withDatabaseName, S as buildRowSecurityPgOptions, T as globalsFileForDump, _ as buildBackupFilename, a as detectToolMajor, b as buildPgRestoreArgs, c as listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-czK-OAuG.js";
11
+ import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-B5Sajku6.js";
12
12
  import { Client, Pool } from "pg";
13
13
  import { drizzle } from "drizzle-orm/node-postgres";
14
14
  import { ApiError, createDdlBootstrapper, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
@@ -552,6 +552,22 @@ var targetOf = (relation) => {
552
552
  return;
553
553
  }
554
554
  };
555
+ /**
556
+ * The SQL each aggregate sort function compiles to.
557
+ *
558
+ * A lookup rather than interpolation, and `sql.raw` applied to five constants
559
+ * written out here rather than to anything derived from a request. The key is
560
+ * already validated by `parseRelationAggregateSort` — this is the second lock
561
+ * on the same door, so that a future caller reaching the builder directly
562
+ * cannot put a string from the wire where a function name goes.
563
+ */
564
+ var AGGREGATE_SQL = {
565
+ min: sql.raw("min"),
566
+ max: sql.raw("max"),
567
+ count: sql.raw("count"),
568
+ sum: sql.raw("sum"),
569
+ avg: sql.raw("avg")
570
+ };
555
571
  /** Column types `ILIKE '%…%'` is defined on. */
556
572
  var ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
557
573
  /**
@@ -616,6 +632,27 @@ function getUnknownFilterFieldsMode() {
616
632
  * of habit, and refusing it would be pedantry about a distinction this layer
617
633
  * erases anyway.
618
634
  */
635
+ /**
636
+ * Split `applications.status` into the relation and the column it addresses.
637
+ *
638
+ * One dot, and only the first one: a relation name cannot contain a dot, and
639
+ * everything after it is handed to the target as a single column key. A second
640
+ * dot would be a second hop — `talents.applications.job.title` — which is a
641
+ * different feature (it needs a chain of subqueries, and a chain has to decide
642
+ * what "some" means at every level), so it is refused rather than silently read
643
+ * as a column named `job.title` that no table has.
644
+ *
645
+ * Returns `undefined` for a field with no dot, which leaves every existing
646
+ * filter on exactly the path it took before.
647
+ */
648
+ function parseRelationFieldPath(field) {
649
+ const dot = field.indexOf(".");
650
+ if (dot <= 0 || dot === field.length - 1) return void 0;
651
+ return {
652
+ relationKey: field.slice(0, dot),
653
+ fieldKey: field.slice(dot + 1)
654
+ };
655
+ }
619
656
  function parseJsonFieldPath(field) {
620
657
  if (!field.includes("->")) return void 0;
621
658
  const segments = field.split(/->>?/).map((s) => s.trim()).filter(Boolean);
@@ -819,6 +856,11 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
819
856
  sourceIdColumn: correlationColumn
820
857
  };
821
858
  }
859
+ const relationField = parseRelationFieldPath(field);
860
+ if (relationField && registry && sourceIdColumn) {
861
+ const target = this.resolveRelationFieldTarget(table, relationField, collection, registry, sourceIdColumn, field, collectionPath);
862
+ if (target) return target;
863
+ }
822
864
  }
823
865
  for (const guess of [
824
866
  `${field}Id`,
@@ -847,6 +889,59 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
847
889
  });
848
890
  }
849
891
  /**
892
+ * `applications.status` — the relation, and the column of the target it
893
+ * addresses.
894
+ *
895
+ * `undefined` when the first segment names no relation: the field simply is
896
+ * not a relation path, and resolution carries on to the guesses and then to
897
+ * the unknown-field answer, which is where a typo belongs. A segment that
898
+ * *does* name a relation is a different matter — the author plainly meant
899
+ * this shape — so everything after that point throws rather than returning,
900
+ * naming what went wrong. Falling through would report "unknown filter
901
+ * field 'applications.status'" and list the columns of the wrong table.
902
+ *
903
+ * `via` is refused for the reason it is absent from
904
+ * `filterableRelationKinds`: its join path is authored source → target with
905
+ * no stated inverse, so there is nothing to correlate a subquery back to.
906
+ */
907
+ static resolveRelationFieldTarget(table, path, collection, registry, sourceIdColumn, field, collectionPath) {
908
+ const relation = resolveCollectionRelations(collection)[path.relationKey];
909
+ if (!relation) return void 0;
910
+ if (relation.kind === "via") throw ApiError.badRequest(`Cannot filter by '${field}' on collection '${collectionPath}': '${path.relationKey}' is a \`via\` relation, whose join path is authored one way only, so there is nothing to correlate a subquery back to.`, "UNSUPPORTED_RELATION_FILTER", {
911
+ field,
912
+ collection: collectionPath,
913
+ relation: path.relationKey,
914
+ kind: relation.kind
915
+ });
916
+ const targetCollection = targetOf(relation);
917
+ const targetTable = targetCollection && registry.getTable(getTableName$1(targetCollection));
918
+ if (!targetCollection || !targetTable) throw new Error(`Table not found for the target of relation '${relation.relationName}' on '${collectionPath}', so '${field}' has nothing to filter against.`);
919
+ const targetColumn = relationColumn(targetTable, targetCollection, path.fieldKey) ?? (path.fieldKey in targetTable ? targetTable[path.fieldKey] : void 0);
920
+ if (!targetColumn) {
921
+ let validFields = [];
922
+ try {
923
+ validFields = Object.keys(getTableColumns(targetTable)).sort();
924
+ } catch {}
925
+ throw ApiError.badRequest(`Unknown field '${path.fieldKey}' on '${targetCollection.slug}', the target of relation '${path.relationKey}' on collection '${collectionPath}'` + (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""), "UNKNOWN_FILTER_FIELD", {
926
+ field,
927
+ collection: collectionPath,
928
+ relation: path.relationKey,
929
+ targetCollection: targetCollection.slug,
930
+ ...validFields.length > 0 && { validFields }
931
+ });
932
+ }
933
+ const correlationColumn = relation.kind === "belongsTo" ? relationColumn(table, collection, relation.localKey) : hasForeignKeyOnTarget(relation) && relation.sourceKey ? relationColumn(table, collection, relation.sourceKey) : sourceIdColumn;
934
+ if (!correlationColumn) throw new Error(`Relation '${relation.relationName}' on '${collectionPath}' names a key that is not a column there, so '${field}' has nothing to correlate against.`);
935
+ return {
936
+ kind: "relation-field",
937
+ relation,
938
+ registry,
939
+ sourceIdColumn: correlationColumn,
940
+ targetTable,
941
+ targetColumn
942
+ };
943
+ }
944
+ /**
850
945
  * Build filter conditions from FilterValues
851
946
  */
852
947
  static buildFilterConditions(filter, table, collectionPath, options = {}) {
@@ -881,6 +976,7 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
881
976
  static compileFilterTarget(target, op, value, field, collectionPath) {
882
977
  if (target.kind === "column") return this.buildSingleFilterCondition(target.column, op, value);
883
978
  if (target.kind === "json") return this.buildJsonPathCondition(target.column, target.path, op, value);
979
+ if (target.kind === "relation-field") return this.buildRelationFieldCondition(target, op, value, field, collectionPath);
884
980
  return this.buildRelationFilterCondition(target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath);
885
981
  }
886
982
  /**
@@ -1095,6 +1191,297 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1095
1191
  });
1096
1192
  }
1097
1193
  }
1194
+ /**
1195
+ * A filter on a *column of the related row* — `applications.status`.
1196
+ *
1197
+ * The same `EXISTS` {@link buildRelationFilterCondition} builds, with the
1198
+ * predicate moved off the target's id and onto one of its columns:
1199
+ *
1200
+ * EXISTS (SELECT 1 FROM talent_applications AS t
1201
+ * WHERE t.talent_id = talents.id
1202
+ * AND t.status IN ('applied', 'reviewing', 'interview'))
1203
+ *
1204
+ * which is the shape every "who is waiting" queue is written in. Without
1205
+ * it the only way to ask is to fetch every row and filter in the browser,
1206
+ * and a filter the client applies after paging is not a filter — the page
1207
+ * was already chosen without it.
1208
+ *
1209
+ * A many-to-many needs one more table than the id filter does. That one
1210
+ * stops at the junction, because the junction already holds the value it
1211
+ * compares; a column of the target is a table further out, so the subquery
1212
+ * joins the target to the junction and correlates from the junction. The
1213
+ * join is inside `EXISTS`, so it cannot multiply the outer rows the way a
1214
+ * top-level join through a junction would.
1215
+ *
1216
+ * `belongsTo` is included even though its foreign key is a column here:
1217
+ * `author.name` is a column of another table either way, and refusing the
1218
+ * one relation kind that reads most naturally would be a rule about
1219
+ * implementation rather than about meaning.
1220
+ *
1221
+ * Under RLS the subquery runs as the reader, so it sees the target rows
1222
+ * that reader's policies allow and no others. On the positive direction
1223
+ * that is exactly right. On the negative — `!=`, `not-in`, and any
1224
+ * `NOT EXISTS` — "no related row satisfies this" and "no related row this
1225
+ * reader can see satisfies this" are the same sentence, so a target table
1226
+ * with row-level security and no `SELECT` policy for `rebase_user` makes
1227
+ * every row look unmatched and the negative filter over-reports. Nothing is
1228
+ * leaked: the outer table's own policies still decide which rows exist. The
1229
+ * cause is a missing policy on the target rather than anything here, and it
1230
+ * is the same caveat the id-filter path carries.
1231
+ */
1232
+ static buildRelationFieldCondition(target, op, value, field, collectionPath) {
1233
+ const alias = "__rel_field";
1234
+ const { relation, targetTable, targetColumn } = target;
1235
+ const ref = sql`${sql.identifier(alias)}.${sql.identifier(targetColumn.name)}`;
1236
+ const { predicate, negate } = this.buildRelationColumnPredicate(ref, targetColumn, op, value, field, collectionPath);
1237
+ let from;
1238
+ let correlation;
1239
+ if (relation.kind === "manyToMany") {
1240
+ const { table: junctionName, sourceColumn, targetColumn: junctionTargetColumn } = relation.through;
1241
+ const junctionTable = target.registry.getTable(junctionName);
1242
+ if (!junctionTable) throw new Error(`Junction table not found: ${junctionName}`);
1243
+ const sourceCol = junctionTable[sourceColumn];
1244
+ const targetCol = junctionTable[junctionTargetColumn];
1245
+ if (!sourceCol || !targetCol) throw new Error(`Junction columns '${sourceColumn}'/'${junctionTargetColumn}' not found in '${junctionName}'`);
1246
+ const targetKey = this.primaryKeyColumn(targetTable);
1247
+ if (!targetKey) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}', so '${field}' has nothing to join the junction against.`);
1248
+ const junctionAlias = "__rel_field_junction";
1249
+ from = sql`${targetTable} AS ${sql.identifier(alias)} INNER JOIN ${junctionTable} AS ${sql.identifier(junctionAlias)} ON ${sql.identifier(junctionAlias)}.${sql.identifier(targetCol.name)} = ${sql.identifier(alias)}.${sql.identifier(targetKey.name)}`;
1250
+ correlation = sql`${sql.identifier(junctionAlias)}.${sql.identifier(sourceCol.name)} = ${target.sourceIdColumn}`;
1251
+ } else if (relation.kind === "belongsTo") {
1252
+ const targetKey = this.primaryKeyColumn(targetTable);
1253
+ if (!targetKey) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}', so '${field}' has nothing to correlate against.`);
1254
+ from = sql`${targetTable} AS ${sql.identifier(alias)}`;
1255
+ correlation = sql`${sql.identifier(alias)}.${sql.identifier(targetKey.name)} = ${target.sourceIdColumn}`;
1256
+ } else {
1257
+ const foreignKey = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
1258
+ if (!foreignKey) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
1259
+ from = sql`${targetTable} AS ${sql.identifier(alias)}`;
1260
+ correlation = sql`${sql.identifier(alias)}.${sql.identifier(foreignKey.name)} = ${target.sourceIdColumn}`;
1261
+ }
1262
+ const where = predicate ? sql`${correlation} AND ${predicate}` : correlation;
1263
+ const exists = sql`EXISTS (SELECT 1 FROM ${from} WHERE ${where})`;
1264
+ return negate ? sql`NOT ${exists}` : exists;
1265
+ }
1266
+ /**
1267
+ * The inner predicate of a relation *column* filter, and whether the
1268
+ * `EXISTS` wrapping it is negated.
1269
+ *
1270
+ * The negation rule is the one {@link buildRelationFilterPredicate} states
1271
+ * and holds for exactly the same reason, one column over. A negative
1272
+ * operator is `NOT EXISTS` of the **positive** predicate, never `EXISTS` of
1273
+ * a negated one: `EXISTS (… AND status != 'hired')` asks "does some
1274
+ * application differ from hired", which is true of nearly every candidate
1275
+ * with more than one application and answers nothing anybody asked.
1276
+ * `NOT EXISTS (… AND status = 'hired')` asks "is there no hired
1277
+ * application", which is what unticking a value means — and it makes `==`
1278
+ * and `!=` partition the rows, the way a filter implies they do.
1279
+ *
1280
+ * `is-null` and `is-not-null` are the exception, and deliberately not a
1281
+ * complementary pair here. On a column they compile to `EXISTS (… AND col
1282
+ * IS NULL)` and `EXISTS (… AND col IS NOT NULL)` — "has a related row whose
1283
+ * column is unset" and "has one where it is set" — which is the plain
1284
+ * reading of `applications.status is-not-null` and the useful one. They are
1285
+ * both true of a candidate with two applications, one of each. Making
1286
+ * `is-not-null` the negation instead would make it "no application has an
1287
+ * unset status", which is true of a candidate with no applications at all
1288
+ * and so answers a queue with the very rows the queue exists to exclude.
1289
+ *
1290
+ * Unlike the id path, every operator is available: the compared value is an
1291
+ * ordinary column, so `>=` on a date and `ilike` on a name mean here what
1292
+ * they mean anywhere else. Only an operator that does not exist is refused,
1293
+ * and it throws rather than returning `null` — a dropped condition widens
1294
+ * the read, which is the whole reason this file fails closed.
1295
+ */
1296
+ static buildRelationColumnPredicate(ref, column, op, value, field, collectionPath) {
1297
+ value = unwrapRelationFilterValue(value);
1298
+ const isNullish = value === null || value === void 0;
1299
+ const equals = () => sql`${ref} = ${value}`;
1300
+ const inList = () => {
1301
+ const values = toMembershipList(value);
1302
+ return values.length === 0 ? sql`FALSE` : sql`${ref} IN (${sql.join(values.map((v) => sql`${v}`), sql`, `)})`;
1303
+ };
1304
+ const isNull = () => sql`${ref} IS NULL`;
1305
+ const contains = () => {
1306
+ const meta = getColumnMeta(column);
1307
+ const isNativeArray = meta.dataType === "array" || meta.columnType === "PgArray";
1308
+ if (op === "array-contains-any") {
1309
+ if (Array.isArray(value) && value.length === 0) return sql`FALSE`;
1310
+ if (Array.isArray(value) && value.length > 0) return isNativeArray ? sql`${ref} && ARRAY[${sql.join(value.map((v) => sql`${v}`), sql`, `)}]` : sql`${ref} ?| array[${sql.join(value.map((v) => sql`${String(v)}`), sql`, `)}]`;
1311
+ }
1312
+ return isNativeArray ? sql`${ref} @> ARRAY[${value}]` : sql`${ref} @> ${JSON.stringify([value])}`;
1313
+ };
1314
+ switch (op) {
1315
+ case "==": return {
1316
+ predicate: isNullish ? isNull() : equals(),
1317
+ negate: false
1318
+ };
1319
+ case "!=": return {
1320
+ predicate: isNullish ? isNull() : equals(),
1321
+ negate: true
1322
+ };
1323
+ case ">": return {
1324
+ predicate: sql`${ref} > ${value}`,
1325
+ negate: false
1326
+ };
1327
+ case ">=": return {
1328
+ predicate: sql`${ref} >= ${value}`,
1329
+ negate: false
1330
+ };
1331
+ case "<": return {
1332
+ predicate: sql`${ref} < ${value}`,
1333
+ negate: false
1334
+ };
1335
+ case "<=": return {
1336
+ predicate: sql`${ref} <= ${value}`,
1337
+ negate: false
1338
+ };
1339
+ case "in": return {
1340
+ predicate: isNullish ? isNull() : inList(),
1341
+ negate: false
1342
+ };
1343
+ case "not-in": return {
1344
+ predicate: isNullish ? isNull() : inList(),
1345
+ negate: true
1346
+ };
1347
+ case "like": return {
1348
+ predicate: sql`${ref} LIKE ${String(value)}`,
1349
+ negate: false
1350
+ };
1351
+ case "not-like": return {
1352
+ predicate: sql`${ref} LIKE ${String(value)}`,
1353
+ negate: true
1354
+ };
1355
+ case "ilike":
1356
+ case "not-ilike":
1357
+ if (!supportsILike(column)) throw ApiError.badRequest(`Operator '${op}' cannot be applied to '${field}' on collection '${collectionPath}': '${column.name}' is ${column.getSQLType?.() ?? "not a text column"}, and case-insensitive matching is only defined on text.`, "UNSUPPORTED_RELATION_FILTER_OPERATOR", {
1358
+ field,
1359
+ collection: collectionPath,
1360
+ operator: op
1361
+ });
1362
+ return {
1363
+ predicate: sql`${ref} ILIKE ${String(value)}`,
1364
+ negate: op === "not-ilike"
1365
+ };
1366
+ case "is-null": return {
1367
+ predicate: isNull(),
1368
+ negate: false
1369
+ };
1370
+ case "is-not-null": return {
1371
+ predicate: sql`${ref} IS NOT NULL`,
1372
+ negate: false
1373
+ };
1374
+ case "array-contains":
1375
+ case "array-contains-any": return {
1376
+ predicate: contains(),
1377
+ negate: false
1378
+ };
1379
+ default: throw ApiError.badRequest(`Unknown filter operator '${op}'. Valid operators: ${ALL_WHERE_FILTER_OPS.join(", ")}.`, "UNKNOWN_FILTER_OPERATOR", {
1380
+ operator: op,
1381
+ validOperators: ALL_WHERE_FILTER_OPS
1382
+ });
1383
+ }
1384
+ }
1385
+ /**
1386
+ * An aggregate over the rows a relation reaches, as a scalar expression —
1387
+ * what `orderBy: [{ relation: "applications", field: "created_at", agg:
1388
+ * "min" }, "asc"]` compiles to.
1389
+ *
1390
+ * (SELECT min(t.created_at) FROM talent_applications AS t
1391
+ * WHERE t.talent_id = talents.id)
1392
+ *
1393
+ * A correlated scalar subquery rather than a `LEFT JOIN LATERAL`: the join
1394
+ * would have to be threaded into a query the relational query builder
1395
+ * assembles, while a scalar expression drops straight into `ORDER BY` and
1396
+ * into the keyset comparison behind cursor paging — which has to be the
1397
+ * *same* expression, or paging and ordering disagree and rows are skipped.
1398
+ *
1399
+ * `correlateTo` is what the subquery is pinned against. Left out, it is the
1400
+ * outer row's key column and the expression is correlated in the ordinary
1401
+ * way. Given a literal — the cursor row's id — the subquery stops being
1402
+ * correlated at all, so Postgres evaluates it once for the whole statement
1403
+ * rather than per row. That is how a cursor pages over an aggregate it has
1404
+ * no stored value for: the value is recomputed from the id it does have.
1405
+ *
1406
+ * Over zero related rows `count` is 0 and every other function is NULL,
1407
+ * which is what puts "nobody waiting" at a defined end of the order rather
1408
+ * than wherever a missing value would land. See `buildOrderExpressions` for
1409
+ * where that end is pinned.
1410
+ *
1411
+ * Under RLS the subquery runs as the reader, so a related row the reader
1412
+ * cannot see does not contribute — an aggregate is over the rows that
1413
+ * reader can see, which is the only total it could honestly report.
1414
+ */
1415
+ static buildRelationAggregateExpression(spec, table, collection, registry, sourceIdColumn, collectionPath, correlateTo) {
1416
+ const relation = resolveCollectionRelations(collection)[spec.relation];
1417
+ if (!relation) throw ApiError.badRequest(`Cannot sort by '${encodeRelationAggregateSort(spec)}' on collection '${collectionPath}': '${spec.relation}' is not a relation there.`, "UNKNOWN_ORDER_BY_FIELD", {
1418
+ field: encodeRelationAggregateSort(spec),
1419
+ collection: collectionPath,
1420
+ relation: spec.relation
1421
+ });
1422
+ if (relation.kind === "via") throw ApiError.badRequest(`Cannot sort by '${encodeRelationAggregateSort(spec)}' on collection '${collectionPath}': \`via\` relations are authored one way only, so there is nothing to correlate a subquery back to.`, "ORDER_BY_FIELD_NOT_SORTABLE", {
1423
+ field: encodeRelationAggregateSort(spec),
1424
+ collection: collectionPath,
1425
+ kind: relation.kind
1426
+ });
1427
+ const targetCollection = targetOf(relation);
1428
+ const targetTable = targetCollection && registry.getTable(getTableName$1(targetCollection));
1429
+ if (!targetCollection || !targetTable) throw new Error(`Table not found for the target of relation '${relation.relationName}' on '${collectionPath}', so there is nothing to aggregate.`);
1430
+ const alias = "__rel_agg";
1431
+ let aggregand = sql`*`;
1432
+ if (spec.field) {
1433
+ const column = relationColumn(targetTable, targetCollection, spec.field) ?? (spec.field in targetTable ? targetTable[spec.field] : void 0);
1434
+ if (!column) {
1435
+ let validFields = [];
1436
+ try {
1437
+ validFields = Object.keys(getTableColumns(targetTable)).sort();
1438
+ } catch {}
1439
+ throw ApiError.badRequest(`Unknown field '${spec.field}' on '${targetCollection.slug}', the target of relation '${spec.relation}' on collection '${collectionPath}'` + (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""), "UNKNOWN_ORDER_BY_FIELD", {
1440
+ field: encodeRelationAggregateSort(spec),
1441
+ collection: collectionPath,
1442
+ relation: spec.relation,
1443
+ targetCollection: targetCollection.slug,
1444
+ ...validFields.length > 0 && { validFields }
1445
+ });
1446
+ }
1447
+ aggregand = sql`${sql.identifier(alias)}.${sql.identifier(column.name)}`;
1448
+ } else if (spec.agg !== "count") throw ApiError.badRequest(`'${spec.agg}' needs a field to aggregate — only 'count' means something on its own.`, "ORDER_BY_FIELD_NOT_SORTABLE", {
1449
+ field: encodeRelationAggregateSort(spec),
1450
+ collection: collectionPath
1451
+ });
1452
+ const source = correlateTo === void 0 ? sql`${sourceIdColumn}` : sql`${correlateTo}`;
1453
+ let from;
1454
+ let correlation;
1455
+ if (relation.kind === "manyToMany") {
1456
+ const { table: junctionName, sourceColumn, targetColumn } = relation.through;
1457
+ const junctionTable = registry.getTable(junctionName);
1458
+ if (!junctionTable) throw new Error(`Junction table not found: ${junctionName}`);
1459
+ const sourceCol = junctionTable[sourceColumn];
1460
+ const targetCol = junctionTable[targetColumn];
1461
+ if (!sourceCol || !targetCol) throw new Error(`Junction columns '${sourceColumn}'/'${targetColumn}' not found in '${junctionName}'`);
1462
+ const targetKey = this.primaryKeyColumn(targetTable);
1463
+ if (!targetKey) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
1464
+ const junctionAlias = "__rel_agg_junction";
1465
+ from = sql`${targetTable} AS ${sql.identifier(alias)} INNER JOIN ${junctionTable} AS ${sql.identifier(junctionAlias)} ON ${sql.identifier(junctionAlias)}.${sql.identifier(targetCol.name)} = ${sql.identifier(alias)}.${sql.identifier(targetKey.name)}`;
1466
+ correlation = sql`${sql.identifier(junctionAlias)}.${sql.identifier(sourceCol.name)} = ${source}`;
1467
+ } else if (relation.kind === "belongsTo") {
1468
+ const targetKey = this.primaryKeyColumn(targetTable);
1469
+ const localKey = relationColumn(table, collection, relation.localKey);
1470
+ if (!targetKey || !localKey) throw new Error(`Relation '${relation.relationName}' on '${collectionPath}' names a key that is not a column, so there is nothing to aggregate against.`);
1471
+ from = sql`${targetTable} AS ${sql.identifier(alias)}`;
1472
+ const owner = correlateTo === void 0 ? sql`${localKey}` : sql`(SELECT ${sql.identifier(localKey.name)} FROM ${table} WHERE ${sourceIdColumn} = ${correlateTo})`;
1473
+ correlation = sql`${sql.identifier(alias)}.${sql.identifier(targetKey.name)} = ${owner}`;
1474
+ } else {
1475
+ const foreignKey = relationColumn(targetTable, targetCollection, relation.foreignKeyOnTarget);
1476
+ if (!foreignKey) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
1477
+ const sourceKeyColumn = relation.sourceKey ? relationColumn(table, collection, relation.sourceKey) : sourceIdColumn;
1478
+ if (!sourceKeyColumn) throw new Error(`\`sourceKey: "${relation.sourceKey}"\` on relation '${relation.relationName}' is not a column on '${collectionPath}'.`);
1479
+ const pinned = correlateTo === void 0 ? sql`${sourceKeyColumn}` : relation.sourceKey ? sql`(SELECT ${sql.identifier(sourceKeyColumn.name)} FROM ${table} WHERE ${sourceIdColumn} = ${correlateTo})` : sql`${correlateTo}`;
1480
+ from = sql`${targetTable} AS ${sql.identifier(alias)}`;
1481
+ correlation = sql`${sql.identifier(alias)}.${sql.identifier(foreignKey.name)} = ${pinned}`;
1482
+ }
1483
+ return sql`(SELECT ${AGGREGATE_SQL[spec.agg]}(${aggregand}) FROM ${from} WHERE ${correlation})`;
1484
+ }
1098
1485
  /** The column a table's rows are keyed by: its primary key, else `id`. */
1099
1486
  static primaryKeyColumn(table) {
1100
1487
  return Object.values(table).find((col) => col.primary) ?? Object.values(table).find((col) => col.name === "id");
@@ -3410,6 +3797,30 @@ var FetchService = class FetchService {
3410
3797
  return this.resolveOrderByField(table, orderBy, collection);
3411
3798
  }
3412
3799
  /**
3800
+ * The aggregate a sort key names, as an expression, or `undefined` if the
3801
+ * key is not one.
3802
+ *
3803
+ * `cursorId` builds the same expression pinned to the cursor row — see
3804
+ * {@link DrizzleConditionBuilder.buildRelationAggregateExpression}.
3805
+ *
3806
+ * A key that *parses* as an aggregate but names no relation, or a column
3807
+ * the target does not have, throws rather than falling through to the
3808
+ * column path. Falling through would report `min(applications.created_at)`
3809
+ * as an unknown column and list the columns of the wrong table.
3810
+ */
3811
+ resolveAggregateOrderTarget(table, orderBy, collection, collectionPath, cursorId) {
3812
+ const spec = parseRelationAggregateSort(orderBy);
3813
+ if (!spec) return void 0;
3814
+ if (!collection || !collectionPath) throw ApiError.badRequest(`Cannot sort by '${orderBy}': an aggregate sort needs the collection it is written against, and this query carries none.`, "ORDER_BY_FIELD_NOT_SORTABLE", { field: orderBy });
3815
+ const primaryKeys = getPrimaryKeys(collection, this.registry);
3816
+ const idColumn = primaryKeys.length === 1 ? table[primaryKeys[0].fieldName] : void 0;
3817
+ if (!idColumn) throw ApiError.badRequest(`Cannot sort by '${orderBy}' on collection '${collectionPath}': the subquery correlates on a single key column, and this collection has none or has a composite one.`, "ORDER_BY_FIELD_NOT_SORTABLE", {
3818
+ field: orderBy,
3819
+ collection: collectionPath
3820
+ });
3821
+ return DrizzleConditionBuilder.buildRelationAggregateExpression(spec, table, collection, this.registry, idColumn, collectionPath, cursorId);
3822
+ }
3823
+ /**
3413
3824
  * Resolve every sort key to the expression it orders by, in order of
3414
3825
  * significance.
3415
3826
  *
@@ -3418,9 +3829,19 @@ var FetchService = class FetchService {
3418
3829
  * unknown-field mode, where dropping is the configured answer, and dropping
3419
3830
  * one key of several still honours the ones that did resolve.
3420
3831
  */
3421
- resolveOrderKeys(table, keys, collection, searchString) {
3832
+ resolveOrderKeys(table, keys, collection, searchString, collectionPath, cursorId) {
3422
3833
  const resolved = [];
3423
3834
  for (const [field, direction] of keys) {
3835
+ const aggregate = this.resolveAggregateOrderTarget(table, field, collection, collectionPath);
3836
+ if (aggregate) {
3837
+ resolved.push({
3838
+ field,
3839
+ direction,
3840
+ target: aggregate,
3841
+ ...cursorId !== void 0 && { cursorTarget: this.resolveAggregateOrderTarget(table, field, collection, collectionPath, cursorId) }
3842
+ });
3843
+ continue;
3844
+ }
3424
3845
  const target = this.resolveOrderTarget(table, field, collection, searchString);
3425
3846
  if (target) resolved.push({
3426
3847
  field,
@@ -3440,9 +3861,18 @@ var FetchService = class FetchService {
3440
3861
  * to agree: they did not, and an ascending sort paged with `id >` against an
3441
3862
  * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
3442
3863
  * every page after the first.
3864
+ *
3865
+ * Where the NULLs go is written out rather than inherited. Postgres already
3866
+ * defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
3867
+ * changes no query — but {@link buildKeysetComparison} encodes that exact
3868
+ * placement, and an invariant two functions depend on should be stated in
3869
+ * both rather than assumed in one. It matters most for the keys that are
3870
+ * *always* nullable: an aggregate over a relation is NULL for every row the
3871
+ * relation reaches nothing from, which is precisely the "nobody waiting"
3872
+ * end of a queue.
3443
3873
  */
3444
3874
  buildOrderExpressions(keys, idField) {
3445
- const expressions = keys.map(({ direction, target }) => direction === "asc" ? asc(target) : desc(target));
3875
+ const expressions = keys.map(({ direction, target }) => direction === "asc" ? sql`${target} ASC NULLS LAST` : sql`${target} DESC NULLS FIRST`);
3446
3876
  expressions.push(desc(idField));
3447
3877
  return expressions;
3448
3878
  }
@@ -3630,12 +4060,12 @@ var FetchService = class FetchService {
3630
4060
  if (keys) {
3631
4061
  if (keys.some(([field]) => field === FetchService.SCORE_FIELD)) throw ApiError.badRequest("Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. Relevance is computed per query rather than stored, so it cannot key a cursor. Use `limit`/`offset` for relevance-ordered pages, or order by a column.", "SCORE_CURSOR_UNSUPPORTED", { field: FetchService.SCORE_FIELD });
3632
4062
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
3633
- const resolved = this.resolveOrderKeys(table, keys, collection);
3634
4063
  const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
4064
+ const resolved = this.resolveOrderKeys(table, keys, collection, void 0, collectionPath, startAfterId ?? void 0);
3635
4065
  if (resolved.length > 0 && startAfterId !== void 0) {
3636
4066
  const cursorValues = cursor.values;
3637
- const values = resolved.map(({ field }) => cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
3638
- if (values.every((value) => value !== void 0)) return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
4067
+ const values = resolved.map(({ field, cursorTarget }) => cursorTarget ? null : cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
4068
+ if (values.every((value, i) => resolved[i].cursorTarget || value !== void 0)) return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
3639
4069
  }
3640
4070
  } else {
3641
4071
  const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
@@ -3658,10 +4088,11 @@ var FetchService = class FetchService {
3658
4088
  */
3659
4089
  buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
3660
4090
  if (index >= keys.length) return lt(idField, cursorId);
3661
- const { direction } = keys[index];
4091
+ const { direction, cursorTarget } = keys[index];
3662
4092
  const target = keys[index].target;
3663
4093
  const value = values[index];
3664
4094
  const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
4095
+ if (cursorTarget) return direction === "asc" ? or(and(isNull(cursorTarget), isNull(target), rest), and(isNotNull(cursorTarget), or(sql`${target} > ${cursorTarget}`, isNull(target), and(sql`${target} = ${cursorTarget}`, rest)))) : or(and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))), and(isNotNull(cursorTarget), or(sql`${target} < ${cursorTarget}`, and(sql`${target} = ${cursorTarget}`, rest))));
3665
4096
  if (value === null) return direction === "asc" ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
3666
4097
  return direction === "asc" ? or(gt(target, value), isNull(target), and(eq(target, value), rest)) : or(lt(target, value), and(eq(target, value), rest));
3667
4098
  }
@@ -10129,7 +10560,7 @@ function createBackupCron(config) {
10129
10560
  enabled: config.enabled ?? true,
10130
10561
  timeoutSeconds: 3600,
10131
10562
  async handler({ log }) {
10132
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BH0Dzo_h.js").then((n) => n.r);
10563
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-czK-OAuG.js").then((n) => n.r);
10133
10564
  const { destination } = config;
10134
10565
  if (destination.kind !== "local" && !config.storage) throw new Error(`Backup destination is ${destination.kind} but no storage controller was provided. Pass the backend's configured StorageController to createBackupCron({ storage }).`);
10135
10566
  log(`Starting backup of "${dbName}"…`);
@@ -13547,7 +13978,7 @@ function createPostgresBootstrapper(pgConfig) {
13547
13978
  * proved it can bootstrap with.
13548
13979
  */
13549
13980
  async ensureCollectionSchema(collections, driverResult, log) {
13550
- const { ensureCollectionTables } = await import("./ensure-collection-tables-Dnb7scJ1.js");
13981
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-BHUjQ-z4.js");
13551
13982
  const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
13552
13983
  for (const failure of plan.failures) logger.warn(failure.kind === "comment-column" ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, but a later change to the \`search\` block will not be detected: ${failure.error}` : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
13553
13984
  return { applied: plan.actions.length - plan.failures.length };
@@ -13568,7 +13999,7 @@ function createPostgresBootstrapper(pgConfig) {
13568
13999
  * stays RLS-enabled, so it denies rather than leaks.
13569
14000
  */
13570
14001
  async ensureCollectionPolicies(collections, driverResult, log) {
13571
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-KzpXpHEL.js");
14002
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-D5PtQLyR.js");
13572
14003
  const queryable = provisioningQueryable(driverResult);
13573
14004
  const outcome = await ensureCollectionPolicies(queryable, collections, log);
13574
14005
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
@@ -13577,7 +14008,7 @@ function createPostgresBootstrapper(pgConfig) {
13577
14008
  for (const u of outcome.unsecured.filter((u) => u.grantWithdrawn)) logger.error(`🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is unreachable rather than unprotected. Reads and writes to that collection will fail until RLS can be enabled.`);
13578
14009
  if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
13579
14010
  try {
13580
- const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-69hYT8nr.js").then((n) => n.n);
14011
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-B5Sajku6.js").then((n) => n.n);
13581
14012
  await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
13582
14013
  info: (m) => logger.info(m),
13583
14014
  warn: (m) => logger.warn(m)
@@ -13592,7 +14023,7 @@ function createPostgresBootstrapper(pgConfig) {
13592
14023
  },
13593
14024
  mountRoutes(app, basePath, driverResult) {},
13594
14025
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
13595
- const { createPostgresWebSocket } = await import("./websocket-D2jXv0Ds.js").then((n) => n.n);
14026
+ const { createPostgresWebSocket } = await import("./websocket-BVgDVO-V.js").then((n) => n.n);
13596
14027
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
13597
14028
  }
13598
14029
  };