@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b

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 (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. package/src/schema/auth-bootstrap-sql.ts +0 -47
package/dist/index.es.js CHANGED
@@ -2,16 +2,17 @@ import { createRequire as __createRequire } from "module";
2
2
  import process from "process";
3
3
  __createRequire(import.meta.url);
4
4
  import { a as guardPoolAgainstDirtyRelease, i as createReadReplicaConnection, n as createDirectDatabaseConnection, o as pinSearchPath, r as createPostgresDatabaseConnection } from "./connection-BuZ97wsr.js";
5
- import { A as toSnakeCase, C as normalizeToEntityRelation, D as getPolicyNamesForRule, F as Vector, N as hasForeignKeyOnTarget, O as mergeDeep, P as isManyToMany, S as createRelationRefWithData, T as generateForeignKeyName, _ as buildCompositeId, a as getJunctionSecurityRules, b as parseIdValues, c as policyToPostgres, d as findRelation, f as getColumnName, g as resolveCollectionRelations, h as getTableVarName, i as getJunctionCollectionConfig, k as camelCase, l as securityRuleToConditions, m as getTableName$1, n as CollectionRegistry, o as resolveJunctionSpecs, p as getEnumVarName, r as resolveStringColumnLength, s as getEffectiveSecurityRules, t as buildSdkData, u as findAnonymousGrants, v as getDeclaredPrimaryKeys, w as updateDateAutoValues, x as createRelationRef, y as isAddressableId } from "./src-DlPBctw_.js";
6
- import { n as isPostgresCollectionConfig, r as isRelationalCollectionConfig } from "./src-DoU9yPqq.js";
7
- import { t as ANONYMOUS_USER_ID } from "./policy-CeA1JcxP.js";
8
- import { t as createPostgresWebSocket } from "./websocket-B2LsrINK.js";
9
- 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 listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-CD8o_1Sl.js";
5
+ import { n as resolveClientListLimit, r as ANONYMOUS_USER_ID, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
6
+ import { $ as isManyToMany, A as getTableName$1, B as normalizeToEntityRelation, C as getEffectiveSecurityRules, D as findRelation, E as findAnonymousGrants, F as isAddressableId, G as getPolicyNamesForRule, H as generateForeignKeyName, I as parseIdValues, J as camelCase, K as isPrototypePollutingKey, L as sortCollectionsBySlug, M as resolveCollectionRelations, N as buildCompositeId, O as getColumnName, P as getDeclaredPrimaryKeys, Q as hasForeignKeyOnTarget, R as createRelationRef, S as resolveJunctionSpecs, T as securityRuleToConditions, U as legacyForeignKeyName, V as updateDateAutoValues, Y as toSnakeCase, _ as CollectionRegistry, b as getJunctionCollectionConfig, et as Vector, g as buildSdkData, h as visibleColumnProjection, j as getTableVarName, k as getEnumVarName, l as buildSearchColumnSpec, q as mergeDeep, r as authUsersColumnSql, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, u as hiddenColumnsOption, v as relationalCollections, w as policyToPostgres, x as getJunctionSecurityRules, y as resolveStringColumnLength, z as createRelationRefWithData } from "./auth-users-columns-CBEOeYqa.js";
7
+ import { c as isPostgresCollectionConfig, l as isRelationalCollectionConfig, n as REBASE_SCHEMA, o as usesLegacyRlsFunctions } from "./src-C_wvdMnl.js";
8
+ import { t as createPostgresWebSocket } from "./websocket-D1qbmLZ2.js";
9
+ 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 listBackups, d as resolvePgBinary, f as restoreDump, g as selectBackupsToPrune, h as require_source, i as createDump, j as resolveConnectionString, k as parseDbNameFromUrl, l as preflight, m as validateDump, n as applyGlobals, o as ensureDatabaseExists, p as uploadBackup, s as getServerVersionMajor, t as BackupToolError, u as pruneBackups, v as buildPgDumpArgs, w as diagnoseRowSecurityDumpFailure, x as buildPgRestoreListArgs, y as buildPgDumpallGlobalsArgs } from "./backup-service-Bww-Lg0s.js";
10
+ import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-Bpv3nUZo.js";
10
11
  import { Client, Pool } from "pg";
11
12
  import { drizzle } from "drizzle-orm/node-postgres";
12
13
  import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
13
14
  import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, or, relations, sql } from "drizzle-orm";
14
- import { PgArray, PgChar, PgTable, PgText, PgVarchar, 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";
15
+ import { PgArray, 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";
15
16
  import fs, { promises } from "fs";
16
17
  import path from "path";
17
18
  import chokidar from "chokidar";
@@ -30,25 +31,6 @@ import { randomUUID } from "crypto";
30
31
  function isChannelBusInstance(setting) {
31
32
  return typeof setting?.publish === "function";
32
33
  }
33
- /**
34
- * Resolve a client-supplied list `limit` into a safe, always-defined value.
35
- *
36
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
37
- * so `0`, negatives, and absurd values can never bypass the cap.
38
- * - An absent / blank / non-numeric limit falls back to the mode default:
39
- * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
40
- *
41
- * The return is never `undefined` — no ingress that routes its client limit
42
- * through this can produce an unbounded read.
43
- */
44
- function resolveClientListLimit(rawLimit, opts = {}) {
45
- const maxLimit = opts.maxLimit ?? 1e3;
46
- if (rawLimit != null && String(rawLimit).trim() !== "") {
47
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
48
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
49
- }
50
- return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
51
- }
52
34
  //#endregion
53
35
  //#region ../common/src/util/email.ts
54
36
  /**
@@ -152,6 +134,163 @@ var buildPropertyCallbacks = (properties) => {
152
134
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
153
135
  };
154
136
  //#endregion
137
+ //#region ../common/src/util/internal-tables.ts
138
+ /**
139
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
140
+ * end-user role away from them.
141
+ *
142
+ * ## Why this exists
143
+ *
144
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
145
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
146
+ * in the schemas a project uses — including `rebase`, because a project's own
147
+ * collections are allowed to live there (the scaffold puts `users` there). It
148
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
149
+ * migrating role inherits the same grant.
150
+ *
151
+ * Every framework-internal table is created later: auth's tables come up during
152
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
153
+ * job registers, `idempotency_keys` on the first request that carries a key. So
154
+ * they all inherited full DML for the end-user role — and none of them enables
155
+ * row-level security, because none of them is a collection with
156
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
157
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
158
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
159
+ * `rebase.api_keys` (including its `admin` flag), and insert into
160
+ * `rebase.app_config`.
161
+ *
162
+ * Nothing routes a user-context query at those tables today, so this was not
163
+ * reachable over the API. That is the wrong thing to depend on: the documented
164
+ * model is that RLS is the authorization boundary, and these tables sat outside
165
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
166
+ * address them.
167
+ *
168
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
169
+ *
170
+ * RLS with no policy denies every row, which is the same outcome, but it is the
171
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
172
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
173
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
174
+ * is "this role has no privilege here at all". It also keeps the owner
175
+ * connection (which auth actually runs on) completely unaffected.
176
+ *
177
+ * ## Keeping it true
178
+ *
179
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
180
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
181
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
182
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
183
+ * someone re-reading this file.
184
+ */
185
+ /**
186
+ * The Postgres role authenticated requests run as.
187
+ *
188
+ * Defined here rather than in the Postgres driver because both the driver (which
189
+ * provisions the role) and this module (which revokes on its behalf) need it,
190
+ * and a second spelling of a role name is a silent no-op waiting to happen.
191
+ */
192
+ var REBASE_USER_ROLE = "rebase_user";
193
+ /**
194
+ * Framework-internal table names, unqualified.
195
+ *
196
+ * Deliberately NOT including `users`: the auth user table is also a collection,
197
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
198
+ * row through it — revoking there would break sign-in.
199
+ *
200
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
201
+ * because `db migrate apply` passes `--revisions-schema rebase`.
202
+ */
203
+ var REBASE_INTERNAL_TABLES = [
204
+ "user_identities",
205
+ "refresh_tokens",
206
+ "password_reset_tokens",
207
+ "magic_link_tokens",
208
+ "mfa_factors",
209
+ "mfa_challenges",
210
+ "recovery_codes",
211
+ "app_config",
212
+ "schema_meta",
213
+ "api_keys",
214
+ "cron_logs",
215
+ "cron_claims",
216
+ "idempotency_keys",
217
+ "entity_history",
218
+ "branches",
219
+ "channel_messages",
220
+ "channel_cursors",
221
+ "channel_presence",
222
+ "atlas_schema_revisions"
223
+ ];
224
+ /** Postgres identifiers this module is willing to interpolate. */
225
+ var SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/;
226
+ /**
227
+ * A single statement that takes every privilege on `schema.table` away from the
228
+ * end-user role.
229
+ *
230
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
231
+ * happen in practice:
232
+ *
233
+ * - the role does not exist when the connection is unprivileged (Rebase then
234
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
235
+ * missing role is an error, not a no-op;
236
+ * - the table may not exist yet — `cron_logs` never appears in a project with
237
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
238
+ *
239
+ * One command, so it is safe on handles that speak the extended query protocol
240
+ * and reject multi-statement strings.
241
+ */
242
+ function revokeInternalTableSql(schema, table) {
243
+ if (!SAFE_IDENTIFIER.test(schema)) throw new Error(`Refusing to build SQL with an unsafe schema name: ${JSON.stringify(schema)}`);
244
+ if (!SAFE_IDENTIFIER.test(table)) throw new Error(`Refusing to build SQL with an unsafe table name: ${JSON.stringify(table)}`);
245
+ const qualified = `"${schema}"."${table}"`;
246
+ return `
247
+ DO $rebase_revoke$
248
+ BEGIN
249
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${REBASE_USER_ROLE}')
250
+ AND to_regclass('${qualified}') IS NOT NULL THEN
251
+ EXECUTE 'REVOKE ALL ON ${qualified} FROM ${REBASE_USER_ROLE}';
252
+ END IF;
253
+ END
254
+ $rebase_revoke$;
255
+ `.trim();
256
+ }
257
+ /**
258
+ * Revoke on every internal table in `schema`, one statement at a time.
259
+ *
260
+ * Best-effort per table: a connection that does not own one of them (a
261
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
262
+ * that must not take down a boot. The caller decides how loud to be — `onError`
263
+ * exists so the driver can warn without this module importing a logger.
264
+ */
265
+ async function revokeInternalTableAccess(execute, schema, options) {
266
+ for (const table of options?.tables ?? REBASE_INTERNAL_TABLES) try {
267
+ await execute(revokeInternalTableSql(schema, table));
268
+ } catch (error) {
269
+ options?.onError?.(table, error);
270
+ }
271
+ }
272
+ //#endregion
273
+ //#region ../common/src/data/filter-conditions.ts
274
+ /**
275
+ * Read one field's filter as the list of conditions it stands for.
276
+ *
277
+ * Accepts both declared shapes and normalises them to a list:
278
+ *
279
+ * ```ts
280
+ * toFilterTuples(["==", "active"]) // [["==", "active"]]
281
+ * toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
282
+ * ```
283
+ *
284
+ * A falsy, non-array or empty param has no conditions in it — the empty list,
285
+ * so a caller iterating adds nothing rather than compiling a tuple of
286
+ * `undefined`s and logging about an operator nobody sent.
287
+ */
288
+ function toFilterTuples(filterParam) {
289
+ if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
290
+ if (Array.isArray(filterParam[0])) return filterParam;
291
+ return [filterParam];
292
+ }
293
+ //#endregion
155
294
  //#region ../common/src/table-classification.ts
156
295
  /** Schemas that are always considered Rebase-internal. */
157
296
  var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
@@ -257,6 +396,38 @@ function getCollectionByPath(collectionPath, registry) {
257
396
  }
258
397
  return collection;
259
398
  }
399
+ /**
400
+ * Reject a write naming something that is not a column of the table.
401
+ *
402
+ * Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
403
+ * from `Object.keys(tableColumns)`, so a key the table does not carry is not
404
+ * rejected by anything — it is *left out of the statement*. The insert answers
405
+ * 201 having stored nothing under that name; the update, if the key was the
406
+ * only one, builds `update "posts" set where …` and Postgres raises a syntax
407
+ * error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
408
+ * 500 for what is a caller's typo.
409
+ *
410
+ * That makes this the last honest place to check, and the only one every write
411
+ * passes through. `assertKnownWriteFields` in the REST layer checks the same
412
+ * thing against the *config* and is skipped on four paths — `strictWrites:
413
+ * false`, a collection declaring no properties, an auth adapter that owns the
414
+ * body's shape, and a nested route whose target cannot be walked — and it never
415
+ * sees an in-process `rebase.data` write at all.
416
+ *
417
+ * It also gives `strictWrites: false` a truthful implementation. The flag is
418
+ * documented for "a column that really does exist which the config never
419
+ * declared", and skipping the config check alone could not deliver that: the
420
+ * value was dropped a layer later regardless. Skipping the config check and
421
+ * keeping this one does exactly what the flag says — the column must exist,
422
+ * the property need not.
423
+ */
424
+ function assertWritableColumns(values, table, collectionPath) {
425
+ const columns = getTableColumns(table);
426
+ if (!columns || Object.keys(columns).length === 0) return;
427
+ const unknown = Object.keys(values).filter((key) => !(key in columns));
428
+ if (unknown.length === 0) return;
429
+ 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.`, "VALIDATION_UNKNOWN_FIELDS");
430
+ }
260
431
  function getTableForCollection(collection, registry) {
261
432
  const tableName = getTableName$1(collection);
262
433
  const table = registry.getTable(tableName);
@@ -436,6 +607,71 @@ function deriveRowAddress(row, collection, registry) {
436
607
  //#endregion
437
608
  //#region src/utils/drizzle-conditions.ts
438
609
  /**
610
+ * Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
611
+ * because the fuzzy predicate has to know when the index-backed operator agrees
612
+ * with the collection's declared threshold and when it would narrow too far.
613
+ */
614
+ var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
615
+ /**
616
+ * A user's search term, made safe to drop inside a `%…%` LIKE pattern.
617
+ *
618
+ * The term is already a bind parameter, so this is not about injection. It is
619
+ * about the two things a LIKE metacharacter does when it arrives from a search
620
+ * box:
621
+ *
622
+ * 1. **It changes the query.** `%` and `_` are wildcards, so searching for
623
+ * `50%` returned every row and `a_c` matched `abc`. Nothing the caller
624
+ * could type would find a literal `%`.
625
+ * 2. **It is a cost the caller chooses.** Postgres matches LIKE by
626
+ * backtracking: each `%` re-tries every remaining offset, so
627
+ * `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
628
+ * exponent — evaluated per row, OR-ed across every string property of the
629
+ * collection, on a sequential scan (a leading `%` cannot use an index), and
630
+ * the page limit does not bound it because the scan happens first.
631
+ *
632
+ * This is the server-side half of the pattern `like-pattern-redos.test.ts`
633
+ * hardened the offline evaluator against; that test's own note ("the same
634
+ * translation in the Mongo driver hands the expression to the database, where
635
+ * it occupies a server thread instead") describes this call site.
636
+ *
637
+ * Backslash is the default `ESCAPE` character for LIKE, and the pattern is
638
+ * bound rather than interpolated, so a single backslash here reaches the
639
+ * matcher as one. Escaping the escape character first is what keeps a term
640
+ * ending in `\` from swallowing the closing `%`.
641
+ *
642
+ * Note this is a *substring search*, not the `like` filter operator: a caller
643
+ * who wants wildcards has `?title=like.foo%` for that, where the pattern is the
644
+ * documented input.
645
+ */
646
+ var escapeLikePattern = (value) => value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
647
+ /** Column types `ILIKE '%…%'` is defined on. */
648
+ var ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
649
+ /**
650
+ * Can this column be matched with `ILIKE`?
651
+ *
652
+ * Asked of the column's *declared SQL type*, never with `instanceof`. The
653
+ * previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
654
+ * and `instanceof` compares class identity: it is only true when the column was
655
+ * constructed by the very same copy of `drizzle-orm` that this module imported.
656
+ *
657
+ * An application's generated schema builds its tables with the app's own
658
+ * `drizzle-orm`, and this driver declares its own dependency on one. When the
659
+ * two ranges do not overlap — an app scaffolded against `^0.44` with a driver
660
+ * asking for `^0.45` — a strict installer gives the driver a second copy, every
661
+ * check returns false, no condition is produced, and the caller compiles that
662
+ * into an impossible `WHERE`. The result is a 200 with an empty page for every
663
+ * search on every collection without a `search` block: the failure looks
664
+ * exactly like "nothing matched". Observed in production, not theorised.
665
+ *
666
+ * `getSQLType()` is a value the column reports about itself, so it crosses
667
+ * module instances the way a class identity cannot. It also happens to fix
668
+ * `citext`, which the `instanceof` list never covered.
669
+ */
670
+ var supportsILike = (column) => {
671
+ const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
672
+ return ILIKE_SQL_TYPES.test(sqlType);
673
+ };
674
+ /**
439
675
  * Process-wide default, set once when the driver is constructed.
440
676
  *
441
677
  * The condition builder is a set of *static* methods reached from a dozen
@@ -493,7 +729,7 @@ function toMembershipList(value) {
493
729
  * @example
494
730
  * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
495
731
  */
496
- var DrizzleConditionBuilder = class {
732
+ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
497
733
  /**
498
734
  * Express "reachable from this parent through this relation" as a plain
499
735
  * `WHERE` condition on the target table.
@@ -669,8 +905,7 @@ var DrizzleConditionBuilder = class {
669
905
  if (!filterParam) continue;
670
906
  const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
671
907
  if (!target) continue;
672
- const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0]) ? filterParam : [filterParam];
673
- for (const [op, value] of paramsList) {
908
+ for (const [op, value] of toFilterTuples(filterParam)) {
674
909
  const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
675
910
  if (condition) conditions.push(condition);
676
911
  }
@@ -1119,22 +1354,153 @@ var DrizzleConditionBuilder = class {
1119
1354
  return or(...conditions);
1120
1355
  }
1121
1356
  /**
1122
- * Build search conditions for text fields
1357
+ * Build search conditions for text fields.
1358
+ *
1359
+ * Two shapes, chosen by whether the collection declared a `search` block:
1360
+ *
1361
+ * - **Declared** — one `@@ websearch_to_tsquery` against the generated
1362
+ * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
1363
+ * inside JSONB and arrays, and uses the GIN index.
1364
+ * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
1365
+ * string properties, with the term escaped (see {@link escapeLikePattern})
1366
+ * so it is matched as the literal text the user typed.
1367
+ *
1368
+ * The second is the default and stays the default. A collection that has
1369
+ * not opted in compiles to exactly the SQL it compiled to before this
1370
+ * branch existed, which is the only reason it is safe to have added it.
1371
+ *
1372
+ * `collection` is optional so that the callers which genuinely have no
1373
+ * collection in hand — nested paths, derived views — keep working; without
1374
+ * one there is no `search` block to read and the ILIKE path is correct.
1123
1375
  */
1124
- static buildSearchConditions(searchString, properties, table) {
1376
+ static buildSearchConditions(searchString, properties, table, collection) {
1125
1377
  const searchConditions = [];
1378
+ const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
1379
+ if (ftsCondition) return [ftsCondition];
1380
+ let declaredStringProperties = 0;
1126
1381
  for (const [key, prop] of Object.entries(properties)) {
1127
1382
  const p = prop;
1128
1383
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1384
+ declaredStringProperties++;
1129
1385
  const fieldColumn = table[key];
1130
- if (fieldColumn) {
1131
- if (fieldColumn instanceof PgVarchar || fieldColumn instanceof PgText || fieldColumn instanceof PgChar || fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
1132
- }
1386
+ if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
1133
1387
  }
1134
1388
  }
1389
+ if (declaredStringProperties > 0 && searchConditions.length === 0) logger.warn(`[search] "${collection?.slug ?? "collection"}" declares ${declaredStringProperties} string property(ies) but none compiled to a searchable column, so this search can only return nothing. Check that the generated schema's column types are text/varchar/char.`);
1135
1390
  return searchConditions;
1136
1391
  }
1137
1392
  /**
1393
+ * The `@@` predicate for a collection that declared a `search` block, or
1394
+ * undefined for one that did not.
1395
+ *
1396
+ * The query is normalized exactly as the indexed content was — same text
1397
+ * search configuration, same accent folding. Skipping that on the query
1398
+ * side is the subtle way to get a search that matches nothing: the column
1399
+ * would hold `gestion` while the query asked for `gestión`.
1400
+ *
1401
+ * `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
1402
+ * one that behaves the way a search box looks like it should — quoted
1403
+ * phrases, `or`, and a leading `-` to exclude — and because it never throws
1404
+ * on user input, which `to_tsquery` does on so much as a stray parenthesis.
1405
+ */
1406
+ static buildFullTextCondition(searchString, table, collection) {
1407
+ let spec;
1408
+ try {
1409
+ spec = buildSearchColumnSpec(collection);
1410
+ } catch {
1411
+ return;
1412
+ }
1413
+ if (!spec) return void 0;
1414
+ const column = table[spec.column];
1415
+ if (!column) return;
1416
+ const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
1417
+ if (!spec.fuzzy) return exact;
1418
+ const fuzzyColumn = table[spec.fuzzy.column];
1419
+ if (!fuzzyColumn) return exact;
1420
+ const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
1421
+ const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
1422
+ return sql`(${exact} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
1423
+ }
1424
+ /**
1425
+ * `websearch_to_tsquery(<config>, <normalized search string>)`.
1426
+ *
1427
+ * Split out because the ranking expression needs the identical query — a
1428
+ * row ranked against a different tsquery than it was matched against is a
1429
+ * ranking of something else.
1430
+ */
1431
+ static normalizedTsQuery(searchString, spec) {
1432
+ const normalized = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
1433
+ return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
1434
+ }
1435
+ /**
1436
+ * A JSONB array of `{ field, snippet }` naming which declared fields matched
1437
+ * and showing the text around each hit — what backs `_matches`.
1438
+ *
1439
+ * A ranked list answers "which rows", never "why this row". For a talent
1440
+ * pool that difference is the product: a candidate surfacing for
1441
+ * "iso 14001" on a *certification* is a different candidate from one whose
1442
+ * bio happens to mention the standard, and the score cannot tell them apart.
1443
+ *
1444
+ * Built as a correlated subquery over a `VALUES` list of the declared
1445
+ * fields, rather than one `CASE` per field, so the shape does not change
1446
+ * with the number of fields and the empty result is a plain `[]`.
1447
+ *
1448
+ * `ts_headline` runs over the same normalized text that was indexed. Over
1449
+ * the *original* text it would find nothing to mark whenever `unaccent` is
1450
+ * on — the query's lexemes are folded and the document's are not — and
1451
+ * would return the text silently unhighlighted. Folded-but-marked beats
1452
+ * pretty-but-inert.
1453
+ *
1454
+ * Undefined when the collection has not opted in, when the column is not on
1455
+ * the table yet, or when the caller did not ask: this costs a `ts_headline`
1456
+ * per field per row and `ts_headline` re-parses the document.
1457
+ */
1458
+ static buildSearchMatchesExpression(searchString, table, collection) {
1459
+ let spec;
1460
+ try {
1461
+ spec = buildSearchColumnSpec(collection);
1462
+ } catch {
1463
+ return;
1464
+ }
1465
+ if (!spec || spec.fields.length === 0) return void 0;
1466
+ if (!table[spec.column]) return void 0;
1467
+ const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
1468
+ const config = sql`${spec.language}`;
1469
+ const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
1470
+ return sql`(
1471
+ SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
1472
+ FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
1473
+ CROSS JOIN LATERAL (
1474
+ SELECT jsonb_build_object(
1475
+ 'field', f.path,
1476
+ 'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
1477
+ 'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
1478
+ ) AS m
1479
+ WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
1480
+ ) s
1481
+ )`;
1482
+ }
1483
+ /**
1484
+ * `ts_rank(<column>, <query>)` for the collection, or undefined when it has
1485
+ * not opted in. This is what backs `orderBy: ["_score", "desc"]`.
1486
+ */
1487
+ static buildSearchRankExpression(searchString, table, collection) {
1488
+ let spec;
1489
+ try {
1490
+ spec = buildSearchColumnSpec(collection);
1491
+ } catch {
1492
+ return;
1493
+ }
1494
+ if (!spec) return void 0;
1495
+ const column = table[spec.column];
1496
+ if (!column) return void 0;
1497
+ const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1498
+ if (!spec.fuzzy) return rank;
1499
+ const fuzzyColumn = table[spec.fuzzy.column];
1500
+ if (!fuzzyColumn) return rank;
1501
+ return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
1502
+ }
1503
+ /**
1138
1504
  * Build a unique field check condition
1139
1505
  */
1140
1506
  static buildUniqueFieldCondition(fieldColumn, value, idColumn, excludeId) {
@@ -1232,10 +1598,19 @@ var DrizzleConditionBuilder = class {
1232
1598
  * - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
1233
1599
  * - `filter`: optional WHERE clause for distance threshold
1234
1600
  * - `distanceSelect`: SQL expression for selecting the distance as `_distance`
1601
+ *
1602
+ * `property` is `?vector_search=` off the querystring, so it is an untrusted
1603
+ * *name*, and it used to be looked up straight in the drizzle table object.
1604
+ * Two ways that went wrong, both answering 500 to a malformed request:
1605
+ * `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
1606
+ * database rejects with "operator does not exist"; and a table object also
1607
+ * carries non-column keys (`_`, methods), which passed the `if (!column)`
1608
+ * guard and compiled to nonsense. The name is resolved against the table's
1609
+ * actual columns and required to be a `vector` — anything else is the
1610
+ * caller's mistake and gets a 400 that says so.
1235
1611
  */
1236
1612
  static buildVectorSearchConditions(table, vectorSearch) {
1237
- const column = table[vectorSearch.property];
1238
- if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
1613
+ const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
1239
1614
  if (!Array.isArray(vectorSearch.vector) || vectorSearch.vector.length === 0 || !vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))) throw new Error("Vector search requires a non-empty array of finite numbers");
1240
1615
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
1241
1616
  const distanceFn = vectorSearch.distance || "cosine";
@@ -1257,7 +1632,33 @@ var DrizzleConditionBuilder = class {
1257
1632
  distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
1258
1633
  };
1259
1634
  }
1635
+ /**
1636
+ * The `vector` column a request named, or a 400 explaining what it named.
1637
+ *
1638
+ * `getTableColumns` rather than a key lookup: it returns only the columns,
1639
+ * so `_`, `getSQL` and every other property of a drizzle table stop looking
1640
+ * like candidates. The type check is on the *physical* column
1641
+ * (`vector(1536)`) rather than on the declared property, so it holds for an
1642
+ * introspected collection too, where the property carries no Rebase type.
1643
+ */
1644
+ static resolveVectorColumn(table, property) {
1645
+ const columns = getTableColumns(table);
1646
+ const column = columns?.[property];
1647
+ if (!column) {
1648
+ const known = Object.entries(columns ?? {}).filter(([, c]) => isVectorColumn(c)).map(([name]) => name);
1649
+ throw ApiError.badRequest(`Unknown vector property "${property}". ` + (known.length > 0 ? `This collection's vector properties are: ${known.join(", ")}.` : "This collection declares no `vector` property to search."), "UNKNOWN_VECTOR_PROPERTY");
1650
+ }
1651
+ if (!isVectorColumn(column)) throw ApiError.badRequest(`Property "${property}" is not a vector column (it is \`${columnSqlType(column) || "unknown"}\`), so it has no distance operator. Name the property declared as \`{ type: "vector" }\`.`, "UNKNOWN_VECTOR_PROPERTY");
1652
+ return column;
1653
+ }
1654
+ };
1655
+ /** The column's SQL type, for a value that may not be a drizzle column at all. */
1656
+ var columnSqlType = (column) => {
1657
+ const getSQLType = column?.getSQLType;
1658
+ return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
1260
1659
  };
1660
+ /** True for `vector(1536)` and its pgvector siblings, whatever the width. */
1661
+ var isVectorColumn = (column) => /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
1261
1662
  /**
1262
1663
  * Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
1263
1664
  * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
@@ -1276,7 +1677,11 @@ function sanitizeAndConvertDates(obj) {
1276
1677
  if (obj instanceof Date) return obj.toISOString();
1277
1678
  if (typeof obj === "object") {
1278
1679
  const newObj = {};
1279
- for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) newObj[key] = sanitizeAndConvertDates(obj[key]);
1680
+ for (const key in obj) {
1681
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
1682
+ if (isPrototypePollutingKey(key)) continue;
1683
+ newObj[key] = sanitizeAndConvertDates(obj[key]);
1684
+ }
1280
1685
  return newObj;
1281
1686
  }
1282
1687
  if (typeof obj === "string") {
@@ -1305,6 +1710,8 @@ function serializeDataToServer(row, properties, collection, registry) {
1305
1710
  if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
1306
1711
  });
1307
1712
  for (const [key, value] of Object.entries(row)) {
1713
+ if (isPrototypePollutingKey(key)) continue;
1714
+ if (value === void 0) continue;
1308
1715
  const property = properties[key];
1309
1716
  const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
1310
1717
  if (!property) {
@@ -1315,11 +1722,11 @@ function serializeDataToServer(row, properties, collection, registry) {
1315
1722
  const relation = findRelation(resolvedRelations, key);
1316
1723
  if (relation) {
1317
1724
  if (relation.kind === "belongsTo") {
1318
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1725
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
1319
1726
  if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
1320
1727
  continue;
1321
1728
  } else if (hasForeignKeyOnTarget(relation)) {
1322
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1729
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
1323
1730
  inverseRelationUpdates.push({
1324
1731
  relationKey: key,
1325
1732
  relation,
@@ -1327,7 +1734,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1327
1734
  });
1328
1735
  continue;
1329
1736
  } else if (relation.kind === "via") {
1330
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1737
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
1331
1738
  if (relation.cardinality === "one") joinPathRelationUpdates.push({
1332
1739
  relationKey: key,
1333
1740
  relation,
@@ -1342,7 +1749,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1342
1749
  }
1343
1750
  }
1344
1751
  }
1345
- result[key] = serializePropertyToServer(effectiveValue, property);
1752
+ result[key] = serializePropertyToServer(effectiveValue, property, key);
1346
1753
  }
1347
1754
  return {
1348
1755
  scalarData: result,
@@ -1351,19 +1758,39 @@ function serializeDataToServer(row, properties, collection, registry) {
1351
1758
  };
1352
1759
  }
1353
1760
  /**
1354
- * Serialize a single property value for database storage
1761
+ * How to name a rejected value in an error, without quoting it back.
1762
+ *
1763
+ * The value may be anything the caller sent, including a secret in the wrong
1764
+ * field, so the message describes its shape rather than echoing it — an echoed
1765
+ * value ends up in logs and in error-reporting services.
1766
+ */
1767
+ function describeValue(value) {
1768
+ if (value === null) return "null";
1769
+ if (Array.isArray(value)) return "an array";
1770
+ if (value instanceof Date) return "a date";
1771
+ const type = typeof value;
1772
+ return type === "object" ? "an object" : `a ${type}`;
1773
+ }
1774
+ /**
1775
+ * Serialize a single property value for database storage.
1776
+ *
1777
+ * `propertyKey` is only ever used to phrase errors and warnings. Without it the
1778
+ * one trace a bad value left was `Expected array value for array property, got
1779
+ * string` — no collection, no property, no value, which in the log of a
1780
+ * thousand-row import names nothing at all.
1355
1781
  */
1356
- function serializePropertyToServer(value, property) {
1782
+ function serializePropertyToServer(value, property, propertyKey) {
1357
1783
  if (value === null || value === void 0) return value;
1784
+ const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
1358
1785
  switch (property.type) {
1359
1786
  case "relation":
1360
- if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property));
1787
+ if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
1361
1788
  else if (typeof value === "object" && value !== null && "id" in value) return value.id;
1362
1789
  if (value === "") return null;
1363
1790
  return value;
1364
1791
  case "array":
1365
1792
  if (Array.isArray(value)) {
1366
- if (property.of) return value.map((item) => serializePropertyToServer(item, property.of));
1793
+ if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
1367
1794
  else if (property.oneOf) {
1368
1795
  const typeField = property.oneOf.typeField ?? "type";
1369
1796
  const valueField = property.oneOf.valueField ?? "value";
@@ -1376,20 +1803,28 @@ function serializePropertyToServer(value, property) {
1376
1803
  if (!type || !childProperty) return e;
1377
1804
  return {
1378
1805
  [typeField]: type,
1379
- [valueField]: serializePropertyToServer(rec[valueField], childProperty)
1806
+ [valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
1380
1807
  };
1381
1808
  });
1382
1809
  }
1383
1810
  return value;
1384
1811
  }
1385
- logger.warn(`Expected array value for array property, got ${typeof value}. Coercing to empty array.`);
1386
- return [];
1812
+ throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
1813
+ case "geopoint": {
1814
+ if (typeof value !== "object" || Array.isArray(value)) throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with \`latitude\` and \`longitude\`, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
1815
+ const point = value;
1816
+ if (typeof point.latitude !== "number" || typeof point.longitude !== "number") throw ApiError.badRequest(`${fieldLabel} expects a geopoint object with numeric \`latitude\` and \`longitude\`.`, "VALIDATION_INVALID_VALUE");
1817
+ return {
1818
+ latitude: point.latitude,
1819
+ longitude: point.longitude
1820
+ };
1821
+ }
1387
1822
  case "map":
1388
1823
  if (typeof value === "object" && property.properties) {
1389
1824
  const result = {};
1390
1825
  for (const [subKey, subValue] of Object.entries(value)) {
1391
1826
  const subProperty = property.properties[subKey];
1392
- if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty);
1827
+ if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
1393
1828
  else result[subKey] = subValue;
1394
1829
  }
1395
1830
  return result;
@@ -1627,6 +2062,7 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1627
2062
  return isNaN(parsed) ? null : parsed;
1628
2063
  }
1629
2064
  return value;
2065
+ case "geopoint": return value;
1630
2066
  case "vector": {
1631
2067
  let nums = [];
1632
2068
  if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
@@ -1670,6 +2106,18 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1670
2106
  * from the result (used by `normalizeDbValues` where
1671
2107
  * Drizzle's relational API already hydrates them).
1672
2108
  */
2109
+ /**
2110
+ * Keys a query computes and attaches to a row, rather than reads from a column.
2111
+ *
2112
+ * An explicit set rather than a `_` prefix rule: a user's column may perfectly
2113
+ * well be called `_internal`, and passing it through here would put a value on
2114
+ * the row that no property describes and nothing downstream knows how to type.
2115
+ */
2116
+ var QUERY_METADATA_KEYS = /* @__PURE__ */ new Set([
2117
+ "_score",
2118
+ "_distance",
2119
+ "_matches"
2120
+ ]);
1673
2121
  function normalizeScalarValues(data, properties, collection, resolvedRelations, options) {
1674
2122
  const result = {};
1675
2123
  const internalFKColumns = /* @__PURE__ */ new Set();
@@ -1681,6 +2129,10 @@ function normalizeScalarValues(data, properties, collection, resolvedRelations,
1681
2129
  result[key] = value === null ? null : typeof value === "number" ? value : String(value);
1682
2130
  continue;
1683
2131
  }
2132
+ if (QUERY_METADATA_KEYS.has(key)) {
2133
+ result[key] = value;
2134
+ continue;
2135
+ }
1684
2136
  const property = properties[key];
1685
2137
  if (!property) continue;
1686
2138
  if (options.skipRelations && property.type === "relation") continue;
@@ -1933,7 +2385,7 @@ var RelationService = class {
1933
2385
  let query = this.db.select().from(targetTable).$dynamic();
1934
2386
  const additionalFilters = [];
1935
2387
  if (options.searchString) {
1936
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable);
2388
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
1937
2389
  if (searchConditions.length === 0) return [];
1938
2390
  const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
1939
2391
  if (searchCombined) additionalFilters.push(searchCombined);
@@ -2233,6 +2685,55 @@ var RelationService = class {
2233
2685
  return resultMap;
2234
2686
  }
2235
2687
  /**
2688
+ * Bring one row's junction links in line with the ids a save carried,
2689
+ * by diffing against what is stored rather than replacing the lot.
2690
+ *
2691
+ * The old shape was `DELETE every link for this parent` followed by
2692
+ * `INSERT what the client sent`, which makes a save of the parent a full
2693
+ * replacement of the membership set from a list the browser assembled out
2694
+ * of a read it did earlier. Three things follow from that, and the diff
2695
+ * closes all three:
2696
+ *
2697
+ * - **Lost update.** Two editors with post 7 open: A adds tag X and saves;
2698
+ * B saves any field and the whole set is rewritten from B's older list,
2699
+ * dropping X with nothing to show for it. A diff only names the ids that
2700
+ * actually changed, so edits to disjoint tags no longer collide.
2701
+ * - **Partial read, partial delete.** The read that fills the form runs
2702
+ * under RLS, so a user who may edit the parent but cannot *see* some of
2703
+ * the linked rows gets a shorter list — and writing it back deleted the
2704
+ * links they were never shown. The DELETE now names ids instead of
2705
+ * "everything for this parent", and the select that produces them runs
2706
+ * in this same transaction under the same policies, so a link the caller
2707
+ * cannot read is in neither list and survives the save.
2708
+ * - **Junction payload columns.** A junction carrying its own columns
2709
+ * (`position`, `role`, `created_at`) lost them on every save, because
2710
+ * every row was re-inserted with only the two keys. Untouched links are
2711
+ * now left alone.
2712
+ *
2713
+ * The insert is `ON CONFLICT DO NOTHING` so that two sessions adding the
2714
+ * same link concurrently is a no-op rather than a unique violation.
2715
+ */
2716
+ async syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedParentId, parsedTargetIds) {
2717
+ const existingRows = await tx.select({ targetId: targetJunctionColumn }).from(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
2718
+ const existingById = /* @__PURE__ */ new Map();
2719
+ for (const row of existingRows) {
2720
+ if (row.targetId === null || row.targetId === void 0) continue;
2721
+ existingById.set(String(row.targetId), row.targetId);
2722
+ }
2723
+ const wantedById = /* @__PURE__ */ new Map();
2724
+ for (const targetId of parsedTargetIds) {
2725
+ if (targetId === null || targetId === void 0) continue;
2726
+ wantedById.set(String(targetId), targetId);
2727
+ }
2728
+ const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
2729
+ const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, value]) => value);
2730
+ if (removed.length > 0) await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), inArray(targetJunctionColumn, removed)));
2731
+ if (added.length > 0) await tx.insert(junctionTable).values(added.map((targetId) => ({
2732
+ [sourceJunctionColumn.name]: parsedParentId,
2733
+ [targetJunctionColumn.name]: targetId
2734
+ }))).onConflictDoNothing();
2735
+ }
2736
+ /**
2236
2737
  * Update many-to-many and junction relations
2237
2738
  */
2238
2739
  async updateRelationsUsingJoins(tx, collection, id, relationValues) {
@@ -2277,16 +2778,13 @@ var RelationService = class {
2277
2778
  const parentPks = requirePrimaryKeys(collection, this.registry);
2278
2779
  const parentIdInfo = parentPks[0];
2279
2780
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
2280
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
2781
+ let parsedTargetIds = [];
2281
2782
  if (targetEntityIds.length > 0) {
2282
2783
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2283
2784
  const targetIdInfo = targetPks[0];
2284
- const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2285
- [sourceJunctionColumn.name]: parsedParentId,
2286
- [targetJunctionColumn.name]: targetId
2287
- }));
2288
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2785
+ parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
2289
2786
  }
2787
+ await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedParentId, parsedTargetIds);
2290
2788
  } else if (relation.kind === "manyToMany") {
2291
2789
  const junctionTable = this.registry.getTable(relation.through.table);
2292
2790
  if (!junctionTable) {
@@ -2302,16 +2800,13 @@ var RelationService = class {
2302
2800
  const parentPks = requirePrimaryKeys(collection, this.registry);
2303
2801
  const parentIdInfo = parentPks[0];
2304
2802
  const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
2305
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
2803
+ let parsedTargetIds = [];
2306
2804
  if (targetEntityIds.length > 0) {
2307
2805
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2308
2806
  const targetIdInfo = targetPks[0];
2309
- const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2310
- [sourceJunctionColumn.name]: parsedParentId,
2311
- [targetJunctionColumn.name]: targetId
2312
- }));
2313
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2807
+ parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
2314
2808
  }
2809
+ await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedParentId, parsedTargetIds);
2315
2810
  } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
2316
2811
  const targetTable = getTableForCollection(targetCollection, this.registry);
2317
2812
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
@@ -2415,25 +2910,17 @@ var RelationService = class {
2415
2910
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2416
2911
  const sourceIdInfo = sourcePks[0];
2417
2912
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
2418
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
2913
+ let parsedTargetIds = [];
2419
2914
  if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2420
2915
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2421
2916
  const targetIdInfo = targetPks[0];
2422
- const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2423
- [sourceJunctionColumn.name]: parsedSourceId,
2424
- [targetJunctionColumn.name]: targetId
2425
- }));
2426
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2917
+ parsedTargetIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
2427
2918
  } else if (newValue && !Array.isArray(newValue)) {
2428
2919
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2429
2920
  const targetIdInfo = targetPks[0];
2430
- const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
2431
- const newLink = {
2432
- [sourceJunctionColumn.name]: parsedSourceId,
2433
- [targetJunctionColumn.name]: parsedTargetId
2434
- };
2435
- await tx.insert(junctionTable).values(newLink);
2921
+ parsedTargetIds = [parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName]];
2436
2922
  }
2923
+ await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedSourceId, parsedTargetIds);
2437
2924
  }
2438
2925
  } catch (error) {
2439
2926
  logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
@@ -2459,16 +2946,14 @@ var RelationService = class {
2459
2946
  const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2460
2947
  const sourceIdInfo = sourcePks[0];
2461
2948
  const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
2462
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
2463
- if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2949
+ const targetEntityIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : [];
2950
+ let parsedTargetIds = [];
2951
+ if (targetEntityIds.length > 0) {
2464
2952
  const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2465
2953
  const targetIdInfo = targetPks[0];
2466
- const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2467
- [sourceJunctionColumn.name]: parsedSourceId,
2468
- [targetJunctionColumn.name]: targetId
2469
- }));
2470
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2954
+ parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
2471
2955
  }
2956
+ await this.syncJunctionLinks(tx, junctionTable, sourceJunctionColumn, targetJunctionColumn, parsedSourceId, parsedTargetIds);
2472
2957
  } catch (error) {
2473
2958
  logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
2474
2959
  throw error;
@@ -3019,8 +3504,7 @@ function sanitizeErrorForClient(error, context) {
3019
3504
  column: pgError.column,
3020
3505
  table: pgError.table,
3021
3506
  constraint: pgError.constraint,
3022
- dataType: pgError.dataType,
3023
- drizzleMessage: error instanceof Error ? error.message : String(error)
3507
+ dataType: pgError.dataType
3024
3508
  });
3025
3509
  return pgErrorToFriendlyMessage(pgError, context);
3026
3510
  }
@@ -3039,7 +3523,7 @@ function sanitizeErrorForClient(error, context) {
3039
3523
  * Service for handling all row read operations.
3040
3524
  * Handles fetching, searching, counting, and filtering rows.
3041
3525
  */
3042
- var FetchService = class {
3526
+ var FetchService = class FetchService {
3043
3527
  db;
3044
3528
  registry;
3045
3529
  relationService;
@@ -3112,6 +3596,24 @@ var FetchService = class {
3112
3596
  * and skips rows rather than erroring. The guesses stay, last, for a
3113
3597
  * caller that hands over no collection to resolve against.
3114
3598
  */
3599
+ /**
3600
+ * The ORDER BY target, which may be relevance rather than a column.
3601
+ *
3602
+ * `_score` is only meaningful for a collection that declared a `search`
3603
+ * block *and* for a request that carried a search string — ranking rows
3604
+ * against no query ranks them all at zero. Outside those two conditions it
3605
+ * is an unknown field and gets the same 400 as any other typo, which is the
3606
+ * behaviour that matters: a sort that is silently dropped returns 200 with
3607
+ * rows in arbitrary order, and paging over that repeats and skips rows.
3608
+ */
3609
+ static SCORE_FIELD = "_score";
3610
+ resolveOrderTarget(table, orderBy, collection, searchString) {
3611
+ if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
3612
+ const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
3613
+ if (rank) return rank;
3614
+ }
3615
+ return this.resolveOrderByField(table, orderBy, collection);
3616
+ }
3115
3617
  resolveOrderByField(table, orderBy, collection) {
3116
3618
  const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
3117
3619
  const direct = columnAt(orderBy);
@@ -3239,12 +3741,14 @@ var FetchService = class {
3239
3741
  */
3240
3742
  buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
3241
3743
  const queryOpts = {};
3744
+ const hidden = hiddenColumnsOption(getTableColumns(table), this.registry.getCollectionByPath(collectionPath) ?? void 0);
3745
+ if (hidden) queryOpts.columns = hidden;
3242
3746
  if (withConfig) queryOpts.with = withConfig;
3243
3747
  const allConditions = [];
3244
3748
  if (scopeCondition) allConditions.push(scopeCondition);
3245
3749
  if (options.searchString) {
3246
3750
  const collection = getCollectionByPath(collectionPath, this.registry);
3247
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3751
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3248
3752
  if (searchConditions.length === 0) {
3249
3753
  queryOpts.where = and(eq(idField, -99999999));
3250
3754
  return queryOpts;
@@ -3267,7 +3771,7 @@ var FetchService = class {
3267
3771
  const orderExpressions = [];
3268
3772
  if (options.orderBy) {
3269
3773
  const collection = getCollectionByPath(collectionPath, this.registry);
3270
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3774
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3271
3775
  if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3272
3776
  }
3273
3777
  orderExpressions.push(desc(idField));
@@ -3284,6 +3788,7 @@ var FetchService = class {
3284
3788
  if (!options.startAfter) return [];
3285
3789
  const cursor = options.startAfter;
3286
3790
  if (options.orderBy) {
3791
+ if (options.orderBy === FetchService.SCORE_FIELD) throw ApiError.badRequest("Cursor pagination (`startAfter`) cannot be combined with `orderBy: \"_score\"`. Relevance is computed per query rather than stored, so it cannot key a cursor. Use `limit`/`offset` for relevance-ordered pages, or order by a column.", "SCORE_CURSOR_UNSUPPORTED", { field: FetchService.SCORE_FIELD });
3287
3792
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
3288
3793
  const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3289
3794
  if (orderByField) {
@@ -3354,9 +3859,11 @@ var FetchService = class {
3354
3859
  const qb = this.getQueryBuilder(tableName);
3355
3860
  if (qb) try {
3356
3861
  const withConfig = this.buildWithConfig(collection);
3862
+ const hidden = hiddenColumnsOption(getTableColumns(table), collection);
3357
3863
  const row = await qb.findFirst({
3358
3864
  where: eq(idField, parsedId),
3359
- with: withConfig
3865
+ with: withConfig,
3866
+ ...hidden ? { columns: hidden } : {}
3360
3867
  });
3361
3868
  if (!row) return void 0;
3362
3869
  const flatRow = toFlatRow(row, collection, this.registry);
@@ -3370,7 +3877,8 @@ var FetchService = class {
3370
3877
  if (reachedDatabase(e)) throw e;
3371
3878
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
3372
3879
  }
3373
- const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
3880
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
3881
+ const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
3374
3882
  if (result.length === 0) return void 0;
3375
3883
  const raw = result[0];
3376
3884
  const values = await parseDataFromServer(raw, collection, this.db, this.registry);
@@ -3426,14 +3934,21 @@ var FetchService = class {
3426
3934
  }
3427
3935
  let vectorMeta;
3428
3936
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
3937
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
3938
+ const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
3939
+ const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
3429
3940
  let query = vectorMeta ? this.db.select({
3430
- table_row: table,
3941
+ table_row: visible ?? table,
3431
3942
  _distance: vectorMeta.distanceSelect
3432
- }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3943
+ }).from(table).$dynamic() : rankSelect ? this.db.select({
3944
+ table_row: visible ?? table,
3945
+ _score: rankSelect,
3946
+ ...matchesSelect ? { _matches: matchesSelect } : {}
3947
+ }).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3433
3948
  const allConditions = [];
3434
3949
  if (scopeCondition) allConditions.push(scopeCondition);
3435
3950
  if (options.searchString) {
3436
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3951
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3437
3952
  if (searchConditions.length === 0) return [];
3438
3953
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3439
3954
  }
@@ -3453,7 +3968,7 @@ var FetchService = class {
3453
3968
  const orderExpressions = [];
3454
3969
  if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3455
3970
  else if (options.orderBy) {
3456
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3971
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3457
3972
  if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3458
3973
  }
3459
3974
  orderExpressions.push(desc(idField));
@@ -3473,6 +3988,10 @@ var FetchService = class {
3473
3988
  const results = vectorMeta ? rawResults.map((r) => ({
3474
3989
  ...r.table_row,
3475
3990
  _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
3991
+ })) : rankSelect ? rawResults.map((r) => ({
3992
+ ...r.table_row,
3993
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
3994
+ ...matchesSelect ? { _matches: r._matches ?? [] } : {}
3476
3995
  })) : rawResults;
3477
3996
  return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
3478
3997
  }
@@ -3564,7 +4083,7 @@ var FetchService = class {
3564
4083
  const allConditions = [];
3565
4084
  if (hop) allConditions.push(this.buildRelationScope(hop));
3566
4085
  if (options.searchString) {
3567
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
4086
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3568
4087
  if (searchConditions.length === 0) return 0;
3569
4088
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3570
4089
  }
@@ -3709,7 +4228,8 @@ var FetchService = class {
3709
4228
  if (reachedDatabase(e)) throw e;
3710
4229
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
3711
4230
  }
3712
- const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
4231
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
4232
+ const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
3713
4233
  if (result.length === 0) return null;
3714
4234
  const flatEntity = { ...result[0] };
3715
4235
  if (!include || include.length === 0) return flatEntity;
@@ -3747,14 +4267,21 @@ var FetchService = class {
3747
4267
  const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
3748
4268
  let vectorMeta;
3749
4269
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
4270
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
4271
+ const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
4272
+ const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
3750
4273
  let query = vectorMeta ? this.db.select({
3751
- table_row: table,
4274
+ table_row: visible ?? table,
3752
4275
  _distance: vectorMeta.distanceSelect
3753
- }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
4276
+ }).from(table).$dynamic() : rankSelect ? this.db.select({
4277
+ table_row: visible ?? table,
4278
+ _score: rankSelect,
4279
+ ...matchesSelect ? { _matches: matchesSelect } : {}
4280
+ }).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3754
4281
  const allConditions = [];
3755
4282
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
3756
4283
  if (options.searchString) {
3757
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
4284
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3758
4285
  if (searchConditions.length === 0) return [];
3759
4286
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3760
4287
  }
@@ -3762,6 +4289,10 @@ var FetchService = class {
3762
4289
  const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3763
4290
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
3764
4291
  }
4292
+ if (options.logical) {
4293
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
4294
+ if (logicalCondition) allConditions.push(logicalCondition);
4295
+ }
3765
4296
  if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
3766
4297
  if (allConditions.length > 0) {
3767
4298
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -3770,7 +4301,7 @@ var FetchService = class {
3770
4301
  const orderExpressions = [];
3771
4302
  if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3772
4303
  else if (options.orderBy) {
3773
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
4304
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3774
4305
  if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3775
4306
  }
3776
4307
  orderExpressions.push(desc(idField));
@@ -3783,6 +4314,11 @@ var FetchService = class {
3783
4314
  ...r.table_row,
3784
4315
  _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
3785
4316
  }));
4317
+ if (rankSelect) return rawResults.map((r) => ({
4318
+ ...r.table_row,
4319
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
4320
+ ...matchesSelect ? { _matches: r._matches ?? [] } : {}
4321
+ }));
3786
4322
  return rawResults;
3787
4323
  }
3788
4324
  /**
@@ -3796,51 +4332,6 @@ var FetchService = class {
3796
4332
  return !!this.getQueryBuilder(tableName);
3797
4333
  }
3798
4334
  /**
3799
- * Attempt to use Drizzle's relational query API (db.query.<table>.findMany)
3800
- * for efficient JOIN-based relation loading.
3801
- * Returns null if the API is not available or the query fails.
3802
- * Note: Primary path now uses `buildWithConfig` + `buildDrizzleQueryOptions`.
3803
- */
3804
- async fetchWithDrizzleQuery(collectionPath, collection, options, include, idInfo, idInfoArray) {
3805
- try {
3806
- const table = getTableForCollection(collection, this.registry);
3807
- const tableName = getTableName(table);
3808
- const queryTarget = this.getQueryBuilder(tableName);
3809
- if (!queryTarget?.findMany) return null;
3810
- const resolvedRelations = resolveCollectionRelations(collection);
3811
- const withConfig = {};
3812
- for (const [key, relation] of Object.entries(resolvedRelations)) if (include[0] === "*" || include.includes(key)) {
3813
- const drizzleRelName = relation.relationName || key;
3814
- withConfig[drizzleRelName] = true;
3815
- }
3816
- const queryOpts = { with: withConfig };
3817
- if (options.limit) queryOpts.limit = options.limit;
3818
- if (options.filter) {
3819
- const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3820
- if (filterConditions.length > 0) queryOpts.where = and(...filterConditions);
3821
- }
3822
- if (options.orderBy) {
3823
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3824
- if (orderByField) queryOpts.orderBy = options.order === "asc" ? asc(orderByField) : desc(orderByField);
3825
- }
3826
- return (await queryTarget.findMany(queryOpts)).map((row) => {
3827
- const flat = {};
3828
- for (const [k, v] of Object.entries(row)) if (Array.isArray(v)) flat[k] = v.map((item) => {
3829
- const keys = Object.keys(item);
3830
- const nestedObj = keys.find((nk) => typeof item[nk] === "object" && item[nk] !== null && !Array.isArray(item[nk]));
3831
- if (nestedObj && keys.length <= 3) return { ...item[nestedObj] };
3832
- return { ...item };
3833
- });
3834
- else if (typeof v === "object" && v !== null) flat[k] = { ...v };
3835
- else flat[k] = v;
3836
- return flat;
3837
- });
3838
- } catch (e) {
3839
- logger.warn(`[include] Drizzle relational query failed for '${collectionPath}', falling back`, { error: e });
3840
- return null;
3841
- }
3842
- }
3843
- /**
3844
4335
  * Fallback path used when db.query is unavailable.
3845
4336
  * The primary path uses db.query.findMany with `with` config, which
3846
4337
  * loads all relations in a single query.
@@ -4023,6 +4514,7 @@ var PersistService = class {
4023
4514
  const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
4024
4515
  const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
4025
4516
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
4517
+ assertWritableColumns(entityData, table, effectiveCollectionPath);
4026
4518
  savedId = await this.db.transaction(async (tx) => {
4027
4519
  let currentId;
4028
4520
  if (id && !options?.upsert) {
@@ -4049,6 +4541,7 @@ var PersistService = class {
4049
4541
  const target = idInfoArray.map((info) => table[info.fieldName]);
4050
4542
  const set = { ...dataForInsert };
4051
4543
  for (const info of idInfoArray) delete set[info.fieldName];
4544
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
4052
4545
  result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
4053
4546
  target,
4054
4547
  set
@@ -4091,7 +4584,8 @@ var PersistService = class {
4091
4584
  if (error instanceof ApiError || error?.name === "ApiError") return error;
4092
4585
  const pgError = extractPgError(error);
4093
4586
  if (pgError) {
4094
- const { message } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4587
+ const { message, code } = pgErrorToFriendlyMessage(pgError, collectionSlug);
4588
+ if (/^2[23]/.test(code)) return code === "23505" ? ApiError.conflict(message, `PG_${code}`) : ApiError.badRequest(message, `PG_${code}`);
4095
4589
  return new Error(message);
4096
4590
  }
4097
4591
  const causeMessage = extractCauseMessage(error);
@@ -4316,6 +4810,7 @@ var BranchService = class {
4316
4810
  metadata JSONB DEFAULT '{}'
4317
4811
  );
4318
4812
  `));
4813
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
4319
4814
  }
4320
4815
  /**
4321
4816
  * Create a new branch database by templating the source database.
@@ -4423,43 +4918,49 @@ var BranchService = class {
4423
4918
  };
4424
4919
  //#endregion
4425
4920
  //#region src/security/rls-enforcement.ts
4921
+ var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
4922
+ /** DML the user role holds on managed tables (RLS still filters per row). */
4923
+ var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
4426
4924
  /**
4427
- * Unified RLS enforcement the "user context vs server context" model.
4428
- *
4429
- * Every operation runs in one of two contexts:
4925
+ * Warn when the connection role shares its name with an existing schema.
4430
4926
  *
4431
- * - **User context** a request authenticated (or anonymous) via
4432
- * `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a
4433
- * non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*
4434
- * statement (SELECT, INSERT, UPDATE, DELETE). The collection's
4435
- * `securityRules` are the whole authorization model; app-layer callbacks
4436
- * are validation/side-effects, not a security boundary.
4927
+ * Postgres resolves unqualified names through `search_path`, which defaults to
4928
+ * `"$user", public` and `$user` is the connection ROLE. When a schema of that
4929
+ * name exists it sits ahead of `public`, so every unqualified statement
4930
+ * silently operates on it instead:
4437
4931
  *
4438
- * - **Server context** the base (owner) connection: auth flows, migrations,
4439
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
4440
- * owner it bypasses RLS. This is the trusted plane, equivalent to
4441
- * Supabase's `service_role`.
4932
+ * CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
4442
4933
  *
4443
- * This module provides the three pieces:
4934
+ * Nothing errors. You get a second table of the same name in the wrong schema,
4935
+ * and reads that pin `public` cannot see it — which reads as "missing table" and
4936
+ * sends people to re-run a push that creates a *third* copy. The bootstrapper
4937
+ * has a whole branch dedicated to recognising the symptom after the fact.
4444
4938
  *
4445
- * 1. {@link detectConnectionPosture} is the connection subject to RLS at
4446
- * all? (superuser / BYPASSRLS / table owner no)
4447
- * 2. {@link ensureAppRole} idempotently provision `rebase_user` with
4448
- * SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future
4449
- * tables stay covered).
4450
- * 3. {@link applyAuthContext}per-transaction: set the `app.*` GUCs the
4451
- * policies read (`auth.uid()` etc.) and `SET LOCAL ROLE rebase_user` so
4452
- * RLS binds. Transaction-scoped, so it composes with poolers.
4939
+ * Rebase shipped straight into this: it creates a schema named `rebase` while
4940
+ * every template named the database role `rebase` too. The scaffold uses
4941
+ * `rebase_app` now, and every pool Rebase opens pins `search_path=public`
4942
+ * (`pinSearchPath`), which covers the paths the framework controls. This covers
4943
+ * the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
4944
+ * a hand-written migration because the hazard is a property of the two NAMES,
4945
+ * not of any one connection.
4453
4946
  *
4454
- * Provisioning runs from the framework's own bootstrap/migrate (which already
4455
- * self-creates the `auth` schema and functions) enforcement is default-on,
4456
- * not an operator opt-in.
4947
+ * A warning rather than a boot failure: the database works, the framework's own
4948
+ * traffic is pinned, and refusing to start over a naming choice a user may have
4949
+ * inherited would be worse than the risk.
4457
4950
  */
4458
- /** The restricted role every authenticated (user-context) request runs as. */
4459
- var REBASE_USER_ROLE = "rebase_user";
4460
- var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
4461
- /** DML the user role holds on managed tables (RLS still filters per row). */
4462
- var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
4951
+ async function warnOnRoleSchemaCollision(run) {
4952
+ try {
4953
+ const rows = await run(`
4954
+ SELECT current_user AS role,
4955
+ EXISTS (
4956
+ SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
4957
+ ) AS collides
4958
+ `);
4959
+ if (rows[0]?.collides !== true) return;
4960
+ const role = String(rows[0]?.role ?? "the connection role");
4961
+ logger.warn(`⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the hazard entirely, connect as a role whose name is not also a schema — the scaffold uses "rebase_app".`);
4962
+ } catch {}
4963
+ }
4463
4964
  async function detectConnectionPosture(run) {
4464
4965
  const row = (await run(`
4465
4966
  SELECT current_user AS role,
@@ -4535,6 +5036,9 @@ async function ensureAppRole(run, schemas) {
4535
5036
  await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
4536
5037
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
4537
5038
  await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
5039
+ await revokeInternalTableAccess(async (text) => {
5040
+ await run(text);
5041
+ }, schema, { onError: (table, error) => logger.warn(`🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — it stays reachable by authenticated requests: ` + (error instanceof Error ? error.message : String(error))) });
4538
5042
  }
4539
5043
  logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
4540
5044
  }
@@ -4620,6 +5124,40 @@ function warnOnAnonymousGrants(collections) {
4620
5124
  logger.warn(`Security rules that read as a lockdown but grant access to anonymous requests. Every caller from a client carries a user id ('${ANONYMOUS_USER_ID}' when nobody is signed in), so these clauses are true for everyone:\n\n` + problems.join("\n\n") + "\n");
4621
5125
  }
4622
5126
  /**
5127
+ * Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
5128
+ *
5129
+ * The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
5130
+ * database, so nothing is broken and no policy is wrong — which is exactly why
5131
+ * this has to be said out loud. A silent rewrite that works forever is not a
5132
+ * migration, it is a second supported spelling nobody wrote down, and the next
5133
+ * person to read those rules will copy the old one.
5134
+ *
5135
+ * Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
5136
+ * `policy.rolesOverlap(...)`) compile from the model and were never affected.
5137
+ */
5138
+ function warnOnLegacyRlsFunctions(collections) {
5139
+ const sites = [];
5140
+ for (const collection of collections) for (const rule of collection.securityRules ?? []) {
5141
+ const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
5142
+ if (![usingExpr, withCheckExpr].filter((e) => e !== null).some(containsLegacyRlsCall)) continue;
5143
+ const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
5144
+ if (!sites.includes(site)) sites.push(site);
5145
+ }
5146
+ if (sites.length === 0) return;
5147
+ logger.warn(`These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), which never had to be spelled by hand:\n\n` + sites.map((s) => ` • ${s}`).join("\n") + "\n");
5148
+ }
5149
+ /** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
5150
+ function containsLegacyRlsCall(expr) {
5151
+ switch (expr.kind) {
5152
+ case "raw": return usesLegacyRlsFunctions(expr.sql);
5153
+ case "and":
5154
+ case "or": return expr.operands.some(containsLegacyRlsCall);
5155
+ case "not": return containsLegacyRlsCall(expr.operand);
5156
+ case "existsIn": return containsLegacyRlsCall(expr.where);
5157
+ default: return false;
5158
+ }
5159
+ }
5160
+ /**
4623
5161
  * Reject `pgRoles` that this server can never satisfy.
4624
5162
  *
4625
5163
  * `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
@@ -4749,6 +5287,25 @@ var PostgresBackendDriver = class PostgresBackendDriver {
4749
5287
  }
4750
5288
  };
4751
5289
  }
5290
+ /**
5291
+ * Build the context handed to every collection callback.
5292
+ *
5293
+ * Note `data: this.data` — `this` is whichever driver is running the
5294
+ * operation, so the callback's data plane inherits that driver's privilege.
5295
+ * On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
5296
+ * constructs a fresh base driver bound to the RLS-scoped transaction and
5297
+ * runs the operation on it, so `this.data` speaks through that connection
5298
+ * and policies apply. On server-context work `this` is the base driver on
5299
+ * the owner connection, and they do not. Pinned by the
5300
+ * `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
5301
+ * suite, because it is the kind of property that is easy to break from a
5302
+ * distance and impossible to notice.
5303
+ *
5304
+ * Previously returned through `as unknown as RebaseCallContext`, which
5305
+ * disabled checking for the whole object and let `driver` — documented in
5306
+ * the callbacks guide — sit on the runtime context while absent from the
5307
+ * contract. Both are declared now, so this is a plain typed return.
5308
+ */
4752
5309
  buildCallContext() {
4753
5310
  return {
4754
5311
  user: this.user,
@@ -5204,6 +5761,106 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5204
5761
  return saved;
5205
5762
  });
5206
5763
  }
5764
+ /**
5765
+ * Update many rows through the same pipeline as {@link save}, in one
5766
+ * transaction.
5767
+ *
5768
+ * Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
5769
+ * same deferred notifications, same per-row error labelling — but it calls
5770
+ * `save` with an explicit `id` and `status: "existing"`, which is precisely
5771
+ * what `saveMany` cannot do: that one passes `status: "new"` and keeps the
5772
+ * key inside `values`, so it inserts or upserts and can never target a
5773
+ * particular row.
5774
+ *
5775
+ * All-or-nothing, so an id matching no row aborts the batch. A partial
5776
+ * update is the outcome with no good recovery: the caller cannot tell which
5777
+ * half landed without re-reading everything.
5778
+ */
5779
+ async updateMany({ path, updates, collection }) {
5780
+ return this.db.transaction(async (tx) => {
5781
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5782
+ txDriver.dataService = new DataService(tx, this.registry);
5783
+ txDriver.client = this.client;
5784
+ txDriver._deferNotifications = this._deferNotifications;
5785
+ txDriver._pendingNotifications = this._pendingNotifications;
5786
+ const saved = [];
5787
+ for (let i = 0; i < updates.length; i++) {
5788
+ const { id, values } = updates[i];
5789
+ try {
5790
+ if (!await txDriver.fetchOne({
5791
+ path,
5792
+ id: String(id),
5793
+ collection
5794
+ })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5795
+ statusCode: 404,
5796
+ code: "NOT_FOUND"
5797
+ });
5798
+ saved.push(await txDriver.save({
5799
+ path,
5800
+ id: String(id),
5801
+ values,
5802
+ collection,
5803
+ status: "existing"
5804
+ }));
5805
+ } catch (error) {
5806
+ throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5807
+ statusCode: error?.statusCode,
5808
+ code: error?.code,
5809
+ name: error?.name
5810
+ });
5811
+ }
5812
+ }
5813
+ return saved;
5814
+ });
5815
+ }
5816
+ /**
5817
+ * Delete many rows in one transaction, running the full delete pipeline —
5818
+ * `beforeDelete`, the delete, `afterDelete` — for each.
5819
+ *
5820
+ * Looping the single-row {@link delete} rather than emitting one
5821
+ * `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
5822
+ * statement would be faster and would skip every callback, so a collection
5823
+ * relying on `beforeDelete` to veto or on `afterDelete` to clean up
5824
+ * dependents would behave differently depending on how many rows the caller
5825
+ * happened to delete at once. Same pipeline, one transaction.
5826
+ */
5827
+ async deleteMany({ path, ids, collection }) {
5828
+ await this.db.transaction(async (tx) => {
5829
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5830
+ txDriver.dataService = new DataService(tx, this.registry);
5831
+ txDriver.client = this.client;
5832
+ txDriver._deferNotifications = this._deferNotifications;
5833
+ txDriver._pendingNotifications = this._pendingNotifications;
5834
+ for (let i = 0; i < ids.length; i++) {
5835
+ const id = ids[i];
5836
+ try {
5837
+ const existing = await txDriver.fetchOne({
5838
+ path,
5839
+ id: String(id),
5840
+ collection
5841
+ });
5842
+ if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5843
+ statusCode: 404,
5844
+ code: "NOT_FOUND"
5845
+ });
5846
+ await txDriver.delete({
5847
+ row: {
5848
+ id: String(id),
5849
+ path,
5850
+ values: existing
5851
+ },
5852
+ collection
5853
+ });
5854
+ } catch (error) {
5855
+ throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5856
+ statusCode: error?.statusCode,
5857
+ code: error?.code,
5858
+ name: error?.name
5859
+ });
5860
+ }
5861
+ }
5862
+ });
5863
+ }
5207
5864
  async delete({ row, collection }) {
5208
5865
  const targetPath = row.path;
5209
5866
  const targetRow = { ...row.values ?? {} };
@@ -5781,6 +6438,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
5781
6438
  * that rotates immediately after it.
5782
6439
  */
5783
6440
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
6441
+ /**
6442
+ * The assurance level the sign-in was established at — `aal2` only
6443
+ * where a second factor was actually presented. Carried across
6444
+ * rotations, because refresh is not a new authentication and has
6445
+ * nothing else to read the level from.
6446
+ */
6447
+ aal: text("aal"),
5784
6448
  userAgent: text("user_agent"),
5785
6449
  ipAddress: text("ip_address"),
5786
6450
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -5826,6 +6490,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
5826
6490
  secretEncrypted: text("secret_encrypted").notNull(),
5827
6491
  friendlyName: text("friendly_name"),
5828
6492
  verified: boolean("verified").default(false).notNull(),
6493
+ /**
6494
+ * The highest TOTP time step ever accepted for this factor. RFC 6238
6495
+ * §5.2 forbids accepting an OTP twice, and the ±1 step window that
6496
+ * exists for clock drift is also a 90-second replay window: without
6497
+ * this, one observed code buys a fresh session for a minute and a half.
6498
+ */
6499
+ lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
5829
6500
  createdAt: timestamp("created_at").defaultNow().notNull(),
5830
6501
  updatedAt: timestamp("updated_at").defaultNow().notNull()
5831
6502
  });
@@ -5843,6 +6514,8 @@ function createAuthSchema(usersSchemaName = "rebase") {
5843
6514
  createdAt: timestamp("created_at").defaultNow().notNull(),
5844
6515
  verifiedAt: timestamp("verified_at"),
5845
6516
  ipAddress: text("ip_address"),
6517
+ /** Failed guesses recorded against this challenge; bounded by the route. */
6518
+ attempts: integer("attempts").default(0).notNull(),
5846
6519
  expiresAt: timestamp("expires_at").notNull()
5847
6520
  }),
5848
6521
  recoveryCodes: tableCreator("recovery_codes", {
@@ -5919,6 +6592,26 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
5919
6592
  * Uses the explicit `columnName` when set (e.g. from introspection),
5920
6593
  * falling back to `toSnakeCase(propName)` for manually-authored collections.
5921
6594
  */
6595
+ var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6596
+ /**
6597
+ * A string literal for the generated schema file.
6598
+ *
6599
+ * Column names, table names and enum values are all written into this file as
6600
+ * literals, and none of them is constrained to be quote-free: a Postgres
6601
+ * identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
6602
+ * Interpolating them raw ended the literal early — for enum values, inside
6603
+ * single quotes, where an apostrophe is not an edge case.
6604
+ */
6605
+ var quote$1 = (value) => JSON.stringify(value);
6606
+ /** An object key: verbatim when it is an identifier, quoted otherwise. */
6607
+ var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
6608
+ /**
6609
+ * A property access on a generated table variable.
6610
+ *
6611
+ * `users.full name` is not an expression; `users["full name"]` is, and Drizzle
6612
+ * treats the two identically.
6613
+ */
6614
+ var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
5922
6615
  var resolveColumnName = (propName, prop) => {
5923
6616
  if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
5924
6617
  return toSnakeCase(propName);
@@ -5979,18 +6672,25 @@ var isIdProperty = (propName, prop, collection) => {
5979
6672
  if ("isId" in prop && Boolean(prop.isId)) return true;
5980
6673
  return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
5981
6674
  };
6675
+ /**
6676
+ * The Drizzle column declaration a property compiles to, or `null` when the
6677
+ * property puts no column on *this* table (an inverse relation, whose column
6678
+ * lives on the target). Exported so it can be checked against its DDL twin
6679
+ * `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
6680
+ * with a database column and no Drizzle key.
6681
+ */
5982
6682
  var getDrizzleColumn = (propName, prop, collection, collections) => {
5983
6683
  const colName = resolveColumnName(propName, prop);
5984
6684
  let columnDefinition;
5985
6685
  switch (prop.type) {
5986
6686
  case "string": {
5987
6687
  const stringProp = prop;
5988
- if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}("${colName}")`;
5989
- else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid("${colName}")`;
5990
- else if (stringProp.columnType === "uuid") columnDefinition = `uuid("${colName}")`;
5991
- else if (stringProp.columnType === "char") columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
5992
- else if (stringProp.columnType === "varchar") columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
5993
- else columnDefinition = `text("${colName}")`;
6688
+ if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
6689
+ else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
6690
+ else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
6691
+ else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
6692
+ else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
6693
+ else columnDefinition = `text(${quote$1(colName)})`;
5994
6694
  if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
5995
6695
  if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
5996
6696
  if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
@@ -6006,10 +6706,10 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
6006
6706
  case "number": {
6007
6707
  const numProp = prop;
6008
6708
  const isId = isIdProperty(propName, prop, collection);
6009
- let baseType = numProp.validation?.integer || isId ? `integer("${colName}")` : `numeric("${colName}")`;
6010
- if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
6011
- else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
6012
- else baseType = `${numProp.columnType}("${colName}")`;
6709
+ let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
6710
+ if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
6711
+ else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
6712
+ else baseType = `${numProp.columnType}(${quote$1(colName)})`;
6013
6713
  if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
6014
6714
  else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
6015
6715
  columnDefinition = baseType;
@@ -6021,19 +6721,22 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
6021
6721
  break;
6022
6722
  }
6023
6723
  case "boolean":
6024
- columnDefinition = `boolean("${colName}")`;
6724
+ columnDefinition = `boolean(${quote$1(colName)})`;
6025
6725
  break;
6026
6726
  case "date": {
6027
6727
  const dateProp = prop;
6028
- if (dateProp.columnType === "date") columnDefinition = `date("${colName}", { mode: 'string' })`;
6029
- else if (dateProp.columnType === "time") columnDefinition = `time("${colName}")`;
6030
- else columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
6728
+ if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
6729
+ else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
6730
+ else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
6031
6731
  if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
6032
6732
  break;
6033
6733
  }
6034
6734
  case "map":
6035
- if (prop.columnType === "json") columnDefinition = `json("${colName}")`;
6036
- else columnDefinition = `jsonb("${colName}")`;
6735
+ if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
6736
+ else columnDefinition = `jsonb(${quote$1(colName)})`;
6737
+ break;
6738
+ case "geopoint":
6739
+ columnDefinition = `jsonb(${quote$1(colName)})`;
6037
6740
  break;
6038
6741
  case "array": {
6039
6742
  const arrayProp = prop;
@@ -6044,19 +6747,21 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
6044
6747
  else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
6045
6748
  else if (ofProp.type === "boolean") colType = "boolean[]";
6046
6749
  }
6047
- if (colType === "json") columnDefinition = `json("${colName}")`;
6048
- else if (colType === "text[]") columnDefinition = `text("${colName}").array()`;
6049
- else if (colType === "integer[]") columnDefinition = `integer("${colName}").array()`;
6050
- else if (colType === "boolean[]") columnDefinition = `boolean("${colName}").array()`;
6051
- else if (colType === "numeric[]") columnDefinition = `numeric("${colName}").array()`;
6052
- else columnDefinition = `jsonb("${colName}")`;
6750
+ if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
6751
+ else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
6752
+ else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
6753
+ else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
6754
+ else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
6755
+ else columnDefinition = `jsonb(${quote$1(colName)})`;
6053
6756
  break;
6054
6757
  }
6055
- case "vector":
6056
- columnDefinition = `vector("${colName}", { dimensions: ${prop.dimensions} })`;
6758
+ case "vector": {
6759
+ const vp = prop;
6760
+ columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
6057
6761
  break;
6762
+ }
6058
6763
  case "binary":
6059
- columnDefinition = `customType({ dataType() { return 'bytea'; } })("${colName}")`;
6764
+ columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
6060
6765
  break;
6061
6766
  case "relation": {
6062
6767
  const refProp = prop;
@@ -6078,30 +6783,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
6078
6783
  const required = prop.validation?.required;
6079
6784
  const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
6080
6785
  const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
6081
- let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
6786
+ let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
6082
6787
  if (required) columnDef += ".notNull()";
6083
- return ` ${relation.localKey}: ${columnDef}`;
6788
+ return ` ${propKey(relation.localKey)}: ${columnDef}`;
6084
6789
  }
6085
6790
  case "reference": {
6086
6791
  const refProp = prop;
6087
6792
  const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
6088
6793
  if (!targetCollection) {
6089
- columnDefinition = `text("${colName}")`;
6794
+ columnDefinition = `text(${quote$1(colName)})`;
6090
6795
  break;
6091
6796
  }
6092
6797
  const pkProp = getPrimaryKeyProp(targetCollection);
6093
6798
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
6094
6799
  const targetIdField = pkProp.name;
6095
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`;
6800
+ const baseColumn = pkProp.type === "number" ? `integer(${quote$1(colName)})` : pkProp.isUuid ? `uuid(${quote$1(colName)})` : `text(${quote$1(colName)})`;
6096
6801
  const required = prop.validation?.required;
6097
- columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${`{ onDelete: "${required ? "cascade" : "set null"}" }`})`;
6802
+ const refOptions = `{ onDelete: "${required ? "cascade" : "set null"}" }`;
6803
+ columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
6098
6804
  if (required) columnDefinition += ".notNull()";
6099
- return ` ${propName}: ${columnDefinition}`;
6805
+ return ` ${propKey(propName)}: ${columnDefinition}`;
6100
6806
  }
6101
- default: return null;
6807
+ default: throw new Error(`No Postgres column mapping for property '${propName}' of type '${prop.type}' in collection '${collection.slug}'. Add a case to \`getDrizzleColumn\` (and to \`getSqlColumnType\`, which must agree).`);
6102
6808
  }
6103
6809
  if (prop.validation?.required) columnDefinition += ".notNull()";
6104
- return ` ${propName}: ${columnDefinition}`;
6810
+ return ` ${propKey(propName)}: ${columnDefinition}`;
6105
6811
  };
6106
6812
  /**
6107
6813
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
@@ -6134,7 +6840,7 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
6134
6840
  parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
6135
6841
  if (usingClause) parts.push(`using: ${usingClause}`);
6136
6842
  if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
6137
- return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
6843
+ return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
6138
6844
  };
6139
6845
  /**
6140
6846
  * Computes a deterministic shared relation name for Drizzle.
@@ -6168,11 +6874,13 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
6168
6874
  }
6169
6875
  return fallback;
6170
6876
  };
6171
- var generateSchema = async (collections, stripPolicies = false) => {
6877
+ var generateSchema = async (allCollections, stripPolicies = false) => {
6878
+ const collections = sortCollectionsBySlug(relationalCollections(allCollections));
6172
6879
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
6173
6880
  const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
6174
6881
  const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
6175
6882
  const hasBinary = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "binary"));
6883
+ const hasSearch = collections.some((c) => buildSearchColumnSpec(c) !== void 0);
6176
6884
  const pgCoreImports = [
6177
6885
  "primaryKey",
6178
6886
  "pgTable",
@@ -6197,7 +6905,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
6197
6905
  ];
6198
6906
  if (hasUuid) pgCoreImports.push("uuid");
6199
6907
  if (hasVector) pgCoreImports.push("vector");
6200
- if (hasBinary) pgCoreImports.push("customType");
6908
+ if (hasBinary || hasSearch) pgCoreImports.push("customType");
6201
6909
  const uniqueSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
6202
6910
  if (uniqueSchemas.length > 0) pgCoreImports.push("pgSchema");
6203
6911
  schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
@@ -6218,7 +6926,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
6218
6926
  const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
6219
6927
  const values = Array.isArray(prop.enum) ? prop.enum.map((v) => String(typeof v === "object" && v !== null && "id" in v ? v.id : v)) : Object.keys(prop.enum);
6220
6928
  if (values.length > 0) {
6221
- schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map((v) => `'${v}'`).join(", ")}]);\n`;
6929
+ schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
6222
6930
  if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
6223
6931
  }
6224
6932
  }
@@ -6279,6 +6987,11 @@ var generateSchema = async (collections, stripPolicies = false) => {
6279
6987
  const columnString = getDrizzleColumn(propName, prop, collection, collections);
6280
6988
  if (columnString) columns.add(columnString);
6281
6989
  });
6990
+ const searchSpec = buildSearchColumnSpec(collection);
6991
+ if (searchSpec) {
6992
+ columns.add(` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}").generatedAlwaysAs(sql\`${searchSpec.expression}\`)`);
6993
+ if (searchSpec.fuzzy) columns.add(` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}").generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`);
6994
+ }
6282
6995
  if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
6283
6996
  schemaContent += `${Array.from(columns).join(",\n")}`;
6284
6997
  const securityRules = getEffectiveSecurityRules(collection);
@@ -6315,9 +7028,9 @@ var generateSchema = async (collections, stripPolicies = false) => {
6315
7028
  break;
6316
7029
  }
6317
7030
  } catch {}
6318
- tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
7031
+ tableRelations.push(` ${quote$1(relation.through.sourceColumn)}: one(${sourceTableVar}, {\n fields: [${member(tableVarName, relation.through.sourceColumn)}],\n references: [${member(sourceTableVar, sourceId)}],\n relationName: ${quote$1(owningRelationName)}\n })`);
6319
7032
  const targetRelationName = inverseRelationName ? inverseRelationName : `${tableName}_${relation.through.targetColumn}`;
6320
- tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: "${targetRelationName}"\n })`);
7033
+ tableRelations.push(` ${quote$1(relation.through.targetColumn)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, relation.through.targetColumn)}],\n references: [${member(targetTableVar, targetId)}],\n relationName: ${quote$1(targetRelationName)}\n })`);
6321
7034
  }
6322
7035
  } else {
6323
7036
  const resolvedRelations = resolveCollectionRelations(collection);
@@ -6332,7 +7045,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
6332
7045
  switch (rel.kind) {
6333
7046
  case "belongsTo": {
6334
7047
  const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
6335
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${localFieldKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
7048
+ tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
6336
7049
  break;
6337
7050
  }
6338
7051
  case "hasOne":
@@ -6363,7 +7076,7 @@ var generateSchema = async (collections, stripPolicies = false) => {
6363
7076
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
6364
7077
  const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
6365
7078
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
6366
- tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
7079
+ tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
6367
7080
  emittedRelationNames.add(deduplicationKey);
6368
7081
  }
6369
7082
  }
@@ -6982,6 +7695,8 @@ var ChannelHistoryStore = class {
6982
7695
  last_seq BIGINT NOT NULL
6983
7696
  )
6984
7697
  `);
7698
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
7699
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
6985
7700
  this.tablesReady = true;
6986
7701
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
6987
7702
  }
@@ -7165,6 +7880,7 @@ var ChannelPresenceStore = class {
7165
7880
  CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
7166
7881
  ON rebase.channel_presence (last_seen)
7167
7882
  `);
7883
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
7168
7884
  this.tablesReady = true;
7169
7885
  }
7170
7886
  /** Record (or refresh) a client's presence. */
@@ -7556,6 +8272,12 @@ var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
7556
8272
  var RealtimeService = class RealtimeService extends EventEmitter {
7557
8273
  db;
7558
8274
  registry;
8275
+ /**
8276
+ * Declares to the multi-engine router that channel frames can be handled
8277
+ * here. Read by `createRoutedRealtimeService`, which otherwise would have to
8278
+ * guess — and guessed "the default provider", whichever engine that is.
8279
+ */
8280
+ supportsChannels = true;
7559
8281
  clients = /* @__PURE__ */ new Map();
7560
8282
  channels = /* @__PURE__ */ new Map();
7561
8283
  presence = /* @__PURE__ */ new Map();
@@ -7602,6 +8324,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7602
8324
  * so a hot channel logs the problem once rather than once per message.
7603
8325
  */
7604
8326
  oversizedBroadcastWarned = /* @__PURE__ */ new Set();
8327
+ /**
8328
+ * Optional narrowing on top of the membership floor — see
8329
+ * {@link ChannelAuthorizer}. Unset by default, which leaves membership as
8330
+ * the whole of the rule.
8331
+ */
8332
+ channelAuthorizer;
8333
+ /**
8334
+ * Whether a notification from another instance has ever arrived.
8335
+ *
8336
+ * The entity LISTEN handler sees a foreign `sid` on every cross-instance
8337
+ * change, which is proof that this deployment runs more than one pod — the
8338
+ * one fact needed to tell "the memory bus is fine here" from "broadcast and
8339
+ * presence silently reach a fraction of your users".
8340
+ */
8341
+ foreignInstanceSeen = false;
8342
+ /** So the multi-pod memory-bus warning is emitted once, not once per join. */
8343
+ memoryBusWarned = false;
7605
8344
  presenceInterval;
7606
8345
  static PRESENCE_TIMEOUT_MS = 3e4;
7607
8346
  /** How often stale roster rows from other instances are reaped. */
@@ -7695,7 +8434,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7695
8434
  limit: config.limit,
7696
8435
  startAfter: config.startAfter,
7697
8436
  databaseId: config.databaseId,
7698
- searchString: config.searchString
8437
+ searchString: config.searchString,
8438
+ searchExplain: config.searchExplain
7699
8439
  }
7700
8440
  });
7701
8441
  if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
@@ -7775,26 +8515,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7775
8515
  await this.handleUnsubscribe(clientId, message.subscriptionId);
7776
8516
  break;
7777
8517
  case "join_channel":
7778
- this.joinChannel(clientId, payload?.channel);
7779
- break;
7780
8518
  case "leave_channel":
7781
- this.leaveChannel(clientId, payload?.channel);
7782
- break;
7783
8519
  case "broadcast":
7784
- this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
7785
- break;
7786
8520
  case "channel_history":
7787
- await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
7788
- break;
7789
8521
  case "presence_track":
7790
- this.joinChannel(clientId, payload?.channel);
7791
- this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
7792
- break;
7793
8522
  case "presence_untrack":
7794
- this.removePresence(clientId, payload?.channel);
7795
- break;
7796
8523
  case "presence_state":
7797
- this.sendPresenceState(clientId, payload?.channel);
8524
+ await this.handleChannelMessage(clientId, message.type, payload, authContext);
7798
8525
  break;
7799
8526
  default: this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
7800
8527
  }
@@ -7809,30 +8536,40 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7809
8536
  this.sendError(clientId, msg, subscriptionId);
7810
8537
  return;
7811
8538
  }
7812
- const boundedLimit = resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch });
8539
+ if (request.vectorSearch) {
8540
+ const msg = "Realtime subscriptions do not support vector search: a subscription is re-run on every matching write, and nothing here computes distances. Use `.vectorSearch(...).find()` for the query, and subscribe without it if you need live updates.";
8541
+ logger.warn(`[RealtimeService] ${msg}`);
8542
+ this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
8543
+ return;
8544
+ }
8545
+ let boundedLimit;
8546
+ try {
8547
+ boundedLimit = resolveClientListLimit(request.limit);
8548
+ } catch (e) {
8549
+ if (!(e instanceof ListLimitError)) throw e;
8550
+ logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
8551
+ this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
8552
+ return;
8553
+ }
7813
8554
  this._subscriptions.set(subscriptionId, {
7814
8555
  clientId,
7815
8556
  type: "collection",
7816
8557
  path: request.path,
7817
8558
  collectionRequest: {
7818
8559
  filter: request.filter,
8560
+ logical: request.logical,
7819
8561
  orderBy: request.orderBy,
7820
8562
  order: request.order,
7821
8563
  limit: boundedLimit,
8564
+ offset: request.offset,
7822
8565
  startAfter: request.startAfter,
7823
8566
  databaseId: request.collection?.databaseId,
7824
- searchString: request.searchString
8567
+ searchString: request.searchString,
8568
+ searchExplain: request.searchExplain
7825
8569
  },
7826
8570
  authContext
7827
8571
  });
7828
- const rows = await this.fetchCollectionWithAuth(request.path, {
7829
- filter: request.filter,
7830
- orderBy: request.orderBy,
7831
- order: request.order,
7832
- limit: boundedLimit,
7833
- startAfter: request.startAfter,
7834
- searchString: request.searchString
7835
- }, authContext);
8572
+ const rows = await this.fetchCollectionWithAuth(request.path, this._subscriptions.get(subscriptionId).collectionRequest, authContext);
7836
8573
  this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
7837
8574
  } catch (error) {
7838
8575
  const sanitized = sanitizeErrorForClient(error, request.path);
@@ -8009,13 +8746,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8009
8746
  let fetchedEntities;
8010
8747
  if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
8011
8748
  filter: collectionRequest.filter,
8749
+ logical: collectionRequest.logical,
8012
8750
  orderBy: collectionRequest.orderBy,
8013
8751
  order: collectionRequest.order,
8014
8752
  limit: collectionRequest.limit,
8015
- databaseId: collectionRequest.databaseId
8753
+ databaseId: collectionRequest.databaseId,
8754
+ searchExplain: collectionRequest.searchExplain
8016
8755
  });
8017
8756
  else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
8018
8757
  filter: collectionRequest.filter,
8758
+ logical: collectionRequest.logical,
8019
8759
  orderBy: collectionRequest.orderBy,
8020
8760
  order: collectionRequest.order,
8021
8761
  limit: collectionRequest.limit,
@@ -8068,13 +8808,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8068
8808
  }
8069
8809
  if (collectionRequest.searchString) return await this.dataService.searchRows(notifyPath, collectionRequest.searchString, {
8070
8810
  filter: collectionRequest.filter,
8811
+ logical: collectionRequest.logical,
8071
8812
  orderBy: collectionRequest.orderBy,
8072
8813
  order: collectionRequest.order,
8073
8814
  limit: collectionRequest.limit,
8074
- databaseId: collectionRequest.databaseId
8815
+ databaseId: collectionRequest.databaseId,
8816
+ searchExplain: collectionRequest.searchExplain
8075
8817
  });
8076
8818
  return await this.dataService.fetchCollection(notifyPath, {
8077
8819
  filter: collectionRequest.filter,
8820
+ logical: collectionRequest.logical,
8078
8821
  orderBy: collectionRequest.orderBy,
8079
8822
  order: collectionRequest.order,
8080
8823
  limit: collectionRequest.limit,
@@ -8258,12 +9001,148 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8258
9001
  }
8259
9002
  return parentPaths;
8260
9003
  }
9004
+ /**
9005
+ * Install a channel authorizer — see {@link ChannelAuthorizer}.
9006
+ *
9007
+ * Nothing in the framework calls this yet: it is the seam a rules API will
9008
+ * be built on, kept deliberately separate from the membership floor so the
9009
+ * floor holds whether or not anyone uses it.
9010
+ */
9011
+ setChannelAuthorizer(authorizer) {
9012
+ this.channelAuthorizer = authorizer;
9013
+ }
9014
+ /** Which action each channel frame is asking to perform. */
9015
+ static CHANNEL_ACTIONS = {
9016
+ join_channel: "join",
9017
+ broadcast: "broadcast",
9018
+ channel_history: "history",
9019
+ presence_track: "join",
9020
+ presence_state: "presence"
9021
+ };
9022
+ /**
9023
+ * The one door every channel frame comes through.
9024
+ *
9025
+ * Returns synchronously — and so dispatches synchronously — unless an
9026
+ * authorizer is installed. That matters: a client sends `join_channel`,
9027
+ * `presence_state` and `channel_history` back to back on connect, and the
9028
+ * socket's message handler processes each frame up to its first `await`,
9029
+ * so a gate that always yielded would let the reads overtake the join that
9030
+ * is about to authorize them.
9031
+ */
9032
+ handleChannelMessage(clientId, type, payload, authContext) {
9033
+ const channel = payload?.channel;
9034
+ if (type === "leave_channel") {
9035
+ this.leaveChannel(clientId, channel);
9036
+ return;
9037
+ }
9038
+ if (type === "presence_untrack") {
9039
+ this.removePresence(clientId, channel);
9040
+ return;
9041
+ }
9042
+ const action = RealtimeService.CHANNEL_ACTIONS[type];
9043
+ const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
9044
+ if (allowed === false) return;
9045
+ if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
9046
+ return allowed.then((ok) => {
9047
+ if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
9048
+ });
9049
+ }
9050
+ /** Perform an already-authorized channel frame. */
9051
+ dispatchChannelMessage(clientId, type, channel, payload) {
9052
+ switch (type) {
9053
+ case "join_channel":
9054
+ this.joinChannel(clientId, channel);
9055
+ return;
9056
+ case "broadcast":
9057
+ this.broadcastToChannel(clientId, channel, payload?.event, payload?.payload);
9058
+ return;
9059
+ case "channel_history": return this.handleChannelHistoryRequest(clientId, channel, payload?.sinceSeq, payload?.limit);
9060
+ case "presence_track":
9061
+ this.joinChannel(clientId, channel);
9062
+ this.trackPresence(clientId, channel, payload?.state ?? {});
9063
+ return;
9064
+ case "presence_state":
9065
+ this.sendPresenceState(clientId, channel);
9066
+ return;
9067
+ }
9068
+ }
9069
+ /**
9070
+ * Decide whether a client may perform an action on a channel.
9071
+ *
9072
+ * **Membership is the floor.** Reading a channel's presence roster, replaying
9073
+ * its retained history and broadcasting into it all require that this client
9074
+ * has joined it. That is a low bar — joining is open to anyone who can name
9075
+ * the channel — but it is not the bar that was there before, which was none
9076
+ * at all: `channel_history` and `presence_state` answered any socket about
9077
+ * any channel, and a broadcast fanned out to members the sender had never
9078
+ * joined. Two internal tables (`rebase.channel_presence`,
9079
+ * `rebase.channel_messages`) are held outside RLS on the strength of this
9080
+ * check, so it fails closed: an authorizer that throws refuses the frame.
9081
+ *
9082
+ * Anything richer than membership belongs in a {@link ChannelAuthorizer};
9083
+ * this method is where it is consulted, and the only place.
9084
+ */
9085
+ authorizeChannelAction(clientId, channel, action, authContext) {
9086
+ if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
9087
+ this.denyChannelAction(clientId, channel, action, "not a member of the channel");
9088
+ return false;
9089
+ }
9090
+ const authorizer = this.channelAuthorizer;
9091
+ if (!authorizer) return true;
9092
+ let verdict;
9093
+ try {
9094
+ verdict = authorizer({
9095
+ channel,
9096
+ action,
9097
+ clientId,
9098
+ user: authContext
9099
+ });
9100
+ } catch (error) {
9101
+ logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
9102
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
9103
+ return false;
9104
+ }
9105
+ if (typeof verdict === "boolean") {
9106
+ if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
9107
+ return verdict;
9108
+ }
9109
+ return verdict.then((ok) => {
9110
+ if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
9111
+ return ok;
9112
+ }, (error) => {
9113
+ logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
9114
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
9115
+ return false;
9116
+ });
9117
+ }
9118
+ /** Tell the client why its channel frame went nowhere, and say so in the log. */
9119
+ denyChannelAction(clientId, channel, action, reason) {
9120
+ this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
9121
+ this.sendError(clientId, `Refused ${action} on channel "${channel}": ${reason}`, void 0, "CHANNEL_FORBIDDEN");
9122
+ }
8261
9123
  /** Join a broadcast channel */
8262
9124
  joinChannel(clientId, channel) {
8263
9125
  if (!this.channels.has(channel)) this.channels.set(channel, /* @__PURE__ */ new Set());
8264
9126
  this.channels.get(channel).add(clientId);
9127
+ this.warnIfMemoryBusOnMultiplePods();
8265
9128
  this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
8266
9129
  }
9130
+ /**
9131
+ * Say something the first time channels are used on a deployment that is
9132
+ * demonstrably multi-pod while the bus is still the in-memory default.
9133
+ *
9134
+ * Every other warning in this subsystem covers a *configured* bus failing —
9135
+ * the case where the operator already knew a bus mattered. The common
9136
+ * misconfiguration is the opposite one: scaled to two replicas, never
9137
+ * touched `realtime.bus`, and broadcast and presence quietly serve a
9138
+ * fraction of the room. The evidence is already in the process, so use it.
9139
+ */
9140
+ warnIfMemoryBusOnMultiplePods() {
9141
+ if (this.memoryBusWarned) return;
9142
+ if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
9143
+ this.memoryBusWarned = true;
9144
+ logger.warn("⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another instance have been seen — this deployment runs more than one process. Broadcast and presence reach only the clients connected to this one. Set `realtime.bus` (or REBASE_REALTIME_BUS=postgres) to make channels cross-instance.");
9145
+ }
8267
9146
  /** Leave a broadcast channel */
8268
9147
  leaveChannel(clientId, channel) {
8269
9148
  const members = this.channels.get(channel);
@@ -8941,6 +9820,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8941
9820
  try {
8942
9821
  const { sid, p, eid, db } = JSON.parse(msg.payload);
8943
9822
  if (sid === this.instanceId) return;
9823
+ this.foreignInstanceSeen = true;
8944
9824
  this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
8945
9825
  let refetchedRow = null;
8946
9826
  try {
@@ -9131,7 +10011,7 @@ function createBackupCron(config) {
9131
10011
  enabled: config.enabled ?? true,
9132
10012
  timeoutSeconds: 3600,
9133
10013
  async handler({ log }) {
9134
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CD8o_1Sl.js").then((n) => n.r);
10014
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-Bww-Lg0s.js").then((n) => n.r);
9135
10015
  const { destination } = config;
9136
10016
  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 }).`);
9137
10017
  log(`Starting backup of "${dbName}"…`);
@@ -9203,6 +10083,54 @@ var quote = (xs) => Array.from(xs).map((s) => `\`${s}\``).join(", ");
9203
10083
  /** `on.from` / `on.to` accept a single column or a composite tuple. */
9204
10084
  var asColumns = (value) => Array.isArray(value) ? value : [value];
9205
10085
  /**
10086
+ * Distinguish "this column name is wrong" from "the generated schema is old".
10087
+ *
10088
+ * They present identically here — a relation asks for a column the registered
10089
+ * table does not have — but they are opposite problems with opposite fixes, and
10090
+ * getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
10091
+ * projects.
10092
+ *
10093
+ * The registered table is not the database. It comes from the project's
10094
+ * checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
10095
+ * derives foreign-key names: `categories` yields `category_id` where it used to
10096
+ * yield `categorie_id`. Boot-ensure renames the database column to match, so by
10097
+ * the time this runs the *database* is correct and the *generated module* is the
10098
+ * stale one. Reporting "not a column" then points at the wrong artifact, and the
10099
+ * generic fix — "set `through.targetColumn` to one of: …", listing the legacy
10100
+ * name because that is what the stale module still has — talks the reader into
10101
+ * pinning a column that no longer exists.
10102
+ *
10103
+ * So when the wanted name is what the current rule derives, and the table
10104
+ * carries what the *previous* rule would have derived from the same source, say
10105
+ * that instead.
10106
+ *
10107
+ * @param wanted the column the relation asks for
10108
+ * @param available every column the registered table has
10109
+ * @param sources names the default could have been derived from (a slug, a
10110
+ * relation name) — checking against these rather than guessing
10111
+ * backwards from `wanted` keeps the match exact
10112
+ */
10113
+ function staleCodegenRename(wanted, available, sources) {
10114
+ for (const source of sources) {
10115
+ if (!source) continue;
10116
+ const current = generateForeignKeyName(source);
10117
+ const legacy = legacyForeignKeyName(source);
10118
+ if (current !== wanted || legacy === current) continue;
10119
+ if (available.has(legacy) && !available.has(current)) return {
10120
+ legacy,
10121
+ current
10122
+ };
10123
+ }
10124
+ return null;
10125
+ }
10126
+ /** The shared explanation, so every relation kind reports it identically. */
10127
+ function staleCodegenDefect(table, { legacy, current }) {
10128
+ return {
10129
+ 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`,
10130
+ 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.`
10131
+ };
10132
+ }
10133
+ /**
9206
10134
  * Relations whose names do not resolve against the registered schema.
9207
10135
  *
9208
10136
  * Fails open wherever it cannot see enough to be sure — an unregistered source
@@ -9249,19 +10177,31 @@ function findRelationDefects(collections, registry) {
9249
10177
  const targetColumns = columnNames(targetTable);
9250
10178
  switch (relation.kind) {
9251
10179
  case "belongsTo":
9252
- if (!sourceColumns.has(relation.localKey)) defects.push({
9253
- ...at,
9254
- problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
9255
- fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
9256
- });
10180
+ if (!sourceColumns.has(relation.localKey)) {
10181
+ const stale = staleCodegenRename(relation.localKey, sourceColumns, [relation.relationName, targetCollection.slug]);
10182
+ defects.push(stale ? {
10183
+ ...at,
10184
+ ...staleCodegenDefect(sourceTableName, stale)
10185
+ } : {
10186
+ ...at,
10187
+ problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
10188
+ fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
10189
+ });
10190
+ }
9257
10191
  break;
9258
10192
  case "hasOne":
9259
10193
  case "hasMany":
9260
- if (!targetColumns.has(relation.foreignKeyOnTarget)) defects.push({
9261
- ...at,
9262
- problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
9263
- fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
9264
- });
10194
+ if (!targetColumns.has(relation.foreignKeyOnTarget)) {
10195
+ const stale = staleCodegenRename(relation.foreignKeyOnTarget, targetColumns, [collection.slug]);
10196
+ defects.push(stale ? {
10197
+ ...at,
10198
+ ...staleCodegenDefect(targetTableName, stale)
10199
+ } : {
10200
+ ...at,
10201
+ problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
10202
+ fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
10203
+ });
10204
+ }
9265
10205
  if (relation.sourceKey && !sourceColumns.has(relation.sourceKey)) defects.push({
9266
10206
  ...at,
9267
10207
  problem: `\`sourceKey: "${relation.sourceKey}"\` is not a column on \`${sourceTableName}\``,
@@ -9280,11 +10220,21 @@ function findRelationDefects(collections, registry) {
9280
10220
  break;
9281
10221
  }
9282
10222
  const junctionColumns = columnNames(junction);
9283
- for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) defects.push({
9284
- ...at,
9285
- problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
9286
- fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
9287
- });
10223
+ const derivedFrom = {
10224
+ sourceColumn: [collection.slug],
10225
+ targetColumn: [targetCollection.slug]
10226
+ };
10227
+ for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]]) if (!junctionColumns.has(column)) {
10228
+ const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
10229
+ defects.push(stale ? {
10230
+ ...at,
10231
+ ...staleCodegenDefect(table, stale)
10232
+ } : {
10233
+ ...at,
10234
+ problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
10235
+ fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` + (label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
10236
+ });
10237
+ }
9288
10238
  break;
9289
10239
  }
9290
10240
  case "via": {
@@ -9356,7 +10306,16 @@ function assertRelationsResolve(collections, registry) {
9356
10306
  const defects = findRelationDefects(collections, registry);
9357
10307
  if (defects.length === 0) return;
9358
10308
  const lines = defects.map((d) => ` • ${d.collection}.${d.relationName} (${d.kind})\n ${d.problem}\n fix: ${d.fix}`);
9359
- throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\nEach of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
10309
+ throw new Error(`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against \`backend/src/schema.generated.ts\`.
10310
+
10311
+ Each of these would return no rows at query time rather than reporting an error, so they are fatal at boot instead.
10312
+
10313
+ If the database was migrated recently — an upgrade, a \`db push\`, a restore — this file is
10314
+ probably older than the schema it describes. Regenerate it before changing anything else:
10315
+
10316
+ rebase schema generate
10317
+
10318
+ If it is already current, then the collection is what disagrees with it:
9360
10319
 
9361
10320
  ` + lines.join("\n\n") + "\n");
9362
10321
  }
@@ -9567,7 +10526,7 @@ async function ensureAuthTablesExist(db, collection) {
9567
10526
  if (idProp) {
9568
10527
  const isId = "isId" in idProp ? idProp.isId : void 0;
9569
10528
  if (isId === "uuid") userIdType = "UUID";
9570
- else if (isId === "autoincrement") userIdType = "INTEGER";
10529
+ else if (isId === "increment") userIdType = "INTEGER";
9571
10530
  }
9572
10531
  }
9573
10532
  try {
@@ -9600,43 +10559,42 @@ async function ensureAuthTablesExist(db, collection) {
9600
10559
  const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
9601
10560
  const emailLowerUniqueIndex = authIdentifier("email_lower_key");
9602
10561
  const verificationTokenIndex = authIdentifier("email_verification_token_idx");
10562
+ const usersColumnDdl = AUTH_USERS_COLUMNS.map((spec) => spec.column === "email" ? `${spec.column} ${authUsersColumnSql(spec)} CONSTRAINT ${emailLengthConstraint} CHECK (length(email) <= 320)` : `${spec.column} ${authUsersColumnSql(spec)}`).join(",\n ");
9603
10563
  await db.execute(sql`
9604
10564
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
9605
10565
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
9606
- email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
9607
- display_name TEXT,
9608
- photo_url TEXT,
9609
- roles TEXT[] DEFAULT '{}' NOT NULL,
9610
- password_hash TEXT,
9611
- email_verified BOOLEAN DEFAULT FALSE NOT NULL,
9612
- email_verification_token TEXT,
9613
- email_verification_sent_at TIMESTAMP WITH TIME ZONE,
9614
- is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
9615
- metadata JSONB DEFAULT '{}' NOT NULL,
9616
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
9617
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
9618
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
10566
+ ${sql.raw(usersColumnDdl)}
9619
10567
  )
9620
10568
  `);
9621
- await db.execute(sql`
9622
- CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
9623
- BEGIN
9624
- IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
9625
- NEW.uid := NEW.user_id;
9626
- ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
9627
- NEW.user_id := NEW.uid;
9628
- END IF;
9629
- RETURN NEW;
9630
- END $$ LANGUAGE plpgsql
9631
- `);
9632
- for (const authTable of [
10569
+ const legacyFkTables = [
9633
10570
  "user_identities",
9634
10571
  "refresh_tokens",
9635
10572
  "password_reset_tokens",
9636
10573
  "magic_link_tokens",
9637
10574
  "mfa_factors",
9638
10575
  "recovery_codes"
9639
- ]) {
10576
+ ];
10577
+ const legacyFkTableList = legacyFkTables.map((t) => `'${t}'`).join(", ");
10578
+ const legacyUserIdPresent = await db.execute(sql`
10579
+ SELECT 1
10580
+ FROM information_schema.columns
10581
+ WHERE table_schema = ${authSchema}
10582
+ AND table_name IN (${sql.raw(legacyFkTableList)})
10583
+ AND column_name = 'user_id'
10584
+ LIMIT 1
10585
+ `);
10586
+ if (legacyUserIdPresent.rows.length > 0) await db.execute(sql`
10587
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
10588
+ BEGIN
10589
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
10590
+ NEW.uid := NEW.user_id;
10591
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
10592
+ NEW.user_id := NEW.uid;
10593
+ END IF;
10594
+ RETURN NEW;
10595
+ END $$ LANGUAGE plpgsql
10596
+ `);
10597
+ for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
9640
10598
  const qualified = `"${authSchema}"."${authTable}"`;
9641
10599
  await db.execute(sql`
9642
10600
  DO $$
@@ -9757,54 +10715,50 @@ async function ensureAuthTablesExist(db, collection) {
9757
10715
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
9758
10716
  )
9759
10717
  `);
9760
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
9761
10718
  await db.transaction(async (tx) => {
9762
10719
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
9763
- await tx.execute(sql`
9764
- CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
9765
- SELECT COALESCE(
9766
- NULLIF(current_setting('app.uid', true), ''),
9767
- NULLIF(current_setting('app.user_id', true), '')
9768
- );
9769
- $$ LANGUAGE sql STABLE
9770
- `);
9771
- await tx.execute(sql`
9772
- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
9773
- SELECT COALESCE(
9774
- NULLIF(current_setting('app.jwt', true), ''),
9775
- '{}'
9776
- )::jsonb;
9777
- $$ LANGUAGE sql STABLE
9778
- `);
9779
- await tx.execute(sql`
9780
- CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
9781
- SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
9782
- $$ LANGUAGE sql STABLE
9783
- `);
10720
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
9784
10721
  });
9785
- for (const columnDef of [
9786
- "display_name TEXT",
9787
- "photo_url TEXT",
9788
- "roles TEXT[] DEFAULT '{}' NOT NULL",
9789
- "password_hash TEXT",
9790
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
9791
- "email_verification_token TEXT",
9792
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
9793
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
9794
- "metadata JSONB DEFAULT '{}' NOT NULL",
9795
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
9796
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
9797
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
9798
- ]) await db.execute(sql`
10722
+ for (const spec of AUTH_USERS_COLUMNS) {
10723
+ if (spec.column === "email") continue;
10724
+ await db.execute(sql`
9799
10725
  ALTER TABLE ${sql.raw(usersTableName)}
9800
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
10726
+ ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
9801
10727
  `);
9802
- const usersColumns = await db.execute(sql`
9803
- SELECT column_name, data_type
10728
+ }
10729
+ const usersColumnRows = (await db.execute(sql`
10730
+ SELECT column_name, data_type, is_nullable, column_default
9804
10731
  FROM information_schema.columns
9805
10732
  WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
9806
- `);
9807
- const usersColumnTypes = new Map(usersColumns.rows.map((row) => [row.column_name, row.data_type]));
10733
+ `)).rows;
10734
+ const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
10735
+ const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
10736
+ for (const spec of AUTH_USERS_COLUMNS) {
10737
+ const state = usersColumnState.get(spec.column);
10738
+ if (!state) continue;
10739
+ if (spec.default !== void 0 && state.column_default === null) {
10740
+ await db.execute(sql`
10741
+ ALTER TABLE ${sql.raw(usersTableName)}
10742
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
10743
+ `);
10744
+ logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
10745
+ }
10746
+ if (!spec.notNull || state.is_nullable !== "YES") continue;
10747
+ if (spec.default !== void 0) await db.execute(sql`
10748
+ UPDATE ${sql.raw(usersTableName)}
10749
+ SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
10750
+ WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
10751
+ `);
10752
+ try {
10753
+ await db.execute(sql`
10754
+ ALTER TABLE ${sql.raw(usersTableName)}
10755
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
10756
+ `);
10757
+ logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
10758
+ } catch (err) {
10759
+ logger.warn(`⚠️ ${usersTableName}.${spec.column} should be NOT NULL but still holds NULLs, so the constraint was not applied. Fill or remove those rows and restart: ` + (err instanceof Error ? err.message : String(err)));
10760
+ }
10761
+ }
9808
10762
  for (const column of [
9809
10763
  "email",
9810
10764
  "display_name",
@@ -9883,6 +10837,7 @@ async function ensureAuthTablesExist(db, collection) {
9883
10837
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
9884
10838
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
9885
10839
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
10840
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
9886
10841
  await db.execute(sql`
9887
10842
  UPDATE ${sql.raw(qualified)}
9888
10843
  SET session_id = gen_random_uuid()::text
@@ -9945,6 +10900,7 @@ async function ensureAuthTablesExist(db, collection) {
9945
10900
  secret_encrypted TEXT NOT NULL,
9946
10901
  friendly_name TEXT,
9947
10902
  verified BOOLEAN DEFAULT FALSE,
10903
+ last_used_counter BIGINT,
9948
10904
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
9949
10905
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
9950
10906
  )
@@ -9960,6 +10916,7 @@ async function ensureAuthTablesExist(db, collection) {
9960
10916
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
9961
10917
  verified_at TIMESTAMP WITH TIME ZONE,
9962
10918
  ip_address TEXT,
10919
+ attempts INTEGER NOT NULL DEFAULT 0,
9963
10920
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL
9964
10921
  )
9965
10922
  `);
@@ -9967,6 +10924,12 @@ async function ensureAuthTablesExist(db, collection) {
9967
10924
  CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
9968
10925
  ON ${sql.raw(mfaChallengesTableName)}(factor_id)
9969
10926
  `);
10927
+ try {
10928
+ await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
10929
+ await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
10930
+ } catch (mfaMigrationError) {
10931
+ logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
10932
+ }
9970
10933
  await db.execute(sql`
9971
10934
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
9972
10935
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
@@ -9986,10 +10949,12 @@ async function ensureAuthTablesExist(db, collection) {
9986
10949
  [authSchema, "user_identities"],
9987
10950
  [authSchema, "refresh_tokens"],
9988
10951
  [authSchema, "password_reset_tokens"],
10952
+ [authSchema, "magic_link_tokens"],
9989
10953
  [authSchema, "app_config"],
9990
10954
  [authSchema, "mfa_factors"],
9991
10955
  [authSchema, "mfa_challenges"],
9992
- [authSchema, "recovery_codes"]
10956
+ [authSchema, "recovery_codes"],
10957
+ [authSchema, "schema_meta"]
9993
10958
  ];
9994
10959
  for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
9995
10960
  SELECT 1
@@ -10009,6 +10974,9 @@ async function ensureAuthTablesExist(db, collection) {
10009
10974
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
10010
10975
  }
10011
10976
  await stampAuthSchemaVersion(db, authSchema);
10977
+ await revokeInternalTableAccess(async (text) => {
10978
+ await db.execute(sql.raw(text));
10979
+ }, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
10012
10980
  logger.info("✅ Auth tables ready");
10013
10981
  } catch (error) {
10014
10982
  if (error instanceof AuthSchemaVersionError) throw error;
@@ -10282,7 +11250,7 @@ var UserService = class {
10282
11250
  const conditions = [];
10283
11251
  if (roleId) conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
10284
11252
  if (search) {
10285
- const pattern = `%${search}%`;
11253
+ const pattern = `%${escapeLikePattern(search)}%`;
10286
11254
  conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
10287
11255
  }
10288
11256
  const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
@@ -10458,7 +11426,8 @@ var RefreshTokenService = class {
10458
11426
  "sessionId",
10459
11427
  "rotatedAt",
10460
11428
  "revoked",
10461
- "sessionStartedAt"
11429
+ "sessionStartedAt",
11430
+ "aal"
10462
11431
  ]) if (this.has(optional)) selection[optional] = this.col(optional);
10463
11432
  return selection;
10464
11433
  }
@@ -10472,6 +11441,7 @@ var RefreshTokenService = class {
10472
11441
  };
10473
11442
  if (session && this.has("sessionId")) values.sessionId = session.id;
10474
11443
  if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
11444
+ if (session?.aal && this.has("aal")) values.aal = session.aal;
10475
11445
  await this.db.insert(this.refreshTokensTable).values(values);
10476
11446
  }
10477
11447
  async findByHash(tokenHash) {
@@ -10976,6 +11946,12 @@ var PostgresAuthRepository = class {
10976
11946
  async hasVerifiedMfaFactors(uid) {
10977
11947
  return this.getMfaService().hasVerifiedMfaFactors(uid);
10978
11948
  }
11949
+ async claimMfaFactorCounter(factorId, counter) {
11950
+ return this.getMfaService().claimMfaFactorCounter(factorId, counter);
11951
+ }
11952
+ async recordMfaChallengeAttempt(challengeId) {
11953
+ return this.getMfaService().recordMfaChallengeAttempt(challengeId);
11954
+ }
10979
11955
  };
10980
11956
  /**
10981
11957
  * PostgreSQL implementation of MfaRepository.
@@ -11028,7 +12004,7 @@ var MfaService = class {
11028
12004
  async getMfaFactorById(factorId) {
11029
12005
  const tableName = this.qualify("mfa_factors");
11030
12006
  const result = await this.db.execute(sql`
11031
- SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
12007
+ SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
11032
12008
  FROM ${sql.raw(tableName)}
11033
12009
  WHERE id = ${factorId}
11034
12010
  `);
@@ -11041,10 +12017,29 @@ var MfaService = class {
11041
12017
  secretEncrypted: row.secret_encrypted,
11042
12018
  friendlyName: row.friendly_name ?? void 0,
11043
12019
  verified: row.verified,
12020
+ lastUsedCounter: row.last_used_counter === null || row.last_used_counter === void 0 ? null : Number(row.last_used_counter),
11044
12021
  createdAt: new Date(row.created_at),
11045
12022
  updatedAt: new Date(row.updated_at)
11046
12023
  };
11047
12024
  }
12025
+ /**
12026
+ * Spend a TOTP time step, once and only once.
12027
+ *
12028
+ * One statement: the `WHERE` is the check, the `UPDATE` is the act, and
12029
+ * `RETURNING` reports which of two concurrent requests carrying the same
12030
+ * six digits won. Reading the counter and then writing it would let both
12031
+ * pass — the exact replay this closes.
12032
+ */
12033
+ async claimMfaFactorCounter(factorId, counter) {
12034
+ const tableName = this.qualify("mfa_factors");
12035
+ return (await this.db.execute(sql`
12036
+ UPDATE ${sql.raw(tableName)}
12037
+ SET last_used_counter = ${counter}, updated_at = NOW()
12038
+ WHERE id = ${factorId}
12039
+ AND (last_used_counter IS NULL OR last_used_counter < ${counter})
12040
+ RETURNING id
12041
+ `)).rows.length > 0;
12042
+ }
11048
12043
  async verifyMfaFactor(factorId) {
11049
12044
  const tableName = this.qualify("mfa_factors");
11050
12045
  await this.db.execute(sql`
@@ -11079,7 +12074,7 @@ var MfaService = class {
11079
12074
  async getMfaChallengeById(challengeId) {
11080
12075
  const tableName = this.qualify("mfa_challenges");
11081
12076
  const result = await this.db.execute(sql`
11082
- SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
12077
+ SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
11083
12078
  FROM ${sql.raw(tableName)}
11084
12079
  WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
11085
12080
  `);
@@ -11090,9 +12085,28 @@ var MfaService = class {
11090
12085
  factorId: row.factor_id,
11091
12086
  createdAt: new Date(row.created_at),
11092
12087
  verifiedAt: row.verified_at ? new Date(row.verified_at) : void 0,
11093
- ipAddress: row.ip_address ?? void 0
12088
+ ipAddress: row.ip_address ?? void 0,
12089
+ attempts: Number(row.attempts ?? 0)
11094
12090
  };
11095
12091
  }
12092
+ /**
12093
+ * Count one failed guess against a challenge and report the new total.
12094
+ *
12095
+ * Incremented in the database rather than in the route so that guesses
12096
+ * arriving in parallel — the shape any real brute-force takes — cannot
12097
+ * share a single increment.
12098
+ */
12099
+ async recordMfaChallengeAttempt(challengeId) {
12100
+ const tableName = this.qualify("mfa_challenges");
12101
+ const result = await this.db.execute(sql`
12102
+ UPDATE ${sql.raw(tableName)}
12103
+ SET attempts = attempts + 1
12104
+ WHERE id = ${challengeId}
12105
+ RETURNING attempts
12106
+ `);
12107
+ if (result.rows.length === 0) return 0;
12108
+ return Number(result.rows[0].attempts);
12109
+ }
11096
12110
  async verifyMfaChallenge(challengeId) {
11097
12111
  const tableName = this.qualify("mfa_challenges");
11098
12112
  await this.db.execute(sql`
@@ -11337,6 +12351,7 @@ async function ensureHistoryTableExists(db) {
11337
12351
  CREATE INDEX IF NOT EXISTS idx_history_time
11338
12352
  ON rebase.entity_history(table_name, entity_id, updated_at DESC)
11339
12353
  `);
12354
+ await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
11340
12355
  logger.info("✅ Entity history table ready");
11341
12356
  } catch (error) {
11342
12357
  logger.error("❌ Failed to create row history table", { error });
@@ -12058,12 +13073,12 @@ function createPostgresBootstrapper(pgConfig) {
12058
13073
  const runSql = async (text) => {
12059
13074
  return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
12060
13075
  };
13076
+ await warnOnRoleSchemaCollision(runSql);
12061
13077
  const posture = await detectConnectionPosture(runSql);
12062
13078
  if (posture.privileged) {
12063
13079
  await ensureAppRole(runSql, [
12064
13080
  "public",
12065
13081
  "rebase",
12066
- "auth",
12067
13082
  ...registry.getCollections().map((c) => c.schema).filter((s) => typeof s === "string")
12068
13083
  ]);
12069
13084
  driver.rlsUserRole = REBASE_USER_ROLE;
@@ -12073,6 +13088,7 @@ function createPostgresBootstrapper(pgConfig) {
12073
13088
  } else logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
12074
13089
  await validatePolicyPgRoles(runSql, registry.getCollections(), driver.rlsUserRole ?? posture.role);
12075
13090
  warnOnAnonymousGrants(registry.getCollections());
13091
+ warnOnLegacyRlsFunctions(registry.getCollections());
12076
13092
  }
12077
13093
  if (driver.branchService) try {
12078
13094
  await driver.branchService.ensureBranchMetadataTable();
@@ -12288,12 +13304,12 @@ function createPostgresBootstrapper(pgConfig) {
12288
13304
  */
12289
13305
  async ensureCollectionSchema(collections, driverResult, log) {
12290
13306
  const internals = driverResult.internals;
12291
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CBQdOETu.js");
13307
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-DzeTEvMv.js");
12292
13308
  const plan = await ensureCollectionTables({ async query(text) {
12293
13309
  const result = await internals.db.execute(sql.raw(text));
12294
13310
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
12295
13311
  } }, collections, log);
12296
- for (const failure of plan.failures) logger.warn(`🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
13312
+ for (const failure of plan.failures) logger.warn(failure.kind === "comment-column" ? `🔍 [schema] Could not record the search fingerprint on "${failure.target}" — search works, but a later change to the \`search\` block will not be detected: ${failure.error}` : `🔗 [schema] Could not add foreign key "${failure.target}" — the column exists and the collection still serves, but rows are not policed by this constraint: ${failure.error}`);
12297
13313
  return { applied: plan.actions.length - plan.failures.length };
12298
13314
  },
12299
13315
  /**
@@ -12313,13 +13329,24 @@ function createPostgresBootstrapper(pgConfig) {
12313
13329
  */
12314
13330
  async ensureCollectionPolicies(collections, driverResult, log) {
12315
13331
  const internals = driverResult.internals;
12316
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-ViG8XiPn.js");
13332
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-B_JMGa5K.js");
12317
13333
  const outcome = await ensureCollectionPolicies({ async query(text) {
12318
13334
  const result = await internals.db.execute(sql.raw(text));
12319
13335
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
12320
13336
  } }, collections, log);
12321
13337
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
12322
13338
  for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — it stays locked (denies) until this is resolved: ${failure.error}`);
13339
+ try {
13340
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-Bpv3nUZo.js").then((n) => n.n);
13341
+ await dropLegacyAuthSchema(async (text) => {
13342
+ return (await internals.db.execute(sql.raw(text))).rows ?? [];
13343
+ }, {
13344
+ info: (m) => logger.info(m),
13345
+ warn: (m) => logger.warn(m)
13346
+ });
13347
+ } catch (err) {
13348
+ logger.info("Left the legacy `auth` schema in place: " + (err instanceof Error ? err.message : String(err)));
13349
+ }
12323
13350
  return { applied: outcome.policiesApplied };
12324
13351
  },
12325
13352
  getAdmin(driverResult) {
@@ -12327,7 +13354,7 @@ function createPostgresBootstrapper(pgConfig) {
12327
13354
  },
12328
13355
  mountRoutes(app, basePath, driverResult) {},
12329
13356
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
12330
- const { createPostgresWebSocket } = await import("./websocket-B2LsrINK.js").then((n) => n.n);
13357
+ const { createPostgresWebSocket } = await import("./websocket-D1qbmLZ2.js").then((n) => n.n);
12331
13358
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
12332
13359
  }
12333
13360
  };
@@ -12367,6 +13394,6 @@ function createPostgresAdapter(pgConfig) {
12367
13394
  };
12368
13395
  }
12369
13396
  //#endregion
12370
- export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, frameByteLength, generateSchema, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
13397
+ export { AuthenticatedPostgresBackendDriver, BackupToolError, BranchService, CHANNEL_BUS_NOTIFY_CHANNEL, DEFAULT_BATCH_WINDOW_MS, DatabasePoolManager, DrizzleConditionBuilder, MemoryChannelBus, PG_NOTIFY_MAX_PAYLOAD_BYTES, PostgresBackendDriver, PostgresChannelBus, PostgresCollectionRegistry, PostgresConditionBuilder, PostgresRealtimeProvider, RealtimeService, appConfig, applyGlobals, backupCronConfigFromEnv, buildBackupFilename, buildPgDumpArgs, buildPgDumpallGlobalsArgs, buildPgRestoreArgs, buildPgRestoreListArgs, buildRowSecurityPgOptions, checkToolServerCompatibility, configureUnknownFilterFields, createAuthSchema, createBackupCron, createChannelBus, createDirectDatabaseConnection, createDump, createPostgresAdapter, createPostgresBootstrapper, createPostgresDatabaseConnection, createPostgresWebSocket, createReadReplicaConnection, detectToolMajor, diagnoseRowSecurityDumpFailure, ensureDatabaseExists, escapeLikePattern, frameByteLength, generateSchema, getDrizzleColumn, getServerVersionMajor, getUnknownFilterFieldsMode, globalsFileForDump, guardPoolAgainstDirtyRelease, isChannelBusInstance, joinStorageKey, listBackups, magicLinkTokens, magicLinkTokensRelations, mfaChallenges, mfaChallengesRelations, mfaFactors, mfaFactorsRelations, parseBackupDestination, parseBackupTimestamp, parseChannelBusFrame, parseChannelBusPayload, parseDbNameFromUrl, parsePgToolMajor, passwordResetTokens, passwordResetTokensRelations, pinSearchPath, preflight, pruneBackups, recoveryCodes, recoveryCodesRelations, refreshTokens, refreshTokensRelations, resolveChannelBusSetting, resolveConnectionString, resolveDriftCheckName, resolvePgBinary, restoreDump, selectBackupsToPrune, serverVersionNumToMajor, splitGlobalsStatements, uploadBackup, userIdentities, userIdentitiesRelations, users, usersRelations, usersSchema, validateDump, withDatabaseName };
12371
13398
 
12372
13399
  //# sourceMappingURL=index.es.js.map