@rebasepro/server-postgres 0.21.2-canary.g1ea48be → 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-CkjsJEcu.js → backup-cli-24v4OlSp.js} +17 -14
- package/dist/backup-cli-24v4OlSp.js.map +1 -0
- package/dist/{backup-service-BNLwvxuy.js → backup-service-HQ9GC4tN.js} +2 -2
- package/dist/{backup-service-BNLwvxuy.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-Gzzd8wTq.js → doctor-Cb2thZ8s.js} +8 -7
- package/dist/doctor-Cb2thZ8s.js.map +1 -0
- package/dist/{ensure-collection-policies-B4IDFtQ-.js → ensure-collection-policies-RHUcEp4v.js} +5 -5
- package/dist/{ensure-collection-policies-B4IDFtQ-.js.map → ensure-collection-policies-RHUcEp4v.js.map} +1 -1
- package/dist/{ensure-collection-tables-Cr2ye5JC.js → ensure-collection-tables-BEEjn5cn.js} +53 -22
- package/dist/ensure-collection-tables-BEEjn5cn.js.map +1 -0
- package/dist/{ensure-tables-BnEvEJPr.js → ensure-tables-Dhn9KM3B.js} +2 -2
- package/dist/{ensure-tables-BnEvEJPr.js.map → ensure-tables-Dhn9KM3B.js.map} +1 -1
- package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js → generate-drizzle-schema-logic-BfzK7UQd.js} +2 -2
- package/dist/{generate-drizzle-schema-logic-DFa9qy9u.js.map → generate-drizzle-schema-logic-BfzK7UQd.js.map} +1 -1
- package/dist/{generate-drizzle-schema-D2MDdJt-.js → generate-drizzle-schema-yzY_BLhr.js} +2 -2
- package/dist/{generate-drizzle-schema-D2MDdJt-.js.map → generate-drizzle-schema-yzY_BLhr.js.map} +1 -1
- package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js → generate-postgres-ddl-logic-Bt2d2mRH.js} +2 -2
- package/dist/{generate-postgres-ddl-logic-CR2xcS7e.js.map → generate-postgres-ddl-logic-Bt2d2mRH.js.map} +1 -1
- package/dist/index.es.js +1882 -392
- 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-Hgl62S-w.js → plan-schema-C0fxM8dY.js} +170 -38
- package/dist/plan-schema-C0fxM8dY.js.map +1 -0
- package/dist/{policy-drift-B0GDRh7_.js → policy-drift-DljYdrpW.js} +2 -2
- package/dist/{policy-drift-B0GDRh7_.js.map → policy-drift-DljYdrpW.js.map} +1 -1
- package/dist/{rls-bootstrap-sql-BHA6jLtj.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/schema/search-column.d.ts +21 -1
- package/dist/services/FetchService.d.ts +147 -30
- package/dist/services/PersistService.d.ts +56 -5
- package/dist/services/RelationService.d.ts +42 -1
- package/dist/services/RelationWriteService.d.ts +22 -1
- package/dist/services/cdc/CdcListener.d.ts +6 -1
- package/dist/services/collection-helpers.d.ts +11 -0
- package/dist/services/dataService.d.ts +19 -21
- 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/read-scope.d.ts +100 -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 +63 -8
- package/dist/websocket.d.ts +10 -3
- package/package.json +9 -9
- package/dist/backup-cli-CkjsJEcu.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-Gzzd8wTq.js.map +0 -1
- package/dist/ensure-collection-tables-Cr2ye5JC.js.map +0 -1
- package/dist/introspect-db-logic-C6LQdTxj.js.map +0 -1
- package/dist/plan-schema-Hgl62S-w.js.map +0 -1
- package/dist/rls-bootstrap-sql-BHA6jLtj.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
|
|
@@ -408,6 +427,15 @@ function deriveRowAddress(row, collection, registry) {
|
|
|
408
427
|
*/
|
|
409
428
|
var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
|
|
410
429
|
/**
|
|
430
|
+
* What a substring-only hit contributes to `_score` under
|
|
431
|
+
* {@link SearchMode} `"hybrid"`.
|
|
432
|
+
*
|
|
433
|
+
* Below the smallest `ts_rank` a real lexeme match can produce (a single
|
|
434
|
+
* D-weighted hit is 0.1 × 0.0607927 ≈ 0.006), so a row found by the indexed
|
|
435
|
+
* half always outranks one found only by substring.
|
|
436
|
+
*/
|
|
437
|
+
var HYBRID_SUBSTRING_SCORE = .001;
|
|
438
|
+
/**
|
|
411
439
|
* A user's search term, made safe to drop inside a `%…%` LIKE pattern.
|
|
412
440
|
*
|
|
413
441
|
* The term is already a bind parameter, so this is not about injection. It is
|
|
@@ -1693,13 +1721,20 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1693
1721
|
* - **Declared** — one `@@ websearch_to_tsquery` against the generated
|
|
1694
1722
|
* `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
|
|
1695
1723
|
* inside JSONB and arrays, and uses the GIN index.
|
|
1696
|
-
* - **Not declared** —
|
|
1697
|
-
* string properties, with
|
|
1698
|
-
* so it is matched as the literal text
|
|
1724
|
+
* - **Not declared** — `ILIKE '%term%'` per term, OR-ed across top-level
|
|
1725
|
+
* string properties and AND-ed across the terms, with each term escaped
|
|
1726
|
+
* (see {@link escapeLikePattern}) so it is matched as the literal text
|
|
1727
|
+
* the user typed.
|
|
1728
|
+
*
|
|
1729
|
+
* The second is the default and stays the default, and it AND-es terms for
|
|
1730
|
+
* the same reason `websearch_to_tsquery` does: a person typing two words
|
|
1731
|
+
* into a search box means both, and a name lives in two columns. Matching
|
|
1732
|
+
* the whole string per column instead — which is what this did — cannot
|
|
1733
|
+
* find `sebastian melendez` on a row with `first_name`/`last_name`, and
|
|
1734
|
+
* could not find `sebastian ` at all.
|
|
1699
1735
|
*
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
* branch existed, which is the only reason it is safe to have added it.
|
|
1736
|
+
* Returns at most one condition. Every caller ORs what it gets back, which
|
|
1737
|
+
* is right across columns and wrong across terms, so the AND is built here.
|
|
1703
1738
|
*
|
|
1704
1739
|
* `collection` is optional so that the callers which genuinely have no
|
|
1705
1740
|
* collection in hand — nested paths, derived views — keep working; without
|
|
@@ -1711,16 +1746,24 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1711
1746
|
if (ftsCondition) return [ftsCondition];
|
|
1712
1747
|
let declaredStringProperties = 0;
|
|
1713
1748
|
const viewer = currentFieldViewer();
|
|
1749
|
+
const searchableColumns = [];
|
|
1714
1750
|
for (const [key, prop] of Object.entries(properties)) {
|
|
1715
1751
|
const p = prop;
|
|
1716
1752
|
if (!canReadField(prop, viewer)) continue;
|
|
1717
1753
|
if (p.type === "string" && !p.enum && p.isId !== "uuid") {
|
|
1718
1754
|
declaredStringProperties++;
|
|
1719
1755
|
const fieldColumn = table[key];
|
|
1720
|
-
if (fieldColumn && supportsILike(fieldColumn))
|
|
1756
|
+
if (fieldColumn && supportsILike(fieldColumn)) searchableColumns.push(fieldColumn);
|
|
1721
1757
|
}
|
|
1722
1758
|
}
|
|
1723
|
-
if (declaredStringProperties > 0 &&
|
|
1759
|
+
if (declaredStringProperties > 0 && searchableColumns.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
|
|
1760
|
+
if (searchableColumns.length === 0) return [];
|
|
1761
|
+
const terms = splitSearchTerms(searchString);
|
|
1762
|
+
const perTerm = (terms.length > 0 ? terms : [searchString]).map((term) => {
|
|
1763
|
+
const pattern = `%${escapeLikePattern(term)}%`;
|
|
1764
|
+
return DrizzleConditionBuilder.combineConditionsWithOr(searchableColumns.map((column) => ilike(column, pattern)));
|
|
1765
|
+
});
|
|
1766
|
+
searchConditions.push(DrizzleConditionBuilder.combineConditionsWithAnd(perTerm));
|
|
1724
1767
|
return searchConditions;
|
|
1725
1768
|
}
|
|
1726
1769
|
/**
|
|
@@ -1748,12 +1791,66 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1748
1791
|
const column = table[spec.column];
|
|
1749
1792
|
if (!column) return;
|
|
1750
1793
|
const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
|
|
1751
|
-
|
|
1794
|
+
const substring = spec.mode === "hybrid" ? DrizzleConditionBuilder.buildFoldedSubstringCondition(searchString, spec) : void 0;
|
|
1795
|
+
const matched = substring ? sql`(${exact} OR ${substring})` : exact;
|
|
1796
|
+
if (!spec.fuzzy) return matched;
|
|
1752
1797
|
const fuzzyColumn = table[spec.fuzzy.column];
|
|
1753
|
-
if (!fuzzyColumn) return
|
|
1798
|
+
if (!fuzzyColumn) return matched;
|
|
1754
1799
|
const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
|
|
1755
1800
|
const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
|
|
1756
|
-
return sql`(${
|
|
1801
|
+
return sql`(${matched} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* The substring half of {@link SearchMode} `"hybrid"`: every term found
|
|
1805
|
+
* somewhere in the declared fields, with accents folded on both sides.
|
|
1806
|
+
*
|
|
1807
|
+
* Terms are AND-ed and fields OR-ed, exactly as the ILIKE default does and
|
|
1808
|
+
* for the same reason — a person typing two words means both, and a name
|
|
1809
|
+
* lives in two columns (`splitSearchTerms`). What it does *not* share with
|
|
1810
|
+
* the default is its reach: these are the declared `search.fields`, so it
|
|
1811
|
+
* folds accents, joins `text[]` and walks into JSONB through the same
|
|
1812
|
+
* IMMUTABLE helpers the generated column uses.
|
|
1813
|
+
*
|
|
1814
|
+
* The needle is folded with the same function as the document, so
|
|
1815
|
+
* `munoz` and `Muñoz` meet in the middle rather than one side being
|
|
1816
|
+
* normalized and the other not — the subtle way to build a search that
|
|
1817
|
+
* matches nothing.
|
|
1818
|
+
*
|
|
1819
|
+
* Interpolated as raw SQL because the field expressions are generated (they
|
|
1820
|
+
* name columns and helper functions, which are identifiers, not values);
|
|
1821
|
+
* the *term* is a bind parameter, escaped for LIKE first — see
|
|
1822
|
+
* {@link escapeLikePattern} for what that escape is for.
|
|
1823
|
+
*/
|
|
1824
|
+
static buildFoldedSubstringCondition(searchString, spec) {
|
|
1825
|
+
if (spec.fields.length === 0) return void 0;
|
|
1826
|
+
const perTerm = DrizzleConditionBuilder.foldedNeedles(searchString).map((needle) => DrizzleConditionBuilder.combineConditionsWithOr(spec.fields.map((f) => sql`${sql.raw(f.foldedTextSql)} ILIKE ${needle}`)));
|
|
1827
|
+
return DrizzleConditionBuilder.combineConditionsWithAnd(perTerm);
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* Whether one text expression holds **any** term of the search string.
|
|
1831
|
+
*
|
|
1832
|
+
* The per-field question, where {@link buildFoldedSubstringCondition} asks
|
|
1833
|
+
* the per-row one: `OR` across terms rather than `AND`, because a two-word
|
|
1834
|
+
* query satisfied across two fields has each field carrying one term. Used
|
|
1835
|
+
* by `_matches` to name the fields that caused a substring hit.
|
|
1836
|
+
*/
|
|
1837
|
+
static foldedSubstringOf(searchString, text) {
|
|
1838
|
+
return DrizzleConditionBuilder.combineConditionsWithOr(DrizzleConditionBuilder.foldedNeedles(searchString).map((needle) => sql`${text} ILIKE ${needle}`));
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* One accent-folded `%term%` bind parameter per search term.
|
|
1842
|
+
*
|
|
1843
|
+
* The needle is folded with the same function as the document, so `munoz`
|
|
1844
|
+
* and `Muñoz` meet in the middle instead of one side being normalized and
|
|
1845
|
+
* the other not — the subtle way to build a search that matches nothing.
|
|
1846
|
+
*
|
|
1847
|
+
* A string with no terms at all is only whitespace, and stays the single
|
|
1848
|
+
* literal pattern it is on the ILIKE path rather than becoming "no rows",
|
|
1849
|
+
* so both paths answer a stray space the same way.
|
|
1850
|
+
*/
|
|
1851
|
+
static foldedNeedles(searchString) {
|
|
1852
|
+
const terms = splitSearchTerms(searchString);
|
|
1853
|
+
return (terms.length > 0 ? terms : [searchString]).map((term) => sql`${sql.raw(SEARCH_UNACCENT_FN)}(${`%${escapeLikePattern(term)}%`})`);
|
|
1757
1854
|
}
|
|
1758
1855
|
/**
|
|
1759
1856
|
* `websearch_to_tsquery(<config>, <normalized search string>)`.
|
|
@@ -1800,17 +1897,18 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1800
1897
|
if (!table[spec.column]) return void 0;
|
|
1801
1898
|
const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
|
|
1802
1899
|
const config = sql`${spec.language}`;
|
|
1803
|
-
const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
|
|
1900
|
+
const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)}, ${sql.raw(f.foldedTextSql)})`);
|
|
1901
|
+
const matchedField = spec.mode === "hybrid" ? sql`(to_tsvector(${config}::regconfig, f.txt) @@ ${query} OR ${DrizzleConditionBuilder.foldedSubstringOf(searchString, sql`f.folded`)})` : sql`to_tsvector(${config}::regconfig, f.txt) @@ ${query}`;
|
|
1804
1902
|
return sql`(
|
|
1805
1903
|
SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
|
|
1806
|
-
FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
|
|
1904
|
+
FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt, folded)
|
|
1807
1905
|
CROSS JOIN LATERAL (
|
|
1808
1906
|
SELECT jsonb_build_object(
|
|
1809
1907
|
'field', f.path,
|
|
1810
1908
|
'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
|
|
1811
1909
|
'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
|
|
1812
1910
|
) AS m
|
|
1813
|
-
WHERE
|
|
1911
|
+
WHERE ${matchedField}
|
|
1814
1912
|
) s
|
|
1815
1913
|
)`;
|
|
1816
1914
|
}
|
|
@@ -1828,18 +1926,27 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1828
1926
|
if (!spec) return void 0;
|
|
1829
1927
|
const column = table[spec.column];
|
|
1830
1928
|
if (!column) return void 0;
|
|
1831
|
-
const
|
|
1929
|
+
const base = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
|
|
1930
|
+
const rank = spec.mode === "hybrid" ? (() => {
|
|
1931
|
+
const substring = DrizzleConditionBuilder.buildFoldedSubstringCondition(searchString, spec);
|
|
1932
|
+
return substring ? sql`(${base} + CASE WHEN ${substring} THEN ${sql.raw(`${HYBRID_SUBSTRING_SCORE}::real`)} ELSE 0 END)` : base;
|
|
1933
|
+
})() : base;
|
|
1832
1934
|
if (!spec.fuzzy) return rank;
|
|
1833
1935
|
const fuzzyColumn = table[spec.fuzzy.column];
|
|
1834
1936
|
if (!fuzzyColumn) return rank;
|
|
1835
1937
|
return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
|
|
1836
1938
|
}
|
|
1837
1939
|
/**
|
|
1838
|
-
* 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.
|
|
1839
1946
|
*/
|
|
1840
|
-
static buildUniqueFieldCondition(fieldColumn, value,
|
|
1947
|
+
static buildUniqueFieldCondition(fieldColumn, value, excludeRow) {
|
|
1841
1948
|
const conditions = [eq(fieldColumn, value)];
|
|
1842
|
-
if (
|
|
1949
|
+
if (excludeRow) conditions.push(not(excludeRow));
|
|
1843
1950
|
return conditions;
|
|
1844
1951
|
}
|
|
1845
1952
|
/**
|
|
@@ -2116,6 +2223,11 @@ function toRestValues(row, collection) {
|
|
|
2116
2223
|
* an `update` that echoes the row back overwrite the real value with the null it
|
|
2117
2224
|
* was handed.
|
|
2118
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
|
+
*
|
|
2119
2231
|
* `_matches` is filtered rather than deleted: it is the list of fields a text
|
|
2120
2232
|
* search hit, and a field the caller cannot read must not appear in it even
|
|
2121
2233
|
* though the array itself is theirs to see.
|
|
@@ -2125,13 +2237,22 @@ function stripUnreadable(row, collection) {
|
|
|
2125
2237
|
if (!properties) return row;
|
|
2126
2238
|
const viewer = currentFieldViewer();
|
|
2127
2239
|
const hidden = /* @__PURE__ */ new Set();
|
|
2240
|
+
const hide = (name) => {
|
|
2241
|
+
hidden.add(name);
|
|
2242
|
+
delete row[name];
|
|
2243
|
+
};
|
|
2244
|
+
let relations;
|
|
2128
2245
|
for (const [key, property] of Object.entries(properties)) {
|
|
2129
2246
|
if (canReadField(property, viewer)) continue;
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
if (property.
|
|
2133
|
-
|
|
2134
|
-
|
|
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
|
+
}
|
|
2135
2256
|
}
|
|
2136
2257
|
}
|
|
2137
2258
|
if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
|
|
@@ -2336,8 +2457,10 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
|
2336
2457
|
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
2337
2458
|
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
2338
2459
|
* the links they were never shown. The select that drives the diff runs in
|
|
2339
|
-
* this same transaction under the same policies,
|
|
2340
|
-
*
|
|
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.
|
|
2341
2464
|
* - **Junction payload columns.** A junction carrying its own columns
|
|
2342
2465
|
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
2343
2466
|
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
@@ -2360,8 +2483,8 @@ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
|
2360
2483
|
* that is what keeps a plain `[1, 2, 3]` membership write from wiping the
|
|
2361
2484
|
* `role`s off the links it did not mention.
|
|
2362
2485
|
*/
|
|
2363
|
-
async function applyJunctionMembership(tx, binding, parentId, links) {
|
|
2364
|
-
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));
|
|
2365
2488
|
const existingById = /* @__PURE__ */ new Map();
|
|
2366
2489
|
for (const row of existingRows) {
|
|
2367
2490
|
if (row.targetId === null || row.targetId === void 0) continue;
|
|
@@ -2420,6 +2543,8 @@ async function removeLinks(tx, binding, parentId, removed) {
|
|
|
2420
2543
|
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2421
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");
|
|
2422
2545
|
}
|
|
2546
|
+
//#endregion
|
|
2547
|
+
//#region src/services/soft-delete.ts
|
|
2423
2548
|
/**
|
|
2424
2549
|
* The soft-delete field this collection uses, or `undefined` if it has none.
|
|
2425
2550
|
*
|
|
@@ -2429,9 +2554,8 @@ async function removeLinks(tx, binding, parentId, removed) {
|
|
|
2429
2554
|
* worth avoiding — deletes would silently start removing rows.
|
|
2430
2555
|
*/
|
|
2431
2556
|
function resolveSoftDelete(collection) {
|
|
2432
|
-
const
|
|
2433
|
-
if (!
|
|
2434
|
-
const field = typeof declared === "object" && declared.field || "deletedAt";
|
|
2557
|
+
const field = softDeleteFieldOf(collection);
|
|
2558
|
+
if (!field) return void 0;
|
|
2435
2559
|
return {
|
|
2436
2560
|
field,
|
|
2437
2561
|
columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
|
|
@@ -2488,6 +2612,128 @@ function andSoftDelete(where, collection, table, withDeleted) {
|
|
|
2488
2612
|
return where ? and(where, condition) : condition;
|
|
2489
2613
|
}
|
|
2490
2614
|
//#endregion
|
|
2615
|
+
//#region src/services/read-scope.ts
|
|
2616
|
+
/**
|
|
2617
|
+
* `beforeQuery`: the one place a collection callback can change *which* rows a
|
|
2618
|
+
* read asks for.
|
|
2619
|
+
*
|
|
2620
|
+
* `afterRead` runs over rows that have already been fetched, so until this
|
|
2621
|
+
* existed an application developer who needed the read itself narrowed — a
|
|
2622
|
+
* tenant scope, a visibility window, a per-role row filter — had two options:
|
|
2623
|
+
* patch this driver, or `rebase eject`.
|
|
2624
|
+
*
|
|
2625
|
+
* Two properties are load-bearing, and both belong to the *shape* of the thing
|
|
2626
|
+
* rather than to how carefully each call site was written.
|
|
2627
|
+
*
|
|
2628
|
+
* ## It can only narrow
|
|
2629
|
+
*
|
|
2630
|
+
* A hook returns a {@link QueryNarrowing} — a declarative filter, optionally a
|
|
2631
|
+
* logical group — which is AND-ed into the query the caller sent. `AND(q, c)`
|
|
2632
|
+
* is a subset of `q` for every `c`, so there is no `c` a hook can return that
|
|
2633
|
+
* widens a read. The obvious alternative shape, `(query) => query`, can: a
|
|
2634
|
+
* hook that drops a condition on the way through runs the query without it,
|
|
2635
|
+
* and on an RLS data plane a dropped condition returns everything the policies
|
|
2636
|
+
* happen to allow. That is the same reasoning behind
|
|
2637
|
+
* `UnknownFilterFieldsMode` defaulting to `"error"`, and it is why that mode is
|
|
2638
|
+
* **forced** to `"error"` for a hook's own filter here whatever the
|
|
2639
|
+
* process-wide setting is: a scope condition naming a renamed column has to
|
|
2640
|
+
* refuse the request, never be dropped from it.
|
|
2641
|
+
*
|
|
2642
|
+
* ## It cannot be skipped
|
|
2643
|
+
*
|
|
2644
|
+
* The hooks are resolved *by the read path itself*, out of the registry that
|
|
2645
|
+
* read already holds — not handed to it by a caller who might forget. So a
|
|
2646
|
+
* read that compiles a WHERE compiles this one too, or it throws.
|
|
2647
|
+
*
|
|
2648
|
+
* The one thing a read path cannot derive is the identity the hook runs as:
|
|
2649
|
+
* `FetchService`, `RelationService` and `DataService` carry no user. That
|
|
2650
|
+
* arrives as a {@link ReadCallContextProvider}, threaded from the driver that
|
|
2651
|
+
* constructed them — the driver is the only object that knows both the caller
|
|
2652
|
+
* and the connection, and it constructs a service per transaction anyway, so
|
|
2653
|
+
* there is exactly one place per instance to pass it.
|
|
2654
|
+
*
|
|
2655
|
+
* Absence is the interesting case, and it fails closed: a service built with no
|
|
2656
|
+
* provider refuses any read of a collection that declares `beforeQuery`, with
|
|
2657
|
+
* a message naming the collection. So a future construction site that forgets
|
|
2658
|
+
* the provider breaks loudly on the first scoped collection instead of quietly
|
|
2659
|
+
* serving every row — which is the failure mode this whole module exists to
|
|
2660
|
+
* rule out. A deployment with no `beforeQuery` anywhere never reaches any of
|
|
2661
|
+
* this: the check is one map lookup and the compiled SQL is byte-identical.
|
|
2662
|
+
*
|
|
2663
|
+
* @module
|
|
2664
|
+
*/
|
|
2665
|
+
/**
|
|
2666
|
+
* The `beforeQuery` hooks that apply to one collection, in the order they run.
|
|
2667
|
+
*
|
|
2668
|
+
* Global first, then the collection's own — the same order `afterRead` and
|
|
2669
|
+
* `beforeSave` use. The order changes nothing about the outcome here (both are
|
|
2670
|
+
* AND-ed in, so neither can relax the other) and is kept because a hook that
|
|
2671
|
+
* logs or measures should see the same sequence everywhere.
|
|
2672
|
+
*
|
|
2673
|
+
* There is no property tier: a property callback shapes one value on a row, and
|
|
2674
|
+
* a row filter is not a value.
|
|
2675
|
+
*/
|
|
2676
|
+
function resolveBeforeQuery(registry, collection) {
|
|
2677
|
+
const hooks = [];
|
|
2678
|
+
const global = registry?.getGlobalCallbacks();
|
|
2679
|
+
if (global?.beforeQuery) hooks.push(global.beforeQuery);
|
|
2680
|
+
const own = collection?.callbacks;
|
|
2681
|
+
if (own?.beforeQuery) hooks.push(own.beforeQuery);
|
|
2682
|
+
return hooks;
|
|
2683
|
+
}
|
|
2684
|
+
/** Whether anything at all would run for this collection. */
|
|
2685
|
+
function hasBeforeQuery(registry, collection) {
|
|
2686
|
+
return resolveBeforeQuery(registry, collection).length > 0;
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* The conditions every `beforeQuery` hook for this collection asks to add.
|
|
2690
|
+
*
|
|
2691
|
+
* Returned as a list rather than pre-combined so a caller pushes them onto the
|
|
2692
|
+
* same `allConditions` array its own filter goes into. Every caller AND-es
|
|
2693
|
+
* that array, which is what makes these additive — the hook is not trusted to
|
|
2694
|
+
* narrow, it is only ever given a way to.
|
|
2695
|
+
*/
|
|
2696
|
+
async function beforeQueryConditions(env, collection, path, table, description, filterContext) {
|
|
2697
|
+
const hooks = resolveBeforeQuery(env.registry, collection);
|
|
2698
|
+
if (hooks.length === 0) return [];
|
|
2699
|
+
if (!collection) throw new Error(`[beforeQuery] "${path}" resolves to no registered collection, so a global \`beforeQuery\` cannot be compiled against it. Register the collection, or narrow the hook to the collections it applies to.`);
|
|
2700
|
+
if (!env.callContext) throw new Error(`[beforeQuery] "${collection.slug}" declares a \`beforeQuery\` hook, and this read was compiled by a service that was built without a call-context provider, so the hook could not run. Serving the read unnarrowed is not an option — a hook that does not run widens the result set. Construct the service through the driver (which passes one), or pass a provider explicitly.`);
|
|
2701
|
+
const context = env.callContext();
|
|
2702
|
+
const unknownFields = "error";
|
|
2703
|
+
const options = {
|
|
2704
|
+
...filterContext,
|
|
2705
|
+
unknownFields
|
|
2706
|
+
};
|
|
2707
|
+
const conditions = [];
|
|
2708
|
+
for (const hook of hooks) {
|
|
2709
|
+
const narrowing = await hook({
|
|
2710
|
+
collection,
|
|
2711
|
+
path,
|
|
2712
|
+
operation: description.operation,
|
|
2713
|
+
query: description.query,
|
|
2714
|
+
context
|
|
2715
|
+
});
|
|
2716
|
+
if (!narrowing) continue;
|
|
2717
|
+
if (!narrowing.filter && !narrowing.logical) throw new Error(`[beforeQuery] "${collection.slug}" returned an object with neither \`filter\` nor \`logical\`. Return nothing (or \`undefined\`) to add no conditions; an empty object reads as a scope that was meant to be built and was not.`);
|
|
2718
|
+
if (narrowing.filter) conditions.push(...DrizzleConditionBuilder.buildFilterConditions(narrowing.filter, table, path, options));
|
|
2719
|
+
if (narrowing.logical) {
|
|
2720
|
+
const logical = DrizzleConditionBuilder.buildLogicalConditions(narrowing.logical, table, path, options);
|
|
2721
|
+
if (logical) conditions.push(logical);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
return conditions;
|
|
2725
|
+
}
|
|
2726
|
+
/**
|
|
2727
|
+
* {@link beforeQueryConditions}, pre-combined, for the read paths that hold a
|
|
2728
|
+
* single `SQL` rather than a list of them — a single get, a relation loader.
|
|
2729
|
+
*/
|
|
2730
|
+
async function beforeQueryCondition(env, collection, path, table, description, filterContext) {
|
|
2731
|
+
const conditions = await beforeQueryConditions(env, collection, path, table, description, filterContext);
|
|
2732
|
+
if (conditions.length === 0) return void 0;
|
|
2733
|
+
if (conditions.length === 1) return conditions[0];
|
|
2734
|
+
return and(...conditions);
|
|
2735
|
+
}
|
|
2736
|
+
//#endregion
|
|
2491
2737
|
//#region src/services/RelationService.ts
|
|
2492
2738
|
/**
|
|
2493
2739
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
@@ -2522,6 +2768,17 @@ function narrowed(base, narrow) {
|
|
|
2522
2768
|
return narrow ? and(base, narrow) : base;
|
|
2523
2769
|
}
|
|
2524
2770
|
/**
|
|
2771
|
+
* Two optional conditions, AND-ed: either, both, or neither.
|
|
2772
|
+
*
|
|
2773
|
+
* The include's own `where` and the target collection's `beforeQuery` arrive
|
|
2774
|
+
* separately and are both optional, and they have to reach the loaders as one
|
|
2775
|
+
* condition because that is the shape every relation kind threads through.
|
|
2776
|
+
*/
|
|
2777
|
+
function narrowBoth(a, b) {
|
|
2778
|
+
if (a && b) return and(a, b);
|
|
2779
|
+
return a ?? b;
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2525
2782
|
* The registry has no table for a collection a relation just asked about.
|
|
2526
2783
|
*
|
|
2527
2784
|
* "Parent table not found" was the whole message, four times over, and it names
|
|
@@ -2538,9 +2795,58 @@ function unregisteredTable(collectionSlug, table, role) {
|
|
|
2538
2795
|
var RelationService = class {
|
|
2539
2796
|
db;
|
|
2540
2797
|
registry;
|
|
2541
|
-
|
|
2798
|
+
callContext;
|
|
2799
|
+
constructor(db, registry, callContext) {
|
|
2542
2800
|
this.db = db;
|
|
2543
2801
|
this.registry = registry;
|
|
2802
|
+
this.callContext = callContext;
|
|
2803
|
+
}
|
|
2804
|
+
/**
|
|
2805
|
+
* The **target** collection's `beforeQuery` narrowing, for rows reached
|
|
2806
|
+
* through a relation.
|
|
2807
|
+
*
|
|
2808
|
+
* The target's, not the parent's, because these are the target's rows: a
|
|
2809
|
+
* scope on `comments` has to hold whether they are listed at
|
|
2810
|
+
* `/comments` or loaded as `post.comments`. Without this an `include` is a
|
|
2811
|
+
* way around every row filter a collection declares — the exact hole
|
|
2812
|
+
* `stripUnreadable` was once missing from the relation-ref branch.
|
|
2813
|
+
*
|
|
2814
|
+
* Folded into the `narrow` condition the loaders already thread through
|
|
2815
|
+
* every relation kind, so a `via` join, a junction, a foreign key and the
|
|
2816
|
+
* dynamic relation builder all apply it or none of them do.
|
|
2817
|
+
*/
|
|
2818
|
+
narrowTargetRead(parentCollection, relation, parentId) {
|
|
2819
|
+
const targetCollection = relation.target();
|
|
2820
|
+
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
2821
|
+
return beforeQueryCondition({
|
|
2822
|
+
registry: this.registry,
|
|
2823
|
+
callContext: this.callContext
|
|
2824
|
+
}, targetCollection, targetCollection.slug, targetTable, {
|
|
2825
|
+
operation: "relation",
|
|
2826
|
+
query: { relatedTo: {
|
|
2827
|
+
parentSlug: parentCollection.slug,
|
|
2828
|
+
parentId,
|
|
2829
|
+
relationName: relation.relationName
|
|
2830
|
+
} }
|
|
2831
|
+
}, {
|
|
2832
|
+
collection: targetCollection,
|
|
2833
|
+
registry: this.registry
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
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);
|
|
2544
2850
|
}
|
|
2545
2851
|
/**
|
|
2546
2852
|
* One target row, as the {@link RelatedRow} everything here returns.
|
|
@@ -2601,11 +2907,11 @@ var RelationService = class {
|
|
|
2601
2907
|
return col;
|
|
2602
2908
|
};
|
|
2603
2909
|
if (parentPks.length === 1) {
|
|
2604
|
-
const values = parentIds.map((id) => parseIdValues(id, parentPks)[parentPks[0].fieldName]);
|
|
2910
|
+
const values = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentPks[0].fieldName]);
|
|
2605
2911
|
return inArray(columnFor(parentPks[0].fieldName), values);
|
|
2606
2912
|
}
|
|
2607
2913
|
return or(...parentIds.map((id) => {
|
|
2608
|
-
const values = parseIdValues(id, parentPks);
|
|
2914
|
+
const values = parseIdValues$1(id, parentPks);
|
|
2609
2915
|
return and(...parentPks.map((pk) => eq(columnFor(pk.fieldName), values[pk.fieldName])));
|
|
2610
2916
|
}));
|
|
2611
2917
|
}
|
|
@@ -2656,7 +2962,7 @@ var RelationService = class {
|
|
|
2656
2962
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2657
2963
|
if (!joinsOnNaturalKey(relation, parentCollection, this.registry)) {
|
|
2658
2964
|
for (const id of parentIds) {
|
|
2659
|
-
const value = parseIdValues(id, parentPks)[parentPks[0].fieldName];
|
|
2965
|
+
const value = parseIdValues$1(id, parentPks)[parentPks[0].fieldName];
|
|
2660
2966
|
keyByParentId.set(String(id), value);
|
|
2661
2967
|
parentIdByKey.set(String(value), id);
|
|
2662
2968
|
}
|
|
@@ -2706,11 +3012,12 @@ var RelationService = class {
|
|
|
2706
3012
|
const idField = targetTable[idInfo[0].fieldName];
|
|
2707
3013
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2708
3014
|
const parentIdInfo = parentPks[0];
|
|
2709
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
3015
|
+
const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
|
|
2710
3016
|
const parentTableName = getTableName$1(parentCollection);
|
|
2711
3017
|
const parentTable = this.registry.getTable(parentTableName);
|
|
2712
3018
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
2713
3019
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
3020
|
+
const relatedNarrowing = await this.narrowTargetRead(parentCollection, relation, parentId);
|
|
2714
3021
|
if (relation.kind === "via") {
|
|
2715
3022
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
2716
3023
|
let currentTable = parentTable;
|
|
@@ -2730,7 +3037,7 @@ var RelationService = class {
|
|
|
2730
3037
|
currentTable = joinTable;
|
|
2731
3038
|
}
|
|
2732
3039
|
const parentIdField = parentTable[requirePrimaryKeys(parentCollection, this.registry)[0].fieldName];
|
|
2733
|
-
query = query.where(eq(parentIdField, parsedParentId));
|
|
3040
|
+
query = query.where(narrowed(eq(parentIdField, parsedParentId), andSoftDelete(relatedNarrowing, targetCollection, targetTable)));
|
|
2734
3041
|
if (options.limit) query = query.limit(options.limit);
|
|
2735
3042
|
const results = await query;
|
|
2736
3043
|
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
@@ -2747,6 +3054,7 @@ var RelationService = class {
|
|
|
2747
3054
|
const additionalFilters = [];
|
|
2748
3055
|
const relatedSoftDelete = softDeleteCondition(targetCollection, targetTable);
|
|
2749
3056
|
if (relatedSoftDelete) additionalFilters.push(relatedSoftDelete);
|
|
3057
|
+
if (relatedNarrowing) additionalFilters.push(relatedNarrowing);
|
|
2750
3058
|
if (options.searchString) {
|
|
2751
3059
|
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
|
|
2752
3060
|
if (searchConditions.length === 0) return [];
|
|
@@ -2792,7 +3100,7 @@ var RelationService = class {
|
|
|
2792
3100
|
const targetIdField = targetTable[requirePrimaryKeys(targetCollection, this.registry)[0].fieldName];
|
|
2793
3101
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
2794
3102
|
const parentIdInfo = parentPks[0];
|
|
2795
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
3103
|
+
const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
|
|
2796
3104
|
const parentTableName = getTableName$1(parentCollection);
|
|
2797
3105
|
const parentTable = this.registry.getTable(parentTableName);
|
|
2798
3106
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
@@ -2801,6 +3109,8 @@ var RelationService = class {
|
|
|
2801
3109
|
if (matchValue === void 0) return 0;
|
|
2802
3110
|
const countSoftDelete = softDeleteCondition(targetCollection, targetTable);
|
|
2803
3111
|
if (countSoftDelete) additionalFilters = [...additionalFilters, countSoftDelete];
|
|
3112
|
+
const countNarrowing = await this.narrowTargetRead(parentCollection, relation, parentId);
|
|
3113
|
+
if (countNarrowing) additionalFilters = [...additionalFilters, countNarrowing];
|
|
2804
3114
|
let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
|
|
2805
3115
|
query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
|
|
2806
3116
|
const result = await query;
|
|
@@ -2817,7 +3127,7 @@ var RelationService = class {
|
|
|
2817
3127
|
async isRelated(hop, targetId) {
|
|
2818
3128
|
const targetTable = getTableForCollection(hop.targetCollection, this.registry);
|
|
2819
3129
|
const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
|
|
2820
|
-
const parsedTargetId = parseIdValues(targetId, targetPks);
|
|
3130
|
+
const parsedTargetId = parseIdValues$1(targetId, targetPks);
|
|
2821
3131
|
const identity = targetPks.map((pk) => {
|
|
2822
3132
|
const column = targetTable[pk.fieldName];
|
|
2823
3133
|
if (!column) throw new Error(`ID field '${pk.fieldName}' not found in table for collection '${hop.targetCollection.slug}'`);
|
|
@@ -2847,8 +3157,8 @@ var RelationService = class {
|
|
|
2847
3157
|
const parentTable = this.registry.getTable(parentTableName);
|
|
2848
3158
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
2849
3159
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
2850
|
-
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
2851
|
-
const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
|
|
3160
|
+
const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
|
|
3161
|
+
const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
|
|
2852
3162
|
if (relation.kind === "via") {
|
|
2853
3163
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
2854
3164
|
let currentTable = parentTable;
|
|
@@ -2951,8 +3261,8 @@ var RelationService = class {
|
|
|
2951
3261
|
const parentTable = this.registry.getTable(parentTableName);
|
|
2952
3262
|
if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
|
|
2953
3263
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
2954
|
-
const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
|
|
2955
|
-
const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
|
|
3264
|
+
const parsedParentIds = parentIds.map((id) => parseIdValues$1(id, parentPks)[parentIdInfo.fieldName]);
|
|
3265
|
+
const narrowTarget = andSoftDelete(narrowBoth(narrow, await this.narrowTargetRead(parentCollection, relation)), targetCollection, targetTable);
|
|
2956
3266
|
if (relation.kind === "via") {
|
|
2957
3267
|
let query = this.db.select().from(parentTable).$dynamic();
|
|
2958
3268
|
let currentTable = parentTable;
|
|
@@ -3652,11 +3962,13 @@ function numericAggregateAliases(aggregates, properties) {
|
|
|
3652
3962
|
var FetchService = class FetchService {
|
|
3653
3963
|
db;
|
|
3654
3964
|
registry;
|
|
3965
|
+
callContext;
|
|
3655
3966
|
relationService;
|
|
3656
|
-
constructor(db, registry) {
|
|
3967
|
+
constructor(db, registry, callContext) {
|
|
3657
3968
|
this.db = db;
|
|
3658
3969
|
this.registry = registry;
|
|
3659
|
-
this.
|
|
3970
|
+
this.callContext = callContext;
|
|
3971
|
+
this.relationService = new RelationService(db, registry, callContext);
|
|
3660
3972
|
}
|
|
3661
3973
|
/**
|
|
3662
3974
|
* Get the relational query builder for a given table name.
|
|
@@ -3690,6 +4002,63 @@ var FetchService = class FetchService {
|
|
|
3690
4002
|
};
|
|
3691
4003
|
}
|
|
3692
4004
|
/**
|
|
4005
|
+
* The conditions this collection's `beforeQuery` hooks ask to add, for a
|
|
4006
|
+
* read that is about to be compiled.
|
|
4007
|
+
*
|
|
4008
|
+
* Resolved here, from the registry this service already holds, rather than
|
|
4009
|
+
* handed in by a caller — so a read path that builds a WHERE cannot serve
|
|
4010
|
+
* one of these collections without the hook. `[]` when nothing is declared,
|
|
4011
|
+
* which leaves the compiled SQL byte-identical.
|
|
4012
|
+
*
|
|
4013
|
+
* Every caller pushes the result onto the same `allConditions` array its
|
|
4014
|
+
* own filter goes into, and every one of those arrays is AND-ed. That is
|
|
4015
|
+
* what keeps a hook additive: it is not trusted to narrow, it is only ever
|
|
4016
|
+
* given a way to.
|
|
4017
|
+
*/
|
|
4018
|
+
narrowRead(collectionPath, collection, table, operation, query) {
|
|
4019
|
+
return beforeQueryConditions({
|
|
4020
|
+
registry: this.registry,
|
|
4021
|
+
callContext: this.callContext
|
|
4022
|
+
}, collection, collectionPath, table, {
|
|
4023
|
+
operation,
|
|
4024
|
+
query
|
|
4025
|
+
}, this.filterContext(collectionPath, table));
|
|
4026
|
+
}
|
|
4027
|
+
/** {@link narrowRead}, pre-combined, for the reads that hold one `SQL`. */
|
|
4028
|
+
narrowReadCondition(collectionPath, collection, table, operation, query) {
|
|
4029
|
+
return beforeQueryCondition({
|
|
4030
|
+
registry: this.registry,
|
|
4031
|
+
callContext: this.callContext
|
|
4032
|
+
}, collection, collectionPath, table, {
|
|
4033
|
+
operation,
|
|
4034
|
+
query
|
|
4035
|
+
}, this.filterContext(collectionPath, table));
|
|
4036
|
+
}
|
|
4037
|
+
/**
|
|
4038
|
+
* The read the hooks are shown, from the options a read path was given.
|
|
4039
|
+
*
|
|
4040
|
+
* Transport is left out on purpose — a `databaseId`, an `include` tree, a
|
|
4041
|
+
* `withDeleted` flag are not part of "which rows". What is in is what a
|
|
4042
|
+
* scope might reasonably branch on.
|
|
4043
|
+
*/
|
|
4044
|
+
static describeRead(options) {
|
|
4045
|
+
const orderBy = normalizeDriverOrderBy(options.orderBy, options.order);
|
|
4046
|
+
return {
|
|
4047
|
+
filter: options.filter,
|
|
4048
|
+
logical: options.logical,
|
|
4049
|
+
searchString: options.searchString,
|
|
4050
|
+
limit: options.limit,
|
|
4051
|
+
offset: options.offset,
|
|
4052
|
+
orderBy: orderBy && orderBy.length > 0 ? orderBy : void 0,
|
|
4053
|
+
fields: options.fields,
|
|
4054
|
+
relatedTo: options.relatedTo ? {
|
|
4055
|
+
parentSlug: options.relatedTo.parentCollection.slug,
|
|
4056
|
+
parentId: options.relatedTo.parentId,
|
|
4057
|
+
relationName: options.relatedTo.relation.relationName
|
|
4058
|
+
} : void 0
|
|
4059
|
+
};
|
|
4060
|
+
}
|
|
4061
|
+
/**
|
|
3693
4062
|
* The table column this collection's rows are keyed by, or `undefined`.
|
|
3694
4063
|
*
|
|
3695
4064
|
* `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
|
|
@@ -3733,6 +4102,11 @@ var FetchService = class FetchService {
|
|
|
3733
4102
|
* rows in arbitrary order, and paging over that repeats and skips rows.
|
|
3734
4103
|
*/
|
|
3735
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";
|
|
3736
4110
|
resolveOrderTarget(table, orderBy, collection, searchString) {
|
|
3737
4111
|
if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
|
|
3738
4112
|
const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
|
|
@@ -3816,15 +4190,17 @@ var FetchService = class FetchService {
|
|
|
3816
4190
|
return key.direction === "asc";
|
|
3817
4191
|
}
|
|
3818
4192
|
/**
|
|
3819
|
-
* The full `ORDER BY`: the caller's keys, then the
|
|
4193
|
+
* The full `ORDER BY`: the caller's keys, then the primary key.
|
|
3820
4194
|
*
|
|
3821
|
-
* The
|
|
3822
|
-
* what makes the ordering *total*, and a cursor over
|
|
3823
|
-
* repeats and skips rows among the ties. Every keyset
|
|
3824
|
-
* {@link buildCursorConditions} ends on the same
|
|
3825
|
-
* to agree: they did not, and an ascending sort
|
|
3826
|
-
* `ORDER BY … , id DESC`, so rows sharing a
|
|
3827
|
-
* 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.
|
|
3828
4204
|
*
|
|
3829
4205
|
* Where the NULLs go is written out rather than inherited. Postgres already
|
|
3830
4206
|
* defaults to `NULLS LAST` ascending and `NULLS FIRST` descending, so this
|
|
@@ -3835,13 +4211,13 @@ var FetchService = class FetchService {
|
|
|
3835
4211
|
* relation reaches nothing from, which is precisely the "nobody waiting"
|
|
3836
4212
|
* end of a queue.
|
|
3837
4213
|
*/
|
|
3838
|
-
buildOrderExpressions(keys,
|
|
4214
|
+
buildOrderExpressions(keys, keyColumns) {
|
|
3839
4215
|
const expressions = keys.map((key) => {
|
|
3840
4216
|
const nullsLast = FetchService.nullsLast(key);
|
|
3841
4217
|
if (key.direction === "asc") return nullsLast ? sql`${key.target} ASC NULLS LAST` : sql`${key.target} ASC NULLS FIRST`;
|
|
3842
4218
|
return nullsLast ? sql`${key.target} DESC NULLS LAST` : sql`${key.target} DESC NULLS FIRST`;
|
|
3843
4219
|
});
|
|
3844
|
-
|
|
4220
|
+
for (const column of keyColumns) expressions.push(desc(column));
|
|
3845
4221
|
return expressions;
|
|
3846
4222
|
}
|
|
3847
4223
|
resolveOrderByField(table, orderBy, collection) {
|
|
@@ -3884,40 +4260,6 @@ var FetchService = class FetchService {
|
|
|
3884
4260
|
});
|
|
3885
4261
|
}
|
|
3886
4262
|
/**
|
|
3887
|
-
* Build the `with` config for Drizzle's relational query API.
|
|
3888
|
-
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
3889
|
-
*
|
|
3890
|
-
* When `include` is provided, only those relations are loaded.
|
|
3891
|
-
* When `include` is absent, ALL relations are loaded (the admin path).
|
|
3892
|
-
*
|
|
3893
|
-
* Automatically detects many-to-many junction tables and nests
|
|
3894
|
-
* the target relation so actual row data is returned.
|
|
3895
|
-
*/
|
|
3896
|
-
buildWithConfig(collection, include) {
|
|
3897
|
-
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3898
|
-
const withConfig = {};
|
|
3899
|
-
const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
|
|
3900
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
3901
|
-
if (!shouldInclude(key)) continue;
|
|
3902
|
-
const drizzleRelName = relation.relationName || key;
|
|
3903
|
-
if (relation.kind === "via") continue;
|
|
3904
|
-
if (relation.cardinality === "many" && isJunctionRelation(relation)) {
|
|
3905
|
-
const targetFkName = this.getJunctionTargetRelationName(relation, collection);
|
|
3906
|
-
if (targetFkName) withConfig[drizzleRelName] = { with: { [targetFkName]: true } };
|
|
3907
|
-
else withConfig[drizzleRelName] = true;
|
|
3908
|
-
} else withConfig[drizzleRelName] = true;
|
|
3909
|
-
}
|
|
3910
|
-
return withConfig;
|
|
3911
|
-
}
|
|
3912
|
-
/**
|
|
3913
|
-
* Get the Drizzle relation name on the junction table that points to the actual target row.
|
|
3914
|
-
* For example, for posts_tags junction, this returns "tag_id" (the relation pointing to tags).
|
|
3915
|
-
*/
|
|
3916
|
-
getJunctionTargetRelationName(relation, _collection) {
|
|
3917
|
-
if (isManyToMany(relation)) return relation.through.targetColumn.replace(/_id$/, "_id");
|
|
3918
|
-
return null;
|
|
3919
|
-
}
|
|
3920
|
-
/**
|
|
3921
4263
|
* The relations one level of an include tree names, resolved.
|
|
3922
4264
|
*
|
|
3923
4265
|
* A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
|
|
@@ -4145,36 +4487,49 @@ var FetchService = class FetchService {
|
|
|
4145
4487
|
*/
|
|
4146
4488
|
cursorFor(collectionPath, row, orderBy) {
|
|
4147
4489
|
if (orderBy?.some(([field]) => field === FetchService.SCORE_FIELD)) return void 0;
|
|
4490
|
+
if (FetchService.DISTANCE_FIELD in row) return void 0;
|
|
4148
4491
|
try {
|
|
4149
4492
|
const pks = getPrimaryKeys(getCollectionByPath(collectionPath, this.registry), this.registry);
|
|
4150
4493
|
if (pks.length === 0) return void 0;
|
|
4151
4494
|
const address = buildCompositeId$1(row, pks);
|
|
4152
4495
|
if (!address || !address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "")) return void 0;
|
|
4153
|
-
return encodeCursor(orderBy, row, row[pks[0].fieldName]);
|
|
4496
|
+
return encodeCursor(orderBy, row, pks.length === 1 ? row[pks[0].fieldName] : address);
|
|
4154
4497
|
} catch {
|
|
4155
4498
|
return;
|
|
4156
4499
|
}
|
|
4157
4500
|
}
|
|
4158
4501
|
/**
|
|
4159
|
-
*
|
|
4160
|
-
*
|
|
4161
|
-
*
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
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
|
|
4176
4522
|
});
|
|
4177
|
-
|
|
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
|
+
}
|
|
4178
4533
|
}
|
|
4179
4534
|
/**
|
|
4180
4535
|
* Extract cursor pagination conditions from startAfter options.
|
|
@@ -4184,26 +4539,102 @@ var FetchService = class FetchService {
|
|
|
4184
4539
|
* one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
|
|
4185
4540
|
* several it nests, each key's tie handing the decision to the next.
|
|
4186
4541
|
*/
|
|
4187
|
-
buildCursorConditions(table,
|
|
4542
|
+
buildCursorConditions(table, idInfoArray, options, collectionPath) {
|
|
4188
4543
|
if (!options.startAfter) return [];
|
|
4189
4544
|
const cursor = options.startAfter;
|
|
4190
4545
|
const keys = normalizeDriverOrderBy(options.orderBy, options.order);
|
|
4546
|
+
const keyColumns = FetchService.keyColumns(table, idInfoArray, collectionPath);
|
|
4547
|
+
const cursorKey = FetchService.cursorKey(cursor, idInfoArray, collectionPath);
|
|
4191
4548
|
if (keys) {
|
|
4192
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 });
|
|
4193
|
-
const collection =
|
|
4194
|
-
const
|
|
4195
|
-
|
|
4196
|
-
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) {
|
|
4197
4553
|
const cursorValues = cursor.values;
|
|
4198
4554
|
const values = resolved.map(({ field, cursorTarget }) => cursorTarget ? null : cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
|
|
4199
|
-
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
|
+
}
|
|
4200
4559
|
}
|
|
4201
|
-
} else
|
|
4202
|
-
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
4203
|
-
if (startAfterId !== void 0 && startAfterId !== null) return [lt(idField, parseIdValues(startAfterId, [idInfo])[idInfo.fieldName])];
|
|
4204
|
-
}
|
|
4560
|
+
} else if (cursorKey) return [FetchService.keyAfter(keyColumns, cursorKey)];
|
|
4205
4561
|
return [];
|
|
4206
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
|
+
}
|
|
4207
4638
|
/**
|
|
4208
4639
|
* "Sorts strictly after the cursor row", over `keys` and then the id.
|
|
4209
4640
|
*
|
|
@@ -4217,13 +4648,13 @@ var FetchService = class FetchService {
|
|
|
4217
4648
|
* NULL and therefore match nothing. Ordering by a nullable column and paging
|
|
4218
4649
|
* used to drop every row whose sort value was NULL from page two onward.
|
|
4219
4650
|
*/
|
|
4220
|
-
buildKeysetComparison(keys, values,
|
|
4221
|
-
if (index >= keys.length) return
|
|
4651
|
+
buildKeysetComparison(keys, values, keyColumns, cursorKey, index = 0) {
|
|
4652
|
+
if (index >= keys.length) return FetchService.keyAfter(keyColumns, cursorKey);
|
|
4222
4653
|
const { direction, cursorTarget } = keys[index];
|
|
4223
4654
|
const nullsLast = FetchService.nullsLast(keys[index]);
|
|
4224
4655
|
const target = keys[index].target;
|
|
4225
4656
|
const value = values[index];
|
|
4226
|
-
const rest = this.buildKeysetComparison(keys, values,
|
|
4657
|
+
const rest = this.buildKeysetComparison(keys, values, keyColumns, cursorKey, index + 1);
|
|
4227
4658
|
const after = (a, b) => direction === "asc" ? gt(a, b) : lt(a, b);
|
|
4228
4659
|
const afterSql = (a, b) => direction === "asc" ? sql`${a} > ${b}` : sql`${a} < ${b}`;
|
|
4229
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))));
|
|
@@ -4237,7 +4668,7 @@ var FetchService = class FetchService {
|
|
|
4237
4668
|
buildRelationScope(hop) {
|
|
4238
4669
|
const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
|
|
4239
4670
|
const parentIdInfo = parentPks[0];
|
|
4240
|
-
const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
4671
|
+
const parsedParentId = parseIdValues$1(hop.parentId, parentPks)[parentIdInfo.fieldName];
|
|
4241
4672
|
const parent = () => {
|
|
4242
4673
|
const table = getTableForCollection(hop.parentCollection, this.registry);
|
|
4243
4674
|
const idColumn = table[parentIdInfo.fieldName];
|
|
@@ -4277,58 +4708,38 @@ var FetchService = class FetchService {
|
|
|
4277
4708
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4278
4709
|
const table = getTableForCollection(collection, this.registry);
|
|
4279
4710
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4280
|
-
const idInfo = idInfoArray[0];
|
|
4281
|
-
const idField = table[idInfo.fieldName];
|
|
4282
|
-
if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
4283
4711
|
if (!idCanAddressTable(id, table, idInfoArray)) return void 0;
|
|
4284
|
-
const
|
|
4712
|
+
const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
|
|
4713
|
+
const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", {});
|
|
4714
|
+
const identity = narrowing ? and(addressed, narrowing) : addressed;
|
|
4285
4715
|
const tableName = getTableName(table);
|
|
4286
4716
|
const qb = this.getQueryBuilder(tableName);
|
|
4287
|
-
if (qb)
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
if (
|
|
4301
|
-
|
|
4302
|
-
|
|
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);
|
|
4303
4734
|
}
|
|
4304
|
-
if (reachedDatabase(e)) throw e;
|
|
4305
|
-
logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
|
|
4306
4735
|
}
|
|
4307
4736
|
const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
|
|
4308
|
-
const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(
|
|
4737
|
+
const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
|
|
4309
4738
|
if (result.length === 0) return void 0;
|
|
4310
4739
|
const raw = result[0];
|
|
4311
|
-
const values = await parseDataFromServer(raw, collection
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
const relationPromises = Object.entries(resolvedRelations).filter(([key]) => propertyKeys.has(key)).map(async ([key, relation]) => {
|
|
4315
|
-
if (relation.cardinality === "many") {
|
|
4316
|
-
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, {});
|
|
4317
|
-
values[key] = relatedRows.map((e) => createRelationRef(e.id, e.path));
|
|
4318
|
-
} else if (relation.cardinality === "one") {
|
|
4319
|
-
if (values[key] == null) try {
|
|
4320
|
-
const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, { limit: 1 });
|
|
4321
|
-
if (relatedRows.length > 0) {
|
|
4322
|
-
const e = relatedRows[0];
|
|
4323
|
-
values[key] = createRelationRef(e.id, e.path);
|
|
4324
|
-
}
|
|
4325
|
-
} catch (e) {
|
|
4326
|
-
if (reachedDatabase(e)) throw e;
|
|
4327
|
-
logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
|
|
4328
|
-
}
|
|
4329
|
-
}
|
|
4330
|
-
});
|
|
4331
|
-
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);
|
|
4332
4743
|
return {
|
|
4333
4744
|
...values,
|
|
4334
4745
|
id: id.toString()
|
|
@@ -4359,6 +4770,7 @@ var FetchService = class FetchService {
|
|
|
4359
4770
|
if (results.length === 0) return [];
|
|
4360
4771
|
const values = await Promise.all(results.map(async (rawRow) => await parseDataFromServer(rawRow, collection)));
|
|
4361
4772
|
if (include) await this.loadIncludes(values, collection, collectionPath, include);
|
|
4773
|
+
for (const row of values) stripUnreadable(row, collection);
|
|
4362
4774
|
return values;
|
|
4363
4775
|
}
|
|
4364
4776
|
/**
|
|
@@ -4366,11 +4778,10 @@ var FetchService = class FetchService {
|
|
|
4366
4778
|
*/
|
|
4367
4779
|
async fetchCollection(collectionPath, options = {}) {
|
|
4368
4780
|
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
4369
|
-
|
|
4781
|
+
return this.fetchRowsWithConditions(hop ? hop.targetCollection.slug : collectionPath, {
|
|
4370
4782
|
...options,
|
|
4371
4783
|
relatedTo: hop
|
|
4372
4784
|
});
|
|
4373
|
-
return this.fetchRowsWithConditions(collectionPath, options);
|
|
4374
4785
|
}
|
|
4375
4786
|
/**
|
|
4376
4787
|
* Search rows by text
|
|
@@ -4406,6 +4817,10 @@ var FetchService = class FetchService {
|
|
|
4406
4817
|
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
|
|
4407
4818
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
4408
4819
|
}
|
|
4820
|
+
allConditions.push(...await this.narrowRead(effectivePath, collection, table, "count", FetchService.describeRead({
|
|
4821
|
+
...options,
|
|
4822
|
+
relatedTo: hop
|
|
4823
|
+
})));
|
|
4409
4824
|
if (options.vectorSearch) {
|
|
4410
4825
|
const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
4411
4826
|
if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
|
|
@@ -4484,13 +4899,16 @@ var FetchService = class FetchService {
|
|
|
4484
4899
|
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
4485
4900
|
if (logicalCondition) conditions.push(logicalCondition);
|
|
4486
4901
|
}
|
|
4902
|
+
conditions.push(...await this.narrowRead(collectionPath, collection, table, "aggregate", FetchService.describeRead(options)));
|
|
4487
4903
|
if (conditions.length > 0) {
|
|
4488
4904
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
|
|
4489
4905
|
if (finalCondition) query = query.where(finalCondition);
|
|
4490
4906
|
}
|
|
4491
4907
|
if (groupColumns.length > 0) {
|
|
4492
4908
|
query = query.groupBy(...groupColumns.map((g) => g.column));
|
|
4909
|
+
query = query.orderBy(...this.aggregateOrdering(options.orderBy, groupColumns, options.aggregates, selection));
|
|
4493
4910
|
if (options.limit) query = query.limit(options.limit);
|
|
4911
|
+
if (options.offset) query = query.offset(options.offset);
|
|
4494
4912
|
}
|
|
4495
4913
|
const rows = await query;
|
|
4496
4914
|
const numericAliases = numericAggregateAliases(options.aggregates, collection?.properties ?? {});
|
|
@@ -4505,19 +4923,69 @@ var FetchService = class FetchService {
|
|
|
4505
4923
|
});
|
|
4506
4924
|
}
|
|
4507
4925
|
/**
|
|
4508
|
-
*
|
|
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
|
+
/**
|
|
4970
|
+
* Check if a field value is unique.
|
|
4971
|
+
*
|
|
4972
|
+
* Deliberately **not** narrowed by `beforeQuery`, and it is the only read
|
|
4973
|
+
* here that is not. The question is whether the value exists anywhere in
|
|
4974
|
+
* the table, not whether it exists among the rows this caller can see:
|
|
4975
|
+
* narrowed, it would answer "unique" for a value a hidden row already
|
|
4976
|
+
* holds, and the insert would then be rejected by the unique constraint
|
|
4977
|
+
* with a message about a row the caller has no way to find. Uniqueness is a
|
|
4978
|
+
* property of the table.
|
|
4509
4979
|
*/
|
|
4510
4980
|
async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
|
|
4511
4981
|
if (value === void 0 || value === null) return true;
|
|
4512
4982
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4513
4983
|
const table = getTableForCollection(collection, this.registry);
|
|
4514
4984
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4515
|
-
const idInfo = idInfoArray[0];
|
|
4516
|
-
const idField = table[idInfo.fieldName];
|
|
4517
4985
|
const field = table[fieldName];
|
|
4518
4986
|
if (!field) return true;
|
|
4519
|
-
const
|
|
4520
|
-
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);
|
|
4521
4989
|
const result = await this.db.select({ count: count() }).from(table).where(and(...conditions));
|
|
4522
4990
|
return Number(result[0]?.count || 0) === 0;
|
|
4523
4991
|
}
|
|
@@ -4577,13 +5045,13 @@ var FetchService = class FetchService {
|
|
|
4577
5045
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4578
5046
|
const table = getTableForCollection(collection, this.registry);
|
|
4579
5047
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4580
|
-
const idInfo = idInfoArray[0];
|
|
4581
|
-
const idField = table[idInfo.fieldName];
|
|
4582
5048
|
if (!idCanAddressTable(id, table, idInfoArray)) return null;
|
|
4583
|
-
const
|
|
5049
|
+
const addressed = rowIdentityCondition(table, idInfoArray, id, collectionPath);
|
|
4584
5050
|
const withDeleted = options?.withDeleted;
|
|
4585
5051
|
const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
|
|
4586
|
-
const
|
|
5052
|
+
const narrowing = await this.narrowReadCondition(collectionPath, collection, table, "get", FetchService.describeRead({ fields: options?.fields }));
|
|
5053
|
+
const identity = narrowing ? and(addressed, narrowing) : addressed;
|
|
5054
|
+
const result = await this.db.select(projection).from(table).where(andSoftDelete(identity, collection, table, withDeleted)).limit(1);
|
|
4587
5055
|
if (result.length === 0) return null;
|
|
4588
5056
|
const row = toRestRow(result[0], collection, this.registry);
|
|
4589
5057
|
const normalizedInclude = normalizeInclude(include);
|
|
@@ -4597,8 +5065,7 @@ var FetchService = class FetchService {
|
|
|
4597
5065
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
4598
5066
|
const table = getTableForCollection(collection, this.registry);
|
|
4599
5067
|
const idInfoArray = requirePrimaryKeys(collection, this.registry);
|
|
4600
|
-
|
|
4601
|
-
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");
|
|
4602
5069
|
let vectorMeta;
|
|
4603
5070
|
if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
4604
5071
|
const visible = this.columnProjection(table, collection, options.fields, idInfoArray, options.distinct === true);
|
|
@@ -4634,6 +5101,7 @@ var FetchService = class FetchService {
|
|
|
4634
5101
|
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
4635
5102
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
4636
5103
|
}
|
|
5104
|
+
allConditions.push(...await this.narrowRead(collectionPath, collection, table, "list", FetchService.describeRead(options)));
|
|
4637
5105
|
if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
|
|
4638
5106
|
if (allConditions.length > 0) {
|
|
4639
5107
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -4645,10 +5113,11 @@ var FetchService = class FetchService {
|
|
|
4645
5113
|
const missing = sortKeys.map(([field]) => field).filter((field) => !selected.has(field));
|
|
4646
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 });
|
|
4647
5115
|
}
|
|
4648
|
-
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);
|
|
4649
5118
|
if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
|
|
4650
5119
|
if (options.startAfter) {
|
|
4651
|
-
const cursorConditions = this.buildCursorConditions(table,
|
|
5120
|
+
const cursorConditions = this.buildCursorConditions(table, idInfoArray, options, collectionPath);
|
|
4652
5121
|
if (cursorConditions.length > 0) {
|
|
4653
5122
|
allConditions.push(...cursorConditions);
|
|
4654
5123
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
@@ -4738,11 +5207,13 @@ function serializePivot(pivot, payload, label) {
|
|
|
4738
5207
|
var RelationWriteService = class {
|
|
4739
5208
|
db;
|
|
4740
5209
|
registry;
|
|
5210
|
+
callContext;
|
|
4741
5211
|
reads;
|
|
4742
|
-
constructor(db, registry) {
|
|
5212
|
+
constructor(db, registry, callContext) {
|
|
4743
5213
|
this.db = db;
|
|
4744
5214
|
this.registry = registry;
|
|
4745
|
-
this.
|
|
5215
|
+
this.callContext = callContext;
|
|
5216
|
+
this.reads = new RelationService(db, registry, callContext);
|
|
4746
5217
|
}
|
|
4747
5218
|
/**
|
|
4748
5219
|
* Remove the junction row linking a parent to `targetId`, leaving the target
|
|
@@ -4785,16 +5256,33 @@ var RelationWriteService = class {
|
|
|
4785
5256
|
relation: hop.relationKey
|
|
4786
5257
|
});
|
|
4787
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
|
+
}
|
|
4788
5276
|
/** A collection's id, parsed to the type its primary key column holds. */
|
|
4789
5277
|
parsedId(collection, id) {
|
|
4790
5278
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4791
|
-
return parseIdValues(id, pks)[pks[0].fieldName];
|
|
5279
|
+
return parseIdValues$1(id, pks)[pks[0].fieldName];
|
|
4792
5280
|
}
|
|
4793
5281
|
/** The same, for the membership list a to-many write names. */
|
|
4794
5282
|
parsedIds(collection, ids) {
|
|
4795
5283
|
if (ids.length === 0) return [];
|
|
4796
5284
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4797
|
-
return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
|
|
5285
|
+
return ids.map((id) => parseIdValues$1(id, pks)[pks[0].fieldName]);
|
|
4798
5286
|
}
|
|
4799
5287
|
/**
|
|
4800
5288
|
* A membership list as links: the target ids parsed, and each element's
|
|
@@ -4812,7 +5300,7 @@ var RelationWriteService = class {
|
|
|
4812
5300
|
if (elements.length === 0) return [];
|
|
4813
5301
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4814
5302
|
return elements.map((element) => ({
|
|
4815
|
-
id: parseIdValues(element.id, pks)[pks[0].fieldName],
|
|
5303
|
+
id: parseIdValues$1(element.id, pks)[pks[0].fieldName],
|
|
4816
5304
|
pivot: element.pivot ? serializePivot(element.pivot, payload, label) : void 0
|
|
4817
5305
|
}));
|
|
4818
5306
|
}
|
|
@@ -4828,8 +5316,8 @@ var RelationWriteService = class {
|
|
|
4828
5316
|
const targetEntityIds = elements.map((element) => element.id);
|
|
4829
5317
|
const targetCollection = relation.target();
|
|
4830
5318
|
const label = `${collection.slug}.${key}`;
|
|
4831
|
-
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));
|
|
4832
|
-
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));
|
|
4833
5321
|
else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
4834
5322
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4835
5323
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -4840,11 +5328,12 @@ var RelationWriteService = class {
|
|
|
4840
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`}`);
|
|
4841
5329
|
const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
|
|
4842
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);
|
|
4843
5332
|
if (targetEntityIds.length > 0) {
|
|
4844
|
-
const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
4845
|
-
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));
|
|
4846
5335
|
await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
|
|
4847
|
-
} 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));
|
|
4848
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`");
|
|
4849
5338
|
}
|
|
4850
5339
|
}
|
|
@@ -4880,15 +5369,16 @@ var RelationWriteService = class {
|
|
|
4880
5369
|
if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
|
|
4881
5370
|
const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
|
|
4882
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.`);
|
|
4883
|
-
|
|
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));
|
|
4884
5374
|
else {
|
|
4885
|
-
const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
|
|
5375
|
+
const parsedNewTargetId = parseIdValues$1(newValue, targetPks)[targetIdInfo.fieldName];
|
|
4886
5376
|
const targetIdField = targetTable[targetIdInfo.fieldName];
|
|
4887
|
-
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));
|
|
4888
5378
|
await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
|
|
4889
5379
|
}
|
|
4890
5380
|
} catch (e) {
|
|
4891
|
-
if (e instanceof ApiError || e?.name === "ApiError") throw e;
|
|
5381
|
+
if (e instanceof ApiError || e?.name === "ApiError" || reachedDatabase(e)) throw e;
|
|
4892
5382
|
logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
|
|
4893
5383
|
}
|
|
4894
5384
|
}
|
|
@@ -4903,7 +5393,7 @@ var RelationWriteService = class {
|
|
|
4903
5393
|
try {
|
|
4904
5394
|
const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
|
|
4905
5395
|
const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationLinkElements([newValue], relation.relationName, sourceCollection.slug);
|
|
4906
|
-
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));
|
|
4907
5397
|
} catch (error) {
|
|
4908
5398
|
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
4909
5399
|
throw error;
|
|
@@ -4916,7 +5406,7 @@ var RelationWriteService = class {
|
|
|
4916
5406
|
const label = `${sourceCollection.slug}.${relation.relationName}`;
|
|
4917
5407
|
try {
|
|
4918
5408
|
const elements = Array.isArray(newValue) ? relationLinkElements(newValue, relation.relationName, sourceCollection.slug) : [];
|
|
4919
|
-
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));
|
|
4920
5410
|
} catch (error) {
|
|
4921
5411
|
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
4922
5412
|
throw error;
|
|
@@ -4939,7 +5429,7 @@ var RelationWriteService = class {
|
|
|
4939
5429
|
const parentTable = getTableForCollection(parentCollection, this.registry);
|
|
4940
5430
|
const parentPks = requirePrimaryKeys(parentCollection, this.registry);
|
|
4941
5431
|
const parentIdInfo = parentPks[0];
|
|
4942
|
-
const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
|
|
5432
|
+
const parsedParentId = parseIdValues$1(parentId, parentPks)[parentIdInfo.fieldName];
|
|
4943
5433
|
const parentIdCol = parentTable[parentIdInfo.fieldName];
|
|
4944
5434
|
const parentSourceCol = parentTable[parentSourceColName];
|
|
4945
5435
|
const targetFKCol = targetTable[targetFKColName];
|
|
@@ -4953,7 +5443,7 @@ var RelationWriteService = class {
|
|
|
4953
5443
|
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
4954
5444
|
continue;
|
|
4955
5445
|
}
|
|
4956
|
-
const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
5446
|
+
const parsedTargetId = parseIdValues$1(newTargetId, targetPks)[targetIdInfo.fieldName];
|
|
4957
5447
|
if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
|
|
4958
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");
|
|
4959
5449
|
await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
|
|
@@ -5022,6 +5512,10 @@ var RelationWriteService = class {
|
|
|
5022
5512
|
* silently empties itself the first time it is incremented from an unset column
|
|
5023
5513
|
* is worse than one that never worked.
|
|
5024
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
|
+
*
|
|
5025
5519
|
* @module
|
|
5026
5520
|
*/
|
|
5027
5521
|
/** What the column is, as Postgres sees it: `text[]`, `jsonb`, `integer`, … */
|
|
@@ -5100,26 +5594,184 @@ function compileFieldOps(table, fieldOps, context) {
|
|
|
5100
5594
|
for (const [field, op] of Object.entries(fieldOps)) compiled[field] = compileFieldOp(table, field, op, context);
|
|
5101
5595
|
return compiled;
|
|
5102
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
|
+
}
|
|
5103
5738
|
//#endregion
|
|
5104
5739
|
//#region src/services/PersistService.ts
|
|
5105
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
|
+
/**
|
|
5106
5756
|
* Service for handling all row write operations.
|
|
5107
5757
|
* Handles saving, deleting, and updating rows.
|
|
5108
5758
|
*/
|
|
5109
5759
|
var PersistService = class {
|
|
5110
5760
|
db;
|
|
5111
5761
|
registry;
|
|
5762
|
+
callContext;
|
|
5112
5763
|
/** Reads: whether a row is under a parent, the key a link joins on. */
|
|
5113
5764
|
relationService;
|
|
5114
5765
|
/** Writes: junction membership, foreign-key stamping, links. */
|
|
5115
5766
|
relationWrites;
|
|
5116
5767
|
fetchService;
|
|
5117
|
-
constructor(db, registry) {
|
|
5768
|
+
constructor(db, registry, callContext) {
|
|
5118
5769
|
this.db = db;
|
|
5119
5770
|
this.registry = registry;
|
|
5120
|
-
this.
|
|
5121
|
-
this.
|
|
5122
|
-
this.
|
|
5771
|
+
this.callContext = callContext;
|
|
5772
|
+
this.relationService = new RelationService(db, registry, callContext);
|
|
5773
|
+
this.relationWrites = new RelationWriteService(db, registry, callContext);
|
|
5774
|
+
this.fetchService = new FetchService(db, registry, callContext);
|
|
5123
5775
|
}
|
|
5124
5776
|
/**
|
|
5125
5777
|
* Set the columns of one many-to-many link, without touching the membership.
|
|
@@ -5162,14 +5814,20 @@ var PersistService = class {
|
|
|
5162
5814
|
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
5163
5815
|
const table = getTableForCollection(collection, this.registry);
|
|
5164
5816
|
const idInfoArray = getPrimaryKeys(collection, this.registry);
|
|
5165
|
-
const parsedIdObj = parseIdValues(id, idInfoArray);
|
|
5817
|
+
const parsedIdObj = parseIdValues$1(id, idInfoArray);
|
|
5166
5818
|
const conditions = [];
|
|
5167
5819
|
for (const info of idInfoArray) {
|
|
5168
5820
|
const field = table[info.fieldName];
|
|
5169
5821
|
if (!field) throw new Error(`ID field '${info.fieldName}' not found in table for collection '${collectionPath}'`);
|
|
5170
5822
|
conditions.push(eq(field, parsedIdObj[info.fieldName]));
|
|
5171
5823
|
}
|
|
5172
|
-
|
|
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");
|
|
5173
5831
|
}
|
|
5174
5832
|
/**
|
|
5175
5833
|
* Delete all rows from a collection
|
|
@@ -5213,6 +5871,10 @@ var PersistService = class {
|
|
|
5213
5871
|
* the row already exists — which is what a re-runnable import needs. The
|
|
5214
5872
|
* conflict is matched on the primary key unless `options.onConflict` names
|
|
5215
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.
|
|
5216
5878
|
*
|
|
5217
5879
|
* `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
|
|
5218
5880
|
* split out here rather than at the REST boundary because every request
|
|
@@ -5230,7 +5892,7 @@ var PersistService = class {
|
|
|
5230
5892
|
effectiveCollectionPath = hop.targetCollection.slug;
|
|
5231
5893
|
const parentIdForWrite = () => {
|
|
5232
5894
|
const parentPks = getPrimaryKeys(hop.parentCollection, this.registry);
|
|
5233
|
-
return parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
5895
|
+
return parseIdValues$1(hop.parentId, parentPks)[parentPks[0].fieldName];
|
|
5234
5896
|
};
|
|
5235
5897
|
if (isManyToMany(hop.relation)) junctionTableInfo = {
|
|
5236
5898
|
parentCollection: hop.parentCollection,
|
|
@@ -5291,10 +5953,11 @@ var PersistService = class {
|
|
|
5291
5953
|
let currentId;
|
|
5292
5954
|
if (id && !options?.upsert) {
|
|
5293
5955
|
currentId = id;
|
|
5294
|
-
const idValues = parseIdValues(id, idInfoArray);
|
|
5956
|
+
const idValues = parseIdValues$1(id, idInfoArray);
|
|
5295
5957
|
if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
|
|
5296
5958
|
const scalarKeys = Object.keys(entityData);
|
|
5297
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 }) : [];
|
|
5298
5961
|
if (scalarKeys.length > 0 || compiledOps) {
|
|
5299
5962
|
const updateQuery = tx.update(table).set({
|
|
5300
5963
|
...entityData,
|
|
@@ -5305,11 +5968,15 @@ var PersistService = class {
|
|
|
5305
5968
|
const field = table[info.fieldName];
|
|
5306
5969
|
conditions.push(eq(field, idValues[info.fieldName]));
|
|
5307
5970
|
}
|
|
5308
|
-
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
|
+
}
|
|
5309
5976
|
}
|
|
5310
5977
|
} else {
|
|
5311
5978
|
const dataForInsert = { ...entityData };
|
|
5312
|
-
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
|
|
5979
|
+
if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues$1(id, idInfoArray));
|
|
5313
5980
|
for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
|
|
5314
5981
|
const insertQuery = tx.insert(table).values(dataForInsert);
|
|
5315
5982
|
const targetFields = options?.onConflict && options.onConflict.length > 0 ? [...options.onConflict] : idInfoArray.map((info) => info.fieldName);
|
|
@@ -5324,11 +5991,24 @@ var PersistService = class {
|
|
|
5324
5991
|
const set = { ...dataForInsert };
|
|
5325
5992
|
for (const info of idInfoArray) delete set[info.fieldName];
|
|
5326
5993
|
for (const field of targetFields) delete set[field];
|
|
5327
|
-
for (const
|
|
5328
|
-
|
|
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({
|
|
5329
5999
|
target,
|
|
5330
|
-
set
|
|
6000
|
+
set,
|
|
6001
|
+
setWhere: scope
|
|
5331
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
|
+
}
|
|
5332
6012
|
} else result = await insertQuery.returning(returningKeys);
|
|
5333
6013
|
const resultRow = result[0];
|
|
5334
6014
|
if (!resultRow) if (id) currentId = id;
|
|
@@ -5349,6 +6029,26 @@ var PersistService = class {
|
|
|
5349
6029
|
return finalEntity;
|
|
5350
6030
|
}
|
|
5351
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
|
+
/**
|
|
5352
6052
|
* Get the RelationService instance for external use
|
|
5353
6053
|
*/
|
|
5354
6054
|
getRelationService() {
|
|
@@ -5377,7 +6077,7 @@ var PersistService = class {
|
|
|
5377
6077
|
* so `author_id` had to be translated to `authorId` by whoever read the
|
|
5378
6078
|
* message — which nothing does. See `pgFieldViolations`.
|
|
5379
6079
|
*/
|
|
5380
|
-
toUserFriendlyError(error, collectionSlug, collection) {
|
|
6080
|
+
toUserFriendlyError(error, collectionSlug, collection, operation = "save") {
|
|
5381
6081
|
if (error instanceof ApiError || error?.name === "ApiError") return error;
|
|
5382
6082
|
const pgError = extractPgError(error);
|
|
5383
6083
|
if (pgError) {
|
|
@@ -5387,7 +6087,7 @@ var PersistService = class {
|
|
|
5387
6087
|
collection: collectionSlug,
|
|
5388
6088
|
violations
|
|
5389
6089
|
} : void 0;
|
|
5390
|
-
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);
|
|
5391
6091
|
if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
|
|
5392
6092
|
return new Error(message, { cause: error });
|
|
5393
6093
|
}
|
|
@@ -5413,13 +6113,15 @@ var PersistService = class {
|
|
|
5413
6113
|
var DataService = class {
|
|
5414
6114
|
db;
|
|
5415
6115
|
registry;
|
|
6116
|
+
callContext;
|
|
5416
6117
|
fetchService;
|
|
5417
6118
|
persistService;
|
|
5418
|
-
constructor(db, registry) {
|
|
6119
|
+
constructor(db, registry, callContext) {
|
|
5419
6120
|
this.db = db;
|
|
5420
6121
|
this.registry = registry;
|
|
5421
|
-
this.
|
|
5422
|
-
this.
|
|
6122
|
+
this.callContext = callContext;
|
|
6123
|
+
this.fetchService = new FetchService(db, registry, callContext);
|
|
6124
|
+
this.persistService = new PersistService(db, registry, callContext);
|
|
5423
6125
|
}
|
|
5424
6126
|
/**
|
|
5425
6127
|
* Fetch a single row by ID
|
|
@@ -5570,6 +6272,10 @@ var WriteTransactionScope = class {
|
|
|
5570
6272
|
open = true;
|
|
5571
6273
|
inflight = /* @__PURE__ */ new Set();
|
|
5572
6274
|
commitHooks = [];
|
|
6275
|
+
/** The first tracked statement that failed, whether or not its caller caught it. */
|
|
6276
|
+
failure;
|
|
6277
|
+
settledHooks = [];
|
|
6278
|
+
settledContext;
|
|
5573
6279
|
/** The transaction handle, for statements in this package that are already written against drizzle. */
|
|
5574
6280
|
tx;
|
|
5575
6281
|
/** Attach the transaction, once it has begun. */
|
|
@@ -5600,7 +6306,10 @@ var WriteTransactionScope = class {
|
|
|
5600
6306
|
const done = () => {
|
|
5601
6307
|
this.inflight.delete(settled);
|
|
5602
6308
|
};
|
|
5603
|
-
settled.then(done,
|
|
6309
|
+
settled.then(done, (error) => {
|
|
6310
|
+
this.failure ??= error;
|
|
6311
|
+
done();
|
|
6312
|
+
});
|
|
5604
6313
|
return settled;
|
|
5605
6314
|
}
|
|
5606
6315
|
afterCommit(fn) {
|
|
@@ -5617,6 +6326,65 @@ var WriteTransactionScope = class {
|
|
|
5617
6326
|
this.open = false;
|
|
5618
6327
|
}
|
|
5619
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
|
+
/**
|
|
5620
6388
|
* The transaction is over, whichever way it went. Nothing may reach it now:
|
|
5621
6389
|
* its client is back in the pool, and a callback's detached promise still
|
|
5622
6390
|
* carries this scope in its async context.
|
|
@@ -5734,7 +6502,15 @@ function classifyCollectionChanges(before, after, facts) {
|
|
|
5734
6502
|
});
|
|
5735
6503
|
continue;
|
|
5736
6504
|
}
|
|
5737
|
-
|
|
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);
|
|
5738
6514
|
}
|
|
5739
6515
|
for (const [slug, collection] of previous) {
|
|
5740
6516
|
if (next.has(slug)) continue;
|
|
@@ -6043,6 +6819,13 @@ function effectiveSqlRole(requestedRole) {
|
|
|
6043
6819
|
/** How {@link effectiveSqlRole} names "whatever role the connection holds". */
|
|
6044
6820
|
var CONNECTION_OWNER = "<connection owner>";
|
|
6045
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
|
+
/**
|
|
6046
6829
|
* A statement named a database role the connection cannot assume.
|
|
6047
6830
|
*
|
|
6048
6831
|
* Its own type because the tempting recovery — run it anyway, as the owner — is
|
|
@@ -6062,6 +6845,66 @@ var RoleSwitchUnavailableError = class extends Error {
|
|
|
6062
6845
|
}
|
|
6063
6846
|
};
|
|
6064
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
|
+
/**
|
|
6065
6908
|
* Refuse a write that would leave a *required* `created_by` / `updated_by`
|
|
6066
6909
|
* column null because nobody is acting.
|
|
6067
6910
|
*
|
|
@@ -6173,7 +7016,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6173
7016
|
this.db = db;
|
|
6174
7017
|
this.registry = registry;
|
|
6175
7018
|
this.poolManager = poolManager;
|
|
6176
|
-
this.dataService = new DataService(db, registry);
|
|
7019
|
+
this.dataService = new DataService(db, registry, () => this.buildCallContext());
|
|
6177
7020
|
this.realtimeService = realtimeService;
|
|
6178
7021
|
this.historyService = historyService;
|
|
6179
7022
|
this.user = user;
|
|
@@ -6398,17 +7241,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6398
7241
|
return out;
|
|
6399
7242
|
}));
|
|
6400
7243
|
}
|
|
6401
|
-
async fetchCollection({ path, collection,
|
|
7244
|
+
async fetchCollection({ path, collection, startAfter, ...query }) {
|
|
6402
7245
|
const rows = await this.dataService.fetchCollection(path, {
|
|
6403
|
-
|
|
6404
|
-
orderBy,
|
|
6405
|
-
order,
|
|
6406
|
-
limit,
|
|
6407
|
-
offset,
|
|
7246
|
+
...query,
|
|
6408
7247
|
startAfter,
|
|
6409
|
-
databaseId: collection?.databaseId
|
|
6410
|
-
searchString,
|
|
6411
|
-
vectorSearch
|
|
7248
|
+
databaseId: collection?.databaseId
|
|
6412
7249
|
});
|
|
6413
7250
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6414
7251
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
@@ -6592,16 +7429,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6592
7429
|
const { collection: slug, userField, tenantField } = tenant.from.membership;
|
|
6593
7430
|
const cap = PostgresBackendDriver.TENANT_MEMBERSHIP_CAP;
|
|
6594
7431
|
try {
|
|
6595
|
-
const rows = await this.dataService.fetchCollection(slug, {
|
|
7432
|
+
const rows = await withFieldViewer(void 0, () => this.dataService.fetchCollection(slug, {
|
|
6596
7433
|
filter: { [userField]: ["==", uid] },
|
|
6597
7434
|
limit: cap + 1,
|
|
6598
7435
|
databaseId: collection?.databaseId
|
|
6599
|
-
});
|
|
7436
|
+
}));
|
|
6600
7437
|
return {
|
|
6601
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))),
|
|
6602
7439
|
complete: rows.length <= cap
|
|
6603
7440
|
};
|
|
6604
7441
|
} catch (err) {
|
|
7442
|
+
if (reachedDatabase(err)) throw err;
|
|
6605
7443
|
logger.debug(`[save] Could not read '${slug}' to resolve the caller's tenants`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6606
7444
|
return {
|
|
6607
7445
|
tenants: [],
|
|
@@ -6609,21 +7447,86 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6609
7447
|
};
|
|
6610
7448
|
}
|
|
6611
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
|
+
}
|
|
6612
7495
|
async save({ path, id, values, collection, status, upsert, onConflict }) {
|
|
6613
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
|
+
}
|
|
6614
7509
|
let updatedValues = values;
|
|
6615
7510
|
const contextForCallback = this.buildCallContext();
|
|
6616
7511
|
if ((status === "new" || status === "copy") && resolvedCollection?.properties) updatedValues = applyDefaultValuesOnCreate(updatedValues, resolvedCollection.properties);
|
|
6617
7512
|
let previousValuesForHistory;
|
|
6618
|
-
if (status === "existing" && id)
|
|
6619
|
-
const
|
|
6620
|
-
|
|
6621
|
-
const
|
|
6622
|
-
|
|
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) });
|
|
6623
7526
|
}
|
|
6624
|
-
} catch (err) {
|
|
6625
|
-
logger.debug(`[save] Could not fetch previous values for "${path}"`, { detail: err instanceof Error ? err.message : String(err) });
|
|
6626
7527
|
}
|
|
7528
|
+
/** The row as the hooks receive it, to tell what they wrote. */
|
|
7529
|
+
const beforeHooks = updatedValues;
|
|
6627
7530
|
try {
|
|
6628
7531
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
6629
7532
|
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
@@ -6637,7 +7540,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6637
7540
|
status,
|
|
6638
7541
|
context: contextForCallback
|
|
6639
7542
|
});
|
|
6640
|
-
if (result) updatedValues =
|
|
7543
|
+
if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
|
|
6641
7544
|
}
|
|
6642
7545
|
if (callbacks?.beforeSave) {
|
|
6643
7546
|
const result = await callbacks.beforeSave({
|
|
@@ -6649,7 +7552,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6649
7552
|
status,
|
|
6650
7553
|
context: contextForCallback
|
|
6651
7554
|
});
|
|
6652
|
-
if (result) updatedValues =
|
|
7555
|
+
if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
|
|
6653
7556
|
}
|
|
6654
7557
|
if (propertyCallbacks?.beforeSave) {
|
|
6655
7558
|
const result = await propertyCallbacks.beforeSave({
|
|
@@ -6661,12 +7564,13 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6661
7564
|
status,
|
|
6662
7565
|
context: contextForCallback
|
|
6663
7566
|
});
|
|
6664
|
-
if (result) updatedValues =
|
|
7567
|
+
if (result) updatedValues = applyBeforeSaveResult(updatedValues, result);
|
|
6665
7568
|
}
|
|
6666
7569
|
}
|
|
6667
7570
|
} catch (callbackError) {
|
|
6668
7571
|
throw toCallbackError(callbackError, "beforeSave", path);
|
|
6669
7572
|
}
|
|
7573
|
+
const afterHooks = updatedValues;
|
|
6670
7574
|
if (resolvedCollection?.properties) {
|
|
6671
7575
|
updatedValues = updateDateAutoValues({
|
|
6672
7576
|
inputValues: updatedValues,
|
|
@@ -6681,12 +7585,29 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6681
7585
|
status: status ?? "new",
|
|
6682
7586
|
uid: this.user?.uid
|
|
6683
7587
|
});
|
|
7588
|
+
if (status === "existing") {
|
|
7589
|
+
const kept = { ...updatedValues };
|
|
7590
|
+
for (const key of createStampKeys(resolvedCollection.properties)) delete kept[key];
|
|
7591
|
+
updatedValues = kept;
|
|
7592
|
+
}
|
|
6684
7593
|
}
|
|
6685
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);
|
|
6686
7606
|
try {
|
|
6687
|
-
let savedRow = await this.dataService.save(path, updatedValues,
|
|
6688
|
-
upsert,
|
|
6689
|
-
onConflict
|
|
7607
|
+
let savedRow = await this.dataService.save(path, updatedValues, writeId, resolvedCollection?.databaseId, {
|
|
7608
|
+
upsert: upserting,
|
|
7609
|
+
onConflict,
|
|
7610
|
+
insertOnlyKeys
|
|
6690
7611
|
});
|
|
6691
7612
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
6692
7613
|
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
@@ -6746,9 +7667,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6746
7667
|
throw toCallbackError(callbackError, "afterSave", path);
|
|
6747
7668
|
}
|
|
6748
7669
|
if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
|
|
6749
|
-
tableName:
|
|
7670
|
+
tableName: resolvedCollection.slug,
|
|
6750
7671
|
id: savedId,
|
|
6751
|
-
action: status === "
|
|
7672
|
+
action: status === "existing" ? "update" : "create",
|
|
6752
7673
|
values: savedValues,
|
|
6753
7674
|
previousValues: previousValuesForHistory,
|
|
6754
7675
|
updatedBy: this.user?.uid
|
|
@@ -6764,27 +7685,31 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6764
7685
|
} catch (error) {
|
|
6765
7686
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
6766
7687
|
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6767
|
-
const
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
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
|
+
}
|
|
6775
7711
|
};
|
|
6776
|
-
if (
|
|
6777
|
-
collection: callbackCollection,
|
|
6778
|
-
...errorProps
|
|
6779
|
-
});
|
|
6780
|
-
if (callbacks?.afterSaveError) await callbacks.afterSaveError({
|
|
6781
|
-
collection: callbackCollection,
|
|
6782
|
-
...errorProps
|
|
6783
|
-
});
|
|
6784
|
-
if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
|
|
6785
|
-
collection: callbackCollection,
|
|
6786
|
-
...errorProps
|
|
6787
|
-
});
|
|
7712
|
+
if (!currentWriteScope()?.afterSettled(runHooks)) await runHooks(contextForCallback);
|
|
6788
7713
|
}
|
|
6789
7714
|
throw error;
|
|
6790
7715
|
}
|
|
@@ -6803,7 +7728,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6803
7728
|
*/
|
|
6804
7729
|
bindToTransaction(tx) {
|
|
6805
7730
|
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
|
|
6806
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
7731
|
+
txDriver.dataService = new DataService(tx, this.registry, () => txDriver.buildCallContext());
|
|
6807
7732
|
txDriver.client = this.client;
|
|
6808
7733
|
txDriver._deferNotifications = this._deferNotifications;
|
|
6809
7734
|
txDriver._pendingNotifications = this._pendingNotifications;
|
|
@@ -6825,7 +7750,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6825
7750
|
*/
|
|
6826
7751
|
callContextWithin(tx, user) {
|
|
6827
7752
|
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, user, this.poolManager, this.historyService);
|
|
6828
|
-
txDriver.dataService = new DataService(tx, this.registry);
|
|
7753
|
+
txDriver.dataService = new DataService(tx, this.registry, () => txDriver.buildCallContext());
|
|
6829
7754
|
txDriver.client = this.client;
|
|
6830
7755
|
return txDriver.buildCallContext();
|
|
6831
7756
|
}
|
|
@@ -6882,7 +7807,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6882
7807
|
if (!await txDriver.fetchOne({
|
|
6883
7808
|
path,
|
|
6884
7809
|
id: String(id),
|
|
6885
|
-
collection
|
|
7810
|
+
collection,
|
|
7811
|
+
withDeleted: restoresSoftDeletedRow(collection ?? this.registry?.getCollectionByPath(path), values) ? true : void 0
|
|
6886
7812
|
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
6887
7813
|
statusCode: 404,
|
|
6888
7814
|
code: "NOT_FOUND"
|
|
@@ -6992,7 +7918,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6992
7918
|
if (!await txDriver.fetchOne({
|
|
6993
7919
|
path: operation.path,
|
|
6994
7920
|
id,
|
|
6995
|
-
collection: operation.collection
|
|
7921
|
+
collection: operation.collection,
|
|
7922
|
+
withDeleted: restoresSoftDeletedRow(operation.collection ?? this.registry?.getCollectionByPath(operation.path), values) ? true : void 0
|
|
6996
7923
|
})) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
|
|
6997
7924
|
statusCode: 404,
|
|
6998
7925
|
code: "NOT_FOUND"
|
|
@@ -7077,7 +8004,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7077
8004
|
} catch (callbackError) {
|
|
7078
8005
|
throw toCallbackError(callbackError, "beforeDelete", targetPath);
|
|
7079
8006
|
}
|
|
7080
|
-
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);
|
|
7081
8010
|
if (softDelete) await this.dataService.save(targetPath, { [softDelete.field]: /* @__PURE__ */ new Date() }, row.id, resolvedCollection?.databaseId);
|
|
7082
8011
|
else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
|
|
7083
8012
|
try {
|
|
@@ -7108,8 +8037,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7108
8037
|
} catch (callbackError) {
|
|
7109
8038
|
throw toCallbackError(callbackError, "afterDelete", targetPath);
|
|
7110
8039
|
}
|
|
7111
|
-
if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
|
|
7112
|
-
tableName:
|
|
8040
|
+
if (this.historyService && resolvedCollection?.history && !unlinking) await this.historyService.recordHistory({
|
|
8041
|
+
tableName: resolvedCollection.slug,
|
|
7113
8042
|
id: row.id.toString(),
|
|
7114
8043
|
action: "delete",
|
|
7115
8044
|
values: stored,
|
|
@@ -7164,8 +8093,48 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7164
8093
|
return sql.join(chunks, sql.raw(""));
|
|
7165
8094
|
}
|
|
7166
8095
|
async executeSql(sqlText, options) {
|
|
8096
|
+
if (options?.isolateSession) return this.onIsolatedSession(this.getTargetDb(options.database), (db) => this.executeSqlOn(db, sqlText, options));
|
|
7167
8097
|
if (!options?.database && !options?.role) return this.dataService.executeSql(sqlText, options?.params);
|
|
7168
|
-
|
|
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) {
|
|
7169
8138
|
try {
|
|
7170
8139
|
let needsRoleSwitch = false;
|
|
7171
8140
|
if (options?.role) try {
|
|
@@ -7345,14 +8314,15 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
7345
8314
|
AND ccu2.table_name != ${safeName};
|
|
7346
8315
|
`)).rows,
|
|
7347
8316
|
policies: (await this.db.execute(sql`
|
|
7348
|
-
SELECT
|
|
7349
|
-
|
|
7350
|
-
|
|
7351
|
-
|
|
7352
|
-
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
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};
|
|
7356
8326
|
`)).rows
|
|
7357
8327
|
};
|
|
7358
8328
|
}
|
|
@@ -7407,11 +8377,22 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7407
8377
|
async withTransaction(operation, options) {
|
|
7408
8378
|
const pendingNotifications = [];
|
|
7409
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
|
+
});
|
|
7410
8390
|
const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await runInWriteScope(writeScope, () => this.delegate.db.transaction(async (tx) => {
|
|
7411
8391
|
if (!this.user?.uid) logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
7412
8392
|
if (!this.user?.roles) logger.warn("[DataDriver] User roles are missing for authenticated delegate. Using empty array. User object", { detail: this.user });
|
|
7413
8393
|
await applyAuthContext(tx, authContextOf(this.user), this.delegate.rlsUserRole);
|
|
7414
|
-
|
|
8394
|
+
let hookContext;
|
|
8395
|
+
const txEntityService = new DataService(tx, this.delegate.registry, () => hookContext ??= this.delegate.callContextWithin(tx, this.user));
|
|
7415
8396
|
const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
|
|
7416
8397
|
txDelegate.dataService = txEntityService;
|
|
7417
8398
|
txDelegate._deferNotifications = true;
|
|
@@ -7426,9 +8407,14 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7426
8407
|
throw error;
|
|
7427
8408
|
}
|
|
7428
8409
|
await writeScope?.settle();
|
|
8410
|
+
await writeScope?.assertCommittable();
|
|
7429
8411
|
return out;
|
|
7430
|
-
}, options)).finally(() => writeScope?.close()))
|
|
8412
|
+
}, options)).finally(() => writeScope?.close())).catch(async (error) => {
|
|
8413
|
+
await writeScope?.settled();
|
|
8414
|
+
throw error;
|
|
8415
|
+
});
|
|
7431
8416
|
writeScope?.committed();
|
|
8417
|
+
await writeScope?.settled();
|
|
7432
8418
|
for (const notification of pendingNotifications) try {
|
|
7433
8419
|
await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
|
|
7434
8420
|
} catch (e) {
|
|
@@ -7797,6 +8783,41 @@ function buildJunctionLinkMap(registry) {
|
|
|
7797
8783
|
return map;
|
|
7798
8784
|
}
|
|
7799
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
|
|
7800
8821
|
//#region src/services/cdc/trigger-cdc.ts
|
|
7801
8822
|
/**
|
|
7802
8823
|
* Trigger-based Change Data Capture (CDC).
|
|
@@ -7829,6 +8850,11 @@ var CDC_TRIGGER_NAME = "rebase_cdc_trigger";
|
|
|
7829
8850
|
* statement* if the limit is exceeded. We stay comfortably under it and, for
|
|
7830
8851
|
* wide rows, fall back to an identity-only payload so CDC can never break a
|
|
7831
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.
|
|
7832
8858
|
*/
|
|
7833
8859
|
var MAX_NOTIFY_BYTES = 7900;
|
|
7834
8860
|
var quoteIdent = (name) => `"${name.replace(/"/g, "\"\"")}"`;
|
|
@@ -7852,6 +8878,7 @@ LANGUAGE plpgsql AS $rebase_cdc$
|
|
|
7852
8878
|
DECLARE
|
|
7853
8879
|
rec jsonb;
|
|
7854
8880
|
payload text;
|
|
8881
|
+
ident jsonb;
|
|
7855
8882
|
BEGIN
|
|
7856
8883
|
IF (TG_OP = 'DELETE') THEN
|
|
7857
8884
|
rec := to_jsonb(OLD);
|
|
@@ -7870,11 +8897,18 @@ BEGIN
|
|
|
7870
8897
|
-- 8000-byte cap, emit an identity-only payload the consumer can still route
|
|
7871
8898
|
-- (and refetch the authoritative row from).
|
|
7872
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;
|
|
7873
8907
|
payload := json_build_object(
|
|
7874
8908
|
'schema', TG_TABLE_SCHEMA,
|
|
7875
8909
|
'table', TG_TABLE_NAME,
|
|
7876
8910
|
'op', TG_OP,
|
|
7877
|
-
'row',
|
|
8911
|
+
'row', ident,
|
|
7878
8912
|
'truncated', true
|
|
7879
8913
|
)::text;
|
|
7880
8914
|
END IF;
|
|
@@ -8023,6 +9057,15 @@ var PgNotifyListener = class {
|
|
|
8023
9057
|
if (initial) throw err;
|
|
8024
9058
|
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
8025
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
|
+
}
|
|
8026
9069
|
}
|
|
8027
9070
|
}
|
|
8028
9071
|
scheduleReconnect() {
|
|
@@ -8079,11 +9122,17 @@ function parseCdcPayload(payload) {
|
|
|
8079
9122
|
*/
|
|
8080
9123
|
var CdcListener = class {
|
|
8081
9124
|
listener;
|
|
8082
|
-
|
|
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) {
|
|
8083
9131
|
this.listener = new PgNotifyListener({
|
|
8084
9132
|
connectionString,
|
|
8085
9133
|
channel: CDC_CHANNEL,
|
|
8086
9134
|
logLabel: "[CDC]",
|
|
9135
|
+
onReconnect,
|
|
8087
9136
|
onPayload: (payload) => {
|
|
8088
9137
|
const event = parseCdcPayload(payload);
|
|
8089
9138
|
if (!event) {
|
|
@@ -8921,6 +9970,23 @@ function callbackContextWithin(driver, tx, auth) {
|
|
|
8921
9970
|
});
|
|
8922
9971
|
}
|
|
8923
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
|
+
/**
|
|
8924
9990
|
* PostgreSQL-specific realtime service.
|
|
8925
9991
|
* Handles WebSocket connections and subscriptions for real-time row updates.
|
|
8926
9992
|
*
|
|
@@ -9037,8 +10103,20 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9037
10103
|
* and always flow through. Keyed → expiry timestamp (ms).
|
|
9038
10104
|
*/
|
|
9039
10105
|
recentAppEmits = /* @__PURE__ */ new Map();
|
|
9040
|
-
/**
|
|
9041
|
-
|
|
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;
|
|
9042
10120
|
constructor(db, registry) {
|
|
9043
10121
|
super();
|
|
9044
10122
|
this.db = db;
|
|
@@ -9263,7 +10341,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9263
10341
|
const subscriptionId = request.subscriptionId;
|
|
9264
10342
|
let canDeliver;
|
|
9265
10343
|
try {
|
|
9266
|
-
|
|
10344
|
+
const collection = this.registry.getCollectionByPath(request.path);
|
|
10345
|
+
if (!collection) {
|
|
9267
10346
|
const registered = this.registry.getCollections().map((c) => c.slug).join(", ");
|
|
9268
10347
|
const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
|
|
9269
10348
|
logger.error(`[RealtimeService] ${msg}`);
|
|
@@ -9294,6 +10373,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9294
10373
|
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
9295
10374
|
return;
|
|
9296
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
|
+
}
|
|
9297
10387
|
const subscription = {
|
|
9298
10388
|
clientId,
|
|
9299
10389
|
type: "collection",
|
|
@@ -9372,6 +10462,76 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9372
10462
|
if (canDeliver && !canDeliver.mayReportFailure()) return;
|
|
9373
10463
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
9374
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
|
+
}
|
|
9375
10535
|
async handleUnsubscribe(_clientId, subscriptionId) {
|
|
9376
10536
|
this._subscriptions.delete(subscriptionId);
|
|
9377
10537
|
this.subscriptionCallbacks.delete(subscriptionId);
|
|
@@ -9402,6 +10562,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9402
10562
|
*/
|
|
9403
10563
|
async notifyUpdate(path, id, row, databaseId, broadcast = true, origin = "app") {
|
|
9404
10564
|
this.debugLog("🔔 [RealtimeService] notifyUpdate called for path:", path, "id:", id, "isDelete:", row === null, "origin:", origin);
|
|
10565
|
+
const aliases = this.aliasPaths(path);
|
|
9405
10566
|
if (this.cdcActive) {
|
|
9406
10567
|
const key = this.dedupKey(path, id, databaseId);
|
|
9407
10568
|
if (origin === "cdc") {
|
|
@@ -9409,7 +10570,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9409
10570
|
this.debugLog("🔁 [RealtimeService] Suppressing CDC echo of local app mutation:", key);
|
|
9410
10571
|
return;
|
|
9411
10572
|
}
|
|
9412
|
-
} 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
|
+
}
|
|
9413
10580
|
}
|
|
9414
10581
|
const pathsToNotify = [path];
|
|
9415
10582
|
if (path.includes("/") && path.split("/").length > 1) {
|
|
@@ -9418,6 +10585,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9418
10585
|
this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
|
|
9419
10586
|
}
|
|
9420
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);
|
|
9421
10589
|
if (broadcast && this.broadcasting && !this.cdcActive) try {
|
|
9422
10590
|
await this.broadcastChange(path, id, databaseId);
|
|
9423
10591
|
} catch (err) {
|
|
@@ -9566,7 +10734,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9566
10734
|
isAnonymous: activeAuth.isAnonymous === true,
|
|
9567
10735
|
claims: activeAuth.claims
|
|
9568
10736
|
}, this.rlsUserRole);
|
|
9569
|
-
|
|
10737
|
+
let hookContext;
|
|
10738
|
+
const fetchedEntities = await new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)).fetchCollectionForRest(notifyPath, {
|
|
9570
10739
|
filter: collectionRequest.filter,
|
|
9571
10740
|
logical: collectionRequest.logical,
|
|
9572
10741
|
orderBy: collectionRequest.orderBy,
|
|
@@ -9615,7 +10784,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9615
10784
|
}));
|
|
9616
10785
|
}
|
|
9617
10786
|
return fetchedEntities;
|
|
9618
|
-
}));
|
|
10787
|
+
}, { accessMode: "read only" }));
|
|
9619
10788
|
}
|
|
9620
10789
|
return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchCollectionForRest(notifyPath, {
|
|
9621
10790
|
filter: collectionRequest.filter,
|
|
@@ -9665,8 +10834,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9665
10834
|
isAnonymous: activeAuth.isAnonymous === true,
|
|
9666
10835
|
claims: activeAuth.claims
|
|
9667
10836
|
}, this.rlsUserRole);
|
|
9668
|
-
|
|
9669
|
-
|
|
10837
|
+
let hookContext;
|
|
10838
|
+
return countOnce(new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)));
|
|
10839
|
+
}, { accessMode: "read only" });
|
|
9670
10840
|
} else total = await countOnce(this.dataService);
|
|
9671
10841
|
} catch (error) {
|
|
9672
10842
|
logger.warn(`[RealtimeService] Could not count '${notifyPath}' for a subscription frame`, { error });
|
|
@@ -9744,7 +10914,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9744
10914
|
isAnonymous: activeAuth.isAnonymous === true,
|
|
9745
10915
|
claims: activeAuth.claims
|
|
9746
10916
|
}, this.rlsUserRole);
|
|
9747
|
-
let
|
|
10917
|
+
let hookContext;
|
|
10918
|
+
let processedEntity = await new DataService(tx, this.registry, () => hookContext ??= callbackContextWithin(this.driver, tx, activeAuth)).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
|
|
9748
10919
|
if (processedEntity) {
|
|
9749
10920
|
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
9750
10921
|
const resolvedCollection = collection ? {
|
|
@@ -9778,7 +10949,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9778
10949
|
}
|
|
9779
10950
|
}
|
|
9780
10951
|
return processedEntity;
|
|
9781
|
-
}));
|
|
10952
|
+
}, { accessMode: "read only" }));
|
|
9782
10953
|
}
|
|
9783
10954
|
return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchOneForRest(notifyPath, id) ?? void 0);
|
|
9784
10955
|
}
|
|
@@ -9857,6 +11028,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9857
11028
|
* Extract parent paths from a nested path like "posts/70/tags"
|
|
9858
11029
|
* Returns ["posts", "posts/70"] for the example above
|
|
9859
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
|
+
}
|
|
9860
11064
|
getParentPaths(path) {
|
|
9861
11065
|
const segments = path.split("/").filter((s) => s.length > 0);
|
|
9862
11066
|
const parentPaths = [];
|
|
@@ -10429,8 +11633,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10429
11633
|
* 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
|
|
10430
11634
|
* 2. All subscription state and callbacks are cleared.
|
|
10431
11635
|
* 3. The dedicated LISTEN client (outside the pool) is disconnected.
|
|
10432
|
-
* 4. All WebSocket clients are
|
|
10433
|
-
*
|
|
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.
|
|
10434
11638
|
*/
|
|
10435
11639
|
async destroy() {
|
|
10436
11640
|
for (const [key, timer] of this.refetchTimers) {
|
|
@@ -10465,7 +11669,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10465
11669
|
await this.stopCdc();
|
|
10466
11670
|
await this.bus.stop().catch((error) => logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
|
|
10467
11671
|
this.bus = new MemoryChannelBus();
|
|
11672
|
+
const sockets = [...this.clients.values()];
|
|
10468
11673
|
this.clients.clear();
|
|
11674
|
+
closeWebSockets(sockets, WEBSOCKET_CLOSE_GRACE_MS);
|
|
10469
11675
|
this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
|
|
10470
11676
|
}
|
|
10471
11677
|
/** Whether database-level change capture is currently the active source. */
|
|
@@ -10496,7 +11702,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10496
11702
|
}
|
|
10497
11703
|
this.cdcTableMap = this.buildCdcTableMap();
|
|
10498
11704
|
this.junctionLinkMap = buildJunctionLinkMap(this.registry);
|
|
10499
|
-
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
|
|
11705
|
+
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event), () => this.resyncSubscriptions());
|
|
10500
11706
|
try {
|
|
10501
11707
|
await this.cdcListener.start();
|
|
10502
11708
|
} catch (err) {
|
|
@@ -10509,6 +11715,32 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10509
11715
|
this.cdcActive = true;
|
|
10510
11716
|
logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
|
|
10511
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
|
+
}
|
|
10512
11744
|
/** Stop the CDC listener and clear its state. */
|
|
10513
11745
|
async stopCdc() {
|
|
10514
11746
|
this.cdcActive = false;
|
|
@@ -10598,7 +11830,29 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10598
11830
|
}
|
|
10599
11831
|
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
10600
11832
|
extractIdFromCdcRow(collection, row) {
|
|
10601
|
-
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;
|
|
10602
11856
|
}
|
|
10603
11857
|
dedupKey(path, id, databaseId) {
|
|
10604
11858
|
return `${databaseId ?? ""}::${path}::${id}`;
|
|
@@ -10670,8 +11924,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10670
11924
|
}
|
|
10671
11925
|
/**
|
|
10672
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.
|
|
10673
11931
|
*/
|
|
10674
|
-
async connectListenClient() {
|
|
11932
|
+
async connectListenClient({ reconnect = false } = {}) {
|
|
10675
11933
|
if (!this.listenConnectionString) return;
|
|
10676
11934
|
let pending;
|
|
10677
11935
|
try {
|
|
@@ -10717,6 +11975,10 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10717
11975
|
this.listenClient = client;
|
|
10718
11976
|
pending = void 0;
|
|
10719
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
|
+
}
|
|
10720
11982
|
} catch (err) {
|
|
10721
11983
|
if (pending) try {
|
|
10722
11984
|
await pending.end();
|
|
@@ -10741,7 +12003,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
10741
12003
|
} catch {}
|
|
10742
12004
|
this.listenClient = void 0;
|
|
10743
12005
|
}
|
|
10744
|
-
await this.connectListenClient();
|
|
12006
|
+
await this.connectListenClient({ reconnect: true });
|
|
10745
12007
|
}, delay);
|
|
10746
12008
|
}
|
|
10747
12009
|
};
|
|
@@ -10776,6 +12038,17 @@ var WS_RATE_WINDOW_MS = 6e4;
|
|
|
10776
12038
|
* a considered product limit (see `docs/channel-authorization.md`).
|
|
10777
12039
|
*/
|
|
10778
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;
|
|
10779
12052
|
/** Frames counted against the channel budget rather than the general one. */
|
|
10780
12053
|
var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
|
|
10781
12054
|
"join_channel",
|
|
@@ -10877,7 +12150,33 @@ function isAdminSession(session) {
|
|
|
10877
12150
|
if (!session.user.roles) return false;
|
|
10878
12151
|
return session.user.roles.some((r) => r === "admin");
|
|
10879
12152
|
}
|
|
10880
|
-
|
|
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) {
|
|
10881
12180
|
const clientSessions = /* @__PURE__ */ new Map();
|
|
10882
12181
|
const isProduction = process.env.NODE_ENV === "production";
|
|
10883
12182
|
/**
|
|
@@ -10900,15 +12199,20 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10900
12199
|
if (extras.length === 0) logger.debug(message);
|
|
10901
12200
|
else logger.debug(message, { details: extras.length === 1 ? extras[0] : extras });
|
|
10902
12201
|
};
|
|
10903
|
-
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
|
+
});
|
|
10904
12207
|
wss.on("error", (err) => {
|
|
10905
12208
|
if (err.code === "EADDRINUSE") return;
|
|
10906
12209
|
logger.error("❌ [WebSocket Server] Error", { error: err });
|
|
10907
12210
|
});
|
|
10908
12211
|
const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
|
|
10909
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.");
|
|
10910
|
-
wss.on("connection", (ws) => {
|
|
12213
|
+
wss.on("connection", (ws, request) => {
|
|
10911
12214
|
const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
12215
|
+
const origin = connectionOrigin(request);
|
|
10912
12216
|
wsDebug(`WebSocket client connected: ${clientId}`);
|
|
10913
12217
|
clientSessions.set(clientId, {
|
|
10914
12218
|
ws,
|
|
@@ -10923,9 +12227,16 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10923
12227
|
wsDebug(`WebSocket client disconnected: ${clientId}`);
|
|
10924
12228
|
clientSessions.delete(clientId);
|
|
10925
12229
|
});
|
|
12230
|
+
ws.on("error", (error) => {
|
|
12231
|
+
wsDebug(`WebSocket client ${clientId} error: ${error.message}`);
|
|
12232
|
+
});
|
|
10926
12233
|
ws.on("message", async (message) => {
|
|
10927
12234
|
let requestId;
|
|
10928
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
|
+
}
|
|
10929
12240
|
const { type, payload, requestId: reqId } = JSON.parse(message.toString());
|
|
10930
12241
|
requestId = reqId;
|
|
10931
12242
|
wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
|
|
@@ -10944,6 +12255,33 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10944
12255
|
}
|
|
10945
12256
|
}));
|
|
10946
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
|
+
}
|
|
10947
12285
|
if (type === "AUTHENTICATE") {
|
|
10948
12286
|
const { token } = payload || {};
|
|
10949
12287
|
if (!token) {
|
|
@@ -10957,7 +12295,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10957
12295
|
uid: adapterUser.uid,
|
|
10958
12296
|
roles: adapterUser.roles,
|
|
10959
12297
|
isAdmin: adapterUser.isAdmin,
|
|
10960
|
-
isAnonymous: adapterUser.isAnonymous === true
|
|
12298
|
+
isAnonymous: adapterUser.isAnonymous === true,
|
|
12299
|
+
...adapterUser.claims ? { claims: adapterUser.claims } : {}
|
|
10961
12300
|
};
|
|
10962
12301
|
} catch {}
|
|
10963
12302
|
else if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) verifiedUser = {
|
|
@@ -10981,6 +12320,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
10981
12320
|
if (session) {
|
|
10982
12321
|
session.user = verifiedUser;
|
|
10983
12322
|
session.authenticated = true;
|
|
12323
|
+
await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
|
|
10984
12324
|
}
|
|
10985
12325
|
wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
|
|
10986
12326
|
ws.send(JSON.stringify({
|
|
@@ -11004,39 +12344,22 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11004
12344
|
return;
|
|
11005
12345
|
}
|
|
11006
12346
|
}
|
|
11007
|
-
{
|
|
11008
|
-
const session = clientSessions.get(clientId);
|
|
11009
|
-
if (session) {
|
|
11010
|
-
const now = Date.now();
|
|
11011
|
-
if (CHANNEL_MESSAGE_TYPES.has(type)) {
|
|
11012
|
-
if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
|
|
11013
|
-
session.channelMessageCount = 0;
|
|
11014
|
-
session.channelWindowStart = now;
|
|
11015
|
-
}
|
|
11016
|
-
session.channelMessageCount++;
|
|
11017
|
-
if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
|
|
11018
|
-
sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
|
|
11019
|
-
return;
|
|
11020
|
-
}
|
|
11021
|
-
} else {
|
|
11022
|
-
if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
|
|
11023
|
-
session.messageCount = 0;
|
|
11024
|
-
session.messageWindowStart = now;
|
|
11025
|
-
}
|
|
11026
|
-
session.messageCount++;
|
|
11027
|
-
if (session.messageCount > WS_RATE_LIMIT) {
|
|
11028
|
-
sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
|
|
11029
|
-
return;
|
|
11030
|
-
}
|
|
11031
|
-
}
|
|
11032
|
-
}
|
|
11033
|
-
}
|
|
11034
12347
|
if (ADMIN_ONLY_TYPES.has(type)) {
|
|
11035
12348
|
if (!isAdminSession(clientSessions.get(clientId))) {
|
|
11036
12349
|
sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
|
|
11037
12350
|
return;
|
|
11038
12351
|
}
|
|
11039
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
|
+
}
|
|
11040
12363
|
/**
|
|
11041
12364
|
* Apply the REST layer's write checks to a socket payload.
|
|
11042
12365
|
*
|
|
@@ -11044,13 +12367,98 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11044
12367
|
* driver decides what a path means, and refusing here would
|
|
11045
12368
|
* turn "unknown collection" into a validation error.
|
|
11046
12369
|
*/
|
|
11047
|
-
const assertWriteRequest = (path, values) => {
|
|
12370
|
+
const assertWriteRequest = (path, values, status) => {
|
|
11048
12371
|
if (!path || !values || typeof values !== "object") return;
|
|
11049
12372
|
const collection = driver.registry?.getCollectionByPath(path);
|
|
11050
12373
|
if (!collection) return;
|
|
11051
|
-
assertWriteRequestValid(values, collection, {
|
|
12374
|
+
assertWriteRequestValid(values, collection, {
|
|
12375
|
+
viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] },
|
|
12376
|
+
status
|
|
12377
|
+
});
|
|
11052
12378
|
assertFieldOpsValid(values, collection);
|
|
11053
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
|
+
};
|
|
11054
12462
|
const getScopedDelegate = async () => {
|
|
11055
12463
|
if (typeof driver.withAuth === "function") try {
|
|
11056
12464
|
const who = sessionAuthContext(clientSessions.get(clientId));
|
|
@@ -11076,6 +12484,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11076
12484
|
{
|
|
11077
12485
|
wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
|
|
11078
12486
|
const request = payload;
|
|
12487
|
+
assertReadRequest(request.path, request);
|
|
11079
12488
|
const rows = await (await getScopedDelegate()).fetchCollection({
|
|
11080
12489
|
...request,
|
|
11081
12490
|
limit: resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch })
|
|
@@ -11113,8 +12522,31 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11113
12522
|
depth: null,
|
|
11114
12523
|
colors: true
|
|
11115
12524
|
}));
|
|
11116
|
-
|
|
11117
|
-
|
|
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);
|
|
11118
12550
|
wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, {
|
|
11119
12551
|
depth: null,
|
|
11120
12552
|
colors: true
|
|
@@ -11133,13 +12565,19 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11133
12565
|
wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
|
|
11134
12566
|
const request = payload;
|
|
11135
12567
|
wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
|
|
11136
|
-
|
|
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({
|
|
11137
12574
|
row: {
|
|
11138
12575
|
id: request.row.id,
|
|
11139
12576
|
path: request.row.path
|
|
11140
12577
|
},
|
|
11141
12578
|
hard: request.hard
|
|
11142
12579
|
});
|
|
12580
|
+
await userAdmin?.finalizeUserDeletions?.(uids);
|
|
11143
12581
|
wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
|
|
11144
12582
|
const response = {
|
|
11145
12583
|
type: "DELETE_SUCCESS",
|
|
@@ -11154,6 +12592,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11154
12592
|
{
|
|
11155
12593
|
wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
|
|
11156
12594
|
const { path, name, value, id, collection } = payload;
|
|
12595
|
+
if (typeof name === "string") assertReadRequest(path, { filter: { [name]: ["==", value] } });
|
|
11157
12596
|
const isUnique = await (await getScopedDelegate()).checkUniqueField(path, name, value, id, collection);
|
|
11158
12597
|
wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
|
|
11159
12598
|
const response = {
|
|
@@ -11168,6 +12607,7 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11168
12607
|
case "COUNT":
|
|
11169
12608
|
{
|
|
11170
12609
|
const request = payload;
|
|
12610
|
+
assertReadRequest(request.path, request);
|
|
11171
12611
|
const response = {
|
|
11172
12612
|
type: "COUNT_SUCCESS",
|
|
11173
12613
|
payload: { count: await (await getScopedDelegate()).count(request) },
|
|
@@ -11185,7 +12625,10 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
|
|
|
11185
12625
|
sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
|
|
11186
12626
|
break;
|
|
11187
12627
|
}
|
|
11188
|
-
const result = await admin.executeSql(sql,
|
|
12628
|
+
const result = await admin.executeSql(sql, {
|
|
12629
|
+
...options,
|
|
12630
|
+
isolateSession: true
|
|
12631
|
+
});
|
|
11189
12632
|
if (process.env.NODE_ENV !== "production") wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
|
|
11190
12633
|
const auditSession = clientSessions.get(clientId);
|
|
11191
12634
|
logger.info("[SQL Audit] WebSocket SQL execution", {
|
|
@@ -11592,7 +13035,7 @@ function createBackupCron(config) {
|
|
|
11592
13035
|
enabled: config.enabled ?? true,
|
|
11593
13036
|
timeoutSeconds: 3600,
|
|
11594
13037
|
async handler({ log }) {
|
|
11595
|
-
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);
|
|
11596
13039
|
const { destination } = config;
|
|
11597
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 }).`);
|
|
11598
13041
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -11606,8 +13049,8 @@ function createBackupCron(config) {
|
|
|
11606
13049
|
log(`Dump created: ${dump.fileName} (${formatBytes(dump.sizeBytes)})`);
|
|
11607
13050
|
const check = await validateDump(dump.localFile);
|
|
11608
13051
|
if (!check.ok) {
|
|
11609
|
-
if (
|
|
11610
|
-
|
|
13052
|
+
if (dump.globalsFile) discardPartialDump(dump.globalsFile);
|
|
13053
|
+
discardPartialDump(dump.localFile);
|
|
11611
13054
|
throw new Error(`New backup failed validation — skipping upload and pruning to protect existing backups. ${check.reason}`);
|
|
11612
13055
|
}
|
|
11613
13056
|
let storedKey = dump.localFile;
|
|
@@ -12182,6 +13625,14 @@ var UserService = class {
|
|
|
12182
13625
|
updatedAt: row.updated_at
|
|
12183
13626
|
}));
|
|
12184
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
|
+
}
|
|
12185
13636
|
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
12186
13637
|
await this.withServerContext(async (db) => db.insert(this.userIdentitiesTable).values({
|
|
12187
13638
|
uid,
|
|
@@ -12196,7 +13647,13 @@ var UserService = class {
|
|
|
12196
13647
|
const payload = this.mapPayload(data);
|
|
12197
13648
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
12198
13649
|
payload[updatedAtKey] = /* @__PURE__ */ new Date();
|
|
12199
|
-
|
|
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
|
+
}
|
|
12200
13657
|
return row ? this.mapRowToUser(row) : null;
|
|
12201
13658
|
}
|
|
12202
13659
|
async deleteUser(id) {
|
|
@@ -12249,7 +13706,7 @@ var UserService = class {
|
|
|
12249
13706
|
};
|
|
12250
13707
|
}
|
|
12251
13708
|
/**
|
|
12252
|
-
* Update user's password hash
|
|
13709
|
+
* Update user's password hash; `null` removes the password.
|
|
12253
13710
|
*/
|
|
12254
13711
|
async updatePassword(id, passwordHash) {
|
|
12255
13712
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -12419,7 +13876,18 @@ var RefreshTokenService = class RefreshTokenService {
|
|
|
12419
13876
|
if (session && this.has("sessionId")) values.sessionId = session.id;
|
|
12420
13877
|
if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
|
|
12421
13878
|
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
12422
|
-
|
|
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
|
+
});
|
|
12423
13891
|
}
|
|
12424
13892
|
/**
|
|
12425
13893
|
* Build a {@link RefreshTokenInfo} out of a row of {@link selection}.
|
|
@@ -12475,17 +13943,31 @@ var RefreshTokenService = class RefreshTokenService {
|
|
|
12475
13943
|
}
|
|
12476
13944
|
await this.db.update(this.refreshTokensTable).set({ rotatedAt: /* @__PURE__ */ new Date() }).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
12477
13945
|
}
|
|
12478
|
-
/**
|
|
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
|
+
*/
|
|
12479
13957
|
async revokeSession(sessionId) {
|
|
12480
13958
|
if (!this.has("sessionId")) return;
|
|
12481
|
-
|
|
12482
|
-
|
|
12483
|
-
|
|
12484
|
-
|
|
12485
|
-
|
|
12486
|
-
|
|
12487
|
-
|
|
12488
|
-
|
|
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
|
+
});
|
|
12489
13971
|
}
|
|
12490
13972
|
/**
|
|
12491
13973
|
* Housekeeping: rotation would otherwise leave a row per refresh forever.
|
|
@@ -12676,7 +14158,7 @@ var PostgresTokenRepository = class {
|
|
|
12676
14158
|
this.magicLinkTokenService = new MagicLinkTokenService(db, tableOrTables);
|
|
12677
14159
|
}
|
|
12678
14160
|
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
12679
|
-
|
|
14161
|
+
return this.refreshTokenService.createToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
12680
14162
|
}
|
|
12681
14163
|
async markRefreshTokenRotated(tokenHash) {
|
|
12682
14164
|
await this.refreshTokenService.markRotated(tokenHash);
|
|
@@ -12765,6 +14247,9 @@ var PostgresAuthRepository = class {
|
|
|
12765
14247
|
async linkUserIdentity(uid, provider, providerId, profileData) {
|
|
12766
14248
|
return this.userService.linkUserIdentity(uid, provider, providerId, profileData);
|
|
12767
14249
|
}
|
|
14250
|
+
async unlinkUserIdentity(uid, provider, providerId) {
|
|
14251
|
+
await this.userService.unlinkUserIdentity(uid, provider, providerId);
|
|
14252
|
+
}
|
|
12768
14253
|
async updateUser(id, data) {
|
|
12769
14254
|
return this.userService.updateUser(id, data);
|
|
12770
14255
|
}
|
|
@@ -12858,7 +14343,7 @@ var PostgresAuthRepository = class {
|
|
|
12858
14343
|
}
|
|
12859
14344
|
async deleteRole(_id) {}
|
|
12860
14345
|
async createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session) {
|
|
12861
|
-
|
|
14346
|
+
return this.tokenRepository.createRefreshToken(uid, tokenHash, expiresAt, userAgent, ipAddress, session);
|
|
12862
14347
|
}
|
|
12863
14348
|
async markRefreshTokenRotated(tokenHash) {
|
|
12864
14349
|
await this.tokenRepository.markRefreshTokenRotated(tokenHash);
|
|
@@ -13113,7 +14598,9 @@ var MfaService = class {
|
|
|
13113
14598
|
};
|
|
13114
14599
|
}
|
|
13115
14600
|
/**
|
|
13116
|
-
* 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.
|
|
13117
14604
|
*
|
|
13118
14605
|
* Incremented in the database rather than in the route so that guesses
|
|
13119
14606
|
* arriving in parallel — the shape any real brute-force takes — cannot
|
|
@@ -14729,9 +16216,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14729
16216
|
const provisioningDb = (driverResult) => {
|
|
14730
16217
|
return (driverResult?.internals)?.db ?? pgConfig.connection;
|
|
14731
16218
|
};
|
|
14732
|
-
const
|
|
14733
|
-
const db = (driverResult
|
|
16219
|
+
const requireProvisioningDb = (driverResult) => {
|
|
16220
|
+
const db = provisioningDb(driverResult);
|
|
14734
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);
|
|
14735
16226
|
return { async query(text) {
|
|
14736
16227
|
return { rows: sqlRows(await db.execute(sql.raw(text))) };
|
|
14737
16228
|
} };
|
|
@@ -14744,14 +16235,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14744
16235
|
* Idempotent and cheap to repeat: `CREATE SCHEMA IF NOT EXISTS` plus a
|
|
14745
16236
|
* handful of `CREATE OR REPLACE FUNCTION`.
|
|
14746
16237
|
*/
|
|
14747
|
-
const provisionRlsRuntime = async (
|
|
14748
|
-
const
|
|
14749
|
-
await
|
|
14750
|
-
|
|
14751
|
-
|
|
14752
|
-
|
|
14753
|
-
|
|
14754
|
-
}
|
|
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
|
+
});
|
|
14755
16245
|
};
|
|
14756
16246
|
return {
|
|
14757
16247
|
type: "postgres",
|
|
@@ -15139,7 +16629,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15139
16629
|
* proved it can bootstrap with.
|
|
15140
16630
|
*/
|
|
15141
16631
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
15142
|
-
const { ensureCollectionTables } = await import("./ensure-collection-tables-
|
|
16632
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-BEEjn5cn.js").then((n) => n.n);
|
|
15143
16633
|
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
15144
16634
|
for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
|
|
15145
16635
|
return { applied: plan.actions.length - plan.failures.length };
|
|
@@ -15170,13 +16660,13 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15170
16660
|
* as the auth path and the migration preamble, from the same constant.
|
|
15171
16661
|
*/
|
|
15172
16662
|
async ensureRlsRuntime(driverResult) {
|
|
15173
|
-
await provisionRlsRuntime(
|
|
16663
|
+
await provisionRlsRuntime(driverResult);
|
|
15174
16664
|
},
|
|
15175
16665
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
15176
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
16666
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-RHUcEp4v.js");
|
|
15177
16667
|
const queryable = provisioningQueryable(driverResult);
|
|
15178
16668
|
const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
|
|
15179
|
-
if (!rows[0]?.present) await provisionRlsRuntime(
|
|
16669
|
+
if (!rows[0]?.present) await provisionRlsRuntime(driverResult);
|
|
15180
16670
|
const outcome = await ensureCollectionPolicies(queryable, collections, log);
|
|
15181
16671
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
15182
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}`);
|
|
@@ -15184,7 +16674,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15184
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.`);
|
|
15185
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.`);
|
|
15186
16676
|
try {
|
|
15187
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
16677
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-B8EclDyM.js").then((n) => n.i);
|
|
15188
16678
|
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
15189
16679
|
info: (m) => logger.info(m),
|
|
15190
16680
|
warn: (m) => logger.warn(m)
|
|
@@ -15262,9 +16752,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
15262
16752
|
return driverResult.internals.driver.admin;
|
|
15263
16753
|
},
|
|
15264
16754
|
mountRoutes(app, basePath, driverResult) {},
|
|
15265
|
-
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
16755
|
+
async initializeWebsockets(server, realtimeService, driver, config, adapter, limits) {
|
|
15266
16756
|
const { createPostgresWebSocket } = await Promise.resolve().then(() => websocket_exports);
|
|
15267
|
-
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
16757
|
+
createPostgresWebSocket(server, realtimeService, driver, config, adapter, limits);
|
|
15268
16758
|
}
|
|
15269
16759
|
};
|
|
15270
16760
|
}
|
|
@@ -15289,8 +16779,8 @@ function createPostgresAdapter(pgConfig) {
|
|
|
15289
16779
|
async initializeHistory(config, driverResult) {
|
|
15290
16780
|
if (bootstrapper.initializeHistory) return bootstrapper.initializeHistory(config, driverResult);
|
|
15291
16781
|
},
|
|
15292
|
-
initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
15293
|
-
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);
|
|
15294
16784
|
},
|
|
15295
16785
|
verifyConnection: bootstrapper.verifyConnection ? (driverResult) => bootstrapper.verifyConnection(driverResult) : void 0,
|
|
15296
16786
|
ensureCollectionSchema: bootstrapper.ensureCollectionSchema ? (collections, driverResult, log) => bootstrapper.ensureCollectionSchema(collections, driverResult, log) : void 0,
|