@rebasepro/server-postgres 0.19.1 → 0.19.2-canary.g09316f6

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 (96) hide show
  1. package/dist/{BranchService-ByLfM_Tt.js → BranchService-W3DMfcZZ.js} +2 -2
  2. package/dist/{BranchService-ByLfM_Tt.js.map → BranchService-W3DMfcZZ.js.map} +1 -1
  3. package/dist/PostgresBackendDriver.d.ts +81 -7
  4. package/dist/PostgresBootstrapper.d.ts +10 -0
  5. package/dist/{backup-cli-oM9WQVlV.js → backup-cli-DSpQyqcG.js} +2 -2
  6. package/dist/{backup-cli-oM9WQVlV.js.map → backup-cli-DSpQyqcG.js.map} +1 -1
  7. package/dist/{backup-service-Br3tqwsy.js → backup-service-DA7a6SUV.js} +2 -2
  8. package/dist/{backup-service-Br3tqwsy.js.map → backup-service-DA7a6SUV.js.map} +1 -1
  9. package/dist/{cli-errors-DxN4yzdH.js → cli-errors-C3g_kHBw.js} +137 -18
  10. package/dist/cli-errors-C3g_kHBw.js.map +1 -0
  11. package/dist/cli-errors.d.ts +19 -0
  12. package/dist/cli-helpers.d.ts +47 -0
  13. package/dist/cli.js +233 -19
  14. package/dist/cli.js.map +1 -1
  15. package/dist/{collection-index-BHgT_TKd.js → collection-index-DdnsxiJ_.js} +14 -4
  16. package/dist/{collection-index-BHgT_TKd.js.map → collection-index-DdnsxiJ_.js.map} +1 -1
  17. package/dist/collections/validate-relations.d.ts +7 -0
  18. package/dist/column-plan-helpers-CpILzHJS.js +145 -0
  19. package/dist/column-plan-helpers-CpILzHJS.js.map +1 -0
  20. package/dist/{doctor-D7HWmGzs.js → doctor-D5SrGJ5P.js} +175 -61
  21. package/dist/doctor-D5SrGJ5P.js.map +1 -0
  22. package/dist/{ensure-collection-policies-2c2NV8zX.js → ensure-collection-policies-CHO0moXQ.js} +5 -5
  23. package/dist/{ensure-collection-policies-2c2NV8zX.js.map → ensure-collection-policies-CHO0moXQ.js.map} +1 -1
  24. package/dist/{ensure-collection-tables-D4wJzM4y.js → ensure-collection-tables-Bkvehxdm.js} +318 -312
  25. package/dist/ensure-collection-tables-Bkvehxdm.js.map +1 -0
  26. package/dist/{ensure-tables-CIojK-Rx.js → ensure-tables-CYMtuuOd.js} +2 -2
  27. package/dist/{ensure-tables-CIojK-Rx.js.map → ensure-tables-CYMtuuOd.js.map} +1 -1
  28. package/dist/generate-drizzle-schema-logic-Dfyf_MRu.js +228 -0
  29. package/dist/generate-drizzle-schema-logic-Dfyf_MRu.js.map +1 -0
  30. package/dist/{generate-drizzle-schema-CwEgVyjO.js → generate-drizzle-schema-vSK-VdYT.js} +3 -3
  31. package/dist/generate-drizzle-schema-vSK-VdYT.js.map +1 -0
  32. package/dist/generate-postgres-ddl-logic-CR2xcS7e.js +475 -0
  33. package/dist/generate-postgres-ddl-logic-CR2xcS7e.js.map +1 -0
  34. package/dist/{generated-schema-staleness-DQdfJl2A.js → generated-schema-staleness-DRQe2BpC.js} +81 -3
  35. package/dist/{generated-schema-staleness-DQdfJl2A.js.map → generated-schema-staleness-DRQe2BpC.js.map} +1 -1
  36. package/dist/history/HistoryService.d.ts +23 -4
  37. package/dist/index.es.js +1991 -580
  38. package/dist/index.es.js.map +1 -1
  39. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -1
  40. package/dist/plan-schema-Hgl62S-w.js +1531 -0
  41. package/dist/plan-schema-Hgl62S-w.js.map +1 -0
  42. package/dist/{policy-drift-kNJ6X2Uj.js → policy-drift-B0GDRh7_.js} +2 -2
  43. package/dist/{policy-drift-kNJ6X2Uj.js.map → policy-drift-B0GDRh7_.js.map} +1 -1
  44. package/dist/{rls-bootstrap-sql-DLDnUjtx.js → rls-bootstrap-sql-BlzsOUtz.js} +111 -8
  45. package/dist/{rls-bootstrap-sql-DLDnUjtx.js.map → rls-bootstrap-sql-BlzsOUtz.js.map} +1 -1
  46. package/dist/{rls-enforcement-DeYEjYLt.js → rls-enforcement-BV12vBwQ.js} +2 -1
  47. package/dist/rls-enforcement-BV12vBwQ.js.map +1 -0
  48. package/dist/schema/catalogue-schema.d.ts +97 -0
  49. package/dist/schema/collection-index.d.ts +9 -0
  50. package/dist/schema/column-plan-helpers.d.ts +125 -0
  51. package/dist/schema/config-relations.d.ts +32 -0
  52. package/dist/schema/doctor-cli.js +4 -4
  53. package/dist/schema/doctor.d.ts +9 -0
  54. package/dist/schema/dynamic-tables.d.ts +27 -6
  55. package/dist/schema/ensure-collection-tables.d.ts +31 -268
  56. package/dist/schema/generate-drizzle-schema-logic.d.ts +26 -8
  57. package/dist/schema/generate-drizzle-schema.js +1 -1
  58. package/dist/schema/generate-postgres-ddl-logic.d.ts +84 -168
  59. package/dist/schema/generate-postgres-ddl.js +8 -2
  60. package/dist/schema/generate-postgres-ddl.js.map +1 -1
  61. package/dist/schema/generated-column-conflicts.d.ts +84 -0
  62. package/dist/schema/generated-schema-diff.d.ts +56 -0
  63. package/dist/schema/generated-schema-staleness.d.ts +33 -0
  64. package/dist/schema/introspect-db-logic.d.ts +2 -0
  65. package/dist/schema/plan/diff-plan.d.ts +220 -0
  66. package/dist/schema/plan/plan-schema.d.ts +84 -0
  67. package/dist/schema/plan/render-ddl.d.ts +62 -0
  68. package/dist/schema/plan/render-drizzle.d.ts +31 -0
  69. package/dist/schema/plan/types.d.ts +410 -0
  70. package/dist/schema/plan/updated-at-trigger.d.ts +22 -0
  71. package/dist/schema/relation-names.d.ts +28 -0
  72. package/dist/schema/search-column.d.ts +9 -0
  73. package/dist/security/rls-enforcement.d.ts +17 -0
  74. package/dist/services/FetchService.d.ts +176 -36
  75. package/dist/services/PersistService.d.ts +17 -4
  76. package/dist/services/RelationService.d.ts +11 -3
  77. package/dist/services/collection-helpers.d.ts +16 -2
  78. package/dist/services/dataService.d.ts +29 -2
  79. package/dist/services/field-op-sql.d.ts +16 -0
  80. package/dist/services/field-viewer.d.ts +19 -0
  81. package/dist/services/realtimeService.d.ts +35 -1
  82. package/dist/services/row-pipeline.d.ts +29 -5
  83. package/dist/services/soft-delete.d.ts +73 -0
  84. package/dist/utils/pg-error-utils.d.ts +41 -1
  85. package/dist/{vector-index-DkWxm2de.js → vector-index-CMJKb3C9.js} +3 -14
  86. package/dist/{vector-index-DkWxm2de.js.map → vector-index-CMJKb3C9.js.map} +1 -1
  87. package/package.json +18 -10
  88. package/dist/cli-errors-DxN4yzdH.js.map +0 -1
  89. package/dist/doctor-D7HWmGzs.js.map +0 -1
  90. package/dist/ensure-collection-tables-D4wJzM4y.js.map +0 -1
  91. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +0 -1
  92. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +0 -528
  93. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +0 -1
  94. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +0 -1312
  95. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +0 -1
  96. package/dist/rls-enforcement-DeYEjYLt.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -2,28 +2,92 @@ import { createRequire as __createRequire } from "module";
2
2
  __createRequire(import.meta.url);
3
3
  import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
4
4
  import { a as createDirectDatabaseConnection, c as guardPoolAgainstDirtyRelease, l as pinSearchPath, o as createPostgresDatabaseConnection, r as cappedPoolMax, s as createReadReplicaConnection, t as DatabasePoolManager, u as poolMaxCeiling } from "./databasePoolManager-Bj5FbeAs.js";
5
- import { a as generatePostgresVectorDdl, g as SEARCH_UNACCENT_FN, i as generatePostgresSearchDdl, n as generatePostgresDdl, p as resolveColumnName, r as generatePostgresPoliciesDdl, v as buildSearchColumnSpec, w as visibleColumnProjection, y as hiddenColumnsOption } from "./generate-postgres-ddl-logic-CtnpIBVv.js";
6
- import { a as isEconnrefused, c as classifyConnectFailure, d as isRoleSwitchingPermissionError, f as isRowLevelSecurityDenial, h as sanitizeErrorForClient, i as diagnoseDbError, l as extractCauseMessage, m as reachedDatabase, n as deepestErrorMessage, o as parseHostInfo, p as pgErrorToFriendlyMessage, u as extractPgError } from "./cli-errors-DxN4yzdH.js";
7
- import { r as BranchingUnsupportedError, t as BranchService } from "./BranchService-ByLfM_Tt.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-DeYEjYLt.js";
9
- import { n as getDrizzleColumn, t as generateSchema } from "./generate-drizzle-schema-logic-so2Z_lyC.js";
10
- import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-D4wJzM4y.js";
11
- import { t as main } from "./generate-drizzle-schema-CwEgVyjO.js";
12
- 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-Br3tqwsy.js";
13
- import { i as resolveAuthSchema, r as probeAuthSchema, t as ensureAuthTablesExist } from "./ensure-tables-CIojK-Rx.js";
5
+ import { f as hiddenColumnsOption, l as SEARCH_UNACCENT_FN, m as searchColumnNames, o as sharedRelationName, u as buildSearchColumnSpec, x as visibleColumnProjection } from "./plan-schema-Hgl62S-w.js";
6
+ import { d as extractPgError, f as isRoleSwitchingPermissionError, g as sanitizeErrorForClient, h as reachedDatabase, i as diagnoseDbError, l as classifyConnectFailure, m as pgErrorToFriendlyMessage, n as deepestErrorMessage, o as isEconnrefused, p as isRowLevelSecurityDenial, s as parseHostInfo, u as extractCauseMessage } from "./cli-errors-C3g_kHBw.js";
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";
9
+ import { l as resolveColumnName } from "./column-plan-helpers-CpILzHJS.js";
10
+ import { i as isAuthCollection, t as AUTH_USERS_COLUMNS } from "./auth-users-columns-D2LBFrMH.js";
11
+ import { a as generatePostgresVectorDdl, i as generatePostgresTriggersDdl, n as generatePostgresPoliciesDdl, r as generatePostgresSearchDdl, t as generatePostgresDdl } from "./generate-postgres-ddl-logic-CR2xcS7e.js";
12
+ import { t as generateSchema } from "./generate-drizzle-schema-logic-Dfyf_MRu.js";
13
+ import { a as readSchemaFactsFor, r as planCollectionSchemaEnsure } from "./ensure-collection-tables-Bkvehxdm.js";
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";
14
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";
15
18
  import { Client } from "pg";
16
- import { ApiError, assertWriteRequestValid, createDdlBootstrapper, createEmailService, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveRequireAuth, safeCompare } from "@rebasepro/server";
17
- import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
18
- import { ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, DEFAULT_COMMIT_PATHS, DEFAULT_DATA_SOURCE_KEY, ListLimitError, Vector, declaredDatabaseExtensions, encodeRelationAggregateSort, hasForeignKeyOnTarget, isChannelBusInstance, isChannelBusInstance as isChannelBusInstance$1, isManyToMany, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, parseRelationAggregateSort, resolveClientListLimit } from "@rebasepro/types";
19
- import { COMPOSITE_ID_SEPARATOR, COMPOSITE_ID_SEPARATOR as COMPOSITE_ID_SEPARATOR$1, CollectionRegistry, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE, OrderBySpecError, buildCompositeId, buildCompositeId as buildCompositeId$1, buildPropertyCallbacks, buildSdkData, callbackRefusal, classifyTable, createRelationRef, createRelationRefWithData, detectJunctionTables, fieldKeyForColumn, findRelation, getColumnName, getDeclaredPrimaryKeys, getTableName as getTableName$1, isAddressableId, normalizeDriverOrderBy, normalizeEmail, normalizeToEntityRelation, parseIdValues, parseOrderBySpecStrict, resolveCollectionRelations, revokeInternalTableSql, toCallbackError, toFilterTuples, updateDateAutoValues } from "@rebasepro/common";
19
+ import { ApiError, assertFieldOpsValid, assertWriteRequestValid, createDdlBootstrapper, createEmailService, extractUserFromToken, logger, rawQueryLoggingEnabled, resolveBatchRefs, resolveRequireAuth, safeCompare, splitFieldOps } from "@rebasepro/server";
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";
20
23
  import { camelCase, firstFreeKey, generateForeignKeyName, isPrototypePollutingKey, legacyForeignKeyName, mergeDeep, toSnakeCase, toWireKey } from "@rebasepro/utils";
24
+ import { AsyncLocalStorage } from "node:async_hooks";
21
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";
22
26
  import fs from "fs";
23
27
  import { WebSocket, WebSocketServer } from "ws";
24
28
  import { EventEmitter } from "events";
25
29
  import { randomUUID } from "crypto";
26
30
  import { inspect } from "util";
31
+ //#region src/services/field-viewer.ts
32
+ /**
33
+ * Who the current read is *for*, available to the row walk without threading it
34
+ * through every read signature.
35
+ *
36
+ * Per-field `access.read` has to be applied wherever a row leaves this driver,
37
+ * and that is not one place: a REST list, a single get, a relation target
38
+ * inlined into a parent, the same target rendered as an admin ref, a realtime
39
+ * frame, a history entry. The row pipeline is the one function they all share —
40
+ * and it is reached through `FetchService`, `RelationService` and `DataService`,
41
+ * none of which carry a user. Threading one through would be a parameter added
42
+ * to a dozen signatures and forgotten on the thirteenth, which is precisely how
43
+ * `stripExcluded` came to be missing from the relation-ref branch for a year.
44
+ *
45
+ * So the viewer is ambient, established at the two places that already
46
+ * establish the *database's* auth context for a read — `applyAuthContext` is
47
+ * called on the same line in both:
48
+ *
49
+ * - `AuthenticatedPostgresBackendDriver.withTransaction`, which every REST,
50
+ * SDK and WebSocket read runs inside;
51
+ * - `RealtimeService`'s RLS-bound refetches, which build the frames a
52
+ * subscriber receives.
53
+ *
54
+ * Deliberately `run()` and not `enterWith()`. `enterWith` persists past the call
55
+ * that made it, so a `rebase.dataAsAdmin` read inside a user's request — a
56
+ * `beforeSave` hook, an `afterRead` hook — would leave the admin viewer in place
57
+ * for the rest of that request and strip nothing from what the user is served.
58
+ *
59
+ * @module
60
+ */
61
+ /**
62
+ * `undefined` inside the store is meaningful and is not the same as no store at
63
+ * all: {@link FieldViewer} reads absence as the trusted server plane. Nothing
64
+ * currently stores `undefined`, but the store's type says the difference is
65
+ * carried rather than flattened.
66
+ */
67
+ var storage = new AsyncLocalStorage();
68
+ /**
69
+ * Run `fn` with `viewer` as the caller every row strip judges against.
70
+ *
71
+ * Nested calls shadow: a `dataAsAdmin` read inside a user request runs under the
72
+ * service identity for its duration and the user's viewer is restored after it,
73
+ * which is the behaviour both halves need and the reason this is not a variable.
74
+ */
75
+ function withFieldViewer(viewer, fn) {
76
+ return storage.run(viewer, fn);
77
+ }
78
+ /**
79
+ * The caller the current read is for, or `undefined` on the trusted server plane
80
+ * — an in-process `rebase.data` call on the base driver, a migration, the auth
81
+ * adapter reading a password hash to verify it.
82
+ *
83
+ * Absence cannot be reached from outside: every API ingress scopes its driver
84
+ * with `withAuth`, including an unauthenticated one (`roles: ["anon"]`), and
85
+ * that is what establishes the viewer.
86
+ */
87
+ function currentFieldViewer() {
88
+ return storage.getStore();
89
+ }
90
+ //#endregion
27
91
  //#region src/services/collection-helpers.ts
28
92
  /** Safely extract Drizzle column metadata from a column object. */
29
93
  function getColumnMeta(col) {
@@ -55,6 +119,25 @@ function idCanAddressTable(id, table, idInfoArray) {
55
119
  };
56
120
  }));
57
121
  }
122
+ /**
123
+ * The columns of a table's composite primary-key constraint, or `[]`.
124
+ *
125
+ * Fails open for anything that is not a real Drizzle table — a test double, a
126
+ * hand-built registry entry — because "no key" is a legitimate answer here and
127
+ * throwing would take out every caller that only wanted to ask.
128
+ */
129
+ function compositePrimaryKeyColumns(table) {
130
+ try {
131
+ return getTableConfig(table).primaryKeys?.[0]?.columns ?? [];
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+ /** The Drizzle key a column is registered under, falling back to its name. */
137
+ function columnFieldName(table, column) {
138
+ for (const [key, candidate] of Object.entries(getTableColumns(table))) if (candidate === column) return key;
139
+ return column.name;
140
+ }
58
141
  function getCollectionByPath(collectionPath, registry) {
59
142
  const collection = registry.getCollectionByPath(collectionPath);
60
143
  if (!collection) {
@@ -66,6 +149,14 @@ function getCollectionByPath(collectionPath, registry) {
66
149
  /**
67
150
  * Reject a write naming something that is not a column of the table.
68
151
  *
152
+ * "The table" is the one read back from `information_schema` at boot — see
153
+ * `catalogue-schema.ts` — so this asks the database, not a file. It used to ask
154
+ * the generated module, which made the commonest cause of this error a stale
155
+ * `schema.generated.ts`: a property added to a collection was created in the
156
+ * database by boot-ensure and still answered `400` on its first save, because
157
+ * the module had not been regenerated. That cause no longer exists, and the
158
+ * remedy below no longer names it.
159
+ *
69
160
  * Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
70
161
  * from `Object.keys(tableColumns)`, so a key the table does not carry is not
71
162
  * rejected by anything — it is *left out of the statement*. The insert answers
@@ -93,7 +184,14 @@ function assertWritableColumns(values, table, collectionPath) {
93
184
  if (!columns || Object.keys(columns).length === 0) return;
94
185
  const unknown = Object.keys(values).filter((key) => !(key in columns));
95
186
  if (unknown.length === 0) return;
96
- throw ApiError.badRequest(`'${collectionPath}' has no column${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => `'${key}'`).join(", ")}, so the value${unknown.length > 1 ? "s" : ""} would have been dropped before the statement was built. If you just added the propert` + (unknown.length > 1 ? "ies" : "y") + ", the generated schema is behind the collection: run `rebase schema generate`.", "VALIDATION_UNKNOWN_FIELDS");
187
+ const byColumnName = /* @__PURE__ */ new Map();
188
+ for (const [key, column] of Object.entries(columns)) {
189
+ const name = column?.name;
190
+ if (typeof name === "string" && name !== key) byColumnName.set(name, key);
191
+ }
192
+ const misspelled = unknown.filter((key) => byColumnName.has(key));
193
+ if (misspelled.length === unknown.length) throw ApiError.badRequest(`'${collectionPath}' has no field ${misspelled.map((key) => `'${key}'`).join(", ")} — ${misspelled.length > 1 ? "those are column names" : "that is a column name"}. Write ${misspelled.map((key) => `'${byColumnName.get(key)}'`).join(", ")} instead: a field is addressed by its property key, and the column behind it is an implementation detail.`, "VALIDATION_UNKNOWN_FIELDS");
194
+ throw ApiError.badRequest(`'${collectionPath}' has no column${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => `'${key}'`).join(", ")}, so the value${unknown.length > 1 ? "s" : ""} would have been dropped before the statement was built. If you just added the propert` + (unknown.length > 1 ? "ies" : "y") + ", the database does not have the column yet: apply the schema (`rebase db push`, or redeploy so boot creates it).", "VALIDATION_UNKNOWN_FIELDS");
97
195
  }
98
196
  /**
99
197
  * A relation whose names do not resolve against the registered schema.
@@ -122,10 +220,16 @@ function getTableForCollection(collection, registry) {
122
220
  * The key columns a collection's rows are addressed by.
123
221
  *
124
222
  * Three tiers, in order: properties marked `isId`, the primary keys of the
125
- * drizzle schema, and finally a column literally named `id`. Only the first is
126
- * visible to the browser, which is why a key known only to drizzle is reported
223
+ * registered table, and finally a column literally named `id`. Only the first is
224
+ * visible to the browser, which is why a key known only to the table is reported
127
225
  * at boot — see {@link warnOnKeysTheAdminCannotResolve}.
128
226
  *
227
+ * The second tier reads the table the registry holds, which is built from
228
+ * `information_schema` — the database's own primary key, not a generated file's
229
+ * claim about it. Composite keys are declared as a table constraint rather than
230
+ * on each column (that is the only legal way to say it), so both shapes are
231
+ * asked for.
232
+ *
129
233
  * Returns `[]` when nothing resolves, rather than throwing. It used to open by
130
234
  * resolving the table, which throws when there is none — so the `isId` tier,
131
235
  * which needs no table at all, was unreachable for exactly the collections
@@ -162,6 +266,14 @@ function getPrimaryKeys(collection, registry) {
162
266
  });
163
267
  }
164
268
  }
269
+ if (keys.length === 0) for (const column of compositePrimaryKeyColumns(table)) {
270
+ const meta = getColumnMeta(column);
271
+ keys.push({
272
+ fieldName: columnFieldName(table, column),
273
+ type: column.dataType === "number" || meta.columnType === "PgSerial" || meta.columnType === "PgInteger" ? "number" : "string",
274
+ isUUID: meta.columnType === "PgUUID"
275
+ });
276
+ }
165
277
  if (keys.length === 0 && "id" in table) {
166
278
  const idCol = table["id"];
167
279
  const idMeta = getColumnMeta(idCol);
@@ -780,7 +892,10 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
780
892
  if ("type" in cond) {
781
893
  const subSQLs = cond.conditions.map((c) => this.buildLogicalConditions(c, table, collectionPath, options)).filter((sql) => sql !== null);
782
894
  if (subSQLs.length === 0) return null;
783
- return (cond.type === "or" ? or(...subSQLs) : and(...subSQLs)) ?? null;
895
+ if (cond.type === "or") return or(...subSQLs) ?? null;
896
+ const conjunction = and(...subSQLs) ?? null;
897
+ if (cond.type === "not") return conjunction ? not(conjunction) : null;
898
+ return conjunction;
784
899
  } else {
785
900
  const target = this.resolveFilterTarget(table, cond.column, collectionPath, options.unknownFields ?? defaultUnknownFilterFieldsMode, options);
786
901
  if (!target) return null;
@@ -1596,8 +1711,10 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
1596
1711
  const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
1597
1712
  if (ftsCondition) return [ftsCondition];
1598
1713
  let declaredStringProperties = 0;
1714
+ const viewer = currentFieldViewer();
1599
1715
  for (const [key, prop] of Object.entries(properties)) {
1600
1716
  const p = prop;
1717
+ if (!canReadField(prop, viewer)) continue;
1601
1718
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1602
1719
  declaredStringProperties++;
1603
1720
  const fieldColumn = table[key];
@@ -2420,42 +2537,101 @@ function coerceDeclaredNumber(value, property) {
2420
2537
  const parsed = parseFloat(value);
2421
2538
  return isNaN(parsed) ? null : parsed;
2422
2539
  }
2423
- /** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
2424
- function coerceDeclaredNumbers(row, collection) {
2540
+ /**
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.
2549
+ *
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.
2553
+ */
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();
2561
+ }
2562
+ /**
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.
2565
+ */
2566
+ function toRestScalar(value, property) {
2567
+ if (property?.type === "date") return toRestDate(value);
2568
+ return coerceDeclaredNumber(value, property);
2569
+ }
2570
+ /**
2571
+ * Apply {@link toRestScalar} across a row, leaving undeclared columns alone.
2572
+ *
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.
2577
+ */
2578
+ function toRestValues(row, collection) {
2425
2579
  const properties = collection.properties;
2426
2580
  if (!properties) return row;
2427
2581
  const out = {};
2428
- for (const [key, value] of Object.entries(row)) out[key] = coerceDeclaredNumber(value, properties[key]);
2582
+ for (const [key, value] of Object.entries(row)) out[key] = toRestScalar(value, properties[key]);
2429
2583
  return out;
2430
2584
  }
2431
2585
  /** Render one target row in the requested style. */
2432
2586
  /**
2433
- * Drop every column the collection marked `excludeFromApi`.
2587
+ * Drop every column this caller may not read.
2434
2588
  *
2435
2589
  * Password hashes and verification tokens have to be readable server-side but
2436
2590
  * must never reach a client — and "never" has to mean every exit from this
2437
2591
  * pipeline, including relation targets, or a secret leaks through whichever
2438
2592
  * path was overlooked. Keyed by both the property name and its column name,
2439
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.
2605
+ *
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.
2440
2609
  */
2441
- function stripExcluded(row, collection) {
2610
+ function stripUnreadable(row, collection) {
2442
2611
  const properties = collection.properties;
2443
2612
  if (!properties) return row;
2613
+ const viewer = currentFieldViewer();
2614
+ const hidden = /* @__PURE__ */ new Set();
2444
2615
  for (const [key, property] of Object.entries(properties)) {
2445
- if (!property?.excludeFromApi) continue;
2616
+ if (canReadField(property, viewer)) continue;
2617
+ hidden.add(key);
2446
2618
  delete row[key];
2447
- if (property.columnName) delete row[property.columnName];
2619
+ if (property.columnName) {
2620
+ hidden.add(property.columnName);
2621
+ delete row[property.columnName];
2622
+ }
2448
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)));
2449
2625
  return row;
2450
2626
  }
2451
2627
  function renderTarget(targetRow, targetCollection, style, registry) {
2452
- if (style === "inline") return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
2628
+ if (style === "inline") return stripUnreadable(toRestValues({ ...targetRow }, targetCollection), targetCollection);
2453
2629
  const address = relationTargetAddress(targetRow, targetCollection, registry);
2454
2630
  const path = targetCollection.slug;
2455
2631
  return createRelationRefWithData(address, path, {
2456
2632
  id: address,
2457
2633
  path,
2458
- values: stripExcluded(normalizeDbValues(targetRow, targetCollection), targetCollection)
2634
+ values: stripUnreadable(normalizeDbValues(targetRow, targetCollection), targetCollection)
2459
2635
  });
2460
2636
  }
2461
2637
  /**
@@ -2491,7 +2667,7 @@ function toFlatRow(row, collection, registry) {
2491
2667
  normalized[key] = relData.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, targetCollection, "ref", registry));
2492
2668
  } else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) normalized[key] = renderTarget(relData, relation.target(), "ref", registry);
2493
2669
  }
2494
- return stripExcluded(normalized, collection);
2670
+ return stripUnreadable(normalized, collection);
2495
2671
  }
2496
2672
  /**
2497
2673
  * The row REST serves: every column under its own name, with the value Postgres
@@ -2499,9 +2675,9 @@ function toFlatRow(row, collection, registry) {
2499
2675
  *
2500
2676
  * Values are the ones the database returned, except where that contradicts the
2501
2677
  * declared type: a `number` property is served as a number (see
2502
- * {@link coerceDeclaredNumber}). Dates stay as the database returned them
2503
- * JSON has its own opinions about dates that the admin's view-model does not
2504
- * share.
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}).
2505
2681
  *
2506
2682
  * Keyed by the row rather than by the relation list — a REST fetch only loads
2507
2683
  * the relations `include` asked for, so the row is the authority on which are
@@ -2514,9 +2690,9 @@ function toRestRow(row, collection, registry) {
2514
2690
  const relation = findRelation(resolvedRelations, key);
2515
2691
  if (relation && Array.isArray(value)) flat[key] = value.map((item) => renderTarget(isJunctionRelation(relation) ? unwrapJunctionRow(item) : item, relation.target(), "inline", registry));
2516
2692
  else if (relation && typeof value === "object" && value !== null) flat[key] = renderTarget(value, relation.target(), "inline", registry);
2517
- else flat[key] = coerceDeclaredNumber(value, collection.properties?.[key]);
2693
+ else flat[key] = toRestScalar(value, collection.properties?.[key]);
2518
2694
  }
2519
- return stripExcluded(flat, collection);
2695
+ return stripUnreadable(flat, collection);
2520
2696
  }
2521
2697
  //#endregion
2522
2698
  //#region src/services/write-denial.ts
@@ -2690,6 +2866,73 @@ async function removeLinks(tx, binding, parentId, removed) {
2690
2866
  if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
2691
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");
2692
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
+ }
2693
2936
  //#endregion
2694
2937
  //#region src/services/RelationService.ts
2695
2938
  /**
@@ -2710,6 +2953,17 @@ function applyDynamicRelationQuery(query, ...args) {
2710
2953
  return DrizzleConditionBuilder.buildRelationQuery(...args);
2711
2954
  }
2712
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;
2965
+ }
2966
+ /**
2713
2967
  * The registry has no table for a collection a relation just asked about.
2714
2968
  *
2715
2969
  * "Parent table not found" was the whole message, four times over, and it names
@@ -2746,7 +3000,7 @@ var RelationService = class {
2746
3000
  */
2747
3001
  async toRelatedRow(targetRow, targetCollection, targetPks, options) {
2748
3002
  const values = options?.resolveNested ? await parseDataFromServer(targetRow, targetCollection, this.db, this.registry) : await parseDataFromServer(targetRow, targetCollection);
2749
- stripExcluded(values, targetCollection);
3003
+ stripUnreadable(values, targetCollection);
2750
3004
  return {
2751
3005
  id: buildCompositeId$1(targetRow, targetPks),
2752
3006
  path: targetCollection.slug,
@@ -2913,6 +3167,8 @@ var RelationService = class {
2913
3167
  if (matchValue === void 0) return [];
2914
3168
  let query = this.db.select().from(targetTable).$dynamic();
2915
3169
  const additionalFilters = [];
3170
+ const relatedSoftDelete = softDeleteCondition(targetCollection, targetTable);
3171
+ if (relatedSoftDelete) additionalFilters.push(relatedSoftDelete);
2916
3172
  if (options.searchString) {
2917
3173
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
2918
3174
  if (searchConditions.length === 0) return [];
@@ -2965,6 +3221,8 @@ var RelationService = class {
2965
3221
  const parentIdCol = parentTable[parentIdInfo.fieldName];
2966
3222
  const matchValue = hasForeignKeyOnTarget(relation) ? (await this.resolveSourceKeys(parentCollection, relation, [parentId])).keyByParentId.get(String(parentId)) : parsedParentId;
2967
3223
  if (matchValue === void 0) return 0;
3224
+ const countSoftDelete = softDeleteCondition(targetCollection, targetTable);
3225
+ if (countSoftDelete) additionalFilters = [...additionalFilters, countSoftDelete];
2968
3226
  let query = this.db.select({ count: sql`count(distinct ${targetIdField})` }).from(targetTable).$dynamic();
2969
3227
  query = DrizzleConditionBuilder.buildRelationCountQuery(query, relation, matchValue, targetTable, parentTable, parentIdCol, targetIdField, this.registry, additionalFilters);
2970
3228
  const result = await query;
@@ -2990,9 +3248,14 @@ var RelationService = class {
2990
3248
  return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
2991
3249
  }
2992
3250
  /**
2993
- * Batch fetch related rows for multiple parent rows to avoid N+1 queries
3251
+ * Batch fetch related rows for multiple parent rows to avoid N+1 queries.
3252
+ *
3253
+ * `narrow` is an extra condition on the *target* table — what a per-include
3254
+ * `where` compiles to. It is pushed into the query rather than applied to
3255
+ * the rows that come back, because filtering afterwards reads every related
3256
+ * row of every parent in order to discard most of them.
2994
3257
  */
2995
- async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
3258
+ async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation, narrow) {
2996
3259
  if (parentIds.length === 0) return /* @__PURE__ */ new Map();
2997
3260
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
2998
3261
  const targetCollection = relation.target();
@@ -3007,6 +3270,7 @@ var RelationService = class {
3007
3270
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
3008
3271
  const parentIdCol = parentTable[parentIdInfo.fieldName];
3009
3272
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
3273
+ const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
3010
3274
  if (relation.kind === "via") {
3011
3275
  let query = this.db.select().from(parentTable).$dynamic();
3012
3276
  let currentTable = parentTable;
@@ -3025,7 +3289,7 @@ var RelationService = class {
3025
3289
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
3026
3290
  currentTable = joinTable;
3027
3291
  }
3028
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
3292
+ query = query.where(narrowed(this.parentKeyCondition(parentTable, parentPks, parentIds), narrowTarget));
3029
3293
  const results = await query;
3030
3294
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
3031
3295
  const resultMap = /* @__PURE__ */ new Map();
@@ -3057,7 +3321,7 @@ var RelationService = class {
3057
3321
  }
3058
3322
  }
3059
3323
  if (uniqueFkValues.length === 0) return /* @__PURE__ */ new Map();
3060
- const targetResults = await this.db.select().from(targetTable).where(inArray(targetIdField, uniqueFkValues));
3324
+ const targetResults = await this.db.select().from(targetTable).where(narrowed(inArray(targetIdField, uniqueFkValues), narrowTarget));
3061
3325
  const targetById = /* @__PURE__ */ new Map();
3062
3326
  for (const row of targetResults) {
3063
3327
  const tid = String(row[targetIdInfo.fieldName]);
@@ -3078,7 +3342,7 @@ var RelationService = class {
3078
3342
  const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
3079
3343
  if (matchValues.length === 0) return /* @__PURE__ */ new Map();
3080
3344
  let query = this.db.select().from(targetTable).$dynamic();
3081
- query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
3345
+ query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, narrowTarget ? [narrowTarget] : []);
3082
3346
  const results = await query;
3083
3347
  const resultMap = /* @__PURE__ */ new Map();
3084
3348
  for (const row of results) {
@@ -3096,7 +3360,7 @@ var RelationService = class {
3096
3360
  * Returns a Map<parentId, RelatedRow[]> instead of Map<parentId, RelatedRow>.
3097
3361
  * Uses a single SQL query with IN clause to avoid N+1.
3098
3362
  */
3099
- async batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, _relationKey, relation) {
3363
+ async batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, _relationKey, relation, narrow) {
3100
3364
  if (parentIds.length === 0) return /* @__PURE__ */ new Map();
3101
3365
  const parentCollection = getCollectionByPath(parentCollectionPath, this.registry);
3102
3366
  const targetCollection = relation.target();
@@ -3110,6 +3374,7 @@ var RelationService = class {
3110
3374
  if (!parentTable) throw unregisteredTable(parentCollection.slug, parentTableName, "parent");
3111
3375
  const parentIdCol = parentTable[parentIdInfo.fieldName];
3112
3376
  const parsedParentIds = parentIds.map((id) => parseIdValues(id, parentPks)[parentIdInfo.fieldName]);
3377
+ const narrowTarget = andSoftDelete(narrow, targetCollection, targetTable);
3113
3378
  if (relation.kind === "via") {
3114
3379
  let query = this.db.select().from(parentTable).$dynamic();
3115
3380
  let currentTable = parentTable;
@@ -3126,7 +3391,7 @@ var RelationService = class {
3126
3391
  query = applyDynamicJoin(query, joinTable, eq(fromCol, toCol));
3127
3392
  currentTable = joinTable;
3128
3393
  }
3129
- query = query.where(this.parentKeyCondition(parentTable, parentPks, parentIds));
3394
+ query = query.where(narrowed(this.parentKeyCondition(parentTable, parentPks, parentIds), narrowTarget));
3130
3395
  const results = await query;
3131
3396
  const targetTableName = relation.joinPath[relation.joinPath.length - 1].table;
3132
3397
  const resultMap = /* @__PURE__ */ new Map();
@@ -3143,7 +3408,7 @@ var RelationService = class {
3143
3408
  if (relation.kind === "manyToMany") {
3144
3409
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
3145
3410
  const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relation.relationName}`);
3146
- const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(inArray(sourceJunctionCol, parsedParentIds));
3411
+ const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(narrowed(inArray(sourceJunctionCol, parsedParentIds), narrowTarget));
3147
3412
  const resultMap = /* @__PURE__ */ new Map();
3148
3413
  const targetTableName = getTableName$1(targetCollection);
3149
3414
  for (const row of results) {
@@ -3164,7 +3429,7 @@ var RelationService = class {
3164
3429
  const matchValues = hasForeignKeyOnTarget(relation) ? [...keyByParentId.values()] : parsedParentIds;
3165
3430
  if (matchValues.length === 0) return /* @__PURE__ */ new Map();
3166
3431
  let query = this.db.select().from(targetTable).$dynamic();
3167
- query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, []);
3432
+ query = applyDynamicRelationQuery(query, query, relation, matchValues, targetTable, parentTable, parentIdCol, targetIdField, this.registry, narrowTarget ? [narrowTarget] : []);
3168
3433
  const results = await query;
3169
3434
  const resultMap = /* @__PURE__ */ new Map();
3170
3435
  for (const row of results) {
@@ -3262,6 +3527,28 @@ function assertWritableThrough(hop, path) {
3262
3527
  //#endregion
3263
3528
  //#region src/services/FetchService.ts
3264
3529
  /**
3530
+ * Ascending comparison for two values of one column, with NULLs placed.
3531
+ *
3532
+ * Used only to order the rows *inside* one included relation, where the sort
3533
+ * happens after a batched load rather than in SQL — see
3534
+ * {@link FetchService.shapeRelatedRows}. The NULL placement is the same
3535
+ * question the `ORDER BY` answers, and it is answered the same way here so a
3536
+ * nested `orderBy` does not put its empty values at the opposite end from the
3537
+ * identical top-level one.
3538
+ */
3539
+ function compareForSort(a, b, nullsLast) {
3540
+ const aNull = a === null || a === void 0;
3541
+ const bNull = b === null || b === void 0;
3542
+ if (aNull && bNull) return 0;
3543
+ if (aNull) return nullsLast ? 1 : -1;
3544
+ if (bNull) return nullsLast ? -1 : 1;
3545
+ const av = a instanceof Date ? a.getTime() : a;
3546
+ const bv = b instanceof Date ? b.getTime() : b;
3547
+ if (typeof av === "number" && typeof bv === "number") return av - bv;
3548
+ if (typeof av === "boolean" && typeof bv === "boolean") return (av ? 1 : 0) - (bv ? 1 : 0);
3549
+ return String(av).localeCompare(String(bv));
3550
+ }
3551
+ /**
3265
3552
  * Service for handling all row read operations.
3266
3553
  * Handles fetching, searching, counting, and filtering rows.
3267
3554
  */
@@ -3391,12 +3678,13 @@ var FetchService = class FetchService {
3391
3678
  */
3392
3679
  resolveOrderKeys(table, keys, collection, searchString, collectionPath, cursorId) {
3393
3680
  const resolved = [];
3394
- for (const [field, direction] of keys) {
3681
+ for (const [field, direction, nulls] of keys) {
3395
3682
  const aggregate = this.resolveAggregateOrderTarget(table, field, collection, collectionPath);
3396
3683
  if (aggregate) {
3397
3684
  resolved.push({
3398
3685
  field,
3399
3686
  direction,
3687
+ nulls,
3400
3688
  target: aggregate,
3401
3689
  ...cursorId !== void 0 && { cursorTarget: this.resolveAggregateOrderTarget(table, field, collection, collectionPath, cursorId) }
3402
3690
  });
@@ -3406,12 +3694,31 @@ var FetchService = class FetchService {
3406
3694
  if (target) resolved.push({
3407
3695
  field,
3408
3696
  direction,
3697
+ nulls,
3409
3698
  target
3410
3699
  });
3411
3700
  }
3412
3701
  return resolved;
3413
3702
  }
3414
3703
  /**
3704
+ * Whether this key's NULLs sort *after* its real values.
3705
+ *
3706
+ * The default is Postgres's own — `NULLS LAST` ascending, `NULLS FIRST`
3707
+ * descending — and it was previously hardcoded in two places that had to
3708
+ * agree: the `ORDER BY` and the keyset comparison behind cursor paging. A
3709
+ * key that states a placement overrides it, in both, because they read the
3710
+ * answer from here.
3711
+ *
3712
+ * The default is also the thing a "newest first" list gets wrong: every row
3713
+ * with no date sorts to the very top, ahead of everything real, and the only
3714
+ * way out used to be an `is-not-null` filter that dropped those rows
3715
+ * entirely. `orderBy: [["published_at", "desc", "last"]]` is the fix.
3716
+ */
3717
+ static nullsLast(key) {
3718
+ if (key.nulls) return key.nulls === "last";
3719
+ return key.direction === "asc";
3720
+ }
3721
+ /**
3415
3722
  * The full `ORDER BY`: the caller's keys, then the id.
3416
3723
  *
3417
3724
  * The id is always last and always descending. It is not decoration — it is
@@ -3431,9 +3738,13 @@ var FetchService = class FetchService {
3431
3738
  * relation reaches nothing from, which is precisely the "nobody waiting"
3432
3739
  * end of a queue.
3433
3740
  */
3434
- buildOrderExpressions(keys, idField) {
3435
- const expressions = keys.map(({ direction, target }) => direction === "asc" ? sql`${target} ASC NULLS LAST` : sql`${target} DESC NULLS FIRST`);
3436
- expressions.push(desc(idField));
3741
+ buildOrderExpressions(keys, idField, tieBreakOnId = true) {
3742
+ const expressions = keys.map((key) => {
3743
+ const nullsLast = FetchService.nullsLast(key);
3744
+ if (key.direction === "asc") return nullsLast ? sql`${key.target} ASC NULLS LAST` : sql`${key.target} ASC NULLS FIRST`;
3745
+ return nullsLast ? sql`${key.target} DESC NULLS LAST` : sql`${key.target} DESC NULLS FIRST`;
3746
+ });
3747
+ if (tieBreakOnId) expressions.push(desc(idField));
3437
3748
  return expressions;
3438
3749
  }
3439
3750
  resolveOrderByField(table, orderBy, collection) {
@@ -3510,6 +3821,240 @@ var FetchService = class FetchService {
3510
3821
  return null;
3511
3822
  }
3512
3823
  /**
3824
+ * The relations one level of an include tree names, resolved.
3825
+ *
3826
+ * A name that is not a relation is a **400 `UNKNOWN_RELATION`**. It used to
3827
+ * be dropped silently, which answers 200 with the field missing — and a
3828
+ * missing relation field is indistinguishable from a row that genuinely has
3829
+ * no related row, so a typo in an `include` looked like empty data.
3830
+ */
3831
+ resolveIncludeLevel(collection, tree, wildcard) {
3832
+ const resolvedRelations = resolveCollectionRelations(collection);
3833
+ const out = [];
3834
+ if (wildcard) for (const [key, relation] of Object.entries(resolvedRelations)) out.push({
3835
+ key,
3836
+ node: tree[key] ?? { children: {} },
3837
+ relation
3838
+ });
3839
+ for (const [key, node] of Object.entries(tree)) {
3840
+ if (wildcard && key in resolvedRelations) continue;
3841
+ const relation = resolvedRelations[key];
3842
+ if (!relation) {
3843
+ const known = Object.keys(resolvedRelations).sort();
3844
+ throw ApiError.badRequest(`Unknown relation '${key}' on collection '${collection.slug ?? collection.name}'` + (known.length > 0 ? `. Its relations are: ${known.join(", ")}` : ". It declares no relations."), "UNKNOWN_RELATION", {
3845
+ relation: key,
3846
+ collection: collection.slug ?? collection.name,
3847
+ ...known.length > 0 && { validRelations: known }
3848
+ });
3849
+ }
3850
+ out.push({
3851
+ key,
3852
+ node,
3853
+ relation
3854
+ });
3855
+ }
3856
+ return out;
3857
+ }
3858
+ /**
3859
+ * The extra `WHERE` an include's own `where`/`logical` puts on the target.
3860
+ *
3861
+ * Pushed into the batch query rather than applied to the rows it returns:
3862
+ * filtering afterwards reads every related row of every parent to throw
3863
+ * most of them away, and on a to-many relation that is the whole table.
3864
+ */
3865
+ includeNarrowing(node, targetCollection) {
3866
+ if (!node.where && !node.logical) return void 0;
3867
+ const targetPath = targetCollection.slug;
3868
+ const targetTable = getTableForCollection(targetCollection, this.registry);
3869
+ const conditions = [];
3870
+ if (node.where) conditions.push(...this.buildFilterConditions(node.where, targetTable, targetPath));
3871
+ if (node.logical) {
3872
+ const logical = DrizzleConditionBuilder.buildLogicalConditions(node.logical, targetTable, targetPath, this.filterContext(targetPath, targetTable));
3873
+ if (logical) conditions.push(logical);
3874
+ }
3875
+ return conditions.length > 0 ? DrizzleConditionBuilder.combineConditionsWithAnd(conditions) : void 0;
3876
+ }
3877
+ /**
3878
+ * Sort, cap and project the rows of ONE to-many relation, per parent.
3879
+ *
3880
+ * `orderBy` and `limit` are applied here rather than in SQL because a limit
3881
+ * on a batched relation load is *per parent*, and expressing that needs a
3882
+ * LATERAL or a window function per relation kind — four of them, each with
3883
+ * its own join shape. The rows were already narrowed by
3884
+ * {@link includeNarrowing} in SQL, so what this sorts and cuts is the set
3885
+ * the caller asked for and not the table.
3886
+ */
3887
+ shapeRelatedRows(rows, node, targetCollection) {
3888
+ let out = rows;
3889
+ if (node.orderBy && node.orderBy.length > 0) {
3890
+ const keys = node.orderBy;
3891
+ out = [...out].sort((a, b) => {
3892
+ for (const [field, direction, nulls] of keys) {
3893
+ const cmp = compareForSort(a[field], b[field], FetchService.nullsLast({
3894
+ direction,
3895
+ nulls
3896
+ }));
3897
+ if (cmp !== 0) return direction === "desc" ? -cmp : cmp;
3898
+ }
3899
+ return 0;
3900
+ });
3901
+ }
3902
+ if (node.limit !== void 0) out = out.slice(0, node.limit);
3903
+ if (node.fields && node.fields.length > 0) {
3904
+ const keep = new Set(node.fields);
3905
+ for (const pk of getPrimaryKeys(targetCollection, this.registry)) keep.add(pk.fieldName);
3906
+ out = out.map((row) => {
3907
+ const projected = {};
3908
+ for (const [key, value] of Object.entries(row)) if (keep.has(key)) projected[key] = value;
3909
+ return projected;
3910
+ });
3911
+ }
3912
+ return out;
3913
+ }
3914
+ /**
3915
+ * Load an include tree onto `rows`, in place, one batched query per
3916
+ * relation per level.
3917
+ *
3918
+ * Recurses into nested includes with the rows it just loaded, so
3919
+ * `comments.author` is two queries for a page of posts rather than one per
3920
+ * comment. The depth bound lives on the normalizer
3921
+ * ({@link MAX_INCLUDE_DEPTH}); this asserts it again because the tree can
3922
+ * also be built in-process, where nothing normalized it.
3923
+ */
3924
+ async loadIncludes(rows, collection, collectionPath, include, depth = 1) {
3925
+ if (rows.length === 0 || depth > MAX_INCLUDE_DEPTH) return;
3926
+ const idInfoArray = getPrimaryKeys(collection, this.registry);
3927
+ if (idInfoArray.length === 0) return;
3928
+ const addressOf = (row) => {
3929
+ const address = buildCompositeId$1(row, idInfoArray);
3930
+ return address && address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "") ? address : void 0;
3931
+ };
3932
+ const addressable = rows.filter((row) => addressOf(row) !== void 0);
3933
+ if (addressable.length === 0) return;
3934
+ const rowIds = addressable.map((row) => addressOf(row));
3935
+ const levels = this.resolveIncludeLevel(collection, include.tree, include.wildcard);
3936
+ for (const { key, node, relation } of levels) {
3937
+ const targetCollection = relation.target();
3938
+ const narrow = this.includeNarrowing(node, targetCollection);
3939
+ const loaded = [];
3940
+ try {
3941
+ if (relation.cardinality === "one") {
3942
+ const results = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation, narrow);
3943
+ for (const row of addressable) {
3944
+ const related = results.get(String(addressOf(row)));
3945
+ if (!related) {
3946
+ row[key] = null;
3947
+ continue;
3948
+ }
3949
+ const [shaped] = this.shapeRelatedRows([toRestValues({ ...related.values }, targetCollection)], node, targetCollection);
3950
+ row[key] = shaped;
3951
+ loaded.push(shaped);
3952
+ }
3953
+ } else {
3954
+ const results = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation, narrow);
3955
+ for (const row of addressable) {
3956
+ const related = results.get(String(addressOf(row))) ?? [];
3957
+ const shaped = this.shapeRelatedRows(related.map((e) => toRestValues({ ...e.values }, targetCollection)), node, targetCollection);
3958
+ row[key] = shaped;
3959
+ loaded.push(...shaped);
3960
+ }
3961
+ }
3962
+ } catch (e) {
3963
+ if (e instanceof ApiError) throw e;
3964
+ if (reachedDatabase(e)) throw e;
3965
+ logger.warn(`[include] Failed to load relation '${key}' on ${collectionPath}`, { error: e });
3966
+ continue;
3967
+ }
3968
+ const children = Object.keys(node.children).length > 0 ? {
3969
+ wildcard: false,
3970
+ tree: node.children
3971
+ } : void 0;
3972
+ if (children && loaded.length > 0) await this.loadIncludes(loaded, targetCollection, targetCollection.slug, children, depth + 1);
3973
+ }
3974
+ }
3975
+ /**
3976
+ * The SELECT list for a read: the visible columns, narrowed to `fields`.
3977
+ *
3978
+ * `?fields=` used to be a trim of the *response* — every column read out of
3979
+ * the database and most of them thrown away in JavaScript. It is a
3980
+ * projection now, so asking for two columns of a wide row reads two
3981
+ * columns; that is also what makes `distinct` mean anything.
3982
+ *
3983
+ * Two things survive a narrowing regardless:
3984
+ *
3985
+ * - the **primary key**, because a row nobody can address cannot be
3986
+ * updated, deleted, or paged past — and `meta.nextCursor` is derived from
3987
+ * it, so a projection without it would silently disable seeking. The one
3988
+ * exception is `distinct`, below;
3989
+ * - the exclusions. `excludeFromApi` and the generated search columns are
3990
+ * removed *after* the narrowing, so naming one in `fields` does not
3991
+ * un-hide it. That was the shape of the `?searchString=` leak: a path
3992
+ * that skipped the projection returned a password hash the plain read
3993
+ * correctly withheld.
3994
+ *
3995
+ * An unknown column name is a 400 rather than a silent omission: a caller
3996
+ * who mistypes `?fields=titel` otherwise gets rows without titles and no
3997
+ * hint why.
3998
+ *
3999
+ * **`distinct` drops the primary key.** Keeping it is what the refusal of
4000
+ * `distinct` beside a search or vector query already describes: a value
4001
+ * that differs on every row makes the whole row distinct by construction,
4002
+ * so the query "would answer 200 having done nothing". A surrogate key does
4003
+ * that more reliably than any score — `?fields=status&distinct=true` came
4004
+ * back with one row per row, every one carrying its `id`. So a distinct
4005
+ * read is a read of the named columns and nothing else. It addresses no
4006
+ * rows, which is the honest shape for one: `cursorFor` finds no key on the
4007
+ * row and issues no cursor, and the result is a set of values rather than a
4008
+ * set of rows to update or delete.
4009
+ */
4010
+ columnProjection(table, collection, fields, idInfoArray, distinct) {
4011
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
4012
+ if (!fields || fields.length === 0) return visible;
4013
+ let tableColumns;
4014
+ try {
4015
+ tableColumns = getTableColumns(table);
4016
+ } catch {
4017
+ return visible;
4018
+ }
4019
+ const available = visible ?? tableColumns;
4020
+ const keep = new Set(fields);
4021
+ if (!distinct) for (const pk of idInfoArray) keep.add(pk.fieldName);
4022
+ const projection = {};
4023
+ for (const name of keep) {
4024
+ const column = available[name];
4025
+ if (column !== void 0) {
4026
+ projection[name] = column;
4027
+ continue;
4028
+ }
4029
+ if (tableColumns[name] !== void 0) continue;
4030
+ throw ApiError.badRequest(`Unknown field '${name}' in \`fields\` on collection '${collection.slug ?? collection.name}'. Valid fields: ` + Object.keys(available).sort().join(", "), "UNKNOWN_FIELD", {
4031
+ field: name,
4032
+ collection: collection.slug ?? collection.name
4033
+ });
4034
+ }
4035
+ return projection;
4036
+ }
4037
+ /**
4038
+ * The opaque cursor that continues a listing after `row`. See
4039
+ * {@link RestFetchService.cursorFor}.
4040
+ *
4041
+ * Here rather than at the route because deriving it needs the collection's
4042
+ * primary key, which may be named anything and span several columns — the
4043
+ * driver's knowledge, not the HTTP layer's.
4044
+ */
4045
+ cursorFor(collectionPath, row, orderBy) {
4046
+ if (orderBy?.some(([field]) => field === FetchService.SCORE_FIELD)) return void 0;
4047
+ try {
4048
+ const pks = getPrimaryKeys(getCollectionByPath(collectionPath, this.registry), this.registry);
4049
+ if (pks.length === 0) return void 0;
4050
+ const address = buildCompositeId$1(row, pks);
4051
+ if (!address || !address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "")) return void 0;
4052
+ return encodeCursor(orderBy, row, row[pks[0].fieldName]);
4053
+ } catch {
4054
+ return;
4055
+ }
4056
+ }
4057
+ /**
3513
4058
  * Post-fetch joinPath relations for a single flat row.
3514
4059
  * joinPath relations cannot be expressed via Drizzle's `with` config,
3515
4060
  * so they must be loaded separately after the primary query.
@@ -3531,81 +4076,6 @@ var FetchService = class FetchService {
3531
4076
  await Promise.all(promises);
3532
4077
  }
3533
4078
  /**
3534
- * Resolves joinPath relations for raw REST rows and directly injects them.
3535
- * Uses RelationService to query the database and maps results back to the flattened objects.
3536
- */
3537
- async resolveJoinPathRelationsBatchRest(rows, collection, collectionPath, idInfoArray, include) {
3538
- if (rows.length === 0) return;
3539
- const resolvedRelations = resolveCollectionRelations(collection);
3540
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
3541
- const shouldInclude = (key) => !include || include.length === 0 || include[0] === "*" || include.includes(key);
3542
- const joinPathRelations = Object.entries(resolvedRelations).filter(([key, relation]) => relation.kind === "via" && propertyKeys.has(key) && shouldInclude(key));
3543
- if (joinPathRelations.length === 0) return;
3544
- const parentIdOf = (row) => {
3545
- const address = buildCompositeId$1(row, idInfoArray);
3546
- return address && address.split(COMPOSITE_ID_SEPARATOR$1).some((part) => part !== "") ? address : void 0;
3547
- };
3548
- for (const [key, relation] of joinPathRelations) try {
3549
- const addressable = rows.filter((r) => parentIdOf(r) !== void 0 && parentIdOf(r) !== null);
3550
- if (addressable.length === 0) continue;
3551
- const rowIds = addressable.map((r) => parentIdOf(r));
3552
- if (relation.cardinality === "one") {
3553
- const resultMap = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
3554
- for (const row of addressable) {
3555
- const relatedRow = resultMap.get(String(parentIdOf(row)));
3556
- row[key] = relatedRow ? { ...relatedRow.values } : null;
3557
- }
3558
- } else if (relation.cardinality === "many") {
3559
- const resultMap = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
3560
- for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
3561
- }
3562
- } catch (e) {
3563
- if (reachedDatabase(e)) throw e;
3564
- logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
3565
- }
3566
- }
3567
- /**
3568
- * Build db.query-compatible options from standard fetch options.
3569
- * Handles filter, search, orderBy, limit, and cursor-based pagination.
3570
- */
3571
- buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
3572
- const queryOpts = {};
3573
- const hidden = hiddenColumnsOption(getTableColumns(table), this.registry.getCollectionByPath(collectionPath) ?? void 0);
3574
- if (hidden) queryOpts.columns = hidden;
3575
- if (withConfig) queryOpts.with = withConfig;
3576
- const allConditions = [];
3577
- if (scopeCondition) allConditions.push(scopeCondition);
3578
- if (options.searchString) {
3579
- const collection = getCollectionByPath(collectionPath, this.registry);
3580
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3581
- if (searchConditions.length === 0) {
3582
- queryOpts.where = and(eq(idField, -99999999));
3583
- return queryOpts;
3584
- }
3585
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3586
- }
3587
- if (options.filter) {
3588
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3589
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
3590
- }
3591
- if (options.logical) {
3592
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
3593
- if (logicalCondition) allConditions.push(logicalCondition);
3594
- }
3595
- if (options.startAfter) {
3596
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
3597
- if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
3598
- }
3599
- if (allConditions.length > 0) queryOpts.where = and(...allConditions);
3600
- const orderKeys = normalizeDriverOrderBy(options.orderBy, options.order);
3601
- const resolvedOrder = orderKeys ? this.resolveOrderKeys(table, orderKeys, getCollectionByPath(collectionPath, this.registry), options.searchString) : [];
3602
- queryOpts.orderBy = this.buildOrderExpressions(resolvedOrder, idField);
3603
- const limitValue = options.searchString ? options.limit || 50 : options.limit;
3604
- if (limitValue) queryOpts.limit = limitValue;
3605
- if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
3606
- return queryOpts;
3607
- }
3608
- /**
3609
4079
  * Extract cursor pagination conditions from startAfter options.
3610
4080
  *
3611
4081
  * "Every row that sorts after this one", written out as a comparison over
@@ -3649,12 +4119,15 @@ var FetchService = class FetchService {
3649
4119
  buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
3650
4120
  if (index >= keys.length) return lt(idField, cursorId);
3651
4121
  const { direction, cursorTarget } = keys[index];
4122
+ const nullsLast = FetchService.nullsLast(keys[index]);
3652
4123
  const target = keys[index].target;
3653
4124
  const value = values[index];
3654
4125
  const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
3655
- if (cursorTarget) return direction === "asc" ? or(and(isNull(cursorTarget), isNull(target), rest), and(isNotNull(cursorTarget), or(sql`${target} > ${cursorTarget}`, isNull(target), and(sql`${target} = ${cursorTarget}`, rest)))) : or(and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))), and(isNotNull(cursorTarget), or(sql`${target} < ${cursorTarget}`, and(sql`${target} = ${cursorTarget}`, rest))));
3656
- if (value === null) return direction === "asc" ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
3657
- return direction === "asc" ? or(gt(target, value), isNull(target), and(eq(target, value), rest)) : or(lt(target, value), and(eq(target, value), rest));
4126
+ const after = (a, b) => direction === "asc" ? gt(a, b) : lt(a, b);
4127
+ const afterSql = (a, b) => direction === "asc" ? sql`${a} > ${b}` : sql`${a} < ${b}`;
4128
+ if (cursorTarget) return nullsLast ? or(and(isNull(cursorTarget), isNull(target), rest), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), isNull(target), and(sql`${target} = ${cursorTarget}`, rest)))) : or(and(isNull(cursorTarget), or(isNotNull(target), and(isNull(target), rest))), and(isNotNull(cursorTarget), or(afterSql(target, cursorTarget), and(sql`${target} = ${cursorTarget}`, rest))));
4129
+ if (value === null) return nullsLast ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
4130
+ return nullsLast ? or(after(target, value), isNull(target), and(eq(target, value), rest)) : or(after(target, value), and(eq(target, value), rest));
3658
4131
  }
3659
4132
  /**
3660
4133
  * Compile "rows reachable from this parent" into a `WHERE` condition on the
@@ -3698,7 +4171,7 @@ var FetchService = class FetchService {
3698
4171
  /**
3699
4172
  * Fetch a single row by ID
3700
4173
  */
3701
- async fetchOne(collectionPath, id, databaseId) {
4174
+ async fetchOne(collectionPath, id, databaseId, withDeleted) {
3702
4175
  if (!await this.isAddressableUnder(collectionPath, id)) return void 0;
3703
4176
  const collection = getCollectionByPath(collectionPath, this.registry);
3704
4177
  const table = getTableForCollection(collection, this.registry);
@@ -3714,7 +4187,7 @@ var FetchService = class FetchService {
3714
4187
  const withConfig = this.buildWithConfig(collection);
3715
4188
  const hidden = hiddenColumnsOption(getTableColumns(table), collection);
3716
4189
  const row = await qb.findFirst({
3717
- where: eq(idField, parsedId),
4190
+ where: andSoftDelete(eq(idField, parsedId), collection, table, withDeleted),
3718
4191
  with: withConfig,
3719
4192
  ...hidden ? { columns: hidden } : {}
3720
4193
  });
@@ -3731,7 +4204,7 @@ var FetchService = class FetchService {
3731
4204
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
3732
4205
  }
3733
4206
  const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
3734
- const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
4207
+ const result = await this.db.select(visibleOne).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
3735
4208
  if (result.length === 0) return void 0;
3736
4209
  const raw = result[0];
3737
4210
  const values = await parseDataFromServer(raw, collection, this.db, this.registry);
@@ -3764,84 +4237,13 @@ var FetchService = class FetchService {
3764
4237
  * Unified method to fetch rows with optional search functionality
3765
4238
  */
3766
4239
  async fetchRowsWithConditions(collectionPath, options = {}) {
3767
- const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
3768
4240
  const collection = getCollectionByPath(collectionPath, this.registry);
3769
4241
  const table = getTableForCollection(collection, this.registry);
3770
4242
  const idInfoArray = requirePrimaryKeys(collection, this.registry);
3771
4243
  const idInfo = idInfoArray[0];
3772
- const idField = table[idInfo.fieldName];
3773
- if (!idField) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
3774
- const tableName = getTableName(table);
3775
- const qb = this.getQueryBuilder(tableName);
3776
- const withConfig = this.buildWithConfig(collection);
3777
- const hasRelations = withConfig && Object.keys(withConfig).length > 0;
3778
- if (qb && !options.searchString && !hasRelations && !options.vectorSearch) try {
3779
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, void 0, scopeCondition);
3780
- return (await qb.findMany(queryOpts)).map((row) => toFlatRow(row, collection, this.registry));
3781
- } catch (e) {
3782
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
3783
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
3784
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
3785
- }
3786
- if (reachedDatabase(e)) throw e;
3787
- logger.warn(`[FetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select`, { error: e });
3788
- }
3789
- let vectorMeta;
3790
- if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
3791
- const visible = visibleColumnProjection(getTableColumns(table), collection);
3792
- const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
3793
- const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
3794
- let query = vectorMeta ? this.db.select({
3795
- table_row: visible ?? table,
3796
- _distance: vectorMeta.distanceSelect
3797
- }).from(table).$dynamic() : rankSelect ? this.db.select({
3798
- table_row: visible ?? table,
3799
- _score: rankSelect,
3800
- ...matchesSelect ? { _matches: matchesSelect } : {}
3801
- }).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3802
- const allConditions = [];
3803
- if (scopeCondition) allConditions.push(scopeCondition);
3804
- if (options.searchString) {
3805
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3806
- if (searchConditions.length === 0) return [];
3807
- allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3808
- }
3809
- if (options.filter) {
3810
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3811
- if (filterConditions.length > 0) allConditions.push(...filterConditions);
3812
- }
3813
- if (options.logical) {
3814
- const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
3815
- if (logicalCondition) allConditions.push(logicalCondition);
3816
- }
3817
- if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
3818
- if (allConditions.length > 0) {
3819
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3820
- if (finalCondition) query = query.where(finalCondition);
3821
- }
3822
- const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
3823
- query = query.orderBy(...orderExpressions);
3824
- if (options.startAfter) {
3825
- const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
3826
- if (cursorConditions.length > 0) {
3827
- allConditions.push(...cursorConditions);
3828
- const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3829
- if (finalCondition) query = query.where(finalCondition);
3830
- }
3831
- }
3832
- const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
3833
- if (limitValue) query = query.limit(limitValue);
3834
- if (options.offset && options.offset > 0) query = query.offset(options.offset);
3835
- const rawResults = await query;
3836
- const results = vectorMeta ? rawResults.map((r) => ({
3837
- ...r.table_row,
3838
- _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
3839
- })) : rankSelect ? rawResults.map((r) => ({
3840
- ...r.table_row,
3841
- _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
3842
- ...matchesSelect ? { _matches: r._matches ?? [] } : {}
3843
- })) : rawResults;
3844
- return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
4244
+ if (!table[idInfo.fieldName]) throw new Error(`ID field '${idInfo.fieldName}' not found in table for collection '${collectionPath}'`);
4245
+ const results = await this.fetchRowsWithConditionsRaw(collectionPath, options);
4246
+ return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, normalizeInclude(options.include), idInfoArray);
3845
4247
  }
3846
4248
  /**
3847
4249
  * Fallback path used when db.query is unavailable.
@@ -3852,54 +4254,11 @@ var FetchService = class FetchService {
3852
4254
  *
3853
4255
  * Process raw database results into flat rows with relations.
3854
4256
  */
3855
- async processRowResults(results, collection, collectionPath, idInfo, _databaseId, skipRelations = false, idInfoArray) {
4257
+ async processRowResults(results, collection, collectionPath, idInfo, _databaseId, include, _idInfoArray) {
3856
4258
  if (results.length === 0) return [];
3857
- const parsedRows = await Promise.all(results.map(async (rawRow) => {
3858
- return {
3859
- rawRow,
3860
- values: await parseDataFromServer(rawRow, collection)
3861
- };
3862
- }));
3863
- if (!skipRelations) {
3864
- const resolvedRelations = resolveCollectionRelations(collection);
3865
- const propertyKeys = new Set(Object.keys(collection.properties));
3866
- for (const [key, relation] of Object.entries(resolvedRelations)) {
3867
- if (!propertyKeys.has(key) || relation.cardinality !== "one") continue;
3868
- const rowsMissingRelation = parsedRows.filter((item) => {
3869
- const val = item.values[key];
3870
- if (val == null) return true;
3871
- if (typeof val === "object" && !Array.isArray(val) && val.__type === "relation" && val.data == null) return true;
3872
- return false;
3873
- });
3874
- if (rowsMissingRelation.length === 0) continue;
3875
- try {
3876
- const rowIds = rowsMissingRelation.map((item) => item.rawRow[idInfo.fieldName]);
3877
- const relationResults = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
3878
- rowsMissingRelation.forEach((item) => {
3879
- const id = item.rawRow[idInfo.fieldName];
3880
- const relatedRow = relationResults.get(String(id));
3881
- if (relatedRow) item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
3882
- });
3883
- } catch (e) {
3884
- if (reachedDatabase(e)) throw e;
3885
- logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
3886
- }
3887
- }
3888
- const manyRelations = Object.entries(resolvedRelations).filter(([key, relation]) => propertyKeys.has(key) && relation.cardinality === "many");
3889
- for (const [key, relation] of manyRelations) try {
3890
- const rowIds = parsedRows.map((item) => item.rawRow[idInfo.fieldName]);
3891
- const relationResults = await this.relationService.batchFetchRelatedEntitiesMany(collectionPath, rowIds, key, relation);
3892
- parsedRows.forEach((item) => {
3893
- const id = String(item.rawRow[idInfo.fieldName]);
3894
- const relatedRows = relationResults.get(id) || [];
3895
- item.values[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
3896
- });
3897
- } catch (e) {
3898
- if (reachedDatabase(e)) throw e;
3899
- logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
3900
- }
3901
- }
3902
- return parsedRows.map((item) => item.values);
4259
+ const values = await Promise.all(results.map(async (rawRow) => await parseDataFromServer(rawRow, collection)));
4260
+ if (include) await this.loadIncludes(values, collection, collectionPath, include);
4261
+ return values;
3903
4262
  }
3904
4263
  /**
3905
4264
  * Fetch a collection of rows
@@ -3932,6 +4291,7 @@ var FetchService = class FetchService {
3932
4291
  let query = this.db.select({ count: count() }).from(table).$dynamic();
3933
4292
  const allConditions = [];
3934
4293
  if (hop) allConditions.push(this.buildRelationScope(hop));
4294
+ withSoftDelete(allConditions, collection, table, options.withDeleted);
3935
4295
  if (options.searchString) {
3936
4296
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3937
4297
  if (searchConditions.length === 0) return 0;
@@ -4012,6 +4372,7 @@ var FetchService = class FetchService {
4012
4372
  for (const group of groupColumns) selection[group.field] = sql`${group.column}`;
4013
4373
  let query = this.db.select(selection).from(table).$dynamic();
4014
4374
  const conditions = [];
4375
+ withSoftDelete(conditions, collection, table, options.withDeleted);
4015
4376
  if (options.searchString) {
4016
4377
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
4017
4378
  if (searchConditions.length === 0) return [];
@@ -4066,12 +4427,24 @@ var FetchService = class FetchService {
4066
4427
  return this.relationService;
4067
4428
  }
4068
4429
  /**
4069
- * Fetch a collection of rows with optional relation includes.
4070
- * When `include` is provided, only the specified relations are populated
4071
- * with full row data (not just { id, path, __type }).
4072
- * When `include` is absent, no relation queries are made (fast path).
4430
+ * Fetch a collection of rows, with the relations `include` names.
4431
+ *
4432
+ * ## Why this no longer uses `db.query.findMany({ with })`
4073
4433
  *
4074
- * @param include - Array of relation keys to populate, or ["*"] for all
4434
+ * It used to, whenever a `db.query` builder existed for the table which
4435
+ * is Drizzle's relational API, and which compiles a to-many relation into a
4436
+ * lateral join. This file's own comment on the *other* read path says what
4437
+ * that costs: "catastrophically slow for large collections (7s+ for 350
4438
+ * rows)". So the admin fetch avoided it and REST took it, and the fast,
4439
+ * batched loader that the admin used was unreachable from the HTTP API.
4440
+ *
4441
+ * There is one loader now, {@link loadIncludes}, and every read reaches it:
4442
+ * one query per relation per level, never one per row. That also makes this
4443
+ * method and the realtime refetch the same code, which is what stops
4444
+ * `find()` and `listen()` returning different shapes for one query.
4445
+ *
4446
+ * @param include - see {@link IncludeSpec}: names, dotted paths, `["*"]`,
4447
+ * or the parametrised tree. An unknown name is a 400 `UNKNOWN_RELATION`.
4075
4448
  */
4076
4449
  async fetchCollectionForRest(collectionPath, options = {}, include) {
4077
4450
  if (isNestedPath(collectionPath)) {
@@ -4081,67 +4454,24 @@ var FetchService = class FetchService {
4081
4454
  relatedTo: hop
4082
4455
  }, include);
4083
4456
  }
4084
- const scopeCondition = options.relatedTo ? this.buildRelationScope(options.relatedTo) : void 0;
4085
4457
  const collection = getCollectionByPath(collectionPath, this.registry);
4086
- const table = getTableForCollection(collection, this.registry);
4087
- const idInfoArray = requirePrimaryKeys(collection, this.registry);
4088
- const idInfo = idInfoArray[0];
4089
- const idField = table[idInfo.fieldName];
4090
- const tableName = getTableName(table);
4091
- const qb = this.getQueryBuilder(tableName);
4092
- if (qb && !options.searchString && !options.vectorSearch) try {
4093
- const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
4094
- const queryOpts = this.buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition);
4095
- const restRows = (await qb.findMany(queryOpts)).map((row) => toRestRow(row, collection, this.registry));
4096
- await this.resolveJoinPathRelationsBatchRest(restRows, collection, collectionPath, idInfoArray, include);
4097
- return restRows;
4098
- } catch (e) {
4099
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
4100
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
4101
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
4102
- }
4103
- if (reachedDatabase(e)) throw e;
4104
- logger.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back`, { error: e });
4105
- }
4458
+ const normalizedInclude = normalizeInclude(include);
4106
4459
  const rows = (await this.fetchRowsWithConditionsRaw(collectionPath, options)).map((row) => toRestRow(row, collection, this.registry));
4107
- if (!include || include.length === 0) return rows;
4108
- const resolvedRelations = resolveCollectionRelations(collection);
4109
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
4110
- const shouldInclude = (key) => include[0] === "*" || include.includes(key);
4111
- const rowIds = rows.map((e) => e[idInfo.fieldName]);
4112
- for (const [key, relation] of Object.entries(resolvedRelations)) {
4113
- if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "one") continue;
4114
- try {
4115
- const batchResults = await this.relationService.batchFetchRelatedEntities(collectionPath, rowIds, key, relation);
4116
- for (const row of rows) {
4117
- const eid = row[idInfo.fieldName];
4118
- const related = batchResults.get(String(eid));
4119
- if (related) row[key] = { ...related.values };
4120
- }
4121
- } catch (e) {
4122
- if (reachedDatabase(e)) throw e;
4123
- logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
4124
- }
4125
- }
4126
- for (const [key, relation] of Object.entries(resolvedRelations)) {
4127
- if (!propertyKeys.has(key) || !shouldInclude(key) || relation.cardinality !== "many") continue;
4128
- try {
4129
- const batchResults = await this.batchFetchManyRelatedRows(collectionPath, rowIds, key);
4130
- for (const row of rows) {
4131
- const eid = row[idInfo.fieldName];
4132
- row[key] = batchResults.get(String(eid)) || [];
4133
- }
4134
- } catch (e) {
4135
- if (reachedDatabase(e)) throw e;
4136
- logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
4137
- }
4138
- }
4460
+ if (normalizedInclude) await this.loadIncludes(rows, collection, collectionPath, normalizedInclude);
4139
4461
  return rows;
4140
4462
  }
4141
4463
  /**
4142
- * Fetch a single row with optional relation includes for REST API.
4464
+ * Fetch a single row, with the relations `include` names.
4465
+ *
4466
+ * The same two steps `fetchCollectionForRest` takes, for one row, through
4467
+ * the same {@link loadIncludes}. It used to be a separate implementation on
4468
+ * `db.query.findFirst({ with })` with a hand-written N+1 fallback beneath
4469
+ * it, and the two disagreed: the primary path returned a to-one relation as
4470
+ * the target's columns, the fallback merged an `id` over them, and a
4471
+ * relation that resolved to nothing was *absent* on one path and `null` on
4472
+ * the other. `find()[0]` and `findById()` now answer with the same row.
4143
4473
  */
4144
- async fetchOneForRest(collectionPath, id, include, databaseId) {
4474
+ async fetchOneForRest(collectionPath, id, include, databaseId, options) {
4145
4475
  if (!await this.isAddressableUnder(collectionPath, id)) return null;
4146
4476
  const collection = getCollectionByPath(collectionPath, this.registry);
4147
4477
  const table = getTableForCollection(collection, this.registry);
@@ -4150,56 +4480,14 @@ var FetchService = class FetchService {
4150
4480
  const idField = table[idInfo.fieldName];
4151
4481
  if (!idCanAddressTable(id, table, idInfoArray)) return null;
4152
4482
  const parsedId = parseIdValues(id, idInfoArray)[idInfo.fieldName];
4153
- const tableName = getTableName(table);
4154
- const qb = this.getQueryBuilder(tableName);
4155
- if (qb) try {
4156
- const withConfig = include && include.length > 0 ? this.buildWithConfig(collection, include) : void 0;
4157
- const row = await qb.findFirst({
4158
- where: eq(idField, parsedId),
4159
- ...withConfig ? { with: withConfig } : {}
4160
- });
4161
- if (!row) return null;
4162
- const restRow = toRestRow(row, collection, this.registry);
4163
- await this.resolveJoinPathRelationsBatchRest([restRow], collection, collectionPath, idInfoArray, include);
4164
- return restRow;
4165
- } catch (e) {
4166
- if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
4167
- logger.error(`[FetchService] ResolvedRelation inference error for collection '${collectionPath}': ${e.message}`);
4168
- logger.error("Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.");
4169
- }
4170
- if (reachedDatabase(e)) throw e;
4171
- logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
4172
- }
4173
- const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
4174
- const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
4483
+ const withDeleted = options?.withDeleted;
4484
+ const projection = this.columnProjection(table, collection, options?.fields, idInfoArray);
4485
+ const result = await this.db.select(projection).from(table).where(andSoftDelete(eq(idField, parsedId), collection, table, withDeleted)).limit(1);
4175
4486
  if (result.length === 0) return null;
4176
- const flatEntity = toRestRow(result[0], collection, this.registry);
4177
- if (!include || include.length === 0) return flatEntity;
4178
- const resolvedRelations = resolveCollectionRelations(collection);
4179
- const propertyKeys = new Set(Object.keys(collection.properties || {}));
4180
- const shouldInclude = (key) => include[0] === "*" || include.includes(key);
4181
- for (const [key, relation] of Object.entries(resolvedRelations)) {
4182
- if (!propertyKeys.has(key) || !shouldInclude(key)) continue;
4183
- try {
4184
- const relatedRows = await this.relationService.fetchRelatedEntities(collectionPath, parsedId, key, {});
4185
- if (relation.cardinality === "one") {
4186
- if (relatedRows.length > 0) {
4187
- const e = relatedRows[0];
4188
- flatEntity[key] = {
4189
- id: e.id,
4190
- ...e.values
4191
- };
4192
- }
4193
- } else flatEntity[key] = relatedRows.map((e) => ({
4194
- id: e.id,
4195
- ...e.values
4196
- }));
4197
- } catch (e) {
4198
- if (reachedDatabase(e)) throw e;
4199
- logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
4200
- }
4201
- }
4202
- return flatEntity;
4487
+ const row = toRestRow(result[0], collection, this.registry);
4488
+ const normalizedInclude = normalizeInclude(include);
4489
+ if (normalizedInclude) await this.loadIncludes([row], collection, collectionPath, normalizedInclude);
4490
+ return row;
4203
4491
  }
4204
4492
  /**
4205
4493
  * Fetch raw rows without any relation processing (for REST fast path)
@@ -4207,12 +4495,20 @@ var FetchService = class FetchService {
4207
4495
  async fetchRowsWithConditionsRaw(collectionPath, options = {}) {
4208
4496
  const collection = getCollectionByPath(collectionPath, this.registry);
4209
4497
  const table = getTableForCollection(collection, this.registry);
4210
- const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
4498
+ const idInfoArray = requirePrimaryKeys(collection, this.registry);
4499
+ const idInfo = idInfoArray[0];
4500
+ const idField = table[idInfo.fieldName];
4211
4501
  let vectorMeta;
4212
4502
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
4213
- const visible = visibleColumnProjection(getTableColumns(table), collection);
4503
+ const visible = this.columnProjection(table, collection, options.fields, idInfoArray, options.distinct === true);
4214
4504
  const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
4215
4505
  const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
4506
+ const wantsDistinct = options.distinct === true;
4507
+ if (wantsDistinct && (vectorMeta || rankSelect)) throw ApiError.badRequest("`distinct` cannot be combined with a search or vector query: both attach a per-row score to every row, so no two rows are ever equal and DISTINCT would have no effect. Drop one of the two.", "DISTINCT_NOT_APPLICABLE");
4508
+ const selectFrom = () => {
4509
+ const builder = wantsDistinct ? this.db.selectDistinct.bind(this.db) : this.db.select.bind(this.db);
4510
+ return visible ? builder(visible).from(table).$dynamic() : builder().from(table).$dynamic();
4511
+ };
4216
4512
  let query = vectorMeta ? this.db.select({
4217
4513
  table_row: visible ?? table,
4218
4514
  _distance: vectorMeta.distanceSelect
@@ -4220,9 +4516,10 @@ var FetchService = class FetchService {
4220
4516
  table_row: visible ?? table,
4221
4517
  _score: rankSelect,
4222
4518
  ...matchesSelect ? { _matches: matchesSelect } : {}
4223
- }).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
4519
+ }).from(table).$dynamic() : selectFrom();
4224
4520
  const allConditions = [];
4225
4521
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
4522
+ withSoftDelete(allConditions, collection, table, options.withDeleted);
4226
4523
  if (options.searchString) {
4227
4524
  const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
4228
4525
  if (searchConditions.length === 0) return [];
@@ -4241,8 +4538,22 @@ var FetchService = class FetchService {
4241
4538
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
4242
4539
  if (finalCondition) query = query.where(finalCondition);
4243
4540
  }
4244
- const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
4245
- query = query.orderBy(...orderExpressions);
4541
+ const sortKeys = normalizeDriverOrderBy(options.orderBy, options.order) ?? [];
4542
+ if (wantsDistinct && visible) {
4543
+ const selected = new Set(Object.keys(visible));
4544
+ const missing = sortKeys.map(([field]) => field).filter((field) => !selected.has(field));
4545
+ if (missing.length > 0) throw ApiError.badRequest(`\`distinct\` cannot sort by ${missing.map((f) => `'${f}'`).join(", ")}: a DISTINCT read can only be ordered by columns it returns, or the rows it collapses have no defined order. Add ${missing.map((f) => `'${f}'`).join(", ")} to \`fields\`, or drop them from \`orderBy\`.`, "DISTINCT_ORDER_BY_NOT_SELECTED", { fields: missing });
4546
+ }
4547
+ const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, sortKeys, collection, options.searchString), idField, !wantsDistinct);
4548
+ if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
4549
+ if (options.startAfter) {
4550
+ const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
4551
+ if (cursorConditions.length > 0) {
4552
+ allConditions.push(...cursorConditions);
4553
+ const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
4554
+ if (finalCondition) query = query.where(finalCondition);
4555
+ }
4556
+ }
4246
4557
  const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
4247
4558
  if (limitValue) query = query.limit(limitValue);
4248
4559
  if (options.offset && options.offset > 0) query = query.offset(options.offset);
@@ -4258,39 +4569,6 @@ var FetchService = class FetchService {
4258
4569
  }));
4259
4570
  return rawResults;
4260
4571
  }
4261
- /**
4262
- * Check if the Drizzle instance has the relational query API available
4263
- * for a given collection path.
4264
- * Note: Primary path now uses inline `getQueryBuilder()` checks.
4265
- */
4266
- hasDrizzleQueryAPI(collectionPath) {
4267
- if (!this.getQueryBuilder("__probe__")) return false;
4268
- const tableName = getTableName(getTableForCollection(getCollectionByPath(collectionPath, this.registry), this.registry));
4269
- return !!this.getQueryBuilder(tableName);
4270
- }
4271
- /**
4272
- * Fallback path used when db.query is unavailable.
4273
- * The primary path uses db.query.findMany with `with` config, which
4274
- * loads all relations in a single query.
4275
- *
4276
- * Batch fetch many-to-many related rows for multiple parent IDs.
4277
- * Groups results by parent ID to avoid N+1.
4278
- */
4279
- async batchFetchManyRelatedRows(parentCollectionPath, parentIds, relationKey) {
4280
- if (parentIds.length === 0) return /* @__PURE__ */ new Map();
4281
- const relation = resolveCollectionRelations(getCollectionByPath(parentCollectionPath, this.registry))[relationKey];
4282
- if (!relation) {
4283
- logger.warn(`[batchFetchManyRelatedRows] ResolvedRelation '${relationKey}' not found, skipping`);
4284
- return /* @__PURE__ */ new Map();
4285
- }
4286
- const entityMap = await this.relationService.batchFetchRelatedEntitiesMany(parentCollectionPath, parentIds, relationKey, relation);
4287
- const flatMap = /* @__PURE__ */ new Map();
4288
- for (const [key, rows] of entityMap) flatMap.set(key, rows.map((e) => ({
4289
- ...e.values,
4290
- id: e.id
4291
- })));
4292
- return flatMap;
4293
- }
4294
4572
  };
4295
4573
  //#endregion
4296
4574
  //#region src/services/RelationWriteService.ts
@@ -4556,6 +4834,103 @@ var RelationWriteService = class {
4556
4834
  }
4557
4835
  };
4558
4836
  //#endregion
4837
+ //#region src/services/field-op-sql.ts
4838
+ /**
4839
+ * Turning `{ views: { $inc: 1 } }` into the `SET` clause that cannot lose a
4840
+ * concurrent write.
4841
+ *
4842
+ * The contract — which operators exist, what they accept, which property types
4843
+ * they are legal on — lives in `@rebasepro/server`. What is here is the part
4844
+ * only this driver can know: an `array` property is a native `text[]` on one
4845
+ * collection and a jsonb document on the next (see the DDL generator's `array`
4846
+ * arm), and the two need entirely different statements. Compiling from the
4847
+ * property type alone would emit `array_append` against a jsonb column, which
4848
+ * Postgres rejects at plan time with a function-does-not-exist error naming
4849
+ * neither the collection nor the field.
4850
+ *
4851
+ * Every expression is NULL-safe. `NULL + 1` is `NULL`, and a counter that
4852
+ * silently empties itself the first time it is incremented from an unset column
4853
+ * is worse than one that never worked.
4854
+ *
4855
+ * @module
4856
+ */
4857
+ /** What the column is, as Postgres sees it: `text[]`, `jsonb`, `integer`, … */
4858
+ function sqlTypeOf(column) {
4859
+ try {
4860
+ return String(column.getSQLType()).toLowerCase();
4861
+ } catch {
4862
+ return "";
4863
+ }
4864
+ }
4865
+ function isNativeArray(type) {
4866
+ return type.endsWith("[]") || type.endsWith(" array");
4867
+ }
4868
+ /** A `json` column needs the result cast back; a `jsonb` one does not. */
4869
+ function isPlainJson(type) {
4870
+ return type === "json";
4871
+ }
4872
+ function asList(operand) {
4873
+ return Array.isArray(operand) ? operand : [operand];
4874
+ }
4875
+ /**
4876
+ * The expression one operation compiles to, or a 400 saying why it cannot.
4877
+ *
4878
+ * `field` is the property key; it is already known to be a column of `table`
4879
+ * (the caller checks that against the same table it builds the statement from).
4880
+ */
4881
+ function compileFieldOp(table, field, op, context) {
4882
+ const column = table[field];
4883
+ if (!column) throw ApiError.badRequest(`'${field}' is not a column of "${context.collectionPath}", so ${op.operator} has nothing to apply to.`, "INVALID_FIELD_OPERATION");
4884
+ const type = sqlTypeOf(column);
4885
+ switch (op.operator) {
4886
+ case "$inc": return sql`COALESCE(${column}, 0) + ${op.operand}`;
4887
+ case "$push": {
4888
+ const values = asList(op.operand);
4889
+ if (isNativeArray(type)) {
4890
+ let expression = sql`${column}`;
4891
+ for (const value of values) expression = sql`array_append(${expression}, ${value})`;
4892
+ return expression;
4893
+ }
4894
+ if (type.startsWith("json")) {
4895
+ const appended = sql`COALESCE(${column}::jsonb, '[]'::jsonb) || ${JSON.stringify(values)}::jsonb`;
4896
+ return isPlainJson(type) ? sql`(${appended})::json` : appended;
4897
+ }
4898
+ throw unsupportedColumn(field, op.operator, type, context);
4899
+ }
4900
+ case "$pull": {
4901
+ const values = asList(op.operand);
4902
+ if (isNativeArray(type)) {
4903
+ let expression = sql`${column}`;
4904
+ for (const value of values) expression = sql`array_remove(${expression}, ${value})`;
4905
+ return expression;
4906
+ }
4907
+ if (type.startsWith("json")) {
4908
+ const filtered = sql`COALESCE((
4909
+ SELECT jsonb_agg(element)
4910
+ FROM jsonb_array_elements(COALESCE(${column}::jsonb, '[]'::jsonb)) AS element
4911
+ WHERE NOT (element IN (SELECT jsonb_array_elements(${sql`${JSON.stringify(values)}::jsonb`})))
4912
+ ), '[]'::jsonb)`;
4913
+ return isPlainJson(type) ? sql`(${filtered})::json` : filtered;
4914
+ }
4915
+ throw unsupportedColumn(field, op.operator, type, context);
4916
+ }
4917
+ case "$merge": {
4918
+ if (!type.startsWith("json")) throw unsupportedColumn(field, op.operator, type, context);
4919
+ const merged = sql`COALESCE(${column}::jsonb, '{}'::jsonb) || ${JSON.stringify(op.operand)}::jsonb`;
4920
+ return isPlainJson(type) ? sql`(${merged})::json` : merged;
4921
+ }
4922
+ }
4923
+ }
4924
+ function unsupportedColumn(field, operator, type, context) {
4925
+ return ApiError.badRequest(`${operator} cannot be applied to '${field}' on "${context.collectionPath}": the column is ${type || "of an unknown type"}, and ${operator} needs ` + (operator === "$merge" ? "a json or jsonb column." : "an array or json column."), "INVALID_FIELD_OPERATION");
4926
+ }
4927
+ /** Compile a whole map of operations into a `SET`-ready object. */
4928
+ function compileFieldOps(table, fieldOps, context) {
4929
+ const compiled = {};
4930
+ for (const [field, op] of Object.entries(fieldOps)) compiled[field] = compileFieldOp(table, field, op, context);
4931
+ return compiled;
4932
+ }
4933
+ //#endregion
4559
4934
  //#region src/services/PersistService.ts
4560
4935
  /**
4561
4936
  * Service for handling all row write operations.
@@ -4646,10 +5021,17 @@ var PersistService = class {
4646
5021
  * Save an row (create or update)
4647
5022
  *
4648
5023
  * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
4649
- * UPDATE against the primary key rather than a plain UPDATE. That is one
4650
- * statement, so it cannot lose a race the way a read-then-write can, and it
4651
- * does not care whether the row already exists — which is what a re-runnable
4652
- * import needs.
5024
+ * UPDATE rather than a plain UPDATE. That is one statement, so it cannot
5025
+ * lose a race the way a read-then-write can, and it does not care whether
5026
+ * the row already exists — which is what a re-runnable import needs. The
5027
+ * conflict is matched on the primary key unless `options.onConflict` names
5028
+ * other columns; see {@link SaveProps.onConflict} for why that matters.
5029
+ *
5030
+ * `values` may carry field operations (`{ views: { $inc: 1 } }`). They are
5031
+ * split out here rather than at the REST boundary because every request
5032
+ * boundary — HTTP, the WebSocket, in-process `context.data` — reaches this
5033
+ * one method, and a rule applied at one door is a rule the other doors do
5034
+ * not have. What they compile to is `field-op-sql.ts`.
4653
5035
  */
4654
5036
  async save(collectionPath, values, id, databaseId, options) {
4655
5037
  let effectiveCollectionPath = collectionPath;
@@ -4692,8 +5074,14 @@ var PersistService = class {
4692
5074
  if (!field) throw new Error(`Primary key field '${info.fieldName}' not found in table for collection '${effectiveCollectionPath}'`);
4693
5075
  returningKeys[info.fieldName] = field;
4694
5076
  });
5077
+ const { values: plainValues, fieldOps } = splitFieldOps(effectiveValues);
5078
+ const fieldOpKeys = Object.keys(fieldOps);
5079
+ if (fieldOpKeys.length > 0) {
5080
+ if (!id) throw ApiError.badRequest(`Field operations (${fieldOpKeys.map((k) => `'${k}'`).join(", ")}) need an existing row to act on, and this write to "${effectiveCollectionPath}" is an insert. Send the value itself instead.`, "INVALID_FIELD_OPERATION");
5081
+ if (options?.upsert) throw ApiError.badRequest(`Field operations (${fieldOpKeys.map((k) => `'${k}'`).join(", ")}) cannot be combined with an upsert: an upsert may insert, and there would be no stored value to operate on. Use an update, or send the value itself.`, "INVALID_FIELD_OPERATION");
5082
+ }
4695
5083
  const relationValues = {};
4696
- const otherValues = { ...effectiveValues };
5084
+ const otherValues = { ...plainValues };
4697
5085
  const resolvedRelations = resolveCollectionRelations(collection);
4698
5086
  for (const key in resolvedRelations) {
4699
5087
  const relation = resolvedRelations[key];
@@ -4711,14 +5099,20 @@ var PersistService = class {
4711
5099
  const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
4712
5100
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
4713
5101
  assertWritableColumns(entityData, table, effectiveCollectionPath);
5102
+ if (fieldOpKeys.length > 0) assertWritableColumns(Object.fromEntries(fieldOpKeys.map((key) => [key, null])), table, effectiveCollectionPath);
4714
5103
  savedId = await this.db.transaction(async (tx) => {
4715
5104
  let currentId;
4716
5105
  if (id && !options?.upsert) {
4717
5106
  currentId = id;
4718
5107
  const idValues = parseIdValues(id, idInfoArray);
4719
5108
  if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
4720
- if (Object.keys(entityData).length > 0) {
4721
- const updateQuery = tx.update(table).set(entityData);
5109
+ const scalarKeys = Object.keys(entityData);
5110
+ const compiledOps = fieldOpKeys.length > 0 ? compileFieldOps(table, fieldOps, { collectionPath: effectiveCollectionPath }) : void 0;
5111
+ if (scalarKeys.length > 0 || compiledOps) {
5112
+ const updateQuery = tx.update(table).set({
5113
+ ...entityData,
5114
+ ...compiledOps ?? {}
5115
+ });
4722
5116
  const conditions = [];
4723
5117
  for (const info of idInfoArray) {
4724
5118
  const field = table[info.fieldName];
@@ -4731,12 +5125,18 @@ var PersistService = class {
4731
5125
  if (id && options?.upsert) Object.assign(dataForInsert, parseIdValues(id, idInfoArray));
4732
5126
  for (const info of idInfoArray) if (dataForInsert[info.fieldName] === "" || dataForInsert[info.fieldName] === null || dataForInsert[info.fieldName] === void 0) delete dataForInsert[info.fieldName];
4733
5127
  const insertQuery = tx.insert(table).values(dataForInsert);
4734
- const hasFullKey = idInfoArray.length > 0 && idInfoArray.every((info) => dataForInsert[info.fieldName] !== void 0);
5128
+ const targetFields = options?.onConflict && options.onConflict.length > 0 ? [...options.onConflict] : idInfoArray.map((info) => info.fieldName);
5129
+ const hasFullKey = targetFields.length > 0 && targetFields.every((field) => dataForInsert[field] !== void 0);
4735
5130
  let result;
4736
5131
  if (options?.upsert && hasFullKey) {
4737
- const target = idInfoArray.map((info) => table[info.fieldName]);
5132
+ const target = targetFields.map((field) => {
5133
+ const column = table[field];
5134
+ if (!column) throw ApiError.badRequest(`'${field}' is not a column of "${effectiveCollectionPath}", so it cannot be an upsert target.`, "INVALID_CONFLICT_TARGET");
5135
+ return column;
5136
+ });
4738
5137
  const set = { ...dataForInsert };
4739
5138
  for (const info of idInfoArray) delete set[info.fieldName];
5139
+ for (const field of targetFields) delete set[field];
4740
5140
  for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
4741
5141
  result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
4742
5142
  target,
@@ -4755,7 +5155,7 @@ var PersistService = class {
4755
5155
  return currentId;
4756
5156
  });
4757
5157
  } catch (error) {
4758
- throw this.toUserFriendlyError(error, collection.slug);
5158
+ throw this.toUserFriendlyError(error, collection.slug, collection);
4759
5159
  }
4760
5160
  const finalEntity = await this.fetchService.fetchOneForRest(collection.slug, savedId, void 0, databaseId);
4761
5161
  if (!finalEntity) throw new Error("Could not fetch row after save.");
@@ -4784,13 +5184,23 @@ var PersistService = class {
4784
5184
  }
4785
5185
  /**
4786
5186
  * Translate raw PostgreSQL / Drizzle errors into user-friendly messages.
5187
+ *
5188
+ * `collection` is passed so the SQLSTATE can be turned into a *field*: the
5189
+ * error carries a physical column and the caller only ever sees wire names,
5190
+ * so `author_id` had to be translated to `authorId` by whoever read the
5191
+ * message — which nothing does. See `pgFieldViolations`.
4787
5192
  */
4788
- toUserFriendlyError(error, collectionSlug) {
5193
+ toUserFriendlyError(error, collectionSlug, collection) {
4789
5194
  if (error instanceof ApiError || error?.name === "ApiError") return error;
4790
5195
  const pgError = extractPgError(error);
4791
5196
  if (pgError) {
4792
- const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4793
- if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
5197
+ const { message, code, violations } = pgErrorToFriendlyMessage(pgError, collectionSlug, { collection });
5198
+ /** `details` for a 4xx, carrying the field(s) never the values. */
5199
+ const details = violations.length > 0 ? {
5200
+ collection: collectionSlug,
5201
+ violations
5202
+ } : void 0;
5203
+ if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`, details) : ApiError.badRequest(message, `PG_${code}`, details);
4794
5204
  if (isRowLevelSecurityDenial(error)) return ApiError.forbidden(message, "WRITE_DENIED");
4795
5205
  return new Error(message, { cause: error });
4796
5206
  }
@@ -4827,8 +5237,8 @@ var DataService = class {
4827
5237
  /**
4828
5238
  * Fetch a single row by ID
4829
5239
  */
4830
- async fetchOne(collectionPath, id, databaseId) {
4831
- return this.fetchService.fetchOne(collectionPath, id, databaseId);
5240
+ async fetchOne(collectionPath, id, databaseId, withDeleted) {
5241
+ return this.fetchService.fetchOne(collectionPath, id, databaseId, withDeleted);
4832
5242
  }
4833
5243
  /**
4834
5244
  * Fetch a collection of rows with optional filtering, ordering, and pagination
@@ -4837,6 +5247,27 @@ var DataService = class {
4837
5247
  return this.fetchService.fetchCollection(collectionPath, options);
4838
5248
  }
4839
5249
  /**
5250
+ * The REST read pipeline: flat rows, with exactly the relations `include`
5251
+ * names — see {@link FetchService.fetchCollectionForRest}.
5252
+ *
5253
+ * Exposed on the facade because it is what *every* consumer should be
5254
+ * reading through. The realtime refetch used to go via `fetchCollection`
5255
+ * above, which nests a relation under a `{ __type: "relation" }` envelope
5256
+ * and loaded every relation regardless, so a subscription and a REST list
5257
+ * answered the same query with two different row shapes.
5258
+ */
5259
+ async fetchCollectionForRest(collectionPath, options = {}, include) {
5260
+ return this.fetchService.fetchCollectionForRest(collectionPath, options, include);
5261
+ }
5262
+ /** One row, through the same pipeline. See {@link FetchService.fetchOneForRest}. */
5263
+ async fetchOneForRest(collectionPath, id, include, databaseId, options) {
5264
+ return this.fetchService.fetchOneForRest(collectionPath, id, include, databaseId, options);
5265
+ }
5266
+ /** See {@link FetchService.cursorFor}. */
5267
+ cursorFor(collectionPath, row, orderBy) {
5268
+ return this.fetchService.cursorFor(collectionPath, row, orderBy);
5269
+ }
5270
+ /**
4840
5271
  * Search rows by text
4841
5272
  */
4842
5273
  async searchRows(collectionPath, searchString, options = {}) {
@@ -4955,7 +5386,9 @@ var physicalShapeOf = (prop) => JSON.stringify([
4955
5386
  prop.columnType ?? null,
4956
5387
  prop.dimensions ?? null,
4957
5388
  prop.isId ?? null,
4958
- prop.validation?.max ?? null
5389
+ prop.validation?.max ?? null,
5390
+ prop.precision ?? null,
5391
+ prop.scale ?? null
4959
5392
  ]);
4960
5393
  /** Whether the table behind a collection exists and is empty. */
4961
5394
  var tableIsEmpty = (collection, facts) => {
@@ -5226,37 +5659,34 @@ async function generateSchemaCommit(input) {
5226
5659
  const options = { constraints: "converge" };
5227
5660
  const classified = classifyCollectionChanges(input.before, input.after, input.existing);
5228
5661
  if (!classified.applicable) throw new SchemaCommitError(`This change cannot be applied to a running database:\n` + classified.changes.filter((change) => change.verdict !== "safe").map((change) => ` • ${change.detail}${change.remedy ? `\n ${change.remedy}` : ""}`).join("\n"), classified);
5229
- const [schema, ddl, policies, search, vector] = await Promise.all([
5230
- generateSchema(input.after),
5231
- generatePostgresDdl(input.after, {
5232
- includePolicies: false,
5233
- includeSearch: false,
5234
- includeVector: false
5235
- }),
5236
- Promise.resolve(generatePostgresPoliciesDdl(input.after)),
5237
- Promise.resolve(generatePostgresSearchDdl(input.after)),
5238
- Promise.resolve(generatePostgresVectorDdl(input.after, { extensions: declaredDatabaseExtensions() }))
5239
- ]);
5240
5662
  const generated = [
5241
5663
  {
5242
5664
  path: paths.schemaFile,
5243
- contents: schema
5665
+ contents: generateSchema(input.after)
5244
5666
  },
5245
5667
  {
5246
5668
  path: paths.ddlFile,
5247
- contents: ddl
5669
+ contents: generatePostgresDdl(input.after, {
5670
+ includePolicies: false,
5671
+ includeSearch: false,
5672
+ includeVector: false
5673
+ })
5248
5674
  },
5249
5675
  {
5250
5676
  path: paths.policiesFile,
5251
- contents: policies
5677
+ contents: generatePostgresPoliciesDdl(input.after)
5252
5678
  },
5253
5679
  {
5254
5680
  path: paths.searchFile,
5255
- contents: search
5681
+ contents: generatePostgresSearchDdl(input.after)
5682
+ },
5683
+ {
5684
+ path: paths.vectorFile,
5685
+ contents: generatePostgresVectorDdl(input.after, { extensions: declaredDatabaseExtensions() })
5256
5686
  },
5257
5687
  {
5258
- path: paths.vectorFile,
5259
- contents: vector
5688
+ path: paths.triggersFile,
5689
+ contents: generatePostgresTriggersDdl(input.after)
5260
5690
  }
5261
5691
  ];
5262
5692
  const previous = planCollectionSchemaEnsure(input.before, existing, options);
@@ -5322,6 +5752,37 @@ var RoleSwitchUnavailableError = class extends Error {
5322
5752
  this.pgError = pgError;
5323
5753
  }
5324
5754
  };
5755
+ /**
5756
+ * Refuse a write that would leave a *required* `created_by` / `updated_by`
5757
+ * column null because nobody is acting.
5758
+ *
5759
+ * A `user_on_create` column takes the uid from the call context, and an
5760
+ * anonymous request, a service token and an in-process write all have none.
5761
+ * Storing `null` is the right answer where the column allows it — plenty of
5762
+ * rows are legitimately written by the server. Where the collection has said
5763
+ * `required`, it is not: that declaration means "a row must record who made
5764
+ * it", and a write that cannot answer the question is a write this collection
5765
+ * does not accept. Better a 400 naming the field than a 23502 naming the
5766
+ * column, three layers down, after the hooks have run.
5767
+ */
5768
+ function assertActingUserForAutoValues(properties, status, uid, path) {
5769
+ if (uid) return;
5770
+ for (const [key, property] of Object.entries(properties)) {
5771
+ const prop = property;
5772
+ if (!prop || prop.type !== "string") continue;
5773
+ if (prop.autoValue !== "user_on_create" && prop.autoValue !== "user_on_update") continue;
5774
+ if (!prop.validation?.required) continue;
5775
+ if (status === "existing" && prop.autoValue === "user_on_create") continue;
5776
+ throw ApiError.badRequest(`'${key}' on '${path}' records the acting user and is required, and this request has none. Sign in, or drop \`required\` from the property to let the server write rows anonymously.`, "VALIDATION_CONSTRAINT", {
5777
+ collection: path,
5778
+ violations: [{
5779
+ field: key,
5780
+ code: "required",
5781
+ message: `'${key}' records the acting user, and there is none.`
5782
+ }]
5783
+ });
5784
+ }
5785
+ }
5325
5786
  var PostgresBackendDriver = class PostgresBackendDriver {
5326
5787
  db;
5327
5788
  registry;
@@ -5431,15 +5892,16 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5431
5892
  const rows = await raw.fetchCollectionForRest(collectionPath, options, include);
5432
5893
  return this.applyAfterReadForRest(rows, collectionPath);
5433
5894
  },
5434
- fetchOneForRest: async (collectionPath, id, include, databaseId) => {
5435
- const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId);
5895
+ fetchOneForRest: async (collectionPath, id, include, databaseId, options) => {
5896
+ const row = await raw.fetchOneForRest(collectionPath, id, include, databaseId, options);
5436
5897
  if (!row) return row;
5437
5898
  const [masked] = await this.applyAfterReadForRest([row], collectionPath);
5438
5899
  return masked;
5439
5900
  },
5440
5901
  aggregate: async (collectionPath, options) => {
5441
5902
  return raw.aggregate(collectionPath, options);
5442
- }
5903
+ },
5904
+ cursorFor: (collectionPath, row, orderBy) => raw.cursorFor(collectionPath, row, orderBy)
5443
5905
  };
5444
5906
  }
5445
5907
  /**
@@ -5661,8 +6123,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5661
6123
  this.realtimeService.subscriptions.delete(subscriptionId);
5662
6124
  };
5663
6125
  }
5664
- async fetchOne({ path, id, databaseId, collection }) {
5665
- let row = await this.dataService.fetchOne(path, id, databaseId || collection?.databaseId);
6126
+ async fetchOne({ path, id, databaseId, collection, withDeleted }) {
6127
+ let row = await this.dataService.fetchOne(path, id, databaseId || collection?.databaseId, withDeleted);
5666
6128
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
5667
6129
  if (row && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
5668
6130
  const contextForCallback = this.buildCallContext();
@@ -5713,10 +6175,110 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5713
6175
  this.realtimeService.subscriptions.delete(subscriptionId);
5714
6176
  };
5715
6177
  }
5716
- async save({ path, id, values, collection, status, upsert }) {
6178
+ /**
6179
+ * How many membership rows a single write will read to answer "which
6180
+ * tenants is this caller in".
6181
+ *
6182
+ * A cap, not a limit on the feature: past it the API-level check stops
6183
+ * being able to prove a tenant is *not* the caller's, and defers to the
6184
+ * policy's `WITH CHECK`, which decides either way. The alternative — an
6185
+ * uncapped read of a membership table on every insert — makes the cost of a
6186
+ * write depend on how many organizations one user happens to belong to.
6187
+ */
6188
+ static TENANT_MEMBERSHIP_CAP = 100;
6189
+ /**
6190
+ * Stamp, or refuse, the tenant on a write to a `tenant`-scoped collection.
6191
+ *
6192
+ * A no-op for every collection that declares no tenancy, which is the
6193
+ * common case and costs one property read.
6194
+ */
6195
+ async applyTenantScope(collection, path, values, status, previousValues) {
6196
+ const tenant = getTenantConfig(collection);
6197
+ if (!tenant) return values;
6198
+ const bypassRoles = tenantBypassRoles(tenant);
6199
+ const callerRoles = (this.user?.roles ?? []).map((r) => typeof r === "string" ? r : String(r?.id ?? r));
6200
+ const bypass = !this.user || callerRoles.some((r) => bypassRoles.includes(r));
6201
+ const { tenants, complete } = bypass ? {
6202
+ tenants: [],
6203
+ complete: true
6204
+ } : await this.resolveCallerTenants(tenant, collection);
6205
+ const decision = resolveTenantWrite({
6206
+ tenant,
6207
+ values,
6208
+ status,
6209
+ callerTenants: tenants,
6210
+ callerTenantsComplete: complete,
6211
+ bypass,
6212
+ previousValues,
6213
+ slug: collection?.slug ?? path
6214
+ });
6215
+ if (decision.refusal) {
6216
+ const { code, field, message } = decision.refusal;
6217
+ throw ApiError.badRequest(message, code, {
6218
+ collection: path,
6219
+ violations: [{
6220
+ field,
6221
+ code,
6222
+ message
6223
+ }]
6224
+ });
6225
+ }
6226
+ return decision.values;
6227
+ }
6228
+ /**
6229
+ * The tenants this caller may write into.
6230
+ *
6231
+ * Two sources, and they answer at different costs. A `claim` is already on
6232
+ * the request — no query. A `membership` is rows in a table, and reading
6233
+ * them is a real query, so it is capped (see
6234
+ * {@link PostgresBackendDriver.TENANT_MEMBERSHIP_CAP}) and `complete` says
6235
+ * whether the cap was hit.
6236
+ *
6237
+ * The membership read runs in the caller's own context, so the membership
6238
+ * collection's RLS applies to it — exactly as it does inside the generated
6239
+ * policy's subquery. That is deliberate: if the two disagreed, the API
6240
+ * would refuse writes the database would have accepted, or accept writes it
6241
+ * refuses. `validateCollectionConfig` is what makes sure the membership
6242
+ * collection is readable by its own members in the first place.
6243
+ */
6244
+ async resolveCallerTenants(tenant, collection) {
6245
+ if ("claim" in tenant.from) {
6246
+ const value = this.user?.claims?.[tenant.from.claim];
6247
+ return {
6248
+ tenants: value === void 0 || value === null || value === "" ? [] : [value],
6249
+ complete: true
6250
+ };
6251
+ }
6252
+ const uid = this.user?.uid;
6253
+ if (!uid) return {
6254
+ tenants: [],
6255
+ complete: true
6256
+ };
6257
+ const { collection: slug, userField, tenantField } = tenant.from.membership;
6258
+ const cap = PostgresBackendDriver.TENANT_MEMBERSHIP_CAP;
6259
+ try {
6260
+ const rows = await this.dataService.fetchCollection(slug, {
6261
+ filter: { [userField]: ["==", uid] },
6262
+ limit: cap + 1,
6263
+ databaseId: collection?.databaseId
6264
+ });
6265
+ return {
6266
+ tenants: Array.from(new Set(rows.slice(0, cap).map((row) => row[tenantField]).filter((value) => value !== null && value !== void 0).map((value) => typeof value === "object" ? value.id ?? value : value))),
6267
+ complete: rows.length <= cap
6268
+ };
6269
+ } catch (err) {
6270
+ logger.debug(`[save] Could not read '${slug}' to resolve the caller's tenants`, { detail: err instanceof Error ? err.message : String(err) });
6271
+ return {
6272
+ tenants: [],
6273
+ complete: false
6274
+ };
6275
+ }
6276
+ }
6277
+ async save({ path, id, values, collection, status, upsert, onConflict }) {
5717
6278
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, path);
5718
6279
  let updatedValues = values;
5719
6280
  const contextForCallback = this.buildCallContext();
6281
+ if ((status === "new" || status === "copy") && resolvedCollection?.properties) updatedValues = applyDefaultValuesOnCreate(updatedValues, resolvedCollection.properties);
5720
6282
  let previousValuesForHistory;
5721
6283
  if (status === "existing" && id) try {
5722
6284
  const existing = await this.dataService.getFetchService().fetchOneForRest(path, id, void 0, resolvedCollection?.databaseId);
@@ -5769,14 +6331,27 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5769
6331
  } catch (callbackError) {
5770
6332
  throw toCallbackError(callbackError, "beforeSave", path);
5771
6333
  }
5772
- if (resolvedCollection?.properties) updatedValues = updateDateAutoValues({
5773
- inputValues: updatedValues,
5774
- properties: resolvedCollection.properties,
5775
- status: status ?? "new",
5776
- timestampNowValue: /* @__PURE__ */ new Date()
5777
- });
6334
+ if (resolvedCollection?.properties) {
6335
+ updatedValues = updateDateAutoValues({
6336
+ inputValues: updatedValues,
6337
+ properties: resolvedCollection.properties,
6338
+ status: status ?? "new",
6339
+ timestampNowValue: /* @__PURE__ */ new Date()
6340
+ });
6341
+ assertActingUserForAutoValues(resolvedCollection.properties, status ?? "new", this.user?.uid, path);
6342
+ updatedValues = updateUserAutoValues({
6343
+ inputValues: updatedValues,
6344
+ properties: resolvedCollection.properties,
6345
+ status: status ?? "new",
6346
+ uid: this.user?.uid
6347
+ });
6348
+ }
6349
+ updatedValues = await this.applyTenantScope(resolvedCollection, path, updatedValues, status ?? "new", previousValuesForHistory);
5778
6350
  try {
5779
- let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, { upsert });
6351
+ let savedRow = await this.dataService.save(path, updatedValues, id, resolvedCollection?.databaseId, {
6352
+ upsert,
6353
+ onConflict
6354
+ });
5780
6355
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
5781
6356
  if (globalCallbacks?.afterRead) savedRow = await globalCallbacks.afterRead({
5782
6357
  collection: resolvedCollection,
@@ -5832,7 +6407,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5832
6407
  } catch (callbackError) {
5833
6408
  throw toCallbackError(callbackError, "afterSave", path);
5834
6409
  }
5835
- if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
6410
+ if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
5836
6411
  tableName: path,
5837
6412
  id: savedId,
5838
6413
  action: status === "new" ? "create" : "update",
@@ -5887,13 +6462,17 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5887
6462
  * Rows are applied in order, so a batch that touches the same key twice ends
5888
6463
  * with the last write winning, exactly as separate calls would.
5889
6464
  */
5890
- async saveMany({ path, rows, collection, upsert }) {
6465
+ bindToTransaction(tx) {
6466
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
6467
+ txDriver.dataService = new DataService(tx, this.registry);
6468
+ txDriver.client = this.client;
6469
+ txDriver._deferNotifications = this._deferNotifications;
6470
+ txDriver._pendingNotifications = this._pendingNotifications;
6471
+ return txDriver;
6472
+ }
6473
+ async saveMany({ path, rows, collection, upsert, onConflict }) {
5891
6474
  return this.db.transaction(async (tx) => {
5892
- const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5893
- txDriver.dataService = new DataService(tx, this.registry);
5894
- txDriver.client = this.client;
5895
- txDriver._deferNotifications = this._deferNotifications;
5896
- txDriver._pendingNotifications = this._pendingNotifications;
6475
+ const txDriver = this.bindToTransaction(tx);
5897
6476
  const saved = [];
5898
6477
  for (let i = 0; i < rows.length; i++) {
5899
6478
  const values = rows[i];
@@ -5904,7 +6483,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5904
6483
  values,
5905
6484
  collection,
5906
6485
  status: "new",
5907
- upsert
6486
+ upsert,
6487
+ onConflict
5908
6488
  }));
5909
6489
  } catch (error) {
5910
6490
  const label = id !== void 0 ? `id ${JSON.stringify(id)}` : "no id";
@@ -5935,11 +6515,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5935
6515
  */
5936
6516
  async updateMany({ path, updates, collection }) {
5937
6517
  return this.db.transaction(async (tx) => {
5938
- const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5939
- txDriver.dataService = new DataService(tx, this.registry);
5940
- txDriver.client = this.client;
5941
- txDriver._deferNotifications = this._deferNotifications;
5942
- txDriver._pendingNotifications = this._pendingNotifications;
6518
+ const txDriver = this.bindToTransaction(tx);
5943
6519
  const saved = [];
5944
6520
  for (let i = 0; i < updates.length; i++) {
5945
6521
  const { id, values } = updates[i];
@@ -5981,13 +6557,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5981
6557
  * dependents would behave differently depending on how many rows the caller
5982
6558
  * happened to delete at once. Same pipeline, one transaction.
5983
6559
  */
5984
- async deleteMany({ path, ids, collection }) {
6560
+ async deleteMany({ path, ids, collection, hard }) {
5985
6561
  await this.db.transaction(async (tx) => {
5986
- const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5987
- txDriver.dataService = new DataService(tx, this.registry);
5988
- txDriver.client = this.client;
5989
- txDriver._deferNotifications = this._deferNotifications;
5990
- txDriver._pendingNotifications = this._pendingNotifications;
6562
+ const txDriver = this.bindToTransaction(tx);
5991
6563
  for (let i = 0; i < ids.length; i++) {
5992
6564
  const id = ids[i];
5993
6565
  try {
@@ -6006,7 +6578,8 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6006
6578
  path,
6007
6579
  values: existing
6008
6580
  },
6009
- collection
6581
+ collection,
6582
+ hard
6010
6583
  });
6011
6584
  } catch (error) {
6012
6585
  throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
@@ -6018,7 +6591,107 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6018
6591
  }
6019
6592
  });
6020
6593
  }
6021
- async delete({ row, collection }) {
6594
+ /**
6595
+ * A mixed list of writes across collections, as one unit of work.
6596
+ *
6597
+ * Same transaction, same tx-bound sub-driver and same per-entry error
6598
+ * labelling as {@link saveMany} — deliberately the same plumbing rather
6599
+ * than a second copy of it, because the three ways this could drift
6600
+ * (notifications not deferred, the sub-driver not bound, an error losing
6601
+ * its status) are all silent.
6602
+ *
6603
+ * The one thing this adds is `$ref`. An operation may name itself, and a
6604
+ * later one may stand a `{ "$ref": "order.id" }` where a value goes; the
6605
+ * substitution happens here because inside the transaction is the only
6606
+ * place the row the reference points at exists. Backward references only —
6607
+ * the REST layer refuses a forward one before the transaction opens, so a
6608
+ * body that cannot work never costs a rollback.
6609
+ */
6610
+ async batchWrite({ operations }) {
6611
+ return this.db.transaction(async (tx) => {
6612
+ const txDriver = this.bindToTransaction(tx);
6613
+ const results = [];
6614
+ /** What each named operation wrote, for the `$ref`s after it. */
6615
+ const named = /* @__PURE__ */ new Map();
6616
+ for (let i = 0; i < operations.length; i++) {
6617
+ const operation = operations[i];
6618
+ try {
6619
+ const values = operation.values ? resolveBatchRefs(operation.values, named) : void 0;
6620
+ const rawId = operation.id !== void 0 ? resolveBatchRefs(operation.id, named) : void 0;
6621
+ const id = rawId === void 0 || rawId === null ? void 0 : String(rawId);
6622
+ let row = null;
6623
+ switch (operation.op) {
6624
+ case "create":
6625
+ row = await txDriver.save({
6626
+ path: operation.path,
6627
+ values: values ?? {},
6628
+ collection: operation.collection,
6629
+ status: "new"
6630
+ });
6631
+ break;
6632
+ case "upsert":
6633
+ row = await txDriver.save({
6634
+ path: operation.path,
6635
+ values: values ?? {},
6636
+ collection: operation.collection,
6637
+ status: "new",
6638
+ upsert: true,
6639
+ onConflict: operation.onConflict
6640
+ });
6641
+ break;
6642
+ case "update":
6643
+ if (!await txDriver.fetchOne({
6644
+ path: operation.path,
6645
+ id,
6646
+ collection: operation.collection
6647
+ })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
6648
+ statusCode: 404,
6649
+ code: "NOT_FOUND"
6650
+ });
6651
+ row = await txDriver.save({
6652
+ path: operation.path,
6653
+ id,
6654
+ values: values ?? {},
6655
+ collection: operation.collection,
6656
+ status: "existing"
6657
+ });
6658
+ break;
6659
+ case "delete": {
6660
+ const existing = await txDriver.fetchOne({
6661
+ path: operation.path,
6662
+ id,
6663
+ collection: operation.collection
6664
+ });
6665
+ if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
6666
+ statusCode: 404,
6667
+ code: "NOT_FOUND"
6668
+ });
6669
+ await txDriver.delete({
6670
+ row: {
6671
+ id,
6672
+ path: operation.path,
6673
+ values: existing
6674
+ },
6675
+ collection: operation.collection
6676
+ });
6677
+ row = null;
6678
+ break;
6679
+ }
6680
+ }
6681
+ if (operation.ref && row) named.set(operation.ref, row);
6682
+ results.push(row);
6683
+ } catch (error) {
6684
+ throw Object.assign(new Error(`Operation ${i} of ${operations.length} (${operation.op} on "${operation.path}") failed: ${error?.message ?? error}`, { cause: error }), {
6685
+ statusCode: error?.statusCode,
6686
+ code: error?.code,
6687
+ name: error?.name
6688
+ });
6689
+ }
6690
+ }
6691
+ return results;
6692
+ });
6693
+ }
6694
+ async delete({ row, collection, hard }) {
6022
6695
  const targetPath = row.path;
6023
6696
  const targetRow = { ...row.values ?? {} };
6024
6697
  const { collection: resolvedCollection, callbacks, globalCallbacks, propertyCallbacks } = this.resolveCollectionCallbacks(collection, targetPath);
@@ -6058,7 +6731,9 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6058
6731
  } catch (callbackError) {
6059
6732
  throw toCallbackError(callbackError, "beforeDelete", targetPath);
6060
6733
  }
6061
- await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
6734
+ const softDelete = hard ? void 0 : resolveSoftDelete(resolvedCollection);
6735
+ if (softDelete) await this.dataService.save(targetPath, { [softDelete.field]: /* @__PURE__ */ new Date() }, row.id, resolvedCollection?.databaseId);
6736
+ else await this.dataService.delete(targetPath, row.id, resolvedCollection?.databaseId);
6062
6737
  try {
6063
6738
  if (globalCallbacks?.afterDelete || callbacks?.afterDelete || propertyCallbacks?.afterDelete) {
6064
6739
  if (globalCallbacks?.afterDelete) await globalCallbacks.afterDelete({
@@ -6086,7 +6761,7 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6086
6761
  } catch (callbackError) {
6087
6762
  throw toCallbackError(callbackError, "afterDelete", targetPath);
6088
6763
  }
6089
- if (this.historyService && resolvedCollection?.history) this.historyService.recordHistory({
6764
+ if (this.historyService && resolvedCollection?.history) await this.historyService.recordHistory({
6090
6765
  tableName: targetPath,
6091
6766
  id: row.id.toString(),
6092
6767
  action: "delete",
@@ -6108,12 +6783,13 @@ var PostgresBackendDriver = class PostgresBackendDriver {
6108
6783
  async checkUniqueField(path, name, value, id, collection) {
6109
6784
  return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
6110
6785
  }
6111
- async count({ path, collection, filter, logical, searchString, vectorSearch }) {
6786
+ async count({ path, collection, filter, logical, searchString, vectorSearch, withDeleted }) {
6112
6787
  return this.dataService.count(path, {
6113
6788
  filter,
6114
6789
  logical,
6115
6790
  searchString,
6116
- vectorSearch
6791
+ vectorSearch,
6792
+ withDeleted
6117
6793
  });
6118
6794
  }
6119
6795
  getTargetDb(databaseName) {
@@ -6365,21 +7041,22 @@ var AuthenticatedPostgresBackendDriver = class {
6365
7041
  return delegate.restFetchService.fetchCollectionForRest(collectionPath, options, include);
6366
7042
  }, { accessMode: "read only" });
6367
7043
  },
6368
- fetchOneForRest: async (collectionPath, id, include, databaseId) => {
7044
+ fetchOneForRest: async (collectionPath, id, include, databaseId, options) => {
6369
7045
  return this.withTransaction(async (delegate) => {
6370
- return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId);
7046
+ return delegate.restFetchService.fetchOneForRest(collectionPath, id, include, databaseId, options);
6371
7047
  }, { accessMode: "read only" });
6372
7048
  },
6373
7049
  aggregate: async (collectionPath, options) => {
6374
7050
  return this.withTransaction(async (delegate) => {
6375
7051
  return delegate.restFetchService.aggregate(collectionPath, options);
6376
7052
  }, { accessMode: "read only" });
6377
- }
7053
+ },
7054
+ cursorFor: (collectionPath, row, orderBy) => this.delegate.restFetchService.cursorFor?.(collectionPath, row, orderBy)
6378
7055
  };
6379
7056
  }
6380
7057
  async withTransaction(operation, options) {
6381
7058
  const pendingNotifications = [];
6382
- const result = await this.delegate.db.transaction(async (tx) => {
7059
+ const result = await withFieldViewer({ roles: this.user?.roles ?? [] }, async () => await this.delegate.db.transaction(async (tx) => {
6383
7060
  let uid = this.user?.uid;
6384
7061
  if (!uid) {
6385
7062
  logger.warn("[DataDriver] User ID (uid) is missing for authenticated delegate. Using 'anonymous'. User object", { detail: this.user });
@@ -6390,7 +7067,8 @@ var AuthenticatedPostgresBackendDriver = class {
6390
7067
  await applyAuthContext(tx, {
6391
7068
  uid,
6392
7069
  roles: userRoles,
6393
- isAnonymous: this.user?.isAnonymous === true
7070
+ isAnonymous: this.user?.isAnonymous === true,
7071
+ claims: this.user?.claims
6394
7072
  }, this.delegate.rlsUserRole);
6395
7073
  const txEntityService = new DataService(tx, this.delegate.registry);
6396
7074
  const txDelegate = new PostgresBackendDriver(tx, this.delegate.realtimeService, this.delegate.registry, this.user, this.delegate.poolManager, this.delegate.historyService);
@@ -6399,7 +7077,7 @@ var AuthenticatedPostgresBackendDriver = class {
6399
7077
  txDelegate._pendingNotifications = pendingNotifications;
6400
7078
  txDelegate.client = this.delegate.client;
6401
7079
  return await operation(txDelegate);
6402
- }, options);
7080
+ }, options));
6403
7081
  for (const notification of pendingNotifications) try {
6404
7082
  await this.delegate.realtimeService.notifyUpdate(notification.path, notification.id, notification.row, notification.databaseId);
6405
7083
  } catch (e) {
@@ -6417,7 +7095,8 @@ var AuthenticatedPostgresBackendDriver = class {
6417
7095
  injectAuthContext(unsubscribe) {
6418
7096
  const authContext = {
6419
7097
  uid: this.user?.uid || "anonymous",
6420
- roles: this.user?.roles ?? []
7098
+ roles: this.user?.roles ?? [],
7099
+ ...this.user?.claims ? { claims: this.user.claims } : {}
6421
7100
  };
6422
7101
  const entries = Array.from(this.delegate.realtimeService.subscriptions.entries());
6423
7102
  const lastSub = entries[entries.length - 1]?.[1];
@@ -6448,6 +7127,33 @@ var AuthenticatedPostgresBackendDriver = class {
6448
7127
  async saveMany(props) {
6449
7128
  return this.withTransaction((delegate) => delegate.saveMany(props));
6450
7129
  }
7130
+ /**
7131
+ * Present for the same reason `saveMany` is, and absent until now.
7132
+ *
7133
+ * Every request that reaches the REST layer is served by *this* class — the
7134
+ * base driver never sees one — and the routes ask `if (!driver.updateMany)`
7135
+ * before doing anything. So `PATCH /api/data/<c>/bulk` and
7136
+ * `POST /api/data/<c>/bulk/delete` answered `BULK_UNSUPPORTED` on Postgres,
7137
+ * the one backend that implements them, for every authenticated caller.
7138
+ * The methods existed one class down and nothing forwarded to them.
7139
+ */
7140
+ async updateMany(props) {
7141
+ return this.withTransaction((delegate) => delegate.updateMany(props));
7142
+ }
7143
+ async deleteMany(props) {
7144
+ return this.withTransaction((delegate) => delegate.deleteMany(props));
7145
+ }
7146
+ /**
7147
+ * One transaction, one RLS context, every collection the batch touches.
7148
+ *
7149
+ * The whole point is that it runs as the caller: a batch that dropped to
7150
+ * the base driver would write across collections with row-level security
7151
+ * switched off, which is the opposite of what a cross-collection write
7152
+ * needs.
7153
+ */
7154
+ async batchWrite(props) {
7155
+ return this.withTransaction((delegate) => delegate.batchWrite(props));
7156
+ }
6451
7157
  async delete(props) {
6452
7158
  return this.withTransaction((delegate) => delegate.delete(props));
6453
7159
  }
@@ -8185,11 +8891,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8185
8891
  orderBy,
8186
8892
  order: request.order,
8187
8893
  limit: boundedLimit,
8188
- offset: request.offset,
8894
+ offset: request.page != null && request.page > 0 ? (request.page - 1) * boundedLimit : request.offset,
8189
8895
  startAfter: request.startAfter,
8190
8896
  databaseId: request.collection?.databaseId,
8191
8897
  searchString: request.searchString,
8192
- searchExplain: request.searchExplain
8898
+ searchExplain: request.searchExplain,
8899
+ include: request.include,
8900
+ fields: request.fields,
8901
+ distinct: request.distinct
8193
8902
  },
8194
8903
  authContext,
8195
8904
  started: 0,
@@ -8198,7 +8907,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8198
8907
  this._subscriptions.set(subscriptionId, subscription);
8199
8908
  const canDeliver = this.beginDelivery(subscriptionId, subscription);
8200
8909
  const rows = await this.fetchCollectionWithAuth(request.path, subscription.collectionRequest, authContext);
8201
- if (canDeliver()) this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
8910
+ const meta = await this.collectionMetaWithAuth(request.path, subscription.collectionRequest, rows, authContext);
8911
+ if (canDeliver()) this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path, meta);
8202
8912
  } catch (error) {
8203
8913
  const sanitized = sanitizeErrorForClient(error, request.path);
8204
8914
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -8361,7 +9071,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8361
9071
  const canDeliver = this.beginDelivery(subscriptionId, subscription);
8362
9072
  try {
8363
9073
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
8364
- if (canDeliver()) this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
9074
+ const meta = await this.collectionMetaWithAuth(notifyPath, subscription.collectionRequest, rows, subscription.authContext);
9075
+ if (canDeliver()) this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath, meta);
8365
9076
  } catch (error) {
8366
9077
  const sanitized = sanitizeErrorForClient(error, notifyPath);
8367
9078
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -8399,24 +9110,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8399
9110
  uid: ANONYMOUS_USER_ID,
8400
9111
  roles: ["anon"]
8401
9112
  };
8402
- return await this.db.transaction(async (tx) => {
9113
+ return await withFieldViewer({ roles: activeAuth.roles ?? [] }, async () => await this.db.transaction(async (tx) => {
8403
9114
  await applyAuthContext(tx, {
8404
9115
  uid: activeAuth.uid,
8405
9116
  roles: activeAuth.roles,
8406
- isAnonymous: activeAuth.isAnonymous === true
9117
+ isAnonymous: activeAuth.isAnonymous === true,
9118
+ claims: activeAuth.claims
8407
9119
  }, this.rlsUserRole);
8408
- const txEntityService = new DataService(tx, this.registry);
8409
- let fetchedEntities;
8410
- if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
8411
- filter: collectionRequest.filter,
8412
- logical: collectionRequest.logical,
8413
- orderBy: collectionRequest.orderBy,
8414
- order: collectionRequest.order,
8415
- limit: collectionRequest.limit,
8416
- databaseId: collectionRequest.databaseId,
8417
- searchExplain: collectionRequest.searchExplain
8418
- });
8419
- else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
9120
+ const fetchedEntities = await new DataService(tx, this.registry).fetchCollectionForRest(notifyPath, {
8420
9121
  filter: collectionRequest.filter,
8421
9122
  logical: collectionRequest.logical,
8422
9123
  orderBy: collectionRequest.orderBy,
@@ -8424,8 +9125,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8424
9125
  limit: collectionRequest.limit,
8425
9126
  offset: collectionRequest.offset,
8426
9127
  startAfter: collectionRequest.startAfter,
8427
- databaseId: collectionRequest.databaseId
8428
- });
9128
+ searchString: collectionRequest.searchString,
9129
+ searchExplain: collectionRequest.searchExplain,
9130
+ databaseId: collectionRequest.databaseId,
9131
+ fields: collectionRequest.fields,
9132
+ distinct: collectionRequest.distinct
9133
+ }, collectionRequest.include);
8429
9134
  const registryCollection = this.registry.getCollectionByPath(notifyPath);
8430
9135
  const resolvedCollection = collection ? {
8431
9136
  ...collection,
@@ -8467,27 +9172,78 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8467
9172
  }));
8468
9173
  }
8469
9174
  return fetchedEntities;
8470
- });
9175
+ }));
8471
9176
  }
8472
- if (collectionRequest.searchString) return await this.dataService.searchRows(notifyPath, collectionRequest.searchString, {
9177
+ return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchCollectionForRest(notifyPath, {
8473
9178
  filter: collectionRequest.filter,
8474
9179
  logical: collectionRequest.logical,
8475
9180
  orderBy: collectionRequest.orderBy,
8476
9181
  order: collectionRequest.order,
8477
9182
  limit: collectionRequest.limit,
9183
+ offset: collectionRequest.offset,
9184
+ startAfter: collectionRequest.startAfter,
9185
+ searchString: collectionRequest.searchString,
9186
+ searchExplain: collectionRequest.searchExplain,
8478
9187
  databaseId: collectionRequest.databaseId,
8479
- searchExplain: collectionRequest.searchExplain
8480
- });
8481
- return await this.dataService.fetchCollection(notifyPath, {
9188
+ fields: collectionRequest.fields,
9189
+ distinct: collectionRequest.distinct
9190
+ }, collectionRequest.include));
9191
+ }
9192
+ /**
9193
+ * The `meta` a `collection_update` frame carries beside its rows.
9194
+ *
9195
+ * Frames used to carry rows and primary keys and nothing else, so the
9196
+ * client issued a `GET /count` **per push** to fill in a total it needed to
9197
+ * render the same list it had just been handed — one extra round trip per
9198
+ * write, per subscriber, forever. The refetch already knows the query; it
9199
+ * counts once, here, under the same RLS transaction that read the rows, so
9200
+ * the total describes the same set they came from.
9201
+ */
9202
+ async collectionMetaWithAuth(notifyPath, collectionRequest, rows, authContext) {
9203
+ const limit = collectionRequest.limit ?? rows.length;
9204
+ const offset = collectionRequest.offset ?? 0;
9205
+ const countOnce = async (service) => service.count(notifyPath, {
8482
9206
  filter: collectionRequest.filter,
8483
9207
  logical: collectionRequest.logical,
8484
- orderBy: collectionRequest.orderBy,
8485
- order: collectionRequest.order,
8486
- limit: collectionRequest.limit,
8487
- offset: collectionRequest.offset,
8488
- startAfter: collectionRequest.startAfter,
9208
+ searchString: collectionRequest.searchString,
8489
9209
  databaseId: collectionRequest.databaseId
8490
9210
  });
9211
+ let total;
9212
+ try {
9213
+ if (this.driver) {
9214
+ const activeAuth = authContext || {
9215
+ uid: ANONYMOUS_USER_ID,
9216
+ roles: ["anon"]
9217
+ };
9218
+ total = await this.db.transaction(async (tx) => {
9219
+ await applyAuthContext(tx, {
9220
+ uid: activeAuth.uid,
9221
+ roles: activeAuth.roles,
9222
+ isAnonymous: activeAuth.isAnonymous === true,
9223
+ claims: activeAuth.claims
9224
+ }, this.rlsUserRole);
9225
+ return countOnce(new DataService(tx, this.registry));
9226
+ });
9227
+ } else total = await countOnce(this.dataService);
9228
+ } catch (error) {
9229
+ logger.warn(`[RealtimeService] Could not count '${notifyPath}' for a subscription frame`, { error });
9230
+ return {
9231
+ limit,
9232
+ offset,
9233
+ hasMore: false,
9234
+ partial: true
9235
+ };
9236
+ }
9237
+ const last = rows[rows.length - 1];
9238
+ const hasMore = offset + rows.length < total;
9239
+ const nextCursor = hasMore && last ? this.dataService.cursorFor?.(notifyPath, last, normalizeDriverOrderBy(collectionRequest.orderBy, collectionRequest.order)) : void 0;
9240
+ return {
9241
+ total,
9242
+ limit,
9243
+ offset,
9244
+ hasMore,
9245
+ ...nextCursor && { nextCursor }
9246
+ };
8491
9247
  }
8492
9248
  /**
8493
9249
  * Debounce an row refetch for a WebSocket subscription.
@@ -8538,13 +9294,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8538
9294
  uid: ANONYMOUS_USER_ID,
8539
9295
  roles: ["anon"]
8540
9296
  };
8541
- return await this.db.transaction(async (tx) => {
9297
+ return await withFieldViewer({ roles: activeAuth.roles ?? [] }, async () => await this.db.transaction(async (tx) => {
8542
9298
  await applyAuthContext(tx, {
8543
9299
  uid: activeAuth.uid,
8544
9300
  roles: activeAuth.roles,
8545
- isAnonymous: activeAuth.isAnonymous === true
9301
+ isAnonymous: activeAuth.isAnonymous === true,
9302
+ claims: activeAuth.claims
8546
9303
  }, this.rlsUserRole);
8547
- let processedEntity = await new DataService(tx, this.registry).fetchOne(notifyPath, id, collection?.databaseId);
9304
+ let processedEntity = await new DataService(tx, this.registry).fetchOneForRest(notifyPath, id, void 0, collection?.databaseId) ?? void 0;
8548
9305
  if (processedEntity) {
8549
9306
  const registryCollection = this.registry.getCollectionByPath(notifyPath);
8550
9307
  const resolvedCollection = collection ? {
@@ -8584,16 +9341,17 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8584
9341
  }
8585
9342
  }
8586
9343
  return processedEntity;
8587
- });
9344
+ }));
8588
9345
  }
8589
- return await this.dataService.fetchOne(notifyPath, id);
9346
+ return await withFieldViewer({ roles: authContext?.roles ?? [] }, async () => await this.dataService.fetchOneForRest(notifyPath, id) ?? void 0);
8590
9347
  }
8591
- sendCollectionUpdate(clientId, subscriptionId, rows, path) {
9348
+ sendCollectionUpdate(clientId, subscriptionId, rows, path, meta) {
8592
9349
  const message = {
8593
9350
  type: "collection_update",
8594
9351
  subscriptionId,
8595
9352
  rows,
8596
- pks: this.primaryKeysForPath(path)
9353
+ pks: this.primaryKeysForPath(path),
9354
+ ...meta && { meta }
8597
9355
  };
8598
9356
  this.sendMessage(clientId, message);
8599
9357
  }
@@ -9750,7 +10508,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
9750
10508
  uid: jwtPayload.uid,
9751
10509
  roles: jwtPayload.roles ?? [],
9752
10510
  isAdmin: (jwtPayload.roles ?? []).some((r) => r === "admin"),
9753
- isAnonymous: jwtPayload.isAnonymous === true
10511
+ isAnonymous: jwtPayload.isAnonymous === true,
10512
+ claims: jwtPayload.claims
9754
10513
  };
9755
10514
  }
9756
10515
  if (verifiedUser) {
@@ -9825,7 +10584,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
9825
10584
  if (!path || !values || typeof values !== "object") return;
9826
10585
  const collection = driver.registry?.getCollectionByPath(path);
9827
10586
  if (!collection) return;
9828
- assertWriteRequestValid(values, collection);
10587
+ assertWriteRequestValid(values, collection, { viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] } });
10588
+ assertFieldOpsValid(values, collection);
9829
10589
  };
9830
10590
  const getScopedDelegate = async () => {
9831
10591
  const session = clientSessions.get(clientId);
@@ -9837,7 +10597,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
9837
10597
  photoURL: null,
9838
10598
  providerId: "websocket",
9839
10599
  isAnonymous: false,
9840
- roles: session.user.roles ?? []
10600
+ roles: session.user.roles ?? [],
10601
+ claims: session.user.claims
9841
10602
  } : {
9842
10603
  uid: ANONYMOUS_USER_ID,
9843
10604
  displayName: null,
@@ -10146,7 +10907,8 @@ function createPostgresWebSocket(server, realtimeService, driver, authConfig, au
10146
10907
  const authContext = session?.user ? {
10147
10908
  uid: session.user.uid,
10148
10909
  roles: session.user.roles ?? [],
10149
- isAnonymous: session.user.isAnonymous === true
10910
+ isAnonymous: session.user.isAnonymous === true,
10911
+ claims: session.user.claims
10150
10912
  } : {
10151
10913
  uid: ANONYMOUS_USER_ID,
10152
10914
  roles: ["anon"],
@@ -10353,7 +11115,7 @@ function createBackupCron(config) {
10353
11115
  enabled: config.enabled ?? true,
10354
11116
  timeoutSeconds: 3600,
10355
11117
  async handler({ log }) {
10356
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-Br3tqwsy.js").then((n) => n.r);
11118
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-DA7a6SUV.js").then((n) => n.r);
10357
11119
  const { destination } = config;
10358
11120
  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 }).`);
10359
11121
  log(`Starting backup of "${dbName}"…`);
@@ -10425,22 +11187,21 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
10425
11187
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
10426
11188
  var asColumns = (value) => Array.isArray(value) ? value : [value];
10427
11189
  /**
10428
- * Distinguish "this column name is wrong" from "the generated schema is old".
11190
+ * Distinguish "this column name is wrong" from "this database was never
11191
+ * migrated".
10429
11192
  *
10430
- * They present identically here — a relation asks for a column the registered
10431
- * table does not have — but they are opposite problems with opposite fixes, and
10432
- * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
10433
- * projects.
11193
+ * They present identically here — a relation asks for a column the table does
11194
+ * not have — but they are opposite problems with opposite fixes, and getting
11195
+ * them the wrong way round is how the 0.12 → 0.13 upgrade bricked projects.
10434
11196
  *
10435
- * The registered table is not the database. It comes from the project's
10436
- * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
10437
- * derives foreign-key names: `categories` yields `category_id` where it used to
10438
- * yield `categorie_id`. Boot-ensure renames the database column to match, so by
10439
- * the time this runs the *database* is correct and the *generated module* is the
10440
- * stale one. Reporting "not a column" then points at the wrong artifact, and the
10441
- * generic fix "set `through.targetColumn` to one of: …", listing the legacy
10442
- * name because that is what the stale module still has — talks the reader into
10443
- * pinning a column that no longer exists.
11197
+ * 0.13 changed the rule that derives foreign-key names: `categories` yields
11198
+ * `category_id` where it used to yield `categorie_id`. Boot-ensure renames the
11199
+ * database column to match but only in a process that provisions. Where
11200
+ * nothing has, the database still carries the old spelling while the config
11201
+ * derives the new one, and the generic fix ("set `through.targetColumn` to one
11202
+ * of: …", listing the legacy name because that is what the table still has)
11203
+ * talks the reader into pinning a column that is about to be renamed out from
11204
+ * under them.
10444
11205
  *
10445
11206
  * So when the wanted name is what the current rule derives, and the table
10446
11207
  * carries what the *previous* rule would have derived from the same source, say
@@ -10468,8 +11229,8 @@ function staleCodegenRename(wanted, available, sources) {
10468
11229
  /** The shared explanation, so every relation kind reports it identically. */
10469
11230
  function staleCodegenDefect(table, { legacy, current }) {
10470
11231
  return {
10471
- problem: `the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this release derives \`${current}\` — the generated schema predates the foreign-key naming fix and no longer describes the database`,
10472
- fix: `regenerate it with \`rebase schema generate\` (or \`pnpm run schema:generate\`). The database column has already been renamed for you at boot, so nothing else is needed. To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
11232
+ problem: `the database still has \`${legacy}\` on \`${table}\`, but this release derives \`${current}\` — the column predates the foreign-key naming fix and nothing has renamed it yet`,
11233
+ fix: `let boot-ensure rename it (redeploy, or start with schema provisioning enabled), or run \`rebase db push\`. Then run \`rebase schema generate\` so the committed schema file agrees. To keep \`${legacy}\` instead, name it explicitly on the relation.`
10473
11234
  };
10474
11235
  }
10475
11236
  /**
@@ -10648,16 +11409,13 @@ function assertRelationsResolve(collections, registry) {
10648
11409
  const defects = findRelationDefects(collections, registry);
10649
11410
  if (defects.length === 0) return;
10650
11411
  const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
10651
- throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against \`backend/src/schema.generated.ts\`.
11412
+ throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database this server is connected to.
10652
11413
 
10653
11414
  Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
10654
11415
 
10655
- If the database was migrated recently an upgrade, a \`db push\`, a restore this file is
10656
- probably older than the schema it describes. Regenerate it before changing anything else:
10657
-
10658
- rebase schema generate
10659
-
10660
- If it is already current, then the collection is what disagrees with it:
11416
+ If the schema has not been applied to this database yet, apply it\`rebase db push\`
11417
+ (dev), \`rebase db migrate\` (prod), or a redeploy, which creates tables at boot. If it
11418
+ has, then the collection is what disagrees with the database:
10661
11419
 
10662
11420
  ` + lines.join("\n\n") + "\n");
10663
11421
  }
@@ -11907,11 +12665,30 @@ var HistoryService = class {
11907
12665
  };
11908
12666
  }
11909
12667
  /**
11910
- * Record a history entry for an row change.
11911
- * This is intentionally fire-and-forget safe — errors are logged but never
11912
- * bubble up to block the main save/delete operation.
12668
+ * Record a history entry for a row change.
12669
+ *
12670
+ * ## Why this is allowed to fail the write
11913
12671
  *
11914
- * After inserting, kicks off a non-blocking pruning pass for this row.
12672
+ * It used to swallow every error into a log line, and the driver called it
12673
+ * without `await`, so the promise was dropped on the floor. Both halves of
12674
+ * that made the same claim: that the audit trail is a nice-to-have. It is
12675
+ * not — `history: true` is opted into by collections whose changes somebody
12676
+ * has to be able to reconstruct, and a trail with gaps is worse than no
12677
+ * trail, because nothing distinguishes "no change was made" from "the entry
12678
+ * did not get written". The gaps were silent and unbounded: a transient
12679
+ * error on the history insert lost the entry for a row that committed
12680
+ * anyway, and nobody found out.
12681
+ *
12682
+ * So it throws, and the driver awaits it inside the write's transaction.
12683
+ * The row and its history entry commit together or neither does. That is a
12684
+ * real trade — a broken `rebase.entity_history` now fails writes to the
12685
+ * collections that declare history, where before it failed quietly — and it
12686
+ * is the right side of it for a table that exists to answer "who changed
12687
+ * this". The table is created at boot (`ensureHistoryTableExists`), so the
12688
+ * failure mode is a database that is broken in other ways too.
12689
+ *
12690
+ * The pruning pass stays non-blocking: a prune that does not run leaves
12691
+ * *extra* history, which is not a loss and is fixed by the next write.
11915
12692
  */
11916
12693
  async recordHistory(params) {
11917
12694
  const { tableName, id, action, values, previousValues, updatedBy } = params;
@@ -11919,7 +12696,7 @@ var HistoryService = class {
11919
12696
  if (action === "update" && (!changedFields || changedFields.length === 0)) return;
11920
12697
  try {
11921
12698
  await this.db.execute(sql`
11922
- INSERT INTO rebase.entity_history
12699
+ INSERT INTO rebase.entity_history
11923
12700
  (table_name, entity_id, action, changed_fields, "values", previous_values, updated_by)
11924
12701
  VALUES (
11925
12702
  ${tableName},
@@ -11931,10 +12708,11 @@ var HistoryService = class {
11931
12708
  ${updatedBy ?? null}
11932
12709
  )
11933
12710
  `);
11934
- this.pruneEntity(tableName, id).catch((err) => logger.error("History prune failed", { error: err }));
11935
12711
  } catch (error) {
11936
12712
  logger.error("Failed to record row history", { error });
12713
+ throw new Error(`Could not record the history entry for "${tableName}" (${String(id)}). The write was rolled back: this collection declares \`history: true\`, and a row that commits without its audit entry leaves a gap nothing can distinguish from "no change was made".`, { cause: error });
11937
12714
  }
12715
+ this.pruneEntity(tableName, id).catch((err) => logger.error("History prune failed", { error: err }));
11938
12716
  }
11939
12717
  /**
11940
12718
  * Fetch history entries for an row, ordered by most recent first.
@@ -12221,6 +12999,12 @@ async function introspectSchema(client, pgSchema) {
12221
12999
  c.udt_name,
12222
13000
  c.is_nullable,
12223
13001
  c.column_default,
13002
+ -- A stored generated column must be declared as one, or drizzle
13003
+ -- writes DEFAULT into it on every INSERT. Postgres tolerates that
13004
+ -- today; the generated module has always declared them properly,
13005
+ -- and the runtime tables are built to match it.
13006
+ c.is_generated,
13007
+ c.generation_expression,
12224
13008
  (SELECT a.atttypmod FROM pg_attribute a
12225
13009
  JOIN pg_class pc ON a.attrelid = pc.oid
12226
13010
  WHERE pc.relname = c.table_name
@@ -12372,18 +13156,40 @@ function buildCollectionsFromSchema({ tablesMap, enumMap, joinTables }, pgSchema
12372
13156
  /**
12373
13157
  * Build drizzle tables at runtime from an introspected schema.
12374
13158
  *
12375
- * A project with declared collections gets its drizzle tables from a generated `schema.generated.ts` that
12376
- * the developer commits. BaaS mode has no such file it points at a database
12377
- * and serves it so the equivalent table objects are constructed here from
12378
- * `information_schema` metadata.
13159
+ * **Every** data source's tables are built here, from `information_schema`,
13160
+ * after boot-ensure has run a project with declared collections included. The
13161
+ * committed `schema.generated.ts` is a build artefact for Atlas, `db push`,
13162
+ * `eject` and user code; the runtime does not read it for its tables, because a
13163
+ * file that is one commit behind the database used to change what the server
13164
+ * served (a renamed foreign-key column 500'd every request, a lost relation
13165
+ * silently emptied a list). See `catalogue-schema.ts` for the caller.
12379
13166
  *
12380
13167
  * These are handed to drizzle as its schema, which keeps the relational query
12381
13168
  * path (`db.query.*`) working; without them FetchService would fall back to
12382
13169
  * plain selects and lose relation loading.
13170
+ *
13171
+ * Two things a caller has to supply for the tables to match what the generated
13172
+ * module used to produce, both about *names*: the Drizzle object is keyed by the
13173
+ * wire name of a column and not by the column (`authorId`, not `author_id`), and
13174
+ * a relation is keyed by the collection's own relation key rather than by a
13175
+ * guess made from the foreign key. Both come from the collections — see
13176
+ * `columnKeysFromCollections` and `buildDrizzleRelationsFromCollections`.
12383
13177
  */
13178
+ var columnNameAsKey = (_table, column) => column;
12384
13179
  /** drizzle ships no bytea builder; binary must round-trip as a Buffer. */
12385
13180
  var bytea = customType({ dataType: () => "bytea" });
12386
13181
  /**
13182
+ * A full-text search column, declared with its real SQL type.
13183
+ *
13184
+ * Not cosmetic: `isSearchIndexColumn` asks the column for its type and drops
13185
+ * every `tsvector` from the projection, so a search column that fell through to
13186
+ * `text` (the old default for an unrecognised type) would be selected, returned
13187
+ * to callers, and rendered in the admin as a wall of lexemes. drizzle ships no
13188
+ * builder for these two.
13189
+ */
13190
+ var tsvector = customType({ dataType: () => "tsvector" });
13191
+ var tsquery = customType({ dataType: () => "tsquery" });
13192
+ /**
12387
13193
  * Postgres stores a column's type modifier (varchar length, vector dimensions)
12388
13194
  * in `atttypmod`. For length-carrying string types it is length + VARHDRSZ(4);
12389
13195
  * for pgvector it is the dimension count as-is. -1 means unspecified.
@@ -12415,8 +13221,11 @@ function scalarBuilder(udtName, name, col) {
12415
13221
  case "date": return date(name);
12416
13222
  case "time": return time(name);
12417
13223
  case "timetz": return time(name, { withTimezone: true });
12418
- case "timestamp": return timestamp(name);
12419
- case "timestamptz": return timestamp(name, { withTimezone: true });
13224
+ case "timestamp": return timestamp(name, { mode: "string" });
13225
+ case "timestamptz": return timestamp(name, {
13226
+ withTimezone: true,
13227
+ mode: "string"
13228
+ });
12420
13229
  case "interval": return interval(name);
12421
13230
  case "bytea": return bytea(name);
12422
13231
  case "inet": return inet(name);
@@ -12438,6 +13247,8 @@ function scalarBuilder(udtName, name, col) {
12438
13247
  const length = varlenLength(col);
12439
13248
  return length ? varchar(name, { length }) : text(name);
12440
13249
  }
13250
+ case "tsvector": return tsvector(name);
13251
+ case "tsquery": return tsquery(name);
12441
13252
  default: return text(name);
12442
13253
  }
12443
13254
  }
@@ -12447,20 +13258,26 @@ function columnBuilderFor(col) {
12447
13258
  }
12448
13259
  /**
12449
13260
  * Build one drizzle table per introspected table, keyed by table name.
13261
+ *
13262
+ * @param columnKey how a column is named on the wire — see {@link ColumnKeyResolver}.
12450
13263
  */
12451
- function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public") {
13264
+ function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public", columnKey = columnNameAsKey) {
12452
13265
  const schema = pgSchemaName === "public" ? null : pgSchema(pgSchemaName);
12453
13266
  const createTable = schema ? schema.table.bind(schema) : pgTable;
12454
13267
  const tables = {};
12455
13268
  for (const [tableName, meta] of tablesMap) {
12456
13269
  const columns = {};
13270
+ const keyOf = /* @__PURE__ */ new Map();
12457
13271
  for (const col of meta.columns) {
12458
13272
  let builder = columnBuilderFor(col);
12459
13273
  if (col.is_nullable === "NO") builder = builder.notNull();
12460
13274
  if (meta.pks.length === 1 && meta.pks[0] === col.column_name) builder = builder.primaryKey();
12461
- columns[col.column_name] = builder;
13275
+ if (col.is_generated === "ALWAYS") builder = builder.generatedAlwaysAs(sql.raw(col.generation_expression ?? ""));
13276
+ const key = columnKey(tableName, col.column_name);
13277
+ keyOf.set(col.column_name, key);
13278
+ columns[key] = builder;
12462
13279
  }
12463
- tables[tableName] = createTable(tableName, columns, meta.pks.length > 1 ? (t) => [primaryKey({ columns: meta.pks.map((pk) => t[pk]) })] : void 0);
13280
+ tables[tableName] = createTable(tableName, columns, meta.pks.length > 1 ? (t) => [primaryKey({ columns: meta.pks.map((pk) => t[keyOf.get(pk) ?? pk]) })] : void 0);
12464
13281
  }
12465
13282
  return tables;
12466
13283
  }
@@ -12475,29 +13292,29 @@ function buildDrizzleTablesFromSchema(tablesMap, pgSchemaName = "public") {
12475
13292
  * The keys here must match `buildRelations` in introspect-runtime.ts, which is
12476
13293
  * what names the collection's relation properties.
12477
13294
  */
12478
- function buildDrizzleRelationsFromSchema(tablesMap, tables) {
13295
+ function buildDrizzleRelationsFromSchema(tablesMap, tables, columnKey = columnNameAsKey) {
12479
13296
  /** Owning side, per table: the `one()` relations from its foreign keys. */
12480
13297
  const owning = /* @__PURE__ */ new Map();
12481
13298
  /** Inverse side, per referenced table: the matching `many()` back-references. */
12482
13299
  const inverse = /* @__PURE__ */ new Map();
12483
13300
  for (const [tableName, meta] of tablesMap) {
12484
13301
  if (!tables[tableName]) continue;
12485
- const columnNames = new Set(meta.columns.map((c) => c.column_name));
13302
+ const columnNames = new Set(meta.columns.map((c) => columnKey(tableName, c.column_name)));
12486
13303
  for (const fk of meta.fks) {
12487
13304
  if (!tables[fk.foreign_table_name]) continue;
12488
- let key = fk.column_name.replace(/_id$/, "");
13305
+ let key = toWireKey(fk.column_name.replace(/_id$/, ""));
12489
13306
  if (meta.pks.includes(fk.column_name) && key === fk.column_name) key = fk.foreign_table_name;
12490
13307
  if (columnNames.has(key)) continue;
12491
13308
  const relationName = `${tableName}_${fk.column_name}`;
12492
13309
  owning.set(tableName, [...owning.get(tableName) ?? [], {
12493
13310
  key,
12494
13311
  targetTable: fk.foreign_table_name,
12495
- fkColumn: fk.column_name,
12496
- targetColumn: fk.foreign_column_name,
13312
+ fkColumn: columnKey(tableName, fk.column_name),
13313
+ targetColumn: columnKey(fk.foreign_table_name, fk.foreign_column_name),
12497
13314
  relationName
12498
13315
  }]);
12499
13316
  const backKey = tableName;
12500
- if (new Set((tablesMap.get(fk.foreign_table_name)?.columns ?? []).map((c) => c.column_name)).has(backKey)) continue;
13317
+ if (new Set((tablesMap.get(fk.foreign_table_name)?.columns ?? []).map((c) => columnKey(fk.foreign_table_name, c.column_name))).has(backKey)) continue;
12501
13318
  inverse.set(fk.foreign_table_name, [...inverse.get(fk.foreign_table_name) ?? [], {
12502
13319
  key: backKey,
12503
13320
  sourceTable: tableName,
@@ -12528,6 +13345,584 @@ function buildDrizzleRelationsFromSchema(tablesMap, tables) {
12528
13345
  return built;
12529
13346
  }
12530
13347
  //#endregion
13348
+ //#region src/schema/config-relations.ts
13349
+ /**
13350
+ * Build drizzle `relations()` for declared collections, from the collections.
13351
+ *
13352
+ * The heuristic builder in `dynamic-tables.ts` names a relation after the
13353
+ * foreign-key column (`author_id` → `author`), which is the only evidence a
13354
+ * database offers and is right for BaaS mode. It is wrong for a project that
13355
+ * declares its collections: there the relation's name is whatever the author
13356
+ * called it, `include=writer` addresses that name, and a relation whose key was
13357
+ * guessed from the column simply does not answer to it — the read returns the
13358
+ * bare foreign key and no target, silently, which is how a demo lost every
13359
+ * relation in its list view while the detail view (a different code path) kept
13360
+ * working.
13361
+ *
13362
+ * So when there are collections, they are the authority for *which* relations
13363
+ * exist and what they are called; the live catalogue is the authority for the
13364
+ * columns underneath. This is the same pairing `generate-drizzle-schema-logic`
13365
+ * emits into `schema.generated.ts` — kind for kind, key for key, and paired by
13366
+ * the same {@link sharedRelationName} — only built against tables read back from
13367
+ * `information_schema` rather than against a file.
13368
+ */
13369
+ /** A collection's table, with any schema prefix stripped — as the DDL creates it. */
13370
+ function bareTableName(name) {
13371
+ return name.includes(".") ? name.split(".").pop() : name;
13372
+ }
13373
+ /**
13374
+ * The field key a collection's rows are addressed by, as the generated module
13375
+ * spells it: the property marked `isId`, else `id`.
13376
+ *
13377
+ * Deliberately the config's answer rather than the catalogue's primary key —
13378
+ * `references: [users.id]` in the generated file is keyed the same way, and the
13379
+ * two have to name the same Drizzle key for a relation to compile at all.
13380
+ */
13381
+ function primaryKeyFieldKey(collection) {
13382
+ for (const [key, prop] of Object.entries(collection.properties ?? {})) if (prop && typeof prop === "object" && "isId" in prop && Boolean(prop.isId)) return key;
13383
+ return "id";
13384
+ }
13385
+ /**
13386
+ * A column of a built table, by its Drizzle key or by its column name.
13387
+ *
13388
+ * The key is the answer in the ordinary case, and the name is the fallback for
13389
+ * the one place a caller holds a column and not a field: a junction table's two
13390
+ * foreign keys, which are keyed by column *unless* the project also declares a
13391
+ * collection over that table, in which case they are keyed by its property
13392
+ * names. Looking both up costs a scan of one table and removes the difference.
13393
+ */
13394
+ function columnOf(table, key) {
13395
+ if (!table) return void 0;
13396
+ const direct = table[key];
13397
+ if (direct) return direct;
13398
+ for (const column of Object.values(getTableColumns(table))) if (column?.name === key) return column;
13399
+ }
13400
+ /**
13401
+ * Drizzle relations for every declared collection, keyed `<table>Relations`.
13402
+ *
13403
+ * @param collections every collection backed by a table in `tables`
13404
+ * @param tables catalogue-built tables, keyed by bare table name
13405
+ */
13406
+ function buildDrizzleRelationsFromCollections(collections, tables) {
13407
+ const ones = /* @__PURE__ */ new Map();
13408
+ const manys = /* @__PURE__ */ new Map();
13409
+ /** `<relationName>::<kind>` already emitted on a table — the generator dedupes the same way. */
13410
+ const emitted = /* @__PURE__ */ new Map();
13411
+ const claim = (table, name, kind) => {
13412
+ const seen = emitted.get(table) ?? /* @__PURE__ */ new Set();
13413
+ emitted.set(table, seen);
13414
+ const key = `${name}::${kind}`;
13415
+ if (seen.has(key)) return false;
13416
+ seen.add(key);
13417
+ return true;
13418
+ };
13419
+ const addOne = (table, spec) => ones.set(table, [...ones.get(table) ?? [], spec]);
13420
+ const addMany = (table, spec) => manys.set(table, [...manys.get(table) ?? [], spec]);
13421
+ const byTable = /* @__PURE__ */ new Map();
13422
+ for (const collection of collections) {
13423
+ const table = bareTableName(getTableName$1(collection));
13424
+ if (table && tables[table]) byTable.set(table, collection);
13425
+ }
13426
+ for (const [tableName, collection] of byTable) {
13427
+ const table = tables[tableName];
13428
+ for (const [relationKey, relation] of Object.entries(resolveCollectionRelations(collection))) {
13429
+ let target;
13430
+ try {
13431
+ target = relation.target();
13432
+ } catch (err) {
13433
+ logger.debug(`[schema] Relation ${collection.slug}.${relationKey} has no resolvable target`, { error: err });
13434
+ continue;
13435
+ }
13436
+ const targetTable = tables[bareTableName(getTableName$1(target))];
13437
+ const name = sharedRelationName(relation, collection);
13438
+ switch (relation.kind) {
13439
+ case "belongsTo": {
13440
+ if (!targetTable) continue;
13441
+ if (!claim(tableName, name, relation.kind)) continue;
13442
+ const localKey = fieldKeyForColumn(collection, relation.localKey);
13443
+ const referenced = primaryKeyFieldKey(target);
13444
+ const field = columnOf(table, localKey);
13445
+ const reference = columnOf(targetTable, referenced);
13446
+ if (!field || !reference) continue;
13447
+ addOne(tableName, {
13448
+ key: relationKey,
13449
+ target: targetTable,
13450
+ fields: [field],
13451
+ references: [reference],
13452
+ relationName: name
13453
+ });
13454
+ break;
13455
+ }
13456
+ case "hasOne":
13457
+ if (!targetTable) continue;
13458
+ if (!claim(tableName, name, relation.kind)) continue;
13459
+ addOne(tableName, {
13460
+ key: relationKey,
13461
+ target: targetTable,
13462
+ relationName: name
13463
+ });
13464
+ break;
13465
+ case "hasMany":
13466
+ if (!targetTable) continue;
13467
+ if (!claim(tableName, name, relation.kind)) continue;
13468
+ addMany(tableName, {
13469
+ key: relationKey,
13470
+ target: targetTable,
13471
+ relationName: name
13472
+ });
13473
+ break;
13474
+ case "manyToMany": {
13475
+ const junctionName = bareTableName(relation.through.table);
13476
+ const junction = tables[junctionName];
13477
+ if (!junction) continue;
13478
+ if (!claim(tableName, name, relation.kind)) continue;
13479
+ addMany(tableName, {
13480
+ key: relationKey,
13481
+ target: junction,
13482
+ relationName: name
13483
+ });
13484
+ addJunctionSides(relation, collection, target, junctionName, tables, addOne, claim);
13485
+ break;
13486
+ }
13487
+ case "via": break;
13488
+ }
13489
+ }
13490
+ }
13491
+ for (const [tableName, collection] of byTable) {
13492
+ const table = tables[tableName];
13493
+ for (const [, relation] of Object.entries(resolveCollectionRelations(collection))) {
13494
+ if (relation.kind !== "hasMany" && relation.kind !== "hasOne") continue;
13495
+ let target;
13496
+ try {
13497
+ target = relation.target();
13498
+ } catch {
13499
+ continue;
13500
+ }
13501
+ const targetTableName = bareTableName(getTableName$1(target));
13502
+ const targetTable = tables[targetTableName];
13503
+ if (!targetTable) continue;
13504
+ const name = sharedRelationName(relation, collection);
13505
+ if (!claim(targetTableName, name, "belongsTo")) continue;
13506
+ const fkKey = fieldKeyForColumn(target, relation.foreignKeyOnTarget);
13507
+ const referencedKey = relation.sourceKey ? fieldKeyForColumn(collection, relation.sourceKey) : primaryKeyFieldKey(collection);
13508
+ const field = columnOf(targetTable, fkKey);
13509
+ const reference = columnOf(table, referencedKey);
13510
+ if (!field || !reference) continue;
13511
+ addOne(targetTableName, {
13512
+ key: `_synth_${targetTableName}_${fkKey}`,
13513
+ target: tables[tableName],
13514
+ fields: [field],
13515
+ references: [reference],
13516
+ relationName: name
13517
+ });
13518
+ }
13519
+ }
13520
+ const built = {};
13521
+ for (const tableName of /* @__PURE__ */ new Set([...ones.keys(), ...manys.keys()])) {
13522
+ const table = tables[tableName];
13523
+ if (!table) continue;
13524
+ const tableOnes = ones.get(tableName) ?? [];
13525
+ const tableManys = manys.get(tableName) ?? [];
13526
+ built[`${tableName}Relations`] = relations(table, ({ one, many }) => {
13527
+ const map = {};
13528
+ for (const spec of tableOnes) map[spec.key] = spec.fields ? one(spec.target, {
13529
+ fields: spec.fields,
13530
+ references: spec.references,
13531
+ relationName: spec.relationName
13532
+ }) : one(spec.target, { relationName: spec.relationName });
13533
+ for (const spec of tableManys) {
13534
+ if (map[spec.key]) continue;
13535
+ map[spec.key] = many(spec.target, { relationName: spec.relationName });
13536
+ }
13537
+ return map;
13538
+ });
13539
+ }
13540
+ return built;
13541
+ }
13542
+ /**
13543
+ * The junction table's own two `one()` sides.
13544
+ *
13545
+ * A many-to-many is a `many(junction)` on each endpoint, so the junction has to
13546
+ * carry the matching `one()` back to each — without them drizzle can join a
13547
+ * post to its `posts_tags` rows and no further, and the tags come back as
13548
+ * junction rows. Keyed by the junction's own columns, which is how the
13549
+ * generated file keys them too.
13550
+ */
13551
+ function addJunctionSides(relation, sourceCollection, targetCollection, junctionName, tables, addOne, claim) {
13552
+ const junction = tables[junctionName];
13553
+ const sourceTable = tables[bareTableName(getTableName$1(sourceCollection))];
13554
+ const targetTable = tables[bareTableName(getTableName$1(targetCollection))];
13555
+ if (!junction || !sourceTable || !targetTable) return;
13556
+ const sourceName = sharedRelationName(relation, sourceCollection);
13557
+ const inverse = Object.values(resolveCollectionRelations(targetCollection)).find((r) => r.kind === "manyToMany" && bareTableName(r.through.table) === junctionName);
13558
+ const targetName = inverse ? sharedRelationName(inverse, targetCollection) : `${junctionName}_${relation.through.targetColumn}`;
13559
+ const pairs = [[
13560
+ relation.through.sourceColumn,
13561
+ sourceTable,
13562
+ sourceName,
13563
+ sourceCollection,
13564
+ "source"
13565
+ ], [
13566
+ relation.through.targetColumn,
13567
+ targetTable,
13568
+ targetName,
13569
+ targetCollection,
13570
+ "target"
13571
+ ]];
13572
+ for (const [column, endpointTable, relationName, endpointCollection] of pairs) {
13573
+ if (!claim(junctionName, relationName, "junction")) continue;
13574
+ const field = columnOf(junction, column);
13575
+ const reference = columnOf(endpointTable, primaryKeyFieldKey(endpointCollection));
13576
+ if (!field || !reference) continue;
13577
+ addOne(junctionName, {
13578
+ key: column,
13579
+ target: endpointTable,
13580
+ fields: [field],
13581
+ references: [reference],
13582
+ relationName
13583
+ });
13584
+ }
13585
+ }
13586
+ //#endregion
13587
+ //#region src/schema/catalogue-schema.ts
13588
+ /** The Postgres schema a collection's table lives in. */
13589
+ function schemaOfCollection(collection, fallback) {
13590
+ const declared = collection.schema;
13591
+ return typeof declared === "string" && declared.length > 0 ? declared : fallback;
13592
+ }
13593
+ /**
13594
+ * How a column of each table is keyed in the Drizzle object.
13595
+ *
13596
+ * The wire name, which is the property key — `authorId`, never `author_id` —
13597
+ * because that is what every read and write path in this driver indexes tables
13598
+ * and rows by, and what the generated module has always emitted. Getting this
13599
+ * wrong does not throw: drizzle leaves an unknown key out of the statement and
13600
+ * the write reports success having stored nothing.
13601
+ *
13602
+ * Two exceptions, both columns no property declares:
13603
+ *
13604
+ * - the generated search columns keep their raw names, because `search_vector`
13605
+ * is how `searchColumnNames` excludes them from responses and how the
13606
+ * condition builder finds the one to match against;
13607
+ * - a table no collection claims (a junction) is keyed by its **key** columns'
13608
+ * names, which is also what the generated file does for them — but a
13609
+ * junction that declares `through.properties` keys those payload columns by
13610
+ * their property key, for the same reason a collection does. `joinedAt` with
13611
+ * `columnName: "joined_at"` has to be one name on both sides or a `_pivot`
13612
+ * write is silently dropped by drizzle and the read serves a key the caller
13613
+ * never declared.
13614
+ */
13615
+ function columnKeysFromCollections(collections) {
13616
+ const byTable = /* @__PURE__ */ new Map();
13617
+ const rawColumns = /* @__PURE__ */ new Map();
13618
+ for (const collection of collections) {
13619
+ const table = bareTableName(getTableName$1(collection));
13620
+ if (!table) continue;
13621
+ byTable.set(table, collection);
13622
+ rawColumns.set(table, new Set(searchColumnNames(collection)));
13623
+ }
13624
+ const junctionByTable = /* @__PURE__ */ new Map();
13625
+ for (const spec of resolveJunctionSpecs(collections).values()) {
13626
+ if (Object.keys(spec.properties).length === 0) continue;
13627
+ junctionByTable.set(bareTableName(spec.table), getJunctionCollectionConfig(spec));
13628
+ }
13629
+ return (tableName, columnName) => {
13630
+ const collection = byTable.get(tableName);
13631
+ if (!collection) {
13632
+ const junction = junctionByTable.get(tableName);
13633
+ return junction ? fieldKeyForColumn(junction, columnName) : columnName;
13634
+ }
13635
+ if (rawColumns.get(tableName)?.has(columnName)) return columnName;
13636
+ return fieldKeyForColumn(collection, columnName);
13637
+ };
13638
+ }
13639
+ /**
13640
+ * Read the tables this data source serves out of `information_schema`.
13641
+ *
13642
+ * Restricted to the collections handed in, because the driver is given every
13643
+ * collection the project declares while each database holds only its own: an
13644
+ * unfiltered read had a second source (`database("analytics")`) reporting the
13645
+ * default's tables as its own. Junction tables come too — they back no
13646
+ * collection and a many-to-many is unreadable without them.
13647
+ *
13648
+ * Every schema the collections name is read, not just `public`. The users
13649
+ * collection lives in `rebase`, and a run restricted to `public` would leave the
13650
+ * registry with no table for it at all.
13651
+ */
13652
+ async function readCatalogueSchema(options) {
13653
+ const { client, collections, defaultSchema } = options;
13654
+ /** schema → the tables wanted from it. */
13655
+ const wanted = /* @__PURE__ */ new Map();
13656
+ const want = (schema, table) => {
13657
+ const set = wanted.get(schema) ?? /* @__PURE__ */ new Set();
13658
+ wanted.set(schema, set);
13659
+ set.add(table);
13660
+ };
13661
+ for (const collection of collections) {
13662
+ const table = bareTableName(getTableName$1(collection));
13663
+ if (table) want(schemaOfCollection(collection, defaultSchema), table);
13664
+ }
13665
+ for (const spec of resolveJunctionSpecs(collections).values()) want(spec.schema, bareTableName(spec.table));
13666
+ const columnKey = columnKeysFromCollections(collections);
13667
+ const tables = {};
13668
+ const meta = /* @__PURE__ */ new Map();
13669
+ const missing = [];
13670
+ for (const [schema, names] of wanted) {
13671
+ const live = await introspectSchema(client, schema);
13672
+ const present = /* @__PURE__ */ new Map();
13673
+ for (const name of names) {
13674
+ const found = live.tablesMap.get(name);
13675
+ if (!found) {
13676
+ missing.push(schema === defaultSchema ? name : `${schema}.${name}`);
13677
+ continue;
13678
+ }
13679
+ if (tables[name]) continue;
13680
+ present.set(name, found);
13681
+ meta.set(name, found);
13682
+ }
13683
+ Object.assign(tables, buildDrizzleTablesFromSchema(present, schema, columnKey));
13684
+ }
13685
+ return {
13686
+ tables,
13687
+ relations: buildDrizzleRelationsFromCollections(collections, tables),
13688
+ meta,
13689
+ missing
13690
+ };
13691
+ }
13692
+ /**
13693
+ * The BaaS variant: no collections are declared, so every table in one schema is
13694
+ * served and both the collections and the relations are derived from the
13695
+ * foreign keys.
13696
+ *
13697
+ * Kept apart from {@link readCatalogueSchema} because the two answer different
13698
+ * questions. Here the database is the entire specification; there it supplies
13699
+ * the columns for a specification the project already wrote down.
13700
+ */
13701
+ function buildBaasSchema(tablesMap, pgSchemaName, collections) {
13702
+ const columnKey = columnKeysFromCollections(collections);
13703
+ const tables = buildDrizzleTablesFromSchema(tablesMap, pgSchemaName, columnKey);
13704
+ return {
13705
+ tables,
13706
+ relations: buildDrizzleRelationsFromSchema(tablesMap, tables, columnKey)
13707
+ };
13708
+ }
13709
+ //#endregion
13710
+ //#region src/schema/generated-schema-diff.ts
13711
+ /**
13712
+ * Compare the committed `schema.generated.ts` against the live catalogue, and
13713
+ * say so — once per difference, at boot, as a warning and nothing more.
13714
+ *
13715
+ * The runtime stopped reading that file for its tables (see
13716
+ * `catalogue-schema.ts`), which removes an entire class of production failure
13717
+ * and creates a smaller one: a stale file is now *invisible*. It is still real —
13718
+ * it is what Atlas plans migrations from, what `db push` diffs, what `eject`
13719
+ * writes out and what user code imports — so a project whose file no longer
13720
+ * describes its database will hit it later, in a tool, with no clue that boot
13721
+ * already knew.
13722
+ *
13723
+ * Hence this: a guard rather than a doc. It never fails boot and never changes
13724
+ * what is served — the previous arrangement, where the file could do both,
13725
+ * is exactly what was removed.
13726
+ *
13727
+ * `generated-schema-staleness.ts` answers a narrower question (does the file
13728
+ * name every table the collections do) from the file's *source text*. This one
13729
+ * compares the loaded module against the database, column by column.
13730
+ */
13731
+ /**
13732
+ * The families a boot warning is worth raising over.
13733
+ *
13734
+ * Deliberately coarse. `varchar(120)` and `text` are the same family because the
13735
+ * runtime treats them identically and a warning about the difference would fire
13736
+ * on every boot of a correct project — the widths still reach Postgres, which
13737
+ * enforces them. What this is looking for is the kind of drift that changes what
13738
+ * a value *is*: a number that became text, a timestamp that became a date, a
13739
+ * column that stopped existing.
13740
+ */
13741
+ function typeFamily(sqlType) {
13742
+ if (!sqlType) return "unknown";
13743
+ let type = sqlType.trim().toLowerCase();
13744
+ const isArray = type.endsWith("[]") || type.startsWith("_");
13745
+ type = type.replace(/\[\]$/, "").replace(/^_/, "");
13746
+ type = type.replace(/\s*\([^)]*\)\s*$/, "").trim();
13747
+ const family = FAMILIES[type] ?? type;
13748
+ return isArray ? `${family}[]` : family;
13749
+ }
13750
+ var FAMILIES = {
13751
+ text: "text",
13752
+ varchar: "text",
13753
+ "character varying": "text",
13754
+ bpchar: "text",
13755
+ char: "text",
13756
+ character: "text",
13757
+ citext: "text",
13758
+ name: "text",
13759
+ int2: "integer",
13760
+ smallint: "integer",
13761
+ smallserial: "integer",
13762
+ int4: "integer",
13763
+ int: "integer",
13764
+ integer: "integer",
13765
+ serial: "integer",
13766
+ int8: "bigint",
13767
+ bigint: "bigint",
13768
+ bigserial: "bigint",
13769
+ float4: "float",
13770
+ real: "float",
13771
+ float8: "float",
13772
+ "double precision": "float",
13773
+ numeric: "numeric",
13774
+ decimal: "numeric",
13775
+ money: "numeric",
13776
+ bool: "boolean",
13777
+ boolean: "boolean",
13778
+ uuid: "uuid",
13779
+ json: "json",
13780
+ jsonb: "jsonb",
13781
+ date: "date",
13782
+ time: "time",
13783
+ "time without time zone": "time",
13784
+ timetz: "time",
13785
+ "time with time zone": "time",
13786
+ timestamp: "timestamp",
13787
+ "timestamp without time zone": "timestamp",
13788
+ timestamptz: "timestamptz",
13789
+ "timestamp with time zone": "timestamptz",
13790
+ interval: "interval",
13791
+ bytea: "bytea",
13792
+ tsvector: "tsvector",
13793
+ tsquery: "tsquery",
13794
+ vector: "vector"
13795
+ };
13796
+ function catalogueColumns(meta) {
13797
+ const columns = /* @__PURE__ */ new Map();
13798
+ for (const col of meta.columns) columns.set(col.column_name, {
13799
+ name: col.column_name,
13800
+ family: typeFamily(col.udt_name || col.data_type),
13801
+ notNull: col.is_nullable === "NO"
13802
+ });
13803
+ return columns;
13804
+ }
13805
+ /**
13806
+ * Every way the generated module and the database disagree, for the tables both
13807
+ * describe.
13808
+ *
13809
+ * One-directional in one respect only: a table the module has and the database
13810
+ * does not is reported (that is the stale-file case), while a table the database
13811
+ * has and the module does not is left alone — a database may hold anything, and
13812
+ * the collections-vs-file question is `generated-schema-staleness`'s.
13813
+ */
13814
+ function diffGeneratedSchemaAgainstCatalogue(options) {
13815
+ const { generated, catalogue, collections } = options;
13816
+ if (!generated) return [];
13817
+ const collectionByTable = /* @__PURE__ */ new Map();
13818
+ for (const collection of collections) {
13819
+ const table = getTableName$1(collection);
13820
+ if (table) collectionByTable.set(bareTableName(table), collection);
13821
+ }
13822
+ const differences = [];
13823
+ for (const value of Object.values(generated)) {
13824
+ if (!isTable(value)) continue;
13825
+ const table = getTableName(value);
13826
+ const collection = collectionByTable.get(table);
13827
+ const at = {
13828
+ table,
13829
+ collection: collection?.slug
13830
+ };
13831
+ const meta = catalogue.get(table);
13832
+ if (!meta) {
13833
+ if (collection) differences.push({
13834
+ ...at,
13835
+ kind: "missing-table"
13836
+ });
13837
+ continue;
13838
+ }
13839
+ const live = catalogueColumns(meta);
13840
+ const seen = /* @__PURE__ */ new Set();
13841
+ const authOwned = isAuthCollection(collection) ? new Set(AUTH_USERS_COLUMNS.map((c) => c.column)) : void 0;
13842
+ for (const [key, column] of Object.entries(getTableColumns(value))) {
13843
+ const name = column.name ?? key;
13844
+ seen.add(name);
13845
+ const property = collection ? fieldKeyForColumn(collection, name) : key;
13846
+ const generatedFamily = typeFamily(column.getSQLType?.());
13847
+ const generatedNotNull = Boolean(column.notNull);
13848
+ const found = live.get(name);
13849
+ if (!found) {
13850
+ differences.push({
13851
+ ...at,
13852
+ kind: "missing-column",
13853
+ column: name,
13854
+ property,
13855
+ generated: generatedFamily
13856
+ });
13857
+ continue;
13858
+ }
13859
+ if (found.family !== generatedFamily) differences.push({
13860
+ ...at,
13861
+ kind: "type",
13862
+ column: name,
13863
+ property,
13864
+ generated: generatedFamily,
13865
+ live: found.family
13866
+ });
13867
+ if (found.notNull !== generatedNotNull && !authOwned?.has(name)) differences.push({
13868
+ ...at,
13869
+ kind: "nullability",
13870
+ column: name,
13871
+ property,
13872
+ generated: generatedNotNull ? "NOT NULL" : "nullable",
13873
+ live: found.notNull ? "NOT NULL" : "nullable"
13874
+ });
13875
+ }
13876
+ for (const [name, column] of live) {
13877
+ if (seen.has(name)) continue;
13878
+ if (authOwned?.has(name)) continue;
13879
+ differences.push({
13880
+ ...at,
13881
+ kind: "extra-column",
13882
+ column: name,
13883
+ property: collection ? fieldKeyForColumn(collection, name) : name,
13884
+ live: column.family
13885
+ });
13886
+ }
13887
+ }
13888
+ return differences;
13889
+ }
13890
+ /** One line, in the terms the reader has: collection, property, column. */
13891
+ function describeSchemaDifference(difference) {
13892
+ const where = difference.collection ? `${difference.collection} (table "${difference.table}")` : `table "${difference.table}"`;
13893
+ const field = difference.column ? `${difference.property && difference.property !== difference.column ? `${difference.property} → "${difference.column}"` : `"${difference.column}"`}` : void 0;
13894
+ switch (difference.kind) {
13895
+ case "missing-table": return `${where} is in schema.generated.ts but not in the database`;
13896
+ case "missing-column": return `${where}: ${field} is in schema.generated.ts but not in the database`;
13897
+ case "extra-column": return `${where}: ${field} is in the database but not in schema.generated.ts (${difference.live})`;
13898
+ case "type": return `${where}: ${field} is ${difference.generated} in schema.generated.ts and ${difference.live} in the database`;
13899
+ case "nullability": return `${where}: ${field} is ${difference.generated} in schema.generated.ts and ${difference.live} in the database`;
13900
+ }
13901
+ }
13902
+ /**
13903
+ * Report the differences, one warning each, and never throw.
13904
+ *
13905
+ * Per difference rather than one summary block on purpose: these are read out of
13906
+ * a log aggregator as often as a terminal, and a single multi-line message
13907
+ * cannot be filtered down to the one collection somebody is looking at.
13908
+ */
13909
+ function warnOnGeneratedSchemaDrift(differences) {
13910
+ if (differences.length === 0) return;
13911
+ logger.warn(`📄 [schema] schema.generated.ts no longer describes the database (${differences.length} difference${differences.length === 1 ? "" : "s"}). The server serves the database, so nothing here is broken — but Atlas, \`rebase db push\`, \`rebase eject\` and any code importing that file are all reading a stale description. Run \`rebase schema generate\`.`);
13912
+ for (const difference of differences) logger.warn(` • ${describeSchemaDifference(difference)}`, {
13913
+ schemaDrift: {
13914
+ kind: difference.kind,
13915
+ table: difference.table,
13916
+ collection: difference.collection,
13917
+ column: difference.column,
13918
+ property: difference.property,
13919
+ generated: difference.generated,
13920
+ live: difference.live
13921
+ },
13922
+ fix: "rebase schema generate"
13923
+ });
13924
+ }
13925
+ //#endregion
12531
13926
  //#region src/PostgresBootstrapper.ts
12532
13927
  /**
12533
13928
  * PostgresBootstrapper
@@ -12756,7 +14151,7 @@ function createPostgresBootstrapper(pgConfig) {
12756
14151
  * handful of `CREATE OR REPLACE FUNCTION`.
12757
14152
  */
12758
14153
  const provisionRlsRuntime = async (queryable) => {
12759
- const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-DLDnUjtx.js").then((n) => n.i);
14154
+ const { RLS_BOOTSTRAP_STATEMENTS } = await import("./rls-bootstrap-sql-BlzsOUtz.js").then((n) => n.i);
12760
14155
  await queryable.query("SELECT pg_advisory_lock(hashtext('rebase_auth_functions_init'))");
12761
14156
  try {
12762
14157
  for (const statement of RLS_BOOTSTRAP_STATEMENTS) await queryable.query(statement);
@@ -12789,33 +14184,49 @@ function createPostgresBootstrapper(pgConfig) {
12789
14184
  }
12790
14185
  if (policyless.length > 0) logger.warn(`🔒 [rls] ${policyless.length} table(s) have RLS enabled but no policies, so they will return no rows: ${policyless.join(", ")}`);
12791
14186
  introspectedCollections = buildCollectionsFromSchema(schema, pgSchemaName);
12792
- introspectedTables = buildDrizzleTablesFromSchema(schema.tablesMap, pgSchemaName);
12793
- introspectedRelations = buildDrizzleRelationsFromSchema(schema.tablesMap, introspectedTables);
14187
+ ({tables: introspectedTables, relations: introspectedRelations} = buildBaasSchema(schema.tablesMap, pgSchemaName, introspectedCollections));
12794
14188
  logger.info(`🔍 [PostgresRegistry] BaaS mode: derived ${introspectedCollections.length} collections from schema "${pgSchemaName}" [${introspectedCollections.map((c) => c.slug).join(", ")}]`);
12795
14189
  }
12796
14190
  let ownTables;
12797
14191
  let ownRelations;
14192
+ let ownCatalogue;
14193
+ let ownCollections = [];
12798
14194
  const sourceKey = config.dataSourceKey ?? DEFAULT_DATA_SOURCE_KEY;
12799
- if (!introspectedCollections && !pgConfig.schema?.tables && collections && collections.length > 0) {
12800
- const own = collections.filter((c) => c.dataSource === sourceKey || !c.dataSource && sourceKey === DEFAULT_DATA_SOURCE_KEY);
12801
- if (own.length > 0) {
12802
- const pgSchemaName = pgConfig.introspectionSchema ?? "public";
12803
- const live = await introspectSchema(rawClient, pgSchemaName);
12804
- const wanted = new Set(own.map((c) => getTableName$1(c)));
12805
- for (const table of [...live.tablesMap.keys()]) if (!wanted.has(table) && !live.joinTables.has(table)) live.tablesMap.delete(table);
12806
- ownTables = buildDrizzleTablesFromSchema(live.tablesMap, pgSchemaName);
12807
- ownRelations = buildDrizzleRelationsFromSchema(live.tablesMap, ownTables);
12808
- const missing = [...wanted].filter((t) => !live.tablesMap.has(t));
12809
- if (missing.length > 0) logger.warn(`[PostgresRegistry] Data source "${sourceKey}" has no table yet for: ${missing.join(", ")}. Their collections will answer "table not found" until the schema is provisioned.`);
14195
+ if (!introspectedCollections && collections && collections.length > 0) {
14196
+ ownCollections = collections.filter((c) => c.dataSource === sourceKey || !c.dataSource && sourceKey === DEFAULT_DATA_SOURCE_KEY);
14197
+ if (ownCollections.length > 0) {
14198
+ try {
14199
+ await rawClient.query("SELECT 1");
14200
+ } catch (err) {
14201
+ const fatal = diagnoseConnectFailure(err, pgConfig.connectionString);
14202
+ if (fatal) throw fatal;
14203
+ }
14204
+ const catalogue = await readCatalogueSchema({
14205
+ client: rawClient,
14206
+ collections: ownCollections,
14207
+ defaultSchema: pgConfig.introspectionSchema ?? "public"
14208
+ });
14209
+ ownTables = catalogue.tables;
14210
+ ownRelations = catalogue.relations;
14211
+ ownCatalogue = catalogue.meta;
14212
+ if (catalogue.missing.length > 0) logger.warn(`[PostgresRegistry] Data source "${sourceKey}" has no table yet for: ${catalogue.missing.join(", ")}. Their collections will answer "table not found" until the schema is provisioned.`);
12810
14213
  }
12811
14214
  }
12812
14215
  const activeCollections = introspectedCollections ?? collections;
12813
- const schemaTables = introspectedTables ?? pgConfig.schema?.tables ?? ownTables;
12814
- const schemaRelations = introspectedRelations ?? pgConfig.schema?.relations ?? ownRelations;
14216
+ const schemaTables = introspectedTables ?? ownTables;
14217
+ const schemaRelations = introspectedRelations ?? ownRelations;
14218
+ if (ownCatalogue) try {
14219
+ warnOnGeneratedSchemaDrift(diffGeneratedSchemaAgainstCatalogue({
14220
+ generated: pgConfig.schema?.tables,
14221
+ catalogue: ownCatalogue,
14222
+ collections: ownCollections
14223
+ }));
14224
+ } catch (err) {
14225
+ logger.debug("[schema] Could not compare schema.generated.ts against the database", { error: err });
14226
+ }
12815
14227
  const registry = buildCollectionRegistry({
12816
14228
  collections: activeCollections,
12817
14229
  tables: schemaTables,
12818
- enums: pgConfig.schema?.enums,
12819
14230
  relations: schemaRelations
12820
14231
  });
12821
14232
  if (schemaTables) {
@@ -13127,7 +14538,7 @@ function createPostgresBootstrapper(pgConfig) {
13127
14538
  * proved it can bootstrap with.
13128
14539
  */
13129
14540
  async ensureCollectionSchema(collections, driverResult, log) {
13130
- const { ensureCollectionTables } = await import("./ensure-collection-tables-D4wJzM4y.js").then((n) => n.n);
14541
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-Bkvehxdm.js").then((n) => n.n);
13131
14542
  const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
13132
14543
  for (const failure of plan.failures) logger.warn(describeEnsureFailure(failure));
13133
14544
  return { applied: plan.actions.length - plan.failures.length };
@@ -13161,7 +14572,7 @@ function createPostgresBootstrapper(pgConfig) {
13161
14572
  await provisionRlsRuntime(provisioningQueryable(driverResult));
13162
14573
  },
13163
14574
  async ensureCollectionPolicies(collections, driverResult, log) {
13164
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-2c2NV8zX.js");
14575
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-CHO0moXQ.js");
13165
14576
  const queryable = provisioningQueryable(driverResult);
13166
14577
  const { rows } = await queryable.query("SELECT to_regprocedure('rebase.roles()') IS NOT NULL AS present");
13167
14578
  if (!rows[0]?.present) await provisionRlsRuntime(queryable);
@@ -13172,7 +14583,7 @@ function createPostgresBootstrapper(pgConfig) {
13172
14583
  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.`);
13173
14584
  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.`);
13174
14585
  try {
13175
- const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-DLDnUjtx.js").then((n) => n.i);
14586
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-BlzsOUtz.js").then((n) => n.i);
13176
14587
  await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
13177
14588
  info: (m) => logger.info(m),
13178
14589
  warn: (m) => logger.warn(m)
@@ -13296,6 +14707,6 @@ function createPostgresAdapter(pgConfig) {
13296
14707
  };
13297
14708
  }
13298
14709
  //#endregion
13299
- export { ADMIN_ONLY_TYPES, AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, BranchingUnsupportedError, CHANNEL_BUS_NOTIFY_CHANNEL, CONNECTION_OWNER, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PUBLIC_TYPES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, REBASE_USER_ROLE, RealtimeService, RoleSwitchUnavailableError, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, cappedPoolMax, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectConnectionPosture, detectToolMajor, diagnoseRowSecurityDumpFailure, discardPartialDump, effectiveSqlRole, ensureAppRole, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isRoleSwitchingOptedOut, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, main, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, poolMaxCeiling, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
14710
+ export { ADMIN_ONLY_TYPES, AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, BranchingUnsupportedError, CHANNEL_BUS_NOTIFY_CHANNEL, CONNECTION_OWNER, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PUBLIC_TYPES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, REBASE_USER_ROLE, RealtimeService, RoleSwitchUnavailableError, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, cappedPoolMax, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, describeSchemaDriftCause, detectConnectionPosture, detectToolMajor, diagnoseRowSecurityDumpFailure, discardPartialDump, effectiveSqlRole, ensureAppRole, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, isRoleSwitchingOptedOut, isScaffoldedLocalDatabase, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, main, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, poolMaxCeiling, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
13300
14711
 
13301
14712
  //# sourceMappingURL=index.es.js.map