@rebasepro/server-postgres 0.22.0 → 0.23.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.
- package/dist/{BranchService-CucnFcSE.js → BranchService-DGPL6G_C.js} +2 -2
- package/dist/{BranchService-CucnFcSE.js.map → BranchService-DGPL6G_C.js.map} +1 -1
- package/dist/PostgresBackendDriver.d.ts +41 -1
- package/dist/auth/services.d.ts +27 -8
- package/dist/backup/backup-cli.d.ts +22 -0
- package/dist/{backup-cli-DqBakiMO.js → backup-cli-24v4OlSp.js} +17 -14
- package/dist/backup-cli-24v4OlSp.js.map +1 -0
- package/dist/{backup-service-CaMOS76G.js → backup-service-HQ9GC4tN.js} +2 -2
- package/dist/{backup-service-CaMOS76G.js.map → backup-service-HQ9GC4tN.js.map} +1 -1
- package/dist/{cli-errors-Dka89exj.js → cli-errors-B8qHg02P.js} +6 -1
- package/dist/cli-errors-B8qHg02P.js.map +1 -0
- package/dist/cli-flags-BglvpjHv.js +138 -0
- package/dist/cli-flags-BglvpjHv.js.map +1 -0
- package/dist/cli-flags.d.ts +28 -0
- package/dist/cli-helpers.d.ts +6 -0
- package/dist/cli-scratch-database.d.ts +34 -0
- package/dist/cli.js +97 -261
- package/dist/cli.js.map +1 -1
- package/dist/{column-plan-helpers-CpILzHJS.js → column-plan-helpers-DF-8dTVa.js} +16 -20
- package/dist/column-plan-helpers-DF-8dTVa.js.map +1 -0
- package/dist/{doctor-CU9IogdL.js → doctor-Cb2thZ8s.js} +8 -7
- package/dist/doctor-Cb2thZ8s.js.map +1 -0
- package/dist/{ensure-collection-policies-Dzd-2S81.js → ensure-collection-policies-RHUcEp4v.js} +5 -5
- package/dist/{ensure-collection-policies-Dzd-2S81.js.map → ensure-collection-policies-RHUcEp4v.js.map} +1 -1
- package/dist/{ensure-collection-tables-CpAgy51F.js → ensure-collection-tables-BEEjn5cn.js} +53 -22
- package/dist/ensure-collection-tables-BEEjn5cn.js.map +1 -0
- package/dist/{ensure-tables-Cr5B4UmH.js → ensure-tables-Dhn9KM3B.js} +2 -2
- package/dist/{ensure-tables-Cr5B4UmH.js.map → ensure-tables-Dhn9KM3B.js.map} +1 -1
- package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js → generate-drizzle-schema-logic-BfzK7UQd.js} +2 -2
- package/dist/{generate-drizzle-schema-logic-BcMl7VSy.js.map → generate-drizzle-schema-logic-BfzK7UQd.js.map} +1 -1
- package/dist/{generate-drizzle-schema-B537GIvz.js → generate-drizzle-schema-yzY_BLhr.js} +2 -2
- package/dist/{generate-drizzle-schema-B537GIvz.js.map → generate-drizzle-schema-yzY_BLhr.js.map} +1 -1
- package/dist/{generate-postgres-ddl-logic-D7imhYV8.js → generate-postgres-ddl-logic-Bt2d2mRH.js} +2 -2
- package/dist/{generate-postgres-ddl-logic-D7imhYV8.js.map → generate-postgres-ddl-logic-Bt2d2mRH.js.map} +1 -1
- package/dist/index.es.js +1508 -355
- package/dist/index.es.js.map +1 -1
- package/dist/{introspect-db-logic-C6LQdTxj.js → introspect-db-logic-WMuAfxvw.js} +323 -4
- package/dist/introspect-db-logic-WMuAfxvw.js.map +1 -0
- package/dist/{plan-schema-CboAIwLN.js → plan-schema-C0fxM8dY.js} +158 -31
- package/dist/plan-schema-C0fxM8dY.js.map +1 -0
- package/dist/{policy-drift-xJfy9xG7.js → policy-drift-DljYdrpW.js} +2 -2
- package/dist/{policy-drift-xJfy9xG7.js.map → policy-drift-DljYdrpW.js.map} +1 -1
- package/dist/{rls-bootstrap-sql-H3rCFi3F.js → rls-bootstrap-sql-B8EclDyM.js} +320 -17
- package/dist/rls-bootstrap-sql-B8EclDyM.js.map +1 -0
- package/dist/schema/atlas-argv.d.ts +15 -0
- package/dist/schema/column-plan-helpers.d.ts +23 -4
- package/dist/schema/destructive-sql.d.ts +52 -1
- package/dist/schema/doctor-cli.js +3 -3
- package/dist/schema/generate-drizzle-schema.js +1 -1
- package/dist/schema/generate-postgres-ddl.js +1 -1
- package/dist/schema/introspect-db.js +1 -312
- package/dist/schema/introspect-db.js.map +1 -1
- package/dist/schema/plan/diff-plan.d.ts +3 -2
- package/dist/schema/plan/plan-schema.d.ts +10 -3
- package/dist/schema/plan/types.d.ts +6 -1
- package/dist/services/FetchService.d.ts +97 -28
- package/dist/services/PersistService.d.ts +41 -5
- package/dist/services/RelationService.d.ts +11 -0
- package/dist/services/RelationWriteService.d.ts +6 -0
- package/dist/services/cdc/CdcListener.d.ts +6 -1
- package/dist/services/collection-helpers.d.ts +11 -0
- package/dist/services/dataService.d.ts +3 -20
- package/dist/services/field-op-sql.d.ts +71 -0
- package/dist/services/junction-writes.d.ts +19 -3
- package/dist/services/pg-notify-listener.d.ts +7 -0
- package/dist/services/read-field-access.d.ts +19 -0
- package/dist/services/realtimeService.d.ts +77 -3
- package/dist/services/row-pipeline.d.ts +5 -0
- package/dist/services/soft-delete.d.ts +0 -2
- package/dist/services/write-transaction-scope.d.ts +39 -0
- package/dist/utils/drizzle-conditions.d.ts +7 -2
- package/dist/websocket.d.ts +10 -3
- package/package.json +9 -9
- package/dist/backup-cli-DqBakiMO.js.map +0 -1
- package/dist/cli-errors-Dka89exj.js.map +0 -1
- package/dist/column-plan-helpers-CpILzHJS.js.map +0 -1
- package/dist/doctor-CU9IogdL.js.map +0 -1
- package/dist/ensure-collection-tables-CpAgy51F.js.map +0 -1
- package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
- package/dist/plan-schema-CboAIwLN.js.map +0 -1
- package/dist/rls-bootstrap-sql-H3rCFi3F.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -2,27 +2,28 @@ import { createRequire as __createRequire } from "module";
|
|
|
2
2
|
__createRequire(import.meta.url);
|
|
3
3
|
import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
|
|
4
4
|
import { a as createDirectDatabaseConnection, c as guardPoolAgainstDirtyRelease, l as pinSearchPath, o as createPostgresDatabaseConnection, r as cappedPoolMax, s as createReadReplicaConnection, t as DatabasePoolManager, u as poolMaxCeiling } from "./databasePoolManager-Bj5FbeAs.js";
|
|
5
|
-
import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-
|
|
6
|
-
import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-
|
|
7
|
-
import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-
|
|
5
|
+
import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-C0fxM8dY.js";
|
|
6
|
+
import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-B8qHg02P.js";
|
|
7
|
+
import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-DGPL6G_C.js";
|
|
8
8
|
import { c as warnOnLegacyRlsFunctions, i as ensureAppRole, l as warnOnRoleSchemaCollision, n as applyAuthContext, o as validatePolicyPgRoles, r as detectConnectionPosture, s as warnOnAnonymousGrants, t as REBASE_USER_ROLE } from "./rls-enforcement-C6Xk0lA6.js";
|
|
9
|
-
import {
|
|
9
|
+
import { u as resolveColumnName } from "./column-plan-helpers-DF-8dTVa.js";
|
|
10
10
|
import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
|
|
11
|
-
import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-
|
|
12
|
-
import { t as generateSchema } from "./generate-drizzle-schema-logic-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { i as
|
|
17
|
-
import {
|
|
18
|
-
import { Client } from "pg";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
11
|
+
import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-Bt2d2mRH.js";
|
|
12
|
+
import { t as generateSchema } from "./generate-drizzle-schema-logic-BfzK7UQd.js";
|
|
13
|
+
import { c as identifyJoinTables, d as singularize, h as humanize, i as buildTablesMap, m as mapPgType, r as buildEnumMap, s as getIconForTable } from "./introspect-db-logic-WMuAfxvw.js";
|
|
14
|
+
import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-BEEjn5cn.js";
|
|
15
|
+
import { t as main } from "./generate-drizzle-schema-yzY_BLhr.js";
|
|
16
|
+
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 getServerVersionMajor, d as pruneBackups, f as resolvePgBinary, g as selectBackupsToPrune, h as validateDump, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as listBackups, m as uploadBackup, n as applyGlobals, o as discardPartialDump, p as restoreDump, s as ensureDatabaseExists, t as BackupToolError, u as preflight, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-HQ9GC4tN.js";
|
|
17
|
+
import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-Dhn9KM3B.js";
|
|
18
|
+
import { Client, Pool } from "pg";
|
|
19
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
20
|
+
import { ApiError, RUNTIME_DEFAULT_MAX_BODY_SIZE, assertFieldOpsValid, assertNestedWriteAllowed, assertNoFieldOpsOnCreate, assertQueryFieldsReadable, assertUserCreationBodyValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, createUserThroughAuthCollection, declaredErrorAnswer, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveConflictTarget, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
|
|
21
|
+
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, is, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
|
|
22
|
+
import { PgArray, PgNumeric, PgTable, PgTimestamp, PgTimestampString, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
21
23
|
import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, JUNCTION_PIVOT_KEY, ListLimitError, MAX_INCLUDE_DEPTH, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, parseEnvBoolean, parseRelationAggregateSort, resolveClientListLimit } from "@rebasepro/types";
|
|
22
|
-
import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, REBASE_USER_ROLE as REBASE_USER_ROLE$1, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, firstSqlRow, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getJunctionConfigForRelation, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, requireCallbackClient, requireCallbackCollection, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, revokeInternalTableSql, sqlRows, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
|
|
23
|
-
import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName,
|
|
24
|
+
import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, REBASE_USER_ROLE as REBASE_USER_ROLE$1, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, firstSqlRow, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getJunctionConfigForRelation, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseIdValues as parseIdValues$1, parseOrderBySpecStrict, requireCallbackClient, requireCallbackCollection, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, restoresSoftDeletedRow, revokeInternalTableSql, softDeleteFieldOf, sqlRows, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
|
|
25
|
+
import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, splitSearchTerms, toSnakeCase, toWireKey, unref } from "@rebasepro/utils";
|
|
24
26
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
|
-
import { PgArray, PgNumeric, PgTable, bigint, boolean, char, cidr, customType, date, doublePrecision, geometry, getTableConfig, index, inet, integer, interval, json, jsonb, line, macaddr, macaddr8, numeric, pgSchema, pgTable, point, primaryKey, real, smallint, text, time, timestamp, unique, uuid, varchar, vector } from "drizzle-orm/pg-core";
|
|
26
27
|
import fs from "fs";
|
|
27
28
|
import { WebSocket, WebSocketServer } from "ws";
|
|
28
29
|
import { EventEmitter } from "events";
|
|
@@ -119,6 +120,24 @@ function idCanAddressTable(id, table, idInfoArray) {
|
|
|
119
120
|
}));
|
|
120
121
|
}
|
|
121
122
|
/**
|
|
123
|
+
* The condition that names one row: every key column equal to its part of the
|
|
124
|
+
* address.
|
|
125
|
+
*
|
|
126
|
+
* Every key column, not the first. A composite-key read that matched only the
|
|
127
|
+
* leading column answered `p1:::bob` with whichever `p1` row Postgres reached
|
|
128
|
+
* first, and a delete then judged that row's `beforeDelete` while deleting bob —
|
|
129
|
+
* the writes have always matched the whole key, so the reads must too.
|
|
130
|
+
*/
|
|
131
|
+
function rowIdentityCondition(table, idInfoArray, id, collectionPath) {
|
|
132
|
+
const parsed = parseIdValues(id, idInfoArray);
|
|
133
|
+
const conditions = idInfoArray.map((info) => {
|
|
134
|
+
const column = table[info.fieldName];
|
|
135
|
+
if (!column) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
136
|
+
return eq(column, parsed[info.fieldName]);
|
|
137
|
+
});
|
|
138
|
+
return conditions.length === 1 ? conditions[0] : and(...conditions);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
122
141
|
* The columns of a table's composite primary-key constraint, or `[]`.
|
|
123
142
|
*
|
|
124
143
|
* Fails open for anything that is not a real Drizzle table — a test double, a
|
|
@@ -1918,11 +1937,16 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1918
1937
|
return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
|
|
1919
1938
|
}
|
|
1920
1939
|
/**
|
|
1921
|
-
* Build a unique field check condition
|
|
1940
|
+
* Build a unique field check condition: rows holding `value`, other than
|
|
1941
|
+
* the one `excludeRow` names.
|
|
1942
|
+
*
|
|
1943
|
+
* `excludeRow` is the whole identity of the row being saved — every key
|
|
1944
|
+
* column — so excluding one member of a composite-key table does not
|
|
1945
|
+
* exclude every row sharing its first key part.
|
|
1922
1946
|
*/
|
|
1923
|
-
static buildUniqueFieldCondition(fieldColumn, value,
|
|
1947
|
+
static buildUniqueFieldCondition(fieldColumn, value, excludeRow) {
|
|
1924
1948
|
const conditions = [eq(fieldColumn, value)];
|
|
1925
|
-
if (
|
|
1949
|
+
if (excludeRow) conditions.push(not(excludeRow));
|
|
1926
1950
|
return conditions;
|
|
1927
1951
|
}
|
|
1928
1952
|
/**
|
|
@@ -2199,6 +2223,11 @@ function toRestValues(row, collection) {
|
|
|
2199
2223
|
* an `update` that echoes the row back overwrite the real value with the null it
|
|
2200
2224
|
* was handed.
|
|
2201
2225
|
*
|
|
2226
|
+
* A withheld to-one relation takes its foreign key with it. `bandId: 7` names
|
|
2227
|
+
* the salary band exactly as `band: { id: 7 }` does, and the row carries the
|
|
2228
|
+
* column beside the relation — so withholding only the relation withheld
|
|
2229
|
+
* nothing.
|
|
2230
|
+
*
|
|
2202
2231
|
* `_matches` is filtered rather than deleted: it is the list of fields a text
|
|
2203
2232
|
* search hit, and a field the caller cannot read must not appear in it even
|
|
2204
2233
|
* though the array itself is theirs to see.
|
|
@@ -2208,13 +2237,22 @@ function stripUnreadable(row, collection) {
|
|
|
2208
2237
|
if (!properties) return row;
|
|
2209
2238
|
const viewer = currentFieldViewer();
|
|
2210
2239
|
const hidden = /* @__PURE__ */ new Set();
|
|
2240
|
+
const hide = (name) => {
|
|
2241
|
+
hidden.add(name);
|
|
2242
|
+
delete row[name];
|
|
2243
|
+
};
|
|
2244
|
+
let relations;
|
|
2211
2245
|
for (const [key, property] of Object.entries(properties)) {
|
|
2212
2246
|
if (canReadField(property, viewer)) continue;
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
if (property.
|
|
2216
|
-
|
|
2217
|
-
|
|
2247
|
+
hide(key);
|
|
2248
|
+
if (property.columnName) hide(property.columnName);
|
|
2249
|
+
if (property.type === "relation") {
|
|
2250
|
+
relations ??= resolveCollectionRelations(collection);
|
|
2251
|
+
const relation = findRelation(relations, key);
|
|
2252
|
+
if (relation?.kind === "belongsTo") {
|
|
2253
|
+
hide(fieldKeyForColumn(collection, relation.localKey));
|
|
2254
|
+
hide(relation.localKey);
|
|
2255
|
+
}
|
|
2218
2256
|
}
|
|
2219
2257
|
}
|
|
2220
2258
|
if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
|
|
@@ -2419,8 +2457,10 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
|
2419
2457
|
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
2420
2458
|
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
2421
2459
|
* the links they were never shown. The select that drives the diff runs in
|
|
2422
|
-
* this same transaction under the same policies,
|
|
2423
|
-
*
|
|
2460
|
+
* this same transaction under the same policies, and through
|
|
2461
|
+
* `visibleTargets` — the target's `beforeQuery` scope and soft delete, which
|
|
2462
|
+
* RLS knows nothing of — so a link the caller cannot read is in neither list
|
|
2463
|
+
* and survives the save.
|
|
2424
2464
|
* - **Junction payload columns.** A junction carrying its own columns
|
|
2425
2465
|
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
2426
2466
|
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
@@ -2443,8 +2483,8 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
|
2443
2483
|
* that is what keeps a plain `[1, 2, 3]` membership write from wiping the
|
|
2444
2484
|
* `role`s off the links it did not mention.
|
|
2445
2485
|
*/
|
|
2446
|
-
async function applyJunctionMembership(tx, binding, parentId, links) {
|
|
2447
|
-
const existingRows = await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
|
|
2486
|
+
async function applyJunctionMembership(tx, binding, parentId, links, visibleTargets) {
|
|
2487
|
+
const existingRows = visibleTargets ? await tx.select({ targetId: binding.targetColumn }).from(binding.table).innerJoin(visibleTargets.table, eq(binding.targetColumn, visibleTargets.idColumn)).where(and(eq(binding.parentColumn, parentId), visibleTargets.condition)) : await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
|
|
2448
2488
|
const existingById = /* @__PURE__ */ new Map();
|
|
2449
2489
|
for (const row of existingRows) {
|
|
2450
2490
|
if (row.targetId === null || row.targetId === void 0) continue;
|
|
@@ -2503,6 +2543,8 @@ async function removeLinks(tx, binding, parentId, removed) {
|
|
|
2503
2543
|
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2504
2544
|
throw ApiError.forbidden(`Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) for relation '${binding.label}': a row-level security policy rejected the write.`, "WRITE_DENIED");
|
|
2505
2545
|
}
|
|
2546
|
+
//#endregion
|
|
2547
|
+
//#region src/services/soft-delete.ts
|
|
2506
2548
|
/**
|
|
2507
2549
|
* The soft-delete field this collection uses, or `undefined` if it has none.
|
|
2508
2550
|
*
|
|
@@ -2512,9 +2554,8 @@ async function removeLinks(tx, binding, parentId, removed) {
|
|
|
2512
2554
|
* worth avoiding — deletes would silently start removing rows.
|
|
2513
2555
|
*/
|
|
2514
2556
|
function resolveSoftDelete(collection) {
|
|
2515
|
-
const
|
|
2516
|
-
if (!
|
|
2517
|
-
const field = typeof declared === "object" && declared.field || "deletedAt";
|
|
2557
|
+
const field = softDeleteFieldOf(collection);
|
|
2558
|
+
if (!field) return void 0;
|
|
2518
2559
|
return {
|
|
2519
2560
|
field,
|
|
2520
2561
|
columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
|
|
@@ -2640,6 +2681,10 @@ function resolveBeforeQuery(registry, collection) {
|
|
|
2640
2681
|
if (own?.beforeQuery) hooks.push(own.beforeQuery);
|
|
2641
2682
|
return hooks;
|
|
2642
2683
|
}
|
|
2684
|
+
/** Whether anything at all would run for this collection. */
|
|
2685
|
+
function hasBeforeQuery(registry, collection) {
|
|
2686
|
+
return resolveBeforeQuery(registry, collection).length > 0;
|
|
2687
|
+
}
|
|
2643
2688
|
/**
|
|
2644
2689
|
* The conditions every `beforeQuery` hook for this collection asks to add.
|
|
2645
2690
|
*
|
|
@@ -2789,6 +2834,21 @@ var RelationService = class {
|
|
|
2789
2834
|
});
|
|
2790
2835
|
}
|
|
2791
2836
|
/**
|
|
2837
|
+
* The target rows a caller may see through `relation`: the target's
|
|
2838
|
+
* `beforeQuery` scope and its soft delete, as one condition on the target
|
|
2839
|
+
* table, or `undefined` when neither applies.
|
|
2840
|
+
*
|
|
2841
|
+
* What every loader here narrows a read by, exposed for the membership
|
|
2842
|
+
* writes. A write that diffs a new link set against "what is linked now"
|
|
2843
|
+
* has to diff against what the caller could have read — otherwise saving
|
|
2844
|
+
* back the list they were shown unlinks every row they were not.
|
|
2845
|
+
*/
|
|
2846
|
+
async visibleTargetCondition(parentCollection, relation, parentId) {
|
|
2847
|
+
const targetCollection = relation.target();
|
|
2848
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2849
|
+
return andSoftDelete(await this.narrowTargetRead(parentCollection, relation, parentId), targetCollection, targetTable);
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2792
2852
|
* One target row, as the {@link RelatedRow} everything here returns.
|
|
2793
2853
|
*
|
|
2794
2854
|
* Eight sites built this by hand, which is how the address came to be the
|
|
@@ -2847,11 +2907,11 @@ var RelationService = class {
|
|
|
2847
2907
|
return col;
|
|
2848
2908
|
};
|
|
2849
2909
|
if (parentPks.length === 1) {
|
|
2850
|
-
const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
2910
|
+
const values = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentPks[0].fieldName]);
|
|
2851
2911
|
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
2852
2912
|
}
|
|
2853
2913
|
return or(...parentIds.map((id) => {
|
|
2854
|
-
const values = parseIdValues(id, parentPks);
|
|
2914
|
+
const values = parseIdValues$1(id, parentPks);
|
|
2855
2915
|
return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
2856
2916
|
}));
|
|
2857
2917
|
}
|
|
@@ -2902,7 +2962,7 @@ var RelationService = class {
|
|
|
2902
2962
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2903
2963
|
if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
|
|
2904
2964
|
for (const id of parentIds) {
|
|
2905
|
-
const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
|
|
2965
|
+
const value = parseIdValues$1(id, parentPks)[parentPks[0].fieldName];
|
|
2906
2966
|
keyByParentId.set(String(id), value);
|
|
2907
2967
|
parentIdByKey.set(String(value), id);
|
|
2908
2968
|
}
|
|
@@ -2952,7 +3012,7 @@ var RelationService = class {
|
|
|
2952
3012
|
const idField = targetTable[idInfo[0].fieldName];
|
|
2953
3013
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2954
3014
|
const parentIdInfo = parentPks[0];
|
|
2955
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
3015
|
+
const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
|
|
2956
3016
|
const parentTableName = getTableName$1(parentCollection);
|
|
2957
3017
|
const parentTable = this.registry.getTable(parentTableName);
|
|
2958
3018
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
@@ -2977,7 +3037,7 @@ var RelationService = class {
|
|
|
2977
3037
|
currentTable = joinTable;
|
|
2978
3038
|
}
|
|
2979
3039
|
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
2980
|
-
query = query.where(narrowed(eq(parentIdField, parsedParentId), relatedNarrowing));
|
|
3040
|
+
query = query.where(narrowed(eq(parentIdField, parsedParentId), andSoftDelete(relatedNarrowing, targetCollection, targetTable)));
|
|
2981
3041
|
if (options.limit) query = query.limit(options.limit);
|
|
2982
3042
|
const results = await query;
|
|
2983
3043
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
@@ -3040,7 +3100,7 @@ var RelationService = class {
|
|
|
3040
3100
|
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
3041
3101
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
3042
3102
|
const parentIdInfo = parentPks[0];
|
|
3043
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
3103
|
+
const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
|
|
3044
3104
|
const parentTableName = getTableName$1(parentCollection);
|
|
3045
3105
|
const parentTable = this.registry.getTable(parentTableName);
|
|
3046
3106
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
@@ -3067,7 +3127,7 @@ var RelationService = class {
|
|
|
3067
3127
|
async isRelated(hop, targetId) {
|
|
3068
3128
|
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
3069
3129
|
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
3070
|
-
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
3130
|
+
const parsedTargetId = parseIdValues$1(targetId, targetPks);
|
|
3071
3131
|
const identity = targetPks.map((pk) => {
|
|
3072
3132
|
const column = targetTable[pk.fieldName];
|
|
3073
3133
|
if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
@@ -3097,7 +3157,7 @@ var RelationService = class {
|
|
|
3097
3157
|
const parentTable = this.registry.getTable(parentTableName);
|
|
3098
3158
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
3099
3159
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
3100
|
-
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
3160
|
+
const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
|
|
3101
3161
|
const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
|
|
3102
3162
|
if (relation.kind === "via") {
|
|
3103
3163
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
@@ -3201,7 +3261,7 @@ var RelationService = class {
|
|
|
3201
3261
|
const parentTable = this.registry.getTable(parentTableName);
|
|
3202
3262
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
3203
3263
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
3204
|
-
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
3264
|
+
const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
|
|
3205
3265
|
const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
|
|
3206
3266
|
if (relation.kind === "via") {
|
|
3207
3267
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
@@ -4042,6 +4102,11 @@ var FetchService = class FetchService {
|
|
|
4042
4102
|
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
4043
4103
|
*/
|
|
4044
4104
|
static SCORE_FIELD = "_score";
|
|
4105
|
+
/**
|
|
4106
|
+
* The distance a vector search attaches to every row it serves, and to no
|
|
4107
|
+
* other. Like `_score` it is computed per query and stored nowhere.
|
|
4108
|
+
*/
|
|
4109
|
+
static DISTANCE_FIELD = "_distance";
|
|
4045
4110
|
resolveOrderTarget(table, orderBy, collection, searchString) {
|
|
4046
4111
|
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
4047
4112
|
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
@@ -4125,15 +4190,17 @@ var FetchService = class FetchService {
|
|
|
4125
4190
|
return key.direction === "asc";
|
|
4126
4191
|
}
|
|
4127
4192
|
/**
|
|
4128
|
-
* The full `ORDER BY`: the caller's keys, then the
|
|
4193
|
+
* The full `ORDER BY`: the caller's keys, then the primary key.
|
|
4129
4194
|
*
|
|
4130
|
-
* The
|
|
4131
|
-
* what makes the ordering *total*, and a cursor over
|
|
4132
|
-
* repeats and skips rows among the ties. Every keyset
|
|
4133
|
-
* {@link buildCursorConditions} ends on the same
|
|
4134
|
-
* to agree: they did not, and an ascending sort
|
|
4135
|
-
* `ORDER BY … , id DESC`, so rows sharing a
|
|
4136
|
-
* every page after the first.
|
|
4195
|
+
* The key is always last and always descending, every column of it. It is
|
|
4196
|
+
* not decoration — it is what makes the ordering *total*, and a cursor over
|
|
4197
|
+
* a non-total order repeats and skips rows among the ties. Every keyset
|
|
4198
|
+
* comparison built by {@link buildCursorConditions} ends on the same key
|
|
4199
|
+
* `DESC`, and the two have to agree: they did not, and an ascending sort
|
|
4200
|
+
* paged with `id >` against an `ORDER BY … , id DESC`, so rows sharing a
|
|
4201
|
+
* sort value were dropped from every page after the first. Nor is the
|
|
4202
|
+
* first column of a composite key enough: it ties every row that shares
|
|
4203
|
+
* it, which is exactly the rows a cursor then skipped.
|
|
4137
4204
|
*
|
|
4138
4205
|
* Where the NULLs go is written out rather than inherited. Postgres already
|
|
4139
4206
|
* defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
|
|
@@ -4144,13 +4211,13 @@ var FetchService = class FetchService {
|
|
|
4144
4211
|
* relation reaches nothing from, which is precisely the "nobody waiting"
|
|
4145
4212
|
* end of a queue.
|
|
4146
4213
|
*/
|
|
4147
|
-
buildOrderExpressions(keys,
|
|
4214
|
+
buildOrderExpressions(keys, keyColumns) {
|
|
4148
4215
|
const expressions = keys.map((key) => {
|
|
4149
4216
|
const nullsLast = FetchService.nullsLast(key);
|
|
4150
4217
|
if (key.direction === "asc") return nullsLast ? sql`${key.target} ASC NULLS LAST` : sql`${key.target} ASC NULLS FIRST`;
|
|
4151
4218
|
return nullsLast ? sql`${key.target} DESC NULLS LAST` : sql`${key.target} DESC NULLS FIRST`;
|
|
4152
4219
|
});
|
|
4153
|
-
|
|
4220
|
+
for (const column of keyColumns) expressions.push(desc(column));
|
|
4154
4221
|
return expressions;
|
|
4155
4222
|
}
|
|
4156
4223
|
resolveOrderByField(table, orderBy, collection) {
|
|
@@ -4193,40 +4260,6 @@ var FetchService = class FetchService {
|
|
|
4193
4260
|
});
|
|
4194
4261
|
}
|
|
4195
4262
|
/**
|
|
4196
|
-
* Build the `with` config for Drizzle's relational query API.
|
|
4197
|
-
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
4198
|
-
*
|
|
4199
|
-
* When `include` is provided, only those relations are loaded.
|
|
4200
|
-
* When `include` is absent, ALL relations are loaded (the admin path).
|
|
4201
|
-
*
|
|
4202
|
-
* Automatically detects many-to-many junction tables and nests
|
|
4203
|
-
* the target relation so actual row data is returned.
|
|
4204
|
-
*/
|
|
4205
|
-
buildWithConfig(collection, include) {
|
|
4206
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
4207
|
-
const withConfig = {};
|
|
4208
|
-
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
4209
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
4210
|
-
if (!shouldInclude(key)) continue;
|
|
4211
|
-
const drizzleRelName = relation.relationName || key;
|
|
4212
|
-
if (relation.kind === "via") continue;
|
|
4213
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
4214
|
-
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
4215
|
-
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
4216
|
-
else withConfig[drizzleRelName] = true;
|
|
4217
|
-
} else withConfig[drizzleRelName] = true;
|
|
4218
|
-
}
|
|
4219
|
-
return withConfig;
|
|
4220
|
-
}
|
|
4221
|
-
/**
|
|
4222
|
-
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
4223
|
-
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
4224
|
-
*/
|
|
4225
|
-
getJunctionTargetRelationName(relation, _collection) {
|
|
4226
|
-
if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
4227
|
-
return null;
|
|
4228
|
-
}
|
|
4229
|
-
/**
|
|
4230
4263
|
* The relations one level of an include tree names, resolved.
|
|
4231
4264
|
*
|
|
4232
4265
|
* A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
|
|
@@ -4454,36 +4487,49 @@ var FetchService = class FetchService {
|
|
|
4454
4487
|
*/
|
|
4455
4488
|
cursorFor(collectionPath, row, orderBy) {
|
|
4456
4489
|
if (orderBy?.some(([field]) => field === FetchService.SCORE_FIELD)) return void 0;
|
|
4490
|
+
if (FetchService.DISTANCE_FIELD in row) return void 0;
|
|
4457
4491
|
try {
|
|
4458
4492
|
const pks = getPrimaryKeys(getCollectionByPath(collectionPath, this.registry), this.registry);
|
|
4459
4493
|
if (pks.length === 0) return void 0;
|
|
4460
4494
|
const address = buildCompositeId$1(row, pks);
|
|
4461
4495
|
if (!address || !address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "")) return void 0;
|
|
4462
|
-
return encodeCursor(orderBy, row, row[pks[0].fieldName]);
|
|
4496
|
+
return encodeCursor(orderBy, row, pks.length === 1 ? row[pks[0].fieldName] : address);
|
|
4463
4497
|
} catch {
|
|
4464
4498
|
return;
|
|
4465
4499
|
}
|
|
4466
4500
|
}
|
|
4467
4501
|
/**
|
|
4468
|
-
*
|
|
4469
|
-
*
|
|
4470
|
-
*
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4502
|
+
* Every relation of one row, as the refs the admin renders, attached in
|
|
4503
|
+
* place.
|
|
4504
|
+
*
|
|
4505
|
+
* Loaded by the batched loaders an `include` takes, so a target reached from
|
|
4506
|
+
* this row is read as the caller may see it: its `beforeQuery` scope and its
|
|
4507
|
+
* soft delete applied, its unreadable fields stripped. Populated through
|
|
4508
|
+
* drizzle's relational `with`, the single get knew neither, and a
|
|
4509
|
+
* tenant-scoped user fetching an owner was handed another tenant's docs —
|
|
4510
|
+
* and the trashed ones — under a relation `?include=` answered correctly.
|
|
4511
|
+
*
|
|
4512
|
+
* A to-many relation that reaches nothing is `[]`; a to-one is left off. A
|
|
4513
|
+
* relation that fails for a reason other than the database is logged and
|
|
4514
|
+
* left off, as on the include path, rather than failing the whole row.
|
|
4515
|
+
*/
|
|
4516
|
+
async loadRelationRefs(row, address, collection, collectionPath) {
|
|
4517
|
+
if (!address) return;
|
|
4518
|
+
const toRef = (related) => createRelationRefWithData(related.id, related.path, {
|
|
4519
|
+
id: related.id,
|
|
4520
|
+
path: related.path,
|
|
4521
|
+
values: related.values
|
|
4485
4522
|
});
|
|
4486
|
-
|
|
4523
|
+
for (const [key, relation] of Object.entries(resolveCollectionRelations(collection))) try {
|
|
4524
|
+
if (relation.cardinality === "one") {
|
|
4525
|
+
const related = (await this.relationService.batchFetchRelatedEntities(collectionPath, [address], key, relation)).get(address);
|
|
4526
|
+
if (related) row[key] = toRef(related);
|
|
4527
|
+
} else row[key] = ((await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, [address], key, relation)).get(address) ?? []).map(toRef);
|
|
4528
|
+
} catch (e) {
|
|
4529
|
+
if (e instanceof ApiError) throw e;
|
|
4530
|
+
if (reachedDatabase(e)) throw e;
|
|
4531
|
+
logger.warn(`[FetchService] Could not load relation '${key}' on ${collectionPath}`, { error: e });
|
|
4532
|
+
}
|
|
4487
4533
|
}
|
|
4488
4534
|
/**
|
|
4489
4535
|
* Extract cursor pagination conditions from startAfter options.
|
|
@@ -4493,26 +4539,102 @@ var FetchService = class FetchService {
|
|
|
4493
4539
|
* one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
|
|
4494
4540
|
* several it nests, each key's tie handing the decision to the next.
|
|
4495
4541
|
*/
|
|
4496
|
-
buildCursorConditions(table,
|
|
4542
|
+
buildCursorConditions(table, idInfoArray, options, collectionPath) {
|
|
4497
4543
|
if (!options.startAfter) return [];
|
|
4498
4544
|
const cursor = options.startAfter;
|
|
4499
4545
|
const keys = normalizeDriverOrderBy(options.orderBy, options.order);
|
|
4546
|
+
const keyColumns = FetchService.keyColumns(table, idInfoArray, collectionPath);
|
|
4547
|
+
const cursorKey = FetchService.cursorKey(cursor, idInfoArray, collectionPath);
|
|
4500
4548
|
if (keys) {
|
|
4501
4549
|
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 });
|
|
4502
|
-
const collection =
|
|
4503
|
-
const
|
|
4504
|
-
|
|
4505
|
-
if (resolved.length > 0 && startAfterId !== void 0) {
|
|
4550
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4551
|
+
const resolved = this.resolveOrderKeys(table, keys, collection, void 0, collectionPath, cursorKey?.length === 1 ? cursorKey[0] : void 0);
|
|
4552
|
+
if (resolved.length > 0 && cursorKey) {
|
|
4506
4553
|
const cursorValues = cursor.values;
|
|
4507
4554
|
const values = resolved.map(({ field, cursorTarget }) => cursorTarget ? null : cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
|
|
4508
|
-
if (values.every((value, i) => resolved[i].cursorTarget || value !== void 0))
|
|
4555
|
+
if (values.every((value, i) => resolved[i].cursorTarget || value !== void 0)) {
|
|
4556
|
+
const seekValues = values.map((value, i) => this.preciseCursorValue(table, resolved[i].target, value, keyColumns, cursorKey));
|
|
4557
|
+
return [this.buildKeysetComparison(resolved, seekValues, keyColumns, cursorKey)];
|
|
4558
|
+
}
|
|
4509
4559
|
}
|
|
4510
|
-
} else
|
|
4511
|
-
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
4512
|
-
if (startAfterId !== void 0 && startAfterId !== null) return [lt(idField, parseIdValues(startAfterId, [idInfo])[idInfo.fieldName])];
|
|
4513
|
-
}
|
|
4560
|
+
} else if (cursorKey) return [FetchService.keyAfter(keyColumns, cursorKey)];
|
|
4514
4561
|
return [];
|
|
4515
4562
|
}
|
|
4563
|
+
/** The table's primary key columns, in key order. */
|
|
4564
|
+
static keyColumns(table, idInfoArray, collectionPath) {
|
|
4565
|
+
return idInfoArray.map((info) => {
|
|
4566
|
+
const column = table[info.fieldName];
|
|
4567
|
+
if (!column) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
4568
|
+
return column;
|
|
4569
|
+
});
|
|
4570
|
+
}
|
|
4571
|
+
/**
|
|
4572
|
+
* The cursor row's key, one value per key column — or `undefined` for a
|
|
4573
|
+
* cursor that names no row.
|
|
4574
|
+
*
|
|
4575
|
+
* A cursor carries the row's address, which for a composite key is every
|
|
4576
|
+
* part joined (see {@link cursorFor}). One that carries fewer parts than
|
|
4577
|
+
* the key has columns — the first column's value alone, say — cannot say
|
|
4578
|
+
* which row it stopped at, and is refused rather than read as a guess.
|
|
4579
|
+
*/
|
|
4580
|
+
static cursorKey(cursor, idInfoArray, collectionPath) {
|
|
4581
|
+
const address = cursor.id;
|
|
4582
|
+
if (idInfoArray.length === 1) {
|
|
4583
|
+
const value = address ?? cursor[idInfoArray[0].fieldName];
|
|
4584
|
+
return value === void 0 || value === null ? void 0 : [value];
|
|
4585
|
+
}
|
|
4586
|
+
if (address === void 0 || address === null) {
|
|
4587
|
+
const parts = idInfoArray.map((info) => cursor[info.fieldName]);
|
|
4588
|
+
return parts.some((part) => part === void 0 || part === null) ? void 0 : parts;
|
|
4589
|
+
}
|
|
4590
|
+
let parsed;
|
|
4591
|
+
try {
|
|
4592
|
+
parsed = parseIdValues$1(String(address), idInfoArray);
|
|
4593
|
+
} catch {
|
|
4594
|
+
throw ApiError.badRequest(`Invalid cursor: its row id does not name every key column of '${collectionPath}' (${idInfoArray.map((info) => info.fieldName).join(", ")}). Pass back the \`nextCursor\` from the previous page unchanged — it is opaque and must not be built by hand.`, "INVALID_CURSOR");
|
|
4595
|
+
}
|
|
4596
|
+
return idInfoArray.map((info) => parsed[info.fieldName]);
|
|
4597
|
+
}
|
|
4598
|
+
/**
|
|
4599
|
+
* "Sorts after the cursor row" on the key alone: the key descending, as the
|
|
4600
|
+
* `ORDER BY` ends.
|
|
4601
|
+
*
|
|
4602
|
+
* Every column runs the same direction, so for a composite key the
|
|
4603
|
+
* row-value comparison is exactly the lexicographic order the `ORDER BY`
|
|
4604
|
+
* produces.
|
|
4605
|
+
*/
|
|
4606
|
+
static keyAfter(keyColumns, cursorKey) {
|
|
4607
|
+
if (keyColumns.length === 1) return lt(keyColumns[0], cursorKey[0]);
|
|
4608
|
+
const values = keyColumns.map((column, i) => sql.param(cursorKey[i], column));
|
|
4609
|
+
return sql`(${sql.join(keyColumns, sql`, `)}) < (${sql.join(values, sql`, `)})`;
|
|
4610
|
+
}
|
|
4611
|
+
/**
|
|
4612
|
+
* The cursor row's value for a timestamp key, to the microsecond.
|
|
4613
|
+
*
|
|
4614
|
+
* A cursor is built from the row as served, and a timestamp is served to
|
|
4615
|
+
* the millisecond while Postgres stores it to the microsecond. Seeking past
|
|
4616
|
+
* the served value compares the column against something up to 999µs short
|
|
4617
|
+
* of the cursor row's own, so every row in that sliver lands on the wrong
|
|
4618
|
+
* side of it: paging `createdAt desc` over rows written in one transaction
|
|
4619
|
+
* stopped after the first page, and ascending repeated the cursor row
|
|
4620
|
+
* forever.
|
|
4621
|
+
*
|
|
4622
|
+
* So the exact value is read back from the cursor row by its key — but only
|
|
4623
|
+
* while that row still holds the millisecond the cursor carries. A row
|
|
4624
|
+
* deleted or re-stamped since the page was served falls back to the stored
|
|
4625
|
+
* value, which is where the listing was when the cursor was issued.
|
|
4626
|
+
*
|
|
4627
|
+
* Any other key, and a NULL, is returned as it came.
|
|
4628
|
+
*/
|
|
4629
|
+
preciseCursorValue(table, target, value, keyColumns, cursorKey) {
|
|
4630
|
+
if (value === null || value === void 0) return value;
|
|
4631
|
+
if (!is(target, PgTimestamp) && !is(target, PgTimestampString)) return value;
|
|
4632
|
+
if (target.precision !== void 0 && target.precision <= 3) return value;
|
|
4633
|
+
const stored = sql`CAST(${value instanceof Date ? value.toISOString() : String(value)} AS ${sql.raw(target.getSQLType())})`;
|
|
4634
|
+
const alias = sql.identifier("__cursor_row");
|
|
4635
|
+
const column = sql`${alias}.${sql.identifier(target.name)}`;
|
|
4636
|
+
return sql`COALESCE((SELECT ${column} FROM ${table} AS ${alias} WHERE ${sql.join(keyColumns.map((key, i) => sql`${alias}.${sql.identifier(key.name)} = ${sql.param(cursorKey[i], key)}`), sql` AND `)} AND ${column} >= ${stored} AND ${column} < ${stored} + INTERVAL '1 millisecond' LIMIT 1), ${stored})`;
|
|
4637
|
+
}
|
|
4516
4638
|
/**
|
|
4517
4639
|
* "Sorts strictly after the cursor row", over `keys` and then the id.
|
|
4518
4640
|
*
|
|
@@ -4526,13 +4648,13 @@ var FetchService = class FetchService {
|
|
|
4526
4648
|
* NULL and therefore match nothing. Ordering by a nullable column and paging
|
|
4527
4649
|
* used to drop every row whose sort value was NULL from page two onward.
|
|
4528
4650
|
*/
|
|
4529
|
-
buildKeysetComparison(keys, values,
|
|
4530
|
-
if (index >= keys.length) return
|
|
4651
|
+
buildKeysetComparison(keys, values, keyColumns, cursorKey, index = 0) {
|
|
4652
|
+
if (index >= keys.length) return FetchService.keyAfter(keyColumns, cursorKey);
|
|
4531
4653
|
const { direction, cursorTarget } = keys[index];
|
|
4532
4654
|
const nullsLast = FetchService.nullsLast(keys[index]);
|
|
4533
4655
|
const target = keys[index].target;
|
|
4534
4656
|
const value = values[index];
|
|
4535
|
-
const rest = this.buildKeysetComparison(keys, values,
|
|
4657
|
+
const rest = this.buildKeysetComparison(keys, values, keyColumns, cursorKey, index + 1);
|
|
4536
4658
|
const after = (a, b) => direction === "asc" ? gt(a, b) : lt(a, b);
|
|
4537
4659
|
const afterSql = (a, b) => direction === "asc" ? sql`${a} > ${b}` : sql`${a} < ${b}`;
|
|
4538
4660
|
if (cursorTarget) return nullsLast ? or(and(isNull(cursorTarget), isNull(target), rest), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), isNull(target), and(sql`${target} = ${cursorTarget}`, rest)))) : or(and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), and(sql`${target} = ${cursorTarget}`, rest))));
|
|
@@ -4546,7 +4668,7 @@ var FetchService = class FetchService {
|
|
|
4546
4668
|
buildRelationScope(hop) {
|
|
4547
4669
|
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
4548
4670
|
const parentIdInfo = parentPks[0];
|
|
4549
|
-
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
4671
|
+
const parsedParentId = parseIdValues$1(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
4550
4672
|
const parent = () => {
|
|
4551
4673
|
const table = getTableForCollection(hop.parentCollection, this.registry);
|
|
4552
4674
|
const idColumn = table[parentIdInfo.fieldName];
|
|
@@ -4586,60 +4708,38 @@ var FetchService = class FetchService {
|
|
|
4586
4708
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4587
4709
|
const table = getTableForCollection(collection, this.registry);
|
|
4588
4710
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4589
|
-
const idInfo = idInfoArray[0];
|
|
4590
|
-
const idField = table[idInfo.fieldName];
|
|
4591
|
-
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
4592
4711
|
if (!idCanAddressTable(id, table, idInfoArray)) return void 0;
|
|
4593
|
-
const
|
|
4712
|
+
const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
|
|
4594
4713
|
const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", {});
|
|
4595
|
-
const identity = narrowing ? and(
|
|
4714
|
+
const identity = narrowing ? and(addressed, narrowing) : addressed;
|
|
4596
4715
|
const tableName = getTableName(table);
|
|
4597
4716
|
const qb = this.getQueryBuilder(tableName);
|
|
4598
|
-
if (qb)
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
if (
|
|
4612
|
-
|
|
4613
|
-
|
|
4717
|
+
if (qb) {
|
|
4718
|
+
let row = null;
|
|
4719
|
+
try {
|
|
4720
|
+
const hidden = hiddenColumnsOption(getTableColumns(table), collection);
|
|
4721
|
+
row = await qb.findFirst({
|
|
4722
|
+
where: andSoftDelete(identity, collection, table, withDeleted),
|
|
4723
|
+
...hidden ? { columns: hidden } : {}
|
|
4724
|
+
});
|
|
4725
|
+
} catch (e) {
|
|
4726
|
+
if (reachedDatabase(e)) throw e;
|
|
4727
|
+
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
4728
|
+
}
|
|
4729
|
+
if (row === void 0) return void 0;
|
|
4730
|
+
if (row !== null) {
|
|
4731
|
+
const flatRow = toFlatRow(row, collection, this.registry);
|
|
4732
|
+
await this.loadRelationRefs(flatRow, buildCompositeId$1(row, idInfoArray), collection, collectionPath);
|
|
4733
|
+
return stripUnreadable(flatRow, collection);
|
|
4614
4734
|
}
|
|
4615
|
-
if (reachedDatabase(e)) throw e;
|
|
4616
|
-
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
4617
4735
|
}
|
|
4618
4736
|
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
4619
4737
|
const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
|
|
4620
4738
|
if (result.length === 0) return void 0;
|
|
4621
4739
|
const raw = result[0];
|
|
4622
|
-
const values = await parseDataFromServer(raw, collection
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
const relationPromises = Object.entries(resolvedRelations).filter(([key]) => propertyKeys.has(key)).map(async ([key, relation]) => {
|
|
4626
|
-
if (relation.cardinality === "many") {
|
|
4627
|
-
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, {});
|
|
4628
|
-
values[key] = relatedRows.map((e) => createRelationRef(e.id, e.path));
|
|
4629
|
-
} else if (relation.cardinality === "one") {
|
|
4630
|
-
if (values[key] == null) try {
|
|
4631
|
-
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: 1 });
|
|
4632
|
-
if (relatedRows.length > 0) {
|
|
4633
|
-
const e = relatedRows[0];
|
|
4634
|
-
values[key] = createRelationRef(e.id, e.path);
|
|
4635
|
-
}
|
|
4636
|
-
} catch (e) {
|
|
4637
|
-
if (reachedDatabase(e)) throw e;
|
|
4638
|
-
logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
|
|
4639
|
-
}
|
|
4640
|
-
}
|
|
4641
|
-
});
|
|
4642
|
-
await Promise.all(relationPromises);
|
|
4740
|
+
const values = await parseDataFromServer(raw, collection);
|
|
4741
|
+
await this.loadRelationRefs(values, buildCompositeId$1(raw, idInfoArray), collection, collectionPath);
|
|
4742
|
+
stripUnreadable(values, collection);
|
|
4643
4743
|
return {
|
|
4644
4744
|
...values,
|
|
4645
4745
|
id: id.toString()
|
|
@@ -4670,6 +4770,7 @@ var FetchService = class FetchService {
|
|
|
4670
4770
|
if (results.length === 0) return [];
|
|
4671
4771
|
const values = await Promise.all(results.map(async (rawRow) => await parseDataFromServer(rawRow, collection)));
|
|
4672
4772
|
if (include) await this.loadIncludes(values, collection, collectionPath, include);
|
|
4773
|
+
for (const row of values) stripUnreadable(row, collection);
|
|
4673
4774
|
return values;
|
|
4674
4775
|
}
|
|
4675
4776
|
/**
|
|
@@ -4677,11 +4778,10 @@ var FetchService = class FetchService {
|
|
|
4677
4778
|
*/
|
|
4678
4779
|
async fetchCollection(collectionPath, options = {}) {
|
|
4679
4780
|
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
4680
|
-
|
|
4781
|
+
return this.fetchRowsWithConditions(hop ? hop.targetCollection.slug : collectionPath, {
|
|
4681
4782
|
...options,
|
|
4682
4783
|
relatedTo: hop
|
|
4683
4784
|
});
|
|
4684
|
-
return this.fetchRowsWithConditions(collectionPath, options);
|
|
4685
4785
|
}
|
|
4686
4786
|
/**
|
|
4687
4787
|
* Search rows by text
|
|
@@ -4806,7 +4906,9 @@ var FetchService = class FetchService {
|
|
|
4806
4906
|
}
|
|
4807
4907
|
if (groupColumns.length > 0) {
|
|
4808
4908
|
query = query.groupBy(...groupColumns.map((g) => g.column));
|
|
4909
|
+
query = query.orderBy(...this.aggregateOrdering(options.orderBy, groupColumns, options.aggregates, selection));
|
|
4809
4910
|
if (options.limit) query = query.limit(options.limit);
|
|
4911
|
+
if (options.offset) query = query.offset(options.offset);
|
|
4810
4912
|
}
|
|
4811
4913
|
const rows = await query;
|
|
4812
4914
|
const numericAliases = numericAggregateAliases(options.aggregates, collection?.properties ?? {});
|
|
@@ -4821,6 +4923,50 @@ var FetchService = class FetchService {
|
|
|
4821
4923
|
});
|
|
4822
4924
|
}
|
|
4823
4925
|
/**
|
|
4926
|
+
* The `ORDER BY` of a grouped aggregate: the caller's keys, then every
|
|
4927
|
+
* group key they did not name.
|
|
4928
|
+
*
|
|
4929
|
+
* A grouped aggregate is paged like a listing — `limit` bounds it, so more
|
|
4930
|
+
* groups than one page holds are reached with `offset` — and a page is only
|
|
4931
|
+
* a page over a total order. With no `ORDER BY` at all, Postgres returns the
|
|
4932
|
+
* groups in whatever order its plan produces them, so "the next fifty" was
|
|
4933
|
+
* not a thing that existed. The group keys identify a group, so ending on
|
|
4934
|
+
* them makes the order total and puts every page boundary in the same place
|
|
4935
|
+
* on every request.
|
|
4936
|
+
*
|
|
4937
|
+
* A key may name a `groupBy` field or an aggregate by its alias
|
|
4938
|
+
* (`count`, `sum_total`), sorted on the aggregate's own expression — a
|
|
4939
|
+
* drizzle selection is not aliased in the SQL, so there is no output name
|
|
4940
|
+
* to refer to. Anything else is a 400: Postgres would refuse it too, but as
|
|
4941
|
+
* a `GROUP BY` error from inside the statement.
|
|
4942
|
+
*/
|
|
4943
|
+
aggregateOrdering(orderBy, groupColumns, aggregates, selection) {
|
|
4944
|
+
const keys = [];
|
|
4945
|
+
const named = /* @__PURE__ */ new Set();
|
|
4946
|
+
for (const [field, direction, nulls] of orderBy ?? []) {
|
|
4947
|
+
const group = groupColumns.find((g) => g.field === field);
|
|
4948
|
+
const aggregate = aggregates.find((a) => a.alias === field);
|
|
4949
|
+
const target = group?.column ?? (aggregate ? selection[aggregate.alias] : void 0);
|
|
4950
|
+
if (!target) {
|
|
4951
|
+
const sortable = [...groupColumns.map((g) => g.field), ...aggregates.map((a) => a.alias)];
|
|
4952
|
+
throw ApiError.badRequest(`An aggregate cannot be sorted by '${field}'. It sorts by what it returns: ${sortable.map((name) => `'${name}'`).join(", ")}.`, "INVALID_ORDER_BY");
|
|
4953
|
+
}
|
|
4954
|
+
keys.push({
|
|
4955
|
+
field,
|
|
4956
|
+
direction,
|
|
4957
|
+
nulls,
|
|
4958
|
+
target
|
|
4959
|
+
});
|
|
4960
|
+
named.add(field);
|
|
4961
|
+
}
|
|
4962
|
+
for (const group of groupColumns) if (!named.has(group.field)) keys.push({
|
|
4963
|
+
field: group.field,
|
|
4964
|
+
direction: "asc",
|
|
4965
|
+
target: group.column
|
|
4966
|
+
});
|
|
4967
|
+
return this.buildOrderExpressions(keys, []);
|
|
4968
|
+
}
|
|
4969
|
+
/**
|
|
4824
4970
|
* Check if a field value is unique.
|
|
4825
4971
|
*
|
|
4826
4972
|
* Deliberately **not** narrowed by `beforeQuery`, and it is the only read
|
|
@@ -4836,12 +4982,10 @@ var FetchService = class FetchService {
|
|
|
4836
4982
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4837
4983
|
const table = getTableForCollection(collection, this.registry);
|
|
4838
4984
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4839
|
-
const idInfo = idInfoArray[0];
|
|
4840
|
-
const idField = table[idInfo.fieldName];
|
|
4841
4985
|
const field = table[fieldName];
|
|
4842
4986
|
if (!field) return true;
|
|
4843
|
-
const
|
|
4844
|
-
const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(field, value,
|
|
4987
|
+
const excludeRow = excludeEntityId && idCanAddressTable(excludeEntityId, table, idInfoArray) ? rowIdentityCondition(table, idInfoArray, excludeEntityId, collectionPath) : void 0;
|
|
4988
|
+
const conditions = DrizzleConditionBuilder.buildUniqueFieldCondition(field, value, excludeRow);
|
|
4845
4989
|
const result = await this.db.select({ count: count() }).from(table).where(and(...conditions));
|
|
4846
4990
|
return Number(result[0]?.count || 0) === 0;
|
|
4847
4991
|
}
|
|
@@ -4901,14 +5045,12 @@ var FetchService = class FetchService {
|
|
|
4901
5045
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4902
5046
|
const table = getTableForCollection(collection, this.registry);
|
|
4903
5047
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4904
|
-
const idInfo = idInfoArray[0];
|
|
4905
|
-
const idField = table[idInfo.fieldName];
|
|
4906
5048
|
if (!idCanAddressTable(id, table, idInfoArray)) return null;
|
|
4907
|
-
const
|
|
5049
|
+
const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
|
|
4908
5050
|
const withDeleted = options?.withDeleted;
|
|
4909
5051
|
const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
|
|
4910
5052
|
const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", FetchService.describeRead({ fields: options?.fields }));
|
|
4911
|
-
const identity = narrowing ? and(
|
|
5053
|
+
const identity = narrowing ? and(addressed, narrowing) : addressed;
|
|
4912
5054
|
const result = await this.db.select(projection).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
|
|
4913
5055
|
if (result.length === 0) return null;
|
|
4914
5056
|
const row = toRestRow(result[0], collection, this.registry);
|
|
@@ -4923,8 +5065,7 @@ var FetchService = class FetchService {
|
|
|
4923
5065
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4924
5066
|
const table = getTableForCollection(collection, this.registry);
|
|
4925
5067
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4926
|
-
|
|
4927
|
-
const idField = table[idInfo.fieldName];
|
|
5068
|
+
if (options.vectorSearch && options.startAfter) throw ApiError.badRequest("Cursor pagination (`startAfter`) cannot be combined with a vector search. Its rows are ordered by a distance computed per query, which cannot key a cursor. Use `limit`/`offset` for the next page of nearest rows.", "VECTOR_CURSOR_UNSUPPORTED");
|
|
4928
5069
|
let vectorMeta;
|
|
4929
5070
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
4930
5071
|
const visible = this.columnProjection(table, collection, options.fields, idInfoArray, options.distinct === true);
|
|
@@ -4972,10 +5113,11 @@ var FetchService = class FetchService {
|
|
|
4972
5113
|
const missing = sortKeys.map(([field]) => field).filter((field) => !selected.has(field));
|
|
4973
5114
|
if (missing.length > 0) throw ApiError.badRequest(`\`distinct\` cannot sort by ${missing.map((f) => `'${f}'`).join(", ")}: a DISTINCT read can only be ordered by columns it returns, or the rows it collapses have no defined order. Add ${missing.map((f) => `'${f}'`).join(", ")} to \`fields\`, or drop them from \`orderBy\`.`, "DISTINCT_ORDER_BY_NOT_SELECTED", { fields: missing });
|
|
4974
5115
|
}
|
|
4975
|
-
const
|
|
5116
|
+
const keyColumns = FetchService.keyColumns(table, idInfoArray, collectionPath);
|
|
5117
|
+
const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), ...keyColumns.map((column) => desc(column))] : this.buildOrderExpressions(this.resolveOrderKeys(table, sortKeys, collection, options.searchString), wantsDistinct ? [] : keyColumns);
|
|
4976
5118
|
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
4977
5119
|
if (options.startAfter) {
|
|
4978
|
-
const cursorConditions = this.buildCursorConditions(table,
|
|
5120
|
+
const cursorConditions = this.buildCursorConditions(table, idInfoArray, options, collectionPath);
|
|
4979
5121
|
if (cursorConditions.length > 0) {
|
|
4980
5122
|
allConditions.push(...cursorConditions);
|
|
4981
5123
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -5114,16 +5256,33 @@ var RelationWriteService = class {
|
|
|
5114
5256
|
relation: hop.relationKey
|
|
5115
5257
|
});
|
|
5116
5258
|
}
|
|
5259
|
+
/**
|
|
5260
|
+
* The target rows of `relation` this caller can see, for a junction diff —
|
|
5261
|
+
* or `undefined` when that is all of them. See
|
|
5262
|
+
* {@link RelationService.visibleTargetCondition}.
|
|
5263
|
+
*/
|
|
5264
|
+
async visibleTargets(parentCollection, relation, parentId) {
|
|
5265
|
+
const condition = await this.reads.visibleTargetCondition(parentCollection, relation, parentId);
|
|
5266
|
+
if (!condition) return void 0;
|
|
5267
|
+
const targetCollection = relation.target();
|
|
5268
|
+
const table = getTableForCollection(targetCollection, this.registry);
|
|
5269
|
+
const [idInfo] = requirePrimaryKeys(targetCollection, this.registry);
|
|
5270
|
+
return {
|
|
5271
|
+
table,
|
|
5272
|
+
idColumn: table[idInfo.fieldName],
|
|
5273
|
+
condition
|
|
5274
|
+
};
|
|
5275
|
+
}
|
|
5117
5276
|
/** A collection's id, parsed to the type its primary key column holds. */
|
|
5118
5277
|
parsedId(collection, id) {
|
|
5119
5278
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
5120
|
-
return parseIdValues(id, pks)[pks[0].fieldName];
|
|
5279
|
+
return parseIdValues$1(id, pks)[pks[0].fieldName];
|
|
5121
5280
|
}
|
|
5122
5281
|
/** The same, for the membership list a to-many write names. */
|
|
5123
5282
|
parsedIds(collection, ids) {
|
|
5124
5283
|
if (ids.length === 0) return [];
|
|
5125
5284
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
5126
|
-
return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
|
|
5285
|
+
return ids.map((id) => parseIdValues$1(id, pks)[pks[0].fieldName]);
|
|
5127
5286
|
}
|
|
5128
5287
|
/**
|
|
5129
5288
|
* A membership list as links: the target ids parsed, and each element's
|
|
@@ -5141,7 +5300,7 @@ var RelationWriteService = class {
|
|
|
5141
5300
|
if (elements.length === 0) return [];
|
|
5142
5301
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
5143
5302
|
return elements.map((element) => ({
|
|
5144
|
-
id: parseIdValues(element.id, pks)[pks[0].fieldName],
|
|
5303
|
+
id: parseIdValues$1(element.id, pks)[pks[0].fieldName],
|
|
5145
5304
|
pivot: element.pivot ? serializePivot(element.pivot, payload, label) : void 0
|
|
5146
5305
|
}));
|
|
5147
5306
|
}
|
|
@@ -5157,8 +5316,8 @@ var RelationWriteService = class {
|
|
|
5157
5316
|
const targetEntityIds = elements.map((element) => element.id);
|
|
5158
5317
|
const targetCollection = relation.target();
|
|
5159
5318
|
const label = `${collection.slug}.${key}`;
|
|
5160
|
-
if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, {}, label));
|
|
5161
|
-
else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, relation.through.properties, label));
|
|
5319
|
+
if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, {}, label), await this.visibleTargets(collection, relation, id));
|
|
5320
|
+
else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedLinks(targetCollection, elements, relation.through.properties, label), await this.visibleTargets(collection, relation, id));
|
|
5162
5321
|
else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
5163
5322
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
5164
5323
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -5169,11 +5328,12 @@ var RelationWriteService = class {
|
|
|
5169
5328
|
if (!fkCol || !targetIdCol) throw relationMisconfigured(label, `the target table '${getTableName$1(targetCollection)}' has no ${fkCol ? `'${targetIdInfo.fieldName}' key column` : `'${relation.foreignKeyOnTarget}' foreign-key column`}`);
|
|
5170
5329
|
const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
5171
5330
|
if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
|
|
5331
|
+
const visible = await this.reads.visibleTargetCondition(collection, relation, id);
|
|
5172
5332
|
if (targetEntityIds.length > 0) {
|
|
5173
|
-
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
5174
|
-
await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds)));
|
|
5333
|
+
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues$1(id, targetPks)[targetIdInfo.fieldName]);
|
|
5334
|
+
await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds), visible));
|
|
5175
5335
|
await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
5176
|
-
} else await tx.update(targetTable).set({ [fkField]: null }).where(eq(fkCol, parentKeyValue));
|
|
5336
|
+
} else await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), visible));
|
|
5177
5337
|
} else throw relationMisconfigured(label, "it is a to-many relation with no way to write links — a `manyToMany` needs `through`, a `hasMany` needs `foreignKeyOnTarget`");
|
|
5178
5338
|
}
|
|
5179
5339
|
}
|
|
@@ -5209,15 +5369,16 @@ var RelationWriteService = class {
|
|
|
5209
5369
|
if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
|
|
5210
5370
|
const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
5211
5371
|
if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
|
|
5212
|
-
|
|
5372
|
+
const visible = await this.reads.visibleTargetCondition(sourceCollection, relation, sourceEntityId);
|
|
5373
|
+
if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(foreignKeyColumn, sourceKeyValue), visible));
|
|
5213
5374
|
else {
|
|
5214
|
-
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
5375
|
+
const parsedNewTargetId = parseIdValues$1(newValue, targetPks)[targetIdInfo.fieldName];
|
|
5215
5376
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
5216
|
-
await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
|
|
5377
|
+
await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(foreignKeyColumn, sourceKeyValue), visible));
|
|
5217
5378
|
await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
5218
5379
|
}
|
|
5219
5380
|
} catch (e) {
|
|
5220
|
-
if (e instanceof ApiError || e?.name === "ApiError") throw e;
|
|
5381
|
+
if (e instanceof ApiError || e?.name === "ApiError" || reachedDatabase(e)) throw e;
|
|
5221
5382
|
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
5222
5383
|
}
|
|
5223
5384
|
}
|
|
@@ -5232,7 +5393,7 @@ var RelationWriteService = class {
|
|
|
5232
5393
|
try {
|
|
5233
5394
|
const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
|
|
5234
5395
|
const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationLinkElements([newValue], relation.relationName, sourceCollection.slug);
|
|
5235
|
-
await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, {}, `${sourceCollection.slug}.${relation.relationName}`));
|
|
5396
|
+
await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, {}, `${sourceCollection.slug}.${relation.relationName}`), await this.visibleTargets(sourceCollection, relation, sourceEntityId));
|
|
5236
5397
|
} catch (error) {
|
|
5237
5398
|
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
5238
5399
|
throw error;
|
|
@@ -5245,7 +5406,7 @@ var RelationWriteService = class {
|
|
|
5245
5406
|
const label = `${sourceCollection.slug}.${relation.relationName}`;
|
|
5246
5407
|
try {
|
|
5247
5408
|
const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : [];
|
|
5248
|
-
await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, label), this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, junctionInfo.properties, label));
|
|
5409
|
+
await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, label), this.parsedId(sourceCollection, sourceEntityId), this.parsedLinks(targetCollection, elements, junctionInfo.properties, label), await this.visibleTargets(sourceCollection, relation, sourceEntityId));
|
|
5249
5410
|
} catch (error) {
|
|
5250
5411
|
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
5251
5412
|
throw error;
|
|
@@ -5268,7 +5429,7 @@ var RelationWriteService = class {
|
|
|
5268
5429
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
5269
5430
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
5270
5431
|
const parentIdInfo = parentPks[0];
|
|
5271
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5432
|
+
const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5272
5433
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
5273
5434
|
const parentSourceCol = parentTable[parentSourceColName];
|
|
5274
5435
|
const targetFKCol = targetTable[targetFKColName];
|
|
@@ -5282,7 +5443,7 @@ var RelationWriteService = class {
|
|
|
5282
5443
|
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
5283
5444
|
continue;
|
|
5284
5445
|
}
|
|
5285
|
-
const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
5446
|
+
const parsedTargetId = parseIdValues$1(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
5286
5447
|
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
5287
5448
|
else throw ApiError.badRequest(`Cannot write relation '${label}': row '${parentId}' has no value in '${parentSourceColName}', which is the column its joinPath joins on, so there is nothing for the related row to point at.`, "RELATION_SOURCE_KEY_EMPTY");
|
|
5288
5449
|
await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
|
|
@@ -5351,6 +5512,10 @@ var RelationWriteService = class {
|
|
|
5351
5512
|
* silently empties itself the first time it is incremented from an unset column
|
|
5352
5513
|
* is worse than one that never worked.
|
|
5353
5514
|
*
|
|
5515
|
+
* What an operation produces answers to the property's declared bounds, and
|
|
5516
|
+
* that check is compiled here too, as conditions for the same UPDATE — see
|
|
5517
|
+
* {@link compileFieldOpBounds}.
|
|
5518
|
+
*
|
|
5354
5519
|
* @module
|
|
5355
5520
|
*/
|
|
5356
5521
|
/** What the column is, as Postgres sees it: `text[]`, `jsonb`, `integer`, … */
|
|
@@ -5429,9 +5594,165 @@ function compileFieldOps(table, fieldOps, context) {
|
|
|
5429
5594
|
for (const [field, op] of Object.entries(fieldOps)) compiled[field] = compileFieldOp(table, field, op, context);
|
|
5430
5595
|
return compiled;
|
|
5431
5596
|
}
|
|
5597
|
+
/**
|
|
5598
|
+
* The declared bounds each operation's *result* must satisfy, as conditions
|
|
5599
|
+
* for the UPDATE that applies it.
|
|
5600
|
+
*
|
|
5601
|
+
* `{ stock: -5 }` on `stock: { validation: { min: 0 } }` is refused before the
|
|
5602
|
+
* statement is built, because the value is in the request. What
|
|
5603
|
+
* `{ stock: { $inc: -1000 } }` produces depends on the stored row, which the
|
|
5604
|
+
* request never sees, so it can only be judged where the row is: in the WHERE
|
|
5605
|
+
* of the UPDATE that does the arithmetic, over the row that statement holds
|
|
5606
|
+
* locked. Reading the row first and judging the sum would reopen the race the
|
|
5607
|
+
* operation exists to close — two decrements of a stock of 1 would each read
|
|
5608
|
+
* `1`, each find room for one more, and both go through. A condition on the
|
|
5609
|
+
* UPDATE cannot be raced that way: under READ COMMITTED an UPDATE blocked on a
|
|
5610
|
+
* concurrent writer re-evaluates its WHERE against the row that writer
|
|
5611
|
+
* committed, so the second decrement sees `0 - 1` and matches nothing.
|
|
5612
|
+
*
|
|
5613
|
+
* The bound is the whole declared range, applied to the result the way it
|
|
5614
|
+
* applies to a value: a number's `min`, `max`, `moreThan`, `lessThan`,
|
|
5615
|
+
* `positive` and `negative`, an array's `min` and `max` items. `integer` is not
|
|
5616
|
+
* here: a whole increment of a whole number stays whole, and the request
|
|
5617
|
+
* already refuses a fractional one. `$merge` has no bounds to hold.
|
|
5618
|
+
*/
|
|
5619
|
+
function compileFieldOpBounds(table, fieldOps, properties, context) {
|
|
5620
|
+
const bounds = [];
|
|
5621
|
+
for (const [field, op] of Object.entries(fieldOps)) {
|
|
5622
|
+
const property = properties[field];
|
|
5623
|
+
if (!property?.validation) continue;
|
|
5624
|
+
if (property.type === "number" && op.operator === "$inc" && typeof op.operand === "number") {
|
|
5625
|
+
const result = compileFieldOp(table, field, op, context);
|
|
5626
|
+
bounds.push(...numberBounds(field, property.validation, result, op.operand));
|
|
5627
|
+
} else if (property.type === "array" && (op.operator === "$push" || op.operator === "$pull")) {
|
|
5628
|
+
const column = table[field];
|
|
5629
|
+
const result = compileFieldOp(table, field, op, context);
|
|
5630
|
+
const size = column && isNativeArray(sqlTypeOf(column)) ? sql`cardinality(${result})` : sql`jsonb_array_length((${result})::jsonb)`;
|
|
5631
|
+
bounds.push(...arrayBounds(field, property.validation, size, op.operator));
|
|
5632
|
+
}
|
|
5633
|
+
}
|
|
5634
|
+
return bounds;
|
|
5635
|
+
}
|
|
5636
|
+
function numberBounds(field, rules, result, operand) {
|
|
5637
|
+
const by = `$inc by ${operand}`;
|
|
5638
|
+
const bounds = [];
|
|
5639
|
+
if (rules.min !== void 0) bounds.push({
|
|
5640
|
+
field,
|
|
5641
|
+
code: "min",
|
|
5642
|
+
holds: sql`(${result}) >= ${rules.min}::numeric`,
|
|
5643
|
+
message: `'${field}' must be at least ${rules.min}, and ${by} would take it below that.`
|
|
5644
|
+
});
|
|
5645
|
+
if (rules.max !== void 0) bounds.push({
|
|
5646
|
+
field,
|
|
5647
|
+
code: "max",
|
|
5648
|
+
holds: sql`(${result}) <= ${rules.max}::numeric`,
|
|
5649
|
+
message: `'${field}' must be at most ${rules.max}, and ${by} would take it above that.`
|
|
5650
|
+
});
|
|
5651
|
+
if (rules.moreThan !== void 0) bounds.push({
|
|
5652
|
+
field,
|
|
5653
|
+
code: "more_than",
|
|
5654
|
+
holds: sql`(${result}) > ${rules.moreThan}::numeric`,
|
|
5655
|
+
message: `'${field}' must be greater than ${rules.moreThan}, and ${by} would take it to ${rules.moreThan} or below.`
|
|
5656
|
+
});
|
|
5657
|
+
if (rules.lessThan !== void 0) bounds.push({
|
|
5658
|
+
field,
|
|
5659
|
+
code: "less_than",
|
|
5660
|
+
holds: sql`(${result}) < ${rules.lessThan}::numeric`,
|
|
5661
|
+
message: `'${field}' must be less than ${rules.lessThan}, and ${by} would take it to ${rules.lessThan} or above.`
|
|
5662
|
+
});
|
|
5663
|
+
if (rules.positive) bounds.push({
|
|
5664
|
+
field,
|
|
5665
|
+
code: "positive",
|
|
5666
|
+
holds: sql`(${result}) > 0`,
|
|
5667
|
+
message: `'${field}' must be positive, and ${by} would take it to 0 or below.`
|
|
5668
|
+
});
|
|
5669
|
+
if (rules.negative) bounds.push({
|
|
5670
|
+
field,
|
|
5671
|
+
code: "negative",
|
|
5672
|
+
holds: sql`(${result}) < 0`,
|
|
5673
|
+
message: `'${field}' must be negative, and ${by} would take it to 0 or above.`
|
|
5674
|
+
});
|
|
5675
|
+
return bounds;
|
|
5676
|
+
}
|
|
5677
|
+
function arrayBounds(field, rules, size, operator) {
|
|
5678
|
+
const items = (n) => `${n} item${n === 1 ? "" : "s"}`;
|
|
5679
|
+
const bounds = [];
|
|
5680
|
+
if (rules.min !== void 0) bounds.push({
|
|
5681
|
+
field,
|
|
5682
|
+
code: "min_items",
|
|
5683
|
+
holds: sql`COALESCE(${size} >= ${rules.min}::numeric, TRUE)`,
|
|
5684
|
+
message: `'${field}' must have at least ${items(rules.min)}, and ${operator} would leave it with fewer.`
|
|
5685
|
+
});
|
|
5686
|
+
if (rules.max !== void 0) bounds.push({
|
|
5687
|
+
field,
|
|
5688
|
+
code: "max_items",
|
|
5689
|
+
holds: sql`COALESCE(${size} <= ${rules.max}::numeric, TRUE)`,
|
|
5690
|
+
message: `'${field}' must have at most ${items(rules.max)}, and ${operator} would leave it with more.`
|
|
5691
|
+
});
|
|
5692
|
+
return bounds;
|
|
5693
|
+
}
|
|
5694
|
+
/**
|
|
5695
|
+
* Which bounds the row, as it now stands, would break — for an UPDATE that
|
|
5696
|
+
* carried bounds and matched nothing.
|
|
5697
|
+
*
|
|
5698
|
+
* A guarded UPDATE matching zero rows means one of three things: the row is not
|
|
5699
|
+
* there for this caller (404), a row-level security policy refused the write
|
|
5700
|
+
* (403), or the operation's result broke a bound (400). The first two are
|
|
5701
|
+
* `explainZeroRowWrite`'s; this answers the third, by evaluating each bound
|
|
5702
|
+
* against the row over the same handle and the same key conditions. An empty
|
|
5703
|
+
* answer — the row is not visible, or every bound holds — leaves the decision to
|
|
5704
|
+
* `explainZeroRowWrite`.
|
|
5705
|
+
*
|
|
5706
|
+
* It is a second statement, so the row can change in between. A row deleted in
|
|
5707
|
+
* between is a 404 here as it would have been anyway. A row changed so that the
|
|
5708
|
+
* operation now fits reads as "every bound holds" and falls through to the
|
|
5709
|
+
* row-level-security answer; the write was refused either way, and the
|
|
5710
|
+
* refusal is never turned into a write.
|
|
5711
|
+
*
|
|
5712
|
+
* Only booleans come back — whether each bound holds — never the stored value.
|
|
5713
|
+
*/
|
|
5714
|
+
async function brokenFieldOpBounds(handle, table, conditions, bounds) {
|
|
5715
|
+
if (bounds.length === 0) return [];
|
|
5716
|
+
const selection = {};
|
|
5717
|
+
bounds.forEach((bound, index) => {
|
|
5718
|
+
selection[`bound${index}`] = sql`(${bound.holds})`;
|
|
5719
|
+
});
|
|
5720
|
+
const row = (await handle.select(selection).from(table).where(and(...conditions)).limit(1))[0];
|
|
5721
|
+
if (!row) return [];
|
|
5722
|
+
return bounds.filter((_bound, index) => row[`bound${index}`] === false);
|
|
5723
|
+
}
|
|
5724
|
+
/** The 400 a write gets when an operation's result breaks a bound. */
|
|
5725
|
+
function fieldOpBoundsError(collectionSlug, broken) {
|
|
5726
|
+
const violations = broken.map(({ field, code, message }) => ({
|
|
5727
|
+
field,
|
|
5728
|
+
code,
|
|
5729
|
+
message
|
|
5730
|
+
}));
|
|
5731
|
+
const messages = violations.map((violation) => violation.message);
|
|
5732
|
+
return ApiError.badRequest(messages.join(" "), "VALIDATION_CONSTRAINT", {
|
|
5733
|
+
collection: collectionSlug,
|
|
5734
|
+
violations,
|
|
5735
|
+
messages
|
|
5736
|
+
});
|
|
5737
|
+
}
|
|
5432
5738
|
//#endregion
|
|
5433
5739
|
//#region src/services/PersistService.ts
|
|
5434
5740
|
/**
|
|
5741
|
+
* The top-level properties whose value is stamped once, when the row is
|
|
5742
|
+
* created: `on_create` dates and `user_on_create` identities.
|
|
5743
|
+
*
|
|
5744
|
+
* What they hold is a fact about the row's creation, so a write to a row that
|
|
5745
|
+
* already exists has nothing to say about them — an update, and the
|
|
5746
|
+
* conflict-update of an upsert that met a stored row. Top level only: a stamp
|
|
5747
|
+
* nested inside a `map` is part of that map's value, not a column a write can
|
|
5748
|
+
* leave out.
|
|
5749
|
+
*/
|
|
5750
|
+
function createStampKeys(properties) {
|
|
5751
|
+
const keys = [];
|
|
5752
|
+
for (const [key, property] of Object.entries(properties ?? {})) if (property?.type === "date" && property.autoValue === "on_create" || property?.type === "string" && property.autoValue === "user_on_create") keys.push(key);
|
|
5753
|
+
return keys;
|
|
5754
|
+
}
|
|
5755
|
+
/**
|
|
5435
5756
|
* Service for handling all row write operations.
|
|
5436
5757
|
* Handles saving, deleting, and updating rows.
|
|
5437
5758
|
*/
|
|
@@ -5493,14 +5814,20 @@ var PersistService = class {
|
|
|
5493
5814
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
5494
5815
|
const table = getTableForCollection(collection, this.registry);
|
|
5495
5816
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
5496
|
-
const parsedIdObj = parseIdValues(id, idInfoArray);
|
|
5817
|
+
const parsedIdObj = parseIdValues$1(id, idInfoArray);
|
|
5497
5818
|
const conditions = [];
|
|
5498
5819
|
for (const info of idInfoArray) {
|
|
5499
5820
|
const field = table[info.fieldName];
|
|
5500
5821
|
if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
5501
5822
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
5502
5823
|
}
|
|
5503
|
-
|
|
5824
|
+
let result;
|
|
5825
|
+
try {
|
|
5826
|
+
result = await this.db.delete(table).where(and(...conditions));
|
|
5827
|
+
} catch (error) {
|
|
5828
|
+
throw this.toUserFriendlyError(error, collection.slug, collection, "delete");
|
|
5829
|
+
}
|
|
5830
|
+
if ((result.rowCount ?? 0) === 0) throw await this.explainZeroRowWrite(this.db, table, conditions, collectionPath, id, "delete");
|
|
5504
5831
|
}
|
|
5505
5832
|
/**
|
|
5506
5833
|
* Delete all rows from a collection
|
|
@@ -5544,6 +5871,10 @@ var PersistService = class {
|
|
|
5544
5871
|
* the row already exists — which is what a re-runnable import needs. The
|
|
5545
5872
|
* conflict is matched on the primary key unless `options.onConflict` names
|
|
5546
5873
|
* other columns; see {@link SaveProps.onConflict} for why that matters.
|
|
5874
|
+
* When the INSERT meets a stored row, the conflict-update sets what the
|
|
5875
|
+
* caller wrote and nothing the create alone decides — see
|
|
5876
|
+
* {@link PersistSaveOptions.insertOnlyKeys} — and only on a row the caller's
|
|
5877
|
+
* `beforeQuery` scope reaches.
|
|
5547
5878
|
*
|
|
5548
5879
|
* `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
|
|
5549
5880
|
* split out here rather than at the REST boundary because every request
|
|
@@ -5561,7 +5892,7 @@ var PersistService = class {
|
|
|
5561
5892
|
effectiveCollectionPath = hop.targetCollection.slug;
|
|
5562
5893
|
const parentIdForWrite = () => {
|
|
5563
5894
|
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
5564
|
-
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
5895
|
+
return parseIdValues$1(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
5565
5896
|
};
|
|
5566
5897
|
if (isManyToMany(hop.relation)) junctionTableInfo = {
|
|
5567
5898
|
parentCollection: hop.parentCollection,
|
|
@@ -5622,10 +5953,11 @@ var PersistService = class {
|
|
|
5622
5953
|
let currentId;
|
|
5623
5954
|
if (id && !options?.upsert) {
|
|
5624
5955
|
currentId = id;
|
|
5625
|
-
const idValues = parseIdValues(id, idInfoArray);
|
|
5956
|
+
const idValues = parseIdValues$1(id, idInfoArray);
|
|
5626
5957
|
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
5627
5958
|
const scalarKeys = Object.keys(entityData);
|
|
5628
5959
|
const compiledOps = fieldOpKeys.length > 0 ? compileFieldOps(table, fieldOps, { collectionPath: effectiveCollectionPath }) : void 0;
|
|
5960
|
+
const bounds = fieldOpKeys.length > 0 ? compileFieldOpBounds(table, fieldOps, collection.properties ?? {}, { collectionPath: effectiveCollectionPath }) : [];
|
|
5629
5961
|
if (scalarKeys.length > 0 || compiledOps) {
|
|
5630
5962
|
const updateQuery = tx.update(table).set({
|
|
5631
5963
|
...entityData,
|
|
@@ -5636,11 +5968,15 @@ var PersistService = class {
|
|
|
5636
5968
|
const field = table[info.fieldName];
|
|
5637
5969
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
5638
5970
|
}
|
|
5639
|
-
if (((await updateQuery.where(and(...conditions))).rowCount ?? 0) === 0)
|
|
5971
|
+
if (((await updateQuery.where(and(...conditions, ...bounds.map((bound) => bound.holds)))).rowCount ?? 0) === 0) {
|
|
5972
|
+
const broken = await brokenFieldOpBounds(tx, table, conditions, bounds);
|
|
5973
|
+
if (broken.length > 0) throw fieldOpBoundsError(collection.slug, broken);
|
|
5974
|
+
throw await this.explainZeroRowWrite(tx, table, conditions, effectiveCollectionPath, currentId, "update");
|
|
5975
|
+
}
|
|
5640
5976
|
}
|
|
5641
5977
|
} else {
|
|
5642
5978
|
const dataForInsert = { ...entityData };
|
|
5643
|
-
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
5979
|
+
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues$1(id, idInfoArray));
|
|
5644
5980
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
5645
5981
|
const insertQuery = tx.insert(table).values(dataForInsert);
|
|
5646
5982
|
const targetFields = options?.onConflict && options.onConflict.length > 0 ? [...options.onConflict] : idInfoArray.map((info) => info.fieldName);
|
|
@@ -5655,11 +5991,24 @@ var PersistService = class {
|
|
|
5655
5991
|
const set = { ...dataForInsert };
|
|
5656
5992
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
5657
5993
|
for (const field of targetFields) delete set[field];
|
|
5658
|
-
for (const
|
|
5659
|
-
|
|
5994
|
+
for (const key of createStampKeys(collection.properties)) delete set[key];
|
|
5995
|
+
for (const key of options.insertOnlyKeys ?? []) delete set[key];
|
|
5996
|
+
const scope = await this.writeScope(collection, effectiveCollectionPath, table);
|
|
5997
|
+
const updating = Object.keys(set).length > 0;
|
|
5998
|
+
result = updating ? await insertQuery.onConflictDoUpdate({
|
|
5660
5999
|
target,
|
|
5661
|
-
set
|
|
6000
|
+
set,
|
|
6001
|
+
setWhere: scope
|
|
5662
6002
|
}).returning(returningKeys) : await insertQuery.onConflictDoNothing({ target }).returning(returningKeys);
|
|
6003
|
+
if (result.length === 0) {
|
|
6004
|
+
const keyConditions = target.map((column, i) => eq(column, dataForInsert[targetFields[i]]));
|
|
6005
|
+
if (scope) keyConditions.push(scope);
|
|
6006
|
+
const keyLabel = targetFields.map((field) => String(dataForInsert[field])).join(", ");
|
|
6007
|
+
const missing = `No row "${keyLabel}" in "${effectiveCollectionPath}" to update.`;
|
|
6008
|
+
if (updating) throw await explainZeroRowWrite(tx, table, keyConditions, `Not allowed to update "${keyLabel}" in "${effectiveCollectionPath}": a row-level security policy rejected the write.`, missing);
|
|
6009
|
+
result = await tx.select(returningKeys).from(table).where(and(...keyConditions)).limit(1);
|
|
6010
|
+
if (result.length === 0) throw ApiError.notFound(missing);
|
|
6011
|
+
}
|
|
5663
6012
|
} else result = await insertQuery.returning(returningKeys);
|
|
5664
6013
|
const resultRow = result[0];
|
|
5665
6014
|
if (!resultRow) if (id) currentId = id;
|
|
@@ -5680,6 +6029,26 @@ var PersistService = class {
|
|
|
5680
6029
|
return finalEntity;
|
|
5681
6030
|
}
|
|
5682
6031
|
/**
|
|
6032
|
+
* The caller's `beforeQuery` scope over this table, as one condition: what
|
|
6033
|
+
* a stored row must satisfy for a write that meets it to change it. The
|
|
6034
|
+
* same scope the update gate and the single get apply; `undefined` when the
|
|
6035
|
+
* collection declares no hook.
|
|
6036
|
+
*/
|
|
6037
|
+
writeScope(collection, collectionPath, table) {
|
|
6038
|
+
const [key] = getPrimaryKeys(collection, this.registry);
|
|
6039
|
+
return beforeQueryCondition({
|
|
6040
|
+
registry: this.registry,
|
|
6041
|
+
callContext: this.callContext
|
|
6042
|
+
}, collection, collectionPath, table, {
|
|
6043
|
+
operation: "get",
|
|
6044
|
+
query: {}
|
|
6045
|
+
}, {
|
|
6046
|
+
collection,
|
|
6047
|
+
registry: this.registry,
|
|
6048
|
+
sourceIdColumn: key ? table[key.fieldName] : void 0
|
|
6049
|
+
});
|
|
6050
|
+
}
|
|
6051
|
+
/**
|
|
5683
6052
|
* Get the RelationService instance for external use
|
|
5684
6053
|
*/
|
|
5685
6054
|
getRelationService() {
|
|
@@ -5708,7 +6077,7 @@ var PersistService = class {
|
|
|
5708
6077
|
* so `author_id` had to be translated to `authorId` by whoever read the
|
|
5709
6078
|
* message — which nothing does. See `pgFieldViolations`.
|
|
5710
6079
|
*/
|
|
5711
|
-
toUserFriendlyError(error, collectionSlug, collection) {
|
|
6080
|
+
toUserFriendlyError(error, collectionSlug, collection, operation = "save") {
|
|
5712
6081
|
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
5713
6082
|
const pgError = extractPgError(error);
|
|
5714
6083
|
if (pgError) {
|
|
@@ -5718,7 +6087,7 @@ var PersistService = class {
|
|
|
5718
6087
|
collection: collectionSlug,
|
|
5719
6088
|
violations
|
|
5720
6089
|
} : void 0;
|
|
5721
|
-
if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`, details) : ApiError.badRequest(message, `PG_${code}`, details);
|
|
6090
|
+
if (/^2[23]/.test(code)) return code === "23505" || code === "23001" || code === "23503" && operation === "delete" ? ApiError.conflict(message, `PG_${code}`, details) : ApiError.badRequest(message, `PG_${code}`, details);
|
|
5722
6091
|
if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
|
|
5723
6092
|
return new Error(message, { cause: error });
|
|
5724
6093
|
}
|
|
@@ -5903,6 +6272,10 @@ var WriteTransactionScope = class {
|
|
|
5903
6272
|
open = true;
|
|
5904
6273
|
inflight = /* @__PURE__ */ new Set();
|
|
5905
6274
|
commitHooks = [];
|
|
6275
|
+
/** The first tracked statement that failed, whether or not its caller caught it. */
|
|
6276
|
+
failure;
|
|
6277
|
+
settledHooks = [];
|
|
6278
|
+
settledContext;
|
|
5906
6279
|
/** The transaction handle, for statements in this package that are already written against drizzle. */
|
|
5907
6280
|
tx;
|
|
5908
6281
|
/** Attach the transaction, once it has begun. */
|
|
@@ -5933,7 +6306,10 @@ var WriteTransactionScope = class {
|
|
|
5933
6306
|
const done = () => {
|
|
5934
6307
|
this.inflight.delete(settled);
|
|
5935
6308
|
};
|
|
5936
|
-
settled.then(done,
|
|
6309
|
+
settled.then(done, (error) => {
|
|
6310
|
+
this.failure ??= error;
|
|
6311
|
+
done();
|
|
6312
|
+
});
|
|
5937
6313
|
return settled;
|
|
5938
6314
|
}
|
|
5939
6315
|
afterCommit(fn) {
|
|
@@ -5950,6 +6326,65 @@ var WriteTransactionScope = class {
|
|
|
5950
6326
|
this.open = false;
|
|
5951
6327
|
}
|
|
5952
6328
|
/**
|
|
6329
|
+
* Refuse to commit a transaction a failed statement has aborted.
|
|
6330
|
+
*
|
|
6331
|
+
* Once any statement fails, Postgres aborts the whole transaction, and
|
|
6332
|
+
* catching the error in JavaScript does not undo that. The COMMIT that
|
|
6333
|
+
* follows is answered with the tag `ROLLBACK` and no error, so the driver
|
|
6334
|
+
* resolves it: the write reported success — 200, webhooks, realtime,
|
|
6335
|
+
* the idempotency record — while nothing was stored. A callback that
|
|
6336
|
+
* wrapped a lookup or a job enqueue in `try/catch` was enough.
|
|
6337
|
+
*
|
|
6338
|
+
* So one statement is asked of the transaction before its commit. On an
|
|
6339
|
+
* aborted one it fails with `25P02`, and the write is refused and rolled
|
|
6340
|
+
* back instead. A failed `context.data` write is not caught by this: it
|
|
6341
|
+
* runs in a savepoint, is undone on its own, and leaves the transaction
|
|
6342
|
+
* usable — which is what makes catching one safe.
|
|
6343
|
+
*/
|
|
6344
|
+
async assertCommittable() {
|
|
6345
|
+
if (!this.tx) return;
|
|
6346
|
+
try {
|
|
6347
|
+
await this.tx.execute(sql`SELECT 1`);
|
|
6348
|
+
} catch (error) {
|
|
6349
|
+
if (extractPgError(error)?.code !== "25P02") throw error;
|
|
6350
|
+
logger.error("[transaction] A statement on this write's transaction failed and its error was caught; the write was rolled back", { error: this.failure ?? error });
|
|
6351
|
+
const refusal = new ApiError(500, "TRANSACTION_ABORTED", "A statement on this write's transaction failed and its error was caught. That aborts the transaction in Postgres, so the write was rolled back and nothing was stored. A failed `context.data` write is undone on its own and may be caught; any other failed statement (a read, a job enqueue) has to be let through.");
|
|
6352
|
+
refusal.cause = this.failure ?? error;
|
|
6353
|
+
throw refusal;
|
|
6354
|
+
}
|
|
6355
|
+
}
|
|
6356
|
+
/**
|
|
6357
|
+
* What a hook deferred with {@link afterSettled} is handed as `context`:
|
|
6358
|
+
* the same caller, on a driver where every call is a transaction of its
|
|
6359
|
+
* own. The write's transaction is gone by the time the hook runs, so a
|
|
6360
|
+
* context bound to it would be bound to a connection back in the pool.
|
|
6361
|
+
*/
|
|
6362
|
+
setSettledContext(context) {
|
|
6363
|
+
this.settledContext = context;
|
|
6364
|
+
}
|
|
6365
|
+
/**
|
|
6366
|
+
* Run `fn` once this write's transaction is over, whichever way it went,
|
|
6367
|
+
* outside it — for what a failure hook hands off (a job, a queue message,
|
|
6368
|
+
* a webhook), which must not ride the transaction the failure rolls back.
|
|
6369
|
+
* `false` when this scope has no context to hand it; the caller runs it
|
|
6370
|
+
* itself then.
|
|
6371
|
+
*/
|
|
6372
|
+
afterSettled(fn) {
|
|
6373
|
+
if (!this.settledContext) return false;
|
|
6374
|
+
this.settledHooks.push(fn);
|
|
6375
|
+
return true;
|
|
6376
|
+
}
|
|
6377
|
+
/** The transaction committed or rolled back: run what was waiting for either. */
|
|
6378
|
+
async settled() {
|
|
6379
|
+
const context = this.settledContext;
|
|
6380
|
+
if (!context) return;
|
|
6381
|
+
for (const hook of this.settledHooks.splice(0)) try {
|
|
6382
|
+
await storage.run(void 0, () => hook(context));
|
|
6383
|
+
} catch (error) {
|
|
6384
|
+
logger.error("[transaction] A hook deferred to the end of the write threw", { error });
|
|
6385
|
+
}
|
|
6386
|
+
}
|
|
6387
|
+
/**
|
|
5953
6388
|
* The transaction is over, whichever way it went. Nothing may reach it now:
|
|
5954
6389
|
* its client is back in the pool, and a callback's detached promise still
|
|
5955
6390
|
* carries this scope in its async context.
|
|
@@ -6067,7 +6502,15 @@ function classifyCollectionChanges(before, after, facts) {
|
|
|
6067
6502
|
});
|
|
6068
6503
|
continue;
|
|
6069
6504
|
}
|
|
6070
|
-
|
|
6505
|
+
const was = previous.get(slug);
|
|
6506
|
+
if (qualifiedTable(was) !== qualifiedTable(collection)) changes.push({
|
|
6507
|
+
kind: "rename-table",
|
|
6508
|
+
verdict: "needs-migration",
|
|
6509
|
+
collection: slug,
|
|
6510
|
+
detail: `"${slug}" moves from table "${qualifiedTable(was)}" to "${qualifiedTable(collection)}", which would create the new table empty and leave every row in the old one.`,
|
|
6511
|
+
remedy: "The ensure path never renames or moves a table. Do it in a migration you have read (ALTER TABLE … RENAME TO, or SET SCHEMA), then change the collection to match."
|
|
6512
|
+
});
|
|
6513
|
+
classifyProperties(was, collection, changes, facts);
|
|
6071
6514
|
}
|
|
6072
6515
|
for (const [slug, collection] of previous) {
|
|
6073
6516
|
if (next.has(slug)) continue;
|
|
@@ -6376,6 +6819,13 @@ function effectiveSqlRole(requestedRole) {
|
|
|
6376
6819
|
/** How {@link effectiveSqlRole} names "whatever role the connection holds". */
|
|
6377
6820
|
var CONNECTION_OWNER = "<connection owner>";
|
|
6378
6821
|
/**
|
|
6822
|
+
* What puts a session back after caller SQL: the session user and role, then
|
|
6823
|
+
* every setting. Two statements, because the extended protocol refuses a
|
|
6824
|
+
* multi-command string. Pinned settings (`search_path` from the connection's
|
|
6825
|
+
* startup options) come back as they were pinned.
|
|
6826
|
+
*/
|
|
6827
|
+
var RESET_SESSION_STATEMENTS = ["SET SESSION AUTHORIZATION DEFAULT", "RESET ALL"];
|
|
6828
|
+
/**
|
|
6379
6829
|
* A statement named a database role the connection cannot assume.
|
|
6380
6830
|
*
|
|
6381
6831
|
* Its own type because the tempting recovery — run it anyway, as the owner — is
|
|
@@ -6395,6 +6845,66 @@ var RoleSwitchUnavailableError = class extends Error {
|
|
|
6395
6845
|
}
|
|
6396
6846
|
};
|
|
6397
6847
|
/**
|
|
6848
|
+
* Fold what a `beforeSave` returned into the values being saved.
|
|
6849
|
+
*
|
|
6850
|
+
* The hook returns the values that will be saved, so a key it returns replaces
|
|
6851
|
+
* the value outright: an array it filtered stays filtered, a map it narrowed
|
|
6852
|
+
* stays narrow. A key it leaves out keeps the value it was given, so a hook
|
|
6853
|
+
* that returns only the field it computed still saves the rest.
|
|
6854
|
+
*
|
|
6855
|
+
* Not a deep merge. `mergeDeep` merges arrays of objects element by element and
|
|
6856
|
+
* keeps every element past the end of the shorter one, so a hook that dropped
|
|
6857
|
+
* one line item of three had the last one merged back in and the order was
|
|
6858
|
+
* stored with a line duplicated.
|
|
6859
|
+
*/
|
|
6860
|
+
function applyBeforeSaveResult(values, result) {
|
|
6861
|
+
return {
|
|
6862
|
+
...values,
|
|
6863
|
+
...result
|
|
6864
|
+
};
|
|
6865
|
+
}
|
|
6866
|
+
/**
|
|
6867
|
+
* The keys of a create's final values that its INSERT alone may write: those
|
|
6868
|
+
* the caller did not send, no `beforeSave` hook wrote, and that are not an
|
|
6869
|
+
* `on_update` stamp — a declared default, a tenant stamp, a create-time stamp.
|
|
6870
|
+
* See `PersistSaveOptions.insertOnlyKeys`.
|
|
6871
|
+
*/
|
|
6872
|
+
function insertOnlyKeysOf(finalValues, callerValues, beforeHooks, afterHooks, properties) {
|
|
6873
|
+
const sent = new Set(Object.keys(callerValues ?? {}));
|
|
6874
|
+
const updateStamps = new Set(Object.entries(properties ?? {}).filter(([, property]) => property?.type === "date" && property.autoValue === "on_update" || property?.type === "string" && property.autoValue === "user_on_update").map(([key]) => key));
|
|
6875
|
+
return Object.keys(finalValues).filter((key) => !sent.has(key) && !updateStamps.has(key) && beforeHooks[key] === afterHooks[key]);
|
|
6876
|
+
}
|
|
6877
|
+
/**
|
|
6878
|
+
* Refuse an update that stamps a `softDelete` collection's field.
|
|
6879
|
+
*
|
|
6880
|
+
* Stamping it IS the delete — every read hides the row from then on — so it is
|
|
6881
|
+
* `delete`'s to do, where the permission is `delete`, `beforeDelete` can veto,
|
|
6882
|
+
* `afterDelete` runs and history says "delete". Written by an update, a caller
|
|
6883
|
+
* holding only write access trashed rows, past all of that. Clearing it stays
|
|
6884
|
+
* an update: that is how a row is restored, and what a form sends back for a
|
|
6885
|
+
* live row.
|
|
6886
|
+
*/
|
|
6887
|
+
function assertUpdateDoesNotSoftDelete(collection, values, path) {
|
|
6888
|
+
const softDelete = resolveSoftDelete(collection);
|
|
6889
|
+
if (!softDelete) return;
|
|
6890
|
+
const value = values?.[softDelete.field];
|
|
6891
|
+
if (value === void 0 || value === null) return;
|
|
6892
|
+
const slug = collection?.slug ?? path;
|
|
6893
|
+
const message = `'${softDelete.field}' on '${slug}' records a delete, so an update may only clear it (null restores the row). Delete the row to set it.`;
|
|
6894
|
+
throw ApiError.badRequest(message, "FIELD_NOT_WRITABLE", {
|
|
6895
|
+
collection: slug,
|
|
6896
|
+
violations: [{
|
|
6897
|
+
field: softDelete.field,
|
|
6898
|
+
code: "soft_delete",
|
|
6899
|
+
message
|
|
6900
|
+
}]
|
|
6901
|
+
});
|
|
6902
|
+
}
|
|
6903
|
+
/** A key that names a row: not absent, and not the empty string a form sends. */
|
|
6904
|
+
function isPresentKey(id) {
|
|
6905
|
+
return id !== void 0 && id !== null && id !== "";
|
|
6906
|
+
}
|
|
6907
|
+
/**
|
|
6398
6908
|
* Refuse a write that would leave a *required* `created_by` / `updated_by`
|
|
6399
6909
|
* column null because nobody is acting.
|
|
6400
6910
|
*
|
|
@@ -6731,17 +7241,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6731
7241
|
return out;
|
|
6732
7242
|
}));
|
|
6733
7243
|
}
|
|
6734
|
-
async fetchCollection({ path, collection,
|
|
7244
|
+
async fetchCollection({ path, collection, startAfter, ...query }) {
|
|
6735
7245
|
const rows = await this.dataService.fetchCollection(path, {
|
|
6736
|
-
|
|
6737
|
-
orderBy,
|
|
6738
|
-
order,
|
|
6739
|
-
limit,
|
|
6740
|
-
offset,
|
|
7246
|
+
...query,
|
|
6741
7247
|
startAfter,
|
|
6742
|
-
databaseId: collection?.databaseId
|
|
6743
|
-
searchString,
|
|
6744
|
-
vectorSearch
|
|
7248
|
+
databaseId: collection?.databaseId
|
|
6745
7249
|
});
|
|
6746
7250
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6747
7251
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
@@ -6925,16 +7429,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6925
7429
|
const { collection: slug, userField, tenantField } = tenant.from.membership;
|
|
6926
7430
|
const cap = PostgresBackendDriver.TENANT_MEMBERSHIP_CAP;
|
|
6927
7431
|
try {
|
|
6928
|
-
const rows = await this.dataService.fetchCollection(slug, {
|
|
7432
|
+
const rows = await withFieldViewer(void 0, () => this.dataService.fetchCollection(slug, {
|
|
6929
7433
|
filter: { [userField]: ["==", uid] },
|
|
6930
7434
|
limit: cap + 1,
|
|
6931
7435
|
databaseId: collection?.databaseId
|
|
6932
|
-
});
|
|
7436
|
+
}));
|
|
6933
7437
|
return {
|
|
6934
7438
|
tenants: Array.from(new Set(rows.slice(0, cap).map((row) => row[tenantField]).filter((value) => value !== null && value !== void 0).map((value) => typeof value === "object" ? value.id ?? value : value))),
|
|
6935
7439
|
complete: rows.length <= cap
|
|
6936
7440
|
};
|
|
6937
7441
|
} catch (err) {
|
|
7442
|
+
if (reachedDatabase(err)) throw err;
|
|
6938
7443
|
logger.debug(`[save] Could not read '${slug}' to resolve the caller's tenants`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6939
7444
|
return {
|
|
6940
7445
|
tenants: [],
|
|
@@ -6942,21 +7447,86 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6942
7447
|
};
|
|
6943
7448
|
}
|
|
6944
7449
|
}
|
|
7450
|
+
/**
|
|
7451
|
+
* The stored row an upsert's key names, if this caller can address it —
|
|
7452
|
+
* with its address and the values to update it with.
|
|
7453
|
+
*
|
|
7454
|
+
* Read as the caller reads: their policies and `beforeQuery` scope, and
|
|
7455
|
+
* soft-deleted rows hidden, so a row found here is one an update may
|
|
7456
|
+
* target. On the trusted plane for its fields only, like
|
|
7457
|
+
* {@link resolveCallerTenants}: the key columns are the server's to see.
|
|
7458
|
+
*
|
|
7459
|
+
* `undefined` when the key is incomplete (the write is a plain insert,
|
|
7460
|
+
* which is the persistence layer's rule too), when a key value is not a
|
|
7461
|
+
* plain value, or when no such row is visible. None of those can write a
|
|
7462
|
+
* stored row wrongly: the statement's own conflict branch sets only what
|
|
7463
|
+
* the caller sent, and only on a row in their scope.
|
|
7464
|
+
*
|
|
7465
|
+
* The values drop the key and the conflict target, which the stored row
|
|
7466
|
+
* already has: a natural-key upsert carrying a key the caller invented
|
|
7467
|
+
* must not move the row onto it.
|
|
7468
|
+
*/
|
|
7469
|
+
async findUpsertTarget(path, collection, values, id, onConflict) {
|
|
7470
|
+
const primaryKeys = getPrimaryKeys(collection, this.registry);
|
|
7471
|
+
const key = { ...values };
|
|
7472
|
+
if (isPresentKey(id)) Object.assign(key, parseIdValues$1(id, primaryKeys));
|
|
7473
|
+
const targetFields = onConflict && onConflict.length > 0 ? [...onConflict] : primaryKeys.map((info) => info.fieldName);
|
|
7474
|
+
if (targetFields.length === 0) return void 0;
|
|
7475
|
+
const filter = {};
|
|
7476
|
+
for (const field of targetFields) {
|
|
7477
|
+
const value = key[field];
|
|
7478
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
7479
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return void 0;
|
|
7480
|
+
filter[field] = ["==", value];
|
|
7481
|
+
}
|
|
7482
|
+
const [row] = await withFieldViewer(void 0, () => this.dataService.fetchCollectionForRest(path, {
|
|
7483
|
+
filter,
|
|
7484
|
+
limit: 1,
|
|
7485
|
+
databaseId: collection.databaseId
|
|
7486
|
+
}));
|
|
7487
|
+
if (!row || targetFields.some((field) => String(row[field]) !== String(key[field]))) return void 0;
|
|
7488
|
+
const rest = { ...values };
|
|
7489
|
+
for (const field of [...primaryKeys.map((info) => info.fieldName), ...targetFields]) delete rest[field];
|
|
7490
|
+
return {
|
|
7491
|
+
id: deriveRowAddress(row, collection, this.registry),
|
|
7492
|
+
values: rest
|
|
7493
|
+
};
|
|
7494
|
+
}
|
|
6945
7495
|
async save({ path, id, values, collection, status, upsert, onConflict }) {
|
|
6946
7496
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
7497
|
+
if (status === "existing") assertUpdateDoesNotSoftDelete(resolvedCollection, values, path);
|
|
7498
|
+
const upserting = upsert === true && status !== "existing";
|
|
7499
|
+
if (upserting && resolvedCollection) {
|
|
7500
|
+
const stored = await this.findUpsertTarget(path, resolvedCollection, values, id, onConflict);
|
|
7501
|
+
if (stored) return this.save({
|
|
7502
|
+
path,
|
|
7503
|
+
id: stored.id,
|
|
7504
|
+
values: stored.values,
|
|
7505
|
+
collection,
|
|
7506
|
+
status: "existing"
|
|
7507
|
+
});
|
|
7508
|
+
}
|
|
6947
7509
|
let updatedValues = values;
|
|
6948
7510
|
const contextForCallback = this.buildCallContext();
|
|
6949
7511
|
if ((status === "new" || status === "copy") && resolvedCollection?.properties) updatedValues = applyDefaultValuesOnCreate(updatedValues, resolvedCollection.properties);
|
|
6950
7512
|
let previousValuesForHistory;
|
|
6951
|
-
if (status === "existing" && id)
|
|
6952
|
-
const
|
|
6953
|
-
|
|
6954
|
-
const
|
|
6955
|
-
|
|
7513
|
+
if (status === "existing" && id) {
|
|
7514
|
+
const gateOnScope = hasBeforeQuery(this.registry, resolvedCollection);
|
|
7515
|
+
try {
|
|
7516
|
+
const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
|
|
7517
|
+
if (!existing && gateOnScope) throw ApiError.notFound(`No row "${id}" in "${path}" to update.`);
|
|
7518
|
+
if (existing) {
|
|
7519
|
+
const { id: _existingId, ...existingValues } = existing;
|
|
7520
|
+
previousValuesForHistory = existingValues;
|
|
7521
|
+
}
|
|
7522
|
+
} catch (err) {
|
|
7523
|
+
if (err instanceof ApiError) throw err;
|
|
7524
|
+
if (reachedDatabase(err)) throw err;
|
|
7525
|
+
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6956
7526
|
}
|
|
6957
|
-
} catch (err) {
|
|
6958
|
-
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6959
7527
|
}
|
|
7528
|
+
/** The row as the hooks receive it, to tell what they wrote. */
|
|
7529
|
+
const beforeHooks = updatedValues;
|
|
6960
7530
|
try {
|
|
6961
7531
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
6962
7532
|
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
@@ -6970,7 +7540,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6970
7540
|
status,
|
|
6971
7541
|
context: contextForCallback
|
|
6972
7542
|
});
|
|
6973
|
-
if (result) updatedValues =
|
|
7543
|
+
if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
|
|
6974
7544
|
}
|
|
6975
7545
|
if (callbacks?.beforeSave) {
|
|
6976
7546
|
const result = await callbacks.beforeSave({
|
|
@@ -6982,7 +7552,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6982
7552
|
status,
|
|
6983
7553
|
context: contextForCallback
|
|
6984
7554
|
});
|
|
6985
|
-
if (result) updatedValues =
|
|
7555
|
+
if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
|
|
6986
7556
|
}
|
|
6987
7557
|
if (propertyCallbacks?.beforeSave) {
|
|
6988
7558
|
const result = await propertyCallbacks.beforeSave({
|
|
@@ -6994,12 +7564,13 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6994
7564
|
status,
|
|
6995
7565
|
context: contextForCallback
|
|
6996
7566
|
});
|
|
6997
|
-
if (result) updatedValues =
|
|
7567
|
+
if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
|
|
6998
7568
|
}
|
|
6999
7569
|
}
|
|
7000
7570
|
} catch (callbackError) {
|
|
7001
7571
|
throw toCallbackError(callbackError, "beforeSave", path);
|
|
7002
7572
|
}
|
|
7573
|
+
const afterHooks = updatedValues;
|
|
7003
7574
|
if (resolvedCollection?.properties) {
|
|
7004
7575
|
updatedValues = updateDateAutoValues({
|
|
7005
7576
|
inputValues: updatedValues,
|
|
@@ -7014,12 +7585,29 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7014
7585
|
status: status ?? "new",
|
|
7015
7586
|
uid: this.user?.uid
|
|
7016
7587
|
});
|
|
7588
|
+
if (status === "existing") {
|
|
7589
|
+
const kept = { ...updatedValues };
|
|
7590
|
+
for (const key of createStampKeys(resolvedCollection.properties)) delete kept[key];
|
|
7591
|
+
updatedValues = kept;
|
|
7592
|
+
}
|
|
7017
7593
|
}
|
|
7018
7594
|
updatedValues = await this.applyTenantScope(resolvedCollection, path, updatedValues, status ?? "new", previousValuesForHistory);
|
|
7595
|
+
let writeId = id;
|
|
7596
|
+
if ((status === "new" || status === "copy") && !upsert && isPresentKey(id) && resolvedCollection) {
|
|
7597
|
+
updatedValues = {
|
|
7598
|
+
...updatedValues,
|
|
7599
|
+
...parseIdValues$1(id, getPrimaryKeys(resolvedCollection, this.registry))
|
|
7600
|
+
};
|
|
7601
|
+
writeId = void 0;
|
|
7602
|
+
}
|
|
7603
|
+
const insertOnlyKeys = upserting ? insertOnlyKeysOf(updatedValues, values, beforeHooks, afterHooks, resolvedCollection?.properties) : void 0;
|
|
7604
|
+
const softDeleteField = resolveSoftDelete(resolvedCollection)?.field;
|
|
7605
|
+
if (insertOnlyKeys && softDeleteField && updatedValues[softDeleteField] !== void 0 && updatedValues[softDeleteField] !== null) insertOnlyKeys.push(softDeleteField);
|
|
7019
7606
|
try {
|
|
7020
|
-
let savedRow = await this.dataService.save(path, updatedValues,
|
|
7021
|
-
upsert,
|
|
7022
|
-
onConflict
|
|
7607
|
+
let savedRow = await this.dataService.save(path, updatedValues, writeId, resolvedCollection?.databaseId, {
|
|
7608
|
+
upsert: upserting,
|
|
7609
|
+
onConflict,
|
|
7610
|
+
insertOnlyKeys
|
|
7023
7611
|
});
|
|
7024
7612
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
7025
7613
|
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
@@ -7079,9 +7667,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7079
7667
|
throw toCallbackError(callbackError, "afterSave", path);
|
|
7080
7668
|
}
|
|
7081
7669
|
if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
|
|
7082
|
-
tableName:
|
|
7670
|
+
tableName: resolvedCollection.slug,
|
|
7083
7671
|
id: savedId,
|
|
7084
|
-
action: status === "
|
|
7672
|
+
action: status === "existing" ? "update" : "create",
|
|
7085
7673
|
values: savedValues,
|
|
7086
7674
|
previousValues: previousValuesForHistory,
|
|
7087
7675
|
updatedBy: this.user?.uid
|
|
@@ -7097,27 +7685,31 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7097
7685
|
} catch (error) {
|
|
7098
7686
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
7099
7687
|
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
7100
|
-
const
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7688
|
+
const runHooks = async (context) => {
|
|
7689
|
+
const errorProps = {
|
|
7690
|
+
collection: callbackCollection,
|
|
7691
|
+
path,
|
|
7692
|
+
id,
|
|
7693
|
+
values: updatedValues,
|
|
7694
|
+
previousValues: previousValuesForHistory,
|
|
7695
|
+
status,
|
|
7696
|
+
error,
|
|
7697
|
+
context
|
|
7698
|
+
};
|
|
7699
|
+
for (const [tier, hook] of [
|
|
7700
|
+
["global", globalCallbacks?.afterSaveError],
|
|
7701
|
+
["collection", callbacks?.afterSaveError],
|
|
7702
|
+
["property", propertyCallbacks?.afterSaveError]
|
|
7703
|
+
]) {
|
|
7704
|
+
if (!hook) continue;
|
|
7705
|
+
try {
|
|
7706
|
+
await hook(errorProps);
|
|
7707
|
+
} catch (hookError) {
|
|
7708
|
+
logger.error(`[save] The ${tier} afterSaveError hook for "${path}" threw; answering with the save's own failure`, { error: hookError });
|
|
7709
|
+
}
|
|
7710
|
+
}
|
|
7108
7711
|
};
|
|
7109
|
-
if (
|
|
7110
|
-
collection: callbackCollection,
|
|
7111
|
-
...errorProps
|
|
7112
|
-
});
|
|
7113
|
-
if (callbacks?.afterSaveError) await callbacks.afterSaveError({
|
|
7114
|
-
collection: callbackCollection,
|
|
7115
|
-
...errorProps
|
|
7116
|
-
});
|
|
7117
|
-
if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
|
|
7118
|
-
collection: callbackCollection,
|
|
7119
|
-
...errorProps
|
|
7120
|
-
});
|
|
7712
|
+
if (!currentWriteScope()?.afterSettled(runHooks)) await runHooks(contextForCallback);
|
|
7121
7713
|
}
|
|
7122
7714
|
throw error;
|
|
7123
7715
|
}
|
|
@@ -7215,7 +7807,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7215
7807
|
if (!await txDriver.fetchOne({
|
|
7216
7808
|
path,
|
|
7217
7809
|
id: String(id),
|
|
7218
|
-
collection
|
|
7810
|
+
collection,
|
|
7811
|
+
withDeleted: restoresSoftDeletedRow(collection ?? this.registry?.getCollectionByPath(path), values) ? true : void 0
|
|
7219
7812
|
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
7220
7813
|
statusCode: 404,
|
|
7221
7814
|
code: "NOT_FOUND"
|
|
@@ -7325,7 +7918,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7325
7918
|
if (!await txDriver.fetchOne({
|
|
7326
7919
|
path: operation.path,
|
|
7327
7920
|
id,
|
|
7328
|
-
collection: operation.collection
|
|
7921
|
+
collection: operation.collection,
|
|
7922
|
+
withDeleted: restoresSoftDeletedRow(operation.collection ?? this.registry?.getCollectionByPath(operation.path), values) ? true : void 0
|
|
7329
7923
|
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
7330
7924
|
statusCode: 404,
|
|
7331
7925
|
code: "NOT_FOUND"
|
|
@@ -7410,7 +8004,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7410
8004
|
} catch (callbackError) {
|
|
7411
8005
|
throw toCallbackError(callbackError, "beforeDelete", targetPath);
|
|
7412
8006
|
}
|
|
7413
|
-
const
|
|
8007
|
+
const hop = isNestedPath(targetPath) ? resolveNestedPath(targetPath, this.registry) : void 0;
|
|
8008
|
+
const unlinking = hop !== void 0 && isJunctionBackedRelation(hop.relation);
|
|
8009
|
+
const softDelete = hard || unlinking ? void 0 : resolveSoftDelete(resolvedCollection);
|
|
7414
8010
|
if (softDelete) await this.dataService.save(targetPath, { [softDelete.field]: /* @__PURE__ */ new Date() }, row.id, resolvedCollection?.databaseId);
|
|
7415
8011
|
else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
|
|
7416
8012
|
try {
|
|
@@ -7441,8 +8037,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7441
8037
|
} catch (callbackError) {
|
|
7442
8038
|
throw toCallbackError(callbackError, "afterDelete", targetPath);
|
|
7443
8039
|
}
|
|
7444
|
-
if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
|
|
7445
|
-
tableName:
|
|
8040
|
+
if (this.historyService && resolvedCollection?.history && !unlinking) await this.historyService.recordHistory({
|
|
8041
|
+
tableName: resolvedCollection.slug,
|
|
7446
8042
|
id: row.id.toString(),
|
|
7447
8043
|
action: "delete",
|
|
7448
8044
|
values: stored,
|
|
@@ -7497,8 +8093,48 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7497
8093
|
return sql.join(chunks, sql.raw(""));
|
|
7498
8094
|
}
|
|
7499
8095
|
async executeSql(sqlText, options) {
|
|
8096
|
+
if (options?.isolateSession) return this.onIsolatedSession(this.getTargetDb(options.database), (db) => this.executeSqlOn(db, sqlText, options));
|
|
7500
8097
|
if (!options?.database && !options?.role) return this.dataService.executeSql(sqlText, options?.params);
|
|
7501
|
-
|
|
8098
|
+
return this.executeSqlOn(this.getTargetDb(options?.database), sqlText, options);
|
|
8099
|
+
}
|
|
8100
|
+
/**
|
|
8101
|
+
* Run `fn` on a session of its own, and put that session back the way it
|
|
8102
|
+
* was handed out before anything else can use it.
|
|
8103
|
+
*
|
|
8104
|
+
* For SQL a person wrote — the Studio editor. It may `SET ROLE`,
|
|
8105
|
+
* `SET SESSION AUTHORIZATION` or `set_config(…, false)`, which outlive the
|
|
8106
|
+
* statement (and a transaction's commit) and stay on the connection. On a
|
|
8107
|
+
* pooled one they were inherited by whatever checked it out next: an auth
|
|
8108
|
+
* lookup, the job store or history running as `rebase_user`, on one
|
|
8109
|
+
* connection in N, until a restart. `SET SESSION AUTHORIZATION DEFAULT`
|
|
8110
|
+
* clears the role as well, which `RESET ALL` does not.
|
|
8111
|
+
*
|
|
8112
|
+
* A connection that cannot be reset — the statement left a transaction
|
|
8113
|
+
* open or aborted — is destroyed rather than returned. A handle that is
|
|
8114
|
+
* already one session (PGlite in process, a single client) is reset in
|
|
8115
|
+
* place.
|
|
8116
|
+
*/
|
|
8117
|
+
async onIsolatedSession(targetDb, fn) {
|
|
8118
|
+
const pool = "$client" in targetDb ? targetDb.$client : void 0;
|
|
8119
|
+
if (!(pool instanceof Pool)) try {
|
|
8120
|
+
return await fn(targetDb);
|
|
8121
|
+
} finally {
|
|
8122
|
+
for (const statement of RESET_SESSION_STATEMENTS) await targetDb.execute(sql.raw(statement)).catch((error) => logger.error("[PostgresBackendDriver] Could not reset the session after caller SQL", { error }));
|
|
8123
|
+
}
|
|
8124
|
+
const connection = await pool.connect();
|
|
8125
|
+
let reusable = true;
|
|
8126
|
+
try {
|
|
8127
|
+
return await fn(drizzle(connection));
|
|
8128
|
+
} finally {
|
|
8129
|
+
try {
|
|
8130
|
+
for (const statement of RESET_SESSION_STATEMENTS) await connection.query(statement);
|
|
8131
|
+
} catch {
|
|
8132
|
+
reusable = false;
|
|
8133
|
+
}
|
|
8134
|
+
connection.release(reusable ? void 0 : true);
|
|
8135
|
+
}
|
|
8136
|
+
}
|
|
8137
|
+
async executeSqlOn(targetDb, sqlText, options) {
|
|
7502
8138
|
try {
|
|
7503
8139
|
let needsRoleSwitch = false;
|
|
7504
8140
|
if (options?.role) try {
|
|
@@ -7678,14 +8314,15 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7678
8314
|
AND ccu2.table_name != ${safeName};
|
|
7679
8315
|
`)).rows,
|
|
7680
8316
|
policies: (await this.db.execute(sql`
|
|
7681
|
-
SELECT
|
|
7682
|
-
|
|
7683
|
-
|
|
7684
|
-
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
8317
|
+
SELECT
|
|
8318
|
+
policyname AS policy_name,
|
|
8319
|
+
permissive,
|
|
8320
|
+
cmd,
|
|
8321
|
+
roles::text[] AS roles,
|
|
8322
|
+
qual,
|
|
8323
|
+
with_check
|
|
8324
|
+
FROM pg_policies
|
|
8325
|
+
WHERE schemaname = 'public' AND tablename = ${safeName};
|
|
7689
8326
|
`)).rows
|
|
7690
8327
|
};
|
|
7691
8328
|
}
|
|
@@ -7740,6 +8377,16 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7740
8377
|
async withTransaction(operation, options) {
|
|
7741
8378
|
const pendingNotifications = [];
|
|
7742
8379
|
const writeScope = options?.accessMode === "read only" ? void 0 : new WriteTransactionScope();
|
|
8380
|
+
const client = this.delegate.client;
|
|
8381
|
+
writeScope?.setSettledContext({
|
|
8382
|
+
user: this.user,
|
|
8383
|
+
driver: this,
|
|
8384
|
+
data: this.data,
|
|
8385
|
+
get client() {
|
|
8386
|
+
return requireCallbackClient(client);
|
|
8387
|
+
},
|
|
8388
|
+
storageSource: client?.storage
|
|
8389
|
+
});
|
|
7743
8390
|
const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await runInWriteScope(writeScope, () => this.delegate.db.transaction(async (tx) => {
|
|
7744
8391
|
if (!this.user?.uid) logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
7745
8392
|
if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
@@ -7760,9 +8407,14 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7760
8407
|
throw error;
|
|
7761
8408
|
}
|
|
7762
8409
|
await writeScope?.settle();
|
|
8410
|
+
await writeScope?.assertCommittable();
|
|
7763
8411
|
return out;
|
|
7764
|
-
}, options)).finally(() => writeScope?.close()))
|
|
8412
|
+
}, options)).finally(() => writeScope?.close())).catch(async (error) => {
|
|
8413
|
+
await writeScope?.settled();
|
|
8414
|
+
throw error;
|
|
8415
|
+
});
|
|
7765
8416
|
writeScope?.committed();
|
|
8417
|
+
await writeScope?.settled();
|
|
7766
8418
|
for (const notification of pendingNotifications) try {
|
|
7767
8419
|
await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
|
|
7768
8420
|
} catch (e) {
|
|
@@ -8131,6 +8783,41 @@ function buildJunctionLinkMap(registry) {
|
|
|
8131
8783
|
return map;
|
|
8132
8784
|
}
|
|
8133
8785
|
//#endregion
|
|
8786
|
+
//#region src/services/read-field-access.ts
|
|
8787
|
+
/**
|
|
8788
|
+
* Refuse a read that names a field its caller may not read, for the doors that
|
|
8789
|
+
* take a driver-shaped request rather than a query string: the socket's
|
|
8790
|
+
* `FETCH_COLLECTION`, `COUNT` and `CHECK_UNIQUE_FIELD`, and `subscribe_collection`.
|
|
8791
|
+
* Callers hand it the request itself, not a re-listed copy of some of its keys.
|
|
8792
|
+
*
|
|
8793
|
+
* The row strip keeps a withheld value off the wire; this is the other half, and
|
|
8794
|
+
* without it the value is still readable one predicate at a time — a `COUNT`
|
|
8795
|
+
* filtered on `passwordHash like '$2b$10$A%'` answers 1 or 0. `GET /api/data`
|
|
8796
|
+
* refuses the same request with `FIELD_NOT_READABLE`, and this is that rule, not
|
|
8797
|
+
* a copy of it: only the request's shape is translated here.
|
|
8798
|
+
*
|
|
8799
|
+
* The shape arrives as whatever JSON the client sent, so it is read defensively.
|
|
8800
|
+
* A malformed `orderBy` or `fields` is not this check's to reject; the entries
|
|
8801
|
+
* that name a field are checked and the rest are left for the driver to refuse.
|
|
8802
|
+
*/
|
|
8803
|
+
function assertReadRequestReadable(request, collection, viewer) {
|
|
8804
|
+
const vectorProperty = request.vectorSearch && typeof request.vectorSearch === "object" ? request.vectorSearch.property : void 0;
|
|
8805
|
+
assertQueryFieldsReadable({
|
|
8806
|
+
where: request.filter && typeof request.filter === "object" ? request.filter : void 0,
|
|
8807
|
+
logical: request.logical,
|
|
8808
|
+
orderBy: sortedFields(request.orderBy),
|
|
8809
|
+
fields: Array.isArray(request.fields) ? request.fields : void 0,
|
|
8810
|
+
include: request.include ?? void 0,
|
|
8811
|
+
vectorSearch: typeof vectorProperty === "string" ? { property: vectorProperty } : void 0
|
|
8812
|
+
}, collection, viewer);
|
|
8813
|
+
}
|
|
8814
|
+
/** The fields an `orderBy` sorts on, in either of its two spellings. */
|
|
8815
|
+
function sortedFields(orderBy) {
|
|
8816
|
+
if (typeof orderBy === "string") return [{ field: orderBy }];
|
|
8817
|
+
if (!Array.isArray(orderBy)) return void 0;
|
|
8818
|
+
return orderBy.flatMap((entry) => Array.isArray(entry) && typeof entry[0] === "string" ? [{ field: entry[0] }] : []);
|
|
8819
|
+
}
|
|
8820
|
+
//#endregion
|
|
8134
8821
|
//#region src/services/cdc/trigger-cdc.ts
|
|
8135
8822
|
/**
|
|
8136
8823
|
* Trigger-based Change Data Capture (CDC).
|
|
@@ -8163,6 +8850,11 @@ var CDC_TRIGGER_NAME = "rebase_cdc_trigger";
|
|
|
8163
8850
|
* statement* if the limit is exceeded. We stay comfortably under it and, for
|
|
8164
8851
|
* wide rows, fall back to an identity-only payload so CDC can never break a
|
|
8165
8852
|
* write. 7900 leaves headroom for the JSON envelope keys.
|
|
8853
|
+
*
|
|
8854
|
+
* The identity is every primary-key column, read from the catalogue for the
|
|
8855
|
+
* table that fired — `id` only where the table declares no primary key. It
|
|
8856
|
+
* used to be `id` alone, so a wide row of a table keyed on anything else
|
|
8857
|
+
* arrived with no key at all and reached no single-row subscriber.
|
|
8166
8858
|
*/
|
|
8167
8859
|
var MAX_NOTIFY_BYTES = 7900;
|
|
8168
8860
|
var quoteIdent = (name) => `"${name.replace(/"/g, "\"\"")}"`;
|
|
@@ -8186,6 +8878,7 @@ LANGUAGE plpgsql AS $rebase_cdc$
|
|
|
8186
8878
|
DECLARE
|
|
8187
8879
|
rec jsonb;
|
|
8188
8880
|
payload text;
|
|
8881
|
+
ident jsonb;
|
|
8189
8882
|
BEGIN
|
|
8190
8883
|
IF (TG_OP = 'DELETE') THEN
|
|
8191
8884
|
rec := to_jsonb(OLD);
|
|
@@ -8204,11 +8897,18 @@ BEGIN
|
|
|
8204
8897
|
-- 8000-byte cap, emit an identity-only payload the consumer can still route
|
|
8205
8898
|
-- (and refetch the authoritative row from).
|
|
8206
8899
|
IF (octet_length(payload) > ${MAX_NOTIFY_BYTES}) THEN
|
|
8900
|
+
SELECT jsonb_object_agg(a.attname, rec->a.attname) INTO ident
|
|
8901
|
+
FROM pg_index i
|
|
8902
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY (i.indkey)
|
|
8903
|
+
WHERE i.indrelid = TG_RELID AND i.indisprimary;
|
|
8904
|
+
IF ident IS NULL THEN
|
|
8905
|
+
ident := CASE WHEN rec ? 'id' THEN jsonb_build_object('id', rec->'id') ELSE '{}'::jsonb END;
|
|
8906
|
+
END IF;
|
|
8207
8907
|
payload := json_build_object(
|
|
8208
8908
|
'schema', TG_TABLE_SCHEMA,
|
|
8209
8909
|
'table', TG_TABLE_NAME,
|
|
8210
8910
|
'op', TG_OP,
|
|
8211
|
-
'row',
|
|
8911
|
+
'row', ident,
|
|
8212
8912
|
'truncated', true
|
|
8213
8913
|
)::text;
|
|
8214
8914
|
END IF;
|
|
@@ -8357,6 +9057,15 @@ var PgNotifyListener = class {
|
|
|
8357
9057
|
if (initial) throw err;
|
|
8358
9058
|
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
8359
9059
|
this.scheduleReconnect();
|
|
9060
|
+
return;
|
|
9061
|
+
}
|
|
9062
|
+
if (!initial && this.options.onReconnect) {
|
|
9063
|
+
logger.warn(`⚠️ ${logLabel} LISTEN client reconnected; anything published while it was down was missed.`);
|
|
9064
|
+
try {
|
|
9065
|
+
this.options.onReconnect();
|
|
9066
|
+
} catch (err) {
|
|
9067
|
+
logger.error(`❌ ${logLabel} Error resyncing after a reconnect`, { error: err });
|
|
9068
|
+
}
|
|
8360
9069
|
}
|
|
8361
9070
|
}
|
|
8362
9071
|
scheduleReconnect() {
|
|
@@ -8413,11 +9122,17 @@ function parseCdcPayload(payload) {
|
|
|
8413
9122
|
*/
|
|
8414
9123
|
var CdcListener = class {
|
|
8415
9124
|
listener;
|
|
8416
|
-
|
|
9125
|
+
/**
|
|
9126
|
+
* @param onReconnect Called when the connection is listening again after a
|
|
9127
|
+
* drop. Every change committed in the gap was notified to nobody, so
|
|
9128
|
+
* this is where subscribers are told to look again.
|
|
9129
|
+
*/
|
|
9130
|
+
constructor(connectionString, onEvent, onReconnect) {
|
|
8417
9131
|
this.listener = new PgNotifyListener({
|
|
8418
9132
|
connectionString,
|
|
8419
9133
|
channel: CDC_CHANNEL,
|
|
8420
9134
|
logLabel: "[CDC]",
|
|
9135
|
+
onReconnect,
|
|
8421
9136
|
onPayload: (payload) => {
|
|
8422
9137
|
const event = parseCdcPayload(payload);
|
|
8423
9138
|
if (!event) {
|
|
@@ -9255,6 +9970,23 @@ function callbackContextWithin(driver, tx, auth) {
|
|
|
9255
9970
|
});
|
|
9256
9971
|
}
|
|
9257
9972
|
/**
|
|
9973
|
+
* How long a client gets to answer the close frame at shutdown before its
|
|
9974
|
+
* socket is dropped. `ws` would otherwise wait its own 30s close timeout, which
|
|
9975
|
+
* is longer than the whole shutdown budget.
|
|
9976
|
+
*/
|
|
9977
|
+
var WEBSOCKET_CLOSE_GRACE_MS = 1e3;
|
|
9978
|
+
/**
|
|
9979
|
+
* Close each socket with 1001, and terminate the ones still open after
|
|
9980
|
+
* `graceMs`. Nothing here waits: the HTTP server's close() is what waits for
|
|
9981
|
+
* the sockets to end.
|
|
9982
|
+
*/
|
|
9983
|
+
function closeWebSockets(sockets, graceMs) {
|
|
9984
|
+
for (const ws of sockets) {
|
|
9985
|
+
if (ws.readyState === WebSocket.OPEN) ws.close(1001, "server shutting down");
|
|
9986
|
+
if (ws.readyState !== WebSocket.CLOSED) unref(setTimeout(() => ws.terminate(), graceMs));
|
|
9987
|
+
}
|
|
9988
|
+
}
|
|
9989
|
+
/**
|
|
9258
9990
|
* PostgreSQL-specific realtime service.
|
|
9259
9991
|
* Handles WebSocket connections and subscriptions for real-time row updates.
|
|
9260
9992
|
*
|
|
@@ -9371,8 +10103,20 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9371
10103
|
* and always flow through. Keyed → expiry timestamp (ms).
|
|
9372
10104
|
*/
|
|
9373
10105
|
recentAppEmits = /* @__PURE__ */ new Map();
|
|
9374
|
-
/**
|
|
9375
|
-
|
|
10106
|
+
/**
|
|
10107
|
+
* How long an app-emit key suppresses its own CDC echo: the refetch
|
|
10108
|
+
* debounce, and no longer.
|
|
10109
|
+
*
|
|
10110
|
+
* The mark cannot tell an echo from another writer's change to the same
|
|
10111
|
+
* row, and a save that touches no row of the table (a to-many relation
|
|
10112
|
+
* only, an empty payload) is announced with no echo ever coming to consume
|
|
10113
|
+
* it. Held for seconds, it swallowed the next change psql, a cron or
|
|
10114
|
+
* another instance made to that row. Inside the debounce nothing is lost:
|
|
10115
|
+
* the refetch the app emit scheduled has not started yet — its timer fires
|
|
10116
|
+
* no earlier than this — and it reads after any commit whose NOTIFY has
|
|
10117
|
+
* already arrived. An echo later than that costs one more refetch.
|
|
10118
|
+
*/
|
|
10119
|
+
static CDC_DEDUP_WINDOW_MS = RealtimeService.REFETCH_DEBOUNCE_MS;
|
|
9376
10120
|
constructor(db, registry) {
|
|
9377
10121
|
super();
|
|
9378
10122
|
this.db = db;
|
|
@@ -9597,7 +10341,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9597
10341
|
const subscriptionId = request.subscriptionId;
|
|
9598
10342
|
let canDeliver;
|
|
9599
10343
|
try {
|
|
9600
|
-
|
|
10344
|
+
const collection = this.registry.getCollectionByPath(request.path);
|
|
10345
|
+
if (!collection) {
|
|
9601
10346
|
const registered = this.registry.getCollections().map((c) => c.slug).join(", ");
|
|
9602
10347
|
const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
|
|
9603
10348
|
logger.error(`[RealtimeService] ${msg}`);
|
|
@@ -9628,6 +10373,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9628
10373
|
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
9629
10374
|
return;
|
|
9630
10375
|
}
|
|
10376
|
+
try {
|
|
10377
|
+
assertReadRequestReadable({
|
|
10378
|
+
...request,
|
|
10379
|
+
orderBy
|
|
10380
|
+
}, collection, { roles: authContext?.roles ?? ["anon"] });
|
|
10381
|
+
} catch (e) {
|
|
10382
|
+
if (!(e instanceof ApiError)) throw e;
|
|
10383
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
10384
|
+
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
10385
|
+
return;
|
|
10386
|
+
}
|
|
9631
10387
|
const subscription = {
|
|
9632
10388
|
clientId,
|
|
9633
10389
|
type: "collection",
|
|
@@ -9706,6 +10462,76 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9706
10462
|
if (canDeliver && !canDeliver.mayReportFailure()) return;
|
|
9707
10463
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
9708
10464
|
}
|
|
10465
|
+
/**
|
|
10466
|
+
* Re-judge everything a socket holds open as the identity it has now.
|
|
10467
|
+
*
|
|
10468
|
+
* A socket re-authenticates on every session refresh, and when a role is
|
|
10469
|
+
* taken away, a tenant claim removed or a different account signs in, the
|
|
10470
|
+
* next token says so. Requests read as the new identity at once; the
|
|
10471
|
+
* subscriptions kept refetching as the one they were opened with, so a
|
|
10472
|
+
* demoted user went on receiving editor-only or another tenant's rows for
|
|
10473
|
+
* as long as the view stayed mounted.
|
|
10474
|
+
*
|
|
10475
|
+
* Each of this client's subscriptions is replaced by one carrying the new
|
|
10476
|
+
* identity — a replacement, so a refetch already running as the old one
|
|
10477
|
+
* cannot deliver — and refetched, so the view narrows now rather than at
|
|
10478
|
+
* the next write. A subscription whose filter, sort or projection names a
|
|
10479
|
+
* field the new roles may not read is ended with the refusal a new
|
|
10480
|
+
* subscription would get. Channel memberships are put to the authorizer
|
|
10481
|
+
* again, when one is installed, and left when it refuses.
|
|
10482
|
+
*/
|
|
10483
|
+
async rescopeClient(clientId, authContext) {
|
|
10484
|
+
for (const [subscriptionId, subscription] of [...this._subscriptions.entries()]) {
|
|
10485
|
+
if (subscription.clientId !== clientId) continue;
|
|
10486
|
+
if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
10487
|
+
const collection = this.registry.getCollectionByPath(subscription.path);
|
|
10488
|
+
const request = subscription.collectionRequest;
|
|
10489
|
+
try {
|
|
10490
|
+
if (collection) assertReadRequestReadable({
|
|
10491
|
+
filter: request.filter,
|
|
10492
|
+
logical: request.logical,
|
|
10493
|
+
orderBy: request.orderBy,
|
|
10494
|
+
fields: request.fields
|
|
10495
|
+
}, collection, { roles: authContext.roles ?? ["anon"] });
|
|
10496
|
+
} catch (e) {
|
|
10497
|
+
if (!(e instanceof ApiError)) throw e;
|
|
10498
|
+
await this.handleUnsubscribe(clientId, subscriptionId);
|
|
10499
|
+
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
10500
|
+
continue;
|
|
10501
|
+
}
|
|
10502
|
+
}
|
|
10503
|
+
const rescoped = {
|
|
10504
|
+
...subscription,
|
|
10505
|
+
authContext,
|
|
10506
|
+
started: 0,
|
|
10507
|
+
delivered: 0
|
|
10508
|
+
};
|
|
10509
|
+
this._subscriptions.set(subscriptionId, rescoped);
|
|
10510
|
+
if (rescoped.type === "single" && rescoped.id !== void 0) this.debouncedSingleRefetch(subscriptionId, rescoped.path, String(rescoped.id), rescoped);
|
|
10511
|
+
else if (rescoped.type === "collection" && rescoped.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, rescoped.path, rescoped);
|
|
10512
|
+
}
|
|
10513
|
+
const authorizer = this.channelAuthorizer;
|
|
10514
|
+
if (!authorizer) return;
|
|
10515
|
+
for (const [channel, members] of [...this.channels.entries()]) {
|
|
10516
|
+
if (!members.has(clientId)) continue;
|
|
10517
|
+
let allowed;
|
|
10518
|
+
try {
|
|
10519
|
+
allowed = await authorizer({
|
|
10520
|
+
channel,
|
|
10521
|
+
action: "join",
|
|
10522
|
+
clientId,
|
|
10523
|
+
user: authContext
|
|
10524
|
+
});
|
|
10525
|
+
} catch (error) {
|
|
10526
|
+
logger.error(`❌ [Channels] Authorizer threw re-checking "${channel}" after a sign-in — leaving it`, { error });
|
|
10527
|
+
allowed = false;
|
|
10528
|
+
}
|
|
10529
|
+
if (!allowed) {
|
|
10530
|
+
this.leaveChannel(clientId, channel);
|
|
10531
|
+
this.denyChannelAction(clientId, channel, "join", "refused by the channel authorizer for the identity this socket signed in as");
|
|
10532
|
+
}
|
|
10533
|
+
}
|
|
10534
|
+
}
|
|
9709
10535
|
async handleUnsubscribe(_clientId, subscriptionId) {
|
|
9710
10536
|
this._subscriptions.delete(subscriptionId);
|
|
9711
10537
|
this.subscriptionCallbacks.delete(subscriptionId);
|
|
@@ -9736,6 +10562,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9736
10562
|
*/
|
|
9737
10563
|
async notifyUpdate(path, id, row, databaseId, broadcast = true, origin = "app") {
|
|
9738
10564
|
this.debugLog("🔔 [RealtimeService] notifyUpdate called for path:", path, "id:", id, "isDelete:", row === null, "origin:", origin);
|
|
10565
|
+
const aliases = this.aliasPaths(path);
|
|
9739
10566
|
if (this.cdcActive) {
|
|
9740
10567
|
const key = this.dedupKey(path, id, databaseId);
|
|
9741
10568
|
if (origin === "cdc") {
|
|
@@ -9743,7 +10570,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9743
10570
|
this.debugLog("🔁 [RealtimeService] Suppressing CDC echo of local app mutation:", key);
|
|
9744
10571
|
return;
|
|
9745
10572
|
}
|
|
9746
|
-
} else
|
|
10573
|
+
} else {
|
|
10574
|
+
this.markAppEmit(key);
|
|
10575
|
+
if (isNestedPath(path)) {
|
|
10576
|
+
const root = aliases.find((alias) => !isNestedPath(alias));
|
|
10577
|
+
if (root) this.markAppEmit(this.dedupKey(root, id, databaseId));
|
|
10578
|
+
}
|
|
10579
|
+
}
|
|
9747
10580
|
}
|
|
9748
10581
|
const pathsToNotify = [path];
|
|
9749
10582
|
if (path.includes("/") && path.split("/").length > 1) {
|
|
@@ -9752,6 +10585,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9752
10585
|
this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
|
|
9753
10586
|
}
|
|
9754
10587
|
for (const notifyPath of pathsToNotify) await this.notifyPathUpdate(notifyPath, path, id, row, databaseId);
|
|
10588
|
+
for (const alias of aliases) await this.notifyPathUpdate(alias, alias, id, row, databaseId);
|
|
9755
10589
|
if (broadcast && this.broadcasting && !this.cdcActive) try {
|
|
9756
10590
|
await this.broadcastChange(path, id, databaseId);
|
|
9757
10591
|
} catch (err) {
|
|
@@ -9950,7 +10784,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9950
10784
|
}));
|
|
9951
10785
|
}
|
|
9952
10786
|
return fetchedEntities;
|
|
9953
|
-
}));
|
|
10787
|
+
}, { accessMode: "read only" }));
|
|
9954
10788
|
}
|
|
9955
10789
|
return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchCollectionForRest(notifyPath, {
|
|
9956
10790
|
filter: collectionRequest.filter,
|
|
@@ -10002,7 +10836,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10002
10836
|
}, this.rlsUserRole);
|
|
10003
10837
|
let hookContext;
|
|
10004
10838
|
return countOnce(new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)));
|
|
10005
|
-
});
|
|
10839
|
+
}, { accessMode: "read only" });
|
|
10006
10840
|
} else total = await countOnce(this.dataService);
|
|
10007
10841
|
} catch (error) {
|
|
10008
10842
|
logger.warn(`[RealtimeService] Could not count '${notifyPath}' for a subscription frame`, { error });
|
|
@@ -10115,7 +10949,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10115
10949
|
}
|
|
10116
10950
|
}
|
|
10117
10951
|
return processedEntity;
|
|
10118
|
-
}));
|
|
10952
|
+
}, { accessMode: "read only" }));
|
|
10119
10953
|
}
|
|
10120
10954
|
return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchOneForRest(notifyPath, id) ?? void 0);
|
|
10121
10955
|
}
|
|
@@ -10194,6 +11028,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10194
11028
|
* Extract parent paths from a nested path like "posts/70/tags"
|
|
10195
11029
|
* Returns ["posts", "posts/70"] for the example above
|
|
10196
11030
|
*/
|
|
11031
|
+
/**
|
|
11032
|
+
* The other paths a subscriber can address the row written at `path` by.
|
|
11033
|
+
*
|
|
11034
|
+
* `authors/1/posts` and `posts` are two addresses for the same rows, and
|
|
11035
|
+
* subscriptions were matched by the written path's exact string. So a post
|
|
11036
|
+
* saved through `authors/1/posts` never reached a subscriber of `posts` or
|
|
11037
|
+
* of `posts/43`, and one saved through `posts` never reached a subscriber
|
|
11038
|
+
* of `authors/1/posts`, whose list it may just have joined or left.
|
|
11039
|
+
*
|
|
11040
|
+
* The aliases are the target collection's root path, and every nested path
|
|
11041
|
+
* a live subscription holds that lands on the same collection. Each still
|
|
11042
|
+
* refetches under its own scope and its own parent, so naming a path here
|
|
11043
|
+
* decides only who is asked to look again — never what they are shown.
|
|
11044
|
+
*/
|
|
11045
|
+
aliasPaths(path) {
|
|
11046
|
+
const slug = this.collectionSlugAt(path);
|
|
11047
|
+
if (!slug) return [];
|
|
11048
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
11049
|
+
if (slug !== path) aliases.add(slug);
|
|
11050
|
+
for (const subscription of this._subscriptions.values()) {
|
|
11051
|
+
if (subscription.path === path || aliases.has(subscription.path) || !isNestedPath(subscription.path)) continue;
|
|
11052
|
+
if (this.collectionSlugAt(subscription.path) === slug) aliases.add(subscription.path);
|
|
11053
|
+
}
|
|
11054
|
+
return [...aliases];
|
|
11055
|
+
}
|
|
11056
|
+
/** The slug of the collection a path lands on, or `undefined` for one that names none. */
|
|
11057
|
+
collectionSlugAt(path) {
|
|
11058
|
+
try {
|
|
11059
|
+
return this.registry.getCollectionByPath(path)?.slug;
|
|
11060
|
+
} catch {
|
|
11061
|
+
return;
|
|
11062
|
+
}
|
|
11063
|
+
}
|
|
10197
11064
|
getParentPaths(path) {
|
|
10198
11065
|
const segments = path.split("/").filter((s) => s.length > 0);
|
|
10199
11066
|
const parentPaths = [];
|
|
@@ -10766,8 +11633,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10766
11633
|
* 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
|
|
10767
11634
|
* 2. All subscription state and callbacks are cleared.
|
|
10768
11635
|
* 3. The dedicated LISTEN client (outside the pool) is disconnected.
|
|
10769
|
-
* 4. All WebSocket clients are
|
|
10770
|
-
*
|
|
11636
|
+
* 4. All WebSocket clients are closed with 1001 ("going away"); one that
|
|
11637
|
+
* does not answer the close frame is dropped after a short grace.
|
|
10771
11638
|
*/
|
|
10772
11639
|
async destroy() {
|
|
10773
11640
|
for (const [key, timer] of this.refetchTimers) {
|
|
@@ -10802,7 +11669,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10802
11669
|
await this.stopCdc();
|
|
10803
11670
|
await this.bus.stop().catch((error) => logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
|
|
10804
11671
|
this.bus = new MemoryChannelBus();
|
|
11672
|
+
const sockets = [...this.clients.values()];
|
|
10805
11673
|
this.clients.clear();
|
|
11674
|
+
closeWebSockets(sockets, WEBSOCKET_CLOSE_GRACE_MS);
|
|
10806
11675
|
this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
|
|
10807
11676
|
}
|
|
10808
11677
|
/** Whether database-level change capture is currently the active source. */
|
|
@@ -10833,7 +11702,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10833
11702
|
}
|
|
10834
11703
|
this.cdcTableMap = this.buildCdcTableMap();
|
|
10835
11704
|
this.junctionLinkMap = buildJunctionLinkMap(this.registry);
|
|
10836
|
-
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
|
|
11705
|
+
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event), () => this.resyncSubscriptions());
|
|
10837
11706
|
try {
|
|
10838
11707
|
await this.cdcListener.start();
|
|
10839
11708
|
} catch (err) {
|
|
@@ -10846,6 +11715,32 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10846
11715
|
this.cdcActive = true;
|
|
10847
11716
|
logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
|
|
10848
11717
|
}
|
|
11718
|
+
/**
|
|
11719
|
+
* Refetch every live subscription, as a change to its path would.
|
|
11720
|
+
*
|
|
11721
|
+
* Called when a LISTEN connection (CDC, or the cross-instance broadcast)
|
|
11722
|
+
* is listening again after a drop. Postgres does not queue NOTIFY for a
|
|
11723
|
+
* session that is not listening, so every change committed while it was
|
|
11724
|
+
* down — by another instance, psql, a cron — reached nobody here, and a
|
|
11725
|
+
* subscriber held its pre-gap rows until some later change to the same
|
|
11726
|
+
* collection happened to arrive. Each refetch runs under the
|
|
11727
|
+
* subscription's own scope, so this decides only who looks again.
|
|
11728
|
+
*/
|
|
11729
|
+
resyncSubscriptions() {
|
|
11730
|
+
for (const [subscriptionId, subscription] of this._subscriptions.entries()) try {
|
|
11731
|
+
if (subscription.clientId === "driver") {
|
|
11732
|
+
const callback = this.subscriptionCallbacks.get(subscriptionId);
|
|
11733
|
+
if (!callback) continue;
|
|
11734
|
+
if (subscription.type === "single" && subscription.id !== void 0) this.debouncedSingleDriverRefetch(subscriptionId, subscription.path, String(subscription.id), subscription, callback);
|
|
11735
|
+
else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedDriverRefetch(subscriptionId, subscription.path, subscription, callback);
|
|
11736
|
+
} else if (this.clients.has(subscription.clientId)) {
|
|
11737
|
+
if (subscription.type === "single" && subscription.id !== void 0) this.debouncedSingleRefetch(subscriptionId, subscription.path, String(subscription.id), subscription);
|
|
11738
|
+
else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, subscription.path, subscription);
|
|
11739
|
+
}
|
|
11740
|
+
} catch (error) {
|
|
11741
|
+
logger.error(`❌ [RealtimeService] Could not resync subscription ${subscriptionId}`, { error });
|
|
11742
|
+
}
|
|
11743
|
+
}
|
|
10849
11744
|
/** Stop the CDC listener and clear its state. */
|
|
10850
11745
|
async stopCdc() {
|
|
10851
11746
|
this.cdcActive = false;
|
|
@@ -10935,7 +11830,29 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10935
11830
|
}
|
|
10936
11831
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
10937
11832
|
extractIdFromCdcRow(collection, row) {
|
|
10938
|
-
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
11833
|
+
return deriveRowAddress(this.cdcRowByKeyFields(collection, row), collection, this.registry) || "*";
|
|
11834
|
+
}
|
|
11835
|
+
/**
|
|
11836
|
+
* A captured row with its key under the key's *field* names.
|
|
11837
|
+
*
|
|
11838
|
+
* The trigger captures the tuple by column — `user_id` — and an address is
|
|
11839
|
+
* built from the key's property names — `userId`. Read by property name
|
|
11840
|
+
* alone, a key declared apart from its column was never on the captured
|
|
11841
|
+
* row, the address fell back to `*`, and no single-row subscriber heard
|
|
11842
|
+
* about any write made outside this server.
|
|
11843
|
+
*/
|
|
11844
|
+
cdcRowByKeyFields(collection, row) {
|
|
11845
|
+
const table = this.registry.getTable(getTableName$1(collection));
|
|
11846
|
+
const columns = table ? getTableColumns(table) : void 0;
|
|
11847
|
+
const keyed = { ...row };
|
|
11848
|
+
for (const pk of getPrimaryKeys(collection, this.registry)) {
|
|
11849
|
+
if (pk.fieldName in row) continue;
|
|
11850
|
+
const property = collection.properties?.[pk.fieldName];
|
|
11851
|
+
const declared = property && "columnName" in property ? property.columnName : void 0;
|
|
11852
|
+
const columnName = columns?.[pk.fieldName]?.name ?? declared ?? toSnakeCase(pk.fieldName);
|
|
11853
|
+
if (columnName in row) keyed[pk.fieldName] = row[columnName];
|
|
11854
|
+
}
|
|
11855
|
+
return keyed;
|
|
10939
11856
|
}
|
|
10940
11857
|
dedupKey(path, id, databaseId) {
|
|
10941
11858
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -11007,8 +11924,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11007
11924
|
}
|
|
11008
11925
|
/**
|
|
11009
11926
|
* Create and connect the dedicated LISTEN client with auto-reconnect.
|
|
11927
|
+
*
|
|
11928
|
+
* @param reconnect Set when the client is coming back from a drop: every
|
|
11929
|
+
* notification published in the gap is gone, so the subscriptions
|
|
11930
|
+
* are refetched once it is listening again.
|
|
11010
11931
|
*/
|
|
11011
|
-
async connectListenClient() {
|
|
11932
|
+
async connectListenClient({ reconnect = false } = {}) {
|
|
11012
11933
|
if (!this.listenConnectionString) return;
|
|
11013
11934
|
let pending;
|
|
11014
11935
|
try {
|
|
@@ -11054,6 +11975,10 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11054
11975
|
this.listenClient = client;
|
|
11055
11976
|
pending = void 0;
|
|
11056
11977
|
this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
|
|
11978
|
+
if (reconnect) {
|
|
11979
|
+
logger.warn("⚠️ [RealtimeService] LISTEN client reconnected; refetching every subscription for what it missed.");
|
|
11980
|
+
this.resyncSubscriptions();
|
|
11981
|
+
}
|
|
11057
11982
|
} catch (err) {
|
|
11058
11983
|
if (pending) try {
|
|
11059
11984
|
await pending.end();
|
|
@@ -11078,7 +12003,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
11078
12003
|
} catch {}
|
|
11079
12004
|
this.listenClient = void 0;
|
|
11080
12005
|
}
|
|
11081
|
-
await this.connectListenClient();
|
|
12006
|
+
await this.connectListenClient({ reconnect: true });
|
|
11082
12007
|
}, delay);
|
|
11083
12008
|
}
|
|
11084
12009
|
};
|
|
@@ -11113,6 +12038,17 @@ var WS_RATE_WINDOW_MS = 6e4;
|
|
|
11113
12038
|
* a considered product limit (see `docs/channel-authorization.md`).
|
|
11114
12039
|
*/
|
|
11115
12040
|
var WS_CHANNEL_RATE_LIMIT = 7200;
|
|
12041
|
+
/**
|
|
12042
|
+
* The largest frame a socket that has not authenticated may send, in bytes.
|
|
12043
|
+
*
|
|
12044
|
+
* All such a socket can usefully say is `AUTHENTICATE` with a token — a few
|
|
12045
|
+
* KiB with custom claims — and everything else is answered `UNAUTHORIZED`.
|
|
12046
|
+
* Without this, each of those answers came after the whole frame was buffered,
|
|
12047
|
+
* stringified and parsed, up to the body limit: a handful of connections that
|
|
12048
|
+
* never signed in could hold the process's memory. A larger frame closes the
|
|
12049
|
+
* socket with 1009 before it is read.
|
|
12050
|
+
*/
|
|
12051
|
+
var MAX_UNAUTHENTICATED_FRAME_BYTES = 64 * 1024;
|
|
11116
12052
|
/** Frames counted against the channel budget rather than the general one. */
|
|
11117
12053
|
var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
11118
12054
|
"join_channel",
|
|
@@ -11214,7 +12150,33 @@ function isAdminSession(session) {
|
|
|
11214
12150
|
if (!session.user.roles) return false;
|
|
11215
12151
|
return session.user.roles.some((r) => r === "admin");
|
|
11216
12152
|
}
|
|
11217
|
-
|
|
12153
|
+
/** A frame's size in bytes, however `ws` delivered it. */
|
|
12154
|
+
function frameBytes(data) {
|
|
12155
|
+
if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);
|
|
12156
|
+
return data.byteLength;
|
|
12157
|
+
}
|
|
12158
|
+
/**
|
|
12159
|
+
* Where a connection comes from, as the data API's rate limiter reads an
|
|
12160
|
+
* address: the upgrade request's headers (believed only as far as proxies are
|
|
12161
|
+
* declared) and the socket's own address.
|
|
12162
|
+
*/
|
|
12163
|
+
function connectionOrigin(request) {
|
|
12164
|
+
return {
|
|
12165
|
+
header: (name) => {
|
|
12166
|
+
const value = request?.headers[name];
|
|
12167
|
+
return Array.isArray(value) ? value.join(", ") : value;
|
|
12168
|
+
},
|
|
12169
|
+
socketAddress: request?.socket?.remoteAddress
|
|
12170
|
+
};
|
|
12171
|
+
}
|
|
12172
|
+
/**
|
|
12173
|
+
* `limits` are the data API's, for the same rows reached through this door:
|
|
12174
|
+
* frames larger than its body limit close the socket with 1009 before they are
|
|
12175
|
+
* read, and every data frame counts in the caller's data-API bucket — the one
|
|
12176
|
+
* their HTTP requests count in — rather than only in a budget per connection,
|
|
12177
|
+
* which a caller multiplied by opening more.
|
|
12178
|
+
*/
|
|
12179
|
+
function createPostgresWebSocket(server, realtimeService, driver, authConfig, authAdapter, limits) {
|
|
11218
12180
|
const clientSessions = /* @__PURE__ */ new Map();
|
|
11219
12181
|
const isProduction = process.env.NODE_ENV === "production";
|
|
11220
12182
|
/**
|
|
@@ -11237,15 +12199,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11237
12199
|
if (extras.length === 0) logger.debug(message);
|
|
11238
12200
|
else logger.debug(message, { details: extras.length === 1 ? extras[0] : extras });
|
|
11239
12201
|
};
|
|
11240
|
-
const
|
|
12202
|
+
const maxPayload = limits?.maxPayload ?? RUNTIME_DEFAULT_MAX_BODY_SIZE;
|
|
12203
|
+
const wss = new WebSocketServer({
|
|
12204
|
+
server,
|
|
12205
|
+
maxPayload: maxPayload > 0 ? maxPayload : 0
|
|
12206
|
+
});
|
|
11241
12207
|
wss.on("error", (err) => {
|
|
11242
12208
|
if (err.code === "EADDRINUSE") return;
|
|
11243
12209
|
logger.error("❌ [WebSocket Server] Error", { error: err });
|
|
11244
12210
|
});
|
|
11245
12211
|
const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
|
|
11246
12212
|
if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) logger.warn("🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or serviceKey is configured — no client can complete AUTH, so every realtime message will be refused with UNAUTHORIZED.");
|
|
11247
|
-
wss.on("connection", (ws) => {
|
|
12213
|
+
wss.on("connection", (ws, request) => {
|
|
11248
12214
|
const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
12215
|
+
const origin = connectionOrigin(request);
|
|
11249
12216
|
wsDebug(`WebSocket client connected: ${clientId}`);
|
|
11250
12217
|
clientSessions.set(clientId, {
|
|
11251
12218
|
ws,
|
|
@@ -11260,9 +12227,16 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11260
12227
|
wsDebug(`WebSocket client disconnected: ${clientId}`);
|
|
11261
12228
|
clientSessions.delete(clientId);
|
|
11262
12229
|
});
|
|
12230
|
+
ws.on("error", (error) => {
|
|
12231
|
+
wsDebug(`WebSocket client ${clientId} error: ${error.message}`);
|
|
12232
|
+
});
|
|
11263
12233
|
ws.on("message", async (message) => {
|
|
11264
12234
|
let requestId;
|
|
11265
12235
|
try {
|
|
12236
|
+
if (!clientSessions.get(clientId)?.authenticated && frameBytes(message) > MAX_UNAUTHENTICATED_FRAME_BYTES) {
|
|
12237
|
+
ws.close(1009, "Authenticate before sending anything larger than a token.");
|
|
12238
|
+
return;
|
|
12239
|
+
}
|
|
11266
12240
|
const { type, payload, requestId: reqId } = JSON.parse(message.toString());
|
|
11267
12241
|
requestId = reqId;
|
|
11268
12242
|
wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
|
|
@@ -11281,6 +12255,33 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11281
12255
|
}
|
|
11282
12256
|
}));
|
|
11283
12257
|
};
|
|
12258
|
+
{
|
|
12259
|
+
const session = clientSessions.get(clientId);
|
|
12260
|
+
if (session) {
|
|
12261
|
+
const now = Date.now();
|
|
12262
|
+
if (CHANNEL_MESSAGE_TYPES.has(type)) {
|
|
12263
|
+
if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
|
|
12264
|
+
session.channelMessageCount = 0;
|
|
12265
|
+
session.channelWindowStart = now;
|
|
12266
|
+
}
|
|
12267
|
+
session.channelMessageCount++;
|
|
12268
|
+
if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
|
|
12269
|
+
sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
|
|
12270
|
+
return;
|
|
12271
|
+
}
|
|
12272
|
+
} else {
|
|
12273
|
+
if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
|
|
12274
|
+
session.messageCount = 0;
|
|
12275
|
+
session.messageWindowStart = now;
|
|
12276
|
+
}
|
|
12277
|
+
session.messageCount++;
|
|
12278
|
+
if (session.messageCount > WS_RATE_LIMIT) {
|
|
12279
|
+
sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
|
|
12280
|
+
return;
|
|
12281
|
+
}
|
|
12282
|
+
}
|
|
12283
|
+
}
|
|
12284
|
+
}
|
|
11284
12285
|
if (type === "AUTHENTICATE") {
|
|
11285
12286
|
const { token } = payload || {};
|
|
11286
12287
|
if (!token) {
|
|
@@ -11294,7 +12295,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11294
12295
|
uid: adapterUser.uid,
|
|
11295
12296
|
roles: adapterUser.roles,
|
|
11296
12297
|
isAdmin: adapterUser.isAdmin,
|
|
11297
|
-
isAnonymous: adapterUser.isAnonymous === true
|
|
12298
|
+
isAnonymous: adapterUser.isAnonymous === true,
|
|
12299
|
+
...adapterUser.claims ? { claims: adapterUser.claims } : {}
|
|
11298
12300
|
};
|
|
11299
12301
|
} catch {}
|
|
11300
12302
|
else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
|
|
@@ -11318,6 +12320,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11318
12320
|
if (session) {
|
|
11319
12321
|
session.user = verifiedUser;
|
|
11320
12322
|
session.authenticated = true;
|
|
12323
|
+
await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
|
|
11321
12324
|
}
|
|
11322
12325
|
wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
|
|
11323
12326
|
ws.send(JSON.stringify({
|
|
@@ -11341,39 +12344,22 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11341
12344
|
return;
|
|
11342
12345
|
}
|
|
11343
12346
|
}
|
|
11344
|
-
{
|
|
11345
|
-
const session = clientSessions.get(clientId);
|
|
11346
|
-
if (session) {
|
|
11347
|
-
const now = Date.now();
|
|
11348
|
-
if (CHANNEL_MESSAGE_TYPES.has(type)) {
|
|
11349
|
-
if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
|
|
11350
|
-
session.channelMessageCount = 0;
|
|
11351
|
-
session.channelWindowStart = now;
|
|
11352
|
-
}
|
|
11353
|
-
session.channelMessageCount++;
|
|
11354
|
-
if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
|
|
11355
|
-
sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
|
|
11356
|
-
return;
|
|
11357
|
-
}
|
|
11358
|
-
} else {
|
|
11359
|
-
if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
|
|
11360
|
-
session.messageCount = 0;
|
|
11361
|
-
session.messageWindowStart = now;
|
|
11362
|
-
}
|
|
11363
|
-
session.messageCount++;
|
|
11364
|
-
if (session.messageCount > WS_RATE_LIMIT) {
|
|
11365
|
-
sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
|
|
11366
|
-
return;
|
|
11367
|
-
}
|
|
11368
|
-
}
|
|
11369
|
-
}
|
|
11370
|
-
}
|
|
11371
12347
|
if (ADMIN_ONLY_TYPES.has(type)) {
|
|
11372
12348
|
if (!isAdminSession(clientSessions.get(clientId))) {
|
|
11373
12349
|
sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
|
|
11374
12350
|
return;
|
|
11375
12351
|
}
|
|
11376
12352
|
}
|
|
12353
|
+
if (limits?.dataRateLimit && PUBLIC_TYPES.has(type)) {
|
|
12354
|
+
const decision = await limits.dataRateLimit({
|
|
12355
|
+
...origin,
|
|
12356
|
+
uid: clientSessions.get(clientId)?.user?.uid
|
|
12357
|
+
});
|
|
12358
|
+
if (decision && !decision.allowed) {
|
|
12359
|
+
sendError("ERROR", "RATE_LIMITED", "Too many requests, please try again later.");
|
|
12360
|
+
return;
|
|
12361
|
+
}
|
|
12362
|
+
}
|
|
11377
12363
|
/**
|
|
11378
12364
|
* Apply the REST layer's write checks to a socket payload.
|
|
11379
12365
|
*
|
|
@@ -11381,13 +12367,98 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11381
12367
|
* driver decides what a path means, and refusing here would
|
|
11382
12368
|
* turn "unknown collection" into a validation error.
|
|
11383
12369
|
*/
|
|
11384
|
-
const assertWriteRequest = (path, values) => {
|
|
12370
|
+
const assertWriteRequest = (path, values, status) => {
|
|
11385
12371
|
if (!path || !values || typeof values !== "object") return;
|
|
11386
12372
|
const collection = driver.registry?.getCollectionByPath(path);
|
|
11387
12373
|
if (!collection) return;
|
|
11388
|
-
assertWriteRequestValid(values, collection, {
|
|
12374
|
+
assertWriteRequestValid(values, collection, {
|
|
12375
|
+
viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] },
|
|
12376
|
+
status
|
|
12377
|
+
});
|
|
11389
12378
|
assertFieldOpsValid(values, collection);
|
|
11390
12379
|
};
|
|
12380
|
+
/**
|
|
12381
|
+
* Apply the REST layer's read check to a socket request: no
|
|
12382
|
+
* filter, sort, projection or uniqueness probe over a field
|
|
12383
|
+
* this caller may not read. The strip keeps the value off the
|
|
12384
|
+
* wire; without this a `COUNT` still answers whether a hidden
|
|
12385
|
+
* value matches a pattern, one character at a time.
|
|
12386
|
+
*
|
|
12387
|
+
* Same viewer as `assertWriteRequest`, and silent in the same
|
|
12388
|
+
* case: a path that names no registered collection is the
|
|
12389
|
+
* driver's to answer.
|
|
12390
|
+
*/
|
|
12391
|
+
const assertReadRequest = (path, request) => {
|
|
12392
|
+
if (!path) return;
|
|
12393
|
+
let collection;
|
|
12394
|
+
try {
|
|
12395
|
+
collection = driver.registry?.getCollectionByPath(path);
|
|
12396
|
+
} catch {
|
|
12397
|
+
return;
|
|
12398
|
+
}
|
|
12399
|
+
if (!collection) return;
|
|
12400
|
+
const session = clientSessions.get(clientId);
|
|
12401
|
+
assertReadRequestReadable(request, collection, { roles: session?.user?.roles ?? ["anon"] });
|
|
12402
|
+
};
|
|
12403
|
+
/**
|
|
12404
|
+
* Refuse a write through a nested path that sets a relation
|
|
12405
|
+
* this caller may not write — the rule REST's nested routes
|
|
12406
|
+
* apply, from the same function. The body is checked by
|
|
12407
|
+
* `assertWriteRequest`; this is what the address writes.
|
|
12408
|
+
*
|
|
12409
|
+
* Silent for a root path, and for a nested one the registry
|
|
12410
|
+
* cannot walk: the driver refuses that with its own message.
|
|
12411
|
+
*/
|
|
12412
|
+
const assertNestedWrite = (path, kind) => {
|
|
12413
|
+
if (typeof path !== "string" || !isNestedPath(path) || !driver.registry) return;
|
|
12414
|
+
let hop;
|
|
12415
|
+
try {
|
|
12416
|
+
hop = resolveNestedPath(path, driver.registry);
|
|
12417
|
+
} catch {
|
|
12418
|
+
return;
|
|
12419
|
+
}
|
|
12420
|
+
if (!hop) return;
|
|
12421
|
+
const session = clientSessions.get(clientId);
|
|
12422
|
+
assertNestedWriteAllowed(hop, kind, path, { roles: session?.user?.roles ?? ["anon"] });
|
|
12423
|
+
};
|
|
12424
|
+
/** The auth collection at `path`, or `undefined` for any other. */
|
|
12425
|
+
const authCollectionAt = (path) => {
|
|
12426
|
+
if (typeof path !== "string") return void 0;
|
|
12427
|
+
let collection;
|
|
12428
|
+
try {
|
|
12429
|
+
collection = driver.registry?.getCollectionByPath(path);
|
|
12430
|
+
} catch {
|
|
12431
|
+
return;
|
|
12432
|
+
}
|
|
12433
|
+
const auth = collection?.auth;
|
|
12434
|
+
return auth === true || !!auth && typeof auth === "object" && auth.enabled === true ? collection : void 0;
|
|
12435
|
+
};
|
|
12436
|
+
/**
|
|
12437
|
+
* The auth collection's rows are the users, so a write to one
|
|
12438
|
+
* is user administration — the auth adapter's to check, as it
|
|
12439
|
+
* is on the REST routes and `/admin/users`. `undefined` for any
|
|
12440
|
+
* other collection, or an adapter with nothing to say.
|
|
12441
|
+
*/
|
|
12442
|
+
const userAdminFor = (path) => authAdapter && authCollectionAt(path) ? authAdapter : void 0;
|
|
12443
|
+
/**
|
|
12444
|
+
* An upsert's options, under the rules `POST /api/data`
|
|
12445
|
+
* applies to `?on_conflict=`: refused on a nested path,
|
|
12446
|
+
* whose insert branch stamps the parent's key into the
|
|
12447
|
+
* DO UPDATE and so moves the row it matched under this
|
|
12448
|
+
* parent; and a target that is a declared unique key of
|
|
12449
|
+
* the registry's collection, or a 400 naming the ones
|
|
12450
|
+
* there are rather than Postgres's 42P10.
|
|
12451
|
+
*/
|
|
12452
|
+
const upsertOptions = (request) => {
|
|
12453
|
+
if (request.upsert !== true) return {};
|
|
12454
|
+
if (typeof request.path === "string" && isNestedPath(request.path)) throw ApiError.badRequest("An upsert is not accepted on a nested path: a row it matched would be moved under this parent. Send the upsert to the collection directly.", "INVALID_CONFLICT_TARGET");
|
|
12455
|
+
const collection = driver.registry?.getCollectionByPath(request.path);
|
|
12456
|
+
const onConflict = collection ? resolveConflictTarget(request.onConflict, collection, { where: "`onConflict`" }) : void 0;
|
|
12457
|
+
return {
|
|
12458
|
+
upsert: true,
|
|
12459
|
+
...onConflict && { onConflict }
|
|
12460
|
+
};
|
|
12461
|
+
};
|
|
11391
12462
|
const getScopedDelegate = async () => {
|
|
11392
12463
|
if (typeof driver.withAuth === "function") try {
|
|
11393
12464
|
const who = sessionAuthContext(clientSessions.get(clientId));
|
|
@@ -11413,6 +12484,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11413
12484
|
{
|
|
11414
12485
|
wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
|
|
11415
12486
|
const request = payload;
|
|
12487
|
+
assertReadRequest(request.path, request);
|
|
11416
12488
|
const rows = await (await getScopedDelegate()).fetchCollection({
|
|
11417
12489
|
...request,
|
|
11418
12490
|
limit: resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch })
|
|
@@ -11450,8 +12522,31 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11450
12522
|
depth: null,
|
|
11451
12523
|
colors: true
|
|
11452
12524
|
}));
|
|
11453
|
-
|
|
11454
|
-
|
|
12525
|
+
const usersCollection = request.status !== "existing" && request.upsert !== true && authAdapter?.prepareUserCreation ? authCollectionAt(request.path) : void 0;
|
|
12526
|
+
if (usersCollection) {
|
|
12527
|
+
const body = request.values ?? {};
|
|
12528
|
+
assertUserCreationBodyValid(authAdapter, usersCollection, body, { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] });
|
|
12529
|
+
assertNoFieldOpsOnCreate(body, "A create");
|
|
12530
|
+
} else assertWriteRequest(request.path, request.values, request.status);
|
|
12531
|
+
assertNestedWrite(request.path, request.id === void 0 || request.status !== "existing" ? "create" : "update");
|
|
12532
|
+
const delegate = await getScopedDelegate();
|
|
12533
|
+
const userAdmin = request.status === "existing" && request.id !== void 0 ? userAdminFor(request.path) : void 0;
|
|
12534
|
+
const [values] = userAdmin?.prepareUserUpdates ? await userAdmin.prepareUserUpdates([{
|
|
12535
|
+
uid: String(request.id),
|
|
12536
|
+
values: request.values
|
|
12537
|
+
}]) : [request.values];
|
|
12538
|
+
const write = (toWrite) => delegate.save({
|
|
12539
|
+
path: request.path,
|
|
12540
|
+
...request.id !== void 0 && { id: request.id },
|
|
12541
|
+
values: toWrite,
|
|
12542
|
+
status: request.status,
|
|
12543
|
+
...upsertOptions(request)
|
|
12544
|
+
});
|
|
12545
|
+
const created = usersCollection ? await createUserThroughAuthCollection(authAdapter, usersCollection, values ?? {}, write) : void 0;
|
|
12546
|
+
const row = created ? {
|
|
12547
|
+
...created.row,
|
|
12548
|
+
...created.delivery
|
|
12549
|
+
} : await write(values);
|
|
11455
12550
|
wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, {
|
|
11456
12551
|
depth: null,
|
|
11457
12552
|
colors: true
|
|
@@ -11470,13 +12565,19 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11470
12565
|
wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
|
|
11471
12566
|
const request = payload;
|
|
11472
12567
|
wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
|
|
11473
|
-
|
|
12568
|
+
assertNestedWrite(request.row?.path, "unlink");
|
|
12569
|
+
const delegate = await getScopedDelegate();
|
|
12570
|
+
const userAdmin = userAdminFor(request.row?.path);
|
|
12571
|
+
const uids = [String(request.row.id)];
|
|
12572
|
+
await userAdmin?.prepareUserDeletions?.(uids);
|
|
12573
|
+
await delegate.delete({
|
|
11474
12574
|
row: {
|
|
11475
12575
|
id: request.row.id,
|
|
11476
12576
|
path: request.row.path
|
|
11477
12577
|
},
|
|
11478
12578
|
hard: request.hard
|
|
11479
12579
|
});
|
|
12580
|
+
await userAdmin?.finalizeUserDeletions?.(uids);
|
|
11480
12581
|
wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
|
|
11481
12582
|
const response = {
|
|
11482
12583
|
type: "DELETE_SUCCESS",
|
|
@@ -11491,6 +12592,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11491
12592
|
{
|
|
11492
12593
|
wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
|
|
11493
12594
|
const { path, name, value, id, collection } = payload;
|
|
12595
|
+
if (typeof name === "string") assertReadRequest(path, { filter: { [name]: ["==", value] } });
|
|
11494
12596
|
const isUnique = await (await getScopedDelegate()).checkUniqueField(path, name, value, id, collection);
|
|
11495
12597
|
wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
|
|
11496
12598
|
const response = {
|
|
@@ -11505,6 +12607,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11505
12607
|
case "COUNT":
|
|
11506
12608
|
{
|
|
11507
12609
|
const request = payload;
|
|
12610
|
+
assertReadRequest(request.path, request);
|
|
11508
12611
|
const response = {
|
|
11509
12612
|
type: "COUNT_SUCCESS",
|
|
11510
12613
|
payload: { count: await (await getScopedDelegate()).count(request) },
|
|
@@ -11522,7 +12625,10 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11522
12625
|
sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
|
|
11523
12626
|
break;
|
|
11524
12627
|
}
|
|
11525
|
-
const result = await admin.executeSql(sql,
|
|
12628
|
+
const result = await admin.executeSql(sql, {
|
|
12629
|
+
...options,
|
|
12630
|
+
isolateSession: true
|
|
12631
|
+
});
|
|
11526
12632
|
if (process.env.NODE_ENV !== "production") wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
|
|
11527
12633
|
const auditSession = clientSessions.get(clientId);
|
|
11528
12634
|
logger.info("[SQL Audit] WebSocket SQL execution", {
|
|
@@ -11929,7 +13035,7 @@ function createBackupCron(config) {
|
|
|
11929
13035
|
enabled: config.enabled ?? true,
|
|
11930
13036
|
timeoutSeconds: 3600,
|
|
11931
13037
|
async handler({ log }) {
|
|
11932
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
13038
|
+
const { createDump, discardPartialDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-HQ9GC4tN.js").then((n) => n.r);
|
|
11933
13039
|
const { destination } = config;
|
|
11934
13040
|
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 }).`);
|
|
11935
13041
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -11943,8 +13049,8 @@ function createBackupCron(config) {
|
|
|
11943
13049
|
log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
|
|
11944
13050
|
const check = await validateDump(dump.localFile);
|
|
11945
13051
|
if (!check.ok) {
|
|
11946
|
-
if (
|
|
11947
|
-
|
|
13052
|
+
if (dump.globalsFile) discardPartialDump(dump.globalsFile);
|
|
13053
|
+
discardPartialDump(dump.localFile);
|
|
11948
13054
|
throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
|
|
11949
13055
|
}
|
|
11950
13056
|
let storedKey = dump.localFile;
|
|
@@ -12519,6 +13625,14 @@ var UserService = class {
|
|
|
12519
13625
|
updatedAt: row.updated_at
|
|
12520
13626
|
}));
|
|
12521
13627
|
}
|
|
13628
|
+
/**
|
|
13629
|
+
* @see UserRepository.unlinkUserIdentity — scoped to the user as well as
|
|
13630
|
+
* the identity, so it can only ever detach an identity from the account
|
|
13631
|
+
* it names.
|
|
13632
|
+
*/
|
|
13633
|
+
async unlinkUserIdentity(uid, provider, providerId) {
|
|
13634
|
+
await this.withServerContext(async (db) => db.delete(this.userIdentitiesTable).where(sql`${this.userIdentitiesTable.uid} = ${uid} AND ${this.userIdentitiesTable.provider} = ${provider} AND ${this.userIdentitiesTable.providerId} = ${providerId}`));
|
|
13635
|
+
}
|
|
12522
13636
|
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
12523
13637
|
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
12524
13638
|
uid,
|
|
@@ -12533,7 +13647,13 @@ var UserService = class {
|
|
|
12533
13647
|
const payload = this.mapPayload(data);
|
|
12534
13648
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
12535
13649
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
12536
|
-
|
|
13650
|
+
let row;
|
|
13651
|
+
try {
|
|
13652
|
+
[row] = await this.withServerContext(async (db) => await db.update(this.usersTable).set(payload).where(eq(idCol, id)).returning());
|
|
13653
|
+
} catch (error) {
|
|
13654
|
+
if (extractPgError(error)?.code === "23505") throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
13655
|
+
throw error;
|
|
13656
|
+
}
|
|
12537
13657
|
return row ? this.mapRowToUser(row) : null;
|
|
12538
13658
|
}
|
|
12539
13659
|
async deleteUser(id) {
|
|
@@ -12586,7 +13706,7 @@ var UserService = class {
|
|
|
12586
13706
|
};
|
|
12587
13707
|
}
|
|
12588
13708
|
/**
|
|
12589
|
-
* Update user's password hash
|
|
13709
|
+
* Update user's password hash; `null` removes the password.
|
|
12590
13710
|
*/
|
|
12591
13711
|
async updatePassword(id, passwordHash) {
|
|
12592
13712
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -12756,7 +13876,18 @@ var RefreshTokenService = class RefreshTokenService {
|
|
|
12756
13876
|
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
12757
13877
|
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
12758
13878
|
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
12759
|
-
|
|
13879
|
+
const parentHash = session?.rotatedFrom && this.has("rotatedAt") ? session.rotatedFrom : void 0;
|
|
13880
|
+
if (!parentHash) {
|
|
13881
|
+
await this.db.insert(this.refreshTokensTable).values(values);
|
|
13882
|
+
return true;
|
|
13883
|
+
}
|
|
13884
|
+
return await this.db.transaction(async (tx) => {
|
|
13885
|
+
const live = this.has("revoked") ? sql`${this.refreshTokensTable.tokenHash} = ${parentHash} AND ${this.col("revoked")} IS NOT TRUE` : eq(this.refreshTokensTable.tokenHash, parentHash);
|
|
13886
|
+
const [parent] = await tx.select({ id: this.refreshTokensTable.id }).from(this.refreshTokensTable).where(live).for("share");
|
|
13887
|
+
if (!parent) return false;
|
|
13888
|
+
await tx.insert(this.refreshTokensTable).values(values);
|
|
13889
|
+
return true;
|
|
13890
|
+
});
|
|
12760
13891
|
}
|
|
12761
13892
|
/**
|
|
12762
13893
|
* Build a {@link RefreshTokenInfo} out of a row of {@link selection}.
|
|
@@ -12812,17 +13943,31 @@ var RefreshTokenService = class RefreshTokenService {
|
|
|
12812
13943
|
}
|
|
12813
13944
|
await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
12814
13945
|
}
|
|
12815
|
-
/**
|
|
13946
|
+
/**
|
|
13947
|
+
* Final kill of one sign-in: logout, or revoking a device remotely.
|
|
13948
|
+
*
|
|
13949
|
+
* Lock first, write second, in one transaction. A rotation in flight
|
|
13950
|
+
* holds a share lock on its parent — a row of this session — until the
|
|
13951
|
+
* token it mints is committed (see `createToken`), so the lock here waits
|
|
13952
|
+
* for it, and the write, being a new statement, reads a snapshot that has
|
|
13953
|
+
* that token in it. A lone UPDATE would work from a snapshot taken before
|
|
13954
|
+
* the token existed and leave it live: the sign-out a refresh in flight
|
|
13955
|
+
* used to undo.
|
|
13956
|
+
*/
|
|
12816
13957
|
async revokeSession(sessionId) {
|
|
12817
13958
|
if (!this.has("sessionId")) return;
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
|
|
12824
|
-
|
|
12825
|
-
|
|
13959
|
+
const inSession = eq(this.col("sessionId"), sessionId);
|
|
13960
|
+
await this.db.transaction(async (tx) => {
|
|
13961
|
+
await tx.select({ id: this.refreshTokensTable.id }).from(this.refreshTokensTable).where(inSession).for("update");
|
|
13962
|
+
if (this.has("revoked")) {
|
|
13963
|
+
await tx.update(this.refreshTokensTable).set({
|
|
13964
|
+
revoked: true,
|
|
13965
|
+
...this.has("rotatedAt") ? { rotatedAt: /* @__PURE__ */ new Date() } : {}
|
|
13966
|
+
}).where(inSession);
|
|
13967
|
+
return;
|
|
13968
|
+
}
|
|
13969
|
+
await tx.delete(this.refreshTokensTable).where(inSession);
|
|
13970
|
+
});
|
|
12826
13971
|
}
|
|
12827
13972
|
/**
|
|
12828
13973
|
* Housekeeping: rotation would otherwise leave a row per refresh forever.
|
|
@@ -13013,7 +14158,7 @@ var PostgresTokenRepository = class {
|
|
|
13013
14158
|
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
13014
14159
|
}
|
|
13015
14160
|
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
13016
|
-
|
|
14161
|
+
return this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
13017
14162
|
}
|
|
13018
14163
|
async markRefreshTokenRotated(tokenHash) {
|
|
13019
14164
|
await this.refreshTokenService.markRotated(tokenHash);
|
|
@@ -13102,6 +14247,9 @@ var PostgresAuthRepository = class {
|
|
|
13102
14247
|
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
13103
14248
|
return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
|
|
13104
14249
|
}
|
|
14250
|
+
async unlinkUserIdentity(uid, provider, providerId) {
|
|
14251
|
+
await this.userService.unlinkUserIdentity(uid, provider, providerId);
|
|
14252
|
+
}
|
|
13105
14253
|
async updateUser(id, data) {
|
|
13106
14254
|
return this.userService.updateUser(id, data);
|
|
13107
14255
|
}
|
|
@@ -13195,7 +14343,7 @@ var PostgresAuthRepository = class {
|
|
|
13195
14343
|
}
|
|
13196
14344
|
async deleteRole(_id) {}
|
|
13197
14345
|
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
13198
|
-
|
|
14346
|
+
return this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
13199
14347
|
}
|
|
13200
14348
|
async markRefreshTokenRotated(tokenHash) {
|
|
13201
14349
|
await this.tokenRepository.markRefreshTokenRotated(tokenHash);
|
|
@@ -13450,7 +14598,9 @@ var MfaService = class {
|
|
|
13450
14598
|
};
|
|
13451
14599
|
}
|
|
13452
14600
|
/**
|
|
13453
|
-
* Count one
|
|
14601
|
+
* Count one guess against a challenge and report the new total. The route
|
|
14602
|
+
* claims it before judging the code, so the total it returns is what the
|
|
14603
|
+
* cap is decided on.
|
|
13454
14604
|
*
|
|
13455
14605
|
* Incremented in the database rather than in the route so that guesses
|
|
13456
14606
|
* arriving in parallel — the shape any real brute-force takes — cannot
|
|
@@ -15066,9 +16216,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15066
16216
|
const provisioningDb = (driverResult) => {
|
|
15067
16217
|
return (driverResult?.internals)?.db ?? pgConfig.connection;
|
|
15068
16218
|
};
|
|
15069
|
-
const
|
|
15070
|
-
const db = (driverResult
|
|
16219
|
+
const requireProvisioningDb = (driverResult) => {
|
|
16220
|
+
const db = provisioningDb(driverResult);
|
|
15071
16221
|
if (!db) throw new Error("Cannot provision the collection schema: this Postgres adapter was created without a `connection`, and no initialized driver was supplied to fall back on. Pass `connection` to `createPostgresAdapter` (see `createPostgresDatabaseConnection`).");
|
|
16222
|
+
return db;
|
|
16223
|
+
};
|
|
16224
|
+
const provisioningQueryable = (driverResult) => {
|
|
16225
|
+
const db = requireProvisioningDb(driverResult);
|
|
15072
16226
|
return { async query(text) {
|
|
15073
16227
|
return { rows: sqlRows(await db.execute(sql.raw(text))) };
|
|
15074
16228
|
} };
|
|
@@ -15081,14 +16235,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15081
16235
|
* Idempotent and cheap to repeat: `CREATE SCHEMA IF NOT EXISTS` plus a
|
|
15082
16236
|
* handful of `CREATE OR REPLACE FUNCTION`.
|
|
15083
16237
|
*/
|
|
15084
|
-
const provisionRlsRuntime = async (
|
|
15085
|
-
const
|
|
15086
|
-
await
|
|
15087
|
-
|
|
15088
|
-
|
|
15089
|
-
|
|
15090
|
-
|
|
15091
|
-
}
|
|
16238
|
+
const provisionRlsRuntime = async (driverResult) => {
|
|
16239
|
+
const db = requireProvisioningDb(driverResult);
|
|
16240
|
+
const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-B8EclDyM.js").then((n) => n.i);
|
|
16241
|
+
await db.transaction(async (tx) => {
|
|
16242
|
+
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
|
|
16243
|
+
for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
|
|
16244
|
+
});
|
|
15092
16245
|
};
|
|
15093
16246
|
return {
|
|
15094
16247
|
type: "postgres",
|
|
@@ -15476,7 +16629,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15476
16629
|
* proved it can bootstrap with.
|
|
15477
16630
|
*/
|
|
15478
16631
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
15479
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
16632
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-BEEjn5cn.js").then((n) => n.n);
|
|
15480
16633
|
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
15481
16634
|
for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
|
|
15482
16635
|
return { applied: plan.actions.length - plan.failures.length };
|
|
@@ -15507,13 +16660,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15507
16660
|
* as the auth path and the migration preamble, from the same constant.
|
|
15508
16661
|
*/
|
|
15509
16662
|
async ensureRlsRuntime(driverResult) {
|
|
15510
|
-
await provisionRlsRuntime(
|
|
16663
|
+
await provisionRlsRuntime(driverResult);
|
|
15511
16664
|
},
|
|
15512
16665
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
15513
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
16666
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-RHUcEp4v.js");
|
|
15514
16667
|
const queryable = provisioningQueryable(driverResult);
|
|
15515
16668
|
const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
|
|
15516
|
-
if (!rows[0]?.present) await provisionRlsRuntime(
|
|
16669
|
+
if (!rows[0]?.present) await provisionRlsRuntime(driverResult);
|
|
15517
16670
|
const outcome = await ensureCollectionPolicies(queryable, collections, log);
|
|
15518
16671
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
15519
16672
|
for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`);
|
|
@@ -15521,7 +16674,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15521
16674
|
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.`);
|
|
15522
16675
|
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.`);
|
|
15523
16676
|
try {
|
|
15524
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
16677
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-B8EclDyM.js").then((n) => n.i);
|
|
15525
16678
|
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
15526
16679
|
info: (m) => logger.info(m),
|
|
15527
16680
|
warn: (m) => logger.warn(m)
|
|
@@ -15599,9 +16752,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15599
16752
|
return driverResult.internals.driver.admin;
|
|
15600
16753
|
},
|
|
15601
16754
|
mountRoutes(app, basePath, driverResult) {},
|
|
15602
|
-
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
16755
|
+
async initializeWebsockets(server, realtimeService, driver, config, adapter, limits) {
|
|
15603
16756
|
const { createPostgresWebSocket } = await Promise.resolve().then(() => websocket_exports);
|
|
15604
|
-
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
16757
|
+
createPostgresWebSocket(server, realtimeService, driver, config, adapter, limits);
|
|
15605
16758
|
}
|
|
15606
16759
|
};
|
|
15607
16760
|
}
|
|
@@ -15626,8 +16779,8 @@ function createPostgresAdapter(pgConfig) {
|
|
|
15626
16779
|
async initializeHistory(config, driverResult) {
|
|
15627
16780
|
if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
|
|
15628
16781
|
},
|
|
15629
|
-
initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
15630
|
-
if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter);
|
|
16782
|
+
initializeWebsockets(server, realtimeService, driver, config, adapter, limits) {
|
|
16783
|
+
if (bootstrapper.initializeWebsockets) return bootstrapper.initializeWebsockets(server, realtimeService, driver, config, adapter, limits);
|
|
15631
16784
|
},
|
|
15632
16785
|
verifyConnection: bootstrapper.verifyConnection ? (driverResult) => bootstrapper.verifyConnection(driverResult) : void 0,
|
|
15633
16786
|
ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,
|