@rebasepro/server-postgres 0.20.0 → 0.21.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/PostgresBackendDriver.d.ts +33 -3
- package/dist/auth/services.d.ts +19 -1
- package/dist/{backup-cli-DSpQyqcG.js → backup-cli-Bp-ou6o2.js} +2 -2
- package/dist/{backup-cli-DSpQyqcG.js.map → backup-cli-Bp-ou6o2.js.map} +1 -1
- package/dist/{backup-service-DA7a6SUV.js → backup-service-BNLwvxuy.js} +2 -2
- package/dist/{backup-service-DA7a6SUV.js.map → backup-service-BNLwvxuy.js.map} +1 -1
- package/dist/cli.js +7 -7
- package/dist/column-plan-helpers-CpILzHJS.js.map +1 -1
- package/dist/{doctor-D5SrGJ5P.js → doctor-ByFWL_ET.js} +25 -6
- package/dist/doctor-ByFWL_ET.js.map +1 -0
- package/dist/{ensure-collection-policies-CHO0moXQ.js → ensure-collection-policies-CQGHln-y.js} +2 -2
- package/dist/{ensure-collection-policies-CHO0moXQ.js.map → ensure-collection-policies-CQGHln-y.js.map} +1 -1
- package/dist/{ensure-tables-CYMtuuOd.js → ensure-tables-BnEvEJPr.js} +2 -2
- package/dist/ensure-tables-BnEvEJPr.js.map +1 -0
- package/dist/history/HistoryService.d.ts +16 -2
- package/dist/history/ensure-history-table.d.ts +7 -1
- package/dist/index.es.js +1580 -1085
- package/dist/index.es.js.map +1 -1
- package/dist/plan-schema-Hgl62S-w.js.map +1 -1
- package/dist/{rls-bootstrap-sql-BlzsOUtz.js → rls-bootstrap-sql-BHA6jLtj.js} +4 -3
- package/dist/{rls-bootstrap-sql-BlzsOUtz.js.map → rls-bootstrap-sql-BHA6jLtj.js.map} +1 -1
- package/dist/{rls-enforcement-BV12vBwQ.js → rls-enforcement-C6Xk0lA6.js} +20 -20
- package/dist/{rls-enforcement-BV12vBwQ.js.map → rls-enforcement-C6Xk0lA6.js.map} +1 -1
- package/dist/schema/doctor-cli.js +2 -2
- package/dist/services/PersistService.d.ts +11 -0
- package/dist/services/RelationService.d.ts +39 -0
- package/dist/services/RelationWriteService.d.ts +32 -1
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/junction-writes.d.ts +58 -2
- package/dist/services/write-transaction-scope.d.ts +68 -0
- package/dist/types.d.ts +18 -0
- package/package.json +7 -7
- package/dist/doctor-D5SrGJ5P.js.map +0 -1
- package/dist/ensure-tables-CYMtuuOd.js.map +0 -1
package/dist/index.es.js
CHANGED
|
@@ -5,22 +5,22 @@ import { a as createDirectDatabaseConnection, c as guardPoolAgainstDirtyRelease,
|
|
|
5
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-Hgl62S-w.js";
|
|
6
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-C3g_kHBw.js";
|
|
7
7
|
import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-W3DMfcZZ.js";
|
|
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-
|
|
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
9
|
import { l as resolveColumnName } from "./column-plan-helpers-CpILzHJS.js";
|
|
10
10
|
import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
|
|
11
11
|
import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-CR2xcS7e.js";
|
|
12
12
|
import { t as generateSchema } from "./generate-drizzle-schema-logic-Dfyf_MRu.js";
|
|
13
13
|
import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Bkvehxdm.js";
|
|
14
14
|
import { t as main } from "./generate-drizzle-schema-vSK-VdYT.js";
|
|
15
|
-
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-
|
|
16
|
-
import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-
|
|
15
|
+
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-BNLwvxuy.js";
|
|
16
|
+
import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-BnEvEJPr.js";
|
|
17
17
|
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-C6LQdTxj.js";
|
|
18
18
|
import { Client } from "pg";
|
|
19
19
|
import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
|
|
20
20
|
import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, not, notInArray, or, relations, sql } from "drizzle-orm";
|
|
21
|
-
import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, ListLimitError, MAX_INCLUDE_DEPTH, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, 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, applyDefaultValuesOnCreate, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, canReadField, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, encodeCursor, fieldKeyForColumn, findRelation, getColumnName, getDeclaredPrimaryKeys, getJunctionCollectionConfig, getTableName as getTableName$1, getTenantConfig, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeInclude, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, resolveCollectionRelations, resolveJunctionSpecs, resolveTenantWrite, revokeInternalTableSql, tenantBypassRoles, toCallbackError, toFilterTuples, updateDateAutoValues, updateUserAutoValues } from "@rebasepro/common";
|
|
23
|
-
import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, mergeDeep, toSnakeCase, toWireKey } from "@rebasepro/utils";
|
|
21
|
+
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, 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, mergeDeep, toSnakeCase, toWireKey, unref } from "@rebasepro/utils";
|
|
24
24
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
25
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
26
|
import fs from "fs";
|
|
@@ -64,7 +64,7 @@ import { inspect } from "util";
|
|
|
64
64
|
* currently stores `undefined`, but the store's type says the difference is
|
|
65
65
|
* carried rather than flattened.
|
|
66
66
|
*/
|
|
67
|
-
var storage = new AsyncLocalStorage();
|
|
67
|
+
var storage$1 = new AsyncLocalStorage();
|
|
68
68
|
/**
|
|
69
69
|
* Run `fn` with `viewer` as the caller every row strip judges against.
|
|
70
70
|
*
|
|
@@ -73,7 +73,7 @@ var storage = new AsyncLocalStorage();
|
|
|
73
73
|
* which is the behaviour both halves need and the reason this is not a variable.
|
|
74
74
|
*/
|
|
75
75
|
function withFieldViewer(viewer, fn) {
|
|
76
|
-
return storage.run(viewer, fn);
|
|
76
|
+
return storage$1.run(viewer, fn);
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
79
|
* The caller the current read is for, or `undefined` on the trusted server plane
|
|
@@ -85,17 +85,16 @@ function withFieldViewer(viewer, fn) {
|
|
|
85
85
|
* that is what establishes the viewer.
|
|
86
86
|
*/
|
|
87
87
|
function currentFieldViewer() {
|
|
88
|
-
return storage.getStore();
|
|
88
|
+
return storage$1.getStore();
|
|
89
89
|
}
|
|
90
90
|
//#endregion
|
|
91
91
|
//#region src/services/collection-helpers.ts
|
|
92
92
|
/** Safely extract Drizzle column metadata from a column object. */
|
|
93
93
|
function getColumnMeta(col) {
|
|
94
|
-
const raw = col;
|
|
95
94
|
return {
|
|
96
|
-
columnType: typeof
|
|
97
|
-
dataType: typeof
|
|
98
|
-
primary: typeof
|
|
95
|
+
columnType: typeof col.columnType === "string" ? col.columnType : void 0,
|
|
96
|
+
dataType: typeof col.dataType === "string" ? col.dataType : void 0,
|
|
97
|
+
primary: typeof col.primary === "boolean" ? col.primary : void 0
|
|
99
98
|
};
|
|
100
99
|
}
|
|
101
100
|
/**
|
|
@@ -1540,11 +1539,9 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1540
1539
|
if (additionalJoins && additionalJoins.length > 0) joins.push(...additionalJoins);
|
|
1541
1540
|
}
|
|
1542
1541
|
if (currentTable !== parentTable) {
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
if (
|
|
1546
|
-
if (parentTable && typeof parentTable === "object") parentTableName = parentTable[Symbol.for("drizzle:Name")] || parentTable._?.name || parentTable.tableName || parentTable.name || "unknown";
|
|
1547
|
-
if (!(currentTableName.includes("_") || currentTableName === "unknown" || parentTableName === "unknown")) throw new Error(`Join path did not result in connecting to parent table. Current: ${currentTableName}, Parent: ${parentTableName}`);
|
|
1542
|
+
const currentTableName = getTableName(currentTable);
|
|
1543
|
+
const parentTableName = getTableName(parentTable);
|
|
1544
|
+
if (!currentTableName.includes("_")) throw new Error(`Join path did not result in connecting to parent table. Current: ${currentTableName}, Parent: ${parentTableName}`);
|
|
1548
1545
|
}
|
|
1549
1546
|
return {
|
|
1550
1547
|
joins,
|
|
@@ -1559,8 +1556,10 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1559
1556
|
let condition;
|
|
1560
1557
|
const additionalJoins = [];
|
|
1561
1558
|
if (currentTable === toTable) {
|
|
1562
|
-
const
|
|
1563
|
-
const
|
|
1559
|
+
const cols = getTableColumns(fromTable);
|
|
1560
|
+
const currentCols = getTableColumns(currentTable);
|
|
1561
|
+
const left = cols[fromColName];
|
|
1562
|
+
const right = currentCols[toColName];
|
|
1564
1563
|
if (!left || !right) {
|
|
1565
1564
|
if (registry) {
|
|
1566
1565
|
const junctionResult = this.tryBuildJunctionJoin(currentTable, fromTable, fromColName, toColName, fromTableName, toTableName, registry);
|
|
@@ -1572,7 +1571,7 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
1572
1571
|
condition = eq(left, right);
|
|
1573
1572
|
} else if (currentTable === fromTable) {
|
|
1574
1573
|
const left = toTable[toColName];
|
|
1575
|
-
const right = currentTable[fromColName];
|
|
1574
|
+
const right = getTableColumns(currentTable)[fromColName];
|
|
1576
1575
|
if (!left || !right) {
|
|
1577
1576
|
if (registry) {
|
|
1578
1577
|
const junctionResult = this.tryBuildJunctionJoin(currentTable, toTable, fromColName, toColName, fromTableName, toTableName, registry);
|
|
@@ -2000,968 +1999,527 @@ var isVectorColumn = (column) => /^(vector|halfvec|sparsevec)\b/.test(columnSqlT
|
|
|
2000
1999
|
*/
|
|
2001
2000
|
var PostgresConditionBuilder = DrizzleConditionBuilder;
|
|
2002
2001
|
//#endregion
|
|
2003
|
-
//#region src/
|
|
2002
|
+
//#region src/services/row-pipeline.ts
|
|
2004
2003
|
/**
|
|
2005
|
-
*
|
|
2004
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
2005
|
+
*
|
|
2006
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
2007
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
2008
|
+
* to unwrap that same level back out.
|
|
2006
2009
|
*/
|
|
2007
|
-
function
|
|
2008
|
-
if (
|
|
2009
|
-
|
|
2010
|
-
if (typeof obj === "string" && obj.toLowerCase() === "nan") return null;
|
|
2011
|
-
if (Array.isArray(obj)) return obj.map((v) => sanitizeAndConvertDates(v));
|
|
2012
|
-
if (obj instanceof Date) return obj.toISOString();
|
|
2013
|
-
if (typeof obj === "object") {
|
|
2014
|
-
const newObj = {};
|
|
2015
|
-
for (const key in obj) {
|
|
2016
|
-
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
2017
|
-
if (isPrototypePollutingKey(key)) continue;
|
|
2018
|
-
newObj[key] = sanitizeAndConvertDates(obj[key]);
|
|
2019
|
-
}
|
|
2020
|
-
return newObj;
|
|
2021
|
-
}
|
|
2022
|
-
if (typeof obj === "string") {
|
|
2023
|
-
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(obj) || /^\w{3} \w{3} \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4} \(.+\)$/.test(obj)) {
|
|
2024
|
-
const date = new Date(obj);
|
|
2025
|
-
if (!isNaN(date.getTime())) return date.toISOString();
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
return obj;
|
|
2010
|
+
function isJunctionRelation(relation) {
|
|
2011
|
+
if (isManyToMany(relation)) return true;
|
|
2012
|
+
return relation.kind === "via" && relation.joinPath.length > 1;
|
|
2029
2013
|
}
|
|
2030
2014
|
/**
|
|
2031
|
-
*
|
|
2015
|
+
* Reach the target row inside a junction row.
|
|
2016
|
+
*
|
|
2017
|
+
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
2018
|
+
* the foreign keys, and the target nested under one of them. The target is the
|
|
2019
|
+
* only object among them, so that is how it is found. A junction row that has
|
|
2020
|
+
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
2021
|
+
*
|
|
2022
|
+
* "The only object" stopped being true when a junction gained
|
|
2023
|
+
* `through.properties`: a payload `map` is an object too, and it sits in the
|
|
2024
|
+
* row beside the target with no guaranteed order between them — so this would
|
|
2025
|
+
* have unwrapped to the payload and served it as the tag. The payload keys are
|
|
2026
|
+
* known from the relation, so they are skipped by name rather than by shape.
|
|
2032
2027
|
*/
|
|
2033
|
-
function
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
joinPathRelationUpdates: []
|
|
2038
|
-
};
|
|
2039
|
-
const result = {};
|
|
2040
|
-
const resolvedRelations = collection ? resolveCollectionRelations(collection) : {};
|
|
2041
|
-
const inverseRelationUpdates = [];
|
|
2042
|
-
const joinPathRelationUpdates = [];
|
|
2043
|
-
const foreignKeys = /* @__PURE__ */ new Set();
|
|
2044
|
-
Object.values(resolvedRelations).forEach((relation) => {
|
|
2045
|
-
if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
|
|
2046
|
-
});
|
|
2047
|
-
for (const [key, value] of Object.entries(row)) {
|
|
2048
|
-
if (isPrototypePollutingKey(key)) continue;
|
|
2049
|
-
if (value === void 0) continue;
|
|
2050
|
-
const property = properties[key];
|
|
2051
|
-
const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
|
|
2052
|
-
if (!property) {
|
|
2053
|
-
result[key] = effectiveValue;
|
|
2054
|
-
continue;
|
|
2055
|
-
}
|
|
2056
|
-
if (property.type === "relation" && collection) {
|
|
2057
|
-
const relation = findRelation(resolvedRelations, key);
|
|
2058
|
-
if (relation) {
|
|
2059
|
-
if (relation.kind === "belongsTo") {
|
|
2060
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
2061
|
-
if (serializedValue !== void 0) result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
|
|
2062
|
-
continue;
|
|
2063
|
-
} else if (hasForeignKeyOnTarget(relation)) {
|
|
2064
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
2065
|
-
inverseRelationUpdates.push({
|
|
2066
|
-
relationKey: key,
|
|
2067
|
-
relation,
|
|
2068
|
-
newValue: serializedValue
|
|
2069
|
-
});
|
|
2070
|
-
continue;
|
|
2071
|
-
} else if (relation.kind === "via") {
|
|
2072
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
2073
|
-
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
2074
|
-
relationKey: key,
|
|
2075
|
-
relation,
|
|
2076
|
-
newTargetId: serializedValue
|
|
2077
|
-
});
|
|
2078
|
-
else inverseRelationUpdates.push({
|
|
2079
|
-
relationKey: key,
|
|
2080
|
-
relation,
|
|
2081
|
-
newValue: serializedValue
|
|
2082
|
-
});
|
|
2083
|
-
continue;
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
2088
|
-
}
|
|
2089
|
-
return {
|
|
2090
|
-
scalarData: result,
|
|
2091
|
-
inverseRelationUpdates,
|
|
2092
|
-
joinPathRelationUpdates
|
|
2093
|
-
};
|
|
2028
|
+
function unwrapJunctionRow(item, relation) {
|
|
2029
|
+
const payload = isManyToMany(relation) ? relation.through.properties : void 0;
|
|
2030
|
+
const nestedKey = Object.keys(item).find((key) => key !== JUNCTION_PIVOT_KEY && !(payload && key in payload) && typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
|
|
2031
|
+
return nestedKey ? item[nestedKey] : item;
|
|
2094
2032
|
}
|
|
2095
2033
|
/**
|
|
2096
|
-
*
|
|
2034
|
+
* Give back the number a `number` property was declared to be.
|
|
2097
2035
|
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
2100
|
-
*
|
|
2036
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
2037
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
2038
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
2039
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
2040
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
2041
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
2042
|
+
* until the first refresh.
|
|
2043
|
+
*
|
|
2044
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
2045
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
2046
|
+
* declares are left exactly as the database returned them.
|
|
2101
2047
|
*/
|
|
2102
|
-
function
|
|
2103
|
-
if (value
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
const type = typeof value;
|
|
2107
|
-
return type === "object" ? "an object" : `a ${type}`;
|
|
2048
|
+
function coerceDeclaredNumber(value, property) {
|
|
2049
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
2050
|
+
const parsed = parseFloat(value);
|
|
2051
|
+
return isNaN(parsed) ? null : parsed;
|
|
2108
2052
|
}
|
|
2109
2053
|
/**
|
|
2110
|
-
*
|
|
2054
|
+
* Serve a `date` column as the timestamp this API says it serves.
|
|
2111
2055
|
*
|
|
2112
|
-
*
|
|
2113
|
-
*
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2056
|
+
* The OpenAPI document this server publishes types a `date` property as
|
|
2057
|
+
* `string, format: date-time` — RFC 3339 — and node-postgres hands over the
|
|
2058
|
+
* Postgres literal: `"2026-08-24 01:37:57.647+02"`, with a space where the `T`
|
|
2059
|
+
* belongs and a two-digit offset. V8 parses that by accident; Safari's
|
|
2060
|
+
* `new Date()` does not have to, and the spec never promised it. A column whose
|
|
2061
|
+
* documented type only parses in some browsers is not a contract.
|
|
2062
|
+
*
|
|
2063
|
+
* A date-only column (`mode: "date"`, `format: "date"` in the spec) is already
|
|
2064
|
+
* RFC 3339 as `YYYY-MM-DD` and is left alone — widening it to a timestamp would
|
|
2065
|
+
* invent a time of day and a timezone the column does not have.
|
|
2116
2066
|
*/
|
|
2117
|
-
function
|
|
2118
|
-
if (value
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
if (value === "") return null;
|
|
2125
|
-
return value;
|
|
2126
|
-
case "array":
|
|
2127
|
-
if (Array.isArray(value)) {
|
|
2128
|
-
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
|
|
2129
|
-
else if (property.oneOf) {
|
|
2130
|
-
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
2131
|
-
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
2132
|
-
return value.map((e) => {
|
|
2133
|
-
if (e === null) return null;
|
|
2134
|
-
if (typeof e !== "object") return e;
|
|
2135
|
-
const rec = e;
|
|
2136
|
-
const type = rec[typeField];
|
|
2137
|
-
const childProperty = property.oneOf?.properties[type];
|
|
2138
|
-
if (!type || !childProperty) return e;
|
|
2139
|
-
return {
|
|
2140
|
-
[typeField]: type,
|
|
2141
|
-
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
2142
|
-
};
|
|
2143
|
-
});
|
|
2144
|
-
}
|
|
2145
|
-
return value;
|
|
2146
|
-
}
|
|
2147
|
-
throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
2148
|
-
case "geopoint": {
|
|
2149
|
-
if (typeof value !== "object" || Array.isArray(value)) throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
2150
|
-
const point = value;
|
|
2151
|
-
if (typeof point.latitude !== "number" || typeof point.longitude !== "number") throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`, "VALIDATION_INVALID_VALUE");
|
|
2152
|
-
return {
|
|
2153
|
-
latitude: point.latitude,
|
|
2154
|
-
longitude: point.longitude
|
|
2155
|
-
};
|
|
2156
|
-
}
|
|
2157
|
-
case "map":
|
|
2158
|
-
if (typeof value === "object" && property.properties) {
|
|
2159
|
-
const result = {};
|
|
2160
|
-
for (const [subKey, subValue] of Object.entries(value)) {
|
|
2161
|
-
const subProperty = property.properties[subKey];
|
|
2162
|
-
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
2163
|
-
else result[subKey] = subValue;
|
|
2164
|
-
}
|
|
2165
|
-
return result;
|
|
2166
|
-
}
|
|
2167
|
-
return value;
|
|
2168
|
-
case "vector":
|
|
2169
|
-
if (value instanceof Vector) return value.value;
|
|
2170
|
-
if (value && typeof value === "object" && "value" in value && Array.isArray(value.value)) return value.value.map(Number);
|
|
2171
|
-
if (Array.isArray(value)) return value.map(Number);
|
|
2172
|
-
return value;
|
|
2173
|
-
case "binary": {
|
|
2174
|
-
const decoded = tryDecodeBase64DataUrl(value);
|
|
2175
|
-
if (decoded) return decoded;
|
|
2176
|
-
if (Buffer.isBuffer(value)) return value;
|
|
2177
|
-
return value;
|
|
2178
|
-
}
|
|
2179
|
-
default: {
|
|
2180
|
-
const decoded = tryDecodeBase64DataUrl(value);
|
|
2181
|
-
if (decoded) return decoded;
|
|
2182
|
-
return value;
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2067
|
+
function toRestDate(value) {
|
|
2068
|
+
if (value instanceof Date) return isNaN(value.getTime()) ? null : value.toISOString();
|
|
2069
|
+
if (value && typeof value === "object" && value.__type === "date") return value.value ?? null;
|
|
2070
|
+
if (typeof value !== "string" && typeof value !== "number") return value;
|
|
2071
|
+
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
|
|
2072
|
+
const date = new Date(value);
|
|
2073
|
+
return isNaN(date.getTime()) ? value : date.toISOString();
|
|
2185
2074
|
}
|
|
2186
2075
|
/**
|
|
2187
|
-
*
|
|
2076
|
+
* One scalar, as REST serves it: declared numbers as numbers, declared dates as
|
|
2077
|
+
* RFC 3339, everything else exactly as the database returned it.
|
|
2188
2078
|
*/
|
|
2189
|
-
|
|
2079
|
+
function toRestScalar(value, property) {
|
|
2080
|
+
if (property?.type === "date") return toRestDate(value);
|
|
2081
|
+
return coerceDeclaredNumber(value, property);
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Apply {@link toRestScalar} across a row, leaving undeclared columns alone.
|
|
2085
|
+
*
|
|
2086
|
+
* Exported because the include loader attaches related rows itself, and a
|
|
2087
|
+
* target rendered differently from its parent is the shape bug this whole file
|
|
2088
|
+
* exists to prevent — a date was a string at the top level and a
|
|
2089
|
+
* `{ __type: "date" }` envelope one level down, in the same response.
|
|
2090
|
+
*/
|
|
2091
|
+
function toRestValues(row, collection) {
|
|
2190
2092
|
const properties = collection.properties;
|
|
2191
|
-
if (!
|
|
2192
|
-
const
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
const sourceTable = registry.getTable(getTableName$1(collection));
|
|
2234
|
-
if (!sourceTable) {
|
|
2235
|
-
logger.warn(`Source table not found for collection: ${collection.slug}`);
|
|
2236
|
-
continue;
|
|
2237
|
-
}
|
|
2238
|
-
let query = db.select().from(sourceTable);
|
|
2239
|
-
let currentTable = sourceTable;
|
|
2240
|
-
for (const join of relation.joinPath) {
|
|
2241
|
-
const joinTable = registry.getTable(join.table);
|
|
2242
|
-
if (!joinTable) {
|
|
2243
|
-
logger.warn(`Join table not found: ${join.table}`);
|
|
2244
|
-
break;
|
|
2245
|
-
}
|
|
2246
|
-
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
2247
|
-
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
2248
|
-
const fromParts = fromColumn.split(".");
|
|
2249
|
-
const toParts = toColumn.split(".");
|
|
2250
|
-
const fromColName = fromParts[fromParts.length - 1];
|
|
2251
|
-
const toColName = toParts[toParts.length - 1];
|
|
2252
|
-
const fromCol = currentTable[fromColName];
|
|
2253
|
-
const toCol = joinTable[toColName];
|
|
2254
|
-
if (!fromCol || !toCol) {
|
|
2255
|
-
logger.warn(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
2256
|
-
break;
|
|
2257
|
-
}
|
|
2258
|
-
query = query.innerJoin(joinTable, eq(fromCol, toCol));
|
|
2259
|
-
currentTable = joinTable;
|
|
2260
|
-
}
|
|
2261
|
-
if (pks.length === 1) {
|
|
2262
|
-
const sourceIdField = sourceTable[pks[0].fieldName];
|
|
2263
|
-
query = query.where(eq(sourceIdField, currentId));
|
|
2264
|
-
} else logger.warn(`Join path resolution for composite primary keys is not yet fully supported: ${collection.slug}`);
|
|
2265
|
-
const additionalFilters = [];
|
|
2266
|
-
let combinedWhere;
|
|
2267
|
-
if (pks.length === 1) {
|
|
2268
|
-
const sourceIdField = sourceTable[pks[0].fieldName];
|
|
2269
|
-
combinedWhere = DrizzleConditionBuilder.combineConditionsWithAnd([eq(sourceIdField, currentId), ...additionalFilters].filter(Boolean));
|
|
2270
|
-
}
|
|
2271
|
-
const joinResults = await query.where(combinedWhere).limit(relation.cardinality === "one" ? 1 : 100);
|
|
2272
|
-
if (joinResults.length > 0) {
|
|
2273
|
-
const targetPks = getPrimaryKeys(targetCollection, registry);
|
|
2274
|
-
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
2275
|
-
if (relation.cardinality === "one") {
|
|
2276
|
-
const joinResult = joinResults[0];
|
|
2277
|
-
result[propKey] = createRelationRef(buildCompositeId$1(joinResult[targetTableName] || joinResult, targetPks), targetCollection.slug);
|
|
2278
|
-
} else result[propKey] = joinResults.map((joinResult) => {
|
|
2279
|
-
return createRelationRef(buildCompositeId$1(joinResult[targetTableName] || joinResult, targetPks), targetCollection.slug);
|
|
2280
|
-
});
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
} catch (e) {
|
|
2284
|
-
logger.warn(`Could not resolve join path relation property: ${propKey}`, { error: e });
|
|
2285
|
-
}
|
|
2093
|
+
if (!properties) return row;
|
|
2094
|
+
const out = {};
|
|
2095
|
+
for (const [key, value] of Object.entries(row)) out[key] = toRestScalar(value, properties[key]);
|
|
2096
|
+
return out;
|
|
2097
|
+
}
|
|
2098
|
+
/** Render one target row in the requested style. */
|
|
2099
|
+
/**
|
|
2100
|
+
* Drop every column this caller may not read.
|
|
2101
|
+
*
|
|
2102
|
+
* Password hashes and verification tokens have to be readable server-side but
|
|
2103
|
+
* must never reach a client — and "never" has to mean every exit from this
|
|
2104
|
+
* pipeline, including relation targets, or a secret leaks through whichever
|
|
2105
|
+
* path was overlooked. Keyed by both the property name and its column name,
|
|
2106
|
+
* since a row can arrive keyed either way depending on the caller.
|
|
2107
|
+
*
|
|
2108
|
+
* `excludeFromApi` is the case with no roles in it: `effectiveAccess` expands
|
|
2109
|
+
* the flag to `read: []`, which no caller satisfies, so the flag needs no branch
|
|
2110
|
+
* of its own here and no longer has one. `access.read: ["hr"]` is the same walk
|
|
2111
|
+
* asking a different question of {@link currentFieldViewer}.
|
|
2112
|
+
*
|
|
2113
|
+
* The field is **deleted**, never nulled. A withheld value that arrives as
|
|
2114
|
+
* `null` is indistinguishable from a stored `null`, which turns a permission
|
|
2115
|
+
* boundary into a question a client can answer by counting nulls — and it makes
|
|
2116
|
+
* an `update` that echoes the row back overwrite the real value with the null it
|
|
2117
|
+
* was handed.
|
|
2118
|
+
*
|
|
2119
|
+
* `_matches` is filtered rather than deleted: it is the list of fields a text
|
|
2120
|
+
* search hit, and a field the caller cannot read must not appear in it even
|
|
2121
|
+
* though the array itself is theirs to see.
|
|
2122
|
+
*/
|
|
2123
|
+
function stripUnreadable(row, collection) {
|
|
2124
|
+
const properties = collection.properties;
|
|
2125
|
+
if (!properties) return row;
|
|
2126
|
+
const viewer = currentFieldViewer();
|
|
2127
|
+
const hidden = /* @__PURE__ */ new Set();
|
|
2128
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
2129
|
+
if (canReadField(property, viewer)) continue;
|
|
2130
|
+
hidden.add(key);
|
|
2131
|
+
delete row[key];
|
|
2132
|
+
if (property.columnName) {
|
|
2133
|
+
hidden.add(property.columnName);
|
|
2134
|
+
delete row[property.columnName];
|
|
2286
2135
|
}
|
|
2287
2136
|
}
|
|
2288
|
-
|
|
2137
|
+
if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
|
|
2138
|
+
return row;
|
|
2139
|
+
}
|
|
2140
|
+
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
2141
|
+
if (style === "inline") return stripUnreadable(toRestValues({ ...targetRow }, targetCollection), targetCollection);
|
|
2142
|
+
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
2143
|
+
const path = targetCollection.slug;
|
|
2144
|
+
return createRelationRefWithData(address, path, {
|
|
2145
|
+
id: address,
|
|
2146
|
+
path,
|
|
2147
|
+
values: stripUnreadable(normalizeDbValues(targetRow, targetCollection), targetCollection)
|
|
2148
|
+
});
|
|
2289
2149
|
}
|
|
2290
2150
|
/**
|
|
2291
|
-
*
|
|
2292
|
-
*
|
|
2151
|
+
* The address a relation ref points at.
|
|
2152
|
+
*
|
|
2153
|
+
* The whole key, not its first column: a composite-keyed target addressed by
|
|
2154
|
+
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
2155
|
+
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
2156
|
+
* array — taking down the parent's fetch over a relation it may not even have
|
|
2157
|
+
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
2158
|
+
* beats no rows at all.
|
|
2293
2159
|
*/
|
|
2294
|
-
function
|
|
2295
|
-
|
|
2296
|
-
if (
|
|
2297
|
-
|
|
2298
|
-
return base64Data ? Buffer.from(base64Data, "base64") : null;
|
|
2160
|
+
function relationTargetAddress(targetRow, targetCollection, registry) {
|
|
2161
|
+
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
2162
|
+
if (address) return address;
|
|
2163
|
+
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
2299
2164
|
}
|
|
2300
2165
|
/**
|
|
2301
|
-
*
|
|
2302
|
-
*
|
|
2303
|
-
*
|
|
2166
|
+
* The row the admin renders: every column, with relations as references.
|
|
2167
|
+
*
|
|
2168
|
+
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
2169
|
+
* expects real types. The row's own address is *not* among the columns — it is
|
|
2170
|
+
* derived by the consumer from the collection's primary keys.
|
|
2304
2171
|
*/
|
|
2305
|
-
function
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2172
|
+
function toFlatRow(row, collection, registry) {
|
|
2173
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2174
|
+
const normalized = normalizeDbValues(row, collection);
|
|
2175
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
2176
|
+
const relData = row[relation.relationName || key];
|
|
2177
|
+
if (relData === void 0 || relData === null) continue;
|
|
2178
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
2179
|
+
const targetCollection = relation.target();
|
|
2180
|
+
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item, relation) : item, targetCollection, "ref", registry));
|
|
2181
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
2310
2182
|
}
|
|
2311
|
-
return
|
|
2183
|
+
return stripUnreadable(normalized, collection);
|
|
2312
2184
|
}
|
|
2313
2185
|
/**
|
|
2314
|
-
*
|
|
2315
|
-
*
|
|
2186
|
+
* The row REST serves: every column under its own name, with the value Postgres
|
|
2187
|
+
* returned, and relations inlined as the target's columns.
|
|
2188
|
+
*
|
|
2189
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
2190
|
+
* declared type: a `number` property is served as a number (see
|
|
2191
|
+
* {@link coerceDeclaredNumber}) and a `date` as RFC 3339, which is what this
|
|
2192
|
+
* server's own OpenAPI document says a date column is (see
|
|
2193
|
+
* {@link toRestDate}).
|
|
2194
|
+
*
|
|
2195
|
+
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
2196
|
+
* the relations `include` asked for, so the row is the authority on which are
|
|
2197
|
+
* actually there.
|
|
2316
2198
|
*/
|
|
2317
|
-
function
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
if (value === null || value === void 0) return value;
|
|
2326
|
-
switch (property.type) {
|
|
2327
|
-
case "binary": {
|
|
2328
|
-
const buf = tryResolveBuffer(value);
|
|
2329
|
-
if (buf) return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
2330
|
-
return value;
|
|
2331
|
-
}
|
|
2332
|
-
case "string": {
|
|
2333
|
-
if (typeof value === "string") return value;
|
|
2334
|
-
const buf = tryResolveBuffer(value);
|
|
2335
|
-
if (buf) return bufferToStringOrBase64(buf);
|
|
2336
|
-
if (typeof value === "object" && value !== null) try {
|
|
2337
|
-
return JSON.stringify(value);
|
|
2338
|
-
} catch {
|
|
2339
|
-
return String(value);
|
|
2340
|
-
}
|
|
2341
|
-
return String(value);
|
|
2342
|
-
}
|
|
2343
|
-
case "relation":
|
|
2344
|
-
if (typeof value === "string" || typeof value === "number") {
|
|
2345
|
-
let relationDef = property.resolvedRelation;
|
|
2346
|
-
if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
|
|
2347
|
-
if (!relationDef) {
|
|
2348
|
-
logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
2349
|
-
return value;
|
|
2350
|
-
}
|
|
2351
|
-
try {
|
|
2352
|
-
const targetCollection = relationDef.target();
|
|
2353
|
-
return createRelationRef(value.toString(), targetCollection.slug);
|
|
2354
|
-
} catch (e) {
|
|
2355
|
-
logger.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, { error: e });
|
|
2356
|
-
return value;
|
|
2357
|
-
}
|
|
2358
|
-
}
|
|
2359
|
-
return value;
|
|
2360
|
-
case "array":
|
|
2361
|
-
if (Array.isArray(value)) {
|
|
2362
|
-
if (property.of) return value.map((item) => parsePropertyFromServer(item, property.of, collection));
|
|
2363
|
-
else if (property.oneOf) {
|
|
2364
|
-
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
2365
|
-
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
2366
|
-
return value.map((e) => {
|
|
2367
|
-
if (e === null) return null;
|
|
2368
|
-
if (typeof e !== "object") return e;
|
|
2369
|
-
const rec = e;
|
|
2370
|
-
const type = rec[typeField];
|
|
2371
|
-
const childProperty = property.oneOf?.properties[type];
|
|
2372
|
-
if (!type || !childProperty) return e;
|
|
2373
|
-
return {
|
|
2374
|
-
[typeField]: type,
|
|
2375
|
-
[valueField]: parsePropertyFromServer(rec[valueField], childProperty, collection)
|
|
2376
|
-
};
|
|
2377
|
-
});
|
|
2378
|
-
}
|
|
2379
|
-
} else {
|
|
2380
|
-
logger.warn(`Expected array value from DB for array property, got ${typeof value}. Coercing to array.`);
|
|
2381
|
-
return typeof value === "string" ? [value] : [];
|
|
2382
|
-
}
|
|
2383
|
-
return value;
|
|
2384
|
-
case "map":
|
|
2385
|
-
if (typeof value === "object" && property.properties) {
|
|
2386
|
-
const result = {};
|
|
2387
|
-
for (const [subKey, subValue] of Object.entries(value)) {
|
|
2388
|
-
const subProperty = property.properties[subKey];
|
|
2389
|
-
if (subProperty) result[subKey] = parsePropertyFromServer(subValue, subProperty, collection);
|
|
2390
|
-
else result[subKey] = subValue;
|
|
2391
|
-
}
|
|
2392
|
-
return result;
|
|
2393
|
-
}
|
|
2394
|
-
return value;
|
|
2395
|
-
case "number":
|
|
2396
|
-
if (typeof value === "string") {
|
|
2397
|
-
const parsed = parseFloat(value);
|
|
2398
|
-
return isNaN(parsed) ? null : parsed;
|
|
2399
|
-
}
|
|
2400
|
-
return value;
|
|
2401
|
-
case "geopoint": return value;
|
|
2402
|
-
case "vector": {
|
|
2403
|
-
let nums = [];
|
|
2404
|
-
if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
|
|
2405
|
-
else if (Array.isArray(value)) nums = value.map(Number);
|
|
2406
|
-
else if (value instanceof Vector) nums = value.value;
|
|
2407
|
-
else if (typeof value === "object" && value !== null && "value" in value) {
|
|
2408
|
-
const valObj = value;
|
|
2409
|
-
if (Array.isArray(valObj.value)) nums = valObj.value.map(Number);
|
|
2410
|
-
}
|
|
2411
|
-
return {
|
|
2412
|
-
__type: "Vector",
|
|
2413
|
-
value: nums
|
|
2414
|
-
};
|
|
2415
|
-
}
|
|
2416
|
-
case "date": {
|
|
2417
|
-
let date;
|
|
2418
|
-
if (value instanceof Date) date = value;
|
|
2419
|
-
else if (typeof value === "string" || typeof value === "number") {
|
|
2420
|
-
const parsedDate = new Date(value);
|
|
2421
|
-
if (!isNaN(parsedDate.getTime())) date = parsedDate;
|
|
2422
|
-
}
|
|
2423
|
-
if (date) return {
|
|
2424
|
-
__type: "date",
|
|
2425
|
-
value: date.toISOString()
|
|
2426
|
-
};
|
|
2427
|
-
return null;
|
|
2428
|
-
}
|
|
2429
|
-
default: {
|
|
2430
|
-
const buf = tryResolveBuffer(value);
|
|
2431
|
-
if (buf) return bufferToStringOrBase64(buf);
|
|
2432
|
-
return value;
|
|
2433
|
-
}
|
|
2199
|
+
function toRestRow(row, collection, registry) {
|
|
2200
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
2201
|
+
const flat = {};
|
|
2202
|
+
for (const [key, value] of Object.entries(row)) {
|
|
2203
|
+
const relation = findRelation(resolvedRelations, key);
|
|
2204
|
+
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item, relation) : item, relation.target(), "inline", registry));
|
|
2205
|
+
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
2206
|
+
else flat[key] = toRestScalar(value, collection.properties?.[key]);
|
|
2434
2207
|
}
|
|
2208
|
+
return stripUnreadable(flat, collection);
|
|
2435
2209
|
}
|
|
2210
|
+
//#endregion
|
|
2211
|
+
//#region src/services/write-denial.ts
|
|
2436
2212
|
/**
|
|
2437
|
-
*
|
|
2438
|
-
* into frontend format. Handles FK-column preservation, type coercion
|
|
2439
|
-
* (dates, numbers, NaN), and property filtering.
|
|
2213
|
+
* Explain a write that matched no rows.
|
|
2440
2214
|
*
|
|
2441
|
-
*
|
|
2442
|
-
*
|
|
2443
|
-
*
|
|
2215
|
+
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
2216
|
+
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
2217
|
+
* like a successful one that happened to match nothing. Left unchecked, a
|
|
2218
|
+
* caller cannot tell "denied" from "done" — the write returns 200/204 and the
|
|
2219
|
+
* row is untouched. An agent handed a key with `orders:delete` and no delete
|
|
2220
|
+
* policy is the case that makes it concrete: it deletes nothing, forever, and
|
|
2221
|
+
* is told it worked every time.
|
|
2222
|
+
*
|
|
2223
|
+
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
2224
|
+
* cases. A visible row means the policy rejected the write (403); an invisible
|
|
2225
|
+
* one means there is nothing there to write for this caller (404, matching what
|
|
2226
|
+
* a GET would say). The re-read is bound by the caller's own policies, so it
|
|
2227
|
+
* discloses nothing a plain read wouldn't.
|
|
2228
|
+
*
|
|
2229
|
+
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
2230
|
+
*
|
|
2231
|
+
* It lives here, rather than beside its first caller, because every zero-row
|
|
2232
|
+
* write has to answer the same question and answer it identically: the rule
|
|
2233
|
+
* that a readable-but-unwritable target is a 403 is the contract, and a second
|
|
2234
|
+
* copy of it is a second chance to get it wrong.
|
|
2235
|
+
*
|
|
2236
|
+
* @param handle The RLS-scoped connection the write ran on — not a fresh
|
|
2237
|
+
* one, or the re-read would answer for a different caller.
|
|
2238
|
+
* @param table The table the write targeted (the junction, for a link).
|
|
2239
|
+
* @param conditions The write's own WHERE terms, reused verbatim.
|
|
2240
|
+
* @param denied Message for the 403: the target is there and was refused.
|
|
2241
|
+
* @param missing Message for the 404: there is nothing there for this caller.
|
|
2444
2242
|
*/
|
|
2243
|
+
async function explainZeroRowWrite(handle, table, conditions, denied, missing) {
|
|
2244
|
+
if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(denied, "WRITE_DENIED");
|
|
2245
|
+
return ApiError.notFound(missing);
|
|
2246
|
+
}
|
|
2247
|
+
//#endregion
|
|
2248
|
+
//#region src/services/junction-writes.ts
|
|
2249
|
+
/** A junction that cannot be resolved is a broken relation, not a no-op. */
|
|
2250
|
+
var misconfigured = (label, detail) => relationMisconfigured(label, detail);
|
|
2251
|
+
function column(table, name, label, role) {
|
|
2252
|
+
const col = name ? table[name] : void 0;
|
|
2253
|
+
if (!col) throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
|
|
2254
|
+
return col;
|
|
2255
|
+
}
|
|
2256
|
+
/** The junction a `manyToMany` names outright. */
|
|
2257
|
+
function bindThroughJunction(registry, through, label) {
|
|
2258
|
+
const table = registry.getTable(through.table);
|
|
2259
|
+
if (!table) throw misconfigured(label, `no table '${through.table}' in the registry`);
|
|
2260
|
+
return {
|
|
2261
|
+
table,
|
|
2262
|
+
parentColumn: column(table, through.sourceColumn, label, "source"),
|
|
2263
|
+
targetColumn: column(table, through.targetColumn, label, "target"),
|
|
2264
|
+
label,
|
|
2265
|
+
payload: through.properties ?? {}
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
/** The bare column name, whether written as `col` or `table.col`. */
|
|
2269
|
+
var columnPart = (spec) => {
|
|
2270
|
+
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2271
|
+
return one.includes(".") ? one.split(".")[1] : one;
|
|
2272
|
+
};
|
|
2273
|
+
/** The table a column spec names, or undefined when it is unqualified. */
|
|
2274
|
+
var tablePart = (spec) => {
|
|
2275
|
+
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2276
|
+
return one.includes(".") ? one.split(".")[0] : void 0;
|
|
2277
|
+
};
|
|
2445
2278
|
/**
|
|
2446
|
-
*
|
|
2279
|
+
* The junction a `via` relation reaches through, from either end.
|
|
2447
2280
|
*
|
|
2448
|
-
*
|
|
2449
|
-
*
|
|
2450
|
-
*
|
|
2281
|
+
* A step is `{ table: T, on: { from, to } }` where `from` names a column on
|
|
2282
|
+
* whatever the walk was standing on and `to` names one on `T`. That positional
|
|
2283
|
+
* meaning is what makes the unqualified form work at all, so the walk carries
|
|
2284
|
+
* the previous table rather than asking the column names where they live.
|
|
2451
2285
|
*/
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
"
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
const
|
|
2463
|
-
if (
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
if (
|
|
2467
|
-
|
|
2468
|
-
continue;
|
|
2469
|
-
}
|
|
2470
|
-
if (QUERY_METADATA_KEYS.has(key)) {
|
|
2471
|
-
result[key] = value;
|
|
2472
|
-
continue;
|
|
2473
|
-
}
|
|
2474
|
-
const property = properties[key];
|
|
2475
|
-
if (!property) continue;
|
|
2476
|
-
if (options.skipRelations && property.type === "relation") continue;
|
|
2477
|
-
result[key] = parsePropertyFromServer(value, property, collection, key);
|
|
2286
|
+
function bindJoinPathJunction(registry, joinPath, parentTableName, targetTableName, label) {
|
|
2287
|
+
const junctionName = joinPath.map((step) => step.table).find((table) => table !== parentTableName && table !== targetTableName);
|
|
2288
|
+
if (!junctionName) throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
|
|
2289
|
+
const table = registry.getTable(junctionName);
|
|
2290
|
+
if (!table) throw misconfigured(label, `no table '${junctionName}' in the registry`);
|
|
2291
|
+
let parentColumnName;
|
|
2292
|
+
let targetColumnName;
|
|
2293
|
+
let previousTable = parentTableName;
|
|
2294
|
+
for (const step of joinPath) {
|
|
2295
|
+
const fromTable = tablePart(step.on.from) ?? previousTable;
|
|
2296
|
+
const toTable = tablePart(step.on.to) ?? step.table;
|
|
2297
|
+
if (toTable === junctionName && fromTable === parentTableName) parentColumnName = columnPart(step.on.to);
|
|
2298
|
+
else if (fromTable === junctionName && toTable === parentTableName) parentColumnName = columnPart(step.on.from);
|
|
2299
|
+
else if (toTable === junctionName && fromTable === targetTableName) targetColumnName = columnPart(step.on.to);
|
|
2300
|
+
else if (fromTable === junctionName && toTable === targetTableName) targetColumnName = columnPart(step.on.from);
|
|
2301
|
+
previousTable = step.table;
|
|
2478
2302
|
}
|
|
2479
|
-
|
|
2303
|
+
if (!parentColumnName || !targetColumnName) throw misconfigured(label, `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`);
|
|
2304
|
+
return {
|
|
2305
|
+
table,
|
|
2306
|
+
parentColumn: column(table, parentColumnName, label, "source"),
|
|
2307
|
+
targetColumn: column(table, targetColumnName, label, "target"),
|
|
2308
|
+
label,
|
|
2309
|
+
payload: {}
|
|
2310
|
+
};
|
|
2480
2311
|
}
|
|
2481
2312
|
/**
|
|
2482
|
-
*
|
|
2483
|
-
* Only handles type coercion (dates, numbers, NaN) and property filtering.
|
|
2484
|
-
* Does NOT query the database for relations — those are already resolved
|
|
2485
|
-
* by Drizzle's relational query API.
|
|
2313
|
+
* Remove one link, leaving the row on the far side alone.
|
|
2486
2314
|
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2315
|
+
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
2316
|
+
* target is shared, so deleting the row would remove the tag from every other
|
|
2317
|
+
* post that uses it.
|
|
2489
2318
|
*/
|
|
2490
|
-
function
|
|
2491
|
-
const
|
|
2492
|
-
if (
|
|
2493
|
-
|
|
2319
|
+
async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
2320
|
+
const conditions = [eq(binding.parentColumn, parentId), eq(binding.targetColumn, targetId)];
|
|
2321
|
+
if (((await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0) === 0) throw await explainZeroRowWrite(tx, binding.table, conditions, `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": a row-level security policy rejected the write.`, `No "${subject.relation}" link between "${subject.parent}" "${parentId}" and "${targetId}" to remove.`);
|
|
2322
|
+
logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
|
|
2494
2323
|
}
|
|
2495
|
-
//#endregion
|
|
2496
|
-
//#region src/services/row-pipeline.ts
|
|
2497
2324
|
/**
|
|
2498
|
-
*
|
|
2325
|
+
* Make the junction say that `parentId` is linked to exactly `targetIds`, by
|
|
2326
|
+
* diffing against what is linked now rather than replacing the set.
|
|
2499
2327
|
*
|
|
2500
|
-
*
|
|
2501
|
-
* the
|
|
2502
|
-
*
|
|
2328
|
+
* A save of the parent used to delete every junction row for it and re-insert
|
|
2329
|
+
* the ids the browser sent — a list the browser assembled from a read it did
|
|
2330
|
+
* earlier. Three things followed, all data loss rather than display:
|
|
2331
|
+
*
|
|
2332
|
+
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
|
|
2333
|
+
* saves any field from a form that predates it, and X is gone with nothing
|
|
2334
|
+
* reported to either of them.
|
|
2335
|
+
* - **A partially-read set is a partially-deleted set.** The read that fills
|
|
2336
|
+
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
2337
|
+
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
2338
|
+
* the links they were never shown. The select that drives the diff runs in
|
|
2339
|
+
* this same transaction under the same policies, so a link the caller cannot
|
|
2340
|
+
* read is in neither list and survives the save.
|
|
2341
|
+
* - **Junction payload columns.** A junction carrying its own columns
|
|
2342
|
+
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
2343
|
+
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
2344
|
+
*
|
|
2345
|
+
* The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
|
|
2346
|
+
* concurrently is a no-op rather than a unique violation.
|
|
2347
|
+
*
|
|
2348
|
+
* ## The payload
|
|
2349
|
+
*
|
|
2350
|
+
* An element may carry `pivot` — the link's own columns, from a `_pivot` on the
|
|
2351
|
+
* wire. The array still *sets membership*: what is not in it is unlinked, and
|
|
2352
|
+
* what is in it is linked. The payload is what the link should hold once it
|
|
2353
|
+
* exists, so a new link is inserted with it (columns the caller left out take
|
|
2354
|
+
* their DEFAULT) and an existing link named with a payload is updated to match.
|
|
2355
|
+
*
|
|
2356
|
+
* The alternative — ignoring the payload on a link that already exists — makes
|
|
2357
|
+
* `PATCH { members: [{ id: 7, _pivot: { role: "admin" } }] }` a no-op that
|
|
2358
|
+
* answers 200, which is the one outcome a caller cannot tell from success. An
|
|
2359
|
+
* element with no `pivot` at all leaves the existing link exactly as it was;
|
|
2360
|
+
* that is what keeps a plain `[1, 2, 3]` membership write from wiping the
|
|
2361
|
+
* `role`s off the links it did not mention.
|
|
2503
2362
|
*/
|
|
2504
|
-
function
|
|
2505
|
-
|
|
2506
|
-
|
|
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));
|
|
2365
|
+
const existingById = /* @__PURE__ */ new Map();
|
|
2366
|
+
for (const row of existingRows) {
|
|
2367
|
+
if (row.targetId === null || row.targetId === void 0) continue;
|
|
2368
|
+
existingById.set(String(row.targetId), row.targetId);
|
|
2369
|
+
}
|
|
2370
|
+
const wantedById = /* @__PURE__ */ new Map();
|
|
2371
|
+
for (const link of links) {
|
|
2372
|
+
if (link.id === null || link.id === void 0) continue;
|
|
2373
|
+
wantedById.set(String(link.id), link);
|
|
2374
|
+
}
|
|
2375
|
+
const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
|
|
2376
|
+
const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, link]) => link);
|
|
2377
|
+
const repayloaded = [...wantedById.entries()].filter(([key, link]) => existingById.has(key) && link.pivot && Object.keys(link.pivot).length > 0).map(([, link]) => link);
|
|
2378
|
+
if (removed.length > 0) await removeLinks(tx, binding, parentId, removed);
|
|
2379
|
+
if (added.length > 0) await tx.insert(binding.table).values(added.map((link) => ({
|
|
2380
|
+
[binding.parentColumn.name]: parentId,
|
|
2381
|
+
[binding.targetColumn.name]: link.id,
|
|
2382
|
+
...link.pivot ?? {}
|
|
2383
|
+
}))).onConflictDoNothing();
|
|
2384
|
+
for (const link of repayloaded) await tx.update(binding.table).set(link.pivot).where(and(eq(binding.parentColumn, parentId), eq(binding.targetColumn, link.id)));
|
|
2507
2385
|
}
|
|
2508
2386
|
/**
|
|
2509
|
-
*
|
|
2387
|
+
* Set the columns of ONE existing link, without touching the membership.
|
|
2510
2388
|
*
|
|
2511
|
-
*
|
|
2512
|
-
*
|
|
2513
|
-
*
|
|
2514
|
-
*
|
|
2389
|
+
* The membership array cannot express this on its own: sending one element
|
|
2390
|
+
* would unlink everything else, and sending the whole set to change one link's
|
|
2391
|
+
* `role` is the lost-update race {@link applyJunctionMembership}'s diff exists
|
|
2392
|
+
* to avoid, reintroduced by the caller. So there is a route for it —
|
|
2393
|
+
* `PATCH /api/data/:slug/:id/:relation/:targetId` with a `_pivot` body.
|
|
2394
|
+
*
|
|
2395
|
+
* A link that is not there is a **404**, not a silent insert: "update the role
|
|
2396
|
+
* on this membership" and "create this membership" are different requests, and
|
|
2397
|
+
* a caller who mistyped an id should hear about it rather than get a new link
|
|
2398
|
+
* with a partial payload. Zero rows is also how a policy refusal arrives on
|
|
2399
|
+
* Postgres, which is why the distinction is drawn by
|
|
2400
|
+
* {@link explainZeroRowWrite} rather than by this function guessing.
|
|
2515
2401
|
*/
|
|
2516
|
-
function
|
|
2517
|
-
const
|
|
2518
|
-
|
|
2402
|
+
async function updateJunctionPivot(tx, binding, parentId, targetId, values, subject) {
|
|
2403
|
+
const conditions = [eq(binding.parentColumn, parentId), eq(binding.targetColumn, targetId)];
|
|
2404
|
+
if (((await tx.update(binding.table).set(values).where(and(...conditions))).rowCount ?? 0) === 0) throw await explainZeroRowWrite(tx, binding.table, conditions, `Not allowed to update the link between "${subject.parent}" "${parentId}" and "${targetId}": a row-level security policy rejected the write.`, `No "${subject.relation}" link between "${subject.parent}" "${parentId}" and "${targetId}" to update.`);
|
|
2405
|
+
logger.info(`Updated '${subject.relation}' link ${targetId} on ${subject.parent} ${parentId}`);
|
|
2519
2406
|
}
|
|
2520
2407
|
/**
|
|
2521
|
-
*
|
|
2408
|
+
* Drop the named links, and refuse to call it done if the database kept any.
|
|
2522
2409
|
*
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
2525
|
-
*
|
|
2526
|
-
*
|
|
2527
|
-
*
|
|
2528
|
-
* follows answers with the string. Any client that multiplies a price works
|
|
2529
|
-
* until the first refresh.
|
|
2530
|
-
*
|
|
2531
|
-
* Declaring a property `number` already accepts double precision — that is what
|
|
2532
|
-
* the admin has always parsed it to. Columns REST serves that no property
|
|
2533
|
-
* declares are left exactly as the database returned them.
|
|
2410
|
+
* Every id here came out of the select in {@link applyJunctionMembership}, on
|
|
2411
|
+
* this same handle, so all of them were visible. Fewer deletions than that means
|
|
2412
|
+
* something refused them — and a save that reports success while the membership
|
|
2413
|
+
* it stored is not the membership it was given has told the caller something
|
|
2414
|
+
* untrue about the database.
|
|
2534
2415
|
*/
|
|
2535
|
-
function
|
|
2536
|
-
|
|
2537
|
-
const
|
|
2538
|
-
|
|
2416
|
+
async function removeLinks(tx, binding, parentId, removed) {
|
|
2417
|
+
const conditions = [eq(binding.parentColumn, parentId), inArray(binding.targetColumn, removed)];
|
|
2418
|
+
const deleted = (await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0;
|
|
2419
|
+
if (deleted >= removed.length) return;
|
|
2420
|
+
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2421
|
+
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");
|
|
2539
2422
|
}
|
|
2540
2423
|
/**
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
2543
|
-
* The OpenAPI document this server publishes types a `date` property as
|
|
2544
|
-
* `string, format: date-time` — RFC 3339 — and node-postgres hands over the
|
|
2545
|
-
* Postgres literal: `"2026-08-24 01:37:57.647+02"`, with a space where the `T`
|
|
2546
|
-
* belongs and a two-digit offset. V8 parses that by accident; Safari's
|
|
2547
|
-
* `new Date()` does not have to, and the spec never promised it. A column whose
|
|
2548
|
-
* documented type only parses in some browsers is not a contract.
|
|
2424
|
+
* The soft-delete field this collection uses, or `undefined` if it has none.
|
|
2549
2425
|
*
|
|
2550
|
-
*
|
|
2551
|
-
*
|
|
2552
|
-
*
|
|
2426
|
+
* Returns the field even when the property is missing, because the *boot* check
|
|
2427
|
+
* is what refuses that config; making this return `undefined` for it would turn
|
|
2428
|
+
* a misconfiguration into "soft delete quietly off", which is the failure mode
|
|
2429
|
+
* worth avoiding — deletes would silently start removing rows.
|
|
2553
2430
|
*/
|
|
2554
|
-
function
|
|
2555
|
-
|
|
2556
|
-
if (
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2431
|
+
function resolveSoftDelete(collection) {
|
|
2432
|
+
const declared = collection?.softDelete;
|
|
2433
|
+
if (!declared) return void 0;
|
|
2434
|
+
const field = typeof declared === "object" && declared.field || "deletedAt";
|
|
2435
|
+
return {
|
|
2436
|
+
field,
|
|
2437
|
+
columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
|
|
2438
|
+
};
|
|
2561
2439
|
}
|
|
2562
2440
|
/**
|
|
2563
|
-
*
|
|
2564
|
-
*
|
|
2441
|
+
* The drizzle column behind the soft-delete field, if the table has one.
|
|
2442
|
+
*
|
|
2443
|
+
* Matched on the column's own `name` rather than on the key, because a
|
|
2444
|
+
* generated schema keys columns by the property name while an introspected one
|
|
2445
|
+
* may key them by the column — and this has to work for both.
|
|
2565
2446
|
*/
|
|
2566
|
-
function
|
|
2567
|
-
|
|
2568
|
-
|
|
2447
|
+
function softDeleteColumn(collection, table) {
|
|
2448
|
+
const resolved = resolveSoftDelete(collection);
|
|
2449
|
+
if (!resolved) return void 0;
|
|
2450
|
+
const columns = table ? getTableColumns(table) : void 0;
|
|
2451
|
+
if (!columns) return void 0;
|
|
2452
|
+
const direct = columns[resolved.field] ?? columns[resolved.columnName];
|
|
2453
|
+
if (direct) return direct;
|
|
2454
|
+
for (const column of Object.values(columns)) if (column.name === resolved.columnName) return column;
|
|
2569
2455
|
}
|
|
2570
2456
|
/**
|
|
2571
|
-
*
|
|
2457
|
+
* `deleted_at IS NULL` — or nothing, when the caller asked for the deleted rows
|
|
2458
|
+
* or the collection has no soft delete at all.
|
|
2572
2459
|
*
|
|
2573
|
-
*
|
|
2574
|
-
*
|
|
2575
|
-
* exists to prevent — a date was a string at the top level and a
|
|
2576
|
-
* `{ __type: "date" }` envelope one level down, in the same response.
|
|
2460
|
+
* The one place the rule is written. Call it from every read; `undefined` means
|
|
2461
|
+
* "add no condition", which composes with `and(...)` unchanged.
|
|
2577
2462
|
*/
|
|
2578
|
-
function
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
return out;
|
|
2463
|
+
function softDeleteCondition(collection, table, withDeleted) {
|
|
2464
|
+
if (withDeleted === true) return void 0;
|
|
2465
|
+
const column = softDeleteColumn(collection, table);
|
|
2466
|
+
if (!column) return void 0;
|
|
2467
|
+
return withDeleted === "only" ? isNotNull(column) : isNull(column);
|
|
2584
2468
|
}
|
|
2585
|
-
/** Render one target row in the requested style. */
|
|
2586
2469
|
/**
|
|
2587
|
-
*
|
|
2588
|
-
*
|
|
2589
|
-
* Password hashes and verification tokens have to be readable server-side but
|
|
2590
|
-
* must never reach a client — and "never" has to mean every exit from this
|
|
2591
|
-
* pipeline, including relation targets, or a secret leaks through whichever
|
|
2592
|
-
* path was overlooked. Keyed by both the property name and its column name,
|
|
2593
|
-
* since a row can arrive keyed either way depending on the caller.
|
|
2594
|
-
*
|
|
2595
|
-
* `excludeFromApi` is the case with no roles in it: `effectiveAccess` expands
|
|
2596
|
-
* the flag to `read: []`, which no caller satisfies, so the flag needs no branch
|
|
2597
|
-
* of its own here and no longer has one. `access.read: ["hr"]` is the same walk
|
|
2598
|
-
* asking a different question of {@link currentFieldViewer}.
|
|
2599
|
-
*
|
|
2600
|
-
* The field is **deleted**, never nulled. A withheld value that arrives as
|
|
2601
|
-
* `null` is indistinguishable from a stored `null`, which turns a permission
|
|
2602
|
-
* boundary into a question a client can answer by counting nulls — and it makes
|
|
2603
|
-
* an `update` that echoes the row back overwrite the real value with the null it
|
|
2604
|
-
* was handed.
|
|
2470
|
+
* Fold the soft-delete condition into a list of conditions that is being built.
|
|
2605
2471
|
*
|
|
2606
|
-
*
|
|
2607
|
-
*
|
|
2608
|
-
* though the array itself is theirs to see.
|
|
2472
|
+
* A convenience for the call sites that already collect `SQL[]`, so adding soft
|
|
2473
|
+
* delete to one of them is a single line rather than three.
|
|
2609
2474
|
*/
|
|
2610
|
-
function
|
|
2611
|
-
const
|
|
2612
|
-
if (
|
|
2613
|
-
|
|
2614
|
-
const hidden = /* @__PURE__ */ new Set();
|
|
2615
|
-
for (const [key, property] of Object.entries(properties)) {
|
|
2616
|
-
if (canReadField(property, viewer)) continue;
|
|
2617
|
-
hidden.add(key);
|
|
2618
|
-
delete row[key];
|
|
2619
|
-
if (property.columnName) {
|
|
2620
|
-
hidden.add(property.columnName);
|
|
2621
|
-
delete row[property.columnName];
|
|
2622
|
-
}
|
|
2623
|
-
}
|
|
2624
|
-
if (hidden.size > 0 && Array.isArray(row._matches)) row._matches = row._matches.filter((match) => !hidden.has(typeof match === "string" ? match : String(match?.field)));
|
|
2625
|
-
return row;
|
|
2626
|
-
}
|
|
2627
|
-
function renderTarget(targetRow, targetCollection, style, registry) {
|
|
2628
|
-
if (style === "inline") return stripUnreadable(toRestValues({ ...targetRow }, targetCollection), targetCollection);
|
|
2629
|
-
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
2630
|
-
const path = targetCollection.slug;
|
|
2631
|
-
return createRelationRefWithData(address, path, {
|
|
2632
|
-
id: address,
|
|
2633
|
-
path,
|
|
2634
|
-
values: stripUnreadable(normalizeDbValues(targetRow, targetCollection), targetCollection)
|
|
2635
|
-
});
|
|
2475
|
+
function withSoftDelete(conditions, collection, table, withDeleted) {
|
|
2476
|
+
const condition = softDeleteCondition(collection, table, withDeleted);
|
|
2477
|
+
if (condition) conditions.push(condition);
|
|
2478
|
+
return conditions;
|
|
2636
2479
|
}
|
|
2637
2480
|
/**
|
|
2638
|
-
*
|
|
2481
|
+
* Combine an existing `where` with the soft-delete condition.
|
|
2639
2482
|
*
|
|
2640
|
-
*
|
|
2641
|
-
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
2642
|
-
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
2643
|
-
* array — taking down the parent's fetch over a relation it may not even have
|
|
2644
|
-
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
2645
|
-
* beats no rows at all.
|
|
2483
|
+
* For the call sites that hold one composed condition rather than a list.
|
|
2646
2484
|
*/
|
|
2647
|
-
function
|
|
2648
|
-
const
|
|
2649
|
-
if (
|
|
2650
|
-
return
|
|
2485
|
+
function andSoftDelete(where, collection, table, withDeleted) {
|
|
2486
|
+
const condition = softDeleteCondition(collection, table, withDeleted);
|
|
2487
|
+
if (!condition) return where;
|
|
2488
|
+
return where ? and(where, condition) : condition;
|
|
2651
2489
|
}
|
|
2490
|
+
//#endregion
|
|
2491
|
+
//#region src/services/RelationService.ts
|
|
2652
2492
|
/**
|
|
2653
|
-
*
|
|
2493
|
+
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
2494
|
+
* Drizzle's `$dynamic()` queries lose the `innerJoin` method from
|
|
2495
|
+
* their static type, but it exists at runtime. This helper bridges
|
|
2496
|
+
* the gap with a single confined cast.
|
|
2654
2497
|
*
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2498
|
+
* Exported so `data-transformer` uses this one rather than restating the same
|
|
2499
|
+
* fact — it wrote `query.innerJoin(...) as unknown as typeof query`, which is
|
|
2500
|
+
* the same claim about the same drizzle behaviour in a second spelling.
|
|
2658
2501
|
*/
|
|
2659
|
-
function
|
|
2660
|
-
|
|
2661
|
-
const normalized = normalizeDbValues(row, collection);
|
|
2662
|
-
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
2663
|
-
const relData = row[relation.relationName || key];
|
|
2664
|
-
if (relData === void 0 || relData === null) continue;
|
|
2665
|
-
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
2666
|
-
const targetCollection = relation.target();
|
|
2667
|
-
normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
|
|
2668
|
-
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
|
|
2669
|
-
}
|
|
2670
|
-
return stripUnreadable(normalized, collection);
|
|
2502
|
+
function applyDynamicJoin(query, joinTable, condition) {
|
|
2503
|
+
return query.innerJoin(joinTable, condition);
|
|
2671
2504
|
}
|
|
2672
2505
|
/**
|
|
2673
|
-
*
|
|
2674
|
-
*
|
|
2675
|
-
*
|
|
2676
|
-
* Values are the ones the database returned, except where that contradicts the
|
|
2677
|
-
* declared type: a `number` property is served as a number (see
|
|
2678
|
-
* {@link coerceDeclaredNumber}) and a `date` as RFC 3339, which is what this
|
|
2679
|
-
* server's own OpenAPI document says a date column is (see
|
|
2680
|
-
* {@link toRestDate}).
|
|
2681
|
-
*
|
|
2682
|
-
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
2683
|
-
* the relations `include` asked for, so the row is the authority on which are
|
|
2684
|
-
* actually there.
|
|
2506
|
+
* Typed wrapper for DrizzleConditionBuilder.buildRelationQuery on dynamic queries.
|
|
2507
|
+
* The method returns a widened generic that doesn't reassign cleanly;
|
|
2508
|
+
* this helper confines the cast.
|
|
2685
2509
|
*/
|
|
2686
|
-
function
|
|
2687
|
-
|
|
2688
|
-
const flat = {};
|
|
2689
|
-
for (const [key, value] of Object.entries(row)) {
|
|
2690
|
-
const relation = findRelation(resolvedRelations, key);
|
|
2691
|
-
if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
|
|
2692
|
-
else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
|
|
2693
|
-
else flat[key] = toRestScalar(value, collection.properties?.[key]);
|
|
2694
|
-
}
|
|
2695
|
-
return stripUnreadable(flat, collection);
|
|
2510
|
+
function applyDynamicRelationQuery(query, ...args) {
|
|
2511
|
+
return DrizzleConditionBuilder.buildRelationQuery(...args);
|
|
2696
2512
|
}
|
|
2697
|
-
//#endregion
|
|
2698
|
-
//#region src/services/write-denial.ts
|
|
2699
2513
|
/**
|
|
2700
|
-
*
|
|
2701
|
-
*
|
|
2702
|
-
* Row-level security filters UPDATE and DELETE through the policy's USING
|
|
2703
|
-
* clause instead of raising: a denied write is reported by Postgres exactly
|
|
2704
|
-
* like a successful one that happened to match nothing. Left unchecked, a
|
|
2705
|
-
* caller cannot tell "denied" from "done" — the write returns 200/204 and the
|
|
2706
|
-
* row is untouched. An agent handed a key with `orders:delete` and no delete
|
|
2707
|
-
* policy is the case that makes it concrete: it deletes nothing, forever, and
|
|
2708
|
-
* is told it worked every time.
|
|
2709
|
-
*
|
|
2710
|
-
* Re-reading the target over the *same* RLS-scoped handle separates the two
|
|
2711
|
-
* cases. A visible row means the policy rejected the write (403); an invisible
|
|
2712
|
-
* one means there is nothing there to write for this caller (404, matching what
|
|
2713
|
-
* a GET would say). The re-read is bound by the caller's own policies, so it
|
|
2714
|
-
* discloses nothing a plain read wouldn't.
|
|
2715
|
-
*
|
|
2716
|
-
* Only reached when zero rows matched, so the happy path pays nothing.
|
|
2717
|
-
*
|
|
2718
|
-
* It lives here, rather than beside its first caller, because every zero-row
|
|
2719
|
-
* write has to answer the same question and answer it identically: the rule
|
|
2720
|
-
* that a readable-but-unwritable target is a 403 is the contract, and a second
|
|
2721
|
-
* copy of it is a second chance to get it wrong.
|
|
2514
|
+
* The relation's own matching condition, AND-ed with an include's `where`.
|
|
2722
2515
|
*
|
|
2723
|
-
*
|
|
2724
|
-
*
|
|
2725
|
-
*
|
|
2726
|
-
*
|
|
2727
|
-
* @param denied Message for the 403: the target is there and was refused.
|
|
2728
|
-
* @param missing Message for the 404: there is nothing there for this caller.
|
|
2516
|
+
* Written once because the batch loaders reach the same decision from six
|
|
2517
|
+
* places — one per relation kind, twice over for the to-one and to-many
|
|
2518
|
+
* variants — and a `where` dropped from one of them is a filter the caller
|
|
2519
|
+
* asked for that silently did not apply.
|
|
2729
2520
|
*/
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
return ApiError.notFound(missing);
|
|
2733
|
-
}
|
|
2734
|
-
//#endregion
|
|
2735
|
-
//#region src/services/junction-writes.ts
|
|
2736
|
-
/** A junction that cannot be resolved is a broken relation, not a no-op. */
|
|
2737
|
-
var misconfigured = (label, detail) => relationMisconfigured(label, detail);
|
|
2738
|
-
function column(table, name, label, role) {
|
|
2739
|
-
const col = name ? table[name] : void 0;
|
|
2740
|
-
if (!col) throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
|
|
2741
|
-
return col;
|
|
2742
|
-
}
|
|
2743
|
-
/** The junction a `manyToMany` names outright. */
|
|
2744
|
-
function bindThroughJunction(registry, through, label) {
|
|
2745
|
-
const table = registry.getTable(through.table);
|
|
2746
|
-
if (!table) throw misconfigured(label, `no table '${through.table}' in the registry`);
|
|
2747
|
-
return {
|
|
2748
|
-
table,
|
|
2749
|
-
parentColumn: column(table, through.sourceColumn, label, "source"),
|
|
2750
|
-
targetColumn: column(table, through.targetColumn, label, "target"),
|
|
2751
|
-
label
|
|
2752
|
-
};
|
|
2753
|
-
}
|
|
2754
|
-
/** The bare column name, whether written as `col` or `table.col`. */
|
|
2755
|
-
var columnPart = (spec) => {
|
|
2756
|
-
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2757
|
-
return one.includes(".") ? one.split(".")[1] : one;
|
|
2758
|
-
};
|
|
2759
|
-
/** The table a column spec names, or undefined when it is unqualified. */
|
|
2760
|
-
var tablePart = (spec) => {
|
|
2761
|
-
const one = Array.isArray(spec) ? spec[0] : spec;
|
|
2762
|
-
return one.includes(".") ? one.split(".")[0] : void 0;
|
|
2763
|
-
};
|
|
2764
|
-
/**
|
|
2765
|
-
* The junction a `via` relation reaches through, from either end.
|
|
2766
|
-
*
|
|
2767
|
-
* A step is `{ table: T, on: { from, to } }` where `from` names a column on
|
|
2768
|
-
* whatever the walk was standing on and `to` names one on `T`. That positional
|
|
2769
|
-
* meaning is what makes the unqualified form work at all, so the walk carries
|
|
2770
|
-
* the previous table rather than asking the column names where they live.
|
|
2771
|
-
*/
|
|
2772
|
-
function bindJoinPathJunction(registry, joinPath, parentTableName, targetTableName, label) {
|
|
2773
|
-
const junctionName = joinPath.map((step) => step.table).find((table) => table !== parentTableName && table !== targetTableName);
|
|
2774
|
-
if (!junctionName) throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
|
|
2775
|
-
const table = registry.getTable(junctionName);
|
|
2776
|
-
if (!table) throw misconfigured(label, `no table '${junctionName}' in the registry`);
|
|
2777
|
-
let parentColumnName;
|
|
2778
|
-
let targetColumnName;
|
|
2779
|
-
let previousTable = parentTableName;
|
|
2780
|
-
for (const step of joinPath) {
|
|
2781
|
-
const fromTable = tablePart(step.on.from) ?? previousTable;
|
|
2782
|
-
const toTable = tablePart(step.on.to) ?? step.table;
|
|
2783
|
-
if (toTable === junctionName && fromTable === parentTableName) parentColumnName = columnPart(step.on.to);
|
|
2784
|
-
else if (fromTable === junctionName && toTable === parentTableName) parentColumnName = columnPart(step.on.from);
|
|
2785
|
-
else if (toTable === junctionName && fromTable === targetTableName) targetColumnName = columnPart(step.on.to);
|
|
2786
|
-
else if (fromTable === junctionName && toTable === targetTableName) targetColumnName = columnPart(step.on.from);
|
|
2787
|
-
previousTable = step.table;
|
|
2788
|
-
}
|
|
2789
|
-
if (!parentColumnName || !targetColumnName) throw misconfigured(label, `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`);
|
|
2790
|
-
return {
|
|
2791
|
-
table,
|
|
2792
|
-
parentColumn: column(table, parentColumnName, label, "source"),
|
|
2793
|
-
targetColumn: column(table, targetColumnName, label, "target"),
|
|
2794
|
-
label
|
|
2795
|
-
};
|
|
2796
|
-
}
|
|
2797
|
-
/**
|
|
2798
|
-
* Remove one link, leaving the row on the far side alone.
|
|
2799
|
-
*
|
|
2800
|
-
* This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
|
|
2801
|
-
* target is shared, so deleting the row would remove the tag from every other
|
|
2802
|
-
* post that uses it.
|
|
2803
|
-
*/
|
|
2804
|
-
async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
|
|
2805
|
-
const conditions = [eq(binding.parentColumn, parentId), eq(binding.targetColumn, targetId)];
|
|
2806
|
-
if (((await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0) === 0) throw await explainZeroRowWrite(tx, binding.table, conditions, `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": a row-level security policy rejected the write.`, `No "${subject.relation}" link between "${subject.parent}" "${parentId}" and "${targetId}" to remove.`);
|
|
2807
|
-
logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
|
|
2808
|
-
}
|
|
2809
|
-
/**
|
|
2810
|
-
* Make the junction say that `parentId` is linked to exactly `targetIds`, by
|
|
2811
|
-
* diffing against what is linked now rather than replacing the set.
|
|
2812
|
-
*
|
|
2813
|
-
* A save of the parent used to delete every junction row for it and re-insert
|
|
2814
|
-
* the ids the browser sent — a list the browser assembled from a read it did
|
|
2815
|
-
* earlier. Three things followed, all data loss rather than display:
|
|
2816
|
-
*
|
|
2817
|
-
* - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
|
|
2818
|
-
* saves any field from a form that predates it, and X is gone with nothing
|
|
2819
|
-
* reported to either of them.
|
|
2820
|
-
* - **A partially-read set is a partially-deleted set.** The read that fills
|
|
2821
|
-
* the form runs under RLS, so a user who may edit the parent but cannot see
|
|
2822
|
-
* some of the linked rows gets a shorter list — and writing it back deleted
|
|
2823
|
-
* the links they were never shown. The select that drives the diff runs in
|
|
2824
|
-
* this same transaction under the same policies, so a link the caller cannot
|
|
2825
|
-
* read is in neither list and survives the save.
|
|
2826
|
-
* - **Junction payload columns.** A junction carrying its own columns
|
|
2827
|
-
* (`position`, `role`, `created_at`) lost them on every save, because every
|
|
2828
|
-
* row was re-inserted with only the two keys. Untouched links are left alone.
|
|
2829
|
-
*
|
|
2830
|
-
* The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
|
|
2831
|
-
* concurrently is a no-op rather than a unique violation.
|
|
2832
|
-
*/
|
|
2833
|
-
async function applyJunctionMembership(tx, binding, parentId, targetIds) {
|
|
2834
|
-
const existingRows = await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
|
|
2835
|
-
const existingById = /* @__PURE__ */ new Map();
|
|
2836
|
-
for (const row of existingRows) {
|
|
2837
|
-
if (row.targetId === null || row.targetId === void 0) continue;
|
|
2838
|
-
existingById.set(String(row.targetId), row.targetId);
|
|
2839
|
-
}
|
|
2840
|
-
const wantedById = /* @__PURE__ */ new Map();
|
|
2841
|
-
for (const targetId of targetIds) {
|
|
2842
|
-
if (targetId === null || targetId === void 0) continue;
|
|
2843
|
-
wantedById.set(String(targetId), targetId);
|
|
2844
|
-
}
|
|
2845
|
-
const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
|
|
2846
|
-
const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, value]) => value);
|
|
2847
|
-
if (removed.length > 0) await removeLinks(tx, binding, parentId, removed);
|
|
2848
|
-
if (added.length > 0) await tx.insert(binding.table).values(added.map((targetId) => ({
|
|
2849
|
-
[binding.parentColumn.name]: parentId,
|
|
2850
|
-
[binding.targetColumn.name]: targetId
|
|
2851
|
-
}))).onConflictDoNothing();
|
|
2852
|
-
}
|
|
2853
|
-
/**
|
|
2854
|
-
* Drop the named links, and refuse to call it done if the database kept any.
|
|
2855
|
-
*
|
|
2856
|
-
* Every id here came out of the select in {@link applyJunctionMembership}, on
|
|
2857
|
-
* this same handle, so all of them were visible. Fewer deletions than that means
|
|
2858
|
-
* something refused them — and a save that reports success while the membership
|
|
2859
|
-
* it stored is not the membership it was given has told the caller something
|
|
2860
|
-
* untrue about the database.
|
|
2861
|
-
*/
|
|
2862
|
-
async function removeLinks(tx, binding, parentId, removed) {
|
|
2863
|
-
const conditions = [eq(binding.parentColumn, parentId), inArray(binding.targetColumn, removed)];
|
|
2864
|
-
const deleted = (await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0;
|
|
2865
|
-
if (deleted >= removed.length) return;
|
|
2866
|
-
if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
|
|
2867
|
-
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");
|
|
2868
|
-
}
|
|
2869
|
-
/**
|
|
2870
|
-
* The soft-delete field this collection uses, or `undefined` if it has none.
|
|
2871
|
-
*
|
|
2872
|
-
* Returns the field even when the property is missing, because the *boot* check
|
|
2873
|
-
* is what refuses that config; making this return `undefined` for it would turn
|
|
2874
|
-
* a misconfiguration into "soft delete quietly off", which is the failure mode
|
|
2875
|
-
* worth avoiding — deletes would silently start removing rows.
|
|
2876
|
-
*/
|
|
2877
|
-
function resolveSoftDelete(collection) {
|
|
2878
|
-
const declared = collection?.softDelete;
|
|
2879
|
-
if (!declared) return void 0;
|
|
2880
|
-
const field = typeof declared === "object" && declared.field || "deletedAt";
|
|
2881
|
-
return {
|
|
2882
|
-
field,
|
|
2883
|
-
columnName: ((collection?.properties)?.[field])?.columnName ?? toSnakeCase(field)
|
|
2884
|
-
};
|
|
2885
|
-
}
|
|
2886
|
-
/**
|
|
2887
|
-
* The drizzle column behind the soft-delete field, if the table has one.
|
|
2888
|
-
*
|
|
2889
|
-
* Matched on the column's own `name` rather than on the key, because a
|
|
2890
|
-
* generated schema keys columns by the property name while an introspected one
|
|
2891
|
-
* may key them by the column — and this has to work for both.
|
|
2892
|
-
*/
|
|
2893
|
-
function softDeleteColumn(collection, table) {
|
|
2894
|
-
const resolved = resolveSoftDelete(collection);
|
|
2895
|
-
if (!resolved) return void 0;
|
|
2896
|
-
const columns = table ? getTableColumns(table) : void 0;
|
|
2897
|
-
if (!columns) return void 0;
|
|
2898
|
-
const direct = columns[resolved.field] ?? columns[resolved.columnName];
|
|
2899
|
-
if (direct) return direct;
|
|
2900
|
-
for (const column of Object.values(columns)) if (column.name === resolved.columnName) return column;
|
|
2901
|
-
}
|
|
2902
|
-
/**
|
|
2903
|
-
* `deleted_at IS NULL` — or nothing, when the caller asked for the deleted rows
|
|
2904
|
-
* or the collection has no soft delete at all.
|
|
2905
|
-
*
|
|
2906
|
-
* The one place the rule is written. Call it from every read; `undefined` means
|
|
2907
|
-
* "add no condition", which composes with `and(...)` unchanged.
|
|
2908
|
-
*/
|
|
2909
|
-
function softDeleteCondition(collection, table, withDeleted) {
|
|
2910
|
-
if (withDeleted === true) return void 0;
|
|
2911
|
-
const column = softDeleteColumn(collection, table);
|
|
2912
|
-
if (!column) return void 0;
|
|
2913
|
-
return withDeleted === "only" ? isNotNull(column) : isNull(column);
|
|
2914
|
-
}
|
|
2915
|
-
/**
|
|
2916
|
-
* Fold the soft-delete condition into a list of conditions that is being built.
|
|
2917
|
-
*
|
|
2918
|
-
* A convenience for the call sites that already collect `SQL[]`, so adding soft
|
|
2919
|
-
* delete to one of them is a single line rather than three.
|
|
2920
|
-
*/
|
|
2921
|
-
function withSoftDelete(conditions, collection, table, withDeleted) {
|
|
2922
|
-
const condition = softDeleteCondition(collection, table, withDeleted);
|
|
2923
|
-
if (condition) conditions.push(condition);
|
|
2924
|
-
return conditions;
|
|
2925
|
-
}
|
|
2926
|
-
/**
|
|
2927
|
-
* Combine an existing `where` with the soft-delete condition.
|
|
2928
|
-
*
|
|
2929
|
-
* For the call sites that hold one composed condition rather than a list.
|
|
2930
|
-
*/
|
|
2931
|
-
function andSoftDelete(where, collection, table, withDeleted) {
|
|
2932
|
-
const condition = softDeleteCondition(collection, table, withDeleted);
|
|
2933
|
-
if (!condition) return where;
|
|
2934
|
-
return where ? and(where, condition) : condition;
|
|
2935
|
-
}
|
|
2936
|
-
//#endregion
|
|
2937
|
-
//#region src/services/RelationService.ts
|
|
2938
|
-
/**
|
|
2939
|
-
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
2940
|
-
* Drizzle's `$dynamic()` queries lose the `innerJoin` method from
|
|
2941
|
-
* their static type, but it exists at runtime. This helper bridges
|
|
2942
|
-
* the gap with a single confined cast.
|
|
2943
|
-
*/
|
|
2944
|
-
function applyDynamicJoin(query, joinTable, condition) {
|
|
2945
|
-
return query.innerJoin(joinTable, condition);
|
|
2946
|
-
}
|
|
2947
|
-
/**
|
|
2948
|
-
* Typed wrapper for DrizzleConditionBuilder.buildRelationQuery on dynamic queries.
|
|
2949
|
-
* The method returns a widened generic that doesn't reassign cleanly;
|
|
2950
|
-
* this helper confines the cast.
|
|
2951
|
-
*/
|
|
2952
|
-
function applyDynamicRelationQuery(query, ...args) {
|
|
2953
|
-
return DrizzleConditionBuilder.buildRelationQuery(...args);
|
|
2954
|
-
}
|
|
2955
|
-
/**
|
|
2956
|
-
* The relation's own matching condition, AND-ed with an include's `where`.
|
|
2957
|
-
*
|
|
2958
|
-
* Written once because the batch loaders reach the same decision from six
|
|
2959
|
-
* places — one per relation kind, twice over for the to-one and to-many
|
|
2960
|
-
* variants — and a `where` dropped from one of them is a filter the caller
|
|
2961
|
-
* asked for that silently did not apply.
|
|
2962
|
-
*/
|
|
2963
|
-
function narrowed(base, narrow) {
|
|
2964
|
-
return narrow ? and(base, narrow) : base;
|
|
2521
|
+
function narrowed(base, narrow) {
|
|
2522
|
+
return narrow ? and(base, narrow) : base;
|
|
2965
2523
|
}
|
|
2966
2524
|
/**
|
|
2967
2525
|
* The registry has no table for a collection a relation just asked about.
|
|
@@ -3008,6 +2566,26 @@ var RelationService = class {
|
|
|
3008
2566
|
};
|
|
3009
2567
|
}
|
|
3010
2568
|
/**
|
|
2569
|
+
* The junction row's own columns, as the caller may see them.
|
|
2570
|
+
*
|
|
2571
|
+
* Read through the same two steps the target's columns go through:
|
|
2572
|
+
* `parseDataFromServer` gives the declared type (a `date` is a Date, a
|
|
2573
|
+
* NUMERIC is a number) and `stripUnreadable` drops what this caller may not
|
|
2574
|
+
* read. The second is the point — `access.read: ["hr"]` on a payload
|
|
2575
|
+
* property has to mean the same thing it means on a collection's, and the
|
|
2576
|
+
* synthetic junction config carries the property verbatim, so the same
|
|
2577
|
+
* `effectiveAccess` answers both.
|
|
2578
|
+
*
|
|
2579
|
+
* Only the payload keys: the two foreign keys are the link's identity, and
|
|
2580
|
+
* the caller already has both — one is the row they asked from, the other
|
|
2581
|
+
* is the target's own id.
|
|
2582
|
+
*/
|
|
2583
|
+
async readPivot(junctionRow, junctionConfig, payloadKeys) {
|
|
2584
|
+
const raw = {};
|
|
2585
|
+
for (const key of payloadKeys) if (key in junctionRow) raw[key] = junctionRow[key];
|
|
2586
|
+
return stripUnreadable(await parseDataFromServer(raw, junctionConfig), junctionConfig);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
3011
2589
|
* A WHERE matching any of `parentIds`, by the whole key.
|
|
3012
2590
|
*
|
|
3013
2591
|
* A single key is an `IN (…)`. A composite one cannot be: matching
|
|
@@ -3411,43 +2989,540 @@ var RelationService = class {
|
|
|
3411
2989
|
const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(narrowed(inArray(sourceJunctionCol, parsedParentIds), narrowTarget));
|
|
3412
2990
|
const resultMap = /* @__PURE__ */ new Map();
|
|
3413
2991
|
const targetTableName = getTableName$1(targetCollection);
|
|
2992
|
+
const payloadKeys = Object.keys(relation.through.properties);
|
|
2993
|
+
const junctionConfig = payloadKeys.length > 0 ? getJunctionConfigForRelation(relation.through) : void 0;
|
|
3414
2994
|
for (const row of results) {
|
|
3415
2995
|
const junctionData = row[relation.through.table] || row;
|
|
3416
2996
|
const targetData = row[targetTableName] || row;
|
|
3417
2997
|
const parentId = String(junctionData[relation.through.sourceColumn]);
|
|
3418
2998
|
const arr = resultMap.get(parentId) || [];
|
|
3419
|
-
|
|
2999
|
+
const related = await this.toRelatedRow(targetData, targetCollection, targetPks);
|
|
3000
|
+
if (junctionConfig) related.pivot = await this.readPivot(junctionData, junctionConfig, payloadKeys);
|
|
3001
|
+
arr.push(related);
|
|
3420
3002
|
resultMap.set(parentId, arr);
|
|
3421
3003
|
}
|
|
3422
3004
|
return resultMap;
|
|
3423
3005
|
}
|
|
3424
|
-
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName, relation);
|
|
3425
|
-
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation) ? await this.resolveSourceKeys(parentCollection, relation, parentIds) : {
|
|
3426
|
-
keyByParentId: /* @__PURE__ */ new Map(),
|
|
3427
|
-
parentIdByKey: /* @__PURE__ */ new Map()
|
|
3428
|
-
};
|
|
3429
|
-
const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
|
|
3430
|
-
if (matchValues.length === 0) return /* @__PURE__ */ new Map();
|
|
3431
|
-
let query = this.db.select().from(targetTable).$dynamic();
|
|
3432
|
-
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, narrowTarget ? [narrowTarget] : []);
|
|
3433
|
-
const results = await query;
|
|
3434
|
-
const resultMap = /* @__PURE__ */ new Map();
|
|
3435
|
-
for (const row of results) {
|
|
3436
|
-
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
3437
|
-
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
3438
|
-
const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
3439
|
-
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
3440
|
-
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
3441
|
-
if (parentId !== void 0) {
|
|
3442
|
-
const key = String(parentId);
|
|
3443
|
-
const arr = resultMap.get(key) || [];
|
|
3444
|
-
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
3445
|
-
resultMap.set(key, arr);
|
|
3446
|
-
}
|
|
3006
|
+
this.assertSingleKeyAddressable(parentCollection, parentPks, hasForeignKeyOnTarget(relation) ? relation.foreignKeyOnTarget : relation.relationName, relation);
|
|
3007
|
+
const { keyByParentId, parentIdByKey } = hasForeignKeyOnTarget(relation) ? await this.resolveSourceKeys(parentCollection, relation, parentIds) : {
|
|
3008
|
+
keyByParentId: /* @__PURE__ */ new Map(),
|
|
3009
|
+
parentIdByKey: /* @__PURE__ */ new Map()
|
|
3010
|
+
};
|
|
3011
|
+
const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
|
|
3012
|
+
if (matchValues.length === 0) return /* @__PURE__ */ new Map();
|
|
3013
|
+
let query = this.db.select().from(targetTable).$dynamic();
|
|
3014
|
+
query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, narrowTarget ? [narrowTarget] : []);
|
|
3015
|
+
const results = await query;
|
|
3016
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
3017
|
+
for (const row of results) {
|
|
3018
|
+
const targetRow = row[getTableName$1(targetCollection)] || row;
|
|
3019
|
+
if (!hasForeignKeyOnTarget(relation)) continue;
|
|
3020
|
+
const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
3021
|
+
if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
|
|
3022
|
+
const parentId = parentIdByKey.get(String(foreignKeyValue));
|
|
3023
|
+
if (parentId !== void 0) {
|
|
3024
|
+
const key = String(parentId);
|
|
3025
|
+
const arr = resultMap.get(key) || [];
|
|
3026
|
+
arr.push(await this.toRelatedRow(targetRow, targetCollection, targetPks));
|
|
3027
|
+
resultMap.set(key, arr);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
return resultMap;
|
|
3031
|
+
}
|
|
3032
|
+
};
|
|
3033
|
+
//#endregion
|
|
3034
|
+
//#region src/data-transformer.ts
|
|
3035
|
+
/**
|
|
3036
|
+
* Helper function to sanitize and convert dates to ISO strings
|
|
3037
|
+
*/
|
|
3038
|
+
function sanitizeAndConvertDates(obj) {
|
|
3039
|
+
if (obj === null || obj === void 0) return null;
|
|
3040
|
+
if (typeof obj === "number" && isNaN(obj)) return null;
|
|
3041
|
+
if (typeof obj === "string" && obj.toLowerCase() === "nan") return null;
|
|
3042
|
+
if (Array.isArray(obj)) return obj.map((v) => sanitizeAndConvertDates(v));
|
|
3043
|
+
if (obj instanceof Date) return obj.toISOString();
|
|
3044
|
+
if (typeof obj === "object") {
|
|
3045
|
+
const newObj = {};
|
|
3046
|
+
for (const key in obj) {
|
|
3047
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
3048
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
3049
|
+
newObj[key] = sanitizeAndConvertDates(obj[key]);
|
|
3050
|
+
}
|
|
3051
|
+
return newObj;
|
|
3052
|
+
}
|
|
3053
|
+
if (typeof obj === "string") {
|
|
3054
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(obj) || /^\w{3} \w{3} \d{2} \d{4} \d{2}:\d{2}:\d{2} GMT[+-]\d{4} \(.+\)$/.test(obj)) {
|
|
3055
|
+
const date = new Date(obj);
|
|
3056
|
+
if (!isNaN(date.getTime())) return date.toISOString();
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
return obj;
|
|
3060
|
+
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Transform relations for database storage (relation objects to IDs)
|
|
3063
|
+
*/
|
|
3064
|
+
function serializeDataToServer(row, properties, collection, registry) {
|
|
3065
|
+
if (!row || !properties) return {
|
|
3066
|
+
scalarData: row ?? {},
|
|
3067
|
+
inverseRelationUpdates: [],
|
|
3068
|
+
joinPathRelationUpdates: []
|
|
3069
|
+
};
|
|
3070
|
+
const result = {};
|
|
3071
|
+
const resolvedRelations = collection ? resolveCollectionRelations(collection) : {};
|
|
3072
|
+
const inverseRelationUpdates = [];
|
|
3073
|
+
const joinPathRelationUpdates = [];
|
|
3074
|
+
const foreignKeys = /* @__PURE__ */ new Set();
|
|
3075
|
+
Object.values(resolvedRelations).forEach((relation) => {
|
|
3076
|
+
if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
|
|
3077
|
+
});
|
|
3078
|
+
for (const [key, value] of Object.entries(row)) {
|
|
3079
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
3080
|
+
if (value === void 0) continue;
|
|
3081
|
+
const property = properties[key];
|
|
3082
|
+
const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
|
|
3083
|
+
if (!property) {
|
|
3084
|
+
result[key] = effectiveValue;
|
|
3085
|
+
continue;
|
|
3086
|
+
}
|
|
3087
|
+
if (property.type === "relation" && collection) {
|
|
3088
|
+
const relation = findRelation(resolvedRelations, key);
|
|
3089
|
+
if (relation) {
|
|
3090
|
+
if (relation.kind === "belongsTo") {
|
|
3091
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
3092
|
+
if (serializedValue !== void 0) result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
|
|
3093
|
+
continue;
|
|
3094
|
+
} else if (hasForeignKeyOnTarget(relation)) {
|
|
3095
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
3096
|
+
inverseRelationUpdates.push({
|
|
3097
|
+
relationKey: key,
|
|
3098
|
+
relation,
|
|
3099
|
+
newValue: serializedValue
|
|
3100
|
+
});
|
|
3101
|
+
continue;
|
|
3102
|
+
} else if (relation.kind === "via") {
|
|
3103
|
+
const serializedValue = serializePropertyToServer(effectiveValue, property, key);
|
|
3104
|
+
if (relation.cardinality === "one") joinPathRelationUpdates.push({
|
|
3105
|
+
relationKey: key,
|
|
3106
|
+
relation,
|
|
3107
|
+
newTargetId: serializedValue
|
|
3108
|
+
});
|
|
3109
|
+
else inverseRelationUpdates.push({
|
|
3110
|
+
relationKey: key,
|
|
3111
|
+
relation,
|
|
3112
|
+
newValue: serializedValue
|
|
3113
|
+
});
|
|
3114
|
+
continue;
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
result[key] = serializePropertyToServer(effectiveValue, property, key);
|
|
3119
|
+
}
|
|
3120
|
+
return {
|
|
3121
|
+
scalarData: result,
|
|
3122
|
+
inverseRelationUpdates,
|
|
3123
|
+
joinPathRelationUpdates
|
|
3124
|
+
};
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* How to name a rejected value in an error, without quoting it back.
|
|
3128
|
+
*
|
|
3129
|
+
* The value may be anything the caller sent, including a secret in the wrong
|
|
3130
|
+
* field, so the message describes its shape rather than echoing it — an echoed
|
|
3131
|
+
* value ends up in logs and in error-reporting services.
|
|
3132
|
+
*/
|
|
3133
|
+
function describeValue(value) {
|
|
3134
|
+
if (value === null) return "null";
|
|
3135
|
+
if (Array.isArray(value)) return "an array";
|
|
3136
|
+
if (value instanceof Date) return "a date";
|
|
3137
|
+
const type = typeof value;
|
|
3138
|
+
return type === "object" ? "an object" : `a ${type}`;
|
|
3139
|
+
}
|
|
3140
|
+
/**
|
|
3141
|
+
* Serialize a single property value for database storage.
|
|
3142
|
+
*
|
|
3143
|
+
* `propertyKey` is only ever used to phrase errors and warnings. Without it the
|
|
3144
|
+
* one trace a bad value left was `Expected array value for array property, got
|
|
3145
|
+
* string` — no collection, no property, no value, which in the log of a
|
|
3146
|
+
* thousand-row import names nothing at all.
|
|
3147
|
+
*/
|
|
3148
|
+
function serializePropertyToServer(value, property, propertyKey) {
|
|
3149
|
+
if (value === null || value === void 0) return value;
|
|
3150
|
+
const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
|
|
3151
|
+
switch (property.type) {
|
|
3152
|
+
case "relation":
|
|
3153
|
+
if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
|
|
3154
|
+
else if (typeof value === "object" && value !== null && "id" in value) return value.id;
|
|
3155
|
+
if (value === "") return null;
|
|
3156
|
+
return value;
|
|
3157
|
+
case "array":
|
|
3158
|
+
if (Array.isArray(value)) {
|
|
3159
|
+
if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
|
|
3160
|
+
else if (property.oneOf) {
|
|
3161
|
+
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
3162
|
+
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
3163
|
+
return value.map((e) => {
|
|
3164
|
+
if (e === null) return null;
|
|
3165
|
+
if (typeof e !== "object") return e;
|
|
3166
|
+
const rec = e;
|
|
3167
|
+
const type = rec[typeField];
|
|
3168
|
+
const childProperty = property.oneOf?.properties[type];
|
|
3169
|
+
if (!type || !childProperty) return e;
|
|
3170
|
+
return {
|
|
3171
|
+
[typeField]: type,
|
|
3172
|
+
[valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
|
|
3173
|
+
};
|
|
3174
|
+
});
|
|
3175
|
+
}
|
|
3176
|
+
return value;
|
|
3177
|
+
}
|
|
3178
|
+
throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
3179
|
+
case "geopoint": {
|
|
3180
|
+
if (typeof value !== "object" || Array.isArray(value)) throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
|
|
3181
|
+
const point = value;
|
|
3182
|
+
if (typeof point.latitude !== "number" || typeof point.longitude !== "number") throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`, "VALIDATION_INVALID_VALUE");
|
|
3183
|
+
return {
|
|
3184
|
+
latitude: point.latitude,
|
|
3185
|
+
longitude: point.longitude
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
case "map":
|
|
3189
|
+
if (typeof value === "object" && property.properties) {
|
|
3190
|
+
const result = {};
|
|
3191
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
3192
|
+
const subProperty = property.properties[subKey];
|
|
3193
|
+
if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
|
|
3194
|
+
else result[subKey] = subValue;
|
|
3195
|
+
}
|
|
3196
|
+
return result;
|
|
3197
|
+
}
|
|
3198
|
+
return value;
|
|
3199
|
+
case "vector":
|
|
3200
|
+
if (value instanceof Vector) return value.value;
|
|
3201
|
+
if (value && typeof value === "object" && "value" in value && Array.isArray(value.value)) return value.value.map(Number);
|
|
3202
|
+
if (Array.isArray(value)) return value.map(Number);
|
|
3203
|
+
return value;
|
|
3204
|
+
case "binary": {
|
|
3205
|
+
const decoded = tryDecodeBase64DataUrl(value);
|
|
3206
|
+
if (decoded) return decoded;
|
|
3207
|
+
if (Buffer.isBuffer(value)) return value;
|
|
3208
|
+
return value;
|
|
3209
|
+
}
|
|
3210
|
+
default: {
|
|
3211
|
+
const decoded = tryDecodeBase64DataUrl(value);
|
|
3212
|
+
if (decoded) return decoded;
|
|
3213
|
+
return value;
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
/**
|
|
3218
|
+
* Transform IDs back to relation objects for frontend
|
|
3219
|
+
*/
|
|
3220
|
+
async function parseDataFromServer(data, collection, db, registry) {
|
|
3221
|
+
const properties = collection.properties;
|
|
3222
|
+
if (!data || !properties) return data;
|
|
3223
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
3224
|
+
const result = normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: false });
|
|
3225
|
+
for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
|
|
3226
|
+
const relation = findRelation(resolvedRelations, propKey);
|
|
3227
|
+
if (relation) {
|
|
3228
|
+
const localField = relation.kind === "belongsTo" ? fieldKeyForColumn(collection, relation.localKey) : "";
|
|
3229
|
+
if (relation.kind === "belongsTo" && localField in data) {
|
|
3230
|
+
const fkValue = data[localField];
|
|
3231
|
+
if (fkValue !== null && fkValue !== void 0) try {
|
|
3232
|
+
const targetCollection = relation.target();
|
|
3233
|
+
result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
|
|
3234
|
+
} catch (e) {
|
|
3235
|
+
logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
|
|
3236
|
+
}
|
|
3237
|
+
} else if (hasForeignKeyOnTarget(relation) && db && registry) try {
|
|
3238
|
+
const targetCollection = relation.target();
|
|
3239
|
+
const targetTable = registry.getTable(getTableName$1(targetCollection));
|
|
3240
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
3241
|
+
const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId$1(data, pks);
|
|
3242
|
+
if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
|
|
3243
|
+
const foreignKeyColumn = targetTable[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
|
|
3244
|
+
if (foreignKeyColumn) {
|
|
3245
|
+
const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
|
|
3246
|
+
if (relatedRows.length > 0) if (relation.cardinality === "one") {
|
|
3247
|
+
const targetPks = getPrimaryKeys(targetCollection, registry);
|
|
3248
|
+
const relatedRow = relatedRows[0];
|
|
3249
|
+
result[propKey] = createRelationRef(buildCompositeId$1(relatedRow, targetPks), targetCollection.slug);
|
|
3250
|
+
} else {
|
|
3251
|
+
const targetPks = getPrimaryKeys(targetCollection, registry);
|
|
3252
|
+
result[propKey] = relatedRows.map((row) => createRelationRef(buildCompositeId$1(row, targetPks), targetCollection.slug));
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
} catch (e) {
|
|
3257
|
+
logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
|
|
3258
|
+
}
|
|
3259
|
+
else if (relation.kind === "via" && db && registry) try {
|
|
3260
|
+
const targetCollection = relation.target();
|
|
3261
|
+
const pks = getPrimaryKeys(collection, registry);
|
|
3262
|
+
const currentId = buildCompositeId$1(data, pks);
|
|
3263
|
+
if (currentId) {
|
|
3264
|
+
const sourceTable = registry.getTable(getTableName$1(collection));
|
|
3265
|
+
if (!sourceTable) {
|
|
3266
|
+
logger.warn(`Source table not found for collection: ${collection.slug}`);
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3269
|
+
let query = db.select().from(sourceTable);
|
|
3270
|
+
let currentTable = sourceTable;
|
|
3271
|
+
for (const join of relation.joinPath) {
|
|
3272
|
+
const joinTable = registry.getTable(join.table);
|
|
3273
|
+
if (!joinTable) {
|
|
3274
|
+
logger.warn(`Join table not found: ${join.table}`);
|
|
3275
|
+
break;
|
|
3276
|
+
}
|
|
3277
|
+
const fromColumn = Array.isArray(join.on.from) ? join.on.from[0] : join.on.from;
|
|
3278
|
+
const toColumn = Array.isArray(join.on.to) ? join.on.to[0] : join.on.to;
|
|
3279
|
+
const fromParts = fromColumn.split(".");
|
|
3280
|
+
const toParts = toColumn.split(".");
|
|
3281
|
+
const fromColName = fromParts[fromParts.length - 1];
|
|
3282
|
+
const toColName = toParts[toParts.length - 1];
|
|
3283
|
+
const fromCol = currentTable[fromColName];
|
|
3284
|
+
const toCol = joinTable[toColName];
|
|
3285
|
+
if (!fromCol || !toCol) {
|
|
3286
|
+
logger.warn(`Join columns not found: ${fromColumn} -> ${toColumn}`);
|
|
3287
|
+
break;
|
|
3288
|
+
}
|
|
3289
|
+
query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
|
|
3290
|
+
currentTable = joinTable;
|
|
3291
|
+
}
|
|
3292
|
+
if (pks.length === 1) {
|
|
3293
|
+
const sourceIdField = sourceTable[pks[0].fieldName];
|
|
3294
|
+
query = query.where(eq(sourceIdField, currentId));
|
|
3295
|
+
} else logger.warn(`Join path resolution for composite primary keys is not yet fully supported: ${collection.slug}`);
|
|
3296
|
+
const additionalFilters = [];
|
|
3297
|
+
let combinedWhere;
|
|
3298
|
+
if (pks.length === 1) {
|
|
3299
|
+
const sourceIdField = sourceTable[pks[0].fieldName];
|
|
3300
|
+
combinedWhere = DrizzleConditionBuilder.combineConditionsWithAnd([eq(sourceIdField, currentId), ...additionalFilters].filter(Boolean));
|
|
3301
|
+
}
|
|
3302
|
+
const joinResults = await query.where(combinedWhere).limit(relation.cardinality === "one" ? 1 : 100);
|
|
3303
|
+
if (joinResults.length > 0) {
|
|
3304
|
+
const targetPks = getPrimaryKeys(targetCollection, registry);
|
|
3305
|
+
const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
|
|
3306
|
+
if (relation.cardinality === "one") {
|
|
3307
|
+
const joinResult = joinResults[0];
|
|
3308
|
+
result[propKey] = createRelationRef(buildCompositeId$1(joinResult[targetTableName] || joinResult, targetPks), targetCollection.slug);
|
|
3309
|
+
} else result[propKey] = joinResults.map((joinResult) => {
|
|
3310
|
+
return createRelationRef(buildCompositeId$1(joinResult[targetTableName] || joinResult, targetPks), targetCollection.slug);
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
} catch (e) {
|
|
3315
|
+
logger.warn(`Could not resolve join path relation property: ${propKey}`, { error: e });
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
return result;
|
|
3320
|
+
}
|
|
3321
|
+
/**
|
|
3322
|
+
* Try to decode a `data:application/octet-stream;base64,...` data URL string
|
|
3323
|
+
* into a Buffer. Returns null if the value is not a matching data URL.
|
|
3324
|
+
*/
|
|
3325
|
+
function tryDecodeBase64DataUrl(value) {
|
|
3326
|
+
if (typeof value !== "string") return null;
|
|
3327
|
+
if (!value.startsWith("data:application/octet-stream;base64,")) return null;
|
|
3328
|
+
const base64Data = value.split(",")[1];
|
|
3329
|
+
return base64Data ? Buffer.from(base64Data, "base64") : null;
|
|
3330
|
+
}
|
|
3331
|
+
/**
|
|
3332
|
+
* Try to resolve an unknown value into a Buffer.
|
|
3333
|
+
* Handles native Buffers and `{ type: "Buffer", data: number[] }` objects (from JSON deserialization).
|
|
3334
|
+
* Returns null if the value is not a buffer.
|
|
3335
|
+
*/
|
|
3336
|
+
function tryResolveBuffer(value) {
|
|
3337
|
+
if (Buffer.isBuffer(value)) return value;
|
|
3338
|
+
if (typeof value === "object" && value !== null) {
|
|
3339
|
+
const rawVal = value;
|
|
3340
|
+
if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) return Buffer.from(rawVal.data);
|
|
3341
|
+
}
|
|
3342
|
+
return null;
|
|
3343
|
+
}
|
|
3344
|
+
/**
|
|
3345
|
+
* Convert a Buffer to a UTF-8 string if all bytes are printable ASCII,
|
|
3346
|
+
* otherwise return a base64 data URL.
|
|
3347
|
+
*/
|
|
3348
|
+
function bufferToStringOrBase64(buf) {
|
|
3349
|
+
for (let i = 0; i < buf.length; i++) {
|
|
3350
|
+
const b = buf[i];
|
|
3351
|
+
if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
3352
|
+
}
|
|
3353
|
+
return buf.toString("utf8");
|
|
3354
|
+
}
|
|
3355
|
+
function parsePropertyFromServer(value, property, collection, propertyKey) {
|
|
3356
|
+
if (value === null || value === void 0) return value;
|
|
3357
|
+
switch (property.type) {
|
|
3358
|
+
case "binary": {
|
|
3359
|
+
const buf = tryResolveBuffer(value);
|
|
3360
|
+
if (buf) return `data:application/octet-stream;base64,${buf.toString("base64")}`;
|
|
3361
|
+
return value;
|
|
3362
|
+
}
|
|
3363
|
+
case "string": {
|
|
3364
|
+
if (typeof value === "string") return value;
|
|
3365
|
+
const buf = tryResolveBuffer(value);
|
|
3366
|
+
if (buf) return bufferToStringOrBase64(buf);
|
|
3367
|
+
if (typeof value === "object" && value !== null) try {
|
|
3368
|
+
return JSON.stringify(value);
|
|
3369
|
+
} catch {
|
|
3370
|
+
return String(value);
|
|
3371
|
+
}
|
|
3372
|
+
return String(value);
|
|
3373
|
+
}
|
|
3374
|
+
case "relation":
|
|
3375
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
3376
|
+
let relationDef = property.resolvedRelation;
|
|
3377
|
+
if (!relationDef && propertyKey) relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
|
|
3378
|
+
if (!relationDef) {
|
|
3379
|
+
logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
3380
|
+
return value;
|
|
3381
|
+
}
|
|
3382
|
+
try {
|
|
3383
|
+
const targetCollection = relationDef.target();
|
|
3384
|
+
return createRelationRef(value.toString(), targetCollection.slug);
|
|
3385
|
+
} catch (e) {
|
|
3386
|
+
logger.warn(`Could not resolve target collection for relation property: ${propertyKey || "unknown"}`, { error: e });
|
|
3387
|
+
return value;
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
return value;
|
|
3391
|
+
case "array":
|
|
3392
|
+
if (Array.isArray(value)) {
|
|
3393
|
+
if (property.of) return value.map((item) => parsePropertyFromServer(item, property.of, collection));
|
|
3394
|
+
else if (property.oneOf) {
|
|
3395
|
+
const typeField = property.oneOf.typeField ?? DEFAULT_ONE_OF_TYPE;
|
|
3396
|
+
const valueField = property.oneOf.valueField ?? DEFAULT_ONE_OF_VALUE;
|
|
3397
|
+
return value.map((e) => {
|
|
3398
|
+
if (e === null) return null;
|
|
3399
|
+
if (typeof e !== "object") return e;
|
|
3400
|
+
const rec = e;
|
|
3401
|
+
const type = rec[typeField];
|
|
3402
|
+
const childProperty = property.oneOf?.properties[type];
|
|
3403
|
+
if (!type || !childProperty) return e;
|
|
3404
|
+
return {
|
|
3405
|
+
[typeField]: type,
|
|
3406
|
+
[valueField]: parsePropertyFromServer(rec[valueField], childProperty, collection)
|
|
3407
|
+
};
|
|
3408
|
+
});
|
|
3409
|
+
}
|
|
3410
|
+
} else {
|
|
3411
|
+
logger.warn(`Expected array value from DB for array property, got ${typeof value}. Coercing to array.`);
|
|
3412
|
+
return typeof value === "string" ? [value] : [];
|
|
3413
|
+
}
|
|
3414
|
+
return value;
|
|
3415
|
+
case "map":
|
|
3416
|
+
if (typeof value === "object" && property.properties) {
|
|
3417
|
+
const result = {};
|
|
3418
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
3419
|
+
const subProperty = property.properties[subKey];
|
|
3420
|
+
if (subProperty) result[subKey] = parsePropertyFromServer(subValue, subProperty, collection);
|
|
3421
|
+
else result[subKey] = subValue;
|
|
3422
|
+
}
|
|
3423
|
+
return result;
|
|
3424
|
+
}
|
|
3425
|
+
return value;
|
|
3426
|
+
case "number":
|
|
3427
|
+
if (typeof value === "string") {
|
|
3428
|
+
const parsed = parseFloat(value);
|
|
3429
|
+
return isNaN(parsed) ? null : parsed;
|
|
3430
|
+
}
|
|
3431
|
+
return value;
|
|
3432
|
+
case "geopoint": return value;
|
|
3433
|
+
case "vector": {
|
|
3434
|
+
let nums = [];
|
|
3435
|
+
if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
|
|
3436
|
+
else if (Array.isArray(value)) nums = value.map(Number);
|
|
3437
|
+
else if (value instanceof Vector) nums = value.value;
|
|
3438
|
+
else if (typeof value === "object" && value !== null && "value" in value) {
|
|
3439
|
+
const valObj = value;
|
|
3440
|
+
if (Array.isArray(valObj.value)) nums = valObj.value.map(Number);
|
|
3441
|
+
}
|
|
3442
|
+
return {
|
|
3443
|
+
__type: "Vector",
|
|
3444
|
+
value: nums
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
case "date": {
|
|
3448
|
+
let date;
|
|
3449
|
+
if (value instanceof Date) date = value;
|
|
3450
|
+
else if (typeof value === "string" || typeof value === "number") {
|
|
3451
|
+
const parsedDate = new Date(value);
|
|
3452
|
+
if (!isNaN(parsedDate.getTime())) date = parsedDate;
|
|
3453
|
+
}
|
|
3454
|
+
if (date) return {
|
|
3455
|
+
__type: "date",
|
|
3456
|
+
value: date.toISOString()
|
|
3457
|
+
};
|
|
3458
|
+
return null;
|
|
3459
|
+
}
|
|
3460
|
+
default: {
|
|
3461
|
+
const buf = tryResolveBuffer(value);
|
|
3462
|
+
if (buf) return bufferToStringOrBase64(buf);
|
|
3463
|
+
return value;
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
/**
|
|
3468
|
+
* Shared internal helper: normalizes scalar column values from a DB row
|
|
3469
|
+
* into frontend format. Handles FK-column preservation, type coercion
|
|
3470
|
+
* (dates, numbers, NaN), and property filtering.
|
|
3471
|
+
*
|
|
3472
|
+
* @param skipRelations When `true`, relation-typed properties are omitted
|
|
3473
|
+
* from the result (used by `normalizeDbValues` where
|
|
3474
|
+
* Drizzle's relational API already hydrates them).
|
|
3475
|
+
*/
|
|
3476
|
+
/**
|
|
3477
|
+
* Keys a query computes and attaches to a row, rather than reads from a column.
|
|
3478
|
+
*
|
|
3479
|
+
* An explicit set rather than a `_` prefix rule: a user's column may perfectly
|
|
3480
|
+
* well be called `_internal`, and passing it through here would put a value on
|
|
3481
|
+
* the row that no property describes and nothing downstream knows how to type.
|
|
3482
|
+
*/
|
|
3483
|
+
var QUERY_METADATA_KEYS = /* @__PURE__ */ new Set([
|
|
3484
|
+
"_score",
|
|
3485
|
+
"_distance",
|
|
3486
|
+
"_matches"
|
|
3487
|
+
]);
|
|
3488
|
+
function normalizeScalarValues(data, properties, collection, resolvedRelations, options) {
|
|
3489
|
+
const result = {};
|
|
3490
|
+
const internalFKColumns = /* @__PURE__ */ new Set();
|
|
3491
|
+
Object.values(resolvedRelations).forEach((relation) => {
|
|
3492
|
+
if (relation.kind !== "belongsTo") return;
|
|
3493
|
+
const localField = fieldKeyForColumn(collection, relation.localKey);
|
|
3494
|
+
if (!properties[localField]) internalFKColumns.add(localField);
|
|
3495
|
+
});
|
|
3496
|
+
for (const [key, value] of Object.entries(data)) {
|
|
3497
|
+
if (internalFKColumns.has(key)) {
|
|
3498
|
+
result[key] = value === null ? null : typeof value === "number" ? value : String(value);
|
|
3499
|
+
continue;
|
|
3500
|
+
}
|
|
3501
|
+
if (QUERY_METADATA_KEYS.has(key)) {
|
|
3502
|
+
result[key] = value;
|
|
3503
|
+
continue;
|
|
3447
3504
|
}
|
|
3448
|
-
|
|
3505
|
+
const property = properties[key];
|
|
3506
|
+
if (!property) continue;
|
|
3507
|
+
if (options.skipRelations && property.type === "relation") continue;
|
|
3508
|
+
result[key] = parsePropertyFromServer(value, property, collection, key);
|
|
3449
3509
|
}
|
|
3450
|
-
|
|
3510
|
+
return result;
|
|
3511
|
+
}
|
|
3512
|
+
/**
|
|
3513
|
+
* Lightweight value normalization for db.query results.
|
|
3514
|
+
* Only handles type coercion (dates, numbers, NaN) and property filtering.
|
|
3515
|
+
* Does NOT query the database for relations — those are already resolved
|
|
3516
|
+
* by Drizzle's relational query API.
|
|
3517
|
+
*
|
|
3518
|
+
* Use this instead of `parseDataFromServer` when processing results from
|
|
3519
|
+
* `db.query.findFirst/findMany` which return pre-hydrated relation data.
|
|
3520
|
+
*/
|
|
3521
|
+
function normalizeDbValues(data, collection) {
|
|
3522
|
+
const properties = collection.properties;
|
|
3523
|
+
if (!data || !properties) return data;
|
|
3524
|
+
return normalizeScalarValues(data, properties, collection, resolveCollectionRelations(collection), { skipRelations: true });
|
|
3525
|
+
}
|
|
3451
3526
|
//#endregion
|
|
3452
3527
|
//#region src/services/nested-path.ts
|
|
3453
3528
|
/**
|
|
@@ -3925,6 +4000,7 @@ var FetchService = class FetchService {
|
|
|
3925
4000
|
if (node.fields && node.fields.length > 0) {
|
|
3926
4001
|
const keep = new Set(node.fields);
|
|
3927
4002
|
for (const pk of getPrimaryKeys(targetCollection, this.registry)) keep.add(pk.fieldName);
|
|
4003
|
+
keep.add(JUNCTION_PIVOT_KEY);
|
|
3928
4004
|
out = out.map((row) => {
|
|
3929
4005
|
const projected = {};
|
|
3930
4006
|
for (const [key, value] of Object.entries(row)) if (keep.has(key)) projected[key] = value;
|
|
@@ -3976,7 +4052,10 @@ var FetchService = class FetchService {
|
|
|
3976
4052
|
const results = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation, narrow);
|
|
3977
4053
|
for (const row of addressable) {
|
|
3978
4054
|
const related = results.get(String(addressOf(row))) ?? [];
|
|
3979
|
-
const shaped = this.shapeRelatedRows(related.map((e) =>
|
|
4055
|
+
const shaped = this.shapeRelatedRows(related.map((e) => e.pivot ? {
|
|
4056
|
+
...toRestValues({ ...e.values }, targetCollection),
|
|
4057
|
+
[JUNCTION_PIVOT_KEY]: e.pivot
|
|
4058
|
+
} : toRestValues({ ...e.values }, targetCollection)), node, targetCollection);
|
|
3980
4059
|
row[key] = shaped;
|
|
3981
4060
|
loaded.push(...shaped);
|
|
3982
4061
|
}
|
|
@@ -4595,33 +4674,54 @@ var FetchService = class FetchService {
|
|
|
4595
4674
|
//#endregion
|
|
4596
4675
|
//#region src/services/RelationWriteService.ts
|
|
4597
4676
|
/**
|
|
4598
|
-
* The
|
|
4677
|
+
* The same reading, keeping the link's own values.
|
|
4599
4678
|
*
|
|
4600
|
-
*
|
|
4601
|
-
*
|
|
4602
|
-
*
|
|
4603
|
-
*
|
|
4604
|
-
* on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
|
|
4605
|
-
* and on a string-keyed one it did not surface at all — `String(undefined)`
|
|
4606
|
-
* wrote a junction row pointing at the literal `"undefined"`, which no read
|
|
4607
|
-
* would ever match. Both shapes are accepted here, in one place, because both
|
|
4608
|
-
* call sites had the same assumption.
|
|
4609
|
-
*
|
|
4610
|
-
* An element that carries no key is refused rather than skipped: dropping it
|
|
4611
|
-
* would silently write a shorter membership list than the caller asked for.
|
|
4679
|
+
* An element may be `{ id, _pivot: { role: "owner" } }` where the relation is a
|
|
4680
|
+
* `manyToMany` whose `through` declares `properties`. The `_pivot` is taken off
|
|
4681
|
+
* here and nowhere else: it is not a column of the target, so leaving it on the
|
|
4682
|
+
* element would make it look like one to every later step.
|
|
4612
4683
|
*/
|
|
4613
|
-
function
|
|
4684
|
+
function relationLinkElements(value, relationName, collectionSlug) {
|
|
4614
4685
|
if (!Array.isArray(value)) return [];
|
|
4615
4686
|
return value.map((element, index) => {
|
|
4616
|
-
if (typeof element === "string" || typeof element === "number") return element;
|
|
4687
|
+
if (typeof element === "string" || typeof element === "number") return { id: element };
|
|
4617
4688
|
if (element && typeof element === "object") {
|
|
4618
4689
|
const id = element.id;
|
|
4619
|
-
|
|
4690
|
+
const pivot = element[JUNCTION_PIVOT_KEY];
|
|
4691
|
+
if (typeof id === "string" || typeof id === "number") return {
|
|
4692
|
+
id,
|
|
4693
|
+
pivot: pivot && typeof pivot === "object" && !Array.isArray(pivot) ? pivot : void 0
|
|
4694
|
+
};
|
|
4620
4695
|
}
|
|
4621
4696
|
throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
|
|
4622
4697
|
});
|
|
4623
4698
|
}
|
|
4624
4699
|
/**
|
|
4700
|
+
* A `_pivot` as the junction table's columns.
|
|
4701
|
+
*
|
|
4702
|
+
* Exported because the payload route needs the identical reading — one function
|
|
4703
|
+
* so a value written through `PATCH posts/1 { tags: [{ id, _pivot }] }` and the
|
|
4704
|
+
* same value written through `PATCH posts/1/tags/5 { _pivot }` cannot be
|
|
4705
|
+
* serialized two ways.
|
|
4706
|
+
*/
|
|
4707
|
+
function serializePivot(pivot, payload, label) {
|
|
4708
|
+
const declared = Object.keys(payload);
|
|
4709
|
+
if (declared.length === 0) throw ApiError.badRequest(`Relation '${label}' carries no junction columns, so there is nothing to write under \`${JUNCTION_PIVOT_KEY}\`. Declare them in \`through.properties\`.`, "VALIDATION_UNKNOWN_FIELDS", { relation: label });
|
|
4710
|
+
const values = {};
|
|
4711
|
+
for (const [key, value] of Object.entries(pivot)) {
|
|
4712
|
+
const property = payload[key];
|
|
4713
|
+
if (!property) throw ApiError.badRequest(`The junction behind relation '${label}' has no column '${key}'. Its \`through.properties\` are: ${declared.sort().map((k) => `'${k}'`).join(", ")}.`, "VALIDATION_UNKNOWN_FIELDS", {
|
|
4714
|
+
relation: label,
|
|
4715
|
+
field: key,
|
|
4716
|
+
validFields: declared.sort()
|
|
4717
|
+
});
|
|
4718
|
+
if (value === void 0) continue;
|
|
4719
|
+
const serialized = serializePropertyToServer(value, property, key);
|
|
4720
|
+
if (serialized !== void 0) values[key] = serialized;
|
|
4721
|
+
}
|
|
4722
|
+
return values;
|
|
4723
|
+
}
|
|
4724
|
+
/**
|
|
4625
4725
|
* Writing relations: junction membership, foreign-key stamping, and the links a
|
|
4626
4726
|
* nested path creates or removes.
|
|
4627
4727
|
*
|
|
@@ -4660,6 +4760,31 @@ var RelationWriteService = class {
|
|
|
4660
4760
|
relation: hop.relationKey
|
|
4661
4761
|
});
|
|
4662
4762
|
}
|
|
4763
|
+
/**
|
|
4764
|
+
* Set the columns of one existing link, leaving the membership alone.
|
|
4765
|
+
*
|
|
4766
|
+
* `PATCH /api/data/:slug/:id/:relation/:targetId` with a `_pivot` body. The
|
|
4767
|
+
* membership array cannot say this: one element would unlink everything
|
|
4768
|
+
* else, and re-sending the whole set to change one `role` reintroduces the
|
|
4769
|
+
* lost update the membership diff exists to avoid.
|
|
4770
|
+
*/
|
|
4771
|
+
async updateRelationPivot(tx, hop, targetId, pivot) {
|
|
4772
|
+
if (!isManyToMany(hop.relation)) throw ApiError.badRequest(`Relation '${hop.relationKey}' on '${hop.parentCollection.slug}' is a ${hop.relation.kind}, which has no junction row to carry \`${JUNCTION_PIVOT_KEY}\`. Only a \`manyToMany\` with \`through.properties\` does.`, "VALIDATION_UNKNOWN_FIELDS", {
|
|
4773
|
+
relation: hop.relationKey,
|
|
4774
|
+
collection: hop.parentCollection.slug
|
|
4775
|
+
});
|
|
4776
|
+
const label = `${hop.parentCollection.slug}.${hop.relationKey}`;
|
|
4777
|
+
const binding = bindThroughJunction(this.registry, hop.relation.through, label);
|
|
4778
|
+
const values = serializePivot(pivot, hop.relation.through.properties, label);
|
|
4779
|
+
if (Object.keys(values).length === 0) throw ApiError.badRequest(`\`${JUNCTION_PIVOT_KEY}\` named no junction column to set on '${label}'.`, "VALIDATION_CONSTRAINT", {
|
|
4780
|
+
relation: hop.relationKey,
|
|
4781
|
+
collection: hop.parentCollection.slug
|
|
4782
|
+
});
|
|
4783
|
+
await updateJunctionPivot(tx, binding, this.parsedId(hop.parentCollection, hop.parentId), this.parsedId(hop.targetCollection, targetId), values, {
|
|
4784
|
+
parent: hop.parentCollection.slug,
|
|
4785
|
+
relation: hop.relationKey
|
|
4786
|
+
});
|
|
4787
|
+
}
|
|
4663
4788
|
/** A collection's id, parsed to the type its primary key column holds. */
|
|
4664
4789
|
parsedId(collection, id) {
|
|
4665
4790
|
const pks = requirePrimaryKeys(collection, this.registry);
|
|
@@ -4672,6 +4797,26 @@ var RelationWriteService = class {
|
|
|
4672
4797
|
return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
|
|
4673
4798
|
}
|
|
4674
4799
|
/**
|
|
4800
|
+
* A membership list as links: the target ids parsed, and each element's
|
|
4801
|
+
* `_pivot` turned into the columns the junction table holds.
|
|
4802
|
+
*
|
|
4803
|
+
* The values go through `serializePropertyToServer` — the same function
|
|
4804
|
+
* every collection column's value goes through on a save — so a `date`
|
|
4805
|
+
* arrives as a Date and a `map` as JSON, keyed by the property key, which is
|
|
4806
|
+
* what the drizzle junction table is keyed by. A key the junction does not
|
|
4807
|
+
* declare is **refused**, not dropped: drizzle leaves an unknown key out of
|
|
4808
|
+
* the statement, so dropping it is a write that reports success having
|
|
4809
|
+
* stored nothing.
|
|
4810
|
+
*/
|
|
4811
|
+
parsedLinks(collection, elements, payload, label) {
|
|
4812
|
+
if (elements.length === 0) return [];
|
|
4813
|
+
const pks = requirePrimaryKeys(collection, this.registry);
|
|
4814
|
+
return elements.map((element) => ({
|
|
4815
|
+
id: parseIdValues(element.id, pks)[pks[0].fieldName],
|
|
4816
|
+
pivot: element.pivot ? serializePivot(element.pivot, payload, label) : void 0
|
|
4817
|
+
}));
|
|
4818
|
+
}
|
|
4819
|
+
/**
|
|
4675
4820
|
* Update many-to-many and junction relations
|
|
4676
4821
|
*/
|
|
4677
4822
|
async updateRelationsUsingJoins(tx, collection, id, relationValues) {
|
|
@@ -4679,11 +4824,12 @@ var RelationWriteService = class {
|
|
|
4679
4824
|
for (const [key, value] of Object.entries(relationValues)) {
|
|
4680
4825
|
const relation = findRelation(resolvedRelations, key);
|
|
4681
4826
|
if (!relation || relation.cardinality !== "many") continue;
|
|
4682
|
-
const
|
|
4827
|
+
const elements = relationLinkElements(value, key, collection.slug);
|
|
4828
|
+
const targetEntityIds = elements.map((element) => element.id);
|
|
4683
4829
|
const targetCollection = relation.target();
|
|
4684
4830
|
const label = `${collection.slug}.${key}`;
|
|
4685
|
-
if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.
|
|
4686
|
-
else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.
|
|
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));
|
|
4687
4833
|
else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
|
|
4688
4834
|
const targetTable = getTableForCollection(targetCollection, this.registry);
|
|
4689
4835
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
@@ -4722,7 +4868,8 @@ var RelationWriteService = class {
|
|
|
4722
4868
|
await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
|
|
4723
4869
|
table: relation.through.table,
|
|
4724
4870
|
sourceColumn: relation.through.sourceColumn,
|
|
4725
|
-
targetColumn: relation.through.targetColumn
|
|
4871
|
+
targetColumn: relation.through.targetColumn,
|
|
4872
|
+
properties: relation.through.properties
|
|
4726
4873
|
});
|
|
4727
4874
|
continue;
|
|
4728
4875
|
}
|
|
@@ -4755,8 +4902,8 @@ var RelationWriteService = class {
|
|
|
4755
4902
|
if (relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName).length !== 1 || relation.cardinality !== "many") return;
|
|
4756
4903
|
try {
|
|
4757
4904
|
const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
|
|
4758
|
-
const
|
|
4759
|
-
await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.
|
|
4905
|
+
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}`));
|
|
4760
4907
|
} catch (error) {
|
|
4761
4908
|
logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
|
|
4762
4909
|
throw error;
|
|
@@ -4766,9 +4913,10 @@ var RelationWriteService = class {
|
|
|
4766
4913
|
* Handle many-to-many inverse relation updates using junction tables
|
|
4767
4914
|
*/
|
|
4768
4915
|
async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
|
|
4916
|
+
const label = `${sourceCollection.slug}.${relation.relationName}`;
|
|
4769
4917
|
try {
|
|
4770
|
-
const
|
|
4771
|
-
await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo,
|
|
4918
|
+
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));
|
|
4772
4920
|
} catch (error) {
|
|
4773
4921
|
logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
|
|
4774
4922
|
throw error;
|
|
@@ -4974,6 +5122,23 @@ var PersistService = class {
|
|
|
4974
5122
|
this.fetchService = new FetchService(db, registry);
|
|
4975
5123
|
}
|
|
4976
5124
|
/**
|
|
5125
|
+
* Set the columns of one many-to-many link, without touching the membership.
|
|
5126
|
+
*
|
|
5127
|
+
* `collectionPath` is the nested address — `posts/1/tags` — and `targetId`
|
|
5128
|
+
* the row on the far side, so this is `PATCH posts/1/tags/5` with a
|
|
5129
|
+
* `_pivot` body. Separate from {@link save} because the two mean different
|
|
5130
|
+
* things at the same URL: a body of the target's own columns edits the tag,
|
|
5131
|
+
* a body of `_pivot` edits the *link* to it, and conflating them would let
|
|
5132
|
+
* a caller who meant one silently perform the other.
|
|
5133
|
+
*/
|
|
5134
|
+
async updateRelationPivot(collectionPath, targetId, pivot) {
|
|
5135
|
+
const hop = isNestedPath(collectionPath) ? resolveNestedPath(collectionPath, this.registry) : void 0;
|
|
5136
|
+
if (!hop) throw ApiError.badRequest(`"${collectionPath}" is not a relation on a row, so there is no link to update. A \`${JUNCTION_PIVOT_KEY}\` write addresses \`<collection>/<id>/<relation>/<targetId>\`.`, "VALIDATION_UNKNOWN_FIELDS", { path: collectionPath });
|
|
5137
|
+
assertWritableThrough(hop, collectionPath);
|
|
5138
|
+
if (!await this.relationService.isRelated(hop, targetId)) throw ApiError.notFound(`No row "${targetId}" in "${collectionPath}" to update the link of.`);
|
|
5139
|
+
await this.relationWrites.updateRelationPivot(this.db, hop, targetId, pivot);
|
|
5140
|
+
}
|
|
5141
|
+
/**
|
|
4977
5142
|
* Explain a row write that matched nothing — see {@link explainZeroRowWrite}
|
|
4978
5143
|
* for why a zero-row write cannot be reported as success.
|
|
4979
5144
|
*/
|
|
@@ -5331,6 +5496,10 @@ var DataService = class {
|
|
|
5331
5496
|
/**
|
|
5332
5497
|
* Delete all rows from a collection
|
|
5333
5498
|
*/
|
|
5499
|
+
/** See {@link PersistService.updateRelationPivot}. */
|
|
5500
|
+
async updateRelationPivot(collectionPath, targetId, pivot) {
|
|
5501
|
+
return this.persistService.updateRelationPivot(collectionPath, targetId, pivot);
|
|
5502
|
+
}
|
|
5334
5503
|
async deleteAll(collectionPath, databaseId) {
|
|
5335
5504
|
return this.persistService.deleteAll(collectionPath, databaseId);
|
|
5336
5505
|
}
|
|
@@ -5377,6 +5546,125 @@ var DataService = class {
|
|
|
5377
5546
|
}
|
|
5378
5547
|
};
|
|
5379
5548
|
//#endregion
|
|
5549
|
+
//#region src/services/write-transaction-scope.ts
|
|
5550
|
+
/**
|
|
5551
|
+
* The write transaction a request is running inside, published to whatever
|
|
5552
|
+
* the request's callbacks call into.
|
|
5553
|
+
*
|
|
5554
|
+
* `AuthenticatedPostgresBackendDriver.withTransaction` opens one of these for
|
|
5555
|
+
* every read-write transaction. A collection callback runs inside that
|
|
5556
|
+
* transaction, and what it hands off — a job, a queue message, a topic event,
|
|
5557
|
+
* a history entry — has to commit with the write or not at all. Those are
|
|
5558
|
+
* written by code that is not handed the transaction (the job store lives in
|
|
5559
|
+
* `@rebasepro/server` and knows no driver), so the transaction is found here
|
|
5560
|
+
* instead: through `AsyncLocalStorage`, and through the resolver
|
|
5561
|
+
* `@rebasepro/server` reads.
|
|
5562
|
+
*
|
|
5563
|
+
* Only write transactions. A read opens its own `READ ONLY` transaction, where
|
|
5564
|
+
* an insert would fail with 25006, and nothing a read does is undone by
|
|
5565
|
+
* anything — so inside a read the scope is explicitly empty and a queued job
|
|
5566
|
+
* commits on its own, as it always did.
|
|
5567
|
+
*/
|
|
5568
|
+
var WriteTransactionScope = class {
|
|
5569
|
+
run;
|
|
5570
|
+
open = true;
|
|
5571
|
+
inflight = /* @__PURE__ */ new Set();
|
|
5572
|
+
commitHooks = [];
|
|
5573
|
+
/** The transaction handle, for statements in this package that are already written against drizzle. */
|
|
5574
|
+
tx;
|
|
5575
|
+
/** Attach the transaction, once it has begun. */
|
|
5576
|
+
bind(tx, run) {
|
|
5577
|
+
this.tx = tx;
|
|
5578
|
+
this.run = run;
|
|
5579
|
+
}
|
|
5580
|
+
/** Bound, and not yet committing. */
|
|
5581
|
+
get active() {
|
|
5582
|
+
return this.open && this.run !== void 0;
|
|
5583
|
+
}
|
|
5584
|
+
exec(sqlText, params) {
|
|
5585
|
+
if (!this.active) return Promise.reject(/* @__PURE__ */ new Error("This write's transaction is no longer open to new statements."));
|
|
5586
|
+
return this.track(this.run(sqlText, params));
|
|
5587
|
+
}
|
|
5588
|
+
/**
|
|
5589
|
+
* Count a statement as part of the transaction, so the commit waits for it.
|
|
5590
|
+
* A callback that queues work without awaiting it has still asked for that
|
|
5591
|
+
* work to be part of the write.
|
|
5592
|
+
*
|
|
5593
|
+
* Settled into a native promise first, and only that one is handed back:
|
|
5594
|
+
* drizzle's query builders re-run their statement on every `.then()`, so
|
|
5595
|
+
* observing one here and awaiting it at the call site would insert twice.
|
|
5596
|
+
*/
|
|
5597
|
+
track(statement) {
|
|
5598
|
+
const settled = Promise.resolve(statement);
|
|
5599
|
+
this.inflight.add(settled);
|
|
5600
|
+
const done = () => {
|
|
5601
|
+
this.inflight.delete(settled);
|
|
5602
|
+
};
|
|
5603
|
+
settled.then(done, done);
|
|
5604
|
+
return settled;
|
|
5605
|
+
}
|
|
5606
|
+
afterCommit(fn) {
|
|
5607
|
+
this.commitHooks.push(fn);
|
|
5608
|
+
}
|
|
5609
|
+
/**
|
|
5610
|
+
* Wait for every tracked statement, then close to new ones — called as the
|
|
5611
|
+
* last thing inside the transaction, before its commit. A statement that
|
|
5612
|
+
* arrives after this cannot be part of the write, so it runs on its own
|
|
5613
|
+
* connection, as if outside any write.
|
|
5614
|
+
*/
|
|
5615
|
+
async settle() {
|
|
5616
|
+
while (this.inflight.size > 0) await Promise.allSettled([...this.inflight]);
|
|
5617
|
+
this.open = false;
|
|
5618
|
+
}
|
|
5619
|
+
/**
|
|
5620
|
+
* The transaction is over, whichever way it went. Nothing may reach it now:
|
|
5621
|
+
* its client is back in the pool, and a callback's detached promise still
|
|
5622
|
+
* carries this scope in its async context.
|
|
5623
|
+
*/
|
|
5624
|
+
close() {
|
|
5625
|
+
this.open = false;
|
|
5626
|
+
}
|
|
5627
|
+
/** The transaction committed: run what was waiting for that, outside any write. */
|
|
5628
|
+
committed() {
|
|
5629
|
+
for (const hook of this.commitHooks.splice(0)) try {
|
|
5630
|
+
storage.run(void 0, hook);
|
|
5631
|
+
} catch (error) {
|
|
5632
|
+
logger.error("[transaction] An after-commit hook threw; the write had already committed", { error });
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
};
|
|
5636
|
+
/**
|
|
5637
|
+
* On `globalThis`, like the singleton, so two copies of this package share one
|
|
5638
|
+
* store: the copy that opened a transaction and the copy whose code asks for it
|
|
5639
|
+
* are not guaranteed to be the same.
|
|
5640
|
+
*/
|
|
5641
|
+
var STORAGE_SLOT = Symbol.for("rebase.postgres.writeTransactionScope");
|
|
5642
|
+
var storage = globalThis[STORAGE_SLOT] ??= new AsyncLocalStorage();
|
|
5643
|
+
/** The open write transaction the caller is inside, if any. */
|
|
5644
|
+
function currentWriteScope() {
|
|
5645
|
+
const scope = storage.getStore();
|
|
5646
|
+
return scope?.active ? scope : void 0;
|
|
5647
|
+
}
|
|
5648
|
+
/**
|
|
5649
|
+
* Run `fn` with `scope` as the current write transaction. `undefined` clears
|
|
5650
|
+
* it: a read nested inside a write's callback is not part of that write.
|
|
5651
|
+
*/
|
|
5652
|
+
function runInWriteScope(scope, fn) {
|
|
5653
|
+
return storage.run(scope, fn);
|
|
5654
|
+
}
|
|
5655
|
+
/**
|
|
5656
|
+
* Publish the scope to `@rebasepro/server`'s job store and webhook dispatcher.
|
|
5657
|
+
*
|
|
5658
|
+
* Written to the slot directly, not through `setAmbientTransactionResolver`:
|
|
5659
|
+
* the runtime image supplies `@rebasepro/server` while a bundle brings this
|
|
5660
|
+
* package, and importing a function an older image does not export would fail
|
|
5661
|
+
* to link and stop the boot. Through the slot, an older server simply never
|
|
5662
|
+
* reads it. The key is frozen — `@rebasepro/server/src/db/ambient-transaction.ts`
|
|
5663
|
+
* reads the same one, and `write-transaction-scope-e2e.test.ts` fails if they
|
|
5664
|
+
* drift apart.
|
|
5665
|
+
*/
|
|
5666
|
+
globalThis[Symbol.for("rebase.server.ambientTransactionResolver")] = currentWriteScope;
|
|
5667
|
+
//#endregion
|
|
5380
5668
|
//#region src/schema/classify-change.ts
|
|
5381
5669
|
var VERDICT_RANK = {
|
|
5382
5670
|
safe: 0,
|
|
@@ -5817,6 +6105,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5817
6105
|
branchService;
|
|
5818
6106
|
user;
|
|
5819
6107
|
data;
|
|
6108
|
+
/**
|
|
6109
|
+
* The server singleton, attached by `initializeRebaseBackend` after boot —
|
|
6110
|
+
* a driver exists before the client does. Typed as what is attached: a
|
|
6111
|
+
* `RebaseServerClient` has no `data`, and callbacks read it as
|
|
6112
|
+
* `context.client`.
|
|
6113
|
+
*/
|
|
5820
6114
|
client;
|
|
5821
6115
|
/**
|
|
5822
6116
|
* Auto-set to `true` once a `SET LOCAL ROLE` has failed with insufficient
|
|
@@ -5896,8 +6190,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5896
6190
|
*/
|
|
5897
6191
|
schemaFactsQueryable() {
|
|
5898
6192
|
return { query: async (text) => {
|
|
5899
|
-
|
|
5900
|
-
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
6193
|
+
return { rows: sqlRows(await this.db.execute(sql.raw(text))) };
|
|
5901
6194
|
} };
|
|
5902
6195
|
}
|
|
5903
6196
|
/**
|
|
@@ -5940,18 +6233,28 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5940
6233
|
* suite, because it is the kind of property that is easy to break from a
|
|
5941
6234
|
* distance and impossible to notice.
|
|
5942
6235
|
*
|
|
5943
|
-
* Previously returned through `as
|
|
6236
|
+
* Previously returned through `as RebaseCallContext`, which
|
|
5944
6237
|
* disabled checking for the whole object and let `driver` — documented in
|
|
5945
6238
|
* the callbacks guide — sit on the runtime context while absent from the
|
|
5946
6239
|
* contract. Both are declared now, so this is a plain typed return.
|
|
6240
|
+
*
|
|
6241
|
+
* `client` went through a narrower cast, `as RebaseCallContext["client"]`,
|
|
6242
|
+
* and it lied twice. It said `RebaseClient`, `data` included, about the
|
|
6243
|
+
* server singleton, which has no `data` — so `context.client.data` compiled
|
|
6244
|
+
* and threw in production. And it dropped the `| undefined` of a client
|
|
6245
|
+
* that is attached after construction. The type now leaves `data` off, and
|
|
6246
|
+
* a driver that was never given a client refuses by name, at the read.
|
|
5947
6247
|
*/
|
|
5948
6248
|
buildCallContext() {
|
|
6249
|
+
const client = this.client;
|
|
5949
6250
|
return {
|
|
5950
6251
|
user: this.user,
|
|
5951
6252
|
driver: this,
|
|
5952
6253
|
data: this.data,
|
|
5953
|
-
client
|
|
5954
|
-
|
|
6254
|
+
get client() {
|
|
6255
|
+
return requireCallbackClient(client);
|
|
6256
|
+
},
|
|
6257
|
+
storageSource: client?.storage
|
|
5955
6258
|
};
|
|
5956
6259
|
}
|
|
5957
6260
|
resolveCollectionCallbacks(collection, path) {
|
|
@@ -5985,20 +6288,22 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5985
6288
|
async applyAfterReadToRow(row, path, resolved, contextForCallback) {
|
|
5986
6289
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = resolved;
|
|
5987
6290
|
let out = row;
|
|
6291
|
+
if (!PostgresBackendDriver.hasAfterRead(resolved)) return out;
|
|
6292
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
5988
6293
|
if (globalCallbacks?.afterRead) out = await globalCallbacks.afterRead({
|
|
5989
|
-
collection:
|
|
6294
|
+
collection: callbackCollection,
|
|
5990
6295
|
path,
|
|
5991
6296
|
row: out,
|
|
5992
6297
|
context: contextForCallback
|
|
5993
6298
|
}) ?? out;
|
|
5994
6299
|
if (callbacks?.afterRead) out = await callbacks.afterRead({
|
|
5995
|
-
collection:
|
|
6300
|
+
collection: callbackCollection,
|
|
5996
6301
|
path,
|
|
5997
6302
|
row: out,
|
|
5998
6303
|
context: contextForCallback
|
|
5999
6304
|
}) ?? out;
|
|
6000
6305
|
if (propertyCallbacks?.afterRead) out = await propertyCallbacks.afterRead({
|
|
6001
|
-
collection:
|
|
6306
|
+
collection: callbackCollection,
|
|
6002
6307
|
path,
|
|
6003
6308
|
row: out,
|
|
6004
6309
|
context: contextForCallback
|
|
@@ -6030,9 +6335,10 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6030
6335
|
if (resolved.collection) try {
|
|
6031
6336
|
const rels = resolveCollectionRelations(resolved.collection);
|
|
6032
6337
|
for (const [key, rel] of Object.entries(rels)) {
|
|
6033
|
-
const
|
|
6338
|
+
const target = typeof rel.target === "function" ? rel.target() : void 0;
|
|
6339
|
+
const targetPath = target?.slug;
|
|
6034
6340
|
if (!targetPath) continue;
|
|
6035
|
-
const targetResolved = this.resolveCollectionCallbacks(
|
|
6341
|
+
const targetResolved = this.resolveCollectionCallbacks(target, targetPath);
|
|
6036
6342
|
if (PostgresBackendDriver.hasAfterRead(targetResolved)) relationTargets[key] = {
|
|
6037
6343
|
path: targetPath,
|
|
6038
6344
|
resolved: targetResolved
|
|
@@ -6079,22 +6385,23 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6079
6385
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6080
6386
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
6081
6387
|
const contextForCallback = this.buildCallContext();
|
|
6388
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6082
6389
|
return Promise.all(rows.map(async (row) => {
|
|
6083
6390
|
let fetched = row;
|
|
6084
6391
|
if (globalCallbacks?.afterRead) fetched = await globalCallbacks.afterRead({
|
|
6085
|
-
collection:
|
|
6392
|
+
collection: callbackCollection,
|
|
6086
6393
|
path,
|
|
6087
6394
|
row: fetched,
|
|
6088
6395
|
context: contextForCallback
|
|
6089
6396
|
}) ?? fetched;
|
|
6090
6397
|
if (callbacks?.afterRead) fetched = await callbacks.afterRead({
|
|
6091
|
-
collection:
|
|
6398
|
+
collection: callbackCollection,
|
|
6092
6399
|
path,
|
|
6093
6400
|
row: fetched,
|
|
6094
6401
|
context: contextForCallback
|
|
6095
6402
|
}) ?? fetched;
|
|
6096
6403
|
if (propertyCallbacks?.afterRead) fetched = await propertyCallbacks.afterRead({
|
|
6097
|
-
collection:
|
|
6404
|
+
collection: callbackCollection,
|
|
6098
6405
|
path,
|
|
6099
6406
|
row: fetched,
|
|
6100
6407
|
context: contextForCallback
|
|
@@ -6150,20 +6457,21 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6150
6457
|
const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
|
|
6151
6458
|
if (row && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
6152
6459
|
const contextForCallback = this.buildCallContext();
|
|
6460
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6153
6461
|
if (globalCallbacks?.afterRead) row = await globalCallbacks.afterRead({
|
|
6154
|
-
collection:
|
|
6462
|
+
collection: callbackCollection,
|
|
6155
6463
|
path,
|
|
6156
6464
|
row,
|
|
6157
6465
|
context: contextForCallback
|
|
6158
6466
|
}) ?? row;
|
|
6159
6467
|
if (callbacks?.afterRead) row = await callbacks.afterRead({
|
|
6160
|
-
collection:
|
|
6468
|
+
collection: callbackCollection,
|
|
6161
6469
|
path,
|
|
6162
6470
|
row,
|
|
6163
6471
|
context: contextForCallback
|
|
6164
6472
|
}) ?? row;
|
|
6165
6473
|
if (propertyCallbacks?.afterRead) row = await propertyCallbacks.afterRead({
|
|
6166
|
-
collection:
|
|
6474
|
+
collection: callbackCollection,
|
|
6167
6475
|
path,
|
|
6168
6476
|
row,
|
|
6169
6477
|
context: contextForCallback
|
|
@@ -6313,9 +6621,10 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6313
6621
|
}
|
|
6314
6622
|
try {
|
|
6315
6623
|
if (globalCallbacks?.beforeSave || callbacks?.beforeSave || propertyCallbacks?.beforeSave) {
|
|
6624
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6316
6625
|
if (globalCallbacks?.beforeSave) {
|
|
6317
6626
|
const result = await globalCallbacks.beforeSave({
|
|
6318
|
-
collection:
|
|
6627
|
+
collection: callbackCollection,
|
|
6319
6628
|
path,
|
|
6320
6629
|
id,
|
|
6321
6630
|
values: updatedValues,
|
|
@@ -6327,7 +6636,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6327
6636
|
}
|
|
6328
6637
|
if (callbacks?.beforeSave) {
|
|
6329
6638
|
const result = await callbacks.beforeSave({
|
|
6330
|
-
collection:
|
|
6639
|
+
collection: callbackCollection,
|
|
6331
6640
|
path,
|
|
6332
6641
|
id,
|
|
6333
6642
|
values: updatedValues,
|
|
@@ -6339,7 +6648,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6339
6648
|
}
|
|
6340
6649
|
if (propertyCallbacks?.beforeSave) {
|
|
6341
6650
|
const result = await propertyCallbacks.beforeSave({
|
|
6342
|
-
collection:
|
|
6651
|
+
collection: callbackCollection,
|
|
6343
6652
|
path,
|
|
6344
6653
|
id,
|
|
6345
6654
|
values: updatedValues,
|
|
@@ -6375,20 +6684,21 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6375
6684
|
onConflict
|
|
6376
6685
|
});
|
|
6377
6686
|
if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
|
|
6687
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6378
6688
|
if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
|
|
6379
|
-
collection:
|
|
6689
|
+
collection: callbackCollection,
|
|
6380
6690
|
path,
|
|
6381
6691
|
row: savedRow,
|
|
6382
6692
|
context: contextForCallback
|
|
6383
6693
|
}) ?? savedRow;
|
|
6384
6694
|
if (callbacks?.afterRead) savedRow = await callbacks.afterRead({
|
|
6385
|
-
collection:
|
|
6695
|
+
collection: callbackCollection,
|
|
6386
6696
|
path,
|
|
6387
6697
|
row: savedRow,
|
|
6388
6698
|
context: contextForCallback
|
|
6389
6699
|
}) ?? savedRow;
|
|
6390
6700
|
if (propertyCallbacks?.afterRead) savedRow = await propertyCallbacks.afterRead({
|
|
6391
|
-
collection:
|
|
6701
|
+
collection: callbackCollection,
|
|
6392
6702
|
path,
|
|
6393
6703
|
row: savedRow,
|
|
6394
6704
|
context: contextForCallback
|
|
@@ -6398,8 +6708,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6398
6708
|
const savedValues = savedRow;
|
|
6399
6709
|
try {
|
|
6400
6710
|
if (globalCallbacks?.afterSave || callbacks?.afterSave || propertyCallbacks?.afterSave) {
|
|
6711
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6401
6712
|
if (globalCallbacks?.afterSave) await globalCallbacks.afterSave({
|
|
6402
|
-
collection:
|
|
6713
|
+
collection: callbackCollection,
|
|
6403
6714
|
path,
|
|
6404
6715
|
id: savedId,
|
|
6405
6716
|
values: savedValues,
|
|
@@ -6408,7 +6719,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6408
6719
|
context: contextForCallback
|
|
6409
6720
|
});
|
|
6410
6721
|
if (callbacks?.afterSave) await callbacks.afterSave({
|
|
6411
|
-
collection:
|
|
6722
|
+
collection: callbackCollection,
|
|
6412
6723
|
path,
|
|
6413
6724
|
id: savedId,
|
|
6414
6725
|
values: savedValues,
|
|
@@ -6417,7 +6728,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6417
6728
|
context: contextForCallback
|
|
6418
6729
|
});
|
|
6419
6730
|
if (propertyCallbacks?.afterSave) await propertyCallbacks.afterSave({
|
|
6420
|
-
collection:
|
|
6731
|
+
collection: callbackCollection,
|
|
6421
6732
|
path,
|
|
6422
6733
|
id: savedId,
|
|
6423
6734
|
values: savedValues,
|
|
@@ -6447,6 +6758,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6447
6758
|
return savedRow;
|
|
6448
6759
|
} catch (error) {
|
|
6449
6760
|
if (globalCallbacks?.afterSaveError || callbacks?.afterSaveError || propertyCallbacks?.afterSaveError) {
|
|
6761
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, path);
|
|
6450
6762
|
const errorProps = {
|
|
6451
6763
|
path,
|
|
6452
6764
|
id,
|
|
@@ -6457,15 +6769,15 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6457
6769
|
context: contextForCallback
|
|
6458
6770
|
};
|
|
6459
6771
|
if (globalCallbacks?.afterSaveError) await globalCallbacks.afterSaveError({
|
|
6460
|
-
collection:
|
|
6772
|
+
collection: callbackCollection,
|
|
6461
6773
|
...errorProps
|
|
6462
6774
|
});
|
|
6463
6775
|
if (callbacks?.afterSaveError) await callbacks.afterSaveError({
|
|
6464
|
-
collection:
|
|
6776
|
+
collection: callbackCollection,
|
|
6465
6777
|
...errorProps
|
|
6466
6778
|
});
|
|
6467
6779
|
if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
|
|
6468
|
-
collection:
|
|
6780
|
+
collection: callbackCollection,
|
|
6469
6781
|
...errorProps
|
|
6470
6782
|
});
|
|
6471
6783
|
}
|
|
@@ -6492,6 +6804,26 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6492
6804
|
txDriver._pendingNotifications = this._pendingNotifications;
|
|
6493
6805
|
return txDriver;
|
|
6494
6806
|
}
|
|
6807
|
+
/**
|
|
6808
|
+
* The callback context for work already running inside `tx` on behalf of
|
|
6809
|
+
* `user` — for a caller that opened the user-scoped transaction itself.
|
|
6810
|
+
*
|
|
6811
|
+
* The realtime refetch is that caller: it applies the subscriber's auth
|
|
6812
|
+
* context and role switch on its own connection. Its `afterRead` hooks used
|
|
6813
|
+
* to get a context assembled by hand, and it was wrong in both directions:
|
|
6814
|
+
* `data` was THIS driver's — the base one, on the owner connection, outside
|
|
6815
|
+
* that transaction — so a hook reading related rows through `context.data`
|
|
6816
|
+
* bypassed RLS on every subscription frame while the REST read of the same
|
|
6817
|
+
* rows did not; and `client` and `storageSource` were absent, though the
|
|
6818
|
+
* type promises both. This builds the context the REST path builds, bound to
|
|
6819
|
+
* the transaction the rows came from.
|
|
6820
|
+
*/
|
|
6821
|
+
callContextWithin(tx, user) {
|
|
6822
|
+
const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, user, this.poolManager, this.historyService);
|
|
6823
|
+
txDriver.dataService = new DataService(tx, this.registry);
|
|
6824
|
+
txDriver.client = this.client;
|
|
6825
|
+
return txDriver.buildCallContext();
|
|
6826
|
+
}
|
|
6495
6827
|
async saveMany({ path, rows, collection, upsert, onConflict }) {
|
|
6496
6828
|
return this.db.transaction(async (tx) => {
|
|
6497
6829
|
const txDriver = this.bindToTransaction(tx);
|
|
@@ -6720,10 +7052,11 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6720
7052
|
const contextForCallback = this.buildCallContext();
|
|
6721
7053
|
try {
|
|
6722
7054
|
if (globalCallbacks?.beforeDelete || callbacks?.beforeDelete || propertyCallbacks?.beforeDelete) {
|
|
7055
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, targetPath);
|
|
6723
7056
|
let preventDefault = false;
|
|
6724
7057
|
if (globalCallbacks?.beforeDelete) {
|
|
6725
7058
|
if (await globalCallbacks.beforeDelete({
|
|
6726
|
-
collection:
|
|
7059
|
+
collection: callbackCollection,
|
|
6727
7060
|
path: targetPath,
|
|
6728
7061
|
id: row.id,
|
|
6729
7062
|
row: targetRow,
|
|
@@ -6732,7 +7065,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6732
7065
|
}
|
|
6733
7066
|
if (callbacks?.beforeDelete) {
|
|
6734
7067
|
if (await callbacks.beforeDelete({
|
|
6735
|
-
collection:
|
|
7068
|
+
collection: callbackCollection,
|
|
6736
7069
|
path: targetPath,
|
|
6737
7070
|
id: row.id,
|
|
6738
7071
|
row: targetRow,
|
|
@@ -6741,7 +7074,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6741
7074
|
}
|
|
6742
7075
|
if (propertyCallbacks?.beforeDelete) {
|
|
6743
7076
|
if (await propertyCallbacks.beforeDelete({
|
|
6744
|
-
collection:
|
|
7077
|
+
collection: callbackCollection,
|
|
6745
7078
|
path: targetPath,
|
|
6746
7079
|
id: row.id,
|
|
6747
7080
|
row: targetRow,
|
|
@@ -6758,22 +7091,23 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6758
7091
|
else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
|
|
6759
7092
|
try {
|
|
6760
7093
|
if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
|
|
7094
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, targetPath);
|
|
6761
7095
|
if (globalCallbacks?.afterDelete) await globalCallbacks.afterDelete({
|
|
6762
|
-
collection:
|
|
7096
|
+
collection: callbackCollection,
|
|
6763
7097
|
path: targetPath,
|
|
6764
7098
|
id: row.id,
|
|
6765
7099
|
row: targetRow,
|
|
6766
7100
|
context: contextForCallback
|
|
6767
7101
|
});
|
|
6768
7102
|
if (callbacks?.afterDelete) await callbacks.afterDelete({
|
|
6769
|
-
collection:
|
|
7103
|
+
collection: callbackCollection,
|
|
6770
7104
|
path: targetPath,
|
|
6771
7105
|
id: row.id,
|
|
6772
7106
|
row: targetRow,
|
|
6773
7107
|
context: contextForCallback
|
|
6774
7108
|
});
|
|
6775
7109
|
if (propertyCallbacks?.afterDelete) await propertyCallbacks.afterDelete({
|
|
6776
|
-
collection:
|
|
7110
|
+
collection: callbackCollection,
|
|
6777
7111
|
path: targetPath,
|
|
6778
7112
|
id: row.id,
|
|
6779
7113
|
row: targetRow,
|
|
@@ -6802,6 +7136,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6802
7136
|
await this.dataService.deleteAll(path);
|
|
6803
7137
|
await this.realtimeService.notifyUpdate(path, "*", null);
|
|
6804
7138
|
}
|
|
7139
|
+
async updateRelationPivot({ path, targetId, pivot }) {
|
|
7140
|
+
await this.dataService.updateRelationPivot(path, targetId, pivot);
|
|
7141
|
+
}
|
|
6805
7142
|
async checkUniqueField(path, name, value, id, collection) {
|
|
6806
7143
|
return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
|
|
6807
7144
|
}
|
|
@@ -6984,7 +7321,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
6984
7321
|
col.enum_values = [];
|
|
6985
7322
|
}
|
|
6986
7323
|
return {
|
|
6987
|
-
columns,
|
|
7324
|
+
columns: sqlRows(columns),
|
|
6988
7325
|
foreignKeys: (await this.db.execute(sql`
|
|
6989
7326
|
SELECT
|
|
6990
7327
|
kcu.column_name as column_name,
|
|
@@ -7078,7 +7415,8 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7078
7415
|
}
|
|
7079
7416
|
async withTransaction(operation, options) {
|
|
7080
7417
|
const pendingNotifications = [];
|
|
7081
|
-
const
|
|
7418
|
+
const writeScope = options?.accessMode === "read only" ? void 0 : new WriteTransactionScope();
|
|
7419
|
+
const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await runInWriteScope(writeScope, () => this.delegate.db.transaction(async (tx) => {
|
|
7082
7420
|
let uid = this.user?.uid;
|
|
7083
7421
|
if (!uid) {
|
|
7084
7422
|
logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
|
|
@@ -7098,8 +7436,18 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7098
7436
|
txDelegate._deferNotifications = true;
|
|
7099
7437
|
txDelegate._pendingNotifications = pendingNotifications;
|
|
7100
7438
|
txDelegate.client = this.delegate.client;
|
|
7101
|
-
|
|
7102
|
-
|
|
7439
|
+
writeScope?.bind(tx, (sqlText, params) => txEntityService.executeSql(sqlText, params));
|
|
7440
|
+
let out;
|
|
7441
|
+
try {
|
|
7442
|
+
out = await operation(txDelegate);
|
|
7443
|
+
} catch (error) {
|
|
7444
|
+
writeScope?.close();
|
|
7445
|
+
throw error;
|
|
7446
|
+
}
|
|
7447
|
+
await writeScope?.settle();
|
|
7448
|
+
return out;
|
|
7449
|
+
}, options)).finally(() => writeScope?.close()));
|
|
7450
|
+
writeScope?.committed();
|
|
7103
7451
|
for (const notification of pendingNotifications) try {
|
|
7104
7452
|
await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
|
|
7105
7453
|
} catch (e) {
|
|
@@ -7182,6 +7530,9 @@ var AuthenticatedPostgresBackendDriver = class {
|
|
|
7182
7530
|
async deleteAll(path) {
|
|
7183
7531
|
return this.withTransaction((delegate) => delegate.deleteAll(path));
|
|
7184
7532
|
}
|
|
7533
|
+
async updateRelationPivot(props) {
|
|
7534
|
+
return this.withTransaction((delegate) => delegate.updateRelationPivot(props));
|
|
7535
|
+
}
|
|
7185
7536
|
async checkUniqueField(path, name, value, id, collection) {
|
|
7186
7537
|
return this.withTransaction((delegate) => delegate.checkUniqueField(path, name, value, id, collection), { accessMode: "read only" });
|
|
7187
7538
|
}
|
|
@@ -7810,7 +8161,7 @@ var CdcListener = class {
|
|
|
7810
8161
|
*/
|
|
7811
8162
|
function drizzleDdlBootstrapper(db, scope) {
|
|
7812
8163
|
return createDdlBootstrapper(async (statement) => {
|
|
7813
|
-
return (await db.execute(sql.raw(statement)))
|
|
8164
|
+
return sqlRows(await db.execute(sql.raw(statement)));
|
|
7814
8165
|
}, scope);
|
|
7815
8166
|
}
|
|
7816
8167
|
//#endregion
|
|
@@ -8412,7 +8763,7 @@ var PostgresChannelBus = class {
|
|
|
8412
8763
|
this.openWindow();
|
|
8413
8764
|
}
|
|
8414
8765
|
}, this.batchWindowMs);
|
|
8415
|
-
this.windowTimer
|
|
8766
|
+
unref(this.windowTimer);
|
|
8416
8767
|
}
|
|
8417
8768
|
/** Send everything queued and settle the promises waiting on it. */
|
|
8418
8769
|
flush() {
|
|
@@ -8563,6 +8914,38 @@ function createChannelBus(setting, deps) {
|
|
|
8563
8914
|
//#region src/services/realtimeService.ts
|
|
8564
8915
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
8565
8916
|
var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
8917
|
+
function mintsCallContexts(driver) {
|
|
8918
|
+
return driver !== void 0 && "callContextWithin" in driver && typeof driver.callContextWithin === "function";
|
|
8919
|
+
}
|
|
8920
|
+
/**
|
|
8921
|
+
* The context a subscription frame's `afterRead` hooks run with.
|
|
8922
|
+
*
|
|
8923
|
+
* It used to be assembled here by hand — `{ user, driver, data }` cast to
|
|
8924
|
+
* `RebaseCallContext` — and the cast hid two faults. `data` was the realtime
|
|
8925
|
+
* service's own driver, the base one on the owner connection, outside the
|
|
8926
|
+
* transaction this frame's rows were read in: a hook enriching rows through
|
|
8927
|
+
* `context.data` bypassed RLS on every frame, while the REST read of the same
|
|
8928
|
+
* rows, whose hooks run on the caller's transaction, did not. And `client` and
|
|
8929
|
+
* `storageSource`, which the type declares required, were missing, so a hook
|
|
8930
|
+
* that signs a URL or invokes a function worked over REST and failed every
|
|
8931
|
+
* `.listen()`. The driver now builds it, bound to `tx`, the way the REST path
|
|
8932
|
+
* builds it.
|
|
8933
|
+
*
|
|
8934
|
+
* Refuses rather than falling back: the fallback IS the unscoped context.
|
|
8935
|
+
*/
|
|
8936
|
+
function callbackContextWithin(driver, tx, auth) {
|
|
8937
|
+
if (!mintsCallContexts(driver)) throw new Error("This driver cannot run afterRead hooks inside the subscriber's transaction.");
|
|
8938
|
+
return driver.callContextWithin(tx, {
|
|
8939
|
+
uid: auth.uid,
|
|
8940
|
+
roles: auth.roles,
|
|
8941
|
+
isAnonymous: auth.isAnonymous === true,
|
|
8942
|
+
displayName: null,
|
|
8943
|
+
email: null,
|
|
8944
|
+
photoURL: null,
|
|
8945
|
+
providerId: "realtime",
|
|
8946
|
+
...auth.claims ? { claims: auth.claims } : {}
|
|
8947
|
+
});
|
|
8948
|
+
}
|
|
8566
8949
|
/**
|
|
8567
8950
|
* PostgreSQL-specific realtime service.
|
|
8568
8951
|
* Handles WebSocket connections and subscriptions for real-time row updates.
|
|
@@ -9162,30 +9545,24 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9162
9545
|
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
9163
9546
|
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : void 0;
|
|
9164
9547
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
9165
|
-
const contextForCallback =
|
|
9166
|
-
|
|
9167
|
-
uid: activeAuth.uid,
|
|
9168
|
-
roles: activeAuth.roles
|
|
9169
|
-
},
|
|
9170
|
-
driver: this.driver,
|
|
9171
|
-
data: this.driver && "data" in this.driver ? this.driver.data : void 0
|
|
9172
|
-
};
|
|
9548
|
+
const contextForCallback = callbackContextWithin(this.driver, tx, activeAuth);
|
|
9549
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, notifyPath);
|
|
9173
9550
|
return await Promise.all(fetchedEntities.map(async (fetchedRow) => {
|
|
9174
9551
|
let processedEntity = fetchedRow;
|
|
9175
9552
|
if (globalCallbacks?.afterRead) processedEntity = await globalCallbacks.afterRead({
|
|
9176
|
-
collection:
|
|
9553
|
+
collection: callbackCollection,
|
|
9177
9554
|
path: notifyPath,
|
|
9178
9555
|
row: processedEntity,
|
|
9179
9556
|
context: contextForCallback
|
|
9180
9557
|
}) ?? processedEntity;
|
|
9181
9558
|
if (callbacks?.afterRead) processedEntity = await callbacks.afterRead({
|
|
9182
|
-
collection:
|
|
9559
|
+
collection: callbackCollection,
|
|
9183
9560
|
path: notifyPath,
|
|
9184
9561
|
row: processedEntity,
|
|
9185
9562
|
context: contextForCallback
|
|
9186
9563
|
}) ?? processedEntity;
|
|
9187
9564
|
if (propertyCallbacks?.afterRead) processedEntity = await propertyCallbacks.afterRead({
|
|
9188
|
-
collection:
|
|
9565
|
+
collection: callbackCollection,
|
|
9189
9566
|
path: notifyPath,
|
|
9190
9567
|
row: processedEntity,
|
|
9191
9568
|
context: contextForCallback
|
|
@@ -9334,28 +9711,22 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9334
9711
|
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
9335
9712
|
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : void 0;
|
|
9336
9713
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
9337
|
-
const contextForCallback =
|
|
9338
|
-
|
|
9339
|
-
uid: activeAuth.uid,
|
|
9340
|
-
roles: activeAuth.roles
|
|
9341
|
-
},
|
|
9342
|
-
driver: this.driver,
|
|
9343
|
-
data: this.driver && "data" in this.driver ? this.driver.data : void 0
|
|
9344
|
-
};
|
|
9714
|
+
const contextForCallback = callbackContextWithin(this.driver, tx, activeAuth);
|
|
9715
|
+
const callbackCollection = requireCallbackCollection(resolvedCollection, notifyPath);
|
|
9345
9716
|
if (globalCallbacks?.afterRead) processedEntity = await globalCallbacks.afterRead({
|
|
9346
|
-
collection:
|
|
9717
|
+
collection: callbackCollection,
|
|
9347
9718
|
path: notifyPath,
|
|
9348
9719
|
row: processedEntity,
|
|
9349
9720
|
context: contextForCallback
|
|
9350
9721
|
}) ?? processedEntity;
|
|
9351
9722
|
if (callbacks?.afterRead) processedEntity = await callbacks.afterRead({
|
|
9352
|
-
collection:
|
|
9723
|
+
collection: callbackCollection,
|
|
9353
9724
|
path: notifyPath,
|
|
9354
9725
|
row: processedEntity,
|
|
9355
9726
|
context: contextForCallback
|
|
9356
9727
|
}) ?? processedEntity;
|
|
9357
9728
|
if (propertyCallbacks?.afterRead) processedEntity = await propertyCallbacks.afterRead({
|
|
9358
|
-
collection:
|
|
9729
|
+
collection: callbackCollection,
|
|
9359
9730
|
path: notifyPath,
|
|
9360
9731
|
row: processedEntity,
|
|
9361
9732
|
context: contextForCallback
|
|
@@ -9990,7 +10361,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9990
10361
|
ensurePresenceSweep() {
|
|
9991
10362
|
if (this.presenceSweepInterval || !this.presenceStore) return;
|
|
9992
10363
|
this.presenceSweepInterval = setInterval(() => void this.sweepStalePresence(), RealtimeService.PRESENCE_SWEEP_INTERVAL_MS);
|
|
9993
|
-
this.presenceSweepInterval
|
|
10364
|
+
unref(this.presenceSweepInterval);
|
|
9994
10365
|
}
|
|
9995
10366
|
/** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
|
|
9996
10367
|
async sweepStalePresence() {
|
|
@@ -11137,7 +11508,7 @@ function createBackupCron(config) {
|
|
|
11137
11508
|
enabled: config.enabled ?? true,
|
|
11138
11509
|
timeoutSeconds: 3600,
|
|
11139
11510
|
async handler({ log }) {
|
|
11140
|
-
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-
|
|
11511
|
+
const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BNLwvxuy.js").then((n) => n.r);
|
|
11141
11512
|
const { destination } = config;
|
|
11142
11513
|
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 }).`);
|
|
11143
11514
|
log(`Starting backup of "${dbName}"…`);
|
|
@@ -11190,6 +11561,28 @@ function formatBytes(bytes) {
|
|
|
11190
11561
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
11191
11562
|
}
|
|
11192
11563
|
//#endregion
|
|
11564
|
+
//#region src/types.ts
|
|
11565
|
+
/**
|
|
11566
|
+
* Read a statically-declared drizzle table as one whose columns are looked up
|
|
11567
|
+
* by name.
|
|
11568
|
+
*
|
|
11569
|
+
* The auth services take either the tables this package declares or a host
|
|
11570
|
+
* application's own, and reach their columns through strings — `has("aal")`,
|
|
11571
|
+
* `col("sessionId")` — because which columns exist is a property of the
|
|
11572
|
+
* database in front of them, not of the schema this package compiled against.
|
|
11573
|
+
* That is what {@link RebasePgTable} is for.
|
|
11574
|
+
*
|
|
11575
|
+
* A concrete table does not satisfy it: its columns have specific types and it
|
|
11576
|
+
* carries methods and symbols besides, so `Record<string, AnyPgColumn>` is not
|
|
11577
|
+
* a supertype and a direct `as` is refused. Written out, that refusal was
|
|
11578
|
+
* answered six times with `as unknown as RebasePgTable`, which also discarded
|
|
11579
|
+
* the check that the thing being converted is a table at all — the parameter
|
|
11580
|
+
* below is what restores it.
|
|
11581
|
+
*/
|
|
11582
|
+
function asRebasePgTable(table) {
|
|
11583
|
+
return table;
|
|
11584
|
+
}
|
|
11585
|
+
//#endregion
|
|
11193
11586
|
//#region src/collections/validate-relations.ts
|
|
11194
11587
|
/**
|
|
11195
11588
|
* Every name a column answers to: the key in the drizzle schema and the real
|
|
@@ -11521,8 +11914,8 @@ var UserService = class {
|
|
|
11521
11914
|
this.userIdentitiesTable = tables.userIdentities || userIdentities;
|
|
11522
11915
|
} else {
|
|
11523
11916
|
const table = tableOrTables;
|
|
11524
|
-
this.usersTable = table || users;
|
|
11525
|
-
this.userIdentitiesTable = userIdentities;
|
|
11917
|
+
this.usersTable = table || asRebasePgTable(users);
|
|
11918
|
+
this.userIdentitiesTable = asRebasePgTable(userIdentities);
|
|
11526
11919
|
}
|
|
11527
11920
|
}
|
|
11528
11921
|
getQualifiedUsersTableName() {
|
|
@@ -11886,7 +12279,7 @@ var UserService = class {
|
|
|
11886
12279
|
};
|
|
11887
12280
|
}
|
|
11888
12281
|
};
|
|
11889
|
-
var RefreshTokenService = class {
|
|
12282
|
+
var RefreshTokenService = class RefreshTokenService {
|
|
11890
12283
|
db;
|
|
11891
12284
|
refreshTokensTable;
|
|
11892
12285
|
usersTable;
|
|
@@ -11896,8 +12289,8 @@ var RefreshTokenService = class {
|
|
|
11896
12289
|
this.refreshTokensTable = tableOrTables.refreshTokens || refreshTokens;
|
|
11897
12290
|
this.usersTable = tableOrTables.users || users;
|
|
11898
12291
|
} else {
|
|
11899
|
-
this.refreshTokensTable = tableOrTables || refreshTokens;
|
|
11900
|
-
this.usersTable = users;
|
|
12292
|
+
this.refreshTokensTable = tableOrTables || asRebasePgTable(refreshTokens);
|
|
12293
|
+
this.usersTable = asRebasePgTable(users);
|
|
11901
12294
|
}
|
|
11902
12295
|
}
|
|
11903
12296
|
/**
|
|
@@ -11944,9 +12337,44 @@ var RefreshTokenService = class {
|
|
|
11944
12337
|
if (session?.aal && this.has("aal")) values.aal = session.aal;
|
|
11945
12338
|
await this.db.insert(this.refreshTokensTable).values(values);
|
|
11946
12339
|
}
|
|
12340
|
+
/**
|
|
12341
|
+
* Build a {@link RefreshTokenInfo} out of a row of {@link selection}.
|
|
12342
|
+
*
|
|
12343
|
+
* `selection()` is assembled at runtime from the columns this table actually
|
|
12344
|
+
* has, so drizzle types the row as an open record and nothing static
|
|
12345
|
+
* connects it to the interface. Both call sites used to assert the
|
|
12346
|
+
* connection — `row as unknown as RefreshTokenInfo` — which claims every
|
|
12347
|
+
* optional field is present and correctly typed on a row where, by
|
|
12348
|
+
* construction, some of them were never selected.
|
|
12349
|
+
*
|
|
12350
|
+
* Constructing it says the true thing instead: the five required fields come
|
|
12351
|
+
* from columns `selection()` always includes, and each optional one is
|
|
12352
|
+
* copied only when the row carries it. A host application's own
|
|
12353
|
+
* `refresh_tokens` table predating session grouping is the case this whole
|
|
12354
|
+
* class is shaped around, and it is exactly the case the assertion
|
|
12355
|
+
* misdescribed.
|
|
12356
|
+
*/
|
|
12357
|
+
static toInfo(row) {
|
|
12358
|
+
const asDate = (value) => value instanceof Date ? value : new Date(String(value));
|
|
12359
|
+
const info = {
|
|
12360
|
+
id: row.id,
|
|
12361
|
+
uid: row.uid,
|
|
12362
|
+
tokenHash: row.tokenHash,
|
|
12363
|
+
expiresAt: asDate(row.expiresAt),
|
|
12364
|
+
createdAt: asDate(row.createdAt)
|
|
12365
|
+
};
|
|
12366
|
+
if (row.userAgent !== void 0) info.userAgent = row.userAgent;
|
|
12367
|
+
if (row.ipAddress !== void 0) info.ipAddress = row.ipAddress;
|
|
12368
|
+
if (row.sessionId !== void 0 && row.sessionId !== null) info.sessionId = String(row.sessionId);
|
|
12369
|
+
if (row.rotatedAt !== void 0) info.rotatedAt = row.rotatedAt === null ? null : asDate(row.rotatedAt);
|
|
12370
|
+
if (row.revoked !== void 0) info.revoked = Boolean(row.revoked);
|
|
12371
|
+
if (row.sessionStartedAt !== void 0 && row.sessionStartedAt !== null) info.sessionStartedAt = asDate(row.sessionStartedAt);
|
|
12372
|
+
if (row.aal === "aal1" || row.aal === "aal2") info.aal = row.aal;
|
|
12373
|
+
return info;
|
|
12374
|
+
}
|
|
11947
12375
|
async findByHash(tokenHash) {
|
|
11948
12376
|
const [token] = await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.tokenHash, tokenHash));
|
|
11949
|
-
return token
|
|
12377
|
+
return token ? RefreshTokenService.toInfo(token) : null;
|
|
11950
12378
|
}
|
|
11951
12379
|
/**
|
|
11952
12380
|
* Record that a token was rotated away, keeping the row.
|
|
@@ -12018,7 +12446,7 @@ var RefreshTokenService = class {
|
|
|
12018
12446
|
await this.db.delete(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid));
|
|
12019
12447
|
}
|
|
12020
12448
|
async listForUser(uid) {
|
|
12021
|
-
return await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt);
|
|
12449
|
+
return (await this.db.select(this.selection()).from(this.refreshTokensTable).where(eq(this.refreshTokensTable.uid, uid)).orderBy(this.refreshTokensTable.createdAt)).map(RefreshTokenService.toInfo);
|
|
12022
12450
|
}
|
|
12023
12451
|
async deleteById(id, uid) {
|
|
12024
12452
|
await this.db.delete(this.refreshTokensTable).where(sql`${this.refreshTokensTable.id} = ${id} AND ${this.refreshTokensTable.uid} = ${uid}`);
|
|
@@ -12033,7 +12461,7 @@ var PasswordResetTokenService = class {
|
|
|
12033
12461
|
constructor(db, tableOrTables) {
|
|
12034
12462
|
this.db = db;
|
|
12035
12463
|
if (tableOrTables && (tableOrTables.passwordResetTokens || tableOrTables.users)) this.passwordResetTokensTable = tableOrTables.passwordResetTokens || passwordResetTokens;
|
|
12036
|
-
else this.passwordResetTokensTable = tableOrTables || passwordResetTokens;
|
|
12464
|
+
else this.passwordResetTokensTable = tableOrTables || asRebasePgTable(passwordResetTokens);
|
|
12037
12465
|
}
|
|
12038
12466
|
getQualifiedPasswordResetTokensTableName() {
|
|
12039
12467
|
const name = getTableName(this.passwordResetTokensTable);
|
|
@@ -12110,7 +12538,7 @@ var MagicLinkTokenService = class {
|
|
|
12110
12538
|
magicLinkTokensTable;
|
|
12111
12539
|
constructor(db, tableOrTables) {
|
|
12112
12540
|
this.db = db;
|
|
12113
|
-
this.magicLinkTokensTable = magicLinkTokens;
|
|
12541
|
+
this.magicLinkTokensTable = asRebasePgTable(magicLinkTokens);
|
|
12114
12542
|
}
|
|
12115
12543
|
getQualifiedTableName() {
|
|
12116
12544
|
const name = getTableName(this.magicLinkTokensTable);
|
|
@@ -12667,6 +13095,81 @@ var MfaService = class {
|
|
|
12667
13095
|
}
|
|
12668
13096
|
};
|
|
12669
13097
|
//#endregion
|
|
13098
|
+
//#region src/history/ensure-history-table.ts
|
|
13099
|
+
/** How a history entry gets written from inside the write it records. */
|
|
13100
|
+
var RECORD_HISTORY_FUNCTION = "rebase.record_history";
|
|
13101
|
+
var RECORD_HISTORY_SIGNATURE = `${RECORD_HISTORY_FUNCTION}(text, text, text, text[], jsonb, jsonb, text)`;
|
|
13102
|
+
/**
|
|
13103
|
+
* Auto-create the row history table if it doesn't exist.
|
|
13104
|
+
* This runs on startup when history is enabled, following the same
|
|
13105
|
+
* pattern as `ensureAuthTablesExist`.
|
|
13106
|
+
*
|
|
13107
|
+
* Resolves `true` when {@link RECORD_HISTORY_FUNCTION} exists, so an entry can
|
|
13108
|
+
* be written on the write's own transaction; `false` sends entries through the
|
|
13109
|
+
* pool as before, committing on their own.
|
|
13110
|
+
*/
|
|
13111
|
+
async function ensureHistoryTableExists(db) {
|
|
13112
|
+
logger.debug("🔍 Checking row history table...");
|
|
13113
|
+
try {
|
|
13114
|
+
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
13115
|
+
await db.execute(sql`
|
|
13116
|
+
CREATE TABLE IF NOT EXISTS rebase.entity_history (
|
|
13117
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
13118
|
+
table_name TEXT NOT NULL,
|
|
13119
|
+
entity_id TEXT NOT NULL,
|
|
13120
|
+
action TEXT NOT NULL,
|
|
13121
|
+
changed_fields TEXT[],
|
|
13122
|
+
"values" JSONB,
|
|
13123
|
+
previous_values JSONB,
|
|
13124
|
+
updated_by TEXT,
|
|
13125
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
13126
|
+
)
|
|
13127
|
+
`);
|
|
13128
|
+
await db.execute(sql`
|
|
13129
|
+
CREATE INDEX IF NOT EXISTS idx_history_entity
|
|
13130
|
+
ON rebase.entity_history(table_name, entity_id)
|
|
13131
|
+
`);
|
|
13132
|
+
await db.execute(sql`
|
|
13133
|
+
CREATE INDEX IF NOT EXISTS idx_history_time
|
|
13134
|
+
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
13135
|
+
`);
|
|
13136
|
+
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
13137
|
+
logger.debug("✅ Entity history table ready");
|
|
13138
|
+
} catch (error) {
|
|
13139
|
+
logger.error("❌ Failed to create row history table", { error });
|
|
13140
|
+
logger.warn("⚠️ Continuing without creating history table.");
|
|
13141
|
+
return false;
|
|
13142
|
+
}
|
|
13143
|
+
try {
|
|
13144
|
+
await db.execute(sql.raw(`
|
|
13145
|
+
CREATE OR REPLACE FUNCTION ${RECORD_HISTORY_FUNCTION}(
|
|
13146
|
+
p_table_name text, p_entity_id text, p_action text, p_changed_fields text[],
|
|
13147
|
+
p_values jsonb, p_previous_values jsonb, p_updated_by text
|
|
13148
|
+
) RETURNS void
|
|
13149
|
+
LANGUAGE sql
|
|
13150
|
+
SECURITY DEFINER
|
|
13151
|
+
SET search_path = pg_catalog, pg_temp
|
|
13152
|
+
AS $fn$
|
|
13153
|
+
INSERT INTO rebase.entity_history
|
|
13154
|
+
(table_name, entity_id, action, changed_fields, "values", previous_values, updated_by)
|
|
13155
|
+
VALUES (p_table_name, p_entity_id, p_action, p_changed_fields, p_values, p_previous_values, p_updated_by)
|
|
13156
|
+
$fn$
|
|
13157
|
+
`));
|
|
13158
|
+
await db.execute(sql.raw(`REVOKE ALL ON FUNCTION ${RECORD_HISTORY_SIGNATURE} FROM PUBLIC`));
|
|
13159
|
+
await db.execute(sql.raw(`
|
|
13160
|
+
DO $grant$ BEGIN
|
|
13161
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE$1}') THEN
|
|
13162
|
+
GRANT EXECUTE ON FUNCTION ${RECORD_HISTORY_SIGNATURE} TO ${REBASE_USER_ROLE$1};
|
|
13163
|
+
END IF;
|
|
13164
|
+
END $grant$
|
|
13165
|
+
`));
|
|
13166
|
+
return true;
|
|
13167
|
+
} catch (error) {
|
|
13168
|
+
logger.warn(`⚠️ ${RECORD_HISTORY_FUNCTION} could not be created, so a history entry commits on its own: it survives a write that rolls back.`, { error });
|
|
13169
|
+
return false;
|
|
13170
|
+
}
|
|
13171
|
+
}
|
|
13172
|
+
//#endregion
|
|
12670
13173
|
//#region src/history/HistoryService.ts
|
|
12671
13174
|
var DEFAULT_RETENTION = {
|
|
12672
13175
|
maxEntries: 200,
|
|
@@ -12679,12 +13182,19 @@ var DEFAULT_RETENTION = {
|
|
|
12679
13182
|
var HistoryService = class {
|
|
12680
13183
|
db;
|
|
12681
13184
|
retention;
|
|
12682
|
-
|
|
13185
|
+
/**
|
|
13186
|
+
* Whether {@link RECORD_HISTORY_FUNCTION} exists, so an entry recorded
|
|
13187
|
+
* inside a write goes on that write's transaction. Without it, entries go
|
|
13188
|
+
* through the pool and commit on their own.
|
|
13189
|
+
*/
|
|
13190
|
+
inTransaction;
|
|
13191
|
+
constructor(db, retention, options) {
|
|
12683
13192
|
this.db = db;
|
|
12684
13193
|
this.retention = {
|
|
12685
13194
|
...DEFAULT_RETENTION,
|
|
12686
13195
|
...retention
|
|
12687
13196
|
};
|
|
13197
|
+
this.inTransaction = options?.inTransaction === true;
|
|
12688
13198
|
}
|
|
12689
13199
|
/**
|
|
12690
13200
|
* Record a history entry for a row change.
|
|
@@ -12702,7 +13212,13 @@ var HistoryService = class {
|
|
|
12702
13212
|
* anyway, and nobody found out.
|
|
12703
13213
|
*
|
|
12704
13214
|
* So it throws, and the driver awaits it inside the write's transaction.
|
|
12705
|
-
* The row and its history entry commit together or neither does
|
|
13215
|
+
* The row and its history entry commit together or neither does — which
|
|
13216
|
+
* needs the entry written *on* that transaction, not merely while it is
|
|
13217
|
+
* open. This service is built once, on the pool, and for a long time it
|
|
13218
|
+
* wrote there: a batch that rolled back after its first row left that
|
|
13219
|
+
* row's entry behind, describing a row that never existed. Inside a write
|
|
13220
|
+
* it now goes through {@link RECORD_HISTORY_FUNCTION} on the write's own
|
|
13221
|
+
* transaction (found through `currentWriteScope`). That is a
|
|
12706
13222
|
* real trade — a broken `rebase.entity_history` now fails writes to the
|
|
12707
13223
|
* collections that declare history, where before it failed quietly — and it
|
|
12708
13224
|
* is the right side of it for a table that exists to answer "who changed
|
|
@@ -12717,7 +13233,19 @@ var HistoryService = class {
|
|
|
12717
13233
|
const changedFields = previousValues && values ? findChangedFields(previousValues, values) : null;
|
|
12718
13234
|
if (action === "update" && (!changedFields || changedFields.length === 0)) return;
|
|
12719
13235
|
try {
|
|
12720
|
-
|
|
13236
|
+
const scope = this.inTransaction ? currentWriteScope() : void 0;
|
|
13237
|
+
if (scope?.tx) await scope.track(scope.tx.execute(sql`
|
|
13238
|
+
SELECT ${sql.raw(RECORD_HISTORY_FUNCTION)}(
|
|
13239
|
+
${tableName},
|
|
13240
|
+
${String(id)},
|
|
13241
|
+
${action},
|
|
13242
|
+
${changedFields ? sql`ARRAY[${sql.join(changedFields.map((f) => sql`${f}`), sql`, `)}]::text[]` : sql`NULL::text[]`},
|
|
13243
|
+
${values ? sql`${JSON.stringify(values)}::jsonb` : sql`NULL::jsonb`},
|
|
13244
|
+
${previousValues ? sql`${JSON.stringify(previousValues)}::jsonb` : sql`NULL::jsonb`},
|
|
13245
|
+
${updatedBy ?? null}::text
|
|
13246
|
+
)
|
|
13247
|
+
`));
|
|
13248
|
+
else await this.db.execute(sql`
|
|
12721
13249
|
INSERT INTO rebase.entity_history
|
|
12722
13250
|
(table_name, entity_id, action, changed_fields, "values", previous_values, updated_by)
|
|
12723
13251
|
VALUES (
|
|
@@ -12759,7 +13287,7 @@ var HistoryService = class {
|
|
|
12759
13287
|
`)]);
|
|
12760
13288
|
const total = parseInt(countResult.rows[0]?.count ?? "0", 10);
|
|
12761
13289
|
return {
|
|
12762
|
-
data: dataResult
|
|
13290
|
+
data: sqlRows(dataResult),
|
|
12763
13291
|
total
|
|
12764
13292
|
};
|
|
12765
13293
|
}
|
|
@@ -12767,14 +13295,12 @@ var HistoryService = class {
|
|
|
12767
13295
|
* Fetch a single history entry by ID.
|
|
12768
13296
|
*/
|
|
12769
13297
|
async fetchHistoryEntry(historyId) {
|
|
12770
|
-
|
|
13298
|
+
return firstSqlRow(await this.db.execute(sql`
|
|
12771
13299
|
SELECT id, table_name, entity_id, action, changed_fields,
|
|
12772
13300
|
"values", previous_values, updated_by, updated_at
|
|
12773
13301
|
FROM rebase.entity_history
|
|
12774
13302
|
WHERE id = ${historyId}
|
|
12775
|
-
`);
|
|
12776
|
-
if (result.rows.length === 0) return null;
|
|
12777
|
-
return result.rows[0];
|
|
13303
|
+
`)) ?? null;
|
|
12778
13304
|
}
|
|
12779
13305
|
/**
|
|
12780
13306
|
* Prune history for a single row: enforce maxEntries and TTL.
|
|
@@ -12851,45 +13377,6 @@ function findChangedFields(oldValues, newValues) {
|
|
|
12851
13377
|
return changed.length > 0 ? changed : null;
|
|
12852
13378
|
}
|
|
12853
13379
|
//#endregion
|
|
12854
|
-
//#region src/history/ensure-history-table.ts
|
|
12855
|
-
/**
|
|
12856
|
-
* Auto-create the row history table if it doesn't exist.
|
|
12857
|
-
* This runs on startup when history is enabled, following the same
|
|
12858
|
-
* pattern as `ensureAuthTablesExist`.
|
|
12859
|
-
*/
|
|
12860
|
-
async function ensureHistoryTableExists(db) {
|
|
12861
|
-
logger.debug("🔍 Checking row history table...");
|
|
12862
|
-
try {
|
|
12863
|
-
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
|
|
12864
|
-
await db.execute(sql`
|
|
12865
|
-
CREATE TABLE IF NOT EXISTS rebase.entity_history (
|
|
12866
|
-
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
12867
|
-
table_name TEXT NOT NULL,
|
|
12868
|
-
entity_id TEXT NOT NULL,
|
|
12869
|
-
action TEXT NOT NULL,
|
|
12870
|
-
changed_fields TEXT[],
|
|
12871
|
-
"values" JSONB,
|
|
12872
|
-
previous_values JSONB,
|
|
12873
|
-
updated_by TEXT,
|
|
12874
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
12875
|
-
)
|
|
12876
|
-
`);
|
|
12877
|
-
await db.execute(sql`
|
|
12878
|
-
CREATE INDEX IF NOT EXISTS idx_history_entity
|
|
12879
|
-
ON rebase.entity_history(table_name, entity_id)
|
|
12880
|
-
`);
|
|
12881
|
-
await db.execute(sql`
|
|
12882
|
-
CREATE INDEX IF NOT EXISTS idx_history_time
|
|
12883
|
-
ON rebase.entity_history(table_name, entity_id, updated_at DESC)
|
|
12884
|
-
`);
|
|
12885
|
-
await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
|
|
12886
|
-
logger.debug("✅ Entity history table ready");
|
|
12887
|
-
} catch (error) {
|
|
12888
|
-
logger.error("❌ Failed to create row history table", { error });
|
|
12889
|
-
logger.warn("⚠️ Continuing without creating history table.");
|
|
12890
|
-
}
|
|
12891
|
-
}
|
|
12892
|
-
//#endregion
|
|
12893
13380
|
//#region src/utils/pg-array-null-patch.ts
|
|
12894
13381
|
/**
|
|
12895
13382
|
* Patches all PgArray columns on the given tables to handle NULL values safely.
|
|
@@ -13274,8 +13761,10 @@ function scalarBuilder(udtName, name, col) {
|
|
|
13274
13761
|
default: return text(name);
|
|
13275
13762
|
}
|
|
13276
13763
|
}
|
|
13764
|
+
/** See {@link FluentColumnBuilder}. */
|
|
13765
|
+
var fluent = (builder) => builder;
|
|
13277
13766
|
function columnBuilderFor(col) {
|
|
13278
|
-
if (col.udt_name.startsWith("_")) return scalarBuilder(col.udt_name.slice(1), col.column_name, col).array();
|
|
13767
|
+
if (col.udt_name.startsWith("_")) return fluent(scalarBuilder(col.udt_name.slice(1), col.column_name, col)).array();
|
|
13279
13768
|
return scalarBuilder(col.udt_name, col.column_name, col);
|
|
13280
13769
|
}
|
|
13281
13770
|
/**
|
|
@@ -13292,9 +13781,9 @@ function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public", column
|
|
|
13292
13781
|
const keyOf = /* @__PURE__ */ new Map();
|
|
13293
13782
|
for (const col of meta.columns) {
|
|
13294
13783
|
let builder = columnBuilderFor(col);
|
|
13295
|
-
if (col.is_nullable === "NO") builder = builder.notNull();
|
|
13296
|
-
if (meta.pks.length === 1 && meta.pks[0] === col.column_name) builder = builder.primaryKey();
|
|
13297
|
-
if (col.is_generated === "ALWAYS") builder = builder.generatedAlwaysAs(sql.raw(col.generation_expression ?? ""));
|
|
13784
|
+
if (col.is_nullable === "NO") builder = fluent(builder).notNull();
|
|
13785
|
+
if (meta.pks.length === 1 && meta.pks[0] === col.column_name) builder = fluent(builder).primaryKey();
|
|
13786
|
+
if (col.is_generated === "ALWAYS") builder = fluent(builder).generatedAlwaysAs(sql.raw(col.generation_expression ?? ""));
|
|
13298
13787
|
const key = columnKey(tableName, col.column_name);
|
|
13299
13788
|
keyOf.set(col.column_name, key);
|
|
13300
13789
|
columns[key] = builder;
|
|
@@ -13353,8 +13842,8 @@ function buildDrizzleRelationsFromSchema(tablesMap, tables, columnKey = columnNa
|
|
|
13353
13842
|
built[`${tableName}Relations`] = relations(table, ({ one, many }) => {
|
|
13354
13843
|
const map = {};
|
|
13355
13844
|
for (const rel of ones) map[rel.key] = one(tables[rel.targetTable], {
|
|
13356
|
-
fields: [table[rel.fkColumn]],
|
|
13357
|
-
references: [tables[rel.targetTable][rel.targetColumn]],
|
|
13845
|
+
fields: [getTableColumns(table)[rel.fkColumn]],
|
|
13846
|
+
references: [getTableColumns(tables[rel.targetTable])[rel.targetColumn]],
|
|
13358
13847
|
relationName: rel.relationName
|
|
13359
13848
|
});
|
|
13360
13849
|
for (const rel of manys) {
|
|
@@ -13415,7 +13904,7 @@ function primaryKeyFieldKey(collection) {
|
|
|
13415
13904
|
*/
|
|
13416
13905
|
function columnOf(table, key) {
|
|
13417
13906
|
if (!table) return void 0;
|
|
13418
|
-
const direct = table[key];
|
|
13907
|
+
const direct = getTableColumns(table)[key];
|
|
13419
13908
|
if (direct) return direct;
|
|
13420
13909
|
for (const column of Object.values(getTableColumns(table))) if (column?.name === key) return column;
|
|
13421
13910
|
}
|
|
@@ -14160,8 +14649,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14160
14649
|
const db = (driverResult?.internals)?.db ?? pgConfig.connection;
|
|
14161
14650
|
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`).");
|
|
14162
14651
|
return { async query(text) {
|
|
14163
|
-
|
|
14164
|
-
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
14652
|
+
return { rows: sqlRows(await db.execute(sql.raw(text))) };
|
|
14165
14653
|
} };
|
|
14166
14654
|
};
|
|
14167
14655
|
/**
|
|
@@ -14173,7 +14661,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14173
14661
|
* handful of `CREATE OR REPLACE FUNCTION`.
|
|
14174
14662
|
*/
|
|
14175
14663
|
const provisionRlsRuntime = async (queryable) => {
|
|
14176
|
-
const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-
|
|
14664
|
+
const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-BHA6jLtj.js").then((n) => n.i);
|
|
14177
14665
|
await queryable.query("SELECT pg_advisory_lock(hashtext('rebase_auth_functions_init'))");
|
|
14178
14666
|
try {
|
|
14179
14667
|
for (const statement of RLS_BOOTSTRAP_STATEMENTS) await queryable.query(statement);
|
|
@@ -14501,7 +14989,14 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14501
14989
|
const usersTable = tableName ? registry.getTable(tableName) : void 0;
|
|
14502
14990
|
let usersSchemaName = "rebase";
|
|
14503
14991
|
if (authCollection && "schema" in authCollection && typeof authCollection.schema === "string") usersSchemaName = authCollection.schema;
|
|
14504
|
-
const
|
|
14992
|
+
const schema = createAuthSchema(usersSchemaName);
|
|
14993
|
+
const authTables = {
|
|
14994
|
+
users: asRebasePgTable(schema.users),
|
|
14995
|
+
refreshTokens: asRebasePgTable(schema.refreshTokens),
|
|
14996
|
+
passwordResetTokens: asRebasePgTable(schema.passwordResetTokens),
|
|
14997
|
+
appConfig: asRebasePgTable(schema.appConfig),
|
|
14998
|
+
userIdentities: asRebasePgTable(schema.userIdentities)
|
|
14999
|
+
};
|
|
14505
15000
|
if (usersTable) authTables.users = usersTable;
|
|
14506
15001
|
const userService = new UserService(db, authTables);
|
|
14507
15002
|
const authRepository = new PostgresAuthRepository(db, authTables);
|
|
@@ -14516,9 +15011,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14516
15011
|
async initializeHistory(config, driverResult) {
|
|
14517
15012
|
if (!config) return void 0;
|
|
14518
15013
|
const db = driverResult.internals.db;
|
|
14519
|
-
await ensureHistoryTableExists(db);
|
|
15014
|
+
const inTransaction = await ensureHistoryTableExists(db);
|
|
14520
15015
|
const retention = typeof config === "object" ? config.retention : void 0;
|
|
14521
|
-
return { historyService: new HistoryService(db, retention ? { ttlDays: retention } : void 0) };
|
|
15016
|
+
return { historyService: new HistoryService(db, retention ? { ttlDays: retention } : void 0, { inTransaction }) };
|
|
14522
15017
|
},
|
|
14523
15018
|
async initializeRealtime(_config, driverResult) {
|
|
14524
15019
|
return driverResult.internals.realtimeService;
|
|
@@ -14594,7 +15089,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14594
15089
|
await provisionRlsRuntime(provisioningQueryable(driverResult));
|
|
14595
15090
|
},
|
|
14596
15091
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
14597
|
-
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-
|
|
15092
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-CQGHln-y.js");
|
|
14598
15093
|
const queryable = provisioningQueryable(driverResult);
|
|
14599
15094
|
const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
|
|
14600
15095
|
if (!rows[0]?.present) await provisionRlsRuntime(queryable);
|
|
@@ -14605,7 +15100,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
14605
15100
|
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.`);
|
|
14606
15101
|
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.`);
|
|
14607
15102
|
try {
|
|
14608
|
-
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-
|
|
15103
|
+
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-BHA6jLtj.js").then((n) => n.i);
|
|
14609
15104
|
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
14610
15105
|
info: (m) => logger.info(m),
|
|
14611
15106
|
warn: (m) => logger.warn(m)
|