@rebasepro/server-postgres 0.14.1 → 0.14.2-canary.g27a129e
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.
- package/dist/{auth-users-columns-C-FDnL_e.js → auth-users-columns-JJ8ngvy5.js} +59 -17
- package/dist/auth-users-columns-JJ8ngvy5.js.map +1 -0
- package/dist/{backup-service-BH0Dzo_h.js → backup-service-czK-OAuG.js} +2 -2
- package/dist/{backup-service-BH0Dzo_h.js.map → backup-service-czK-OAuG.js.map} +1 -1
- package/dist/{ensure-collection-policies-DoHwhVf8.js → ensure-collection-policies-D5PtQLyR.js} +3 -3
- package/dist/{ensure-collection-policies-DoHwhVf8.js.map → ensure-collection-policies-D5PtQLyR.js.map} +1 -1
- package/dist/{ensure-collection-tables-DT2eq859.js → ensure-collection-tables-BHUjQ-z4.js} +3 -3
- package/dist/{ensure-collection-tables-DT2eq859.js.map → ensure-collection-tables-BHUjQ-z4.js.map} +1 -1
- package/dist/index.es.js +485 -19
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-69hYT8nr.js → rls-bootstrap-sql-B5Sajku6.js} +2 -2
- package/dist/{rls-bootstrap-sql-69hYT8nr.js.map → rls-bootstrap-sql-B5Sajku6.js.map} +1 -1
- package/dist/{rls-enforcement-gUNDfm7l.js → rls-enforcement-BDBfuTD4.js} +4 -3
- package/dist/rls-enforcement-BDBfuTD4.js.map +1 -0
- package/dist/services/FetchService.d.ts +22 -0
- package/dist/{src-DCdn3Val.js → src-BBFsDaeA.js} +60 -2
- package/dist/src-BBFsDaeA.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +168 -1
- package/dist/utils/pg-error-utils.d.ts +27 -0
- package/dist/{websocket-D2jXv0Ds.js → websocket-BVgDVO-V.js} +2 -2
- package/dist/{websocket-D2jXv0Ds.js.map → websocket-BVgDVO-V.js.map} +1 -1
- package/package.json +6 -6
- package/src/services/FetchService.ts +155 -15
- package/src/services/PersistService.ts +23 -2
- package/src/utils/drizzle-conditions.ts +594 -1
- package/src/utils/pg-error-utils.ts +49 -4
- package/dist/auth-users-columns-C-FDnL_e.js.map +0 -1
- package/dist/rls-enforcement-gUNDfm7l.js.map +0 -1
- 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-
|
|
7
|
-
import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, l as isRelationalCollectionConfig } from "./src-
|
|
8
|
-
import { t as createPostgresWebSocket } from "./websocket-
|
|
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-
|
|
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-
|
|
11
|
-
import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-
|
|
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");
|
|
@@ -3155,6 +3542,37 @@ function isRoleSwitchingPermissionError(error) {
|
|
|
3155
3542
|
return msg.includes("set role") || msg.includes("member of role");
|
|
3156
3543
|
}
|
|
3157
3544
|
/**
|
|
3545
|
+
* Was this `42501` the *caller* being refused by a policy, rather than the
|
|
3546
|
+
* server lacking a privilege?
|
|
3547
|
+
*
|
|
3548
|
+
* Both arrive as `insufficient_privilege`, and they are opposite kinds of
|
|
3549
|
+
* problem. A row-level-security refusal is a working access-control system
|
|
3550
|
+
* doing its job: the caller asked for something their policies do not permit,
|
|
3551
|
+
* which is a 403 and nobody's bug. A missing `GRANT` is the deployment being
|
|
3552
|
+
* wrong — the connection role cannot touch the table at all, no policy is
|
|
3553
|
+
* involved, and nothing the caller changes about the request will help.
|
|
3554
|
+
*
|
|
3555
|
+
* Postgres distinguishes them in the message, so this does too:
|
|
3556
|
+
*
|
|
3557
|
+
* new row violates row-level security policy for table "notes" → the caller
|
|
3558
|
+
* permission denied for table notes → the server
|
|
3559
|
+
*
|
|
3560
|
+
* Only writes reach this. A read that RLS excludes is not an error — the rows
|
|
3561
|
+
* are filtered and the caller gets an empty page — so the erroring case is
|
|
3562
|
+
* specifically an `INSERT`/`UPDATE` whose row fails a policy's `WITH CHECK`.
|
|
3563
|
+
*
|
|
3564
|
+
* Matched on the message because that is the only thing carrying the
|
|
3565
|
+
* distinction; the SQLSTATE is identical either way. Narrow by design: anything
|
|
3566
|
+
* not naming row-level security stays the server's problem, since reporting a
|
|
3567
|
+
* genuine privilege misconfiguration as "forbidden" would send an operator
|
|
3568
|
+
* hunting for a policy bug that does not exist.
|
|
3569
|
+
*/
|
|
3570
|
+
function isRowLevelSecurityDenial(error) {
|
|
3571
|
+
const pgError = extractPgError(error);
|
|
3572
|
+
if (!pgError || pgError.code !== "42501") return false;
|
|
3573
|
+
return pgError.message.toLowerCase().includes("row-level security policy");
|
|
3574
|
+
}
|
|
3575
|
+
/**
|
|
3158
3576
|
* Translate a raw PostgreSQL error into a user-friendly message.
|
|
3159
3577
|
*
|
|
3160
3578
|
* @param pgError - The extracted PostgreSQL error (from {@link extractPgError})
|
|
@@ -3209,8 +3627,11 @@ function pgErrorToFriendlyMessage(pgError, context) {
|
|
|
3209
3627
|
message: `Table not found for "${context}": ${pgMessage}. Check if your schema is up to date (run migrations).${suffix}`,
|
|
3210
3628
|
code
|
|
3211
3629
|
};
|
|
3212
|
-
case "42501": return {
|
|
3213
|
-
message: `
|
|
3630
|
+
case "42501": return pgMessage.toLowerCase().includes("row-level security policy") ? {
|
|
3631
|
+
message: `Not permitted to write this row in "${tableRef}": it does not satisfy the row-level security policy.${suffix}`,
|
|
3632
|
+
code
|
|
3633
|
+
} : {
|
|
3634
|
+
message: `Permission denied on "${tableRef}": the database role this server connects as lacks privileges on it.${suffix}`,
|
|
3214
3635
|
code
|
|
3215
3636
|
};
|
|
3216
3637
|
case "28000": return {
|
|
@@ -3376,6 +3797,30 @@ var FetchService = class FetchService {
|
|
|
3376
3797
|
return this.resolveOrderByField(table, orderBy, collection);
|
|
3377
3798
|
}
|
|
3378
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
|
+
/**
|
|
3379
3824
|
* Resolve every sort key to the expression it orders by, in order of
|
|
3380
3825
|
* significance.
|
|
3381
3826
|
*
|
|
@@ -3384,9 +3829,19 @@ var FetchService = class FetchService {
|
|
|
3384
3829
|
* unknown-field mode, where dropping is the configured answer, and dropping
|
|
3385
3830
|
* one key of several still honours the ones that did resolve.
|
|
3386
3831
|
*/
|
|
3387
|
-
resolveOrderKeys(table, keys, collection, searchString) {
|
|
3832
|
+
resolveOrderKeys(table, keys, collection, searchString, collectionPath, cursorId) {
|
|
3388
3833
|
const resolved = [];
|
|
3389
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
|
+
}
|
|
3390
3845
|
const target = this.resolveOrderTarget(table, field, collection, searchString);
|
|
3391
3846
|
if (target) resolved.push({
|
|
3392
3847
|
field,
|
|
@@ -3406,9 +3861,18 @@ var FetchService = class FetchService {
|
|
|
3406
3861
|
* to agree: they did not, and an ascending sort paged with `id >` against an
|
|
3407
3862
|
* `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
|
|
3408
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.
|
|
3409
3873
|
*/
|
|
3410
3874
|
buildOrderExpressions(keys, idField) {
|
|
3411
|
-
const expressions = keys.map(({ direction, target }) => direction === "asc" ?
|
|
3875
|
+
const expressions = keys.map(({ direction, target }) => direction === "asc" ? sql`${target} ASC NULLS LAST` : sql`${target} DESC NULLS FIRST`);
|
|
3412
3876
|
expressions.push(desc(idField));
|
|
3413
3877
|
return expressions;
|
|
3414
3878
|
}
|
|
@@ -3596,12 +4060,12 @@ var FetchService = class FetchService {
|
|
|
3596
4060
|
if (keys) {
|
|
3597
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 });
|
|
3598
4062
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
|
|
3599
|
-
const resolved = this.resolveOrderKeys(table, keys, collection);
|
|
3600
4063
|
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
4064
|
+
const resolved = this.resolveOrderKeys(table, keys, collection, void 0, collectionPath, startAfterId ?? void 0);
|
|
3601
4065
|
if (resolved.length > 0 && startAfterId !== void 0) {
|
|
3602
4066
|
const cursorValues = cursor.values;
|
|
3603
|
-
const values = resolved.map(({ field }) => cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
|
|
3604
|
-
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)];
|
|
3605
4069
|
}
|
|
3606
4070
|
} else {
|
|
3607
4071
|
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
@@ -3624,10 +4088,11 @@ var FetchService = class FetchService {
|
|
|
3624
4088
|
*/
|
|
3625
4089
|
buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
|
|
3626
4090
|
if (index >= keys.length) return lt(idField, cursorId);
|
|
3627
|
-
const { direction } = keys[index];
|
|
4091
|
+
const { direction, cursorTarget } = keys[index];
|
|
3628
4092
|
const target = keys[index].target;
|
|
3629
4093
|
const value = values[index];
|
|
3630
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))));
|
|
3631
4096
|
if (value === null) return direction === "asc" ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
|
|
3632
4097
|
return direction === "asc" ? or(gt(target, value), isNull(target), and(eq(target, value), rest)) : or(lt(target, value), and(eq(target, value), rest));
|
|
3633
4098
|
}
|
|
@@ -4766,6 +5231,7 @@ var PersistService = class {
|
|
|
4766
5231
|
if (pgError) {
|
|
4767
5232
|
const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
|
|
4768
5233
|
if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
|
|
5234
|
+
if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
|
|
4769
5235
|
return new Error(message);
|
|
4770
5236
|
}
|
|
4771
5237
|
const causeMessage = extractCauseMessage(error);
|
|
@@ -10094,7 +10560,7 @@ function createBackupCron(config) {
|
|
|
10094
10560
|
enabled: config.enabled ?? true,
|
|
10095
10561
|
timeoutSeconds: 3600,
|
|
10096
10562
|
async handler({ log }) {
|
|
10097
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
10563
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-czK-OAuG.js").then((n) => n.r);
|
|
10098
10564
|
const { destination } = config;
|
|
10099
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 }).`);
|
|
10100
10566
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -13512,7 +13978,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13512
13978
|
* proved it can bootstrap with.
|
|
13513
13979
|
*/
|
|
13514
13980
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
13515
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
13981
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-BHUjQ-z4.js");
|
|
13516
13982
|
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
13517
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}`);
|
|
13518
13984
|
return { applied: plan.actions.length - plan.failures.length };
|
|
@@ -13533,7 +13999,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13533
13999
|
* stays RLS-enabled, so it denies rather than leaks.
|
|
13534
14000
|
*/
|
|
13535
14001
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
13536
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
14002
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-D5PtQLyR.js");
|
|
13537
14003
|
const queryable = provisioningQueryable(driverResult);
|
|
13538
14004
|
const outcome = await ensureCollectionPolicies(queryable, collections, log);
|
|
13539
14005
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
@@ -13542,7 +14008,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13542
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.`);
|
|
13543
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.`);
|
|
13544
14010
|
try {
|
|
13545
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
14011
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-B5Sajku6.js").then((n) => n.n);
|
|
13546
14012
|
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
13547
14013
|
info: (m) => logger.info(m),
|
|
13548
14014
|
warn: (m) => logger.warn(m)
|
|
@@ -13557,7 +14023,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13557
14023
|
},
|
|
13558
14024
|
mountRoutes(app, basePath, driverResult) {},
|
|
13559
14025
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
13560
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
14026
|
+
const { createPostgresWebSocket } = await import("./websocket-BVgDVO-V.js").then((n) => n.n);
|
|
13561
14027
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
13562
14028
|
}
|
|
13563
14029
|
};
|