@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
package/dist/index.es.js CHANGED
@@ -2,16 +2,18 @@ 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 mergeDeep, C as createRelationRefWithData, D as legacyForeignKeyName, E as generateForeignKeyName, F as resolveClientListLimit, I as hasForeignKeyOnTarget, L as isManyToMany, M as toSnakeCase, O as getPolicyNamesForRule, R as Vector, S as createRelationRef, T as updateDateAutoValues, _ as resolveCollectionRelations, a as getJunctionCollectionConfig, b as isAddressableId, c as getEffectiveSecurityRules, d as findAnonymousGrants, f as findRelation, g as getTableVarName, h as getTableName$1, i as resolveStringColumnLength, j as camelCase, k as isPrototypePollutingKey, l as policyToPostgres, m as getEnumVarName, n as CollectionRegistry, o as getJunctionSecurityRules, p as getColumnName, r as relationalCollections, s as resolveJunctionSpecs, t as buildSdkData, u as securityRuleToConditions, v as buildCompositeId, w as normalizeToEntityRelation, x as parseIdValues, y as getDeclaredPrimaryKeys } from "./src-CU6WZGYV.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, t as ListLimitError } from "./data_driver-ULAyJEi9.js";
6
+ import { A as getEnumVarName, B as createRelationRefWithData, C as getEffectiveSecurityRules, D as fieldKeyForColumn, F as getDeclaredPrimaryKeys, G as legacyForeignKeyName, H as updateDateAutoValues, I as isAddressableId, J as getPolicyNamesForRule, L as parseIdValues, M as getTableVarName, N as resolveCollectionRelations, O as findRelation, P as buildCompositeId, Q as toSnakeCase, R as sortCollectionsBySlug, S as resolveJunctionSpecs, T as securityRuleToConditions, U as firstFreeKey, V as normalizeToEntityRelation, W as generateForeignKeyName, X as mergeDeep, Y as isPrototypePollutingKey, Z as camelCase, _ as CollectionRegistry, b as getJunctionCollectionConfig, g as buildSdkData, h as visibleColumnProjection, j as getTableName$1, k as getColumnName, l as buildSearchColumnSpec, nt as isManyToMany, q as toWireKey, r as authUsersColumnSql, rt as Vector, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as hasForeignKeyOnTarget, u as hiddenColumnsOption, v as relationalCollections, w as policyToPostgres, x as getJunctionSecurityRules, y as resolveStringColumnLength, z as createRelationRef } from "./auth-users-columns-BfQHf9JE.js";
7
+ import { c as isPostgresCollectionConfig, f as ALL_WHERE_FILTER_OPS, l as isRelationalCollectionConfig } from "./src-DCdn3Val.js";
8
+ import { t as createPostgresWebSocket } from "./websocket-C8ZqVBiV.js";
9
+ import { a as warnOnAnonymousGrants, c as REBASE_USER_ROLE, i as validatePolicyPgRoles, l as revokeInternalTableAccess, n as detectConnectionPosture, o as warnOnLegacyRlsFunctions, r as ensureAppRole, s as warnOnRoleSchemaCollision, t as applyAuthContext, u as revokeInternalTableSql } from "./rls-enforcement-BJ_3wxwg.js";
10
+ 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-BH0Dzo_h.js";
11
+ import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-69hYT8nr.js";
10
12
  import { Client, Pool } from "pg";
11
13
  import { drizzle } from "drizzle-orm/node-postgres";
12
14
  import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
13
- 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 { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
16
+ 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
17
  import fs, { promises } from "fs";
16
18
  import path from "path";
17
19
  import chokidar from "chokidar";
@@ -133,6 +135,27 @@ var buildPropertyCallbacks = (properties) => {
133
135
  return Object.keys(propertyCallbacks).length > 0 ? propertyCallbacks : void 0;
134
136
  };
135
137
  //#endregion
138
+ //#region ../common/src/data/filter-conditions.ts
139
+ /**
140
+ * Read one field's filter as the list of conditions it stands for.
141
+ *
142
+ * Accepts both declared shapes and normalises them to a list:
143
+ *
144
+ * ```ts
145
+ * toFilterTuples(["==", "active"]) // [["==", "active"]]
146
+ * toFilterTuples([[">=", 18], ["<", 65]]) // [[">=", 18], ["<", 65]]
147
+ * ```
148
+ *
149
+ * A falsy, non-array or empty param has no conditions in it — the empty list,
150
+ * so a caller iterating adds nothing rather than compiling a tuple of
151
+ * `undefined`s and logging about an operator nobody sent.
152
+ */
153
+ function toFilterTuples(filterParam) {
154
+ if (!filterParam || !Array.isArray(filterParam) || filterParam.length === 0) return [];
155
+ if (Array.isArray(filterParam[0])) return filterParam;
156
+ return [filterParam];
157
+ }
158
+ //#endregion
136
159
  //#region ../common/src/table-classification.ts
137
160
  /** Schemas that are always considered Rebase-internal. */
138
161
  var REBASE_INTERNAL_SCHEMAS = ["rebase", "auth"];
@@ -238,6 +261,55 @@ function getCollectionByPath(collectionPath, registry) {
238
261
  }
239
262
  return collection;
240
263
  }
264
+ /**
265
+ * Reject a write naming something that is not a column of the table.
266
+ *
267
+ * Drizzle builds INSERT from `Object.entries(table[Symbol.Columns])` and UPDATE
268
+ * from `Object.keys(tableColumns)`, so a key the table does not carry is not
269
+ * rejected by anything — it is *left out of the statement*. The insert answers
270
+ * 201 having stored nothing under that name; the update, if the key was the
271
+ * only one, builds `update "posts" set where …` and Postgres raises a syntax
272
+ * error (SQLSTATE 42601), which is neither class 22 nor 23 and so surfaces as a
273
+ * 500 for what is a caller's typo.
274
+ *
275
+ * That makes this the last honest place to check, and the only one every write
276
+ * passes through. `assertKnownWriteFields` in the REST layer checks the same
277
+ * thing against the *config* and is skipped on four paths — `strictWrites:
278
+ * false`, a collection declaring no properties, an auth adapter that owns the
279
+ * body's shape, and a nested route whose target cannot be walked — and it never
280
+ * sees an in-process `rebase.data` write at all.
281
+ *
282
+ * It also gives `strictWrites: false` a truthful implementation. The flag is
283
+ * documented for "a column that really does exist which the config never
284
+ * declared", and skipping the config check alone could not deliver that: the
285
+ * value was dropped a layer later regardless. Skipping the config check and
286
+ * keeping this one does exactly what the flag says — the column must exist,
287
+ * the property need not.
288
+ */
289
+ function assertWritableColumns(values, table, collectionPath) {
290
+ const columns = getTableColumns(table);
291
+ if (!columns || Object.keys(columns).length === 0) return;
292
+ const unknown = Object.keys(values).filter((key) => !(key in columns));
293
+ if (unknown.length === 0) return;
294
+ 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");
295
+ }
296
+ /**
297
+ * A relation whose names do not resolve against the registered schema.
298
+ *
299
+ * Every one of these used to be a `logger.warn` followed by `continue`, so a
300
+ * save reported success for a relation it had not written and a read answered
301
+ * `[]` for one it could not resolve. `assertRelationsResolve` (validate-relations)
302
+ * fails boot on the same defects, which is where they belong — a server that
303
+ * refuses to start is recoverable in a minute. This is the second line, for the
304
+ * paths that assemble a registry by hand, and it exists so that "cannot resolve"
305
+ * is never again reported as "done".
306
+ *
307
+ * @param label `<collection>.<relation>`
308
+ * @param detail what does not resolve, in terms of the schema
309
+ */
310
+ function relationMisconfigured(label, detail) {
311
+ return ApiError.internal(`Relation '${label}' does not resolve against the registered schema: ${detail}. The operation was refused rather than skipped — silently dropping it would report success for a write that never happened, or emptiness for rows that exist. Run \`rebase schema generate\` if the generated schema is older than the database.`, "RELATION_MISCONFIGURED");
312
+ }
241
313
  function getTableForCollection(collection, registry) {
242
314
  const tableName = getTableName$1(collection);
243
315
  const table = registry.getTable(tableName);
@@ -329,7 +401,7 @@ function requirePrimaryKeys(collection, registry) {
329
401
  * they were *duplicated* and could disagree, not because they existed.
330
402
  */
331
403
  function sourceKeyField(relation, sourceCollection, registry) {
332
- if (relation.sourceKey) return relation.sourceKey;
404
+ if (relation.sourceKey) return fieldKeyForColumn(sourceCollection, relation.sourceKey);
333
405
  return requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
334
406
  }
335
407
  /**
@@ -343,7 +415,7 @@ function sourceKeyField(relation, sourceCollection, registry) {
343
415
  */
344
416
  function joinsOnNaturalKey(relation, sourceCollection, registry) {
345
417
  if (!relation.sourceKey) return false;
346
- return relation.sourceKey !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
418
+ return fieldKeyForColumn(sourceCollection, relation.sourceKey) !== requirePrimaryKeys(sourceCollection, registry)[0].fieldName;
347
419
  }
348
420
  /**
349
421
  * Collections whose key the *browser* cannot resolve, and what it will do
@@ -417,6 +489,97 @@ function deriveRowAddress(row, collection, registry) {
417
489
  //#endregion
418
490
  //#region src/utils/drizzle-conditions.ts
419
491
  /**
492
+ * Postgres's own default for `pg_trgm.word_similarity_threshold`. Named here
493
+ * because the fuzzy predicate has to know when the index-backed operator agrees
494
+ * with the collection's declared threshold and when it would narrow too far.
495
+ */
496
+ var PG_TRGM_WORD_SIMILARITY_DEFAULT = .6;
497
+ /**
498
+ * A user's search term, made safe to drop inside a `%…%` LIKE pattern.
499
+ *
500
+ * The term is already a bind parameter, so this is not about injection. It is
501
+ * about the two things a LIKE metacharacter does when it arrives from a search
502
+ * box:
503
+ *
504
+ * 1. **It changes the query.** `%` and `_` are wildcards, so searching for
505
+ * `50%` returned every row and `a_c` matched `abc`. Nothing the caller
506
+ * could type would find a literal `%`.
507
+ * 2. **It is a cost the caller chooses.** Postgres matches LIKE by
508
+ * backtracking: each `%` re-tries every remaining offset, so
509
+ * `?searchString=a%a%a%a%a%a%a%b` is polynomial with an attacker-chosen
510
+ * exponent — evaluated per row, OR-ed across every string property of the
511
+ * collection, on a sequential scan (a leading `%` cannot use an index), and
512
+ * the page limit does not bound it because the scan happens first.
513
+ *
514
+ * This is the server-side half of the pattern `like-pattern-redos.test.ts`
515
+ * hardened the offline evaluator against; that test's own note ("the same
516
+ * translation in the Mongo driver hands the expression to the database, where
517
+ * it occupies a server thread instead") describes this call site.
518
+ *
519
+ * Backslash is the default `ESCAPE` character for LIKE, and the pattern is
520
+ * bound rather than interpolated, so a single backslash here reaches the
521
+ * matcher as one. Escaping the escape character first is what keeps a term
522
+ * ending in `\` from swallowing the closing `%`.
523
+ *
524
+ * Note this is a *substring search*, not the `like` filter operator: a caller
525
+ * who wants wildcards has `?title=like.foo%` for that, where the pattern is the
526
+ * documented input.
527
+ */
528
+ var escapeLikePattern = (value) => value.replace(/[\\%_]/g, (ch) => `\\${ch}`);
529
+ /**
530
+ * The Drizzle column a relation's column name addresses on a table.
531
+ *
532
+ * A relation names its link in *column* terms — `localKey: "author_id"`,
533
+ * `foreignKeyOnTarget: "author_id"` — because that is what the database and
534
+ * every FK constraint call it. A Drizzle table is keyed by the *wire* name,
535
+ * `authorId`. Indexing the table with the column, which is what every one of
536
+ * these call sites used to do, therefore finds nothing the moment the two
537
+ * differ: for a `columnName`-carrying property that was already true, and it is
538
+ * now true of every derived foreign key.
539
+ *
540
+ * `undefined` rather than a throw: each caller already has a message naming the
541
+ * relation it was resolving, which is worth more than a generic one here.
542
+ */
543
+ var relationColumn = (table, collection, column) => {
544
+ const key = fieldKeyForColumn(collection, column);
545
+ return (key in table ? table[key] : void 0) || void 0;
546
+ };
547
+ /** The target collection of a relation, or `undefined` if its thunk cannot resolve. */
548
+ var targetOf = (relation) => {
549
+ try {
550
+ return relation.target();
551
+ } catch {
552
+ return;
553
+ }
554
+ };
555
+ /** Column types `ILIKE '%…%'` is defined on. */
556
+ var ILIKE_SQL_TYPES = /^(text|varchar|character varying|char|character|bpchar|citext)\b/;
557
+ /**
558
+ * Can this column be matched with `ILIKE`?
559
+ *
560
+ * Asked of the column's *declared SQL type*, never with `instanceof`. The
561
+ * previous version tested `column instanceof PgVarchar || … PgText || … PgChar`,
562
+ * and `instanceof` compares class identity: it is only true when the column was
563
+ * constructed by the very same copy of `drizzle-orm` that this module imported.
564
+ *
565
+ * An application's generated schema builds its tables with the app's own
566
+ * `drizzle-orm`, and this driver declares its own dependency on one. When the
567
+ * two ranges do not overlap — an app scaffolded against `^0.44` with a driver
568
+ * asking for `^0.45` — a strict installer gives the driver a second copy, every
569
+ * check returns false, no condition is produced, and the caller compiles that
570
+ * into an impossible `WHERE`. The result is a 200 with an empty page for every
571
+ * search on every collection without a `search` block: the failure looks
572
+ * exactly like "nothing matched". Observed in production, not theorised.
573
+ *
574
+ * `getSQLType()` is a value the column reports about itself, so it crosses
575
+ * module instances the way a class identity cannot. It also happens to fix
576
+ * `citext`, which the `instanceof` list never covered.
577
+ */
578
+ var supportsILike = (column) => {
579
+ const sqlType = typeof column?.getSQLType === "function" ? column.getSQLType().toLowerCase() : "";
580
+ return ILIKE_SQL_TYPES.test(sqlType);
581
+ };
582
+ /**
420
583
  * Process-wide default, set once when the driver is constructed.
421
584
  *
422
585
  * The condition builder is a set of *static* methods reached from a dozen
@@ -474,7 +637,7 @@ function toMembershipList(value) {
474
637
  * @example
475
638
  * const builder: ConditionBuilderStatic<SQL> = DrizzleConditionBuilder;
476
639
  */
477
- var DrizzleConditionBuilder = class {
640
+ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
478
641
  /**
479
642
  * Express "reachable from this parent through this relation" as a plain
480
643
  * `WHERE` condition on the target table.
@@ -514,7 +677,7 @@ var DrizzleConditionBuilder = class {
514
677
  }
515
678
  case "hasOne":
516
679
  case "hasMany": {
517
- const fkColumn = targetTable[relation.foreignKeyOnTarget];
680
+ const fkColumn = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
518
681
  if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
519
682
  if (!relation.sourceKey) return eq(fkColumn, parentId);
520
683
  const { table, idColumn } = parent();
@@ -602,14 +765,14 @@ var DrizzleConditionBuilder = class {
602
765
  if (collection) {
603
766
  const relation = resolveCollectionRelations(collection)[field];
604
767
  if (relation?.kind === "belongsTo") {
605
- const foreignKey = columnAt(relation.localKey);
768
+ const foreignKey = relationColumn(table, collection, relation.localKey);
606
769
  if (foreignKey) return {
607
770
  kind: "column",
608
771
  column: foreignKey
609
772
  };
610
773
  }
611
774
  if (relation && (hasForeignKeyOnTarget(relation) || isManyToMany(relation)) && registry && sourceIdColumn) {
612
- const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ? columnAt(relation.sourceKey) : sourceIdColumn;
775
+ const correlationColumn = hasForeignKeyOnTarget(relation) && relation.sourceKey ? relationColumn(table, collection, relation.sourceKey) : sourceIdColumn;
613
776
  if (!correlationColumn) throw new Error(`\`sourceKey: "${relation.sourceKey}"\` on relation '${relation.relationName}' is not a column on '${collectionPath}', so a filter on that relation has nothing to correlate against.`);
614
777
  return {
615
778
  kind: "relation",
@@ -619,7 +782,12 @@ var DrizzleConditionBuilder = class {
619
782
  };
620
783
  }
621
784
  }
622
- for (const guess of [`${field}_id`, generateForeignKeyName(field)]) {
785
+ for (const guess of [
786
+ `${field}Id`,
787
+ toWireKey(generateForeignKeyName(field)),
788
+ `${field}_id`,
789
+ generateForeignKeyName(field)
790
+ ]) {
623
791
  const foreignKey = columnAt(guess);
624
792
  if (foreignKey) return {
625
793
  kind: "column",
@@ -650,8 +818,7 @@ var DrizzleConditionBuilder = class {
650
818
  if (!filterParam) continue;
651
819
  const target = this.resolveFilterTarget(table, field, collectionPath, mode, options);
652
820
  if (!target) continue;
653
- const paramsList = Array.isArray(filterParam) && filterParam.length > 0 && Array.isArray(filterParam[0]) ? filterParam : [filterParam];
654
- for (const [op, value] of paramsList) {
821
+ for (const [op, value] of toFilterTuples(filterParam)) {
655
822
  const condition = this.compileFilterTarget(target, op, value, field, collectionPath);
656
823
  if (condition) conditions.push(condition);
657
824
  }
@@ -728,7 +895,7 @@ var DrizzleConditionBuilder = class {
728
895
  const targetCollection = relation.target();
729
896
  const targetTable = registry.getTable(getTableName$1(targetCollection));
730
897
  if (!targetTable) throw new Error(`Table not found for the target of relation '${relation.relationName}' (collection '${targetCollection.slug}')`);
731
- const fkColumn = targetTable[relation.foreignKeyOnTarget];
898
+ const fkColumn = relationColumn(targetTable, targetCollection, relation.foreignKeyOnTarget);
732
899
  if (!fkColumn) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'.`);
733
900
  const targetIdColumn = this.primaryKeyColumn(targetTable);
734
901
  if (!targetIdColumn) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}', so a filter on it has nothing to match against.`);
@@ -880,9 +1047,10 @@ var DrizzleConditionBuilder = class {
880
1047
  case "not-ilike": return sql`${column} NOT ILIKE ${String(value)}`;
881
1048
  case "is-null": return sql`${column} IS NULL`;
882
1049
  case "is-not-null": return sql`${column} IS NOT NULL`;
883
- default:
884
- logger.warn(`Unsupported filter operation: ${op}`);
885
- return null;
1050
+ default: throw ApiError.badRequest(`Unknown filter operator '${op}'. Valid operators: ${ALL_WHERE_FILTER_OPS.join(", ")}.`, "UNKNOWN_FILTER_OPERATOR", {
1051
+ operator: op,
1052
+ validOperators: ALL_WHERE_FILTER_OPS
1053
+ });
886
1054
  }
887
1055
  }
888
1056
  /**
@@ -1079,7 +1247,7 @@ var DrizzleConditionBuilder = class {
1079
1247
  if (!targetIdCol) throw new Error(`No primary key or "id" column in the target table of relation '${relation.relationName}'.`);
1080
1248
  return match(targetIdCol);
1081
1249
  }
1082
- const foreignKeyCol = targetTable[relation.foreignKeyOnTarget];
1250
+ const foreignKeyCol = relationColumn(targetTable, targetOf(relation), relation.foreignKeyOnTarget);
1083
1251
  if (!foreignKeyCol) throw new Error(`Foreign key column '${relation.foreignKeyOnTarget}' not found in the target table of relation '${relation.relationName}'. A link through a junction is \`kind: "manyToMany"\`.`);
1084
1252
  return match(foreignKeyCol);
1085
1253
  }
@@ -1100,22 +1268,153 @@ var DrizzleConditionBuilder = class {
1100
1268
  return or(...conditions);
1101
1269
  }
1102
1270
  /**
1103
- * Build search conditions for text fields
1271
+ * Build search conditions for text fields.
1272
+ *
1273
+ * Two shapes, chosen by whether the collection declared a `search` block:
1274
+ *
1275
+ * - **Declared** — one `@@ websearch_to_tsquery` against the generated
1276
+ * `tsvector` column. Stems, drops stopwords, AND-es the terms, reaches
1277
+ * inside JSONB and arrays, and uses the GIN index.
1278
+ * - **Not declared** — the original `ILIKE '%term%'` OR-ed across top-level
1279
+ * string properties, with the term escaped (see {@link escapeLikePattern})
1280
+ * so it is matched as the literal text the user typed.
1281
+ *
1282
+ * The second is the default and stays the default. A collection that has
1283
+ * not opted in compiles to exactly the SQL it compiled to before this
1284
+ * branch existed, which is the only reason it is safe to have added it.
1285
+ *
1286
+ * `collection` is optional so that the callers which genuinely have no
1287
+ * collection in hand — nested paths, derived views — keep working; without
1288
+ * one there is no `search` block to read and the ILIKE path is correct.
1104
1289
  */
1105
- static buildSearchConditions(searchString, properties, table) {
1290
+ static buildSearchConditions(searchString, properties, table, collection) {
1106
1291
  const searchConditions = [];
1292
+ const ftsCondition = collection ? DrizzleConditionBuilder.buildFullTextCondition(searchString, table, collection) : void 0;
1293
+ if (ftsCondition) return [ftsCondition];
1294
+ let declaredStringProperties = 0;
1107
1295
  for (const [key, prop] of Object.entries(properties)) {
1108
1296
  const p = prop;
1109
1297
  if (p.type === "string" && !p.enum && p.isId !== "uuid") {
1298
+ declaredStringProperties++;
1110
1299
  const fieldColumn = table[key];
1111
- if (fieldColumn) {
1112
- if (fieldColumn instanceof PgVarchar || fieldColumn instanceof PgText || fieldColumn instanceof PgChar || fieldColumn && typeof fieldColumn === "object" && !("columnType" in fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${searchString}%`));
1113
- }
1300
+ if (fieldColumn && supportsILike(fieldColumn)) searchConditions.push(ilike(fieldColumn, `%${escapeLikePattern(searchString)}%`));
1114
1301
  }
1115
1302
  }
1303
+ 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.`);
1116
1304
  return searchConditions;
1117
1305
  }
1118
1306
  /**
1307
+ * The `@@` predicate for a collection that declared a `search` block, or
1308
+ * undefined for one that did not.
1309
+ *
1310
+ * The query is normalized exactly as the indexed content was — same text
1311
+ * search configuration, same accent folding. Skipping that on the query
1312
+ * side is the subtle way to get a search that matches nothing: the column
1313
+ * would hold `gestion` while the query asked for `gestión`.
1314
+ *
1315
+ * `websearch_to_tsquery` rather than `plainto_tsquery` because it is the
1316
+ * one that behaves the way a search box looks like it should — quoted
1317
+ * phrases, `or`, and a leading `-` to exclude — and because it never throws
1318
+ * on user input, which `to_tsquery` does on so much as a stray parenthesis.
1319
+ */
1320
+ static buildFullTextCondition(searchString, table, collection) {
1321
+ let spec;
1322
+ try {
1323
+ spec = buildSearchColumnSpec(collection);
1324
+ } catch {
1325
+ return;
1326
+ }
1327
+ if (!spec) return void 0;
1328
+ const column = table[spec.column];
1329
+ if (!column) return;
1330
+ const exact = sql`${column} @@ ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)}`;
1331
+ if (!spec.fuzzy) return exact;
1332
+ const fuzzyColumn = table[spec.fuzzy.column];
1333
+ if (!fuzzyColumn) return exact;
1334
+ const needle = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
1335
+ const similar = sql`public.word_similarity(${needle}, ${fuzzyColumn}) >= ${spec.fuzzy.threshold}`;
1336
+ return sql`(${exact} OR ${spec.fuzzy.threshold > PG_TRGM_WORD_SIMILARITY_DEFAULT ? sql`(${needle} OPERATOR(public.<%) ${fuzzyColumn} AND ${similar})` : similar})`;
1337
+ }
1338
+ /**
1339
+ * `websearch_to_tsquery(<config>, <normalized search string>)`.
1340
+ *
1341
+ * Split out because the ranking expression needs the identical query — a
1342
+ * row ranked against a different tsquery than it was matched against is a
1343
+ * ranking of something else.
1344
+ */
1345
+ static normalizedTsQuery(searchString, spec) {
1346
+ const normalized = spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`;
1347
+ return sql`websearch_to_tsquery(${spec.language}, ${normalized})`;
1348
+ }
1349
+ /**
1350
+ * A JSONB array of `{ field, snippet }` naming which declared fields matched
1351
+ * and showing the text around each hit — what backs `_matches`.
1352
+ *
1353
+ * A ranked list answers "which rows", never "why this row". For a talent
1354
+ * pool that difference is the product: a candidate surfacing for
1355
+ * "iso 14001" on a *certification* is a different candidate from one whose
1356
+ * bio happens to mention the standard, and the score cannot tell them apart.
1357
+ *
1358
+ * Built as a correlated subquery over a `VALUES` list of the declared
1359
+ * fields, rather than one `CASE` per field, so the shape does not change
1360
+ * with the number of fields and the empty result is a plain `[]`.
1361
+ *
1362
+ * `ts_headline` runs over the same normalized text that was indexed. Over
1363
+ * the *original* text it would find nothing to mark whenever `unaccent` is
1364
+ * on — the query's lexemes are folded and the document's are not — and
1365
+ * would return the text silently unhighlighted. Folded-but-marked beats
1366
+ * pretty-but-inert.
1367
+ *
1368
+ * Undefined when the collection has not opted in, when the column is not on
1369
+ * the table yet, or when the caller did not ask: this costs a `ts_headline`
1370
+ * per field per row and `ts_headline` re-parses the document.
1371
+ */
1372
+ static buildSearchMatchesExpression(searchString, table, collection) {
1373
+ let spec;
1374
+ try {
1375
+ spec = buildSearchColumnSpec(collection);
1376
+ } catch {
1377
+ return;
1378
+ }
1379
+ if (!spec || spec.fields.length === 0) return void 0;
1380
+ if (!table[spec.column]) return void 0;
1381
+ const query = DrizzleConditionBuilder.normalizedTsQuery(searchString, spec);
1382
+ const config = sql`${spec.language}`;
1383
+ const rows = spec.fields.map((f, i) => sql`(${i}, ${f.path}, ${sql.raw(f.textSql)})`);
1384
+ return sql`(
1385
+ SELECT coalesce(jsonb_agg(s.m ORDER BY f.ord), '[]'::jsonb)
1386
+ FROM (VALUES ${sql.join(rows, sql`, `)}) AS f(ord, path, txt)
1387
+ CROSS JOIN LATERAL (
1388
+ SELECT jsonb_build_object(
1389
+ 'field', f.path,
1390
+ 'snippet', ts_headline(${config}::regconfig, f.txt, ${query},
1391
+ 'StartSel=<mark>,StopSel=</mark>,MaxWords=14,MinWords=1,MaxFragments=1,FragmentDelimiter= … ')
1392
+ ) AS m
1393
+ WHERE to_tsvector(${config}::regconfig, f.txt) @@ ${query}
1394
+ ) s
1395
+ )`;
1396
+ }
1397
+ /**
1398
+ * `ts_rank(<column>, <query>)` for the collection, or undefined when it has
1399
+ * not opted in. This is what backs `orderBy: ["_score", "desc"]`.
1400
+ */
1401
+ static buildSearchRankExpression(searchString, table, collection) {
1402
+ let spec;
1403
+ try {
1404
+ spec = buildSearchColumnSpec(collection);
1405
+ } catch {
1406
+ return;
1407
+ }
1408
+ if (!spec) return void 0;
1409
+ const column = table[spec.column];
1410
+ if (!column) return void 0;
1411
+ const rank = sql`ts_rank(${column}, ${DrizzleConditionBuilder.normalizedTsQuery(searchString, spec)})`;
1412
+ if (!spec.fuzzy) return rank;
1413
+ const fuzzyColumn = table[spec.fuzzy.column];
1414
+ if (!fuzzyColumn) return rank;
1415
+ return sql`(${rank} + public.word_similarity(${spec.unaccent ? sql`${sql.raw(SEARCH_UNACCENT_FN)}(${searchString})` : sql`${searchString}`}, ${fuzzyColumn}))`;
1416
+ }
1417
+ /**
1119
1418
  * Build a unique field check condition
1120
1419
  */
1121
1420
  static buildUniqueFieldCondition(fieldColumn, value, idColumn, excludeId) {
@@ -1213,10 +1512,19 @@ var DrizzleConditionBuilder = class {
1213
1512
  * - `orderBy`: SQL expression to ORDER BY distance (ascending = closest first)
1214
1513
  * - `filter`: optional WHERE clause for distance threshold
1215
1514
  * - `distanceSelect`: SQL expression for selecting the distance as `_distance`
1515
+ *
1516
+ * `property` is `?vector_search=` off the querystring, so it is an untrusted
1517
+ * *name*, and it used to be looked up straight in the drizzle table object.
1518
+ * Two ways that went wrong, both answering 500 to a malformed request:
1519
+ * `?vector_search=title` built `"title" <=> '[1,2]'::vector`, which the
1520
+ * database rejects with "operator does not exist"; and a table object also
1521
+ * carries non-column keys (`_`, methods), which passed the `if (!column)`
1522
+ * guard and compiled to nonsense. The name is resolved against the table's
1523
+ * actual columns and required to be a `vector` — anything else is the
1524
+ * caller's mistake and gets a 400 that says so.
1216
1525
  */
1217
1526
  static buildVectorSearchConditions(table, vectorSearch) {
1218
- const column = table[vectorSearch.property];
1219
- if (!column) throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
1527
+ const column = DrizzleConditionBuilder.resolveVectorColumn(table, vectorSearch.property);
1220
1528
  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");
1221
1529
  const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
1222
1530
  const distanceFn = vectorSearch.distance || "cosine";
@@ -1238,7 +1546,33 @@ var DrizzleConditionBuilder = class {
1238
1546
  distanceSelect: sql`(${column} ${sql.raw(operator)} ${sql.raw(vectorLiteral)})`
1239
1547
  };
1240
1548
  }
1549
+ /**
1550
+ * The `vector` column a request named, or a 400 explaining what it named.
1551
+ *
1552
+ * `getTableColumns` rather than a key lookup: it returns only the columns,
1553
+ * so `_`, `getSQL` and every other property of a drizzle table stop looking
1554
+ * like candidates. The type check is on the *physical* column
1555
+ * (`vector(1536)`) rather than on the declared property, so it holds for an
1556
+ * introspected collection too, where the property carries no Rebase type.
1557
+ */
1558
+ static resolveVectorColumn(table, property) {
1559
+ const columns = getTableColumns(table);
1560
+ const column = columns?.[property];
1561
+ if (!column) {
1562
+ const known = Object.entries(columns ?? {}).filter(([, c]) => isVectorColumn(c)).map(([name]) => name);
1563
+ 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");
1564
+ }
1565
+ 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");
1566
+ return column;
1567
+ }
1568
+ };
1569
+ /** The column's SQL type, for a value that may not be a drizzle column at all. */
1570
+ var columnSqlType = (column) => {
1571
+ const getSQLType = column?.getSQLType;
1572
+ return typeof getSQLType === "function" ? getSQLType.call(column).toLowerCase() : "";
1241
1573
  };
1574
+ /** True for `vector(1536)` and its pgvector siblings, whatever the width. */
1575
+ var isVectorColumn = (column) => /^(vector|halfvec|sparsevec)\b/.test(columnSqlType(column));
1242
1576
  /**
1243
1577
  * Alias for DrizzleConditionBuilder for consistent naming with other database implementations.
1244
1578
  * This allows code to use PostgresConditionBuilder alongside future MongoConditionBuilder, etc.
@@ -1287,10 +1621,11 @@ function serializeDataToServer(row, properties, collection, registry) {
1287
1621
  const joinPathRelationUpdates = [];
1288
1622
  const foreignKeys = /* @__PURE__ */ new Set();
1289
1623
  Object.values(resolvedRelations).forEach((relation) => {
1290
- if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
1624
+ if (relation.kind === "belongsTo") foreignKeys.add(fieldKeyForColumn(collection, relation.localKey));
1291
1625
  });
1292
1626
  for (const [key, value] of Object.entries(row)) {
1293
1627
  if (isPrototypePollutingKey(key)) continue;
1628
+ if (value === void 0) continue;
1294
1629
  const property = properties[key];
1295
1630
  const effectiveValue = foreignKeys.has(key) && value === "" ? null : value;
1296
1631
  if (!property) {
@@ -1301,11 +1636,11 @@ function serializeDataToServer(row, properties, collection, registry) {
1301
1636
  const relation = findRelation(resolvedRelations, key);
1302
1637
  if (relation) {
1303
1638
  if (relation.kind === "belongsTo") {
1304
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1305
- if (serializedValue !== void 0) result[relation.localKey] = serializedValue;
1639
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
1640
+ if (serializedValue !== void 0) result[fieldKeyForColumn(collection, relation.localKey)] = serializedValue;
1306
1641
  continue;
1307
1642
  } else if (hasForeignKeyOnTarget(relation)) {
1308
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1643
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
1309
1644
  inverseRelationUpdates.push({
1310
1645
  relationKey: key,
1311
1646
  relation,
@@ -1313,7 +1648,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1313
1648
  });
1314
1649
  continue;
1315
1650
  } else if (relation.kind === "via") {
1316
- const serializedValue = serializePropertyToServer(effectiveValue, property);
1651
+ const serializedValue = serializePropertyToServer(effectiveValue, property, key);
1317
1652
  if (relation.cardinality === "one") joinPathRelationUpdates.push({
1318
1653
  relationKey: key,
1319
1654
  relation,
@@ -1328,7 +1663,7 @@ function serializeDataToServer(row, properties, collection, registry) {
1328
1663
  }
1329
1664
  }
1330
1665
  }
1331
- result[key] = serializePropertyToServer(effectiveValue, property);
1666
+ result[key] = serializePropertyToServer(effectiveValue, property, key);
1332
1667
  }
1333
1668
  return {
1334
1669
  scalarData: result,
@@ -1337,19 +1672,39 @@ function serializeDataToServer(row, properties, collection, registry) {
1337
1672
  };
1338
1673
  }
1339
1674
  /**
1340
- * Serialize a single property value for database storage
1675
+ * How to name a rejected value in an error, without quoting it back.
1676
+ *
1677
+ * The value may be anything the caller sent, including a secret in the wrong
1678
+ * field, so the message describes its shape rather than echoing it — an echoed
1679
+ * value ends up in logs and in error-reporting services.
1680
+ */
1681
+ function describeValue(value) {
1682
+ if (value === null) return "null";
1683
+ if (Array.isArray(value)) return "an array";
1684
+ if (value instanceof Date) return "a date";
1685
+ const type = typeof value;
1686
+ return type === "object" ? "an object" : `a ${type}`;
1687
+ }
1688
+ /**
1689
+ * Serialize a single property value for database storage.
1690
+ *
1691
+ * `propertyKey` is only ever used to phrase errors and warnings. Without it the
1692
+ * one trace a bad value left was `Expected array value for array property, got
1693
+ * string` — no collection, no property, no value, which in the log of a
1694
+ * thousand-row import names nothing at all.
1341
1695
  */
1342
- function serializePropertyToServer(value, property) {
1696
+ function serializePropertyToServer(value, property, propertyKey) {
1343
1697
  if (value === null || value === void 0) return value;
1698
+ const fieldLabel = propertyKey ? `'${propertyKey}'` : `a '${property.type}' field`;
1344
1699
  switch (property.type) {
1345
1700
  case "relation":
1346
- if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property));
1701
+ if (Array.isArray(value)) return value.map((v) => serializePropertyToServer(v, property, propertyKey));
1347
1702
  else if (typeof value === "object" && value !== null && "id" in value) return value.id;
1348
1703
  if (value === "") return null;
1349
1704
  return value;
1350
1705
  case "array":
1351
1706
  if (Array.isArray(value)) {
1352
- if (property.of) return value.map((item) => serializePropertyToServer(item, property.of));
1707
+ if (property.of) return value.map((item) => serializePropertyToServer(item, property.of, propertyKey));
1353
1708
  else if (property.oneOf) {
1354
1709
  const typeField = property.oneOf.typeField ?? "type";
1355
1710
  const valueField = property.oneOf.valueField ?? "value";
@@ -1362,20 +1717,28 @@ function serializePropertyToServer(value, property) {
1362
1717
  if (!type || !childProperty) return e;
1363
1718
  return {
1364
1719
  [typeField]: type,
1365
- [valueField]: serializePropertyToServer(rec[valueField], childProperty)
1720
+ [valueField]: serializePropertyToServer(rec[valueField], childProperty, propertyKey)
1366
1721
  };
1367
1722
  });
1368
1723
  }
1369
1724
  return value;
1370
1725
  }
1371
- logger.warn(`Expected array value for array property, got ${typeof value}. Coercing to empty array.`);
1372
- return [];
1726
+ throw ApiError.badRequest(`${fieldLabel} expects an array, but received ${describeValue(value)}.`, "VALIDATION_INVALID_VALUE");
1727
+ case "geopoint": {
1728
+ 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");
1729
+ const point = value;
1730
+ 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");
1731
+ return {
1732
+ latitude: point.latitude,
1733
+ longitude: point.longitude
1734
+ };
1735
+ }
1373
1736
  case "map":
1374
1737
  if (typeof value === "object" && property.properties) {
1375
1738
  const result = {};
1376
1739
  for (const [subKey, subValue] of Object.entries(value)) {
1377
1740
  const subProperty = property.properties[subKey];
1378
- if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty);
1741
+ if (subProperty) result[subKey] = serializePropertyToServer(subValue, subProperty, propertyKey ? `${propertyKey}.${subKey}` : subKey);
1379
1742
  else result[subKey] = subValue;
1380
1743
  }
1381
1744
  return result;
@@ -1410,8 +1773,9 @@ async function parseDataFromServer(data, collection, db, registry) {
1410
1773
  for (const [propKey, property] of Object.entries(properties)) if (property.type === "relation" && !(propKey in result)) {
1411
1774
  const relation = findRelation(resolvedRelations, propKey);
1412
1775
  if (relation) {
1413
- if (relation.kind === "belongsTo" && relation.localKey in data) {
1414
- const fkValue = data[relation.localKey];
1776
+ const localField = relation.kind === "belongsTo" ? fieldKeyForColumn(collection, relation.localKey) : "";
1777
+ if (relation.kind === "belongsTo" && localField in data) {
1778
+ const fkValue = data[localField];
1415
1779
  if (fkValue !== null && fkValue !== void 0) try {
1416
1780
  const targetCollection = relation.target();
1417
1781
  result[propKey] = createRelationRef(fkValue.toString(), targetCollection.slug);
@@ -1424,7 +1788,7 @@ async function parseDataFromServer(data, collection, db, registry) {
1424
1788
  const pks = getPrimaryKeys(collection, registry);
1425
1789
  const currentId = relation.sourceKey ? data[relation.sourceKey] : buildCompositeId(data, pks);
1426
1790
  if (targetTable && currentId !== void 0 && currentId !== null && currentId !== "") {
1427
- const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
1791
+ const foreignKeyColumn = targetTable[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
1428
1792
  if (foreignKeyColumn) {
1429
1793
  const relatedRows = await db.select().from(targetTable).where(eq(foreignKeyColumn, currentId)).limit(relation.cardinality === "one" ? 1 : 100);
1430
1794
  if (relatedRows.length > 0) if (relation.cardinality === "one") {
@@ -1613,6 +1977,7 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1613
1977
  return isNaN(parsed) ? null : parsed;
1614
1978
  }
1615
1979
  return value;
1980
+ case "geopoint": return value;
1616
1981
  case "vector": {
1617
1982
  let nums = [];
1618
1983
  if (typeof value === "string") nums = value.slice(1, -1).split(",").map(Number);
@@ -1656,17 +2021,35 @@ function parsePropertyFromServer(value, property, collection, propertyKey) {
1656
2021
  * from the result (used by `normalizeDbValues` where
1657
2022
  * Drizzle's relational API already hydrates them).
1658
2023
  */
2024
+ /**
2025
+ * Keys a query computes and attaches to a row, rather than reads from a column.
2026
+ *
2027
+ * An explicit set rather than a `_` prefix rule: a user's column may perfectly
2028
+ * well be called `_internal`, and passing it through here would put a value on
2029
+ * the row that no property describes and nothing downstream knows how to type.
2030
+ */
2031
+ var QUERY_METADATA_KEYS = /* @__PURE__ */ new Set([
2032
+ "_score",
2033
+ "_distance",
2034
+ "_matches"
2035
+ ]);
1659
2036
  function normalizeScalarValues(data, properties, collection, resolvedRelations, options) {
1660
2037
  const result = {};
1661
2038
  const internalFKColumns = /* @__PURE__ */ new Set();
1662
2039
  Object.values(resolvedRelations).forEach((relation) => {
1663
- if (relation.kind === "belongsTo" && !properties[relation.localKey]) internalFKColumns.add(relation.localKey);
2040
+ if (relation.kind !== "belongsTo") return;
2041
+ const localField = fieldKeyForColumn(collection, relation.localKey);
2042
+ if (!properties[localField]) internalFKColumns.add(localField);
1664
2043
  });
1665
2044
  for (const [key, value] of Object.entries(data)) {
1666
2045
  if (internalFKColumns.has(key)) {
1667
2046
  result[key] = value === null ? null : typeof value === "number" ? value : String(value);
1668
2047
  continue;
1669
2048
  }
2049
+ if (QUERY_METADATA_KEYS.has(key)) {
2050
+ result[key] = value;
2051
+ continue;
2052
+ }
1670
2053
  const property = properties[key];
1671
2054
  if (!property) continue;
1672
2055
  if (options.skipRelations && property.type === "relation") continue;
@@ -1689,34 +2072,179 @@ function normalizeDbValues(data, collection) {
1689
2072
  return normalizeScalarValues(data, properties, collection, resolveCollectionRelations(collection), { skipRelations: true });
1690
2073
  }
1691
2074
  //#endregion
1692
- //#region src/services/RelationService.ts
2075
+ //#region src/services/write-denial.ts
2076
+ /**
2077
+ * Explain a write that matched no rows.
2078
+ *
2079
+ * Row-level security filters UPDATE and DELETE through the policy's USING
2080
+ * clause instead of raising: a denied write is reported by Postgres exactly
2081
+ * like a successful one that happened to match nothing. Left unchecked, a
2082
+ * caller cannot tell "denied" from "done" — the write returns 200/204 and the
2083
+ * row is untouched. An agent handed a key with `orders:delete` and no delete
2084
+ * policy is the case that makes it concrete: it deletes nothing, forever, and
2085
+ * is told it worked every time.
2086
+ *
2087
+ * Re-reading the target over the *same* RLS-scoped handle separates the two
2088
+ * cases. A visible row means the policy rejected the write (403); an invisible
2089
+ * one means there is nothing there to write for this caller (404, matching what
2090
+ * a GET would say). The re-read is bound by the caller's own policies, so it
2091
+ * discloses nothing a plain read wouldn't.
2092
+ *
2093
+ * Only reached when zero rows matched, so the happy path pays nothing.
2094
+ *
2095
+ * It lives here, rather than beside its first caller, because every zero-row
2096
+ * write has to answer the same question and answer it identically: the rule
2097
+ * that a readable-but-unwritable target is a 403 is the contract, and a second
2098
+ * copy of it is a second chance to get it wrong.
2099
+ *
2100
+ * @param handle The RLS-scoped connection the write ran on — not a fresh
2101
+ * one, or the re-read would answer for a different caller.
2102
+ * @param table The table the write targeted (the junction, for a link).
2103
+ * @param conditions The write's own WHERE terms, reused verbatim.
2104
+ * @param denied Message for the 403: the target is there and was refused.
2105
+ * @param missing Message for the 404: there is nothing there for this caller.
2106
+ */
2107
+ async function explainZeroRowWrite(handle, table, conditions, denied, missing) {
2108
+ if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(denied, "WRITE_DENIED");
2109
+ return ApiError.notFound(missing);
2110
+ }
2111
+ //#endregion
2112
+ //#region src/services/junction-writes.ts
2113
+ /** A junction that cannot be resolved is a broken relation, not a no-op. */
2114
+ var misconfigured = (label, detail) => relationMisconfigured(label, detail);
2115
+ function column(table, name, label, role) {
2116
+ const col = name ? table[name] : void 0;
2117
+ if (!col) throw misconfigured(label, `no ${role} column '${name ?? "?"}' on the junction table`);
2118
+ return col;
2119
+ }
2120
+ /** The junction a `manyToMany` names outright. */
2121
+ function bindThroughJunction(registry, through, label) {
2122
+ const table = registry.getTable(through.table);
2123
+ if (!table) throw misconfigured(label, `no table '${through.table}' in the registry`);
2124
+ return {
2125
+ table,
2126
+ parentColumn: column(table, through.sourceColumn, label, "source"),
2127
+ targetColumn: column(table, through.targetColumn, label, "target"),
2128
+ label
2129
+ };
2130
+ }
2131
+ /** The bare column name, whether written as `col` or `table.col`. */
2132
+ var columnPart = (spec) => {
2133
+ const one = Array.isArray(spec) ? spec[0] : spec;
2134
+ return one.includes(".") ? one.split(".")[1] : one;
2135
+ };
2136
+ /** The table a column spec names, or undefined when it is unqualified. */
2137
+ var tablePart = (spec) => {
2138
+ const one = Array.isArray(spec) ? spec[0] : spec;
2139
+ return one.includes(".") ? one.split(".")[0] : void 0;
2140
+ };
1693
2141
  /**
1694
- * The ids in a to-many relation write, whatever shape the caller sent.
2142
+ * The junction a `via` relation reaches through, from either end.
2143
+ *
2144
+ * A step is `{ table: T, on: { from, to } }` where `from` names a column on
2145
+ * whatever the walk was standing on and `to` names one on `T`. That positional
2146
+ * meaning is what makes the unqualified form work at all, so the walk carries
2147
+ * the previous table rather than asking the column names where they live.
2148
+ */
2149
+ function bindJoinPathJunction(registry, joinPath, parentTableName, targetTableName, label) {
2150
+ const junctionName = joinPath.map((step) => step.table).find((table) => table !== parentTableName && table !== targetTableName);
2151
+ if (!junctionName) throw misconfigured(label, "its joinPath has no table between the two ends to hold the links");
2152
+ const table = registry.getTable(junctionName);
2153
+ if (!table) throw misconfigured(label, `no table '${junctionName}' in the registry`);
2154
+ let parentColumnName;
2155
+ let targetColumnName;
2156
+ let previousTable = parentTableName;
2157
+ for (const step of joinPath) {
2158
+ const fromTable = tablePart(step.on.from) ?? previousTable;
2159
+ const toTable = tablePart(step.on.to) ?? step.table;
2160
+ if (toTable === junctionName && fromTable === parentTableName) parentColumnName = columnPart(step.on.to);
2161
+ else if (fromTable === junctionName && toTable === parentTableName) parentColumnName = columnPart(step.on.from);
2162
+ else if (toTable === junctionName && fromTable === targetTableName) targetColumnName = columnPart(step.on.to);
2163
+ else if (fromTable === junctionName && toTable === targetTableName) targetColumnName = columnPart(step.on.from);
2164
+ previousTable = step.table;
2165
+ }
2166
+ if (!parentColumnName || !targetColumnName) throw misconfigured(label, `its joinPath does not connect '${parentTableName}' and '${targetTableName}' through '${junctionName}'`);
2167
+ return {
2168
+ table,
2169
+ parentColumn: column(table, parentColumnName, label, "source"),
2170
+ targetColumn: column(table, targetColumnName, label, "target"),
2171
+ label
2172
+ };
2173
+ }
2174
+ /**
2175
+ * Remove one link, leaving the row on the far side alone.
1695
2176
  *
1696
- * A membership list is written as either the related rows (`[{ id: 1 }]`, what
1697
- * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
1698
- * what anyone writing the API by hand sends). Only the first was read, via a
1699
- * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
1700
- * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
1701
- * and on a string-keyed one it did not surface at all — `String(undefined)`
1702
- * wrote a junction row pointing at the literal `"undefined"`, which no read
1703
- * would ever match. Both shapes are accepted here, in one place, because both
1704
- * call sites had the same assumption.
2177
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
2178
+ * target is shared, so deleting the row would remove the tag from every other
2179
+ * post that uses it.
2180
+ */
2181
+ async function removeJunctionLink(tx, binding, parentId, targetId, subject) {
2182
+ const conditions = [eq(binding.parentColumn, parentId), eq(binding.targetColumn, targetId)];
2183
+ if (((await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0) === 0) throw await explainZeroRowWrite(tx, binding.table, conditions, `Not allowed to unlink "${targetId}" from "${subject.parent}" "${parentId}": a row-level security policy rejected the write.`, `No "${subject.relation}" link between "${subject.parent}" "${parentId}" and "${targetId}" to remove.`);
2184
+ logger.info(`Unlinked '${subject.relation}' ${targetId} from ${subject.parent} ${parentId}`);
2185
+ }
2186
+ /**
2187
+ * Make the junction say that `parentId` is linked to exactly `targetIds`, by
2188
+ * diffing against what is linked now rather than replacing the set.
2189
+ *
2190
+ * A save of the parent used to delete every junction row for it and re-insert
2191
+ * the ids the browser sent — a list the browser assembled from a read it did
2192
+ * earlier. Three things followed, all data loss rather than display:
2193
+ *
2194
+ * - **Lost update.** Two editors with post 7 open: A adds tag X and saves, B
2195
+ * saves any field from a form that predates it, and X is gone with nothing
2196
+ * reported to either of them.
2197
+ * - **A partially-read set is a partially-deleted set.** The read that fills
2198
+ * the form runs under RLS, so a user who may edit the parent but cannot see
2199
+ * some of the linked rows gets a shorter list — and writing it back deleted
2200
+ * the links they were never shown. The select that drives the diff runs in
2201
+ * this same transaction under the same policies, so a link the caller cannot
2202
+ * read is in neither list and survives the save.
2203
+ * - **Junction payload columns.** A junction carrying its own columns
2204
+ * (`position`, `role`, `created_at`) lost them on every save, because every
2205
+ * row was re-inserted with only the two keys. Untouched links are left alone.
2206
+ *
2207
+ * The insert is `ON CONFLICT DO NOTHING`, so two sessions adding the same link
2208
+ * concurrently is a no-op rather than a unique violation.
2209
+ */
2210
+ async function applyJunctionMembership(tx, binding, parentId, targetIds) {
2211
+ const existingRows = await tx.select({ targetId: binding.targetColumn }).from(binding.table).where(eq(binding.parentColumn, parentId));
2212
+ const existingById = /* @__PURE__ */ new Map();
2213
+ for (const row of existingRows) {
2214
+ if (row.targetId === null || row.targetId === void 0) continue;
2215
+ existingById.set(String(row.targetId), row.targetId);
2216
+ }
2217
+ const wantedById = /* @__PURE__ */ new Map();
2218
+ for (const targetId of targetIds) {
2219
+ if (targetId === null || targetId === void 0) continue;
2220
+ wantedById.set(String(targetId), targetId);
2221
+ }
2222
+ const removed = [...existingById.entries()].filter(([key]) => !wantedById.has(key)).map(([, value]) => value);
2223
+ const added = [...wantedById.entries()].filter(([key]) => !existingById.has(key)).map(([, value]) => value);
2224
+ if (removed.length > 0) await removeLinks(tx, binding, parentId, removed);
2225
+ if (added.length > 0) await tx.insert(binding.table).values(added.map((targetId) => ({
2226
+ [binding.parentColumn.name]: parentId,
2227
+ [binding.targetColumn.name]: targetId
2228
+ }))).onConflictDoNothing();
2229
+ }
2230
+ /**
2231
+ * Drop the named links, and refuse to call it done if the database kept any.
1705
2232
  *
1706
- * An element that carries no key is refused rather than skipped: dropping it
1707
- * would silently write a shorter membership list than the caller asked for.
2233
+ * Every id here came out of the select in {@link applyJunctionMembership}, on
2234
+ * this same handle, so all of them were visible. Fewer deletions than that means
2235
+ * something refused them — and a save that reports success while the membership
2236
+ * it stored is not the membership it was given has told the caller something
2237
+ * untrue about the database.
1708
2238
  */
1709
- function relationTargetIds(value, relationName, collectionSlug) {
1710
- if (!Array.isArray(value)) return [];
1711
- return value.map((element, index) => {
1712
- if (typeof element === "string" || typeof element === "number") return element;
1713
- if (element && typeof element === "object") {
1714
- const id = element.id;
1715
- if (typeof id === "string" || typeof id === "number") return id;
1716
- }
1717
- throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
1718
- });
2239
+ async function removeLinks(tx, binding, parentId, removed) {
2240
+ const conditions = [eq(binding.parentColumn, parentId), inArray(binding.targetColumn, removed)];
2241
+ const deleted = (await tx.delete(binding.table).where(and(...conditions))).rowCount ?? 0;
2242
+ if (deleted >= removed.length) return;
2243
+ if ((await tx.select({ present: sql`1` }).from(binding.table).where(and(...conditions)).limit(1)).length === 0) return;
2244
+ throw ApiError.forbidden(`Not allowed to remove ${removed.length - deleted} of ${removed.length} link(s) for relation '${binding.label}': a row-level security policy rejected the write.`, "WRITE_DENIED");
1719
2245
  }
2246
+ //#endregion
2247
+ //#region src/services/RelationService.ts
1720
2248
  /**
1721
2249
  * Typed wrapper for Drizzle dynamic query innerJoin.
1722
2250
  * Drizzle's `$dynamic()` queries lose the `innerJoin` method from
@@ -1824,6 +2352,10 @@ var RelationService = class {
1824
2352
  const { keyByParentId } = await this.resolveSourceKeys(parentCollection, relation, [parentId], db);
1825
2353
  return keyByParentId.get(String(parentId));
1826
2354
  }
2355
+ /**
2356
+ * Shared with {@link RelationWriteService}: a write needs the same source
2357
+ * key a read does, and resolving it twice is how the two would disagree.
2358
+ */
1827
2359
  async resolveSourceKeys(parentCollection, relation, parentIds, db = this.db) {
1828
2360
  const keyByParentId = /* @__PURE__ */ new Map();
1829
2361
  const parentIdByKey = /* @__PURE__ */ new Map();
@@ -1919,7 +2451,7 @@ var RelationService = class {
1919
2451
  let query = this.db.select().from(targetTable).$dynamic();
1920
2452
  const additionalFilters = [];
1921
2453
  if (options.searchString) {
1922
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable);
2454
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, targetCollection.properties, targetTable, targetCollection);
1923
2455
  if (searchConditions.length === 0) return [];
1924
2456
  const searchCombined = DrizzleConditionBuilder.combineConditionsWithOr(searchConditions);
1925
2457
  if (searchCombined) additionalFilters.push(searchCombined);
@@ -1994,30 +2526,6 @@ var RelationService = class {
1994
2526
  return await this.countRelatedRows(hop.parentCollection, hop.parentId, hop.relation, identity) > 0;
1995
2527
  }
1996
2528
  /**
1997
- * Remove the junction row linking a parent to `targetId`, leaving the target
1998
- * row itself alone.
1999
- *
2000
- * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
2001
- * target is shared, so deleting the row would remove the tag from every other
2002
- * post that uses it. It used to do exactly that — resolve the path to the
2003
- * `tags` table and delete by primary key.
2004
- */
2005
- async unlinkRelatedEntity(tx, hop, targetId) {
2006
- if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
2007
- const through = hop.relation.through;
2008
- const junctionTable = this.registry.getTable(through.table);
2009
- if (!junctionTable) throw new Error(`Junction table not found: ${through.table}`);
2010
- const sourceJunctionColumn = junctionTable[through.sourceColumn];
2011
- const targetJunctionColumn = junctionTable[through.targetColumn];
2012
- if (!sourceJunctionColumn || !targetJunctionColumn) throw new Error(`Junction columns not found for relation '${hop.relationKey}' on table '${through.table}'`);
2013
- const parentPks = requirePrimaryKeys(hop.parentCollection, this.registry);
2014
- const parsedParentId = parseIdValues(hop.parentId, parentPks)[parentPks[0].fieldName];
2015
- const targetPks = requirePrimaryKeys(hop.targetCollection, this.registry);
2016
- const parsedTargetId = parseIdValues(targetId, targetPks)[targetPks[0].fieldName];
2017
- await tx.delete(junctionTable).where(and(eq(sourceJunctionColumn, parsedParentId), eq(targetJunctionColumn, parsedTargetId)));
2018
- logger.info(`Unlinked '${hop.relationKey}' ${parsedTargetId} from ${hop.parentCollection.slug} ${parsedParentId}`);
2019
- }
2020
- /**
2021
2529
  * Batch fetch related rows for multiple parent rows to avoid N+1 queries
2022
2530
  */
2023
2531
  async batchFetchRelatedEntities(parentCollectionPath, parentIds, _relationKey, relation) {
@@ -2065,7 +2573,7 @@ var RelationService = class {
2065
2573
  }
2066
2574
  if (relation.kind === "belongsTo") {
2067
2575
  this.assertSingleKeyAddressable(parentCollection, parentPks, relation.localKey);
2068
- const localKeyCol = parentTable[relation.localKey];
2576
+ const localKeyCol = parentTable[fieldKeyForColumn(parentCollection, relation.localKey)];
2069
2577
  if (!localKeyCol) throw new Error(`Local key column '${relation.localKey}' not found in parent table`);
2070
2578
  const fkRows = await this.db.select({
2071
2579
  parentId: parentIdCol,
@@ -2111,7 +2619,7 @@ var RelationService = class {
2111
2619
  for (const row of results) {
2112
2620
  const targetRow = row[getTableName$1(targetCollection)] || row;
2113
2621
  if (!hasForeignKeyOnTarget(relation)) continue;
2114
- const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
2622
+ const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
2115
2623
  if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
2116
2624
  const parentId = parentIdByKey.get(String(foreignKeyValue));
2117
2625
  if (parentId !== void 0) resultMap.set(String(parentId), await this.toRelatedRow(targetRow, targetCollection, targetPks));
@@ -2168,17 +2676,7 @@ var RelationService = class {
2168
2676
  }
2169
2677
  if (relation.kind === "manyToMany") {
2170
2678
  this.assertSingleKeyAddressable(parentCollection, parentPks, `${relation.through.table}.${relation.through.sourceColumn}`);
2171
- const junctionTable = this.registry.getTable(relation.through.table);
2172
- if (!junctionTable) {
2173
- logger.warn(`[batchFetchRelatedEntitiesMany] Junction table '${relation.through.table}' not found`);
2174
- return /* @__PURE__ */ new Map();
2175
- }
2176
- const sourceJunctionCol = junctionTable[relation.through.sourceColumn];
2177
- const targetJunctionCol = junctionTable[relation.through.targetColumn];
2178
- if (!sourceJunctionCol || !targetJunctionCol) {
2179
- logger.warn(`[batchFetchRelatedEntitiesMany] Junction columns not found in '${relation.through.table}'`);
2180
- return /* @__PURE__ */ new Map();
2181
- }
2679
+ const { table: junctionTable, parentColumn: sourceJunctionCol, targetColumn: targetJunctionCol } = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relation.relationName}`);
2182
2680
  const results = await this.db.select().from(junctionTable).innerJoin(targetTable, eq(targetJunctionCol, targetIdField)).where(inArray(sourceJunctionCol, parsedParentIds));
2183
2681
  const resultMap = /* @__PURE__ */ new Map();
2184
2682
  const targetTableName = getTableName$1(targetCollection);
@@ -2206,7 +2704,7 @@ var RelationService = class {
2206
2704
  for (const row of results) {
2207
2705
  const targetRow = row[getTableName$1(targetCollection)] || row;
2208
2706
  if (!hasForeignKeyOnTarget(relation)) continue;
2209
- const foreignKeyValue = targetRow[relation.foreignKeyOnTarget];
2707
+ const foreignKeyValue = targetRow[fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget)];
2210
2708
  if (foreignKeyValue === void 0 || foreignKeyValue === null) continue;
2211
2709
  const parentId = parentIdByKey.get(String(foreignKeyValue));
2212
2710
  if (parentId !== void 0) {
@@ -2218,346 +2716,6 @@ var RelationService = class {
2218
2716
  }
2219
2717
  return resultMap;
2220
2718
  }
2221
- /**
2222
- * Update many-to-many and junction relations
2223
- */
2224
- async updateRelationsUsingJoins(tx, collection, id, relationValues) {
2225
- const resolvedRelations = resolveCollectionRelations(collection);
2226
- for (const [key, value] of Object.entries(relationValues)) {
2227
- const relation = findRelation(resolvedRelations, key);
2228
- if (!relation || relation.cardinality !== "many") continue;
2229
- const targetEntityIds = relationTargetIds(value, key, collection.slug);
2230
- const targetCollection = relation.target();
2231
- if (relation.kind === "via") {
2232
- const parentTableName = getTableName$1(collection);
2233
- const targetTableName = getTableName$1(targetCollection);
2234
- let junctionTable = void 0;
2235
- let sourceJunctionColumn = null;
2236
- let targetJunctionColumn = null;
2237
- const junctionTableName = relation.joinPath.find((step) => step.table !== parentTableName && step.table !== targetTableName)?.table;
2238
- if (junctionTableName) {
2239
- junctionTable = this.registry.getTable(junctionTableName);
2240
- if (junctionTable) for (const joinStep of relation.joinPath) {
2241
- const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.from)[0];
2242
- const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(joinStep.on.to)[0];
2243
- if (fromTable === parentTableName && toTable === junctionTableName) {
2244
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
2245
- sourceJunctionColumn = junctionTable[columnNames[0]];
2246
- } else if (fromTable === junctionTableName && toTable === parentTableName) {
2247
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
2248
- sourceJunctionColumn = junctionTable[columnNames[0]];
2249
- }
2250
- if (fromTable === junctionTableName && toTable === targetTableName) {
2251
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.from);
2252
- targetJunctionColumn = junctionTable[columnNames[0]];
2253
- } else if (fromTable === targetTableName && toTable === junctionTableName) {
2254
- const columnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(joinStep.on.to);
2255
- targetJunctionColumn = junctionTable[columnNames[0]];
2256
- }
2257
- }
2258
- }
2259
- if (!junctionTable || !sourceJunctionColumn || !targetJunctionColumn) {
2260
- logger.warn(`Could not determine junction table for relation '${key}' in collection '${collection.slug}'`);
2261
- continue;
2262
- }
2263
- const parentPks = requirePrimaryKeys(collection, this.registry);
2264
- const parentIdInfo = parentPks[0];
2265
- const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
2266
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
2267
- if (targetEntityIds.length > 0) {
2268
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2269
- const targetIdInfo = targetPks[0];
2270
- const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2271
- [sourceJunctionColumn.name]: parsedParentId,
2272
- [targetJunctionColumn.name]: targetId
2273
- }));
2274
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2275
- }
2276
- } else if (relation.kind === "manyToMany") {
2277
- const junctionTable = this.registry.getTable(relation.through.table);
2278
- if (!junctionTable) {
2279
- logger.warn(`Junction table '${relation.through.table}' not found for relation '${key}' in collection '${collection.slug}'`);
2280
- continue;
2281
- }
2282
- const sourceJunctionColumn = junctionTable[relation.through.sourceColumn];
2283
- const targetJunctionColumn = junctionTable[relation.through.targetColumn];
2284
- if (!sourceJunctionColumn || !targetJunctionColumn) {
2285
- logger.warn(`Junction columns not found for relation '${key}'`);
2286
- continue;
2287
- }
2288
- const parentPks = requirePrimaryKeys(collection, this.registry);
2289
- const parentIdInfo = parentPks[0];
2290
- const parsedParentId = parseIdValues(id, parentPks)[parentIdInfo.fieldName];
2291
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedParentId));
2292
- if (targetEntityIds.length > 0) {
2293
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2294
- const targetIdInfo = targetPks[0];
2295
- const newLinks = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2296
- [sourceJunctionColumn.name]: parsedParentId,
2297
- [targetJunctionColumn.name]: targetId
2298
- }));
2299
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2300
- }
2301
- } else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
2302
- const targetTable = getTableForCollection(targetCollection, this.registry);
2303
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2304
- const targetIdInfo = targetPks[0];
2305
- const targetIdCol = targetTable[targetIdInfo.fieldName];
2306
- const fkCol = targetTable[relation.foreignKeyOnTarget];
2307
- if (!fkCol || !targetIdCol) {
2308
- logger.warn(`Invalid inverse-many config for relation '${key}' in collection '${collection.slug}'`);
2309
- continue;
2310
- }
2311
- const parentKeyValue = (await this.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
2312
- if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
2313
- if (targetEntityIds.length > 0) {
2314
- const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
2315
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(and(eq(fkCol, parentKeyValue), sql`${targetIdCol} NOT IN (${sql.join(parsedTargetIds)})`));
2316
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
2317
- } else await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(fkCol, parentKeyValue));
2318
- } else logger.warn(`Many relation '${key}' in collection '${collection.slug}' lacks write configuration and will be skipped during save.`);
2319
- }
2320
- }
2321
- /**
2322
- * Update inverse relations (where FK is on the target table)
2323
- */
2324
- async updateInverseRelations(tx, sourceCollection, sourceEntityId, inverseRelationUpdates) {
2325
- for (const update of inverseRelationUpdates) {
2326
- const { relation, newValue } = update;
2327
- try {
2328
- const targetCollection = relation.target();
2329
- const targetTable = getTableForCollection(targetCollection, this.registry);
2330
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2331
- const targetIdInfo = targetPks[0];
2332
- requirePrimaryKeys(sourceCollection, this.registry)[0];
2333
- if (relation.kind === "via") {
2334
- await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
2335
- continue;
2336
- }
2337
- if (isManyToMany(relation)) {
2338
- await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
2339
- table: relation.through.table,
2340
- sourceColumn: relation.through.sourceColumn,
2341
- targetColumn: relation.through.targetColumn
2342
- });
2343
- continue;
2344
- }
2345
- if (!hasForeignKeyOnTarget(relation)) {
2346
- logger.warn(`Relation '${relation.relationName}' has no column on the target to write. Skipping.`);
2347
- continue;
2348
- }
2349
- const foreignKeyColumn = targetTable[relation.foreignKeyOnTarget];
2350
- if (!foreignKeyColumn) {
2351
- logger.warn(`Foreign key column '${relation.foreignKeyOnTarget}' not found in target table for relation '${relation.relationName}'`);
2352
- continue;
2353
- }
2354
- const sourceKeyValue = (await this.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
2355
- if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
2356
- if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
2357
- else {
2358
- const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
2359
- const targetIdField = targetTable[targetIdInfo.fieldName];
2360
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
2361
- await tx.update(targetTable).set({ [relation.foreignKeyOnTarget]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
2362
- }
2363
- } catch (e) {
2364
- logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
2365
- }
2366
- }
2367
- }
2368
- /**
2369
- * Handle inverse relations with joinPath
2370
- */
2371
- async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
2372
- try {
2373
- const sourceTableName = getTableName$1(sourceCollection);
2374
- const targetTableName = getTableName$1(targetCollection);
2375
- const intermediateTables = relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName);
2376
- if (intermediateTables.length === 1 && relation.cardinality === "many") {
2377
- const junctionTableName = intermediateTables[0];
2378
- const junctionTable = this.registry.getTable(junctionTableName);
2379
- if (!junctionTable) {
2380
- logger.warn(`Junction table '${junctionTableName}' not found for inverse joinPath relation '${relation.relationName}'`);
2381
- return;
2382
- }
2383
- let sourceJunctionColumn = null;
2384
- let targetJunctionColumn = null;
2385
- for (const step of relation.joinPath) if (step.table === junctionTableName) {
2386
- const fromTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.from)[0];
2387
- const toColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.to);
2388
- const fromColumnNames = DrizzleConditionBuilder.getColumnNamesFromColumns(step.on.from);
2389
- if (fromTable === sourceTableName) sourceJunctionColumn = junctionTable[toColumnNames[0]];
2390
- else if (fromTable === targetTableName) targetJunctionColumn = junctionTable[toColumnNames[0]];
2391
- else {
2392
- const toTable = DrizzleConditionBuilder.getTableNamesFromColumns(step.on.to)[0];
2393
- if (toTable === sourceTableName) sourceJunctionColumn = junctionTable[fromColumnNames[0]];
2394
- else if (toTable === targetTableName) targetJunctionColumn = junctionTable[fromColumnNames[0]];
2395
- }
2396
- }
2397
- if (!sourceJunctionColumn || !targetJunctionColumn) {
2398
- logger.warn(`Could not determine junction columns for inverse joinPath relation '${relation.relationName}'`);
2399
- return;
2400
- }
2401
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2402
- const sourceIdInfo = sourcePks[0];
2403
- const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
2404
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
2405
- if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2406
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2407
- const targetIdInfo = targetPks[0];
2408
- const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2409
- [sourceJunctionColumn.name]: parsedSourceId,
2410
- [targetJunctionColumn.name]: targetId
2411
- }));
2412
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2413
- } else if (newValue && !Array.isArray(newValue)) {
2414
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2415
- const targetIdInfo = targetPks[0];
2416
- const parsedTargetId = parseIdValues(typeof newValue === "object" && newValue !== null ? newValue.id : newValue, targetPks)[targetIdInfo.fieldName];
2417
- const newLink = {
2418
- [sourceJunctionColumn.name]: parsedSourceId,
2419
- [targetJunctionColumn.name]: parsedTargetId
2420
- };
2421
- await tx.insert(junctionTable).values(newLink);
2422
- }
2423
- }
2424
- } catch (error) {
2425
- logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
2426
- throw error;
2427
- }
2428
- }
2429
- /**
2430
- * Handle many-to-many inverse relation updates using junction tables
2431
- */
2432
- async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
2433
- try {
2434
- const junctionTable = this.registry.getTable(junctionInfo.table);
2435
- if (!junctionTable) {
2436
- logger.warn(`Junction table '${junctionInfo.table}' not found for many-to-many inverse relation '${relation.relationName}'`);
2437
- return;
2438
- }
2439
- const sourceJunctionColumn = junctionTable[junctionInfo.sourceColumn];
2440
- const targetJunctionColumn = junctionTable[junctionInfo.targetColumn];
2441
- if (!sourceJunctionColumn || !targetJunctionColumn) {
2442
- logger.warn(`Junction columns not found for relation '${relation.relationName}'`);
2443
- return;
2444
- }
2445
- const sourcePks = requirePrimaryKeys(sourceCollection, this.registry);
2446
- const sourceIdInfo = sourcePks[0];
2447
- const parsedSourceId = parseIdValues(sourceEntityId, sourcePks)[sourceIdInfo.fieldName];
2448
- await tx.delete(junctionTable).where(eq(sourceJunctionColumn, parsedSourceId));
2449
- if (newValue && Array.isArray(newValue) && newValue.length > 0) {
2450
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2451
- const targetIdInfo = targetPks[0];
2452
- const newLinks = relationTargetIds(newValue, relation.relationName, sourceCollection.slug).map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]).map((targetId) => ({
2453
- [sourceJunctionColumn.name]: parsedSourceId,
2454
- [targetJunctionColumn.name]: targetId
2455
- }));
2456
- if (newLinks.length > 0) await tx.insert(junctionTable).values(newLinks);
2457
- }
2458
- } catch (error) {
2459
- logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
2460
- throw error;
2461
- }
2462
- }
2463
- /**
2464
- * Update one-to-one relations that use joinPath
2465
- */
2466
- async updateJoinPathOneToOneRelations(tx, parentCollection, parentId, updates) {
2467
- for (const upd of updates) {
2468
- const { relation, newTargetId } = upd;
2469
- const targetCollection = relation.target();
2470
- const targetTable = getTableForCollection(targetCollection, this.registry);
2471
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2472
- const targetIdInfo = targetPks[0];
2473
- const targetIdCol = targetTable[targetIdInfo.fieldName];
2474
- const { targetFKColName, parentSourceColName } = this.resolveJoinPathWriteMapping(parentCollection, relation);
2475
- const parentTable = getTableForCollection(parentCollection, this.registry);
2476
- const parentPks = requirePrimaryKeys(parentCollection, this.registry);
2477
- const parentIdInfo = parentPks[0];
2478
- const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
2479
- const parentIdCol = parentTable[parentIdInfo.fieldName];
2480
- const parentSourceCol = parentTable[parentSourceColName];
2481
- const targetFKCol = targetTable[targetFKColName];
2482
- if (!parentSourceCol) {
2483
- logger.warn(`Parent source column '${parentSourceColName}' not found for joinPath relation '${relation.relationName}'`);
2484
- continue;
2485
- }
2486
- if (!targetFKCol) {
2487
- logger.warn(`Target FK column '${targetFKColName}' not found for joinPath relation '${relation.relationName}'`);
2488
- continue;
2489
- }
2490
- const parentRows = await tx.select({ val: parentSourceCol }).from(parentTable).where(eq(parentIdCol, parsedParentId)).limit(1);
2491
- if (parentRows.length === 0) continue;
2492
- const parentFKValue = parentRows[0].val;
2493
- if (newTargetId === null || newTargetId === void 0) {
2494
- if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
2495
- continue;
2496
- }
2497
- const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
2498
- if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
2499
- else {
2500
- logger.warn(`Cannot set joinPath relation '${relation.relationName}' because parent FK value is null/undefined`);
2501
- continue;
2502
- }
2503
- await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
2504
- }
2505
- }
2506
- /**
2507
- * Resolve joinPath write mapping for one-to-one relations
2508
- */
2509
- resolveJoinPathWriteMapping(parentCollection, relation) {
2510
- if (!relation.joinPath || relation.joinPath.length === 0) throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
2511
- const parentTableName = getTableName$1(parentCollection);
2512
- const lastStep = relation.joinPath[relation.joinPath.length - 1];
2513
- const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
2514
- let currentFrom = lastStep.on.from;
2515
- let safety = 0;
2516
- while (safety++ < 10) {
2517
- if (DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0] === parentTableName) break;
2518
- const prevStep = relation.joinPath.find((s) => {
2519
- return (Array.isArray(s.on.to) ? s.on.to[0] : s.on.to) === currentFrom;
2520
- });
2521
- if (!prevStep) throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
2522
- currentFrom = prevStep.on.from;
2523
- }
2524
- return {
2525
- targetFKColName,
2526
- parentSourceColName: DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0]
2527
- };
2528
- }
2529
- /**
2530
- * Handle junction table creation for many-to-many path-based saves
2531
- */
2532
- async handleJunctionTableCreation(tx, newEntityId, junctionTableInfo) {
2533
- const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
2534
- const targetCollection = relation.target();
2535
- try {
2536
- const junctionTable = this.registry.getTable(relation.through.table);
2537
- if (!junctionTable) {
2538
- logger.warn(`Junction table '${relation.through.table}' not found for relation '${relationKey}'`);
2539
- return;
2540
- }
2541
- const sourceJunctionColumn = junctionTable[relation.through.sourceColumn];
2542
- const targetJunctionColumn = junctionTable[relation.through.targetColumn];
2543
- if (!sourceJunctionColumn || !targetJunctionColumn) {
2544
- logger.warn(`Junction columns not found for relation '${relationKey}'`);
2545
- return;
2546
- }
2547
- const targetPks = requirePrimaryKeys(targetCollection, this.registry);
2548
- const targetIdInfo = targetPks[0];
2549
- const parsedNewEntityId = parseIdValues(newEntityId, targetPks)[targetIdInfo.fieldName];
2550
- const junctionData = {
2551
- [sourceJunctionColumn.name]: parentId,
2552
- [targetJunctionColumn.name]: parsedNewEntityId
2553
- };
2554
- await tx.insert(junctionTable).values(junctionData).onConflictDoNothing();
2555
- logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
2556
- } catch (error) {
2557
- logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
2558
- throw error;
2559
- }
2560
- }
2561
2719
  };
2562
2720
  //#endregion
2563
2721
  //#region src/services/row-pipeline.ts
@@ -3005,8 +3163,7 @@ function sanitizeErrorForClient(error, context) {
3005
3163
  column: pgError.column,
3006
3164
  table: pgError.table,
3007
3165
  constraint: pgError.constraint,
3008
- dataType: pgError.dataType,
3009
- drizzleMessage: error instanceof Error ? error.message : String(error)
3166
+ dataType: pgError.dataType
3010
3167
  });
3011
3168
  return pgErrorToFriendlyMessage(pgError, context);
3012
3169
  }
@@ -3025,7 +3182,7 @@ function sanitizeErrorForClient(error, context) {
3025
3182
  * Service for handling all row read operations.
3026
3183
  * Handles fetching, searching, counting, and filtering rows.
3027
3184
  */
3028
- var FetchService = class {
3185
+ var FetchService = class FetchService {
3029
3186
  db;
3030
3187
  registry;
3031
3188
  relationService;
@@ -3098,16 +3255,39 @@ var FetchService = class {
3098
3255
  * and skips rows rather than erroring. The guesses stay, last, for a
3099
3256
  * caller that hands over no collection to resolve against.
3100
3257
  */
3258
+ /**
3259
+ * The ORDER BY target, which may be relevance rather than a column.
3260
+ *
3261
+ * `_score` is only meaningful for a collection that declared a `search`
3262
+ * block *and* for a request that carried a search string — ranking rows
3263
+ * against no query ranks them all at zero. Outside those two conditions it
3264
+ * is an unknown field and gets the same 400 as any other typo, which is the
3265
+ * behaviour that matters: a sort that is silently dropped returns 200 with
3266
+ * rows in arbitrary order, and paging over that repeats and skips rows.
3267
+ */
3268
+ static SCORE_FIELD = "_score";
3269
+ resolveOrderTarget(table, orderBy, collection, searchString) {
3270
+ if (orderBy === FetchService.SCORE_FIELD && collection && searchString) {
3271
+ const rank = DrizzleConditionBuilder.buildSearchRankExpression(searchString, table, collection);
3272
+ if (rank) return rank;
3273
+ }
3274
+ return this.resolveOrderByField(table, orderBy, collection);
3275
+ }
3101
3276
  resolveOrderByField(table, orderBy, collection) {
3102
3277
  const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
3103
3278
  const direct = columnAt(orderBy);
3104
3279
  if (direct) return direct;
3105
3280
  const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : void 0;
3106
3281
  if (declaredRelation?.kind === "belongsTo") {
3107
- const foreignKey = columnAt(declaredRelation.localKey);
3282
+ const foreignKey = columnAt(fieldKeyForColumn(collection, declaredRelation.localKey));
3108
3283
  if (foreignKey) return foreignKey;
3109
3284
  }
3110
- for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
3285
+ for (const guess of [
3286
+ `${orderBy}Id`,
3287
+ toWireKey(generateForeignKeyName(orderBy)),
3288
+ `${orderBy}_id`,
3289
+ generateForeignKeyName(orderBy)
3290
+ ]) {
3111
3291
  const foreignKey = columnAt(guess);
3112
3292
  if (foreignKey) return foreignKey;
3113
3293
  }
@@ -3181,6 +3361,7 @@ var FetchService = class {
3181
3361
  row[key] = createRelationRefWithData(e.id, e.path, e);
3182
3362
  } else if (relation.cardinality === "many") row[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
3183
3363
  } catch (e) {
3364
+ if (reachedDatabase(e)) throw e;
3184
3365
  logger.warn(`Could not resolve joinPath relation '${key}'`, { error: e });
3185
3366
  }
3186
3367
  });
@@ -3216,6 +3397,7 @@ var FetchService = class {
3216
3397
  for (const row of addressable) row[key] = (resultMap.get(String(parentIdOf(row))) || []).map((e) => ({ ...e.values }));
3217
3398
  }
3218
3399
  } catch (e) {
3400
+ if (reachedDatabase(e)) throw e;
3219
3401
  logger.warn(`Could not batch resolve joinPath relation '${key}' for REST`, { error: e });
3220
3402
  }
3221
3403
  }
@@ -3225,12 +3407,14 @@ var FetchService = class {
3225
3407
  */
3226
3408
  buildDrizzleQueryOptions(table, idField, idInfo, options, collectionPath, withConfig, scopeCondition) {
3227
3409
  const queryOpts = {};
3410
+ const hidden = hiddenColumnsOption(getTableColumns(table), this.registry.getCollectionByPath(collectionPath) ?? void 0);
3411
+ if (hidden) queryOpts.columns = hidden;
3228
3412
  if (withConfig) queryOpts.with = withConfig;
3229
3413
  const allConditions = [];
3230
3414
  if (scopeCondition) allConditions.push(scopeCondition);
3231
3415
  if (options.searchString) {
3232
3416
  const collection = getCollectionByPath(collectionPath, this.registry);
3233
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3417
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3234
3418
  if (searchConditions.length === 0) {
3235
3419
  queryOpts.where = and(eq(idField, -99999999));
3236
3420
  return queryOpts;
@@ -3253,7 +3437,7 @@ var FetchService = class {
3253
3437
  const orderExpressions = [];
3254
3438
  if (options.orderBy) {
3255
3439
  const collection = getCollectionByPath(collectionPath, this.registry);
3256
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3440
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3257
3441
  if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3258
3442
  }
3259
3443
  orderExpressions.push(desc(idField));
@@ -3270,6 +3454,7 @@ var FetchService = class {
3270
3454
  if (!options.startAfter) return [];
3271
3455
  const cursor = options.startAfter;
3272
3456
  if (options.orderBy) {
3457
+ 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 });
3273
3458
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
3274
3459
  const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3275
3460
  if (orderByField) {
@@ -3340,9 +3525,11 @@ var FetchService = class {
3340
3525
  const qb = this.getQueryBuilder(tableName);
3341
3526
  if (qb) try {
3342
3527
  const withConfig = this.buildWithConfig(collection);
3528
+ const hidden = hiddenColumnsOption(getTableColumns(table), collection);
3343
3529
  const row = await qb.findFirst({
3344
3530
  where: eq(idField, parsedId),
3345
- with: withConfig
3531
+ with: withConfig,
3532
+ ...hidden ? { columns: hidden } : {}
3346
3533
  });
3347
3534
  if (!row) return void 0;
3348
3535
  const flatRow = toFlatRow(row, collection, this.registry);
@@ -3356,7 +3543,8 @@ var FetchService = class {
3356
3543
  if (reachedDatabase(e)) throw e;
3357
3544
  logger.warn(`[FetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select`, { error: e });
3358
3545
  }
3359
- const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
3546
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
3547
+ const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
3360
3548
  if (result.length === 0) return void 0;
3361
3549
  const raw = result[0];
3362
3550
  const values = await parseDataFromServer(raw, collection, this.db, this.registry);
@@ -3374,6 +3562,7 @@ var FetchService = class {
3374
3562
  values[key] = createRelationRef(e.id, e.path);
3375
3563
  }
3376
3564
  } catch (e) {
3565
+ if (reachedDatabase(e)) throw e;
3377
3566
  logger.warn(`Could not resolve one-to-one relation property: ${key}`, { error: e });
3378
3567
  }
3379
3568
  }
@@ -3412,14 +3601,21 @@ var FetchService = class {
3412
3601
  }
3413
3602
  let vectorMeta;
3414
3603
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
3604
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
3605
+ const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
3606
+ const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
3415
3607
  let query = vectorMeta ? this.db.select({
3416
- table_row: table,
3608
+ table_row: visible ?? table,
3417
3609
  _distance: vectorMeta.distanceSelect
3418
- }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3610
+ }).from(table).$dynamic() : rankSelect ? this.db.select({
3611
+ table_row: visible ?? table,
3612
+ _score: rankSelect,
3613
+ ...matchesSelect ? { _matches: matchesSelect } : {}
3614
+ }).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3419
3615
  const allConditions = [];
3420
3616
  if (scopeCondition) allConditions.push(scopeCondition);
3421
3617
  if (options.searchString) {
3422
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3618
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3423
3619
  if (searchConditions.length === 0) return [];
3424
3620
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3425
3621
  }
@@ -3439,7 +3635,7 @@ var FetchService = class {
3439
3635
  const orderExpressions = [];
3440
3636
  if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3441
3637
  else if (options.orderBy) {
3442
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3638
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3443
3639
  if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3444
3640
  }
3445
3641
  orderExpressions.push(desc(idField));
@@ -3459,6 +3655,10 @@ var FetchService = class {
3459
3655
  const results = vectorMeta ? rawResults.map((r) => ({
3460
3656
  ...r.table_row,
3461
3657
  _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
3658
+ })) : rankSelect ? rawResults.map((r) => ({
3659
+ ...r.table_row,
3660
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
3661
+ ...matchesSelect ? { _matches: r._matches ?? [] } : {}
3462
3662
  })) : rawResults;
3463
3663
  return this.processRowResults(results, collection, collectionPath, idInfo, options.databaseId, false, idInfoArray);
3464
3664
  }
@@ -3500,6 +3700,7 @@ var FetchService = class {
3500
3700
  if (relatedRow) item.values[key] = createRelationRefWithData(relatedRow.id, relatedRow.path, relatedRow);
3501
3701
  });
3502
3702
  } catch (e) {
3703
+ if (reachedDatabase(e)) throw e;
3503
3704
  logger.warn(`Could not batch load one-to-one relation property: ${key}`, { error: e });
3504
3705
  }
3505
3706
  }
@@ -3513,6 +3714,7 @@ var FetchService = class {
3513
3714
  item.values[key] = relatedRows.map((e) => createRelationRefWithData(e.id, e.path, e));
3514
3715
  });
3515
3716
  } catch (e) {
3717
+ if (reachedDatabase(e)) throw e;
3516
3718
  logger.warn(`Could not batch load many relation property: ${key}`, { error: e });
3517
3719
  }
3518
3720
  }
@@ -3550,7 +3752,7 @@ var FetchService = class {
3550
3752
  const allConditions = [];
3551
3753
  if (hop) allConditions.push(this.buildRelationScope(hop));
3552
3754
  if (options.searchString) {
3553
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3755
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3554
3756
  if (searchConditions.length === 0) return 0;
3555
3757
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3556
3758
  }
@@ -3646,6 +3848,7 @@ var FetchService = class {
3646
3848
  if (related) row[key] = { ...related.values };
3647
3849
  }
3648
3850
  } catch (e) {
3851
+ if (reachedDatabase(e)) throw e;
3649
3852
  logger.warn(`[include] Failed to batch load one-to-one '${key}'`, { error: e });
3650
3853
  }
3651
3854
  }
@@ -3658,6 +3861,7 @@ var FetchService = class {
3658
3861
  row[key] = batchResults.get(String(eid)) || [];
3659
3862
  }
3660
3863
  } catch (e) {
3864
+ if (reachedDatabase(e)) throw e;
3661
3865
  logger.warn(`[include] Failed to batch load many '${key}'`, { error: e });
3662
3866
  }
3663
3867
  }
@@ -3695,7 +3899,8 @@ var FetchService = class {
3695
3899
  if (reachedDatabase(e)) throw e;
3696
3900
  logger.warn(`[fetchOneForRest] db.query.findFirst failed for ${collectionPath}, falling back`, { error: e });
3697
3901
  }
3698
- const result = await this.db.select().from(table).where(eq(idField, parsedId)).limit(1);
3902
+ const visibleOne = visibleColumnProjection(getTableColumns(table), collection);
3903
+ const result = await this.db.select(visibleOne).from(table).where(eq(idField, parsedId)).limit(1);
3699
3904
  if (result.length === 0) return null;
3700
3905
  const flatEntity = { ...result[0] };
3701
3906
  if (!include || include.length === 0) return flatEntity;
@@ -3719,6 +3924,7 @@ var FetchService = class {
3719
3924
  ...e.values
3720
3925
  }));
3721
3926
  } catch (e) {
3927
+ if (reachedDatabase(e)) throw e;
3722
3928
  logger.warn(`[include] Failed to load relation '${key}'`, { error: e });
3723
3929
  }
3724
3930
  }
@@ -3733,14 +3939,21 @@ var FetchService = class {
3733
3939
  const idField = table[requirePrimaryKeys(collection, this.registry)[0].fieldName];
3734
3940
  let vectorMeta;
3735
3941
  if (options.vectorSearch) vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
3942
+ const visible = visibleColumnProjection(getTableColumns(table), collection);
3943
+ const rankSelect = options.searchString ? DrizzleConditionBuilder.buildSearchRankExpression(options.searchString, table, collection) : void 0;
3944
+ const matchesSelect = options.searchString && options.searchExplain ? DrizzleConditionBuilder.buildSearchMatchesExpression(options.searchString, table, collection) : void 0;
3736
3945
  let query = vectorMeta ? this.db.select({
3737
- table_row: table,
3946
+ table_row: visible ?? table,
3738
3947
  _distance: vectorMeta.distanceSelect
3739
- }).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3948
+ }).from(table).$dynamic() : rankSelect ? this.db.select({
3949
+ table_row: visible ?? table,
3950
+ _score: rankSelect,
3951
+ ...matchesSelect ? { _matches: matchesSelect } : {}
3952
+ }).from(table).$dynamic() : visible ? this.db.select(visible).from(table).$dynamic() : this.db.select().from(table).$dynamic();
3740
3953
  const allConditions = [];
3741
3954
  if (options.relatedTo) allConditions.push(this.buildRelationScope(options.relatedTo));
3742
3955
  if (options.searchString) {
3743
- const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table);
3956
+ const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
3744
3957
  if (searchConditions.length === 0) return [];
3745
3958
  allConditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
3746
3959
  }
@@ -3748,6 +3961,10 @@ var FetchService = class {
3748
3961
  const filterConditions = this.buildFilterConditions(options.filter, table, collectionPath);
3749
3962
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
3750
3963
  }
3964
+ if (options.logical) {
3965
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
3966
+ if (logicalCondition) allConditions.push(logicalCondition);
3967
+ }
3751
3968
  if (vectorMeta?.filter) allConditions.push(vectorMeta.filter);
3752
3969
  if (allConditions.length > 0) {
3753
3970
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
@@ -3756,7 +3973,7 @@ var FetchService = class {
3756
3973
  const orderExpressions = [];
3757
3974
  if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3758
3975
  else if (options.orderBy) {
3759
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3976
+ const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3760
3977
  if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3761
3978
  }
3762
3979
  orderExpressions.push(desc(idField));
@@ -3769,6 +3986,11 @@ var FetchService = class {
3769
3986
  ...r.table_row,
3770
3987
  _distance: typeof r._distance === "number" ? r._distance : parseFloat(String(r._distance))
3771
3988
  }));
3989
+ if (rankSelect) return rawResults.map((r) => ({
3990
+ ...r.table_row,
3991
+ _score: typeof r._score === "number" ? r._score : parseFloat(String(r._score)),
3992
+ ...matchesSelect ? { _matches: r._matches ?? [] } : {}
3993
+ }));
3772
3994
  return rawResults;
3773
3995
  }
3774
3996
  /**
@@ -3806,6 +4028,269 @@ var FetchService = class {
3806
4028
  }
3807
4029
  };
3808
4030
  //#endregion
4031
+ //#region src/services/RelationWriteService.ts
4032
+ /**
4033
+ * The ids in a to-many relation write, whatever shape the caller sent.
4034
+ *
4035
+ * A membership list is written as either the related rows (`[{ id: 1 }]`, what
4036
+ * the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
4037
+ * what anyone writing the API by hand sends). Only the first was read, via a
4038
+ * blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
4039
+ * on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
4040
+ * and on a string-keyed one it did not surface at all — `String(undefined)`
4041
+ * wrote a junction row pointing at the literal `"undefined"`, which no read
4042
+ * would ever match. Both shapes are accepted here, in one place, because both
4043
+ * call sites had the same assumption.
4044
+ *
4045
+ * An element that carries no key is refused rather than skipped: dropping it
4046
+ * would silently write a shorter membership list than the caller asked for.
4047
+ */
4048
+ function relationTargetIds(value, relationName, collectionSlug) {
4049
+ if (!Array.isArray(value)) return [];
4050
+ return value.map((element, index) => {
4051
+ if (typeof element === "string" || typeof element === "number") return element;
4052
+ if (element && typeof element === "object") {
4053
+ const id = element.id;
4054
+ if (typeof id === "string" || typeof id === "number") return id;
4055
+ }
4056
+ throw new Error(`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. Pass either the related rows (\`[{ id: … }]\`) or their keys (\`[1, 2]\`), not ${element === null ? "null" : typeof element}.`);
4057
+ });
4058
+ }
4059
+ /**
4060
+ * Writing relations: junction membership, foreign-key stamping, and the links a
4061
+ * nested path creates or removes.
4062
+ *
4063
+ * Split from {@link RelationService}, which now only reads. The two had grown
4064
+ * into one 1700-line class doing two unrelated jobs, and sharing one habit —
4065
+ * warning about a relation it could not resolve and carrying on, which surfaces
4066
+ * as an empty list on the read side and as a successful save on the write side.
4067
+ * Separating them is what made that visible as one class of defect rather than
4068
+ * eighteen scattered log lines.
4069
+ *
4070
+ * The reads it still needs — the source key a link joins on — it asks
4071
+ * {@link RelationService} for rather than reimplementing.
4072
+ */
4073
+ var RelationWriteService = class {
4074
+ db;
4075
+ registry;
4076
+ reads;
4077
+ constructor(db, registry) {
4078
+ this.db = db;
4079
+ this.registry = registry;
4080
+ this.reads = new RelationService(db, registry);
4081
+ }
4082
+ /**
4083
+ * Remove the junction row linking a parent to `targetId`, leaving the target
4084
+ * row itself alone.
4085
+ *
4086
+ * This is what `DELETE authors/1/tags/5` has to mean for a many-to-many: the
4087
+ * target is shared, so deleting the row would remove the tag from every other
4088
+ * post that uses it. It used to do exactly that — resolve the path to the
4089
+ * `tags` table and delete by primary key.
4090
+ */
4091
+ async unlinkRelatedEntity(tx, hop, targetId) {
4092
+ if (!isManyToMany(hop.relation)) throw new Error(`Relation '${hop.relationKey}' has no junction table to unlink through`);
4093
+ await removeJunctionLink(tx, bindThroughJunction(this.registry, hop.relation.through, `${hop.parentCollection.slug}.${hop.relationKey}`), this.parsedId(hop.parentCollection, hop.parentId), this.parsedId(hop.targetCollection, targetId), {
4094
+ parent: hop.parentCollection.slug,
4095
+ relation: hop.relationKey
4096
+ });
4097
+ }
4098
+ /** A collection's id, parsed to the type its primary key column holds. */
4099
+ parsedId(collection, id) {
4100
+ const pks = requirePrimaryKeys(collection, this.registry);
4101
+ return parseIdValues(id, pks)[pks[0].fieldName];
4102
+ }
4103
+ /** The same, for the membership list a to-many write names. */
4104
+ parsedIds(collection, ids) {
4105
+ if (ids.length === 0) return [];
4106
+ const pks = requirePrimaryKeys(collection, this.registry);
4107
+ return ids.map((id) => parseIdValues(id, pks)[pks[0].fieldName]);
4108
+ }
4109
+ /**
4110
+ * Update many-to-many and junction relations
4111
+ */
4112
+ async updateRelationsUsingJoins(tx, collection, id, relationValues) {
4113
+ const resolvedRelations = resolveCollectionRelations(collection);
4114
+ for (const [key, value] of Object.entries(relationValues)) {
4115
+ const relation = findRelation(resolvedRelations, key);
4116
+ if (!relation || relation.cardinality !== "many") continue;
4117
+ const targetEntityIds = relationTargetIds(value, key, collection.slug);
4118
+ const targetCollection = relation.target();
4119
+ const label = `${collection.slug}.${key}`;
4120
+ if (relation.kind === "via") await applyJunctionMembership(tx, bindJoinPathJunction(this.registry, relation.joinPath, getTableName$1(collection), getTableName$1(targetCollection), label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
4121
+ else if (relation.kind === "manyToMany") await applyJunctionMembership(tx, bindThroughJunction(this.registry, relation.through, label), this.parsedId(collection, id), this.parsedIds(targetCollection, targetEntityIds));
4122
+ else if (relation.cardinality === "many" && hasForeignKeyOnTarget(relation)) {
4123
+ const targetTable = getTableForCollection(targetCollection, this.registry);
4124
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
4125
+ const targetIdInfo = targetPks[0];
4126
+ const targetIdCol = targetTable[targetIdInfo.fieldName];
4127
+ const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
4128
+ const fkCol = targetTable[fkField];
4129
+ if (!fkCol || !targetIdCol) throw relationMisconfigured(label, `the target table '${getTableName$1(targetCollection)}' has no ${fkCol ? `'${targetIdInfo.fieldName}' key column` : `'${relation.foreignKeyOnTarget}' foreign-key column`}`);
4130
+ const parentKeyValue = (await this.reads.resolveSourceKeys(collection, relation, [id], tx)).keyByParentId.get(String(id));
4131
+ if (parentKeyValue === void 0) throw new Error(`Cannot write relation '${key}' on '${collection.slug}': row '${id}' has no value in \`sourceKey: "${sourceKeyField(relation, collection, this.registry)}"\`, so there is nothing for the related rows to point at.`);
4132
+ if (targetEntityIds.length > 0) {
4133
+ const parsedTargetIds = targetEntityIds.map((id) => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
4134
+ await tx.update(targetTable).set({ [fkField]: null }).where(and(eq(fkCol, parentKeyValue), notInArray(targetIdCol, parsedTargetIds)));
4135
+ await tx.update(targetTable).set({ [fkField]: parentKeyValue }).where(inArray(targetIdCol, parsedTargetIds));
4136
+ } else await tx.update(targetTable).set({ [fkField]: null }).where(eq(fkCol, parentKeyValue));
4137
+ } else throw relationMisconfigured(label, "it is a to-many relation with no way to write links — a `manyToMany` needs `through`, a `hasMany` needs `foreignKeyOnTarget`");
4138
+ }
4139
+ }
4140
+ /**
4141
+ * Update inverse relations (where FK is on the target table)
4142
+ */
4143
+ async updateInverseRelations(tx, sourceCollection, sourceEntityId, inverseRelationUpdates) {
4144
+ for (const update of inverseRelationUpdates) {
4145
+ const { relation, newValue } = update;
4146
+ try {
4147
+ const targetCollection = relation.target();
4148
+ const targetTable = getTableForCollection(targetCollection, this.registry);
4149
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
4150
+ const targetIdInfo = targetPks[0];
4151
+ requirePrimaryKeys(sourceCollection, this.registry)[0];
4152
+ if (relation.kind === "via") {
4153
+ await this.updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue);
4154
+ continue;
4155
+ }
4156
+ if (isManyToMany(relation)) {
4157
+ await this.updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, {
4158
+ table: relation.through.table,
4159
+ sourceColumn: relation.through.sourceColumn,
4160
+ targetColumn: relation.through.targetColumn
4161
+ });
4162
+ continue;
4163
+ }
4164
+ const label = `${sourceCollection.slug}.${relation.relationName}`;
4165
+ if (!hasForeignKeyOnTarget(relation)) throw relationMisconfigured(label, `a '${relation.kind}' relation names no column on the target to write the link into`);
4166
+ const fkField = fieldKeyForColumn(targetCollection, relation.foreignKeyOnTarget);
4167
+ const foreignKeyColumn = targetTable[fkField];
4168
+ if (!foreignKeyColumn) throw relationMisconfigured(label, `'${relation.foreignKeyOnTarget}' is not a column on the target table '${getTableName$1(targetCollection)}'`);
4169
+ const sourceKeyValue = (await this.reads.resolveSourceKeys(sourceCollection, relation, [sourceEntityId], tx)).keyByParentId.get(String(sourceEntityId));
4170
+ if (sourceKeyValue === void 0) throw new Error(`Cannot write relation '${relation.relationName}' on '${sourceCollection.slug}': row '${sourceEntityId}' has no value in \`sourceKey: "${sourceKeyField(relation, sourceCollection, this.registry)}"\`, so there is nothing for the related row to point at.`);
4171
+ if (newValue === null || newValue === void 0) await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
4172
+ else {
4173
+ const parsedNewTargetId = parseIdValues(newValue, targetPks)[targetIdInfo.fieldName];
4174
+ const targetIdField = targetTable[targetIdInfo.fieldName];
4175
+ await tx.update(targetTable).set({ [fkField]: null }).where(eq(foreignKeyColumn, sourceKeyValue));
4176
+ await tx.update(targetTable).set({ [fkField]: sourceKeyValue }).where(eq(targetIdField, parsedNewTargetId));
4177
+ }
4178
+ } catch (e) {
4179
+ if (e instanceof ApiError || e?.name === "ApiError") throw e;
4180
+ logger.warn(`Failed to update inverse relation '${relation.relationName}'`, { error: e });
4181
+ }
4182
+ }
4183
+ }
4184
+ /**
4185
+ * Handle inverse relations with joinPath
4186
+ */
4187
+ async updateInverseJoinPathRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue) {
4188
+ const sourceTableName = getTableName$1(sourceCollection);
4189
+ const targetTableName = getTableName$1(targetCollection);
4190
+ if (relation.joinPath.map((step) => step.table).filter((table) => table !== sourceTableName && table !== targetTableName).length !== 1 || relation.cardinality !== "many") return;
4191
+ try {
4192
+ const binding = bindJoinPathJunction(this.registry, relation.joinPath, sourceTableName, targetTableName, `${sourceCollection.slug}.${relation.relationName}`);
4193
+ const targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : newValue === null || newValue === void 0 ? [] : relationTargetIds([newValue], relation.relationName, sourceCollection.slug);
4194
+ await applyJunctionMembership(tx, binding, this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
4195
+ } catch (error) {
4196
+ logger.error(`Failed to update inverse joinPath relation '${relation.relationName}'`, { error });
4197
+ throw error;
4198
+ }
4199
+ }
4200
+ /**
4201
+ * Handle many-to-many inverse relation updates using junction tables
4202
+ */
4203
+ async updateManyToManyInverseRelation(tx, sourceCollection, sourceEntityId, targetCollection, relation, newValue, junctionInfo) {
4204
+ try {
4205
+ const targetIds = Array.isArray(newValue) ? relationTargetIds(newValue, relation.relationName, sourceCollection.slug) : [];
4206
+ await applyJunctionMembership(tx, bindThroughJunction(this.registry, junctionInfo, `${sourceCollection.slug}.${relation.relationName}`), this.parsedId(sourceCollection, sourceEntityId), this.parsedIds(targetCollection, targetIds));
4207
+ } catch (error) {
4208
+ logger.error(`Failed to update many-to-many inverse relation '${relation.relationName}'`, { error });
4209
+ throw error;
4210
+ }
4211
+ }
4212
+ /**
4213
+ * Update one-to-one relations that use joinPath
4214
+ */
4215
+ async updateJoinPathOneToOneRelations(tx, parentCollection, parentId, updates) {
4216
+ for (const upd of updates) {
4217
+ const { relation, newTargetId } = upd;
4218
+ const targetCollection = relation.target();
4219
+ const targetTable = getTableForCollection(targetCollection, this.registry);
4220
+ const targetPks = requirePrimaryKeys(targetCollection, this.registry);
4221
+ const targetIdInfo = targetPks[0];
4222
+ const targetIdCol = targetTable[targetIdInfo.fieldName];
4223
+ const { targetFKColName: targetFKColumn, parentSourceColName: parentSourceColumn } = this.resolveJoinPathWriteMapping(parentCollection, relation);
4224
+ const targetFKColName = fieldKeyForColumn(targetCollection, targetFKColumn);
4225
+ const parentSourceColName = fieldKeyForColumn(parentCollection, parentSourceColumn);
4226
+ const parentTable = getTableForCollection(parentCollection, this.registry);
4227
+ const parentPks = requirePrimaryKeys(parentCollection, this.registry);
4228
+ const parentIdInfo = parentPks[0];
4229
+ const parsedParentId = parseIdValues(parentId, parentPks)[parentIdInfo.fieldName];
4230
+ const parentIdCol = parentTable[parentIdInfo.fieldName];
4231
+ const parentSourceCol = parentTable[parentSourceColName];
4232
+ const targetFKCol = targetTable[targetFKColName];
4233
+ const label = `${parentCollection.slug}.${relation.relationName}`;
4234
+ if (!parentSourceCol) throw relationMisconfigured(label, `its joinPath reads '${parentSourceColName}', which is not a column on '${getTableName$1(parentCollection)}'`);
4235
+ if (!targetFKCol) throw relationMisconfigured(label, `its joinPath writes '${targetFKColName}', which is not a column on '${getTableName$1(targetCollection)}'`);
4236
+ const parentRows = await tx.select({ val: parentSourceCol }).from(parentTable).where(eq(parentIdCol, parsedParentId)).limit(1);
4237
+ if (parentRows.length === 0) continue;
4238
+ const parentFKValue = parentRows[0].val;
4239
+ if (newTargetId === null || newTargetId === void 0) {
4240
+ if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
4241
+ continue;
4242
+ }
4243
+ const parsedTargetId = parseIdValues(newTargetId, targetPks)[targetIdInfo.fieldName];
4244
+ if (parentFKValue !== null && parentFKValue !== void 0) await tx.update(targetTable).set({ [targetFKColName]: null }).where(eq(targetFKCol, String(parentFKValue)));
4245
+ else throw ApiError.badRequest(`Cannot write relation '${label}': row '${parentId}' has no value in '${parentSourceColName}', which is the column its joinPath joins on, so there is nothing for the related row to point at.`, "RELATION_SOURCE_KEY_EMPTY");
4246
+ await tx.update(targetTable).set({ [targetFKColName]: parentFKValue }).where(eq(targetIdCol, parsedTargetId));
4247
+ }
4248
+ }
4249
+ /**
4250
+ * Resolve joinPath write mapping for one-to-one relations
4251
+ */
4252
+ resolveJoinPathWriteMapping(parentCollection, relation) {
4253
+ if (!relation.joinPath || relation.joinPath.length === 0) throw new Error("resolveJoinPathWriteMapping requires a joinPath relation");
4254
+ const parentTableName = getTableName$1(parentCollection);
4255
+ const lastStep = relation.joinPath[relation.joinPath.length - 1];
4256
+ const targetFKColName = DrizzleConditionBuilder.getColumnNamesFromColumns(lastStep.on.to)[0];
4257
+ let currentFrom = lastStep.on.from;
4258
+ let safety = 0;
4259
+ while (safety++ < 10) {
4260
+ if (DrizzleConditionBuilder.getTableNamesFromColumns(currentFrom)[0] === parentTableName) break;
4261
+ const prevStep = relation.joinPath.find((s) => {
4262
+ return (Array.isArray(s.on.to) ? s.on.to[0] : s.on.to) === currentFrom;
4263
+ });
4264
+ if (!prevStep) throw new Error(`Could not resolve parent source column for joinPath relation '${relation.relationName}'`);
4265
+ currentFrom = prevStep.on.from;
4266
+ }
4267
+ return {
4268
+ targetFKColName,
4269
+ parentSourceColName: DrizzleConditionBuilder.getColumnNamesFromColumns(currentFrom)[0]
4270
+ };
4271
+ }
4272
+ /**
4273
+ * Handle junction table creation for many-to-many path-based saves
4274
+ */
4275
+ async handleJunctionTableCreation(tx, newEntityId, junctionTableInfo) {
4276
+ const { parentCollection, parentId, relation, relationKey } = junctionTableInfo;
4277
+ const targetCollection = relation.target();
4278
+ try {
4279
+ const binding = bindThroughJunction(this.registry, relation.through, `${parentCollection.slug}.${relationKey}`);
4280
+ const parsedNewEntityId = this.parsedId(targetCollection, newEntityId);
4281
+ const junctionData = {
4282
+ [binding.parentColumn.name]: parentId,
4283
+ [binding.targetColumn.name]: parsedNewEntityId
4284
+ };
4285
+ await tx.insert(binding.table).values(junctionData).onConflictDoNothing();
4286
+ logger.info(`Linked '${relationKey}' ${parsedNewEntityId} to ${parentId}`);
4287
+ } catch (error) {
4288
+ logger.error(`Failed to create junction table entry for relation '${relationKey}'`, { error });
4289
+ throw error;
4290
+ }
4291
+ }
4292
+ };
4293
+ //#endregion
3809
4294
  //#region src/services/PersistService.ts
3810
4295
  /**
3811
4296
  * Service for handling all row write operations.
@@ -3814,34 +4299,24 @@ var FetchService = class {
3814
4299
  var PersistService = class {
3815
4300
  db;
3816
4301
  registry;
4302
+ /** Reads: whether a row is under a parent, the key a link joins on. */
3817
4303
  relationService;
4304
+ /** Writes: junction membership, foreign-key stamping, links. */
4305
+ relationWrites;
3818
4306
  fetchService;
3819
4307
  constructor(db, registry) {
3820
4308
  this.db = db;
3821
4309
  this.registry = registry;
3822
4310
  this.relationService = new RelationService(db, registry);
4311
+ this.relationWrites = new RelationWriteService(db, registry);
3823
4312
  this.fetchService = new FetchService(db, registry);
3824
4313
  }
3825
4314
  /**
3826
- * Explain a write that matched no rows.
3827
- *
3828
- * Row-level security filters UPDATE and DELETE through the policy's USING
3829
- * clause instead of raising: a denied write is reported by Postgres exactly
3830
- * like a successful one that happened to match nothing. Left unchecked, a
3831
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
3832
- * the row is untouched.
3833
- *
3834
- * Re-reading the target over the *same* RLS-scoped handle separates the two
3835
- * cases. A visible row means the policy rejected the write (403); an
3836
- * invisible one means there is nothing there to write for this caller (404,
3837
- * matching what a GET would say). The re-read is bound by the caller's own
3838
- * policies, so it discloses nothing a plain read wouldn't.
3839
- *
3840
- * Only reached when zero rows matched, so the happy path pays nothing.
4315
+ * Explain a row write that matched nothing — see {@link explainZeroRowWrite}
4316
+ * for why a zero-row write cannot be reported as success.
3841
4317
  */
3842
- async explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
3843
- if ((await handle.select({ present: sql`1` }).from(table).where(and(...conditions)).limit(1)).length > 0) return ApiError.forbidden(`Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, "WRITE_DENIED");
3844
- return ApiError.notFound(`No row "${id}" in "${collectionPath}" to ${operation}.`);
4318
+ explainZeroRowWrite(handle, table, conditions, collectionPath, id, operation) {
4319
+ return explainZeroRowWrite(handle, table, conditions, `Not allowed to ${operation} "${id}" in "${collectionPath}": a row-level security policy rejected the write.`, `No row "${id}" in "${collectionPath}" to ${operation}.`);
3845
4320
  }
3846
4321
  /**
3847
4322
  * Delete an row by ID
@@ -3853,7 +4328,7 @@ var PersistService = class {
3853
4328
  if (!await this.relationService.isRelated(hop, id)) throw ApiError.notFound(`No row "${id}" in "${collectionPath}" to delete.`);
3854
4329
  if (isJunctionBackedRelation(hop.relation)) {
3855
4330
  if (!isManyToMany(hop.relation)) throw ApiError.badRequest(`"${collectionPath}" reaches '${hop.targetCollection.slug}' through a multi-hop joinPath, so there is no single link to remove. Delete the row at "${hop.targetCollection.slug}" directly if that is what you meant.`, "RELATION_NOT_UNLINKABLE");
3856
- await this.relationService.unlinkRelatedEntity(this.db, hop, id);
4331
+ await this.relationWrites.unlinkRelatedEntity(this.db, hop, id);
3857
4332
  return;
3858
4333
  }
3859
4334
  }
@@ -3877,8 +4352,14 @@ var PersistService = class {
3877
4352
  await this.db.delete(table);
3878
4353
  }
3879
4354
  /**
3880
- * The column on the *target* table that records the parent, for a create
3881
- * under a nested one-to-many path.
4355
+ * The field on the *target* row that records the parent, for a create under
4356
+ * a nested one-to-many path.
4357
+ *
4358
+ * A **field**, not the column: the value is stamped into the caller's
4359
+ * payload, which is keyed by wire names — `authorId`, never the `author_id`
4360
+ * the relation names its link by. Stamping the column instead put a key on
4361
+ * the payload that no property answers to, and `strictWrites` rejected the
4362
+ * request the framework had just written to.
3882
4363
  *
3883
4364
  * Returns `undefined` when the link is not a column at all (a multi-hop
3884
4365
  * `joinPath`), so the caller writes the row without stamping anything.
@@ -3891,8 +4372,8 @@ var PersistService = class {
3891
4372
  const { relation } = hop;
3892
4373
  switch (relation.kind) {
3893
4374
  case "hasOne":
3894
- case "hasMany": return relation.foreignKeyOnTarget;
3895
- case "via": return relation.joinPath.length === 1 ? DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0] : void 0;
4375
+ case "hasMany": return fieldKeyForColumn(hop.targetCollection, relation.foreignKeyOnTarget);
4376
+ case "via": return relation.joinPath.length === 1 ? fieldKeyForColumn(hop.targetCollection, DrizzleConditionBuilder.getColumnNamesFromColumns(relation.joinPath[0].on.to)[0]) : void 0;
3896
4377
  default: return;
3897
4378
  }
3898
4379
  }
@@ -3964,12 +4445,13 @@ var PersistService = class {
3964
4445
  const inverseRelationUpdates = serializedResult.inverseRelationUpdates;
3965
4446
  const joinPathRelationUpdates = serializedResult.joinPathRelationUpdates;
3966
4447
  const entityData = sanitizeAndConvertDates(serializedResult.scalarData);
4448
+ assertWritableColumns(entityData, table, effectiveCollectionPath);
3967
4449
  savedId = await this.db.transaction(async (tx) => {
3968
4450
  let currentId;
3969
4451
  if (id && !options?.upsert) {
3970
4452
  currentId = id;
3971
4453
  const idValues = parseIdValues(id, idInfoArray);
3972
- if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
4454
+ if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
3973
4455
  if (Object.keys(entityData).length > 0) {
3974
4456
  const updateQuery = tx.update(table).set(entityData);
3975
4457
  const conditions = [];
@@ -3990,6 +4472,7 @@ var PersistService = class {
3990
4472
  const target = idInfoArray.map((info) => table[info.fieldName]);
3991
4473
  const set = { ...dataForInsert };
3992
4474
  for (const info of idInfoArray) delete set[info.fieldName];
4475
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) if (prop.type === "date" && prop.autoValue === "on_create") delete set[propName];
3993
4476
  result = Object.keys(set).length > 0 ? await insertQuery.onConflictDoUpdate({
3994
4477
  target,
3995
4478
  set
@@ -3999,11 +4482,11 @@ var PersistService = class {
3999
4482
  if (!resultRow) if (id) currentId = id;
4000
4483
  else throw ApiError.forbidden(`Not allowed to write to "${effectiveCollectionPath}": the row was rejected by a row-level security policy.`, "WRITE_DENIED");
4001
4484
  else currentId = buildCompositeId(resultRow, idInfoArray);
4002
- if (joinPathRelationUpdates.length > 0) await this.relationService.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
4485
+ if (joinPathRelationUpdates.length > 0) await this.relationWrites.updateJoinPathOneToOneRelations(tx, collection, currentId, joinPathRelationUpdates);
4003
4486
  }
4004
- if (inverseRelationUpdates.length > 0) await this.relationService.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
4005
- if (Object.keys(relationValues).length > 0) await this.relationService.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
4006
- if (junctionTableInfo) await this.relationService.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
4487
+ if (inverseRelationUpdates.length > 0) await this.relationWrites.updateInverseRelations(tx, collection, currentId, inverseRelationUpdates);
4488
+ if (Object.keys(relationValues).length > 0) await this.relationWrites.updateRelationsUsingJoins(tx, collection, currentId, relationValues);
4489
+ if (junctionTableInfo) await this.relationWrites.handleJunctionTableCreation(tx, currentId, junctionTableInfo);
4007
4490
  return currentId;
4008
4491
  });
4009
4492
  } catch (error) {
@@ -4020,6 +4503,15 @@ var PersistService = class {
4020
4503
  return this.relationService;
4021
4504
  }
4022
4505
  /**
4506
+ * The write half, for external use. Separate from
4507
+ * {@link getRelationService} because they are separate objects now: reads
4508
+ * answer questions, writes change rows, and the callers of one are not the
4509
+ * callers of the other.
4510
+ */
4511
+ getRelationWriteService() {
4512
+ return this.relationWrites;
4513
+ }
4514
+ /**
4023
4515
  * Get the FetchService instance for external use
4024
4516
  */
4025
4517
  getFetchService() {
@@ -4258,6 +4750,7 @@ var BranchService = class {
4258
4750
  metadata JSONB DEFAULT '{}'
4259
4751
  );
4260
4752
  `));
4753
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
4261
4754
  }
4262
4755
  /**
4263
4756
  * Create a new branch database by templating the source database.
@@ -4348,256 +4841,21 @@ var BranchService = class {
4348
4841
  WHERE b.name = ${name}
4349
4842
  `)).rows;
4350
4843
  if (rows.length === 0) return void 0;
4351
- const row = rows[0];
4352
- let sizeBytes;
4353
- try {
4354
- const dbName = row.db_name;
4355
- const sizeRows = (await this.db.execute(sql`SELECT pg_database_size(${dbName}) as size_bytes`)).rows;
4356
- if (sizeRows.length > 0 && sizeRows[0].size_bytes != null) sizeBytes = Number(sizeRows[0].size_bytes);
4357
- } catch {}
4358
- return {
4359
- name: row.name,
4360
- parentDatabase: row.parent_db,
4361
- createdAt: new Date(row.created_at),
4362
- sizeBytes
4363
- };
4364
- }
4365
- };
4366
- //#endregion
4367
- //#region src/security/rls-enforcement.ts
4368
- /**
4369
- * Unified RLS enforcement — the "user context vs server context" model.
4370
- *
4371
- * Every operation runs in one of two contexts:
4372
- *
4373
- * - **User context** — a request authenticated (or anonymous) via
4374
- * `driver.withAuth(user)`. Runs as the restricted `rebase_user` role: a
4375
- * non-owner, NOSUPERUSER, NOBYPASSRLS role, so Postgres RLS binds *every*
4376
- * statement (SELECT, INSERT, UPDATE, DELETE). The collection's
4377
- * `securityRules` are the whole authorization model; app-layer callbacks
4378
- * are validation/side-effects, not a security boundary.
4379
- *
4380
- * - **Server context** — the base (owner) connection: auth flows, migrations,
4381
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
4382
- * owner it bypasses RLS. This is the trusted plane, equivalent to
4383
- * Supabase's `service_role`.
4384
- *
4385
- * This module provides the three pieces:
4386
- *
4387
- * 1. {@link detectConnectionPosture} — is the connection subject to RLS at
4388
- * all? (superuser / BYPASSRLS / table owner ⇒ no)
4389
- * 2. {@link ensureAppRole} — idempotently provision `rebase_user` with
4390
- * SELECT/INSERT/UPDATE/DELETE grants (+ default privileges so future
4391
- * tables stay covered).
4392
- * 3. {@link applyAuthContext} — per-transaction: set the `app.*` GUCs the
4393
- * policies read (`auth.uid()` etc.) and `SET LOCAL ROLE rebase_user` so
4394
- * RLS binds. Transaction-scoped, so it composes with poolers.
4395
- *
4396
- * Provisioning runs from the framework's own bootstrap/migrate (which already
4397
- * self-creates the `auth` schema and functions) — enforcement is default-on,
4398
- * not an operator opt-in.
4399
- */
4400
- /** The restricted role every authenticated (user-context) request runs as. */
4401
- var REBASE_USER_ROLE = "rebase_user";
4402
- var quoteIdent$1 = (name) => `"${name.replace(/"/g, "\"\"")}"`;
4403
- /** DML the user role holds on managed tables (RLS still filters per row). */
4404
- var USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
4405
- async function detectConnectionPosture(run) {
4406
- const row = (await run(`
4407
- SELECT current_user AS role,
4408
- r.rolsuper AS superuser,
4409
- r.rolbypassrls AS bypassrls,
4410
- EXISTS (
4411
- SELECT 1 FROM pg_tables t
4412
- WHERE t.tableowner = current_user
4413
- AND t.schemaname NOT IN ('pg_catalog', 'information_schema')
4414
- ) AS owns_tables
4415
- FROM pg_roles r
4416
- WHERE r.rolname = current_user
4417
- `))[0] ?? {};
4418
- const superuser = row.superuser === true;
4419
- const bypassRLS = row.bypassrls === true;
4420
- const ownsTables = row.owns_tables === true;
4421
- return {
4422
- role: String(row.role ?? "unknown"),
4423
- superuser,
4424
- bypassRLS,
4425
- ownsTables,
4426
- privileged: superuser || bypassRLS || ownsTables
4427
- };
4428
- }
4429
- /**
4430
- * Human-actionable instructions for when the connection cannot provision the
4431
- * user role itself (no CREATEROLE and role not pre-created by the platform).
4432
- */
4433
- function appRoleSetupInstructions(connectionRole, schemas) {
4434
- const grants = schemas.map((s) => `GRANT USAGE ON SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};\nGRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};\nGRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent$1(s)} TO ${REBASE_USER_ROLE};`).join("\n");
4435
- return `Rebase enforces row-level security by running authenticated requests as the restricted role "${REBASE_USER_ROLE}", but the connection role "${connectionRole}" bypasses RLS and cannot create that role itself.\nRun the following as a database administrator, then restart:\n\nCREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;\nGRANT ${REBASE_USER_ROLE} TO ${quoteIdent$1(connectionRole)};\n` + grants;
4436
- }
4437
- /**
4438
- * Idempotently provision the `rebase_user` role, membership for the current
4439
- * connection role, and DML grants (+ default privileges for future tables)
4440
- * on every existing schema in `schemas`.
4441
- *
4442
- * Split into privilege tiers so it works both when the connection is a
4443
- * superuser (creates everything) and when the platform pre-created the role
4444
- * and membership (e.g. CNPG `postInitApplicationSQL`) and the connection is
4445
- * merely the table owner — owners can always run the grant tier themselves.
4446
- *
4447
- * RLS still filters every row: these grants only make the tables *reachable*
4448
- * by the role; the policies decide which rows/commands actually pass.
4449
- *
4450
- * Throws with precise setup instructions when the role is missing and the
4451
- * connection cannot create it.
4452
- */
4453
- async function ensureAppRole(run, schemas) {
4454
- const uniqueSchemas = Array.from(new Set(schemas.filter(Boolean)));
4455
- if ((await run(`SELECT 1 FROM pg_roles WHERE rolname = 'rebase_user'`)).length === 0) try {
4456
- await run(`CREATE ROLE ${REBASE_USER_ROLE} NOLOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT`);
4457
- } catch (err) {
4458
- throw new Error(`Failed to create the "${REBASE_USER_ROLE}" role: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions("current connection role", uniqueSchemas));
4844
+ const row = rows[0];
4845
+ let sizeBytes;
4846
+ try {
4847
+ const dbName = row.db_name;
4848
+ const sizeRows = (await this.db.execute(sql`SELECT pg_database_size(${dbName}) as size_bytes`)).rows;
4849
+ if (sizeRows.length > 0 && sizeRows[0].size_bytes != null) sizeBytes = Number(sizeRows[0].size_bytes);
4850
+ } catch {}
4851
+ return {
4852
+ name: row.name,
4853
+ parentDatabase: row.parent_db,
4854
+ createdAt: new Date(row.created_at),
4855
+ sizeBytes
4856
+ };
4459
4857
  }
4460
- const memberRows = await run(`
4461
- SELECT (pg_has_role(current_user, '${REBASE_USER_ROLE}', 'MEMBER')
4462
- OR (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)) AS can_set,
4463
- current_user AS role
4464
- `);
4465
- if (memberRows[0]?.can_set !== true) try {
4466
- await run(`GRANT ${REBASE_USER_ROLE} TO CURRENT_USER`);
4467
- } catch (err) {
4468
- throw new Error(`The connection role is not a member of "${REBASE_USER_ROLE}" and cannot grant itself membership: ${err instanceof Error ? err.message : String(err)}\n\n` + appRoleSetupInstructions(String(memberRows[0]?.role ?? "current connection role"), uniqueSchemas));
4469
- }
4470
- const nspRows = await run("SELECT nspname FROM pg_namespace");
4471
- const existing = new Set(nspRows.map((r) => String(r.nspname)));
4472
- for (const schema of uniqueSchemas) {
4473
- if (!existing.has(schema)) continue;
4474
- const s = quoteIdent$1(schema);
4475
- await run(`GRANT USAGE ON SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
4476
- await run(`GRANT ${USER_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
4477
- await run(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${s} TO ${REBASE_USER_ROLE}`);
4478
- await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
4479
- await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
4480
- }
4481
- logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
4482
- }
4483
- /**
4484
- * Apply the authenticated context to a transaction: the `app.*` GUCs that RLS
4485
- * policies read via `auth.uid()` / `auth.roles()` / `auth.jwt()`, and — when
4486
- * `userRole` is set — `SET LOCAL ROLE` so RLS binds every statement in this
4487
- * transaction (reads *and* writes).
4488
- *
4489
- * GUCs are set with `is_local = true` and the role switch is `LOCAL`: both
4490
- * reset at commit/rollback, so pooled connections are never polluted.
4491
- *
4492
- * Fails closed by construction: if the role switch errors, the transaction
4493
- * aborts instead of proceeding privileged.
4494
- *
4495
- * SECURITY: this function is only ever called on the **user** path (the server
4496
- * context uses the base/owner driver and never calls it). The default policies
4497
- * treat `auth.uid() IS NULL` as the trusted server context, and `auth.uid()`
4498
- * is `NULLIF(current_setting('app.uid'), '')` — so an EMPTY user id would
4499
- * be read as NULL and silently escalate a user request to server privileges.
4500
- * Coerce empty/blank ids to `ANONYMOUS_USER_ID` here, at the single chokepoint,
4501
- * rather than trusting every caller (e.g. realtime subscription auth) to do it.
4502
- * That sentinel is exported from `@rebasepro/types` because it leaks into rule
4503
- * semantics: it is why `auth.uid() IS NOT NULL` is true for anonymous requests.
4504
- */
4505
- async function applyAuthContext(tx, auth, userRole) {
4506
- const uid = typeof auth.uid === "string" && auth.uid.trim() !== "" ? auth.uid : ANONYMOUS_USER_ID;
4507
- const normalizedRoles = auth.roles.map((r) => typeof r === "string" ? r : r?.id ?? String(r));
4508
- await tx.execute(sql`
4509
- SELECT
4510
- set_config('app.uid', ${uid}, true),
4511
- set_config('app.user_id', ${uid}, true),
4512
- set_config('app.user_roles', ${normalizedRoles.join(",")}, true),
4513
- set_config('app.jwt', ${JSON.stringify({
4514
- sub: uid,
4515
- roles: auth.roles
4516
- })}, true)
4517
- `);
4518
- if (userRole) await tx.execute(sql.raw(`SET LOCAL ROLE ${quoteIdent$1(userRole)}`));
4519
- }
4520
- /** Role names from other BaaS platforms that people reach for out of habit. */
4521
- var FOREIGN_CONVENTION_ROLES = {
4522
- authenticated: "Supabase",
4523
- anon: "Supabase",
4524
- service_role: "Supabase"
4525
4858
  };
4526
- /**
4527
- * Warn about rules that read as "signed-in users only" but admit anonymous
4528
- * callers — `auth.uid() IS NOT NULL`, or a comparison against another
4529
- * platform's magic user id such as `'anon'`.
4530
- *
4531
- * The sibling of {@link validatePolicyPgRoles}, for the more dangerous spelling
4532
- * of the same habit. A foreign `pgRoles` value makes a policy unreachable and
4533
- * the table reads empty — loud, and that guard throws. These do the opposite:
4534
- * the rule compiles to a grant, and nothing looks wrong until the data is
4535
- * already public.
4536
- *
4537
- * Warns rather than throws. Unlike an unreachable `pgRoles`, these rules are
4538
- * serving traffic today: refusing to boot would take an app offline to report a
4539
- * problem it already has, and on the read path it would take it offline
4540
- * *because* its data was exposed. Rewriting the author's SQL is not an option
4541
- * either — this is the escape hatch whose whole promise is that it means what it
4542
- * says. So: say so, loudly, and leave the rule alone.
4543
- */
4544
- function warnOnAnonymousGrants(collections) {
4545
- const byRisk = /* @__PURE__ */ new Map();
4546
- for (const collection of collections) for (const rule of collection.securityRules ?? []) {
4547
- const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
4548
- const risks = [usingExpr, withCheckExpr].filter((e) => e !== null).flatMap(findAnonymousGrants);
4549
- for (const risk of risks) {
4550
- const key = `${risk.pattern}:${risk.detail}`;
4551
- const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
4552
- const entry = byRisk.get(key) ?? {
4553
- risk,
4554
- sites: []
4555
- };
4556
- if (!entry.sites.includes(site)) entry.sites.push(site);
4557
- byRisk.set(key, entry);
4558
- }
4559
- }
4560
- if (byRisk.size === 0) return;
4561
- const problems = [...byRisk.values()].map(({ risk, sites }) => ` • ${risk.explanation}\n ${sites.length} rule(s): ${sites.join(", ")}`);
4562
- 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");
4563
- }
4564
- /**
4565
- * Reject `pgRoles` that this server can never satisfy.
4566
- *
4567
- * `pgRoles` sets the `TO` clause of a generated policy, so a policy naming a
4568
- * role the request never runs as simply never applies — and RLS then filters
4569
- * every row. The table reads as empty, which is indistinguishable from having
4570
- * no data, so the mistake survives review and ships.
4571
- *
4572
- * Requests run as `rebase_user`, so a policy is only reachable if it targets
4573
- * `public` or a role `rebase_user` holds. Anything else is a configuration
4574
- * error worth failing the boot for.
4575
- */
4576
- async function validatePolicyPgRoles(run, collections, requestRole = REBASE_USER_ROLE) {
4577
- const wanted = /* @__PURE__ */ new Map();
4578
- for (const collection of collections) for (const rule of collection.securityRules ?? []) for (const role of rule.pgRoles ?? []) {
4579
- if (role === "public") continue;
4580
- wanted.set(role, [...wanted.get(role) ?? [], collection.slug ?? "(unnamed)"]);
4581
- }
4582
- if (wanted.size === 0) return;
4583
- const names = [...wanted.keys()].map((r) => `'${r.replace(/'/g, "''")}'`).join(",");
4584
- const rows = await run(`
4585
- SELECT r.rolname AS role,
4586
- COALESCE(pg_has_role(to_regrole('${requestRole.replace(/'/g, "''")}'), r.oid, 'MEMBER'), false) AS reachable
4587
- FROM pg_roles r
4588
- WHERE r.rolname IN (${names})
4589
- `);
4590
- const reachable = new Map(rows.map((row) => [String(row.role), row.reachable === true]));
4591
- const problems = [];
4592
- for (const [role, slugs] of wanted) {
4593
- if (reachable.get(role) === true) continue;
4594
- const why = reachable.has(role) ? `"${requestRole}" is not a member of it` : "no such role exists in this database";
4595
- const platform = FOREIGN_CONVENTION_ROLES[role];
4596
- const hint = platform ? `"${role}" is a ${platform} convention, not a PostgreSQL role. Application roles belong in \`roles: ["${role === "service_role" ? "admin" : role}"]\`, which is checked inside the policy via auth.roles().` : `Either grant it (GRANT ${role} TO ${requestRole}) or drop \`pgRoles\` so the policy targets \`public\`.`;
4597
- problems.push(` • pgRoles: ["${role}"] on ${slugs.join(", ")} — ${why}.\n ${hint}`);
4598
- }
4599
- if (problems.length > 0) throw new Error(`Security rules target PostgreSQL roles this server cannot use. Requests run as "${requestRole}", so these policies would never apply and every row would be filtered out — the collections would look empty rather than error.\n\n` + problems.join("\n\n") + "\n");
4600
- }
4601
4859
  //#endregion
4602
4860
  //#region src/PostgresBackendDriver.ts
4603
4861
  var PostgresBackendDriver = class PostgresBackendDriver {
@@ -4691,6 +4949,25 @@ var PostgresBackendDriver = class PostgresBackendDriver {
4691
4949
  }
4692
4950
  };
4693
4951
  }
4952
+ /**
4953
+ * Build the context handed to every collection callback.
4954
+ *
4955
+ * Note `data: this.data` — `this` is whichever driver is running the
4956
+ * operation, so the callback's data plane inherits that driver's privilege.
4957
+ * On a user request `AuthenticatedPostgresBackendDriver.withTransaction`
4958
+ * constructs a fresh base driver bound to the RLS-scoped transaction and
4959
+ * runs the operation on it, so `this.data` speaks through that connection
4960
+ * and policies apply. On server-context work `this` is the base driver on
4961
+ * the owner connection, and they do not. Pinned by the
4962
+ * `"scopes context.data to the caller"` case in the `rls-enforcement` e2e
4963
+ * suite, because it is the kind of property that is easy to break from a
4964
+ * distance and impossible to notice.
4965
+ *
4966
+ * Previously returned through `as unknown as RebaseCallContext`, which
4967
+ * disabled checking for the whole object and let `driver` — documented in
4968
+ * the callbacks guide — sit on the runtime context while absent from the
4969
+ * contract. Both are declared now, so this is a plain typed return.
4970
+ */
4694
4971
  buildCallContext() {
4695
4972
  return {
4696
4973
  user: this.user,
@@ -5146,6 +5423,106 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5146
5423
  return saved;
5147
5424
  });
5148
5425
  }
5426
+ /**
5427
+ * Update many rows through the same pipeline as {@link save}, in one
5428
+ * transaction.
5429
+ *
5430
+ * Structurally the mirror of {@link saveMany} — same tx-bound sub-driver,
5431
+ * same deferred notifications, same per-row error labelling — but it calls
5432
+ * `save` with an explicit `id` and `status: "existing"`, which is precisely
5433
+ * what `saveMany` cannot do: that one passes `status: "new"` and keeps the
5434
+ * key inside `values`, so it inserts or upserts and can never target a
5435
+ * particular row.
5436
+ *
5437
+ * All-or-nothing, so an id matching no row aborts the batch. A partial
5438
+ * update is the outcome with no good recovery: the caller cannot tell which
5439
+ * half landed without re-reading everything.
5440
+ */
5441
+ async updateMany({ path, updates, collection }) {
5442
+ return this.db.transaction(async (tx) => {
5443
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5444
+ txDriver.dataService = new DataService(tx, this.registry);
5445
+ txDriver.client = this.client;
5446
+ txDriver._deferNotifications = this._deferNotifications;
5447
+ txDriver._pendingNotifications = this._pendingNotifications;
5448
+ const saved = [];
5449
+ for (let i = 0; i < updates.length; i++) {
5450
+ const { id, values } = updates[i];
5451
+ try {
5452
+ if (!await txDriver.fetchOne({
5453
+ path,
5454
+ id: String(id),
5455
+ collection
5456
+ })) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5457
+ statusCode: 404,
5458
+ code: "NOT_FOUND"
5459
+ });
5460
+ saved.push(await txDriver.save({
5461
+ path,
5462
+ id: String(id),
5463
+ values,
5464
+ collection,
5465
+ status: "existing"
5466
+ }));
5467
+ } catch (error) {
5468
+ throw Object.assign(new Error(`Update ${i} of ${updates.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5469
+ statusCode: error?.statusCode,
5470
+ code: error?.code,
5471
+ name: error?.name
5472
+ });
5473
+ }
5474
+ }
5475
+ return saved;
5476
+ });
5477
+ }
5478
+ /**
5479
+ * Delete many rows in one transaction, running the full delete pipeline —
5480
+ * `beforeDelete`, the delete, `afterDelete` — for each.
5481
+ *
5482
+ * Looping the single-row {@link delete} rather than emitting one
5483
+ * `DELETE ... WHERE id = ANY($1)` is the deliberate choice: a single
5484
+ * statement would be faster and would skip every callback, so a collection
5485
+ * relying on `beforeDelete` to veto or on `afterDelete` to clean up
5486
+ * dependents would behave differently depending on how many rows the caller
5487
+ * happened to delete at once. Same pipeline, one transaction.
5488
+ */
5489
+ async deleteMany({ path, ids, collection }) {
5490
+ await this.db.transaction(async (tx) => {
5491
+ const txDriver = new PostgresBackendDriver(tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService);
5492
+ txDriver.dataService = new DataService(tx, this.registry);
5493
+ txDriver.client = this.client;
5494
+ txDriver._deferNotifications = this._deferNotifications;
5495
+ txDriver._pendingNotifications = this._pendingNotifications;
5496
+ for (let i = 0; i < ids.length; i++) {
5497
+ const id = ids[i];
5498
+ try {
5499
+ const existing = await txDriver.fetchOne({
5500
+ path,
5501
+ id: String(id),
5502
+ collection
5503
+ });
5504
+ if (!existing) throw Object.assign(/* @__PURE__ */ new Error(`No row with id ${JSON.stringify(id)}`), {
5505
+ statusCode: 404,
5506
+ code: "NOT_FOUND"
5507
+ });
5508
+ await txDriver.delete({
5509
+ row: {
5510
+ id: String(id),
5511
+ path,
5512
+ values: existing
5513
+ },
5514
+ collection
5515
+ });
5516
+ } catch (error) {
5517
+ throw Object.assign(new Error(`Delete ${i} of ${ids.length} (id ${JSON.stringify(id)}) failed: ${error?.message ?? error}`, { cause: error }), {
5518
+ statusCode: error?.statusCode,
5519
+ code: error?.code,
5520
+ name: error?.name
5521
+ });
5522
+ }
5523
+ }
5524
+ });
5525
+ }
5149
5526
  async delete({ row, collection }) {
5150
5527
  const targetPath = row.path;
5151
5528
  const targetRow = { ...row.values ?? {} };
@@ -5723,6 +6100,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
5723
6100
  * that rotates immediately after it.
5724
6101
  */
5725
6102
  sessionStartedAt: timestamp("session_started_at").defaultNow().notNull(),
6103
+ /**
6104
+ * The assurance level the sign-in was established at — `aal2` only
6105
+ * where a second factor was actually presented. Carried across
6106
+ * rotations, because refresh is not a new authentication and has
6107
+ * nothing else to read the level from.
6108
+ */
6109
+ aal: text("aal"),
5726
6110
  userAgent: text("user_agent"),
5727
6111
  ipAddress: text("ip_address"),
5728
6112
  createdAt: timestamp("created_at").defaultNow().notNull()
@@ -5768,6 +6152,13 @@ function createAuthSchema(usersSchemaName = "rebase") {
5768
6152
  secretEncrypted: text("secret_encrypted").notNull(),
5769
6153
  friendlyName: text("friendly_name"),
5770
6154
  verified: boolean("verified").default(false).notNull(),
6155
+ /**
6156
+ * The highest TOTP time step ever accepted for this factor. RFC 6238
6157
+ * §5.2 forbids accepting an OTP twice, and the ±1 step window that
6158
+ * exists for clock drift is also a 90-second replay window: without
6159
+ * this, one observed code buys a fresh session for a minute and a half.
6160
+ */
6161
+ lastUsedCounter: bigint("last_used_counter", { mode: "number" }),
5771
6162
  createdAt: timestamp("created_at").defaultNow().notNull(),
5772
6163
  updatedAt: timestamp("updated_at").defaultNow().notNull()
5773
6164
  });
@@ -5785,6 +6176,8 @@ function createAuthSchema(usersSchemaName = "rebase") {
5785
6176
  createdAt: timestamp("created_at").defaultNow().notNull(),
5786
6177
  verifiedAt: timestamp("verified_at"),
5787
6178
  ipAddress: text("ip_address"),
6179
+ /** Failed guesses recorded against this challenge; bounded by the route. */
6180
+ attempts: integer("attempts").default(0).notNull(),
5788
6181
  expiresAt: timestamp("expires_at").notNull()
5789
6182
  }),
5790
6183
  recoveryCodes: tableCreator("recovery_codes", {
@@ -5861,6 +6254,26 @@ var magicLinkTokensRelations = relations(magicLinkTokens, ({ one }) => ({ user:
5861
6254
  * Uses the explicit `columnName` when set (e.g. from introspection),
5862
6255
  * falling back to `toSnakeCase(propName)` for manually-authored collections.
5863
6256
  */
6257
+ var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
6258
+ /**
6259
+ * A string literal for the generated schema file.
6260
+ *
6261
+ * Column names, table names and enum values are all written into this file as
6262
+ * literals, and none of them is constrained to be quote-free: a Postgres
6263
+ * identifier only has to be quoted, and `O'Brien` is an ordinary enum value.
6264
+ * Interpolating them raw ended the literal early — for enum values, inside
6265
+ * single quotes, where an apostrophe is not an edge case.
6266
+ */
6267
+ var quote$1 = (value) => JSON.stringify(value);
6268
+ /** An object key: verbatim when it is an identifier, quoted otherwise. */
6269
+ var propKey = (name) => JS_IDENTIFIER.test(name) ? name : quote$1(name);
6270
+ /**
6271
+ * A property access on a generated table variable.
6272
+ *
6273
+ * `users.full name` is not an expression; `users["full name"]` is, and Drizzle
6274
+ * treats the two identically.
6275
+ */
6276
+ var member = (object, key) => JS_IDENTIFIER.test(key) ? `${object}.${key}` : `${object}[${quote$1(key)}]`;
5864
6277
  var resolveColumnName = (propName, prop) => {
5865
6278
  if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
5866
6279
  return toSnakeCase(propName);
@@ -5891,26 +6304,16 @@ var getPrimaryKeyProp = (collection) => {
5891
6304
  };
5892
6305
  };
5893
6306
  /**
5894
- * Given a raw DB column name (e.g. "client_id"), find the Drizzle property key
5895
- * on the collection that maps to that column. A property matches if:
5896
- * (a) it has an explicit `columnName` equal to the given column, OR
5897
- * (b) its snake_case form equals the given column.
6307
+ * Given a raw DB column name (e.g. "client_id"), the Drizzle property key that
6308
+ * maps to it.
5898
6309
  *
5899
- * Returns the property key (the Drizzle object key) if found, or the original
5900
- * column name as a fallback.
6310
+ * One line, because the rule is shared: the Drizzle object key is the wire
6311
+ * name, and {@link fieldKeyForColumn} is the one definition of what a column is
6312
+ * named on the wire. This used to be a private copy that fell back to the
6313
+ * column verbatim, which is how a derived foreign key ended up served as
6314
+ * `author_id` beside a hand-authored `displayName`.
5901
6315
  */
5902
- var resolvePropertyKeyForColumn = (collection, column) => {
5903
- if (!collection.properties) return column;
5904
- for (const [propKey, prop] of Object.entries(collection.properties)) {
5905
- const p = prop;
5906
- if ("columnName" in p && typeof p.columnName === "string") {
5907
- if (p.columnName === column) return propKey;
5908
- }
5909
- if (toSnakeCase(propKey) === column) return propKey;
5910
- if (propKey === column) return propKey;
5911
- }
5912
- return column;
5913
- };
6316
+ var resolvePropertyKeyForColumn = (collection, column) => fieldKeyForColumn(collection, column);
5914
6317
  var isNumericId = (collection) => {
5915
6318
  return getPrimaryKeyProp(collection).type === "number";
5916
6319
  };
@@ -5921,18 +6324,25 @@ var isIdProperty = (propName, prop, collection) => {
5921
6324
  if ("isId" in prop && Boolean(prop.isId)) return true;
5922
6325
  return !Object.values(collection.properties ?? {}).some((p) => "isId" in p && Boolean(p.isId)) && propName === "id";
5923
6326
  };
6327
+ /**
6328
+ * The Drizzle column declaration a property compiles to, or `null` when the
6329
+ * property puts no column on *this* table (an inverse relation, whose column
6330
+ * lives on the target). Exported so it can be checked against its DDL twin
6331
+ * `getSqlColumnType` directly — the two disagreeing is what left `geopoint`
6332
+ * with a database column and no Drizzle key.
6333
+ */
5924
6334
  var getDrizzleColumn = (propName, prop, collection, collections) => {
5925
6335
  const colName = resolveColumnName(propName, prop);
5926
6336
  let columnDefinition;
5927
6337
  switch (prop.type) {
5928
6338
  case "string": {
5929
6339
  const stringProp = prop;
5930
- if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}("${colName}")`;
5931
- else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid("${colName}")`;
5932
- else if (stringProp.columnType === "uuid") columnDefinition = `uuid("${colName}")`;
5933
- else if (stringProp.columnType === "char") columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
5934
- else if (stringProp.columnType === "varchar") columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
5935
- else columnDefinition = `text("${colName}")`;
6340
+ if (stringProp.enum) columnDefinition = `${getEnumVarName(getTableName$1(collection), propName)}(${quote$1(colName)})`;
6341
+ else if ("isId" in stringProp && stringProp.isId === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
6342
+ else if (stringProp.columnType === "uuid") columnDefinition = `uuid(${quote$1(colName)})`;
6343
+ else if (stringProp.columnType === "char") columnDefinition = `char(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
6344
+ else if (stringProp.columnType === "varchar") columnDefinition = `varchar(${quote$1(colName)}, { length: ${resolveStringColumnLength(stringProp)} })`;
6345
+ else columnDefinition = `text(${quote$1(colName)})`;
5936
6346
  if (isIdProperty(propName, prop, collection)) columnDefinition += ".primaryKey()";
5937
6347
  if ("isId" in stringProp && stringProp.isId !== "manual" && stringProp.isId !== true) {
5938
6348
  if (stringProp.isId === "uuid") columnDefinition += ".defaultRandom()";
@@ -5948,10 +6358,10 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5948
6358
  case "number": {
5949
6359
  const numProp = prop;
5950
6360
  const isId = isIdProperty(propName, prop, collection);
5951
- let baseType = numProp.validation?.integer || isId ? `integer("${colName}")` : `numeric("${colName}")`;
5952
- if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision("${colName}")`;
5953
- else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}("${colName}", { mode: "number" })`;
5954
- else baseType = `${numProp.columnType}("${colName}")`;
6361
+ let baseType = numProp.validation?.integer || isId ? `integer(${quote$1(colName)})` : `numeric(${quote$1(colName)})`;
6362
+ if (numProp.columnType) if (numProp.columnType === "double precision") baseType = `doublePrecision(${quote$1(colName)})`;
6363
+ else if (numProp.columnType === "bigint" || numProp.columnType === "bigserial") baseType = `${numProp.columnType}(${quote$1(colName)}, { mode: "number" })`;
6364
+ else baseType = `${numProp.columnType}(${quote$1(colName)})`;
5955
6365
  if ("isId" in numProp && numProp.isId === "increment") columnDefinition = `${baseType}.generatedByDefaultAsIdentity()`;
5956
6366
  else if ("isId" in numProp && typeof numProp.isId === "string" && numProp.isId !== "manual") {
5957
6367
  columnDefinition = baseType;
@@ -5963,19 +6373,22 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5963
6373
  break;
5964
6374
  }
5965
6375
  case "boolean":
5966
- columnDefinition = `boolean("${colName}")`;
6376
+ columnDefinition = `boolean(${quote$1(colName)})`;
5967
6377
  break;
5968
6378
  case "date": {
5969
6379
  const dateProp = prop;
5970
- if (dateProp.columnType === "date") columnDefinition = `date("${colName}", { mode: 'string' })`;
5971
- else if (dateProp.columnType === "time") columnDefinition = `time("${colName}")`;
5972
- else columnDefinition = `timestamp("${colName}", { withTimezone: true, mode: 'string' })`;
6380
+ if (dateProp.columnType === "date") columnDefinition = `date(${quote$1(colName)}, { mode: 'string' })`;
6381
+ else if (dateProp.columnType === "time") columnDefinition = `time(${quote$1(colName)})`;
6382
+ else columnDefinition = `timestamp(${quote$1(colName)}, { withTimezone: true, mode: 'string' })`;
5973
6383
  if (dateProp.autoValue === "on_create" || dateProp.autoValue === "on_update") columnDefinition += ".default(sql`now()`)";
5974
6384
  break;
5975
6385
  }
5976
6386
  case "map":
5977
- if (prop.columnType === "json") columnDefinition = `json("${colName}")`;
5978
- else columnDefinition = `jsonb("${colName}")`;
6387
+ if (prop.columnType === "json") columnDefinition = `json(${quote$1(colName)})`;
6388
+ else columnDefinition = `jsonb(${quote$1(colName)})`;
6389
+ break;
6390
+ case "geopoint":
6391
+ columnDefinition = `jsonb(${quote$1(colName)})`;
5979
6392
  break;
5980
6393
  case "array": {
5981
6394
  const arrayProp = prop;
@@ -5986,25 +6399,28 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
5986
6399
  else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
5987
6400
  else if (ofProp.type === "boolean") colType = "boolean[]";
5988
6401
  }
5989
- if (colType === "json") columnDefinition = `json("${colName}")`;
5990
- else if (colType === "text[]") columnDefinition = `text("${colName}").array()`;
5991
- else if (colType === "integer[]") columnDefinition = `integer("${colName}").array()`;
5992
- else if (colType === "boolean[]") columnDefinition = `boolean("${colName}").array()`;
5993
- else if (colType === "numeric[]") columnDefinition = `numeric("${colName}").array()`;
5994
- else columnDefinition = `jsonb("${colName}")`;
6402
+ if (colType === "json") columnDefinition = `json(${quote$1(colName)})`;
6403
+ else if (colType === "text[]") columnDefinition = `text(${quote$1(colName)}).array()`;
6404
+ else if (colType === "integer[]") columnDefinition = `integer(${quote$1(colName)}).array()`;
6405
+ else if (colType === "boolean[]") columnDefinition = `boolean(${quote$1(colName)}).array()`;
6406
+ else if (colType === "numeric[]") columnDefinition = `numeric(${quote$1(colName)}).array()`;
6407
+ else columnDefinition = `jsonb(${quote$1(colName)})`;
5995
6408
  break;
5996
6409
  }
5997
- case "vector":
5998
- columnDefinition = `vector("${colName}", { dimensions: ${prop.dimensions} })`;
6410
+ case "vector": {
6411
+ const vp = prop;
6412
+ columnDefinition = `vector(${quote$1(colName)}, { dimensions: ${vp.dimensions} })`;
5999
6413
  break;
6414
+ }
6000
6415
  case "binary":
6001
- columnDefinition = `customType({ dataType() { return 'bytea'; } })("${colName}")`;
6416
+ columnDefinition = `customType({ dataType() { return 'bytea'; } })(${quote$1(colName)})`;
6002
6417
  break;
6003
6418
  case "relation": {
6004
6419
  const refProp = prop;
6005
6420
  const relation = findRelation(resolveCollectionRelations(collection), refProp.relation?.relationName ?? propName);
6006
6421
  if (!relation || relation.kind !== "belongsTo") return null;
6007
- if (collection.properties[relation.localKey] && propName !== relation.localKey) return null;
6422
+ const fkFieldKey = fieldKeyForColumn(collection, relation.localKey);
6423
+ if (collection.properties[fkFieldKey] && propName !== fkFieldKey) return null;
6008
6424
  let targetCollection;
6009
6425
  try {
6010
6426
  targetCollection = relation.target();
@@ -6020,30 +6436,31 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
6020
6436
  const required = prop.validation?.required;
6021
6437
  const refOptionsParts = [onUpdate, `onDelete: \"${relation.onDelete ?? (required ? "cascade" : "set null")}\"`].filter(Boolean);
6022
6438
  const refOptions = refOptionsParts.length > 0 ? `{ ${refOptionsParts.join(", ")} }` : "";
6023
- let columnDef = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}${refOptions ? `, ${refOptions}` : ""})`;
6439
+ let columnDef = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}${refOptions ? `, ${refOptions}` : ""})`;
6024
6440
  if (required) columnDef += ".notNull()";
6025
- return ` ${relation.localKey}: ${columnDef}`;
6441
+ return ` ${propKey(fkFieldKey)}: ${columnDef}`;
6026
6442
  }
6027
6443
  case "reference": {
6028
6444
  const refProp = prop;
6029
6445
  const targetCollection = collections.find((c) => c.slug === refProp.path || getTableName$1(c) === refProp.path);
6030
6446
  if (!targetCollection) {
6031
- columnDefinition = `text("${colName}")`;
6447
+ columnDefinition = `text(${quote$1(colName)})`;
6032
6448
  break;
6033
6449
  }
6034
6450
  const pkProp = getPrimaryKeyProp(targetCollection);
6035
6451
  const targetTableVar = getTableVarName(getTableName$1(targetCollection));
6036
6452
  const targetIdField = pkProp.name;
6037
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`;
6453
+ const baseColumn = pkProp.type === "number" ? `integer(${quote$1(colName)})` : pkProp.isUuid ? `uuid(${quote$1(colName)})` : `text(${quote$1(colName)})`;
6038
6454
  const required = prop.validation?.required;
6039
- columnDefinition = `${baseColumn}.references(() => ${targetTableVar}.${targetIdField}, ${`{ onDelete: "${required ? "cascade" : "set null"}" }`})`;
6455
+ const refOptions = `{ onDelete: "${required ? "cascade" : "set null"}" }`;
6456
+ columnDefinition = `${baseColumn}.references(() => ${member(targetTableVar, targetIdField)}, ${refOptions})`;
6040
6457
  if (required) columnDefinition += ".notNull()";
6041
- return ` ${propName}: ${columnDefinition}`;
6458
+ return ` ${propKey(propName)}: ${columnDefinition}`;
6042
6459
  }
6043
- default: return null;
6460
+ 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).`);
6044
6461
  }
6045
6462
  if (prop.validation?.required) columnDefinition += ".notNull()";
6046
- return ` ${propName}: ${columnDefinition}`;
6463
+ return ` ${propKey(propName)}: ${columnDefinition}`;
6047
6464
  };
6048
6465
  /**
6049
6466
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
@@ -6076,7 +6493,7 @@ var generateSinglePolicyCode = (collection, rule, operation, policyName, resolve
6076
6493
  parts.push(`to: [${toRoles.map((r) => `"${r}"`).join(", ")}]`);
6077
6494
  if (usingClause) parts.push(`using: ${usingClause}`);
6078
6495
  if (withCheckClause) parts.push(`withCheck: ${withCheckClause}`);
6079
- return ` pgPolicy("${policyName}", { ${parts.join(", ")} }),\n`;
6496
+ return ` pgPolicy(${quote$1(policyName)}, { ${parts.join(", ")} }),\n`;
6080
6497
  };
6081
6498
  /**
6082
6499
  * Computes a deterministic shared relation name for Drizzle.
@@ -6111,11 +6528,12 @@ var computeSharedRelationName = (rel, sourceCollection, _collections) => {
6111
6528
  return fallback;
6112
6529
  };
6113
6530
  var generateSchema = async (allCollections, stripPolicies = false) => {
6114
- const collections = relationalCollections(allCollections);
6531
+ const collections = sortCollectionsBySlug(relationalCollections(allCollections));
6115
6532
  let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
6116
6533
  const hasUuid = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "string" && (p.autoValue === "uuid" || p.isId === "uuid")));
6117
6534
  const hasVector = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "vector"));
6118
6535
  const hasBinary = collections.some((c) => c.properties && Object.values(c.properties).some((p) => p.type === "binary"));
6536
+ const hasSearch = collections.some((c) => buildSearchColumnSpec(c) !== void 0);
6119
6537
  const pgCoreImports = [
6120
6538
  "primaryKey",
6121
6539
  "pgTable",
@@ -6140,7 +6558,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6140
6558
  ];
6141
6559
  if (hasUuid) pgCoreImports.push("uuid");
6142
6560
  if (hasVector) pgCoreImports.push("vector");
6143
- if (hasBinary) pgCoreImports.push("customType");
6561
+ if (hasBinary || hasSearch) pgCoreImports.push("customType");
6144
6562
  const uniqueSchemas = Array.from(new Set(collections.map((c) => isPostgresCollectionConfig(c) ? c.schema : void 0).filter(Boolean)));
6145
6563
  if (uniqueSchemas.length > 0) pgCoreImports.push("pgSchema");
6146
6564
  schemaContent += `import { ${pgCoreImports.join(", ")} } from 'drizzle-orm/pg-core';\n`;
@@ -6161,7 +6579,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6161
6579
  const enumDbName = `${collectionPath}_${resolveColumnName(propName, prop)}`;
6162
6580
  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);
6163
6581
  if (values.length > 0) {
6164
- schemaContent += `export const ${enumVarName} = pgEnum(\"${enumDbName}\", [${values.map((v) => `'${v}'`).join(", ")}]);\n`;
6582
+ schemaContent += `export const ${enumVarName} = pgEnum(${quote$1(enumDbName)}, [${values.map((v) => quote$1(v)).join(", ")}]);\n`;
6165
6583
  if (!exportedEnumVars.includes(enumVarName)) exportedEnumVars.push(enumVarName);
6166
6584
  }
6167
6585
  }
@@ -6222,6 +6640,11 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6222
6640
  const columnString = getDrizzleColumn(propName, prop, collection, collections);
6223
6641
  if (columnString) columns.add(columnString);
6224
6642
  });
6643
+ const searchSpec = buildSearchColumnSpec(collection);
6644
+ if (searchSpec) {
6645
+ columns.add(` ${searchSpec.column}: customType({ dataType() { return 'tsvector'; } })("${searchSpec.column}").generatedAlwaysAs(sql\`${searchSpec.expression}\`)`);
6646
+ if (searchSpec.fuzzy) columns.add(` ${searchSpec.fuzzy.column}: text("${searchSpec.fuzzy.column}").generatedAlwaysAs(sql\`${searchSpec.fuzzy.expression}\`)`);
6647
+ }
6225
6648
  if (!Array.from(columns).some((col) => col.includes(".primaryKey()"))) columns.add(" id: text(\"id\").primaryKey()");
6226
6649
  schemaContent += `${Array.from(columns).join(",\n")}`;
6227
6650
  const securityRules = getEffectiveSecurityRules(collection);
@@ -6258,9 +6681,9 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6258
6681
  break;
6259
6682
  }
6260
6683
  } catch {}
6261
- tableRelations.push(` "${relation.through.sourceColumn}": one(${sourceTableVar}, {\n fields: [${tableVarName}.${relation.through.sourceColumn}],\n references: [${sourceTableVar}.${sourceId}],\n relationName: \"${owningRelationName}\"\n })`);
6684
+ 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 })`);
6262
6685
  const targetRelationName = inverseRelationName ? inverseRelationName : `${tableName}_${relation.through.targetColumn}`;
6263
- tableRelations.push(` "${relation.through.targetColumn}": one(${targetTableVar}, {\n fields: [${tableVarName}.${relation.through.targetColumn}],\n references: [${targetTableVar}.${targetId}],\n relationName: "${targetRelationName}"\n })`);
6686
+ 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 })`);
6264
6687
  }
6265
6688
  } else {
6266
6689
  const resolvedRelations = resolveCollectionRelations(collection);
@@ -6275,7 +6698,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6275
6698
  switch (rel.kind) {
6276
6699
  case "belongsTo": {
6277
6700
  const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
6278
- tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${localFieldKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
6701
+ tableRelations.push(` ${quote$1(relationKey)}: one(${targetTableVar}, {\n fields: [${member(tableVarName, localFieldKey)}],\n references: [${member(targetTableVar, getPrimaryKeyName(target))}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
6279
6702
  break;
6280
6703
  }
6281
6704
  case "hasOne":
@@ -6306,7 +6729,7 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6306
6729
  const drizzleFieldKey = resolvePropertyKeyForColumn(collection, otherRel.foreignKeyOnTarget);
6307
6730
  const referencedKey = otherRel.sourceKey ? resolvePropertyKeyForColumn(otherCollection, otherRel.sourceKey) : getPrimaryKeyName(otherCollection);
6308
6731
  const synthKey = `_synth_${otherTableVar}_${drizzleFieldKey}`;
6309
- tableRelations.push(` "${synthKey}": one(${otherTableVar}, {\n fields: [${tableVarName}.${drizzleFieldKey}],\n references: [${otherTableVar}.${referencedKey}],\n relationName: \"${drizzleRelationName}\"\n })`);
6732
+ tableRelations.push(` ${quote$1(synthKey)}: one(${otherTableVar}, {\n fields: [${member(tableVarName, drizzleFieldKey)}],\n references: [${member(otherTableVar, referencedKey)}],\n relationName: ${quote$1(drizzleRelationName)}\n })`);
6310
6733
  emittedRelationNames.add(deduplicationKey);
6311
6734
  }
6312
6735
  }
@@ -6326,6 +6749,44 @@ var generateSchema = async (allCollections, stripPolicies = false) => {
6326
6749
  return schemaContent;
6327
6750
  };
6328
6751
  //#endregion
6752
+ //#region src/cli-output.ts
6753
+ /**
6754
+ * Terminal output for the `rebase db|schema|doctor` commands.
6755
+ *
6756
+ * These commands used to write every line through `logger`, and that is a
6757
+ * category error with three separate consequences:
6758
+ *
6759
+ * - `logger` prefixes each line with its own level, so a box-drawn report
6760
+ * arrived as `ℹ️ [INFO] ┌─ ✗ Missing Column ───` and the frame no longer
6761
+ * lined up with anything;
6762
+ * - `logger` is gated by `LOG_LEVEL`, which ships in the scaffold's own
6763
+ * `.env.example` — a developer who quietened their dev server with
6764
+ * `LOG_LEVEL=warn` got a `rebase db push` that printed almost nothing and
6765
+ * still exited non-zero, indistinguishable from a crash;
6766
+ * - under `NODE_ENV=production` `logger` emits JSON, so the whole report
6767
+ * became log records with the chalk escape codes embedded in them.
6768
+ *
6769
+ * A CLI's report *is* its return value. It goes to the terminal unconditionally
6770
+ * and unadorned. `packages/cli` has always written its output this way; this is
6771
+ * the same three functions for the plugin CLI that `rebase` delegates to.
6772
+ *
6773
+ * `logger` still belongs in this package's *runtime* — a request handler has no
6774
+ * terminal and its lines want levels, timestamps and redaction. The rule is the
6775
+ * caller, not the severity: anything a developer reads because they typed a
6776
+ * command goes here, anything a server emits while running goes to `logger`.
6777
+ *
6778
+ * Errors and warnings go to stderr so `rebase db push > plan.txt` keeps the
6779
+ * diagnosis on the terminal where it is readable.
6780
+ */
6781
+ /** One line of human-facing output on stdout. */
6782
+ var out = (line = "") => {
6783
+ console.log(line);
6784
+ };
6785
+ /** One line of human-facing error output on stderr. */
6786
+ var outError = (line = "") => {
6787
+ console.error(line);
6788
+ };
6789
+ //#endregion
6329
6790
  //#region src/schema/generate-drizzle-schema.ts
6330
6791
  var formatTerminalText = (text, options = {}) => {
6331
6792
  let codes = "";
@@ -6353,7 +6814,7 @@ var formatTerminalText = (text, options = {}) => {
6353
6814
  var runGeneration = async (collectionsFilePath, outputPath) => {
6354
6815
  try {
6355
6816
  if (!collectionsFilePath) {
6356
- logger.error("Error: No collections file path provided. Skipping schema generation.");
6817
+ outError("Error: No collections file path provided. Skipping schema generation.");
6357
6818
  return;
6358
6819
  }
6359
6820
  let collections = await loadCollectionsFromDirectory(path.resolve(collectionsFilePath));
@@ -6364,18 +6825,18 @@ var runGeneration = async (collectionsFilePath, outputPath) => {
6364
6825
  const outputDir = path.dirname(outputPath);
6365
6826
  await promises.mkdir(outputDir, { recursive: true });
6366
6827
  await promises.writeFile(outputPath, schemaContent);
6367
- logger.info(`✅ Drizzle schema generated successfully at ${outputPath}`);
6828
+ out(`✅ Drizzle schema generated successfully at ${outputPath}`);
6368
6829
  } else {
6369
- logger.info("✅ Drizzle schema generated successfully.");
6370
- logger.info(String(schemaContent));
6830
+ out("✅ Drizzle schema generated successfully.");
6831
+ out(String(schemaContent));
6371
6832
  }
6372
- logger.info(`You can now run ${formatTerminalText("rebase db generate", {
6833
+ out(`You can now run ${formatTerminalText("rebase db generate", {
6373
6834
  bold: true,
6374
6835
  backgroundColor: "blue",
6375
6836
  textColor: "black"
6376
6837
  })} to generate the SQL migration files.`);
6377
6838
  } catch (error) {
6378
- logger.error("Error generating schema", { error });
6839
+ outError(`Error generating schema: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
6379
6840
  }
6380
6841
  };
6381
6842
  var main = () => {
@@ -6385,18 +6846,18 @@ var main = () => {
6385
6846
  const outputPath = outputPathArg ? outputPathArg.split("=")[1] : void 0;
6386
6847
  const watch = process.argv.includes("--watch");
6387
6848
  if (!collectionsFilePath) {
6388
- logger.info("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
6849
+ out("Usage: ts-node generate-drizzle-schema.ts <path-to-collections-file> [--output <path-to-output-file>] [--watch]");
6389
6850
  return;
6390
6851
  }
6391
6852
  const resolvedPath = path.resolve(process.cwd(), collectionsFilePath);
6392
6853
  const resolvedOutputPath = outputPath ? path.resolve(process.cwd(), outputPath) : void 0;
6393
6854
  if (watch) {
6394
- logger.info(`Watching for changes in ${resolvedPath}...`);
6855
+ out(`Watching for changes in ${resolvedPath}...`);
6395
6856
  chokidar.watch(resolvedPath, {
6396
6857
  persistent: true,
6397
6858
  ignoreInitial: false
6398
6859
  }).on("all", (event, filePath) => {
6399
- logger.info(`[${event}] ${filePath}. Regenerating schema...`);
6860
+ out(`[${event}] ${filePath}. Regenerating schema...`);
6400
6861
  runGeneration(resolvedPath, resolvedOutputPath);
6401
6862
  });
6402
6863
  } else runGeneration(resolvedPath, resolvedOutputPath);
@@ -6580,7 +7041,7 @@ async function provisionTriggerCdc(run, tables) {
6580
7041
  logger.warn(`⚠️ [CDC] Could not attach change-capture trigger to "${key}" — is the table migrated? Writes to it won't emit database-level events.`, { detail: reason });
6581
7042
  }
6582
7043
  }
6583
- logger.info(`📡 [CDC] Trigger-based change capture provisioned on ${installed.length} table(s)` + (skipped.length ? ` (${skipped.length} skipped)` : "") + ".");
7044
+ logger.debug(`📡 [CDC] Trigger-based change capture provisioned on ${installed.length} table(s)` + (skipped.length ? ` (${skipped.length} skipped)` : "") + ".");
6584
7045
  return {
6585
7046
  installed,
6586
7047
  skipped
@@ -6670,7 +7131,7 @@ var PgNotifyListener = class {
6670
7131
  await client.connect();
6671
7132
  await client.query(`LISTEN ${channel}`);
6672
7133
  this.client = client;
6673
- logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
7134
+ logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
6674
7135
  } catch (err) {
6675
7136
  if (initial) throw err;
6676
7137
  logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
@@ -6925,6 +7386,8 @@ var ChannelHistoryStore = class {
6925
7386
  last_seq BIGINT NOT NULL
6926
7387
  )
6927
7388
  `);
7389
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")));
7390
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")));
6928
7391
  this.tablesReady = true;
6929
7392
  logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
6930
7393
  }
@@ -7108,6 +7571,7 @@ var ChannelPresenceStore = class {
7108
7571
  CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
7109
7572
  ON rebase.channel_presence (last_seen)
7110
7573
  `);
7574
+ await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
7111
7575
  this.tablesReady = true;
7112
7576
  }
7113
7577
  /** Record (or refresh) a client's presence. */
@@ -7499,6 +7963,12 @@ var PG_NOTIFY_CHANNEL = "rebase_entity_changes";
7499
7963
  var RealtimeService = class RealtimeService extends EventEmitter {
7500
7964
  db;
7501
7965
  registry;
7966
+ /**
7967
+ * Declares to the multi-engine router that channel frames can be handled
7968
+ * here. Read by `createRoutedRealtimeService`, which otherwise would have to
7969
+ * guess — and guessed "the default provider", whichever engine that is.
7970
+ */
7971
+ supportsChannels = true;
7502
7972
  clients = /* @__PURE__ */ new Map();
7503
7973
  channels = /* @__PURE__ */ new Map();
7504
7974
  presence = /* @__PURE__ */ new Map();
@@ -7545,6 +8015,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7545
8015
  * so a hot channel logs the problem once rather than once per message.
7546
8016
  */
7547
8017
  oversizedBroadcastWarned = /* @__PURE__ */ new Set();
8018
+ /**
8019
+ * Optional narrowing on top of the membership floor — see
8020
+ * {@link ChannelAuthorizer}. Unset by default, which leaves membership as
8021
+ * the whole of the rule.
8022
+ */
8023
+ channelAuthorizer;
8024
+ /**
8025
+ * Whether a notification from another instance has ever arrived.
8026
+ *
8027
+ * The entity LISTEN handler sees a foreign `sid` on every cross-instance
8028
+ * change, which is proof that this deployment runs more than one pod — the
8029
+ * one fact needed to tell "the memory bus is fine here" from "broadcast and
8030
+ * presence silently reach a fraction of your users".
8031
+ */
8032
+ foreignInstanceSeen = false;
8033
+ /** So the multi-pod memory-bus warning is emitted once, not once per join. */
8034
+ memoryBusWarned = false;
7548
8035
  presenceInterval;
7549
8036
  static PRESENCE_TIMEOUT_MS = 3e4;
7550
8037
  /** How often stale roster rows from other instances are reaped. */
@@ -7638,7 +8125,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7638
8125
  limit: config.limit,
7639
8126
  startAfter: config.startAfter,
7640
8127
  databaseId: config.databaseId,
7641
- searchString: config.searchString
8128
+ searchString: config.searchString,
8129
+ searchExplain: config.searchExplain
7642
8130
  }
7643
8131
  });
7644
8132
  if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
@@ -7718,26 +8206,13 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7718
8206
  await this.handleUnsubscribe(clientId, message.subscriptionId);
7719
8207
  break;
7720
8208
  case "join_channel":
7721
- this.joinChannel(clientId, payload?.channel);
7722
- break;
7723
8209
  case "leave_channel":
7724
- this.leaveChannel(clientId, payload?.channel);
7725
- break;
7726
8210
  case "broadcast":
7727
- this.broadcastToChannel(clientId, payload?.channel, payload?.event, payload?.payload);
7728
- break;
7729
8211
  case "channel_history":
7730
- await this.handleChannelHistoryRequest(clientId, payload?.channel, payload?.sinceSeq, payload?.limit);
7731
- break;
7732
8212
  case "presence_track":
7733
- this.joinChannel(clientId, payload?.channel);
7734
- this.trackPresence(clientId, payload?.channel, payload?.state ?? {});
7735
- break;
7736
8213
  case "presence_untrack":
7737
- this.removePresence(clientId, payload?.channel);
7738
- break;
7739
8214
  case "presence_state":
7740
- this.sendPresenceState(clientId, payload?.channel);
8215
+ await this.handleChannelMessage(clientId, message.type, payload, authContext);
7741
8216
  break;
7742
8217
  default: this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
7743
8218
  }
@@ -7752,7 +8227,21 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7752
8227
  this.sendError(clientId, msg, subscriptionId);
7753
8228
  return;
7754
8229
  }
7755
- const boundedLimit = resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch });
8230
+ if (request.vectorSearch) {
8231
+ 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.";
8232
+ logger.warn(`[RealtimeService] ${msg}`);
8233
+ this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
8234
+ return;
8235
+ }
8236
+ let boundedLimit;
8237
+ try {
8238
+ boundedLimit = resolveClientListLimit(request.limit);
8239
+ } catch (e) {
8240
+ if (!(e instanceof ListLimitError)) throw e;
8241
+ logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
8242
+ this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
8243
+ return;
8244
+ }
7756
8245
  this._subscriptions.set(subscriptionId, {
7757
8246
  clientId,
7758
8247
  type: "collection",
@@ -7766,7 +8255,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7766
8255
  offset: request.offset,
7767
8256
  startAfter: request.startAfter,
7768
8257
  databaseId: request.collection?.databaseId,
7769
- searchString: request.searchString
8258
+ searchString: request.searchString,
8259
+ searchExplain: request.searchExplain
7770
8260
  },
7771
8261
  authContext
7772
8262
  });
@@ -7855,7 +8345,39 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7855
8345
  this.debugLog("🔔 [RealtimeService] notifyUpdate completed for path:", path);
7856
8346
  }
7857
8347
  /**
7858
- * Notify subscriptions for a specific path
8348
+ * Notify subscriptions for a specific path.
8349
+ *
8350
+ * **A subscriber only ever receives rows re-read under its own scope.**
8351
+ * `row` is used to decide *that* something changed, never to say *what* —
8352
+ * every delivery below goes through a refetch that binds the subscription's
8353
+ * own auth context.
8354
+ *
8355
+ * It used to be conditional. The CDC path already did the right thing: it
8356
+ * discards the captured tuple and emits `{_rebase_invalidated: true}`, and
8357
+ * that marker selected the refetch branch. But the marker is produced in
8358
+ * exactly two places, and the *other* side of each branch here shipped the
8359
+ * row it was handed straight to the socket. Two of the three entry paths
8360
+ * took that side — every API mutation (`PostgresBackendDriver.save` passes
8361
+ * the row it just wrote, read under the **writer's** scope) and the legacy
8362
+ * cross-instance LISTEN handler (which re-reads on the owner connection,
8363
+ * bypassing RLS altogether). Path matching was the only filter applied: the
8364
+ * subscription's own `filter`/`logical` was never evaluated, and any
8365
+ * `afterRead` redaction was the writer's rather than the reader's.
8366
+ *
8367
+ * A single-row subscription was the sharpest case. `subscribe_one` on a row
8368
+ * RLS denies is accepted and answered `null`; the next update then pushed
8369
+ * the full row with no later correction. The collection variant was merely
8370
+ * papered over ~300 ms later by the debounced refetch — after the bytes had
8371
+ * already reached the browser.
8372
+ *
8373
+ * The same defect was found and fixed on the Mongo driver in `065e2b615`
8374
+ * (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
8375
+ * the Postgres half, stated as one rule rather than three patched branches.
8376
+ *
8377
+ * The cost is the instant row-level patch that used to precede the refetch:
8378
+ * cross-tab feedback now waits for the debounce. That is the price of not
8379
+ * being able to know, without asking the database as this subscriber,
8380
+ * whether this subscriber may see the row at all.
7859
8381
  */
7860
8382
  async notifyPathUpdate(notifyPath, originalPath, id, row, _databaseId) {
7861
8383
  this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
@@ -7869,12 +8391,8 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7869
8391
  const webSocketSubscriptions = allSubscriptions.filter(([, sub]) => sub.clientId !== "driver" && this.clients.has(sub.clientId));
7870
8392
  const driverSubscriptions = allSubscriptions.filter(([subscriptionId, sub]) => sub.clientId === "driver" && this.subscriptionCallbacks.has(subscriptionId));
7871
8393
  for (const [subscriptionId, subscription] of webSocketSubscriptions) try {
7872
- if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
7873
- else this.sendSingleUpdate(subscription.clientId, subscriptionId, row);
7874
- else if (subscription.type === "collection" && subscription.collectionRequest) {
7875
- if (!row || !row?._rebase_invalidated) this.sendCollectionPatch(subscription.clientId, subscriptionId, id, row, notifyPath);
7876
- this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
7877
- }
8394
+ if (subscription.type === "single" && notifyPath === originalPath) this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
8395
+ else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
7878
8396
  } catch (error) {
7879
8397
  const sanitized = sanitizeErrorForClient(error, notifyPath);
7880
8398
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -7882,8 +8400,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7882
8400
  for (const [subscriptionId, subscription] of driverSubscriptions) try {
7883
8401
  const callback = this.subscriptionCallbacks.get(subscriptionId);
7884
8402
  if (!callback) continue;
7885
- if (subscription.type === "single" && notifyPath === originalPath) if (row && row?._rebase_invalidated) this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
7886
- else callback(row);
8403
+ if (subscription.type === "single" && notifyPath === originalPath) this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
7887
8404
  else if (subscription.type === "collection" && subscription.collectionRequest) this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
7888
8405
  } catch (error) {
7889
8406
  logger.error(`❌ [RealtimeService] Error processing DataDriver subscription ${subscriptionId}`, { error });
@@ -7947,10 +8464,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
7947
8464
  let fetchedEntities;
7948
8465
  if (collectionRequest.searchString) fetchedEntities = await txEntityService.searchRows(notifyPath, collectionRequest.searchString, {
7949
8466
  filter: collectionRequest.filter,
8467
+ logical: collectionRequest.logical,
7950
8468
  orderBy: collectionRequest.orderBy,
7951
8469
  order: collectionRequest.order,
7952
8470
  limit: collectionRequest.limit,
7953
- databaseId: collectionRequest.databaseId
8471
+ databaseId: collectionRequest.databaseId,
8472
+ searchExplain: collectionRequest.searchExplain
7954
8473
  });
7955
8474
  else fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
7956
8475
  filter: collectionRequest.filter,
@@ -8007,13 +8526,16 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8007
8526
  }
8008
8527
  if (collectionRequest.searchString) return await this.dataService.searchRows(notifyPath, collectionRequest.searchString, {
8009
8528
  filter: collectionRequest.filter,
8529
+ logical: collectionRequest.logical,
8010
8530
  orderBy: collectionRequest.orderBy,
8011
8531
  order: collectionRequest.order,
8012
8532
  limit: collectionRequest.limit,
8013
- databaseId: collectionRequest.databaseId
8533
+ databaseId: collectionRequest.databaseId,
8534
+ searchExplain: collectionRequest.searchExplain
8014
8535
  });
8015
8536
  return await this.dataService.fetchCollection(notifyPath, {
8016
8537
  filter: collectionRequest.filter,
8538
+ logical: collectionRequest.logical,
8017
8539
  orderBy: collectionRequest.orderBy,
8018
8540
  order: collectionRequest.order,
8019
8541
  limit: collectionRequest.limit,
@@ -8143,16 +8665,6 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8143
8665
  * columns and no address. The SDK holds no collection config to derive one
8144
8666
  * from, so this is the only place the mapping can come from.
8145
8667
  */
8146
- sendCollectionPatch(clientId, subscriptionId, id, row, notifyPath) {
8147
- const message = {
8148
- type: "collection_patch",
8149
- subscriptionId,
8150
- id,
8151
- row,
8152
- pks: this.primaryKeysForPath(notifyPath)
8153
- };
8154
- this.sendMessage(clientId, message);
8155
- }
8156
8668
  /** The key columns of the collection at `path`, if they can be resolved. */
8157
8669
  primaryKeysForPath(path) {
8158
8670
  try {
@@ -8197,12 +8709,148 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8197
8709
  }
8198
8710
  return parentPaths;
8199
8711
  }
8712
+ /**
8713
+ * Install a channel authorizer — see {@link ChannelAuthorizer}.
8714
+ *
8715
+ * Nothing in the framework calls this yet: it is the seam a rules API will
8716
+ * be built on, kept deliberately separate from the membership floor so the
8717
+ * floor holds whether or not anyone uses it.
8718
+ */
8719
+ setChannelAuthorizer(authorizer) {
8720
+ this.channelAuthorizer = authorizer;
8721
+ }
8722
+ /** Which action each channel frame is asking to perform. */
8723
+ static CHANNEL_ACTIONS = {
8724
+ join_channel: "join",
8725
+ broadcast: "broadcast",
8726
+ channel_history: "history",
8727
+ presence_track: "join",
8728
+ presence_state: "presence"
8729
+ };
8730
+ /**
8731
+ * The one door every channel frame comes through.
8732
+ *
8733
+ * Returns synchronously — and so dispatches synchronously — unless an
8734
+ * authorizer is installed. That matters: a client sends `join_channel`,
8735
+ * `presence_state` and `channel_history` back to back on connect, and the
8736
+ * socket's message handler processes each frame up to its first `await`,
8737
+ * so a gate that always yielded would let the reads overtake the join that
8738
+ * is about to authorize them.
8739
+ */
8740
+ handleChannelMessage(clientId, type, payload, authContext) {
8741
+ const channel = payload?.channel;
8742
+ if (type === "leave_channel") {
8743
+ this.leaveChannel(clientId, channel);
8744
+ return;
8745
+ }
8746
+ if (type === "presence_untrack") {
8747
+ this.removePresence(clientId, channel);
8748
+ return;
8749
+ }
8750
+ const action = RealtimeService.CHANNEL_ACTIONS[type];
8751
+ const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
8752
+ if (allowed === false) return;
8753
+ if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
8754
+ return allowed.then((ok) => {
8755
+ if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
8756
+ });
8757
+ }
8758
+ /** Perform an already-authorized channel frame. */
8759
+ dispatchChannelMessage(clientId, type, channel, payload) {
8760
+ switch (type) {
8761
+ case "join_channel":
8762
+ this.joinChannel(clientId, channel);
8763
+ return;
8764
+ case "broadcast":
8765
+ this.broadcastToChannel(clientId, channel, payload?.event, payload?.payload);
8766
+ return;
8767
+ case "channel_history": return this.handleChannelHistoryRequest(clientId, channel, payload?.sinceSeq, payload?.limit);
8768
+ case "presence_track":
8769
+ this.joinChannel(clientId, channel);
8770
+ this.trackPresence(clientId, channel, payload?.state ?? {});
8771
+ return;
8772
+ case "presence_state":
8773
+ this.sendPresenceState(clientId, channel);
8774
+ return;
8775
+ }
8776
+ }
8777
+ /**
8778
+ * Decide whether a client may perform an action on a channel.
8779
+ *
8780
+ * **Membership is the floor.** Reading a channel's presence roster, replaying
8781
+ * its retained history and broadcasting into it all require that this client
8782
+ * has joined it. That is a low bar — joining is open to anyone who can name
8783
+ * the channel — but it is not the bar that was there before, which was none
8784
+ * at all: `channel_history` and `presence_state` answered any socket about
8785
+ * any channel, and a broadcast fanned out to members the sender had never
8786
+ * joined. Two internal tables (`rebase.channel_presence`,
8787
+ * `rebase.channel_messages`) are held outside RLS on the strength of this
8788
+ * check, so it fails closed: an authorizer that throws refuses the frame.
8789
+ *
8790
+ * Anything richer than membership belongs in a {@link ChannelAuthorizer};
8791
+ * this method is where it is consulted, and the only place.
8792
+ */
8793
+ authorizeChannelAction(clientId, channel, action, authContext) {
8794
+ if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
8795
+ this.denyChannelAction(clientId, channel, action, "not a member of the channel");
8796
+ return false;
8797
+ }
8798
+ const authorizer = this.channelAuthorizer;
8799
+ if (!authorizer) return true;
8800
+ let verdict;
8801
+ try {
8802
+ verdict = authorizer({
8803
+ channel,
8804
+ action,
8805
+ clientId,
8806
+ user: authContext
8807
+ });
8808
+ } catch (error) {
8809
+ logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
8810
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
8811
+ return false;
8812
+ }
8813
+ if (typeof verdict === "boolean") {
8814
+ if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
8815
+ return verdict;
8816
+ }
8817
+ return verdict.then((ok) => {
8818
+ if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
8819
+ return ok;
8820
+ }, (error) => {
8821
+ logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
8822
+ this.denyChannelAction(clientId, channel, action, "channel authorization failed");
8823
+ return false;
8824
+ });
8825
+ }
8826
+ /** Tell the client why its channel frame went nowhere, and say so in the log. */
8827
+ denyChannelAction(clientId, channel, action, reason) {
8828
+ this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
8829
+ this.sendError(clientId, `Refused ${action} on channel "${channel}": ${reason}`, void 0, "CHANNEL_FORBIDDEN");
8830
+ }
8200
8831
  /** Join a broadcast channel */
8201
8832
  joinChannel(clientId, channel) {
8202
8833
  if (!this.channels.has(channel)) this.channels.set(channel, /* @__PURE__ */ new Set());
8203
8834
  this.channels.get(channel).add(clientId);
8835
+ this.warnIfMemoryBusOnMultiplePods();
8204
8836
  this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
8205
8837
  }
8838
+ /**
8839
+ * Say something the first time channels are used on a deployment that is
8840
+ * demonstrably multi-pod while the bus is still the in-memory default.
8841
+ *
8842
+ * Every other warning in this subsystem covers a *configured* bus failing —
8843
+ * the case where the operator already knew a bus mattered. The common
8844
+ * misconfiguration is the opposite one: scaled to two replicas, never
8845
+ * touched `realtime.bus`, and broadcast and presence quietly serve a
8846
+ * fraction of the room. The evidence is already in the process, so use it.
8847
+ */
8848
+ warnIfMemoryBusOnMultiplePods() {
8849
+ if (this.memoryBusWarned) return;
8850
+ if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
8851
+ this.memoryBusWarned = true;
8852
+ 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.");
8853
+ }
8206
8854
  /** Leave a broadcast channel */
8207
8855
  leaveChannel(clientId, channel) {
8208
8856
  const members = this.channels.get(channel);
@@ -8697,7 +9345,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8697
9345
  throw err;
8698
9346
  }
8699
9347
  this.cdcActive = true;
8700
- logger.info(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
9348
+ logger.debug(`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events (${this.cdcTableMap.size} mapped table key(s)).`);
8701
9349
  }
8702
9350
  /** Stop the CDC listener and clear its state. */
8703
9351
  async stopCdc() {
@@ -8880,6 +9528,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8880
9528
  try {
8881
9529
  const { sid, p, eid, db } = JSON.parse(msg.payload);
8882
9530
  if (sid === this.instanceId) return;
9531
+ this.foreignInstanceSeen = true;
8883
9532
  this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
8884
9533
  let refetchedRow = null;
8885
9534
  try {
@@ -9070,7 +9719,7 @@ function createBackupCron(config) {
9070
9719
  enabled: config.enabled ?? true,
9071
9720
  timeoutSeconds: 3600,
9072
9721
  async handler({ log }) {
9073
- const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-CD8o_1Sl.js").then((n) => n.r);
9722
+ const { createDump, pruneBackups, uploadBackup, validateDump } = await import("./backup-service-BH0Dzo_h.js").then((n) => n.r);
9074
9723
  const { destination } = config;
9075
9724
  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 }).`);
9076
9725
  log(`Starting backup of "${dbName}"…`);
@@ -9395,7 +10044,7 @@ function buildCollectionRegistry(schema) {
9395
10044
  const registry = new PostgresCollectionRegistry();
9396
10045
  if (schema.collections) {
9397
10046
  registry.registerMultiple(schema.collections);
9398
- logger.info(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
10047
+ logger.debug(`📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: [${registry.getCollections().map((c) => c.slug).join(", ")}]`);
9399
10048
  }
9400
10049
  if (schema.tables) Object.values(schema.tables).forEach((table) => {
9401
10050
  if (isTable(table)) registry.registerTable(table, getTableName(table));
@@ -9570,7 +10219,7 @@ async function probeAuthSchema(db, authSchema) {
9570
10219
  * When omitted, a default `rebase.users` table is created.
9571
10220
  */
9572
10221
  async function ensureAuthTablesExist(db, collection) {
9573
- logger.info("🔍 Checking auth tables...");
10222
+ logger.debug("🔍 Checking auth tables...");
9574
10223
  await assertAuthSchemaCompatible(db, resolveAuthSchema(collection));
9575
10224
  try {
9576
10225
  let usersTableName = "\"rebase\".\"users\"";
@@ -9585,7 +10234,7 @@ async function ensureAuthTablesExist(db, collection) {
9585
10234
  if (idProp) {
9586
10235
  const isId = "isId" in idProp ? idProp.isId : void 0;
9587
10236
  if (isId === "uuid") userIdType = "UUID";
9588
- else if (isId === "autoincrement") userIdType = "INTEGER";
10237
+ else if (isId === "increment") userIdType = "INTEGER";
9589
10238
  }
9590
10239
  }
9591
10240
  try {
@@ -9601,7 +10250,7 @@ async function ensureAuthTablesExist(db, collection) {
9601
10250
  if (dbType === "UUID") userIdType = "UUID";
9602
10251
  else if (dbType === "INTEGER" || dbType === "SMALLINT" || dbType === "BIGINT") userIdType = "INTEGER";
9603
10252
  else userIdType = "TEXT";
9604
- logger.info(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
10253
+ logger.debug(`✨ Detected ${usersTableName}.id type from database: ${dbType}. Using user_id type: ${userIdType}`);
9605
10254
  }
9606
10255
  } catch (err) {
9607
10256
  logger.warn(`⚠️ Failed to introspect ${usersTableName}.id type from database, falling back to config type: ${userIdType}`, { error: err });
@@ -9618,43 +10267,42 @@ async function ensureAuthTablesExist(db, collection) {
9618
10267
  const emailLengthConstraint = `"${authIdentifier("email_length_check")}"`;
9619
10268
  const emailLowerUniqueIndex = authIdentifier("email_lower_key");
9620
10269
  const verificationTokenIndex = authIdentifier("email_verification_token_idx");
10270
+ 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 ");
9621
10271
  await db.execute(sql`
9622
10272
  CREATE TABLE IF NOT EXISTS ${sql.raw(usersTableName)} (
9623
10273
  id ${sql.raw(userIdType)} PRIMARY KEY ${sql.raw(idDefault)},
9624
- email TEXT NOT NULL CONSTRAINT ${sql.raw(emailLengthConstraint)} CHECK (length(email) <= 320),
9625
- display_name TEXT,
9626
- photo_url TEXT,
9627
- roles TEXT[] DEFAULT '{}' NOT NULL,
9628
- password_hash TEXT,
9629
- email_verified BOOLEAN DEFAULT FALSE NOT NULL,
9630
- email_verification_token TEXT,
9631
- email_verification_sent_at TIMESTAMP WITH TIME ZONE,
9632
- is_anonymous BOOLEAN DEFAULT FALSE NOT NULL,
9633
- metadata JSONB DEFAULT '{}' NOT NULL,
9634
- tokens_valid_after TIMESTAMP WITH TIME ZONE,
9635
- created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
9636
- updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
10274
+ ${sql.raw(usersColumnDdl)}
9637
10275
  )
9638
10276
  `);
9639
- await db.execute(sql`
9640
- CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
9641
- BEGIN
9642
- IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
9643
- NEW.uid := NEW.user_id;
9644
- ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
9645
- NEW.user_id := NEW.uid;
9646
- END IF;
9647
- RETURN NEW;
9648
- END $$ LANGUAGE plpgsql
9649
- `);
9650
- for (const authTable of [
10277
+ const legacyFkTables = [
9651
10278
  "user_identities",
9652
10279
  "refresh_tokens",
9653
10280
  "password_reset_tokens",
9654
10281
  "magic_link_tokens",
9655
10282
  "mfa_factors",
9656
10283
  "recovery_codes"
9657
- ]) {
10284
+ ];
10285
+ const legacyFkTableList = legacyFkTables.map((t) => `'${t}'`).join(", ");
10286
+ const legacyUserIdPresent = await db.execute(sql`
10287
+ SELECT 1
10288
+ FROM information_schema.columns
10289
+ WHERE table_schema = ${authSchema}
10290
+ AND table_name IN (${sql.raw(legacyFkTableList)})
10291
+ AND column_name = 'user_id'
10292
+ LIMIT 1
10293
+ `);
10294
+ if (legacyUserIdPresent.rows.length > 0) await db.execute(sql`
10295
+ CREATE OR REPLACE FUNCTION ${sql.raw(`"${authSchema}"`)}.sync_uid_user_id() RETURNS trigger AS $$
10296
+ BEGIN
10297
+ IF NEW.uid IS NULL AND NEW.user_id IS NOT NULL THEN
10298
+ NEW.uid := NEW.user_id;
10299
+ ELSIF NEW.user_id IS NULL AND NEW.uid IS NOT NULL THEN
10300
+ NEW.user_id := NEW.uid;
10301
+ END IF;
10302
+ RETURN NEW;
10303
+ END $$ LANGUAGE plpgsql
10304
+ `);
10305
+ for (const authTable of legacyUserIdPresent.rows.length > 0 ? legacyFkTables : []) {
9658
10306
  const qualified = `"${authSchema}"."${authTable}"`;
9659
10307
  await db.execute(sql`
9660
10308
  DO $$
@@ -9775,54 +10423,50 @@ async function ensureAuthTablesExist(db, collection) {
9775
10423
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
9776
10424
  )
9777
10425
  `);
9778
- await db.execute(sql`CREATE SCHEMA IF NOT EXISTS auth`);
9779
10426
  await db.transaction(async (tx) => {
9780
10427
  await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext('rebase_auth_functions_init'))`);
9781
- await tx.execute(sql`
9782
- CREATE OR REPLACE FUNCTION auth.uid() RETURNS text AS $$
9783
- SELECT COALESCE(
9784
- NULLIF(current_setting('app.uid', true), ''),
9785
- NULLIF(current_setting('app.user_id', true), '')
9786
- );
9787
- $$ LANGUAGE sql STABLE
9788
- `);
9789
- await tx.execute(sql`
9790
- CREATE OR REPLACE FUNCTION auth.jwt() RETURNS jsonb AS $$
9791
- SELECT COALESCE(
9792
- NULLIF(current_setting('app.jwt', true), ''),
9793
- '{}'
9794
- )::jsonb;
9795
- $$ LANGUAGE sql STABLE
9796
- `);
9797
- await tx.execute(sql`
9798
- CREATE OR REPLACE FUNCTION auth.roles() RETURNS text AS $$
9799
- SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
9800
- $$ LANGUAGE sql STABLE
9801
- `);
10428
+ for (const statement of RLS_BOOTSTRAP_STATEMENTS) await tx.execute(sql.raw(statement));
9802
10429
  });
9803
- for (const columnDef of [
9804
- "display_name TEXT",
9805
- "photo_url TEXT",
9806
- "roles TEXT[] DEFAULT '{}' NOT NULL",
9807
- "password_hash TEXT",
9808
- "email_verified BOOLEAN DEFAULT FALSE NOT NULL",
9809
- "email_verification_token TEXT",
9810
- "email_verification_sent_at TIMESTAMP WITH TIME ZONE",
9811
- "is_anonymous BOOLEAN DEFAULT FALSE NOT NULL",
9812
- "metadata JSONB DEFAULT '{}' NOT NULL",
9813
- "tokens_valid_after TIMESTAMP WITH TIME ZONE",
9814
- "created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL",
9815
- "updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL"
9816
- ]) await db.execute(sql`
10430
+ for (const spec of AUTH_USERS_COLUMNS) {
10431
+ if (spec.column === "email") continue;
10432
+ await db.execute(sql`
9817
10433
  ALTER TABLE ${sql.raw(usersTableName)}
9818
- ADD COLUMN IF NOT EXISTS ${sql.raw(columnDef)}
10434
+ ADD COLUMN IF NOT EXISTS ${sql.raw(`${spec.column} ${authUsersColumnSql(spec)}`)}
9819
10435
  `);
9820
- const usersColumns = await db.execute(sql`
9821
- SELECT column_name, data_type
10436
+ }
10437
+ const usersColumnRows = (await db.execute(sql`
10438
+ SELECT column_name, data_type, is_nullable, column_default
9822
10439
  FROM information_schema.columns
9823
10440
  WHERE table_schema = ${usersSchema} AND table_name = ${resolvedTable}
9824
- `);
9825
- const usersColumnTypes = new Map(usersColumns.rows.map((row) => [row.column_name, row.data_type]));
10441
+ `)).rows;
10442
+ const usersColumnTypes = new Map(usersColumnRows.map((row) => [row.column_name, row.data_type]));
10443
+ const usersColumnState = new Map(usersColumnRows.map((row) => [row.column_name, row]));
10444
+ for (const spec of AUTH_USERS_COLUMNS) {
10445
+ const state = usersColumnState.get(spec.column);
10446
+ if (!state) continue;
10447
+ if (spec.default !== void 0 && state.column_default === null) {
10448
+ await db.execute(sql`
10449
+ ALTER TABLE ${sql.raw(usersTableName)}
10450
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET DEFAULT ${sql.raw(spec.default)}
10451
+ `);
10452
+ logger.info(`🔧 Restored the default on ${usersTableName}.${spec.column}`);
10453
+ }
10454
+ if (!spec.notNull || state.is_nullable !== "YES") continue;
10455
+ if (spec.default !== void 0) await db.execute(sql`
10456
+ UPDATE ${sql.raw(usersTableName)}
10457
+ SET ${sql.raw(`"${spec.column}"`)} = ${sql.raw(spec.default)}
10458
+ WHERE ${sql.raw(`"${spec.column}"`)} IS NULL
10459
+ `);
10460
+ try {
10461
+ await db.execute(sql`
10462
+ ALTER TABLE ${sql.raw(usersTableName)}
10463
+ ALTER COLUMN ${sql.raw(`"${spec.column}"`)} SET NOT NULL
10464
+ `);
10465
+ logger.info(`🔧 Restored NOT NULL on ${usersTableName}.${spec.column}`);
10466
+ } catch (err) {
10467
+ 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)));
10468
+ }
10469
+ }
9826
10470
  for (const column of [
9827
10471
  "email",
9828
10472
  "display_name",
@@ -9893,7 +10537,7 @@ async function ensureAuthTablesExist(db, collection) {
9893
10537
  FROM information_schema.tables
9894
10538
  WHERE table_name = 'refresh_tokens'
9895
10539
  `)).rows;
9896
- logger.info(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
10540
+ logger.debug(`🔍 refresh_tokens reconcile: found ${found.length} table(s): ${found.map((r) => `"${r.table_schema}"."${r.table_name}"`).join(", ") || "(none)"}`);
9897
10541
  for (const { table_schema } of found) {
9898
10542
  const qualified = `"${table_schema}"."refresh_tokens"`;
9899
10543
  try {
@@ -9901,6 +10545,7 @@ async function ensureAuthTablesExist(db, collection) {
9901
10545
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS revoked BOOLEAN DEFAULT FALSE NOT NULL`);
9902
10546
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS rotated_at TIMESTAMP WITH TIME ZONE`);
9903
10547
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS session_started_at TIMESTAMP WITH TIME ZONE`);
10548
+ await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} ADD COLUMN IF NOT EXISTS aal TEXT`);
9904
10549
  await db.execute(sql`
9905
10550
  UPDATE ${sql.raw(qualified)}
9906
10551
  SET session_id = gen_random_uuid()::text
@@ -9920,7 +10565,7 @@ async function ensureAuthTablesExist(db, collection) {
9920
10565
  ON ${sql.raw(qualified)}(session_id)
9921
10566
  `);
9922
10567
  await db.execute(sql`ALTER TABLE ${sql.raw(qualified)} DROP CONSTRAINT IF EXISTS unique_device_session`);
9923
- logger.info(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
10568
+ logger.debug(`✅ refresh_tokens reconciled for session-scoped rotation: ${qualified}`);
9924
10569
  } catch (perTableError) {
9925
10570
  logger.warn(`⚠️ refresh_tokens reconcile failed for ${qualified}: ${perTableError instanceof Error ? perTableError.message : String(perTableError)}`);
9926
10571
  }
@@ -9963,6 +10608,7 @@ async function ensureAuthTablesExist(db, collection) {
9963
10608
  secret_encrypted TEXT NOT NULL,
9964
10609
  friendly_name TEXT,
9965
10610
  verified BOOLEAN DEFAULT FALSE,
10611
+ last_used_counter BIGINT,
9966
10612
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
9967
10613
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
9968
10614
  )
@@ -9978,6 +10624,7 @@ async function ensureAuthTablesExist(db, collection) {
9978
10624
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
9979
10625
  verified_at TIMESTAMP WITH TIME ZONE,
9980
10626
  ip_address TEXT,
10627
+ attempts INTEGER NOT NULL DEFAULT 0,
9981
10628
  expires_at TIMESTAMP WITH TIME ZONE NOT NULL
9982
10629
  )
9983
10630
  `);
@@ -9985,6 +10632,12 @@ async function ensureAuthTablesExist(db, collection) {
9985
10632
  CREATE INDEX IF NOT EXISTS idx_mfa_challenges_factor
9986
10633
  ON ${sql.raw(mfaChallengesTableName)}(factor_id)
9987
10634
  `);
10635
+ try {
10636
+ await db.execute(sql`ALTER TABLE ${sql.raw(mfaFactorsTableName)} ADD COLUMN IF NOT EXISTS last_used_counter BIGINT`);
10637
+ await db.execute(sql`ALTER TABLE ${sql.raw(mfaChallengesTableName)} ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 0`);
10638
+ } catch (mfaMigrationError) {
10639
+ logger.warn(`⚠️ MFA hardening columns skipped: ${mfaMigrationError instanceof Error ? mfaMigrationError.message : String(mfaMigrationError)}`);
10640
+ }
9988
10641
  await db.execute(sql`
9989
10642
  CREATE TABLE IF NOT EXISTS ${sql.raw(recoveryCodesTableName)} (
9990
10643
  id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
@@ -10004,10 +10657,12 @@ async function ensureAuthTablesExist(db, collection) {
10004
10657
  [authSchema, "user_identities"],
10005
10658
  [authSchema, "refresh_tokens"],
10006
10659
  [authSchema, "password_reset_tokens"],
10660
+ [authSchema, "magic_link_tokens"],
10007
10661
  [authSchema, "app_config"],
10008
10662
  [authSchema, "mfa_factors"],
10009
10663
  [authSchema, "mfa_challenges"],
10010
- [authSchema, "recovery_codes"]
10664
+ [authSchema, "recovery_codes"],
10665
+ [authSchema, "schema_meta"]
10011
10666
  ];
10012
10667
  for (const [schemaName, tableName] of authTablePairs) if ((await db.execute(sql`
10013
10668
  SELECT 1
@@ -10027,7 +10682,10 @@ async function ensureAuthTablesExist(db, collection) {
10027
10682
  logger.warn(`⚠️ Could not reconcile FORCE ROW LEVEL SECURITY on auth tables: ${rlsReconcileError instanceof Error ? rlsReconcileError.message : String(rlsReconcileError)}`);
10028
10683
  }
10029
10684
  await stampAuthSchemaVersion(db, authSchema);
10030
- logger.info("✅ Auth tables ready");
10685
+ await revokeInternalTableAccess(async (text) => {
10686
+ await db.execute(sql.raw(text));
10687
+ }, authSchema, { onError: (table, err) => logger.warn(`🔐 Could not revoke authenticated-role access to "${authSchema}"."${table}": ` + (err instanceof Error ? err.message : String(err))) });
10688
+ logger.debug("✅ Auth tables ready");
10031
10689
  } catch (error) {
10032
10690
  if (error instanceof AuthSchemaVersionError) throw error;
10033
10691
  logger.error("❌ Failed to create auth tables", { error });
@@ -10300,7 +10958,7 @@ var UserService = class {
10300
10958
  const conditions = [];
10301
10959
  if (roleId) conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
10302
10960
  if (search) {
10303
- const pattern = `%${search}%`;
10961
+ const pattern = `%${escapeLikePattern(search)}%`;
10304
10962
  conditions.push(sql`(${sql.raw(usersTableName)}.${sql.raw(emailColumn)} ILIKE ${pattern} OR ${sql.raw(usersTableName)}.${sql.raw(displayNameColumn)} ILIKE ${pattern})`);
10305
10963
  }
10306
10964
  const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``;
@@ -10476,7 +11134,8 @@ var RefreshTokenService = class {
10476
11134
  "sessionId",
10477
11135
  "rotatedAt",
10478
11136
  "revoked",
10479
- "sessionStartedAt"
11137
+ "sessionStartedAt",
11138
+ "aal"
10480
11139
  ]) if (this.has(optional)) selection[optional] = this.col(optional);
10481
11140
  return selection;
10482
11141
  }
@@ -10490,6 +11149,7 @@ var RefreshTokenService = class {
10490
11149
  };
10491
11150
  if (session && this.has("sessionId")) values.sessionId = session.id;
10492
11151
  if (session && this.has("sessionStartedAt")) values.sessionStartedAt = session.startedAt;
11152
+ if (session?.aal && this.has("aal")) values.aal = session.aal;
10493
11153
  await this.db.insert(this.refreshTokensTable).values(values);
10494
11154
  }
10495
11155
  async findByHash(tokenHash) {
@@ -10967,6 +11627,9 @@ var PostgresAuthRepository = class {
10967
11627
  async verifyMfaFactor(factorId) {
10968
11628
  return this.getMfaService().verifyMfaFactor(factorId);
10969
11629
  }
11630
+ async updateMfaFactorSecret(factorId, secretEncrypted) {
11631
+ return this.getMfaService().updateMfaFactorSecret(factorId, secretEncrypted);
11632
+ }
10970
11633
  async deleteMfaFactor(factorId, uid) {
10971
11634
  return this.getMfaService().deleteMfaFactor(factorId, uid);
10972
11635
  }
@@ -10994,6 +11657,12 @@ var PostgresAuthRepository = class {
10994
11657
  async hasVerifiedMfaFactors(uid) {
10995
11658
  return this.getMfaService().hasVerifiedMfaFactors(uid);
10996
11659
  }
11660
+ async claimMfaFactorCounter(factorId, counter) {
11661
+ return this.getMfaService().claimMfaFactorCounter(factorId, counter);
11662
+ }
11663
+ async recordMfaChallengeAttempt(challengeId) {
11664
+ return this.getMfaService().recordMfaChallengeAttempt(challengeId);
11665
+ }
10997
11666
  };
10998
11667
  /**
10999
11668
  * PostgreSQL implementation of MfaRepository.
@@ -11046,7 +11715,7 @@ var MfaService = class {
11046
11715
  async getMfaFactorById(factorId) {
11047
11716
  const tableName = this.qualify("mfa_factors");
11048
11717
  const result = await this.db.execute(sql`
11049
- SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
11718
+ SELECT id, uid, factor_type, secret_encrypted, friendly_name, verified, last_used_counter, created_at, updated_at
11050
11719
  FROM ${sql.raw(tableName)}
11051
11720
  WHERE id = ${factorId}
11052
11721
  `);
@@ -11059,10 +11728,29 @@ var MfaService = class {
11059
11728
  secretEncrypted: row.secret_encrypted,
11060
11729
  friendlyName: row.friendly_name ?? void 0,
11061
11730
  verified: row.verified,
11731
+ lastUsedCounter: row.last_used_counter === null || row.last_used_counter === void 0 ? null : Number(row.last_used_counter),
11062
11732
  createdAt: new Date(row.created_at),
11063
11733
  updatedAt: new Date(row.updated_at)
11064
11734
  };
11065
11735
  }
11736
+ /**
11737
+ * Spend a TOTP time step, once and only once.
11738
+ *
11739
+ * One statement: the `WHERE` is the check, the `UPDATE` is the act, and
11740
+ * `RETURNING` reports which of two concurrent requests carrying the same
11741
+ * six digits won. Reading the counter and then writing it would let both
11742
+ * pass — the exact replay this closes.
11743
+ */
11744
+ async claimMfaFactorCounter(factorId, counter) {
11745
+ const tableName = this.qualify("mfa_factors");
11746
+ return (await this.db.execute(sql`
11747
+ UPDATE ${sql.raw(tableName)}
11748
+ SET last_used_counter = ${counter}, updated_at = NOW()
11749
+ WHERE id = ${factorId}
11750
+ AND (last_used_counter IS NULL OR last_used_counter < ${counter})
11751
+ RETURNING id
11752
+ `)).rows.length > 0;
11753
+ }
11066
11754
  async verifyMfaFactor(factorId) {
11067
11755
  const tableName = this.qualify("mfa_factors");
11068
11756
  await this.db.execute(sql`
@@ -11071,6 +11759,14 @@ var MfaService = class {
11071
11759
  WHERE id = ${factorId}
11072
11760
  `);
11073
11761
  }
11762
+ async updateMfaFactorSecret(factorId, secretEncrypted) {
11763
+ const tableName = this.qualify("mfa_factors");
11764
+ await this.db.execute(sql`
11765
+ UPDATE ${sql.raw(tableName)}
11766
+ SET secret_encrypted = ${secretEncrypted}, updated_at = NOW()
11767
+ WHERE id = ${factorId}
11768
+ `);
11769
+ }
11074
11770
  async deleteMfaFactor(factorId, uid) {
11075
11771
  const tableName = this.qualify("mfa_factors");
11076
11772
  await this.db.execute(sql`
@@ -11097,7 +11793,7 @@ var MfaService = class {
11097
11793
  async getMfaChallengeById(challengeId) {
11098
11794
  const tableName = this.qualify("mfa_challenges");
11099
11795
  const result = await this.db.execute(sql`
11100
- SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
11796
+ SELECT id, factor_id, created_at, verified_at, ip_address, attempts, expires_at
11101
11797
  FROM ${sql.raw(tableName)}
11102
11798
  WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
11103
11799
  `);
@@ -11108,9 +11804,28 @@ var MfaService = class {
11108
11804
  factorId: row.factor_id,
11109
11805
  createdAt: new Date(row.created_at),
11110
11806
  verifiedAt: row.verified_at ? new Date(row.verified_at) : void 0,
11111
- ipAddress: row.ip_address ?? void 0
11807
+ ipAddress: row.ip_address ?? void 0,
11808
+ attempts: Number(row.attempts ?? 0)
11112
11809
  };
11113
11810
  }
11811
+ /**
11812
+ * Count one failed guess against a challenge and report the new total.
11813
+ *
11814
+ * Incremented in the database rather than in the route so that guesses
11815
+ * arriving in parallel — the shape any real brute-force takes — cannot
11816
+ * share a single increment.
11817
+ */
11818
+ async recordMfaChallengeAttempt(challengeId) {
11819
+ const tableName = this.qualify("mfa_challenges");
11820
+ const result = await this.db.execute(sql`
11821
+ UPDATE ${sql.raw(tableName)}
11822
+ SET attempts = attempts + 1
11823
+ WHERE id = ${challengeId}
11824
+ RETURNING attempts
11825
+ `);
11826
+ if (result.rows.length === 0) return 0;
11827
+ return Number(result.rows[0].attempts);
11828
+ }
11114
11829
  async verifyMfaChallenge(challengeId) {
11115
11830
  const tableName = this.qualify("mfa_challenges");
11116
11831
  await this.db.execute(sql`
@@ -11331,7 +12046,7 @@ function findChangedFields(oldValues, newValues) {
11331
12046
  * pattern as `ensureAuthTablesExist`.
11332
12047
  */
11333
12048
  async function ensureHistoryTableExists(db) {
11334
- logger.info("🔍 Checking row history table...");
12049
+ logger.debug("🔍 Checking row history table...");
11335
12050
  try {
11336
12051
  await db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
11337
12052
  await db.execute(sql`
@@ -11355,7 +12070,8 @@ async function ensureHistoryTableExists(db) {
11355
12070
  CREATE INDEX IF NOT EXISTS idx_history_time
11356
12071
  ON rebase.entity_history(table_name, entity_id, updated_at DESC)
11357
12072
  `);
11358
- logger.info(" Entity history table ready");
12073
+ await db.execute(sql.raw(revokeInternalTableSql("rebase", "entity_history")));
12074
+ logger.debug("✅ Entity history table ready");
11359
12075
  } catch (error) {
11360
12076
  logger.error("❌ Failed to create row history table", { error });
11361
12077
  logger.warn("⚠️ Continuing without creating history table.");
@@ -11669,6 +12385,7 @@ function idKindFor(col, propType) {
11669
12385
  }
11670
12386
  function buildProperties(meta, enumMap) {
11671
12387
  const properties = {};
12388
+ const takenKeys = /* @__PURE__ */ new Set();
11672
12389
  for (const col of meta.columns) {
11673
12390
  const isPk = meta.pks.includes(col.column_name);
11674
12391
  if (meta.fks.some((fk) => fk.column_name === col.column_name) && !isPk) continue;
@@ -11681,7 +12398,8 @@ function buildProperties(meta, enumMap) {
11681
12398
  columnName: col.column_name,
11682
12399
  type: propType
11683
12400
  };
11684
- const key = col.column_name;
12401
+ const key = firstFreeKey([toWireKey(col.column_name), col.column_name], takenKeys);
12402
+ takenKeys.add(key);
11685
12403
  if (isPk) property.isId = idKindFor(col, propType);
11686
12404
  else if (col.is_nullable === "NO" && col.column_default === null) property.validation = { required: true };
11687
12405
  if (isEnum && enumValues) property.enum = enumValues.map((value) => ({
@@ -11718,7 +12436,7 @@ function buildRelations(meta, slugByTable, collectionBySlug) {
11718
12436
  for (const fk of meta.fks) {
11719
12437
  const targetSlug = slugByTable.get(fk.foreign_table_name);
11720
12438
  if (!targetSlug) continue;
11721
- let key = fk.column_name.replace(/_id$/, "");
12439
+ let key = toWireKey(fk.column_name.replace(/_id$/, ""));
11722
12440
  if (meta.pks.includes(fk.column_name) && key === fk.column_name) key = fk.foreign_table_name;
11723
12441
  relations[key] = {
11724
12442
  name: humanize(key),
@@ -11975,6 +12693,43 @@ function resolveDriftCheckName(col, registeredTableNames) {
11975
12693
  return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
11976
12694
  }
11977
12695
  /**
12696
+ * Is this the local database `rebase init` scaffolds — i.e. the one case where
12697
+ * "you are connected as a superuser" is not news?
12698
+ *
12699
+ * The scaffold's own `docker-compose.yml` sets `POSTGRES_USER: rebase_app`,
12700
+ * which makes that role the cluster superuser, so the superuser advisory below
12701
+ * was the only WARN a brand-new project ever saw and it was about a decision
12702
+ * the tool had made for the developer.
12703
+ *
12704
+ * Of the two available fixes — provision a non-superuser table-owner role in
12705
+ * the scaffold, or recognise the local shape and stay quiet — this is the
12706
+ * second, because the first breaks the scaffold it is meant to improve: a
12707
+ * non-superuser owner cannot `CREATE EXTENSION` (search collections need
12708
+ * `pg_trgm`/`unaccent`, applied by `rebase db push` and again by the boot
12709
+ * schema-ensure), so the very first `pnpm run db:push` on a scaffolded project
12710
+ * with a search block would fail. Trading a working first run for a quieter log
12711
+ * line is the wrong trade.
12712
+ *
12713
+ * The condition is deliberately narrow — a *non-production* process talking to
12714
+ * a database on the loopback interface. A genuine production superuser
12715
+ * connection still warns, and so does a non-production process pointed at a
12716
+ * remote database (the usual "my dev machine writes to staging" mistake, where
12717
+ * the advisory is exactly right). NODE_ENV alone would not do: the scaffold
12718
+ * ships `NODE_ENV=development` and some deployments inherit it.
12719
+ */
12720
+ function isScaffoldedLocalDatabase(connectionString) {
12721
+ if (process.env.NODE_ENV === "production") return false;
12722
+ if (!connectionString) return false;
12723
+ let host;
12724
+ try {
12725
+ host = new URL(connectionString).hostname;
12726
+ } catch {
12727
+ return false;
12728
+ }
12729
+ const bare = host.replace(/^\[|\]$/g, "").toLowerCase();
12730
+ return bare === "localhost" || bare === "::1" || bare === "0.0.0.0" || bare === "" || /^127\./.test(bare) || bare.endsWith(".localhost");
12731
+ }
12732
+ /**
11978
12733
  * Default PostgreSQL bootstrapper.
11979
12734
  *
11980
12735
  * Use it to register Postgres with `initializeRebaseBackend()`:
@@ -12076,21 +12831,26 @@ function createPostgresBootstrapper(pgConfig) {
12076
12831
  const runSql = async (text) => {
12077
12832
  return (await schemaAwareDb.execute(sql.raw(text))).rows ?? [];
12078
12833
  };
12834
+ await warnOnRoleSchemaCollision(runSql);
12079
12835
  const posture = await detectConnectionPosture(runSql);
12080
12836
  if (posture.privileged) {
12081
12837
  await ensureAppRole(runSql, [
12082
12838
  "public",
12083
12839
  "rebase",
12084
- "auth",
12085
12840
  ...registry.getCollections().map((c) => c.schema).filter((s) => typeof s === "string")
12086
12841
  ]);
12087
12842
  driver.rlsUserRole = REBASE_USER_ROLE;
12088
12843
  realtimeService.rlsUserRole = REBASE_USER_ROLE;
12089
12844
  logger.info(`🔐 RLS enforcement active: authenticated requests run as "${REBASE_USER_ROLE}" (connection "${posture.role}" bypasses RLS: ${posture.superuser ? "superuser" : posture.bypassRLS ? "BYPASSRLS" : "table owner"})`);
12090
- if (posture.superuser || posture.bypassRLS) logger.warn(`⚠️ The database connection runs as ${posture.superuser ? "a superuser" : "a BYPASSRLS role"} ("${posture.role}"). User requests are isolated via SET LOCAL ROLE, but connect as a non-superuser table-owner role in production so the server/owner context is least-privilege.`);
12845
+ if (posture.superuser || posture.bypassRLS) {
12846
+ const message = `The database connection runs as ${posture.superuser ? "a superuser" : "a BYPASSRLS role"} ("${posture.role}"). User requests are isolated via SET LOCAL ROLE, but connect as a non-superuser table-owner role in production so the server/owner context is least-privilege.`;
12847
+ if (isScaffoldedLocalDatabase(pgConfig.connectionString)) logger.debug(`🔐 ${message}`);
12848
+ else logger.warn(`⚠️ ${message}`);
12849
+ }
12091
12850
  } else logger.info(`🔐 RLS enforcement: connection role "${posture.role}" is subject to RLS natively; no role switch needed.`);
12092
12851
  await validatePolicyPgRoles(runSql, registry.getCollections(), driver.rlsUserRole ?? posture.role);
12093
12852
  warnOnAnonymousGrants(registry.getCollections());
12853
+ warnOnLegacyRlsFunctions(registry.getCollections());
12094
12854
  }
12095
12855
  if (driver.branchService) try {
12096
12856
  await driver.branchService.ensureBranchMetadataTable();
@@ -12306,12 +13066,12 @@ function createPostgresBootstrapper(pgConfig) {
12306
13066
  */
12307
13067
  async ensureCollectionSchema(collections, driverResult, log) {
12308
13068
  const internals = driverResult.internals;
12309
- const { ensureCollectionTables } = await import("./ensure-collection-tables-BLIIACla.js");
13069
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-CbvaGuVn.js");
12310
13070
  const plan = await ensureCollectionTables({ async query(text) {
12311
13071
  const result = await internals.db.execute(sql.raw(text));
12312
13072
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
12313
13073
  } }, collections, log);
12314
- 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}`);
13074
+ 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}`);
12315
13075
  return { applied: plan.actions.length - plan.failures.length };
12316
13076
  },
12317
13077
  /**
@@ -12331,13 +13091,27 @@ function createPostgresBootstrapper(pgConfig) {
12331
13091
  */
12332
13092
  async ensureCollectionPolicies(collections, driverResult, log) {
12333
13093
  const internals = driverResult.internals;
12334
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-Bck0ky4u.js");
13094
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-8vuu-n4r.js");
12335
13095
  const outcome = await ensureCollectionPolicies({ async query(text) {
12336
13096
  const result = await internals.db.execute(sql.raw(text));
12337
13097
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
12338
13098
  } }, collections, log);
12339
13099
  for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
12340
- 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}`);
13100
+ for (const failure of outcome.failures) logger.warn(`🔐 [rls] Could not fully apply policies to "${failure.table}" — RLS is on, so it denies until this is resolved: ${failure.error}`);
13101
+ const unrevoked = outcome.unsecured.filter((u) => !u.grantWithdrawn);
13102
+ for (const u of outcome.unsecured.filter((u) => u.grantWithdrawn)) logger.error(`🔐 [rls] Could not enable row-level security on "${u.table}": ${u.error}. Its privileges have been revoked from ${REBASE_USER_ROLE}, so the table is unreachable rather than unprotected. Reads and writes to that collection will fail until RLS can be enabled.`);
13103
+ if (unrevoked.length > 0) throw new Error("Refusing to start: row-level security could not be enabled on " + unrevoked.map((u) => `"${u.table}" (${u.error})`).join(", ") + `, and the privileges granted to ${REBASE_USER_ROLE} could not be revoked either. The table would be served with no row filtering. Fix the database permissions (the connection role must own these tables, or be able to ALTER them) and boot again.`);
13104
+ try {
13105
+ const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-69hYT8nr.js").then((n) => n.n);
13106
+ await dropLegacyAuthSchema(async (text) => {
13107
+ return (await internals.db.execute(sql.raw(text))).rows ?? [];
13108
+ }, {
13109
+ info: (m) => logger.info(m),
13110
+ warn: (m) => logger.warn(m)
13111
+ });
13112
+ } catch (err) {
13113
+ logger.info("Left the legacy `auth` schema in place: " + (err instanceof Error ? err.message : String(err)));
13114
+ }
12341
13115
  return { applied: outcome.policiesApplied };
12342
13116
  },
12343
13117
  getAdmin(driverResult) {
@@ -12345,7 +13119,7 @@ function createPostgresBootstrapper(pgConfig) {
12345
13119
  },
12346
13120
  mountRoutes(app, basePath, driverResult) {},
12347
13121
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
12348
- const { createPostgresWebSocket } = await import("./websocket-B2LsrINK.js").then((n) => n.n);
13122
+ const { createPostgresWebSocket } = await import("./websocket-C8ZqVBiV.js").then((n) => n.n);
12349
13123
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
12350
13124
  }
12351
13125
  };
@@ -12385,6 +13159,6 @@ function createPostgresAdapter(pgConfig) {
12385
13159
  };
12386
13160
  }
12387
13161
  //#endregion
12388
- 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 };
13162
+ 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, isScaffoldedLocalDatabase, 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 };
12389
13163
 
12390
13164
  //# sourceMappingURL=index.es.js.map