@rebasepro/server-postgres 0.14.0 → 0.14.1-canary.g7e666eb

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 (32) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/auth/services.d.ts +10 -0
  3. package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
  4. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  5. package/dist/data_driver-ULAyJEi9.js.map +1 -1
  6. package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-BedO2aNX.js} +3 -3
  7. package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-BedO2aNX.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-B1qKdXA4.js} +2 -2
  9. package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-B1qKdXA4.js.map} +1 -1
  10. package/dist/index.es.js +220 -65
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
  13. package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
  14. package/dist/services/FetchService.d.ts +54 -5
  15. package/dist/services/RelationService.d.ts +3 -3
  16. package/dist/services/dataService.d.ts +6 -4
  17. package/dist/services/realtimeService.d.ts +54 -10
  18. package/dist/src-DCdn3Val.js.map +1 -1
  19. package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
  20. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  21. package/package.json +6 -6
  22. package/src/PostgresBackendDriver.ts +7 -3
  23. package/src/auth/services.ts +26 -5
  24. package/src/schema/generate-drizzle-schema-logic.ts +19 -1
  25. package/src/services/FetchService.ts +185 -63
  26. package/src/services/RelationService.ts +3 -3
  27. package/src/services/dataService.ts +6 -4
  28. package/src/services/pg-notify-listener.ts +14 -0
  29. package/src/services/realtimeService.ts +160 -40
  30. package/src/websocket.ts +44 -1
  31. package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
  32. package/dist/websocket-C8ZqVBiV.js.map +0 -1
package/dist/index.es.js CHANGED
@@ -3,16 +3,16 @@ 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
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";
6
+ import { $ as mergeDeep, A as fieldKeyForColumn, B as parseIdValues, C as getJunctionCollectionConfig, D as policyToPostgres, E as getEffectiveSecurityRules, F as getTableVarName, G as updateDateAutoValues, H as createRelationRef, I as resolveCollectionRelations, J as legacyForeignKeyName, K as firstFreeKey, L as buildCompositeId, M as getColumnName, N as getEnumVarName, O as securityRuleToConditions, P as getTableName$1, Q as isPrototypePollutingKey, R as getDeclaredPrimaryKeys, S as resolveStringColumnLength, T as resolveJunctionSpecs, U as createRelationRefWithData, V as sortCollectionsBySlug, W as normalizeToEntityRelation, X as toWireKey, Z as getPolicyNamesForRule, _ as OrderBySpecError, at as isManyToMany, b as CollectionRegistry, et as camelCase, g as buildSdkData, h as visibleColumnProjection, it as hasForeignKeyOnTarget, j as findRelation, l as buildSearchColumnSpec, ot as Vector, q as generateForeignKeyName, r as authUsersColumnSql, s as SEARCH_UNACCENT_FN, t as AUTH_USERS_COLUMNS, tt as toSnakeCase, u as hiddenColumnsOption, v as normalizeDriverOrderBy, w as getJunctionSecurityRules, x as relationalCollections, y as parseOrderBySpecStrict, z as isAddressableId } from "./auth-users-columns-C-FDnL_e.js";
7
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";
8
+ import { t as createPostgresWebSocket } from "./websocket-D2jXv0Ds.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-gUNDfm7l.js";
10
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
11
  import { t as RLS_BOOTSTRAP_STATEMENTS } from "./rls-bootstrap-sql-69hYT8nr.js";
12
12
  import { Client, Pool } from "pg";
13
13
  import { drizzle } from "drizzle-orm/node-postgres";
14
14
  import { ApiError, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
15
- import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
15
+ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, ilike, inArray, isNotNull, isNull, isTable, lt, notInArray, or, relations, sql } from "drizzle-orm";
16
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";
17
17
  import fs, { promises } from "fs";
18
18
  import path from "path";
@@ -3273,6 +3273,43 @@ var FetchService = class FetchService {
3273
3273
  }
3274
3274
  return this.resolveOrderByField(table, orderBy, collection);
3275
3275
  }
3276
+ /**
3277
+ * Resolve every sort key to the expression it orders by, in order of
3278
+ * significance.
3279
+ *
3280
+ * A key that resolves to nothing is dropped rather than skipping the rest:
3281
+ * `resolveOrderByField` only *returns* undefined under the lenient
3282
+ * unknown-field mode, where dropping is the configured answer, and dropping
3283
+ * one key of several still honours the ones that did resolve.
3284
+ */
3285
+ resolveOrderKeys(table, keys, collection, searchString) {
3286
+ const resolved = [];
3287
+ for (const [field, direction] of keys) {
3288
+ const target = this.resolveOrderTarget(table, field, collection, searchString);
3289
+ if (target) resolved.push({
3290
+ field,
3291
+ direction,
3292
+ target
3293
+ });
3294
+ }
3295
+ return resolved;
3296
+ }
3297
+ /**
3298
+ * The full `ORDER BY`: the caller's keys, then the id.
3299
+ *
3300
+ * The id is always last and always descending. It is not decoration — it is
3301
+ * what makes the ordering *total*, and a cursor over a non-total order
3302
+ * repeats and skips rows among the ties. Every keyset comparison built by
3303
+ * {@link buildCursorConditions} ends on the same `id DESC`, and the two have
3304
+ * to agree: they did not, and an ascending sort paged with `id >` against an
3305
+ * `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
3306
+ * every page after the first.
3307
+ */
3308
+ buildOrderExpressions(keys, idField) {
3309
+ const expressions = keys.map(({ direction, target }) => direction === "asc" ? asc(target) : desc(target));
3310
+ expressions.push(desc(idField));
3311
+ return expressions;
3312
+ }
3276
3313
  resolveOrderByField(table, orderBy, collection) {
3277
3314
  const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
3278
3315
  const direct = columnAt(orderBy);
@@ -3434,14 +3471,9 @@ var FetchService = class FetchService {
3434
3471
  if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
3435
3472
  }
3436
3473
  if (allConditions.length > 0) queryOpts.where = and(...allConditions);
3437
- const orderExpressions = [];
3438
- if (options.orderBy) {
3439
- const collection = getCollectionByPath(collectionPath, this.registry);
3440
- const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3441
- if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3442
- }
3443
- orderExpressions.push(desc(idField));
3444
- if (orderExpressions.length > 0) queryOpts.orderBy = orderExpressions;
3474
+ const orderKeys = normalizeDriverOrderBy(options.orderBy, options.order);
3475
+ const resolvedOrder = orderKeys ? this.resolveOrderKeys(table, orderKeys, getCollectionByPath(collectionPath, this.registry), options.searchString) : [];
3476
+ queryOpts.orderBy = this.buildOrderExpressions(resolvedOrder, idField);
3445
3477
  const limitValue = options.searchString ? options.limit || 50 : options.limit;
3446
3478
  if (limitValue) queryOpts.limit = limitValue;
3447
3479
  if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
@@ -3449,19 +3481,25 @@ var FetchService = class FetchService {
3449
3481
  }
3450
3482
  /**
3451
3483
  * Extract cursor pagination conditions from startAfter options.
3484
+ *
3485
+ * "Every row that sorts after this one", written out as a comparison over
3486
+ * the same keys the `ORDER BY` uses and ending on the same `id DESC`. With
3487
+ * one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
3488
+ * several it nests, each key's tie handing the decision to the next.
3452
3489
  */
3453
3490
  buildCursorConditions(table, idField, idInfo, options, collectionPath) {
3454
3491
  if (!options.startAfter) return [];
3455
3492
  const cursor = options.startAfter;
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 });
3493
+ const keys = normalizeDriverOrderBy(options.orderBy, options.order);
3494
+ if (keys) {
3495
+ if (keys.some(([field]) => field === 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 });
3458
3496
  const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
3459
- const orderByField = this.resolveOrderByField(table, options.orderBy, collection);
3460
- if (orderByField) {
3461
- const startAfterOrderValue = cursor.values?.[options.orderBy] ?? cursor[options.orderBy];
3462
- const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
3463
- if (startAfterOrderValue !== void 0 && startAfterId !== void 0) if (options.order === "asc") return [or(gt(orderByField, startAfterOrderValue), and(eq(orderByField, startAfterOrderValue), gt(idField, startAfterId)))];
3464
- else return [or(lt(orderByField, startAfterOrderValue), and(eq(orderByField, startAfterOrderValue), lt(idField, startAfterId)))];
3497
+ const resolved = this.resolveOrderKeys(table, keys, collection);
3498
+ const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
3499
+ if (resolved.length > 0 && startAfterId !== void 0) {
3500
+ const cursorValues = cursor.values;
3501
+ const values = resolved.map(({ field }) => cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
3502
+ if (values.every((value) => value !== void 0)) return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
3465
3503
  }
3466
3504
  } else {
3467
3505
  const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
@@ -3470,6 +3508,28 @@ var FetchService = class FetchService {
3470
3508
  return [];
3471
3509
  }
3472
3510
  /**
3511
+ * "Sorts strictly after the cursor row", over `keys` and then the id.
3512
+ *
3513
+ * Built by recursion rather than as a row-value comparison — `(a, b) > (x, y)`
3514
+ * would be shorter, but it is only correct when every key runs the same
3515
+ * direction, and `roles ASC, created_at DESC` is exactly the case this
3516
+ * exists to serve.
3517
+ *
3518
+ * NULLs are compared by the rule Postgres sorts them under (last ascending,
3519
+ * first descending) rather than by `>`/`<`, which answer *unknown* against
3520
+ * NULL and therefore match nothing. Ordering by a nullable column and paging
3521
+ * used to drop every row whose sort value was NULL from page two onward.
3522
+ */
3523
+ buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
3524
+ if (index >= keys.length) return lt(idField, cursorId);
3525
+ const { direction } = keys[index];
3526
+ const target = keys[index].target;
3527
+ const value = values[index];
3528
+ const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
3529
+ if (value === null) return direction === "asc" ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
3530
+ return direction === "asc" ? or(gt(target, value), isNull(target), and(eq(target, value), rest)) : or(lt(target, value), and(eq(target, value), rest));
3531
+ }
3532
+ /**
3473
3533
  * Compile "rows reachable from this parent" into a `WHERE` condition on the
3474
3534
  * target table, so a nested listing can run as an ordinary collection query.
3475
3535
  */
@@ -3632,14 +3692,8 @@ var FetchService = class FetchService {
3632
3692
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3633
3693
  if (finalCondition) query = query.where(finalCondition);
3634
3694
  }
3635
- const orderExpressions = [];
3636
- if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3637
- else if (options.orderBy) {
3638
- const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3639
- if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3640
- }
3641
- orderExpressions.push(desc(idField));
3642
- if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
3695
+ const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
3696
+ query = query.orderBy(...orderExpressions);
3643
3697
  if (options.startAfter) {
3644
3698
  const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
3645
3699
  if (cursorConditions.length > 0) {
@@ -3764,6 +3818,10 @@ var FetchService = class FetchService {
3764
3818
  const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
3765
3819
  if (logicalCondition) allConditions.push(logicalCondition);
3766
3820
  }
3821
+ if (options.vectorSearch) {
3822
+ const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
3823
+ if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
3824
+ }
3767
3825
  if (allConditions.length > 0) {
3768
3826
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3769
3827
  if (finalCondition) query = query.where(finalCondition);
@@ -3970,14 +4028,8 @@ var FetchService = class FetchService {
3970
4028
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
3971
4029
  if (finalCondition) query = query.where(finalCondition);
3972
4030
  }
3973
- const orderExpressions = [];
3974
- if (vectorMeta) orderExpressions.push(asc(vectorMeta.orderBy));
3975
- else if (options.orderBy) {
3976
- const orderByField = this.resolveOrderTarget(table, options.orderBy, collection, options.searchString);
3977
- if (orderByField) orderExpressions.push(options.order === "asc" ? asc(orderByField) : desc(orderByField));
3978
- }
3979
- orderExpressions.push(desc(idField));
3980
- if (orderExpressions.length > 0) query = query.orderBy(...orderExpressions);
4031
+ const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
4032
+ query = query.orderBy(...orderExpressions);
3981
4033
  const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
3982
4034
  if (limitValue) query = query.limit(limitValue);
3983
4035
  if (options.offset && options.offset > 0) query = query.offset(options.offset);
@@ -5605,11 +5657,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
5605
5657
  async checkUniqueField(path, name, value, id, collection) {
5606
5658
  return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
5607
5659
  }
5608
- async count({ path, collection, filter, logical, searchString }) {
5660
+ async count({ path, collection, filter, logical, searchString, vectorSearch }) {
5609
5661
  return this.dataService.count(path, {
5610
5662
  filter,
5611
5663
  logical,
5612
- searchString
5664
+ searchString,
5665
+ vectorSearch
5613
5666
  });
5614
5667
  }
5615
5668
  getTargetDb(databaseName) {
@@ -6464,8 +6517,25 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
6464
6517
  };
6465
6518
  /**
6466
6519
  * Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
6520
+ *
6521
+ * The clause is SQL being written into a TypeScript file, so it has to survive
6522
+ * being read back as a template literal. Three characters do not:
6523
+ *
6524
+ * - `` ` `` closes the template early, and the rest of the clause becomes code.
6525
+ * - `${` opens an interpolation — the file stops compiling, or worse, compiles
6526
+ * against whatever identifier happens to be in scope.
6527
+ * - `\` is an escape, and Drizzle's `sql` tag reads the *cooked* strings, not
6528
+ * `.raw`. So a policy written as `email ~ '^admin\.user@corp\.com$'` reaches
6529
+ * the database as `^admin.user@corp.com$`, where every `\.` now matches any
6530
+ * character. A `USING` clause is a security boundary and that one silently
6531
+ * widened it — the SQL file emitted by the DDL generator kept the backslashes
6532
+ * while this path dropped them, so the two disagreed about who could read the
6533
+ * table.
6534
+ *
6535
+ * Escaping here rather than in the compiler: the clause is correct SQL, and it
6536
+ * is only this destination that has an opinion about backslashes.
6467
6537
  */
6468
- var wrapSql = (clause) => `sql\`${clause}\``;
6538
+ var wrapSql = (clause) => `sql\`${clause.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``;
6469
6539
  var generatePolicyCode = (collection, rule, index, resolveCollection) => {
6470
6540
  const tableName = getTableName$1(collection);
6471
6541
  const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
@@ -7112,8 +7182,10 @@ var PgNotifyListener = class {
7112
7182
  }
7113
7183
  async connect({ initial = false } = {}) {
7114
7184
  const { connectionString, channel, onPayload, logLabel } = this.options;
7185
+ let pending;
7115
7186
  try {
7116
7187
  const client = new Client({ connectionString });
7188
+ pending = client;
7117
7189
  client.on("error", (err) => {
7118
7190
  logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
7119
7191
  this.scheduleReconnect();
@@ -7131,8 +7203,12 @@ var PgNotifyListener = class {
7131
7203
  await client.connect();
7132
7204
  await client.query(`LISTEN ${channel}`);
7133
7205
  this.client = client;
7206
+ pending = void 0;
7134
7207
  logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
7135
7208
  } catch (err) {
7209
+ if (pending) try {
7210
+ await pending.end();
7211
+ } catch {}
7136
7212
  if (initial) throw err;
7137
7213
  logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
7138
7214
  this.scheduleReconnect();
@@ -8098,9 +8174,40 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8098
8174
  get subscriptions() {
8099
8175
  return this._subscriptions;
8100
8176
  }
8177
+ /**
8178
+ * Claim a delivery slot for a subscription, before doing the work.
8179
+ *
8180
+ * Returns the check to run immediately before delivering. It refuses in
8181
+ * three cases, all of which used to deliver:
8182
+ *
8183
+ * - **Out of order.** A newer refetch has already delivered, so this one is
8184
+ * stale — the subscriber would go back to the state before the change.
8185
+ * - **Unsubscribed.** The subscription was cancelled while the fetch was in
8186
+ * flight. The `has(subscriptionId)` check the debounced refetches ran
8187
+ * *before* the await cannot answer this; only a check after it can.
8188
+ * - **Replaced.** The same id can name a *different* subscription by the
8189
+ * time a fetch lands — a re-subscribe overwrites the map entry, and the
8190
+ * old filter's rows would be delivered to the new subscriber.
8191
+ *
8192
+ * The last two are identity, not presence: the map has to still hold *this
8193
+ * exact object*, not merely something under this id.
8194
+ */
8195
+ beginDelivery(subscriptionId, subscription) {
8196
+ const seq = ++subscription.started;
8197
+ return () => {
8198
+ if (this._subscriptions.get(subscriptionId) !== subscription) return false;
8199
+ if (seq <= subscription.delivered) return false;
8200
+ subscription.delivered = seq;
8201
+ return true;
8202
+ };
8203
+ }
8101
8204
  registerDataDriverSubscription(subscriptionId, subscription) {
8102
8205
  this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
8103
- this._subscriptions.set(subscriptionId, subscription);
8206
+ this._subscriptions.set(subscriptionId, {
8207
+ ...subscription,
8208
+ started: 0,
8209
+ delivered: 0
8210
+ });
8104
8211
  }
8105
8212
  addSubscriptionCallback(subscriptionId, callback) {
8106
8213
  this.debugLog("📋 [RealtimeService] Adding callback for subscription:", subscriptionId);
@@ -8127,7 +8234,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8127
8234
  databaseId: config.databaseId,
8128
8235
  searchString: config.searchString,
8129
8236
  searchExplain: config.searchExplain
8130
- }
8237
+ },
8238
+ started: 0,
8239
+ delivered: 0
8131
8240
  });
8132
8241
  if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
8133
8242
  }
@@ -8139,7 +8248,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8139
8248
  clientId: config.clientId,
8140
8249
  type: "single",
8141
8250
  path: config.path,
8142
- id: config.id
8251
+ id: config.id,
8252
+ started: 0,
8253
+ delivered: 0
8143
8254
  });
8144
8255
  if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
8145
8256
  }
@@ -8242,14 +8353,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8242
8353
  this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
8243
8354
  return;
8244
8355
  }
8245
- this._subscriptions.set(subscriptionId, {
8356
+ let orderBy;
8357
+ try {
8358
+ orderBy = parseOrderBySpecStrict(request.orderBy, request.order);
8359
+ } catch (e) {
8360
+ if (!(e instanceof OrderBySpecError)) throw e;
8361
+ logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
8362
+ this.sendError(clientId, e.message, subscriptionId, e.code);
8363
+ return;
8364
+ }
8365
+ const subscription = {
8246
8366
  clientId,
8247
8367
  type: "collection",
8248
8368
  path: request.path,
8249
8369
  collectionRequest: {
8250
8370
  filter: request.filter,
8251
8371
  logical: request.logical,
8252
- orderBy: request.orderBy,
8372
+ orderBy,
8253
8373
  order: request.order,
8254
8374
  limit: boundedLimit,
8255
8375
  offset: request.offset,
@@ -8258,10 +8378,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8258
8378
  searchString: request.searchString,
8259
8379
  searchExplain: request.searchExplain
8260
8380
  },
8261
- authContext
8262
- });
8263
- const rows = await this.fetchCollectionWithAuth(request.path, this._subscriptions.get(subscriptionId).collectionRequest, authContext);
8264
- this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
8381
+ authContext,
8382
+ started: 0,
8383
+ delivered: 0
8384
+ };
8385
+ this._subscriptions.set(subscriptionId, subscription);
8386
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
8387
+ const rows = await this.fetchCollectionWithAuth(request.path, subscription.collectionRequest, authContext);
8388
+ if (canDeliver()) this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
8265
8389
  } catch (error) {
8266
8390
  const sanitized = sanitizeErrorForClient(error, request.path);
8267
8391
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -8277,15 +8401,19 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8277
8401
  this.sendError(clientId, msg, subscriptionId);
8278
8402
  return;
8279
8403
  }
8280
- this._subscriptions.set(subscriptionId, {
8404
+ const subscription = {
8281
8405
  clientId,
8282
8406
  type: "single",
8283
8407
  path: request.path,
8284
8408
  id: request.id,
8285
- authContext
8286
- });
8409
+ authContext,
8410
+ started: 0,
8411
+ delivered: 0
8412
+ };
8413
+ this._subscriptions.set(subscriptionId, subscription);
8414
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
8287
8415
  const row = await this.fetchEntityWithAuth(request.path, String(request.id), authContext);
8288
- this.sendSingleUpdate(clientId, subscriptionId, row || null);
8416
+ if (canDeliver()) this.sendSingleUpdate(clientId, subscriptionId, row || null);
8289
8417
  } catch (error) {
8290
8418
  const sanitized = sanitizeErrorForClient(error, request.path);
8291
8419
  this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -8416,10 +8544,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8416
8544
  if (existing) clearTimeout(existing);
8417
8545
  this.refetchTimers.set(timerKey, setTimeout(async () => {
8418
8546
  this.refetchTimers.delete(timerKey);
8419
- if (!this._subscriptions.has(subscriptionId)) return;
8547
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
8548
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
8420
8549
  try {
8421
8550
  const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
8422
- this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
8551
+ if (canDeliver()) this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
8423
8552
  } catch (error) {
8424
8553
  const sanitized = sanitizeErrorForClient(error, notifyPath);
8425
8554
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -8435,9 +8564,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8435
8564
  if (existing) clearTimeout(existing);
8436
8565
  this.refetchTimers.set(timerKey, setTimeout(async () => {
8437
8566
  this.refetchTimers.delete(timerKey);
8438
- if (!this._subscriptions.has(subscriptionId)) return;
8567
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
8568
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
8439
8569
  try {
8440
- callback(await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext));
8570
+ const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
8571
+ if (canDeliver()) callback(rows);
8441
8572
  } catch (error) {
8442
8573
  logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error });
8443
8574
  }
@@ -8553,10 +8684,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8553
8684
  if (existing) clearTimeout(existing);
8554
8685
  this.refetchTimers.set(timerKey, setTimeout(async () => {
8555
8686
  this.refetchTimers.delete(timerKey);
8556
- if (!this._subscriptions.has(subscriptionId)) return;
8687
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
8688
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
8557
8689
  try {
8558
8690
  const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
8559
- this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
8691
+ if (canDeliver()) this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
8560
8692
  } catch (error) {
8561
8693
  const sanitized = sanitizeErrorForClient(error, notifyPath);
8562
8694
  this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
@@ -8572,9 +8704,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
8572
8704
  if (existing) clearTimeout(existing);
8573
8705
  this.refetchTimers.set(timerKey, setTimeout(async () => {
8574
8706
  this.refetchTimers.delete(timerKey);
8575
- if (!this._subscriptions.has(subscriptionId)) return;
8707
+ if (this._subscriptions.get(subscriptionId) !== subscription) return;
8708
+ const canDeliver = this.beginDelivery(subscriptionId, subscription);
8576
8709
  try {
8577
- callback(await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext) || null);
8710
+ const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
8711
+ if (canDeliver()) callback(row || null);
8578
8712
  } catch (error) {
8579
8713
  logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error });
8580
8714
  }
@@ -9511,8 +9645,10 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9511
9645
  */
9512
9646
  async connectListenClient() {
9513
9647
  if (!this.listenConnectionString) return;
9648
+ let pending;
9514
9649
  try {
9515
9650
  const client = new Client({ connectionString: this.listenConnectionString });
9651
+ pending = client;
9516
9652
  client.on("error", (err) => {
9517
9653
  logger.error("❌ [RealtimeService] LISTEN client error", { detail: err.message });
9518
9654
  this.scheduleReconnect();
@@ -9551,8 +9687,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
9551
9687
  await client.connect();
9552
9688
  await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
9553
9689
  this.listenClient = client;
9690
+ pending = void 0;
9554
9691
  this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
9555
9692
  } catch (err) {
9693
+ if (pending) try {
9694
+ await pending.end();
9695
+ } catch {}
9556
9696
  logger.error("❌ [RealtimeService] Failed to connect LISTEN client", { error: err });
9557
9697
  this.scheduleReconnect();
9558
9698
  }
@@ -10873,10 +11013,25 @@ var UserService = class {
10873
11013
  if (metadataKey in this.usersTable) payload[metadataKey] = remainingMetadata;
10874
11014
  return payload;
10875
11015
  }
11016
+ /**
11017
+ * @see UserRepository.createUser — an email already in use is a 409.
11018
+ *
11019
+ * The route checks first and answers 409; this is the same answer for the
11020
+ * requests that get past the check, which two clicks on a signup button
11021
+ * are enough to produce. `PersistService` has mapped `23505` to a conflict
11022
+ * for collection writes since the layer that holds the SQLSTATE was made
11023
+ * responsible for saying whose fault a failure is; the auth writes never
11024
+ * got the same treatment and reached the client as "Internal Server Error".
11025
+ */
10876
11026
  async createUser(data) {
10877
11027
  const payload = this.mapPayload(data);
10878
- const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
10879
- return this.mapRowToUser(row);
11028
+ try {
11029
+ const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
11030
+ return this.mapRowToUser(row);
11031
+ } catch (error) {
11032
+ if (extractPgError(error)?.code === "23505") throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
11033
+ throw error;
11034
+ }
10880
11035
  }
10881
11036
  async getUserById(id) {
10882
11037
  const idCol = getColumn(this.usersTable, "id");
@@ -13066,7 +13221,7 @@ function createPostgresBootstrapper(pgConfig) {
13066
13221
  */
13067
13222
  async ensureCollectionSchema(collections, driverResult, log) {
13068
13223
  const internals = driverResult.internals;
13069
- const { ensureCollectionTables } = await import("./ensure-collection-tables-CbvaGuVn.js");
13224
+ const { ensureCollectionTables } = await import("./ensure-collection-tables-B1qKdXA4.js");
13070
13225
  const plan = await ensureCollectionTables({ async query(text) {
13071
13226
  const result = await internals.db.execute(sql.raw(text));
13072
13227
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -13091,7 +13246,7 @@ function createPostgresBootstrapper(pgConfig) {
13091
13246
  */
13092
13247
  async ensureCollectionPolicies(collections, driverResult, log) {
13093
13248
  const internals = driverResult.internals;
13094
- const { ensureCollectionPolicies } = await import("./ensure-collection-policies-8vuu-n4r.js");
13249
+ const { ensureCollectionPolicies } = await import("./ensure-collection-policies-BedO2aNX.js");
13095
13250
  const outcome = await ensureCollectionPolicies({ async query(text) {
13096
13251
  const result = await internals.db.execute(sql.raw(text));
13097
13252
  return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
@@ -13119,7 +13274,7 @@ function createPostgresBootstrapper(pgConfig) {
13119
13274
  },
13120
13275
  mountRoutes(app, basePath, driverResult) {},
13121
13276
  async initializeWebsockets(server, realtimeService, driver, config, adapter) {
13122
- const { createPostgresWebSocket } = await import("./websocket-C8ZqVBiV.js").then((n) => n.n);
13277
+ const { createPostgresWebSocket } = await import("./websocket-D2jXv0Ds.js").then((n) => n.n);
13123
13278
  createPostgresWebSocket(server, realtimeService, driver, config, adapter);
13124
13279
  }
13125
13280
  };