@rebasepro/server-postgres 0.14.0 → 0.14.1
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/PostgresBootstrapper.d.ts +19 -0
- 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-DoHwhVf8.js} +3 -3
- package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
- package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
- package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
- package/dist/index.es.js +543 -105
- 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/schema/drizzle-ddl.d.ts +9 -0
- package/dist/services/FetchService.d.ts +81 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/channel-presence.d.ts +16 -1
- 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/utils/drizzle-conditions.d.ts +25 -0
- 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/PostgresBootstrapper.ts +105 -41
- package/src/auth/services.ts +26 -5
- package/src/schema/drizzle-ddl.ts +33 -0
- package/src/schema/ensure-collection-tables.test.ts +99 -0
- package/src/schema/ensure-collection-tables.ts +65 -3
- package/src/schema/generate-drizzle-schema-logic.ts +19 -1
- package/src/services/FetchService.ts +310 -63
- package/src/services/RelationService.ts +3 -3
- package/src/services/channel-history.ts +38 -6
- package/src/services/channel-presence.ts +31 -7
- package/src/services/dataService.ts +6 -4
- package/src/services/pg-notify-listener.ts +14 -0
- package/src/services/realtimeService.ts +161 -41
- package/src/utils/drizzle-conditions.ts +155 -5
- 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
|
-
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";
|
|
14
|
+
import { ApiError, createDdlBootstrapper, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
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";
|
|
@@ -602,6 +602,31 @@ function getUnknownFilterFieldsMode() {
|
|
|
602
602
|
return defaultUnknownFilterFieldsMode;
|
|
603
603
|
}
|
|
604
604
|
/**
|
|
605
|
+
* Split `metadata->address->>city` into its column and its path.
|
|
606
|
+
*
|
|
607
|
+
* The arrows are PostgREST's spelling and Postgres's own, so the filter reads
|
|
608
|
+
* the same as the SQL it becomes — and, more usefully, the same as what someone
|
|
609
|
+
* would have written by hand in the SQL console while working out what to ask
|
|
610
|
+
* for. A field with no arrow is not a JSON path and returns `undefined`, which
|
|
611
|
+
* leaves every existing filter on exactly the path it took before.
|
|
612
|
+
*
|
|
613
|
+
* Both arrows are accepted and mean the same thing here: the extraction is
|
|
614
|
+
* always compiled to `->>` (text) at the leaf, because that is the only form a
|
|
615
|
+
* comparison can be made against. `->` is allowed because people write it out
|
|
616
|
+
* of habit, and refusing it would be pedantry about a distinction this layer
|
|
617
|
+
* erases anyway.
|
|
618
|
+
*/
|
|
619
|
+
function parseJsonFieldPath(field) {
|
|
620
|
+
if (!field.includes("->")) return void 0;
|
|
621
|
+
const segments = field.split(/->>?/).map((s) => s.trim()).filter(Boolean);
|
|
622
|
+
if (segments.length < 2) return void 0;
|
|
623
|
+
const [columnKey, ...path] = segments;
|
|
624
|
+
return {
|
|
625
|
+
columnKey,
|
|
626
|
+
path
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
605
630
|
* Filter values may arrive as relation wire objects — `EntityRelation`
|
|
606
631
|
* instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
|
|
607
632
|
* the admin filters a relation column. SQL comparisons need the raw id, so
|
|
@@ -762,6 +787,19 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
762
787
|
kind: "column",
|
|
763
788
|
column: direct
|
|
764
789
|
};
|
|
790
|
+
const jsonPath = parseJsonFieldPath(field);
|
|
791
|
+
if (jsonPath) {
|
|
792
|
+
const base = columnAt(jsonPath.columnKey);
|
|
793
|
+
if (base) {
|
|
794
|
+
const meta = getColumnMeta(base);
|
|
795
|
+
if (meta.dataType !== "json" && meta.columnType !== "PgJsonb" && meta.columnType !== "PgJson") throw ApiError.badRequest(`Cannot filter inside "${jsonPath.columnKey}" — it is not a json or jsonb column.`, "INVALID_FILTER_FIELD");
|
|
796
|
+
return {
|
|
797
|
+
kind: "json",
|
|
798
|
+
column: base,
|
|
799
|
+
path: jsonPath.path
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
}
|
|
765
803
|
if (collection) {
|
|
766
804
|
const relation = resolveCollectionRelations(collection)[field];
|
|
767
805
|
if (relation?.kind === "belongsTo") {
|
|
@@ -841,7 +879,71 @@ var DrizzleConditionBuilder = class DrizzleConditionBuilder {
|
|
|
841
879
|
}
|
|
842
880
|
/** Dispatch a resolved filter field onto the shape it actually compiles to. */
|
|
843
881
|
static compileFilterTarget(target, op, value, field, collectionPath) {
|
|
844
|
-
|
|
882
|
+
if (target.kind === "column") return this.buildSingleFilterCondition(target.column, op, value);
|
|
883
|
+
if (target.kind === "json") return this.buildJsonPathCondition(target.column, target.path, op, value);
|
|
884
|
+
return this.buildRelationFilterCondition(target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* A comparison against a value extracted from a json/jsonb column.
|
|
888
|
+
*
|
|
889
|
+
* The path is walked with `->` and the leaf taken with `->>`, so what comes
|
|
890
|
+
* out is always **text**. That is the whole of the type story, and it is
|
|
891
|
+
* the part worth being explicit about, because the alternatives are all
|
|
892
|
+
* worse:
|
|
893
|
+
*
|
|
894
|
+
* - text comparison alone makes `["<", 100]` compare lexically, where
|
|
895
|
+
* `"9"` is greater than `"100"`;
|
|
896
|
+
* - casting unconditionally makes every filter on a non-numeric value a
|
|
897
|
+
* runtime `invalid input syntax for type numeric` — a 500 on a row whose
|
|
898
|
+
* JSON simply holds a string.
|
|
899
|
+
*
|
|
900
|
+
* So the *filter value* decides. A number on an ordering comparison casts
|
|
901
|
+
* both sides to numeric; everything else compares as text, with booleans
|
|
902
|
+
* rendered the way `->>` renders them (`"true"` / `"false"`). A row whose
|
|
903
|
+
* JSON holds a non-numeric value at a path being compared numerically is
|
|
904
|
+
* excluded rather than fatal, which is what `IS NOT NULL`-style filtering
|
|
905
|
+
* means everywhere else in this file.
|
|
906
|
+
*
|
|
907
|
+
* The path segments are bound as parameters, never interpolated: they come
|
|
908
|
+
* from a query string, and `->>` takes a text parameter perfectly well.
|
|
909
|
+
*/
|
|
910
|
+
static buildJsonPathCondition(column, path, op, value) {
|
|
911
|
+
let expr = sql`${column}`;
|
|
912
|
+
for (const key of path.slice(0, -1)) expr = sql`${expr} -> ${key}`;
|
|
913
|
+
const leaf = sql`${expr} ->> ${path[path.length - 1]}`;
|
|
914
|
+
if (typeof value === "number" && (op === ">" || op === ">=" || op === "<" || op === "<=")) {
|
|
915
|
+
const numeric = sql`CASE WHEN ${leaf} ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN (${leaf})::numeric END`;
|
|
916
|
+
switch (op) {
|
|
917
|
+
case ">": return sql`${numeric} > ${value}`;
|
|
918
|
+
case ">=": return sql`${numeric} >= ${value}`;
|
|
919
|
+
case "<": return sql`${numeric} < ${value}`;
|
|
920
|
+
case "<=": return sql`${numeric} <= ${value}`;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const asText = (v) => typeof v === "boolean" ? String(v) : String(v);
|
|
924
|
+
switch (op) {
|
|
925
|
+
case "==": return value === null || value === void 0 ? sql`${leaf} IS NULL` : sql`${leaf} = ${asText(value)}`;
|
|
926
|
+
case "!=": return value === null || value === void 0 ? sql`${leaf} IS NOT NULL` : sql`${leaf} != ${asText(value)}`;
|
|
927
|
+
case ">": return sql`${leaf} > ${asText(value)}`;
|
|
928
|
+
case ">=": return sql`${leaf} >= ${asText(value)}`;
|
|
929
|
+
case "<": return sql`${leaf} < ${asText(value)}`;
|
|
930
|
+
case "<=": return sql`${leaf} <= ${asText(value)}`;
|
|
931
|
+
case "like": return sql`${leaf} LIKE ${asText(value)}`;
|
|
932
|
+
case "ilike": return sql`${leaf} ILIKE ${asText(value)}`;
|
|
933
|
+
case "not-like": return sql`${leaf} NOT LIKE ${asText(value)}`;
|
|
934
|
+
case "not-ilike": return sql`${leaf} NOT ILIKE ${asText(value)}`;
|
|
935
|
+
case "is-null": return sql`${leaf} IS NULL`;
|
|
936
|
+
case "is-not-null": return sql`${leaf} IS NOT NULL`;
|
|
937
|
+
case "in":
|
|
938
|
+
case "not-in": {
|
|
939
|
+
if (value === null || value === void 0) return op === "in" ? sql`${leaf} IS NULL` : sql`${leaf} IS NOT NULL`;
|
|
940
|
+
const values = toMembershipList(value).map(asText);
|
|
941
|
+
if (values.length === 0) return op === "in" ? sql`FALSE` : sql`TRUE`;
|
|
942
|
+
const list = sql.join(values.map((v) => sql`${v}`), sql`, `);
|
|
943
|
+
return op === "in" ? sql`${leaf} IN (${list})` : sql`${leaf} NOT IN (${list})`;
|
|
944
|
+
}
|
|
945
|
+
default: throw ApiError.badRequest(`Operator "${op}" is not supported on a JSON path. Use it on the column itself.`, "INVALID_FILTER_OPERATOR");
|
|
946
|
+
}
|
|
845
947
|
}
|
|
846
948
|
/**
|
|
847
949
|
* A filter on a relation that owns no column on this row — `EXISTS` over
|
|
@@ -3273,6 +3375,43 @@ var FetchService = class FetchService {
|
|
|
3273
3375
|
}
|
|
3274
3376
|
return this.resolveOrderByField(table, orderBy, collection);
|
|
3275
3377
|
}
|
|
3378
|
+
/**
|
|
3379
|
+
* Resolve every sort key to the expression it orders by, in order of
|
|
3380
|
+
* significance.
|
|
3381
|
+
*
|
|
3382
|
+
* A key that resolves to nothing is dropped rather than skipping the rest:
|
|
3383
|
+
* `resolveOrderByField` only *returns* undefined under the lenient
|
|
3384
|
+
* unknown-field mode, where dropping is the configured answer, and dropping
|
|
3385
|
+
* one key of several still honours the ones that did resolve.
|
|
3386
|
+
*/
|
|
3387
|
+
resolveOrderKeys(table, keys, collection, searchString) {
|
|
3388
|
+
const resolved = [];
|
|
3389
|
+
for (const [field, direction] of keys) {
|
|
3390
|
+
const target = this.resolveOrderTarget(table, field, collection, searchString);
|
|
3391
|
+
if (target) resolved.push({
|
|
3392
|
+
field,
|
|
3393
|
+
direction,
|
|
3394
|
+
target
|
|
3395
|
+
});
|
|
3396
|
+
}
|
|
3397
|
+
return resolved;
|
|
3398
|
+
}
|
|
3399
|
+
/**
|
|
3400
|
+
* The full `ORDER BY`: the caller's keys, then the id.
|
|
3401
|
+
*
|
|
3402
|
+
* The id is always last and always descending. It is not decoration — it is
|
|
3403
|
+
* what makes the ordering *total*, and a cursor over a non-total order
|
|
3404
|
+
* repeats and skips rows among the ties. Every keyset comparison built by
|
|
3405
|
+
* {@link buildCursorConditions} ends on the same `id DESC`, and the two have
|
|
3406
|
+
* to agree: they did not, and an ascending sort paged with `id >` against an
|
|
3407
|
+
* `ORDER BY … , id DESC`, so rows sharing a sort value were dropped from
|
|
3408
|
+
* every page after the first.
|
|
3409
|
+
*/
|
|
3410
|
+
buildOrderExpressions(keys, idField) {
|
|
3411
|
+
const expressions = keys.map(({ direction, target }) => direction === "asc" ? asc(target) : desc(target));
|
|
3412
|
+
expressions.push(desc(idField));
|
|
3413
|
+
return expressions;
|
|
3414
|
+
}
|
|
3276
3415
|
resolveOrderByField(table, orderBy, collection) {
|
|
3277
3416
|
const columnAt = (key) => (key in table ? table[key] : void 0) || void 0;
|
|
3278
3417
|
const direct = columnAt(orderBy);
|
|
@@ -3434,14 +3573,9 @@ var FetchService = class FetchService {
|
|
|
3434
3573
|
if (cursorConditions.length > 0) allConditions.push(...cursorConditions);
|
|
3435
3574
|
}
|
|
3436
3575
|
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;
|
|
3576
|
+
const orderKeys = normalizeDriverOrderBy(options.orderBy, options.order);
|
|
3577
|
+
const resolvedOrder = orderKeys ? this.resolveOrderKeys(table, orderKeys, getCollectionByPath(collectionPath, this.registry), options.searchString) : [];
|
|
3578
|
+
queryOpts.orderBy = this.buildOrderExpressions(resolvedOrder, idField);
|
|
3445
3579
|
const limitValue = options.searchString ? options.limit || 50 : options.limit;
|
|
3446
3580
|
if (limitValue) queryOpts.limit = limitValue;
|
|
3447
3581
|
if (options.offset && options.offset > 0) queryOpts.offset = options.offset;
|
|
@@ -3449,19 +3583,25 @@ var FetchService = class FetchService {
|
|
|
3449
3583
|
}
|
|
3450
3584
|
/**
|
|
3451
3585
|
* Extract cursor pagination conditions from startAfter options.
|
|
3586
|
+
*
|
|
3587
|
+
* "Every row that sorts after this one", written out as a comparison over
|
|
3588
|
+
* the same keys the `ORDER BY` uses and ending on the same `id DESC`. With
|
|
3589
|
+
* one key that is the familiar `k > v OR (k = v AND id < cursorId)`; with
|
|
3590
|
+
* several it nests, each key's tie handing the decision to the next.
|
|
3452
3591
|
*/
|
|
3453
3592
|
buildCursorConditions(table, idField, idInfo, options, collectionPath) {
|
|
3454
3593
|
if (!options.startAfter) return [];
|
|
3455
3594
|
const cursor = options.startAfter;
|
|
3456
|
-
|
|
3457
|
-
|
|
3595
|
+
const keys = normalizeDriverOrderBy(options.orderBy, options.order);
|
|
3596
|
+
if (keys) {
|
|
3597
|
+
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
3598
|
const collection = collectionPath ? getCollectionByPath(collectionPath, this.registry) : void 0;
|
|
3459
|
-
const
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
const
|
|
3463
|
-
|
|
3464
|
-
|
|
3599
|
+
const resolved = this.resolveOrderKeys(table, keys, collection);
|
|
3600
|
+
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
3601
|
+
if (resolved.length > 0 && startAfterId !== void 0) {
|
|
3602
|
+
const cursorValues = cursor.values;
|
|
3603
|
+
const values = resolved.map(({ field }) => cursorValues && field in cursorValues ? cursorValues[field] : cursor[field]);
|
|
3604
|
+
if (values.every((value) => value !== void 0)) return [this.buildKeysetComparison(resolved, values, idField, startAfterId)];
|
|
3465
3605
|
}
|
|
3466
3606
|
} else {
|
|
3467
3607
|
const startAfterId = cursor.id ?? cursor[idInfo.fieldName];
|
|
@@ -3470,6 +3610,28 @@ var FetchService = class FetchService {
|
|
|
3470
3610
|
return [];
|
|
3471
3611
|
}
|
|
3472
3612
|
/**
|
|
3613
|
+
* "Sorts strictly after the cursor row", over `keys` and then the id.
|
|
3614
|
+
*
|
|
3615
|
+
* Built by recursion rather than as a row-value comparison — `(a, b) > (x, y)`
|
|
3616
|
+
* would be shorter, but it is only correct when every key runs the same
|
|
3617
|
+
* direction, and `roles ASC, created_at DESC` is exactly the case this
|
|
3618
|
+
* exists to serve.
|
|
3619
|
+
*
|
|
3620
|
+
* NULLs are compared by the rule Postgres sorts them under (last ascending,
|
|
3621
|
+
* first descending) rather than by `>`/`<`, which answer *unknown* against
|
|
3622
|
+
* NULL and therefore match nothing. Ordering by a nullable column and paging
|
|
3623
|
+
* used to drop every row whose sort value was NULL from page two onward.
|
|
3624
|
+
*/
|
|
3625
|
+
buildKeysetComparison(keys, values, idField, cursorId, index = 0) {
|
|
3626
|
+
if (index >= keys.length) return lt(idField, cursorId);
|
|
3627
|
+
const { direction } = keys[index];
|
|
3628
|
+
const target = keys[index].target;
|
|
3629
|
+
const value = values[index];
|
|
3630
|
+
const rest = this.buildKeysetComparison(keys, values, idField, cursorId, index + 1);
|
|
3631
|
+
if (value === null) return direction === "asc" ? and(isNull(target), rest) : or(isNotNull(target), and(isNull(target), rest));
|
|
3632
|
+
return direction === "asc" ? or(gt(target, value), isNull(target), and(eq(target, value), rest)) : or(lt(target, value), and(eq(target, value), rest));
|
|
3633
|
+
}
|
|
3634
|
+
/**
|
|
3473
3635
|
* Compile "rows reachable from this parent" into a `WHERE` condition on the
|
|
3474
3636
|
* target table, so a nested listing can run as an ordinary collection query.
|
|
3475
3637
|
*/
|
|
@@ -3632,14 +3794,8 @@ var FetchService = class FetchService {
|
|
|
3632
3794
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3633
3795
|
if (finalCondition) query = query.where(finalCondition);
|
|
3634
3796
|
}
|
|
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);
|
|
3797
|
+
const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
|
|
3798
|
+
query = query.orderBy(...orderExpressions);
|
|
3643
3799
|
if (options.startAfter) {
|
|
3644
3800
|
const cursorConditions = this.buildCursorConditions(table, idField, idInfo, options, collectionPath);
|
|
3645
3801
|
if (cursorConditions.length > 0) {
|
|
@@ -3764,6 +3920,10 @@ var FetchService = class FetchService {
|
|
|
3764
3920
|
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, effectivePath, this.filterContext(effectivePath, table));
|
|
3765
3921
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
3766
3922
|
}
|
|
3923
|
+
if (options.vectorSearch) {
|
|
3924
|
+
const vectorMeta = DrizzleConditionBuilder.buildVectorSearchConditions(table, options.vectorSearch);
|
|
3925
|
+
if (vectorMeta.filter) allConditions.push(vectorMeta.filter);
|
|
3926
|
+
}
|
|
3767
3927
|
if (allConditions.length > 0) {
|
|
3768
3928
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3769
3929
|
if (finalCondition) query = query.where(finalCondition);
|
|
@@ -3772,6 +3932,92 @@ var FetchService = class FetchService {
|
|
|
3772
3932
|
return Number(result[0]?.count || 0);
|
|
3773
3933
|
}
|
|
3774
3934
|
/**
|
|
3935
|
+
* `count`/`sum`/`avg`/`min`/`max`, optionally grouped.
|
|
3936
|
+
*
|
|
3937
|
+
* The gap this fills is narrow and constant: every dashboard wants "revenue
|
|
3938
|
+
* by status" and "orders per day", and without it the options were a custom
|
|
3939
|
+
* function holding hand-written SQL, or fetching every row and reducing in
|
|
3940
|
+
* JavaScript — which is wrong at any size that matters, and silently wrong
|
|
3941
|
+
* under a `limit`.
|
|
3942
|
+
*
|
|
3943
|
+
* It runs through the same request-scoped handle as every other read, so
|
|
3944
|
+
* **RLS applies to the rows being aggregated**. That is the property worth
|
|
3945
|
+
* protecting here: an aggregate is an effective way to read data you cannot
|
|
3946
|
+
* select, and `count(*)` over a table whose policies would return nothing
|
|
3947
|
+
* has to be zero.
|
|
3948
|
+
*/
|
|
3949
|
+
async aggregate(collectionPath, options) {
|
|
3950
|
+
const collection = getCollectionByPath(collectionPath, this.registry);
|
|
3951
|
+
const table = getTableForCollection(collection, this.registry);
|
|
3952
|
+
const columns = getTableColumns(table);
|
|
3953
|
+
const columnFor = (field, forWhat) => {
|
|
3954
|
+
const column = columns[field];
|
|
3955
|
+
if (!column) throw ApiError.badRequest(`Unknown field '${field}' in ${forWhat}. Valid fields: ${Object.keys(columns).sort().join(", ")}`, "UNKNOWN_AGGREGATE_FIELD");
|
|
3956
|
+
return column;
|
|
3957
|
+
};
|
|
3958
|
+
const selection = {};
|
|
3959
|
+
for (const aggregate of options.aggregates) {
|
|
3960
|
+
if (aggregate.fn === "count" && !aggregate.field) {
|
|
3961
|
+
selection[aggregate.alias] = sql`count(*)`;
|
|
3962
|
+
continue;
|
|
3963
|
+
}
|
|
3964
|
+
const column = columnFor(aggregate.field, `${aggregate.fn}()`);
|
|
3965
|
+
switch (aggregate.fn) {
|
|
3966
|
+
case "count":
|
|
3967
|
+
selection[aggregate.alias] = sql`count(${column})`;
|
|
3968
|
+
break;
|
|
3969
|
+
case "sum":
|
|
3970
|
+
selection[aggregate.alias] = sql`sum(${column})::numeric`;
|
|
3971
|
+
break;
|
|
3972
|
+
case "avg":
|
|
3973
|
+
selection[aggregate.alias] = sql`avg(${column})::numeric`;
|
|
3974
|
+
break;
|
|
3975
|
+
case "min":
|
|
3976
|
+
selection[aggregate.alias] = sql`min(${column})`;
|
|
3977
|
+
break;
|
|
3978
|
+
case "max":
|
|
3979
|
+
selection[aggregate.alias] = sql`max(${column})`;
|
|
3980
|
+
break;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
const groupColumns = (options.groupBy ?? []).map((field) => ({
|
|
3984
|
+
field,
|
|
3985
|
+
column: columnFor(field, "groupBy")
|
|
3986
|
+
}));
|
|
3987
|
+
for (const group of groupColumns) selection[group.field] = sql`${group.column}`;
|
|
3988
|
+
let query = this.db.select(selection).from(table).$dynamic();
|
|
3989
|
+
const conditions = [];
|
|
3990
|
+
if (options.searchString) {
|
|
3991
|
+
const searchConditions = DrizzleConditionBuilder.buildSearchConditions(options.searchString, collection.properties, table, collection);
|
|
3992
|
+
if (searchConditions.length === 0) return [];
|
|
3993
|
+
conditions.push(DrizzleConditionBuilder.combineConditionsWithOr(searchConditions));
|
|
3994
|
+
}
|
|
3995
|
+
if (options.filter) conditions.push(...this.buildFilterConditions(options.filter, table, collectionPath));
|
|
3996
|
+
if (options.logical) {
|
|
3997
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
3998
|
+
if (logicalCondition) conditions.push(logicalCondition);
|
|
3999
|
+
}
|
|
4000
|
+
if (conditions.length > 0) {
|
|
4001
|
+
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(conditions);
|
|
4002
|
+
if (finalCondition) query = query.where(finalCondition);
|
|
4003
|
+
}
|
|
4004
|
+
if (groupColumns.length > 0) {
|
|
4005
|
+
query = query.groupBy(...groupColumns.map((g) => g.column));
|
|
4006
|
+
if (options.limit) query = query.limit(options.limit);
|
|
4007
|
+
}
|
|
4008
|
+
const rows = await query;
|
|
4009
|
+
const numericAliases = new Set(options.aggregates.filter((a) => a.fn === "count" || a.fn === "sum" || a.fn === "avg").map((a) => a.alias));
|
|
4010
|
+
return rows.map((row) => {
|
|
4011
|
+
const out = { ...row };
|
|
4012
|
+
for (const alias of numericAliases) {
|
|
4013
|
+
if (out[alias] === null || out[alias] === void 0) continue;
|
|
4014
|
+
const parsed = Number(out[alias]);
|
|
4015
|
+
if (!Number.isNaN(parsed)) out[alias] = parsed;
|
|
4016
|
+
}
|
|
4017
|
+
return out;
|
|
4018
|
+
});
|
|
4019
|
+
}
|
|
4020
|
+
/**
|
|
3775
4021
|
* Check if a field value is unique
|
|
3776
4022
|
*/
|
|
3777
4023
|
async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
|
|
@@ -3970,14 +4216,8 @@ var FetchService = class FetchService {
|
|
|
3970
4216
|
const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
|
|
3971
4217
|
if (finalCondition) query = query.where(finalCondition);
|
|
3972
4218
|
}
|
|
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);
|
|
4219
|
+
const orderExpressions = vectorMeta ? [asc(vectorMeta.orderBy), desc(idField)] : this.buildOrderExpressions(this.resolveOrderKeys(table, normalizeDriverOrderBy(options.orderBy, options.order) ?? [], collection, options.searchString), idField);
|
|
4220
|
+
query = query.orderBy(...orderExpressions);
|
|
3981
4221
|
const limitValue = options.vectorSearch ? options.limit || 10 : options.searchString ? options.limit || 50 : options.limit;
|
|
3982
4222
|
if (limitValue) query = query.limit(limitValue);
|
|
3983
4223
|
if (options.offset && options.offset > 0) query = query.offset(options.offset);
|
|
@@ -5605,11 +5845,12 @@ var PostgresBackendDriver = class PostgresBackendDriver {
|
|
|
5605
5845
|
async checkUniqueField(path, name, value, id, collection) {
|
|
5606
5846
|
return this.dataService.checkUniqueField(path, name, value, id, collection?.databaseId);
|
|
5607
5847
|
}
|
|
5608
|
-
async count({ path, collection, filter, logical, searchString }) {
|
|
5848
|
+
async count({ path, collection, filter, logical, searchString, vectorSearch }) {
|
|
5609
5849
|
return this.dataService.count(path, {
|
|
5610
5850
|
filter,
|
|
5611
5851
|
logical,
|
|
5612
|
-
searchString
|
|
5852
|
+
searchString,
|
|
5853
|
+
vectorSearch
|
|
5613
5854
|
});
|
|
5614
5855
|
}
|
|
5615
5856
|
getTargetDb(databaseName) {
|
|
@@ -6464,8 +6705,25 @@ var getDrizzleColumn = (propName, prop, collection, collections) => {
|
|
|
6464
6705
|
};
|
|
6465
6706
|
/**
|
|
6466
6707
|
* Wraps a compiled SQL clause in a Drizzle `sql\`...\`` template literal.
|
|
6708
|
+
*
|
|
6709
|
+
* The clause is SQL being written into a TypeScript file, so it has to survive
|
|
6710
|
+
* being read back as a template literal. Three characters do not:
|
|
6711
|
+
*
|
|
6712
|
+
* - `` ` `` closes the template early, and the rest of the clause becomes code.
|
|
6713
|
+
* - `${` opens an interpolation — the file stops compiling, or worse, compiles
|
|
6714
|
+
* against whatever identifier happens to be in scope.
|
|
6715
|
+
* - `\` is an escape, and Drizzle's `sql` tag reads the *cooked* strings, not
|
|
6716
|
+
* `.raw`. So a policy written as `email ~ '^admin\.user@corp\.com$'` reaches
|
|
6717
|
+
* the database as `^admin.user@corp.com$`, where every `\.` now matches any
|
|
6718
|
+
* character. A `USING` clause is a security boundary and that one silently
|
|
6719
|
+
* widened it — the SQL file emitted by the DDL generator kept the backslashes
|
|
6720
|
+
* while this path dropped them, so the two disagreed about who could read the
|
|
6721
|
+
* table.
|
|
6722
|
+
*
|
|
6723
|
+
* Escaping here rather than in the compiler: the clause is correct SQL, and it
|
|
6724
|
+
* is only this destination that has an opinion about backslashes.
|
|
6467
6725
|
*/
|
|
6468
|
-
var wrapSql = (clause) => `sql\`${clause}\``;
|
|
6726
|
+
var wrapSql = (clause) => `sql\`${clause.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``;
|
|
6469
6727
|
var generatePolicyCode = (collection, rule, index, resolveCollection) => {
|
|
6470
6728
|
const tableName = getTableName$1(collection);
|
|
6471
6729
|
const ops = rule.operations && rule.operations.length > 0 ? rule.operations : [rule.operation ?? "all"];
|
|
@@ -7112,8 +7370,10 @@ var PgNotifyListener = class {
|
|
|
7112
7370
|
}
|
|
7113
7371
|
async connect({ initial = false } = {}) {
|
|
7114
7372
|
const { connectionString, channel, onPayload, logLabel } = this.options;
|
|
7373
|
+
let pending;
|
|
7115
7374
|
try {
|
|
7116
7375
|
const client = new Client({ connectionString });
|
|
7376
|
+
pending = client;
|
|
7117
7377
|
client.on("error", (err) => {
|
|
7118
7378
|
logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
|
|
7119
7379
|
this.scheduleReconnect();
|
|
@@ -7131,8 +7391,12 @@ var PgNotifyListener = class {
|
|
|
7131
7391
|
await client.connect();
|
|
7132
7392
|
await client.query(`LISTEN ${channel}`);
|
|
7133
7393
|
this.client = client;
|
|
7394
|
+
pending = void 0;
|
|
7134
7395
|
logger.debug(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
7135
7396
|
} catch (err) {
|
|
7397
|
+
if (pending) try {
|
|
7398
|
+
await pending.end();
|
|
7399
|
+
} catch {}
|
|
7136
7400
|
if (initial) throw err;
|
|
7137
7401
|
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
7138
7402
|
this.scheduleReconnect();
|
|
@@ -7229,6 +7493,29 @@ var CdcListener = class {
|
|
|
7229
7493
|
}
|
|
7230
7494
|
};
|
|
7231
7495
|
//#endregion
|
|
7496
|
+
//#region src/schema/drizzle-ddl.ts
|
|
7497
|
+
/**
|
|
7498
|
+
* The server's DDL bootstrapper, over a Drizzle handle.
|
|
7499
|
+
*
|
|
7500
|
+
* `createDdlBootstrapper` in `@rebasepro/server` wants a plain
|
|
7501
|
+
* `(sql: string) => Promise<rows>`; the driver's internal stores hold a Drizzle
|
|
7502
|
+
* database. This is the adapter between them, and it exists so the retry policy
|
|
7503
|
+
* has exactly one definition. A second copy of the SQLSTATE list living in the
|
|
7504
|
+
* driver is how the two drift apart, and the drift is invisible: both versions
|
|
7505
|
+
* work perfectly on every single-instance deployment.
|
|
7506
|
+
*/
|
|
7507
|
+
/**
|
|
7508
|
+
* A {@link DdlBootstrapper} that runs its statements through `db.execute`.
|
|
7509
|
+
*
|
|
7510
|
+
* @param db the Drizzle handle the calling store already holds
|
|
7511
|
+
* @param scope log prefix identifying the caller, e.g. `"channel-presence"`
|
|
7512
|
+
*/
|
|
7513
|
+
function drizzleDdlBootstrapper(db, scope) {
|
|
7514
|
+
return createDdlBootstrapper(async (statement) => {
|
|
7515
|
+
return (await db.execute(sql.raw(statement))).rows ?? [];
|
|
7516
|
+
}, scope);
|
|
7517
|
+
}
|
|
7518
|
+
//#endregion
|
|
7232
7519
|
//#region src/services/channel-history.ts
|
|
7233
7520
|
/**
|
|
7234
7521
|
* Ordered, replayable per-channel message history.
|
|
@@ -7364,8 +7651,9 @@ var ChannelHistoryStore = class {
|
|
|
7364
7651
|
*/
|
|
7365
7652
|
async ensureTables() {
|
|
7366
7653
|
if (!this.enabled || this.tablesReady) return;
|
|
7367
|
-
|
|
7368
|
-
await
|
|
7654
|
+
const ddl = drizzleDdlBootstrapper(this.db, "channel-history");
|
|
7655
|
+
await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
7656
|
+
await ddl.ensureObject("channel_messages table", `
|
|
7369
7657
|
CREATE TABLE IF NOT EXISTS rebase.channel_messages (
|
|
7370
7658
|
channel TEXT NOT NULL,
|
|
7371
7659
|
seq BIGINT NOT NULL,
|
|
@@ -7376,18 +7664,23 @@ var ChannelHistoryStore = class {
|
|
|
7376
7664
|
PRIMARY KEY (channel, seq)
|
|
7377
7665
|
)
|
|
7378
7666
|
`);
|
|
7379
|
-
await
|
|
7667
|
+
await ddl.ensureObject("channel_messages created_at index", `
|
|
7380
7668
|
CREATE INDEX IF NOT EXISTS idx_channel_messages_created
|
|
7381
7669
|
ON rebase.channel_messages (created_at)
|
|
7382
7670
|
`);
|
|
7383
|
-
await
|
|
7671
|
+
await ddl.ensureObject("channel_cursors table", `
|
|
7384
7672
|
CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
|
|
7385
7673
|
channel TEXT PRIMARY KEY,
|
|
7386
7674
|
last_seq BIGINT NOT NULL
|
|
7387
7675
|
)
|
|
7388
7676
|
`);
|
|
7389
|
-
await
|
|
7390
|
-
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "
|
|
7677
|
+
const [messagesReady, cursorsReady] = await Promise.all([ddl.isReadable("rebase.channel_messages"), ddl.isReadable("rebase.channel_cursors")]);
|
|
7678
|
+
if (messagesReady) await ddl.step("channel_messages revoke", () => this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages"))));
|
|
7679
|
+
if (cursorsReady) await ddl.step("channel_cursors revoke", () => this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors"))));
|
|
7680
|
+
if (!messagesReady || !cursorsReady) {
|
|
7681
|
+
logger.warn("[ChannelHistory] Retained-channel tables are not both present; history is not ready yet.");
|
|
7682
|
+
return;
|
|
7683
|
+
}
|
|
7391
7684
|
this.tablesReady = true;
|
|
7392
7685
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
7393
7686
|
}
|
|
@@ -7553,11 +7846,27 @@ var ChannelPresenceStore = class {
|
|
|
7553
7846
|
this.db = db;
|
|
7554
7847
|
this.instanceId = instanceId;
|
|
7555
7848
|
}
|
|
7556
|
-
/**
|
|
7849
|
+
/**
|
|
7850
|
+
* Create the roster table. Idempotent, and safe to run on every instance at
|
|
7851
|
+
* once.
|
|
7852
|
+
*
|
|
7853
|
+
* Written as separate contained steps rather than one straight sequence for
|
|
7854
|
+
* a reason that only bites with more than one replica, which is exactly the
|
|
7855
|
+
* deployment shape this table exists to serve: `CREATE … IF NOT EXISTS`
|
|
7856
|
+
* reads the catalog and then writes to it non-atomically, so peers booting
|
|
7857
|
+
* together collide, and the loser used to abandon everything after it —
|
|
7858
|
+
* including the trailing `REVOKE`. That revoke is the only thing keeping the
|
|
7859
|
+
* roster off the end-user role, so losing a boot race silently left the
|
|
7860
|
+
* whole channel roster readable by every signed-in user.
|
|
7861
|
+
*
|
|
7862
|
+
* `tablesReady` is now set from a probe of what exists, not from having been
|
|
7863
|
+
* the instance that created it.
|
|
7864
|
+
*/
|
|
7557
7865
|
async ensureTables() {
|
|
7558
7866
|
if (this.tablesReady) return;
|
|
7559
|
-
|
|
7560
|
-
await
|
|
7867
|
+
const ddl = drizzleDdlBootstrapper(this.db, "channel-presence");
|
|
7868
|
+
await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
7869
|
+
await ddl.ensureObject("channel_presence table", `
|
|
7561
7870
|
CREATE TABLE IF NOT EXISTS rebase.channel_presence (
|
|
7562
7871
|
channel TEXT NOT NULL,
|
|
7563
7872
|
client_id TEXT NOT NULL,
|
|
@@ -7567,12 +7876,14 @@ var ChannelPresenceStore = class {
|
|
|
7567
7876
|
PRIMARY KEY (channel, client_id)
|
|
7568
7877
|
)
|
|
7569
7878
|
`);
|
|
7570
|
-
await
|
|
7879
|
+
await ddl.ensureObject("channel_presence last_seen index", `
|
|
7571
7880
|
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
7572
7881
|
ON rebase.channel_presence (last_seen)
|
|
7573
7882
|
`);
|
|
7574
|
-
await
|
|
7575
|
-
|
|
7883
|
+
if (await ddl.isReadable("rebase.channel_presence")) {
|
|
7884
|
+
await ddl.step("channel_presence revoke", () => this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence"))));
|
|
7885
|
+
this.tablesReady = true;
|
|
7886
|
+
}
|
|
7576
7887
|
}
|
|
7577
7888
|
/** Record (or refresh) a client's presence. */
|
|
7578
7889
|
async track(channel, clientId, state) {
|
|
@@ -8098,9 +8409,40 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8098
8409
|
get subscriptions() {
|
|
8099
8410
|
return this._subscriptions;
|
|
8100
8411
|
}
|
|
8412
|
+
/**
|
|
8413
|
+
* Claim a delivery slot for a subscription, before doing the work.
|
|
8414
|
+
*
|
|
8415
|
+
* Returns the check to run immediately before delivering. It refuses in
|
|
8416
|
+
* three cases, all of which used to deliver:
|
|
8417
|
+
*
|
|
8418
|
+
* - **Out of order.** A newer refetch has already delivered, so this one is
|
|
8419
|
+
* stale — the subscriber would go back to the state before the change.
|
|
8420
|
+
* - **Unsubscribed.** The subscription was cancelled while the fetch was in
|
|
8421
|
+
* flight. The `has(subscriptionId)` check the debounced refetches ran
|
|
8422
|
+
* *before* the await cannot answer this; only a check after it can.
|
|
8423
|
+
* - **Replaced.** The same id can name a *different* subscription by the
|
|
8424
|
+
* time a fetch lands — a re-subscribe overwrites the map entry, and the
|
|
8425
|
+
* old filter's rows would be delivered to the new subscriber.
|
|
8426
|
+
*
|
|
8427
|
+
* The last two are identity, not presence: the map has to still hold *this
|
|
8428
|
+
* exact object*, not merely something under this id.
|
|
8429
|
+
*/
|
|
8430
|
+
beginDelivery(subscriptionId, subscription) {
|
|
8431
|
+
const seq = ++subscription.started;
|
|
8432
|
+
return () => {
|
|
8433
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return false;
|
|
8434
|
+
if (seq <= subscription.delivered) return false;
|
|
8435
|
+
subscription.delivered = seq;
|
|
8436
|
+
return true;
|
|
8437
|
+
};
|
|
8438
|
+
}
|
|
8101
8439
|
registerDataDriverSubscription(subscriptionId, subscription) {
|
|
8102
8440
|
this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
|
|
8103
|
-
this._subscriptions.set(subscriptionId,
|
|
8441
|
+
this._subscriptions.set(subscriptionId, {
|
|
8442
|
+
...subscription,
|
|
8443
|
+
started: 0,
|
|
8444
|
+
delivered: 0
|
|
8445
|
+
});
|
|
8104
8446
|
}
|
|
8105
8447
|
addSubscriptionCallback(subscriptionId, callback) {
|
|
8106
8448
|
this.debugLog("📋 [RealtimeService] Adding callback for subscription:", subscriptionId);
|
|
@@ -8127,7 +8469,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8127
8469
|
databaseId: config.databaseId,
|
|
8128
8470
|
searchString: config.searchString,
|
|
8129
8471
|
searchExplain: config.searchExplain
|
|
8130
|
-
}
|
|
8472
|
+
},
|
|
8473
|
+
started: 0,
|
|
8474
|
+
delivered: 0
|
|
8131
8475
|
});
|
|
8132
8476
|
if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
|
|
8133
8477
|
}
|
|
@@ -8139,7 +8483,9 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8139
8483
|
clientId: config.clientId,
|
|
8140
8484
|
type: "single",
|
|
8141
8485
|
path: config.path,
|
|
8142
|
-
id: config.id
|
|
8486
|
+
id: config.id,
|
|
8487
|
+
started: 0,
|
|
8488
|
+
delivered: 0
|
|
8143
8489
|
});
|
|
8144
8490
|
if (callback) this.subscriptionCallbacks.set(subscriptionId, callback);
|
|
8145
8491
|
}
|
|
@@ -8242,14 +8588,23 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8242
8588
|
this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
|
|
8243
8589
|
return;
|
|
8244
8590
|
}
|
|
8245
|
-
|
|
8591
|
+
let orderBy;
|
|
8592
|
+
try {
|
|
8593
|
+
orderBy = parseOrderBySpecStrict(request.orderBy, request.order);
|
|
8594
|
+
} catch (e) {
|
|
8595
|
+
if (!(e instanceof OrderBySpecError)) throw e;
|
|
8596
|
+
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
8597
|
+
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
8598
|
+
return;
|
|
8599
|
+
}
|
|
8600
|
+
const subscription = {
|
|
8246
8601
|
clientId,
|
|
8247
8602
|
type: "collection",
|
|
8248
8603
|
path: request.path,
|
|
8249
8604
|
collectionRequest: {
|
|
8250
8605
|
filter: request.filter,
|
|
8251
8606
|
logical: request.logical,
|
|
8252
|
-
orderBy
|
|
8607
|
+
orderBy,
|
|
8253
8608
|
order: request.order,
|
|
8254
8609
|
limit: boundedLimit,
|
|
8255
8610
|
offset: request.offset,
|
|
@@ -8258,10 +8613,14 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8258
8613
|
searchString: request.searchString,
|
|
8259
8614
|
searchExplain: request.searchExplain
|
|
8260
8615
|
},
|
|
8261
|
-
authContext
|
|
8262
|
-
|
|
8263
|
-
|
|
8264
|
-
|
|
8616
|
+
authContext,
|
|
8617
|
+
started: 0,
|
|
8618
|
+
delivered: 0
|
|
8619
|
+
};
|
|
8620
|
+
this._subscriptions.set(subscriptionId, subscription);
|
|
8621
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8622
|
+
const rows = await this.fetchCollectionWithAuth(request.path, subscription.collectionRequest, authContext);
|
|
8623
|
+
if (canDeliver()) this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
8265
8624
|
} catch (error) {
|
|
8266
8625
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
8267
8626
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8277,15 +8636,19 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8277
8636
|
this.sendError(clientId, msg, subscriptionId);
|
|
8278
8637
|
return;
|
|
8279
8638
|
}
|
|
8280
|
-
|
|
8639
|
+
const subscription = {
|
|
8281
8640
|
clientId,
|
|
8282
8641
|
type: "single",
|
|
8283
8642
|
path: request.path,
|
|
8284
8643
|
id: request.id,
|
|
8285
|
-
authContext
|
|
8286
|
-
|
|
8644
|
+
authContext,
|
|
8645
|
+
started: 0,
|
|
8646
|
+
delivered: 0
|
|
8647
|
+
};
|
|
8648
|
+
this._subscriptions.set(subscriptionId, subscription);
|
|
8649
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8287
8650
|
const row = await this.fetchEntityWithAuth(request.path, String(request.id), authContext);
|
|
8288
|
-
this.sendSingleUpdate(clientId, subscriptionId, row || null);
|
|
8651
|
+
if (canDeliver()) this.sendSingleUpdate(clientId, subscriptionId, row || null);
|
|
8289
8652
|
} catch (error) {
|
|
8290
8653
|
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
8291
8654
|
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8416,10 +8779,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8416
8779
|
if (existing) clearTimeout(existing);
|
|
8417
8780
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
8418
8781
|
this.refetchTimers.delete(timerKey);
|
|
8419
|
-
if (
|
|
8782
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
8783
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8420
8784
|
try {
|
|
8421
8785
|
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
8422
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
8786
|
+
if (canDeliver()) this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
8423
8787
|
} catch (error) {
|
|
8424
8788
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
8425
8789
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8435,9 +8799,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8435
8799
|
if (existing) clearTimeout(existing);
|
|
8436
8800
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
8437
8801
|
this.refetchTimers.delete(timerKey);
|
|
8438
|
-
if (
|
|
8802
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
8803
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8439
8804
|
try {
|
|
8440
|
-
|
|
8805
|
+
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest, subscription.authContext);
|
|
8806
|
+
if (canDeliver()) callback(rows);
|
|
8441
8807
|
} catch (error) {
|
|
8442
8808
|
logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error });
|
|
8443
8809
|
}
|
|
@@ -8553,10 +8919,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8553
8919
|
if (existing) clearTimeout(existing);
|
|
8554
8920
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
8555
8921
|
this.refetchTimers.delete(timerKey);
|
|
8556
|
-
if (
|
|
8922
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
8923
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8557
8924
|
try {
|
|
8558
8925
|
const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
|
|
8559
|
-
this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
|
|
8926
|
+
if (canDeliver()) this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
|
|
8560
8927
|
} catch (error) {
|
|
8561
8928
|
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
8562
8929
|
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
@@ -8572,9 +8939,11 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8572
8939
|
if (existing) clearTimeout(existing);
|
|
8573
8940
|
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
8574
8941
|
this.refetchTimers.delete(timerKey);
|
|
8575
|
-
if (
|
|
8942
|
+
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
8943
|
+
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
8576
8944
|
try {
|
|
8577
|
-
|
|
8945
|
+
const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
|
|
8946
|
+
if (canDeliver()) callback(row || null);
|
|
8578
8947
|
} catch (error) {
|
|
8579
8948
|
logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error });
|
|
8580
8949
|
}
|
|
@@ -8849,7 +9218,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8849
9218
|
if (this.memoryBusWarned) return;
|
|
8850
9219
|
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
8851
9220
|
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
|
|
9221
|
+
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 REALTIME_CHANNEL_BUS=postgres) to make channels cross-instance.");
|
|
8853
9222
|
}
|
|
8854
9223
|
/** Leave a broadcast channel */
|
|
8855
9224
|
leaveChannel(clientId, channel) {
|
|
@@ -9511,8 +9880,10 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9511
9880
|
*/
|
|
9512
9881
|
async connectListenClient() {
|
|
9513
9882
|
if (!this.listenConnectionString) return;
|
|
9883
|
+
let pending;
|
|
9514
9884
|
try {
|
|
9515
9885
|
const client = new Client({ connectionString: this.listenConnectionString });
|
|
9886
|
+
pending = client;
|
|
9516
9887
|
client.on("error", (err) => {
|
|
9517
9888
|
logger.error("❌ [RealtimeService] LISTEN client error", { detail: err.message });
|
|
9518
9889
|
this.scheduleReconnect();
|
|
@@ -9551,8 +9922,12 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
9551
9922
|
await client.connect();
|
|
9552
9923
|
await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
|
|
9553
9924
|
this.listenClient = client;
|
|
9925
|
+
pending = void 0;
|
|
9554
9926
|
this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
|
|
9555
9927
|
} catch (err) {
|
|
9928
|
+
if (pending) try {
|
|
9929
|
+
await pending.end();
|
|
9930
|
+
} catch {}
|
|
9556
9931
|
logger.error("❌ [RealtimeService] Failed to connect LISTEN client", { error: err });
|
|
9557
9932
|
this.scheduleReconnect();
|
|
9558
9933
|
}
|
|
@@ -10873,10 +11248,25 @@ var UserService = class {
|
|
|
10873
11248
|
if (metadataKey in this.usersTable) payload[metadataKey] = remainingMetadata;
|
|
10874
11249
|
return payload;
|
|
10875
11250
|
}
|
|
11251
|
+
/**
|
|
11252
|
+
* @see UserRepository.createUser — an email already in use is a 409.
|
|
11253
|
+
*
|
|
11254
|
+
* The route checks first and answers 409; this is the same answer for the
|
|
11255
|
+
* requests that get past the check, which two clicks on a signup button
|
|
11256
|
+
* are enough to produce. `PersistService` has mapped `23505` to a conflict
|
|
11257
|
+
* for collection writes since the layer that holds the SQLSTATE was made
|
|
11258
|
+
* responsible for saying whose fault a failure is; the auth writes never
|
|
11259
|
+
* got the same treatment and reached the client as "Internal Server Error".
|
|
11260
|
+
*/
|
|
10876
11261
|
async createUser(data) {
|
|
10877
11262
|
const payload = this.mapPayload(data);
|
|
10878
|
-
|
|
10879
|
-
|
|
11263
|
+
try {
|
|
11264
|
+
const [row] = await this.withServerContext(async (db) => await db.insert(this.usersTable).values(payload).returning());
|
|
11265
|
+
return this.mapRowToUser(row);
|
|
11266
|
+
} catch (error) {
|
|
11267
|
+
if (extractPgError(error)?.code === "23505") throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
11268
|
+
throw error;
|
|
11269
|
+
}
|
|
10880
11270
|
}
|
|
10881
11271
|
async getUserById(id) {
|
|
10882
11272
|
const idCol = getColumn(this.usersTable, "id");
|
|
@@ -12693,6 +13083,43 @@ function resolveDriftCheckName(col, registeredTableNames) {
|
|
|
12693
13083
|
return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
|
|
12694
13084
|
}
|
|
12695
13085
|
/**
|
|
13086
|
+
* Why the tables this backend serves are not in the database — the part of the
|
|
13087
|
+
* drift warning that has to be true rather than merely plausible.
|
|
13088
|
+
*
|
|
13089
|
+
* The three answers need three different actions, and only the caller knows
|
|
13090
|
+
* which one applies. This warning used to assert the first ("this runtime
|
|
13091
|
+
* applies the collection schema at boot unless REBASE_MIGRATE_ON_BOOT=none")
|
|
13092
|
+
* and then point at that variable and at driver-version skew. For an app whose
|
|
13093
|
+
* boot path contained no provisioning step at all, every word of that was a
|
|
13094
|
+
* dead end: nothing read the variable, and the driver was current. The advice
|
|
13095
|
+
* cost an investigation, which is a strictly worse outcome than saying less.
|
|
13096
|
+
*
|
|
13097
|
+
* Exported for its own test: the surrounding check needs a live pool and a real
|
|
13098
|
+
* database, and this is the part that was wrong.
|
|
13099
|
+
*/
|
|
13100
|
+
function describeSchemaDriftCause(provisioning) {
|
|
13101
|
+
if (provisioning === void 0) return [
|
|
13102
|
+
" This runtime could not determine whether a schema-creation step ran",
|
|
13103
|
+
" before this check (the caller predates that signal).",
|
|
13104
|
+
" • Look for a \"Collection schema:\" line above. No such line at all",
|
|
13105
|
+
" means nothing tried to create these tables in this process."
|
|
13106
|
+
];
|
|
13107
|
+
if (provisioning.attempted) return [
|
|
13108
|
+
" A schema-creation step DID run this boot and these tables are still",
|
|
13109
|
+
" missing, so it did not create them — check the \"schema:\" lines above",
|
|
13110
|
+
" for what it did instead, and for DDL errors.",
|
|
13111
|
+
" • A collection routed to another engine or data source is not",
|
|
13112
|
+
" created here; that is reported separately at boot.",
|
|
13113
|
+
" • Otherwise this is a bug worth reporting, with those lines."
|
|
13114
|
+
];
|
|
13115
|
+
return [
|
|
13116
|
+
" No schema-creation step ran this boot:",
|
|
13117
|
+
` ${provisioning.reason ?? "no reason was given."}`,
|
|
13118
|
+
" Resolve that reason — the drift is its consequence, not a separate",
|
|
13119
|
+
" problem, and re-running a migration tool will not change it."
|
|
13120
|
+
];
|
|
13121
|
+
}
|
|
13122
|
+
/**
|
|
12696
13123
|
* Is this the local database `rebase init` scaffolds — i.e. the one case where
|
|
12697
13124
|
* "you are connected as a superuser" is not news?
|
|
12698
13125
|
*
|
|
@@ -12742,10 +13169,34 @@ function isScaffoldedLocalDatabase(connectionString) {
|
|
|
12742
13169
|
*/
|
|
12743
13170
|
function createPostgresBootstrapper(pgConfig) {
|
|
12744
13171
|
if (pgConfig.unknownFilterFields) configureUnknownFilterFields(pgConfig.unknownFilterFields);
|
|
13172
|
+
/**
|
|
13173
|
+
* The handle the schema/policy hooks issue their DDL through.
|
|
13174
|
+
*
|
|
13175
|
+
* Both hooks run BEFORE `initializeDriver`, so `driverResult` is a stand-in
|
|
13176
|
+
* the caller may not have: the bundle path can synthesize one from the
|
|
13177
|
+
* connection its coordinator opened, but an application that built this
|
|
13178
|
+
* adapter itself never handed the framework a connection — it handed it to
|
|
13179
|
+
* *us*, as `pgConfig.connection`. Falling back to that is what lets a
|
|
13180
|
+
* self-built adapter provision at all; requiring the argument is what left
|
|
13181
|
+
* those apps with no tables and a 500 on every data route.
|
|
13182
|
+
*
|
|
13183
|
+
* Either handle is equivalent here. The driver's `schemaAwareDb` differs
|
|
13184
|
+
* only by the drizzle schema object registered on it — relevant to the query
|
|
13185
|
+
* builder, not to `execute(sql.raw(...))` — and every statement these hooks
|
|
13186
|
+
* emit is schema-qualified DDL, so neither depends on `search_path`.
|
|
13187
|
+
*/
|
|
13188
|
+
const provisioningQueryable = (driverResult) => {
|
|
13189
|
+
const db = (driverResult?.internals)?.db ?? pgConfig.connection;
|
|
13190
|
+
if (!db) throw new Error("Cannot provision the collection schema: this Postgres adapter was created without a `connection`, and no initialized driver was supplied to fall back on. Pass `connection` to `createPostgresAdapter` (see `createPostgresDatabaseConnection`).");
|
|
13191
|
+
return { async query(text) {
|
|
13192
|
+
const result = await db.execute(sql.raw(text));
|
|
13193
|
+
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
13194
|
+
} };
|
|
13195
|
+
};
|
|
12745
13196
|
return {
|
|
12746
13197
|
type: "postgres",
|
|
12747
13198
|
async initializeDriver(config) {
|
|
12748
|
-
const { collections, collectionRegistry, introspectCollections, baas } = config;
|
|
13199
|
+
const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config;
|
|
12749
13200
|
const unprotectedTables = baas?.unprotectedTables ?? "exclude";
|
|
12750
13201
|
const connection = pgConfig.connection;
|
|
12751
13202
|
const rawClient = connection && typeof connection === "object" && "$client" in connection ? connection.$client : connection;
|
|
@@ -12962,22 +13413,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
12962
13413
|
" (`?options=-c%20search_path%3Dpublic`).",
|
|
12963
13414
|
""
|
|
12964
13415
|
];
|
|
13416
|
+
const cause = describeSchemaDriftCause(schemaProvisioning);
|
|
12965
13417
|
logger.warn([
|
|
12966
13418
|
"",
|
|
12967
13419
|
"⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
|
|
12968
13420
|
...lines,
|
|
12969
13421
|
"",
|
|
12970
13422
|
...misplacedHelp,
|
|
12971
|
-
|
|
12972
|
-
"
|
|
12973
|
-
"
|
|
12974
|
-
" • Managed cloud:
|
|
12975
|
-
"
|
|
12976
|
-
" If the log above says the driver does not implement collection-table",
|
|
12977
|
-
" creation, THIS driver is too old to do it. A driver is installed from",
|
|
12978
|
-
" your bundle's dependencies, not supplied by the platform image, so a",
|
|
12979
|
-
" newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
|
|
12980
|
-
" in your project's package.json and redeploy.",
|
|
13423
|
+
...cause,
|
|
13424
|
+
"",
|
|
13425
|
+
" To apply this project's schema:",
|
|
13426
|
+
" • Managed cloud: the runtime creates tables and RLS at boot. `rebase db",
|
|
13427
|
+
" push` cannot reach a tenant's in-cluster database — redeploy instead.",
|
|
12981
13428
|
" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
|
|
12982
13429
|
" against DATABASE_URL.",
|
|
12983
13430
|
""
|
|
@@ -13065,12 +13512,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13065
13512
|
* proved it can bootstrap with.
|
|
13066
13513
|
*/
|
|
13067
13514
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
13068
|
-
const
|
|
13069
|
-
const
|
|
13070
|
-
const plan = await ensureCollectionTables({ async query(text) {
|
|
13071
|
-
const result = await internals.db.execute(sql.raw(text));
|
|
13072
|
-
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
13073
|
-
} }, collections, log);
|
|
13515
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-DT2eq859.js");
|
|
13516
|
+
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
13074
13517
|
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}`);
|
|
13075
13518
|
return { applied: plan.actions.length - plan.failures.length };
|
|
13076
13519
|
},
|
|
@@ -13090,12 +13533,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13090
13533
|
* stays RLS-enabled, so it denies rather than leaks.
|
|
13091
13534
|
*/
|
|
13092
13535
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
13093
|
-
const
|
|
13094
|
-
const
|
|
13095
|
-
const outcome = await ensureCollectionPolicies(
|
|
13096
|
-
const result = await internals.db.execute(sql.raw(text));
|
|
13097
|
-
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
13098
|
-
} }, collections, log);
|
|
13536
|
+
const { ensureCollectionPolicies } = await import("./ensure-collection-policies-DoHwhVf8.js");
|
|
13537
|
+
const queryable = provisioningQueryable(driverResult);
|
|
13538
|
+
const outcome = await ensureCollectionPolicies(queryable, collections, log);
|
|
13099
13539
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
13100
13540
|
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
13541
|
const unrevoked = outcome.unsecured.filter((u) => !u.grantWithdrawn);
|
|
@@ -13103,9 +13543,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13103
13543
|
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
13544
|
try {
|
|
13105
13545
|
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
|
-
}, {
|
|
13546
|
+
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
13109
13547
|
info: (m) => logger.info(m),
|
|
13110
13548
|
warn: (m) => logger.warn(m)
|
|
13111
13549
|
});
|
|
@@ -13119,7 +13557,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13119
13557
|
},
|
|
13120
13558
|
mountRoutes(app, basePath, driverResult) {},
|
|
13121
13559
|
async initializeWebsockets(server, realtimeService, driver, config, adapter) {
|
|
13122
|
-
const { createPostgresWebSocket } = await import("./websocket-
|
|
13560
|
+
const { createPostgresWebSocket } = await import("./websocket-D2jXv0Ds.js").then((n) => n.n);
|
|
13123
13561
|
createPostgresWebSocket(server, realtimeService, driver, config, adapter);
|
|
13124
13562
|
}
|
|
13125
13563
|
};
|
|
@@ -13159,6 +13597,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
13159
13597
|
};
|
|
13160
13598
|
}
|
|
13161
13599
|
//#endregion
|
|
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 };
|
|
13600
|
+
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, describeSchemaDriftCause, 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 };
|
|
13163
13601
|
|
|
13164
13602
|
//# sourceMappingURL=index.es.js.map
|