@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.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/auth/services.d.ts +10 -0
- package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
- package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -1
- package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-BedO2aNX.js} +3 -3
- package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-BedO2aNX.js.map} +1 -1
- package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-B1qKdXA4.js} +2 -2
- package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-B1qKdXA4.js.map} +1 -1
- package/dist/index.es.js +220 -65
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
- package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
- package/dist/services/FetchService.d.ts +54 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/dataService.d.ts +6 -4
- package/dist/services/realtimeService.d.ts +54 -10
- package/dist/src-DCdn3Val.js.map +1 -1
- package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
- package/dist/websocket-D2jXv0Ds.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +7 -3
- package/src/auth/services.ts +26 -5
- package/src/schema/generate-drizzle-schema-logic.ts +19 -1
- package/src/services/FetchService.ts +185 -63
- package/src/services/RelationService.ts +3 -3
- package/src/services/dataService.ts +6 -4
- package/src/services/pg-notify-listener.ts +14 -0
- package/src/services/realtimeService.ts +160 -40
- package/src/websocket.ts +44 -1
- package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
- 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
|
|
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-
|
|
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-
|
|
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
|
|
3438
|
-
|
|
3439
|
-
|
|
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
|
-
|
|
3457
|
-
|
|
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
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
const
|
|
3463
|
-
|
|
3464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
8264
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
8567
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
8568
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8439
8569
|
try {
|
|
8440
|
-
|
|
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 (
|
|
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 (
|
|
8707
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
8708
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8576
8709
|
try {
|
|
8577
|
-
|
|
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
|
-
|
|
10879
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
13277
|
+
const { createPostgresWebSocket } = await import("./websocket-D2jXv0Ds.js").then((n) => n.n);
|
|
13123
13278
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
13124
13279
|
}
|
|
13125
13280
|
};
|