@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.
Files changed (34) hide show
  1. package/dist/PostgresBackendDriver.d.ts +33 -3
  2. package/dist/auth/services.d.ts +19 -1
  3. package/dist/{backup-cli-DSpQyqcG.js → backup-cli-Bp-ou6o2.js} +2 -2
  4. package/dist/{backup-cli-DSpQyqcG.js.map → backup-cli-Bp-ou6o2.js.map} +1 -1
  5. package/dist/{backup-service-DA7a6SUV.js → backup-service-BNLwvxuy.js} +2 -2
  6. package/dist/{backup-service-DA7a6SUV.js.map → backup-service-BNLwvxuy.js.map} +1 -1
  7. package/dist/cli.js +7 -7
  8. package/dist/column-plan-helpers-CpILzHJS.js.map +1 -1
  9. package/dist/{doctor-D5SrGJ5P.js → doctor-ByFWL_ET.js} +25 -6
  10. package/dist/doctor-ByFWL_ET.js.map +1 -0
  11. package/dist/{ensure-collection-policies-CHO0moXQ.js → ensure-collection-policies-CQGHln-y.js} +2 -2
  12. package/dist/{ensure-collection-policies-CHO0moXQ.js.map → ensure-collection-policies-CQGHln-y.js.map} +1 -1
  13. package/dist/{ensure-tables-CYMtuuOd.js → ensure-tables-BnEvEJPr.js} +2 -2
  14. package/dist/ensure-tables-BnEvEJPr.js.map +1 -0
  15. package/dist/history/HistoryService.d.ts +16 -2
  16. package/dist/history/ensure-history-table.d.ts +7 -1
  17. package/dist/index.es.js +1580 -1085
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/plan-schema-Hgl62S-w.js.map +1 -1
  20. package/dist/{rls-bootstrap-sql-BlzsOUtz.js → rls-bootstrap-sql-BHA6jLtj.js} +4 -3
  21. package/dist/{rls-bootstrap-sql-BlzsOUtz.js.map → rls-bootstrap-sql-BHA6jLtj.js.map} +1 -1
  22. package/dist/{rls-enforcement-BV12vBwQ.js → rls-enforcement-C6Xk0lA6.js} +20 -20
  23. package/dist/{rls-enforcement-BV12vBwQ.js.map → rls-enforcement-C6Xk0lA6.js.map} +1 -1
  24. package/dist/schema/doctor-cli.js +2 -2
  25. package/dist/services/PersistService.d.ts +11 -0
  26. package/dist/services/RelationService.d.ts +39 -0
  27. package/dist/services/RelationWriteService.d.ts +32 -1
  28. package/dist/services/dataService.d.ts +2 -0
  29. package/dist/services/junction-writes.d.ts +58 -2
  30. package/dist/services/write-transaction-scope.d.ts +68 -0
  31. package/dist/types.d.ts +18 -0
  32. package/package.json +7 -7
  33. package/dist/doctor-D5SrGJ5P.js.map +0 -1
  34. 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-BV12vBwQ.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-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-DA7a6SUV.js";
16
- import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-CYMtuuOd.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-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 raw.columnType === "string" ? raw.columnType : void 0,
97
- dataType: typeof raw.dataType === "string" ? raw.dataType : void 0,
98
- primary: typeof raw.primary === "boolean" ? raw.primary : void 0
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
- let currentTableName = "unknown";
1544
- let parentTableName = "unknown";
1545
- if (currentTable && typeof currentTable === "object") currentTableName = currentTable[Symbol.for("drizzle:Name")] || currentTable._?.name || currentTable.tableName || currentTable.name || "unknown";
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 left = fromTable[fromColName];
1563
- const right = currentTable[toColName];
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/data-transformer.ts
2002
+ //#region src/services/row-pipeline.ts
2004
2003
  /**
2005
- * Helper function to sanitize and convert dates to ISO strings
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 sanitizeAndConvertDates(obj) {
2008
- if (obj === null || obj === void 0) return null;
2009
- if (typeof obj === "number" && isNaN(obj)) return null;
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
- * Transform relations for database storage (relation objects to IDs)
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 serializeDataToServer(row, properties, collection, registry) {
2034
- if (!row || !properties) return {
2035
- scalarData: row ?? {},
2036
- inverseRelationUpdates: [],
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
- * How to name a rejected value in an error, without quoting it back.
2034
+ * Give back the number a `number` property was declared to be.
2097
2035
  *
2098
- * The value may be anything the caller sent, including a secret in the wrong
2099
- * field, so the message describes its shape rather than echoing it — an echoed
2100
- * value ends up in logs and in error-reporting services.
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 describeValue(value) {
2103
- if (value === null) return "null";
2104
- if (Array.isArray(value)) return "an array";
2105
- if (value instanceof Date) return "a date";
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
- * Serialize a single property value for database storage.
2054
+ * Serve a `date` column as the timestamp this API says it serves.
2111
2055
  *
2112
- * `propertyKey` is only ever used to phrase errors and warnings. Without it the
2113
- * one trace a bad value left was `Expected array value for array property, got
2114
- * string` no collection, no property, no value, which in the log of a
2115
- * thousand-row import names nothing at all.
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 serializePropertyToServer(value, property, propertyKey) {
2118
- if (value === null || value === void 0) return value;
2119
- const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
2120
- switch (property.type) {
2121
- case "relation":
2122
- if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
2123
- else if (typeof value === "object" && value !== null && "id" in value) return value.id;
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
- * Transform IDs back to relation objects for frontend
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
- async function parseDataFromServer(data, collection, db, registry) {
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 (!data || !properties) return data;
2192
- const resolvedRelations = resolveCollectionRelations(collection);
2193
- const result = normalizeScalarValues(data, properties, collection, resolvedRelations, { skipRelations: false });
2194
- for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
2195
- const relation = findRelation(resolvedRelations, propKey);
2196
- if (relation) {
2197
- const localField = relation.kind === "belongsTo" ? fieldKeyForColumn(collection, relation.localKey) : "";
2198
- if (relation.kind === "belongsTo" && localField in data) {
2199
- const fkValue = data[localField];
2200
- if (fkValue !== null && fkValue !== void 0) try {
2201
- const targetCollection = relation.target();
2202
- result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
2203
- } catch (e) {
2204
- logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
2205
- }
2206
- } else if (hasForeignKeyOnTarget(relation) && db && registry) try {
2207
- const targetCollection = relation.target();
2208
- const targetTable = registry.getTable(getTableName$1(targetCollection));
2209
- const pks = getPrimaryKeys(collection, registry);
2210
- const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId$1(data, pks);
2211
- if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
2212
- const foreignKeyColumn = targetTable[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
2213
- if (foreignKeyColumn) {
2214
- const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
2215
- if (relatedRows.length > 0) if (relation.cardinality === "one") {
2216
- const targetPks = getPrimaryKeys(targetCollection, registry);
2217
- const relatedRow = relatedRows[0];
2218
- result[propKey] = createRelationRef(buildCompositeId$1(relatedRow, targetPks), targetCollection.slug);
2219
- } else {
2220
- const targetPks = getPrimaryKeys(targetCollection, registry);
2221
- result[propKey] = relatedRows.map((row) => createRelationRef(buildCompositeId$1(row, targetPks), targetCollection.slug));
2222
- }
2223
- }
2224
- }
2225
- } catch (e) {
2226
- logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
2227
- }
2228
- else if (relation.kind === "via" && db && registry) try {
2229
- const targetCollection = relation.target();
2230
- const pks = getPrimaryKeys(collection, registry);
2231
- const currentId = buildCompositeId$1(data, pks);
2232
- if (currentId) {
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
- return result;
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
- * Try to decode a `data:application/octet-stream;base64,...` data URL string
2292
- * into a Buffer. Returns null if the value is not a matching data URL.
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 tryDecodeBase64DataUrl(value) {
2295
- if (typeof value !== "string") return null;
2296
- if (!value.startsWith("data:application/octet-stream;base64,")) return null;
2297
- const base64Data = value.split(",")[1];
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
- * Try to resolve an unknown value into a Buffer.
2302
- * Handles native Buffers and `{ type: "Buffer", data: number[] }` objects (from JSON deserialization).
2303
- * Returns null if the value is not a buffer.
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 tryResolveBuffer(value) {
2306
- if (Buffer.isBuffer(value)) return value;
2307
- if (typeof value === "object" && value !== null) {
2308
- const rawVal = value;
2309
- if (rawVal.type === "Buffer" && Array.isArray(rawVal.data)) return Buffer.from(rawVal.data);
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 null;
2183
+ return stripUnreadable(normalized, collection);
2312
2184
  }
2313
2185
  /**
2314
- * Convert a Buffer to a UTF-8 string if all bytes are printable ASCII,
2315
- * otherwise return a base64 data URL.
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 bufferToStringOrBase64(buf) {
2318
- for (let i = 0; i < buf.length; i++) {
2319
- const b = buf[i];
2320
- if ((b < 32 || b > 126) && b !== 9 && b !== 10 && b !== 13) return `data:application/octet-stream;base64,${buf.toString("base64")}`;
2321
- }
2322
- return buf.toString("utf8");
2323
- }
2324
- function parsePropertyFromServer(value, property, collection, propertyKey) {
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
- * Shared internal helper: normalizes scalar column values from a DB row
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
- * @param skipRelations When `true`, relation-typed properties are omitted
2442
- * from the result (used by `normalizeDbValues` where
2443
- * Drizzle's relational API already hydrates them).
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
- * Keys a query computes and attaches to a row, rather than reads from a column.
2279
+ * The junction a `via` relation reaches through, from either end.
2447
2280
  *
2448
- * An explicit set rather than a `_` prefix rule: a user's column may perfectly
2449
- * well be called `_internal`, and passing it through here would put a value on
2450
- * the row that no property describes and nothing downstream knows how to type.
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
- var QUERY_METADATA_KEYS = /* @__PURE__ */ new Set([
2453
- "_score",
2454
- "_distance",
2455
- "_matches"
2456
- ]);
2457
- function normalizeScalarValues(data, properties, collection, resolvedRelations, options) {
2458
- const result = {};
2459
- const internalFKColumns = /* @__PURE__ */ new Set();
2460
- Object.values(resolvedRelations).forEach((relation) => {
2461
- if (relation.kind !== "belongsTo") return;
2462
- const localField = fieldKeyForColumn(collection, relation.localKey);
2463
- if (!properties[localField]) internalFKColumns.add(localField);
2464
- });
2465
- for (const [key, value] of Object.entries(data)) {
2466
- if (internalFKColumns.has(key)) {
2467
- result[key] = value === null ? null : typeof value === "number" ? value : String(value);
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
- return result;
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
- * Lightweight value normalization for db.query results.
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
- * Use this instead of `parseDataFromServer` when processing results from
2488
- * `db.query.findFirst/findMany` which return pre-hydrated relation data.
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 normalizeDbValues(data, collection) {
2491
- const properties = collection.properties;
2492
- if (!data || !properties) return data;
2493
- return normalizeScalarValues(data, properties, collection, resolveCollectionRelations(collection), { skipRelations: true });
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
- * Whether a many-relation reaches its target through a junction table.
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
- * Also used to build the drizzle `with` config, which is why it is exported:
2501
- * the query has to nest one level deeper for a junction, and the row walk has
2502
- * to unwrap that same level back out.
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 isJunctionRelation(relation) {
2505
- if (isManyToMany(relation)) return true;
2506
- return relation.kind === "via" && relation.joinPath.length > 1;
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
- * Reach the target row inside a junction row.
2387
+ * Set the columns of ONE existing link, without touching the membership.
2510
2388
  *
2511
- * A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
2512
- * the foreign keys, and the target nested under one of them. The target is the
2513
- * only object among them, so that is how it is found. A junction row that has
2514
- * not been nested (no `with` on the join) has no object and is returned as-is.
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 unwrapJunctionRow(item) {
2517
- const nestedKey = Object.keys(item).find((key) => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key]));
2518
- return nestedKey ? item[nestedKey] : item;
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
- * Give back the number a `number` property was declared to be.
2408
+ * Drop the named links, and refuse to call it done if the database kept any.
2522
2409
  *
2523
- * Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
2524
- * numeric can hold more precision than a double. But the collection declared
2525
- * this property `number` and the OpenAPI spec this server publishes says
2526
- * `type: number`, so serving `"9.99"` breaks its own contract and breaks it
2527
- * asymmetrically, because a create answers with the number and the read that
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 coerceDeclaredNumber(value, property) {
2536
- if (property?.type !== "number" || typeof value !== "string") return value;
2537
- const parsed = parseFloat(value);
2538
- return isNaN(parsed) ? null : parsed;
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
- * Serve a `date` column as the timestamp this API says it serves.
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
- * A date-only column (`mode: "date"`, `format: "date"` in the spec) is already
2551
- * RFC 3339 as `YYYY-MM-DD` and is left alone widening it to a timestamp would
2552
- * invent a time of day and a timezone the column does not have.
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 toRestDate(value) {
2555
- if (value instanceof Date) return isNaN(value.getTime()) ? null : value.toISOString();
2556
- if (value && typeof value === "object" && value.__type === "date") return value.value ?? null;
2557
- if (typeof value !== "string" && typeof value !== "number") return value;
2558
- if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
2559
- const date = new Date(value);
2560
- return isNaN(date.getTime()) ? value : date.toISOString();
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
- * One scalar, as REST serves it: declared numbers as numbers, declared dates as
2564
- * RFC 3339, everything else exactly as the database returned it.
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 toRestScalar(value, property) {
2567
- if (property?.type === "date") return toRestDate(value);
2568
- return coerceDeclaredNumber(value, property);
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
- * Apply {@link toRestScalar} across a row, leaving undeclared columns alone.
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
- * Exported because the include loader attaches related rows itself, and a
2574
- * target rendered differently from its parent is the shape bug this whole file
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 toRestValues(row, collection) {
2579
- const properties = collection.properties;
2580
- if (!properties) return row;
2581
- const out = {};
2582
- for (const [key, value] of Object.entries(row)) out[key] = toRestScalar(value, properties[key]);
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
- * Drop every column this caller may not read.
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
- * `_matches` is filtered rather than deleted: it is the list of fields a text
2607
- * search hit, and a field the caller cannot read must not appear in it even
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 stripUnreadable(row, collection) {
2611
- const properties = collection.properties;
2612
- if (!properties) return row;
2613
- const viewer = currentFieldViewer();
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
- * The address a relation ref points at.
2481
+ * Combine an existing `where` with the soft-delete condition.
2639
2482
  *
2640
- * The whole key, not its first column: a composite-keyed target addressed by
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 relationTargetAddress(targetRow, targetCollection, registry) {
2648
- const address = deriveRowAddress(targetRow, targetCollection, registry);
2649
- if (address) return address;
2650
- return String(targetRow[Object.keys(targetRow)[0]] ?? "");
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
- * The row the admin renders: every column, with relations as references.
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
- * Values are normalized (dates, numbers, NaN) because the admin's view-model
2656
- * expects real types. The row's own address is *not* among the columns — it is
2657
- * derived by the consumer from the collection's primary keys.
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 toFlatRow(row, collection, registry) {
2660
- const resolvedRelations = resolveCollectionRelations(collection);
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
- * The row REST serves: every column under its own name, with the value Postgres
2674
- * returned, and relations inlined as the target's columns.
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 toRestRow(row, collection, registry) {
2687
- const resolvedRelations = resolveCollectionRelations(collection);
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
- * Explain a write that matched no rows.
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
- * @param handle The RLS-scoped connection the write ran on not a fresh
2724
- * one, or the re-read would answer for a different caller.
2725
- * @param table The table the write targeted (the junction, for a link).
2726
- * @param conditions The write's own WHERE terms, reused verbatim.
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
- async function explainZeroRowWrite(handle, table, conditions, denied, missing) {
2731
- if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(denied, "WRITE_DENIED");
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
- arr.push(await this.toRelatedRow(targetData, targetCollection, targetPks));
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
- return resultMap;
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) => toRestValues({ ...e.values }, targetCollection)), node, targetCollection);
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 ids in a to-many relation write, whatever shape the caller sent.
4677
+ * The same reading, keeping the link's own values.
4599
4678
  *
4600
- * A membership list is written as either the related rows (`[{ id: 1 }]`, what
4601
- * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
4602
- * what anyone writing the API by hand sends). Only the first was read, via a
4603
- * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
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 relationTargetIds(value, relationName, collectionSlug) {
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
- if (typeof id === "string" || typeof id === "number") return id;
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 targetEntityIds = relationTargetIds(value, key, collection.slug);
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.parsedIds(targetCollection, targetEntityIds));
4686
- else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
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 targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationTargetIds([newValue], relation.relationName, sourceCollection.slug);
4759
- await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
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 targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : [];
4771
- await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, `${sourceCollection.slug}.${relation.relationName}`), this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
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
- const result = await this.db.execute(sql.raw(text));
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 unknown as RebaseCallContext`, which
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: this.client,
5954
- storageSource: this.client?.storage
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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 targetPath = (typeof rel.target === "function" ? rel.target() : void 0)?.slug;
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(void 0, targetPath);
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
6772
+ collection: callbackCollection,
6461
6773
  ...errorProps
6462
6774
  });
6463
6775
  if (callbacks?.afterSaveError) await callbacks.afterSaveError({
6464
- collection: resolvedCollection,
6776
+ collection: callbackCollection,
6465
6777
  ...errorProps
6466
6778
  });
6467
6779
  if (propertyCallbacks?.afterSaveError) await propertyCallbacks.afterSaveError({
6468
- collection: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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 result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await this.delegate.db.transaction(async (tx) => {
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
- return await operation(txDelegate);
7102
- }, options));
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))).rows ?? [];
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.unref?.();
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
- user: {
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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
- user: {
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: resolvedCollection,
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: resolvedCollection,
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: resolvedCollection,
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.unref?.();
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-DA7a6SUV.js").then((n) => n.r);
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 || null;
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
- constructor(db, retention) {
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. That is a
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
- await this.db.execute(sql`
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.rows,
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
- const result = await this.db.execute(sql`
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
- const result = await db.execute(sql.raw(text));
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-BlzsOUtz.js").then((n) => n.i);
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 authTables = createAuthSchema(usersSchemaName);
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-CHO0moXQ.js");
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-BlzsOUtz.js").then((n) => n.i);
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)