@rebasepro/server-postgres 0.14.1-canary.g7e666eb → 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/PostgresBootstrapper.d.ts +19 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -1
- package/dist/{ensure-collection-policies-BedO2aNX.js → ensure-collection-policies-DoHwhVf8.js} +2 -2
- package/dist/{ensure-collection-policies-BedO2aNX.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
- package/dist/{ensure-collection-tables-B1qKdXA4.js → ensure-collection-tables-DT2eq859.js} +44 -4
- package/dist/{ensure-collection-tables-B1qKdXA4.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
- package/dist/index.es.js +325 -42
- package/dist/index.es.js.map +1 -1
- package/dist/schema/drizzle-ddl.d.ts +9 -0
- package/dist/services/FetchService.d.ts +27 -0
- package/dist/services/channel-presence.d.ts +16 -1
- package/dist/utils/drizzle-conditions.d.ts +25 -0
- package/dist/websocket-D2jXv0Ds.js.map +1 -1
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +105 -41
- 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/services/FetchService.ts +126 -1
- package/src/services/channel-history.ts +38 -6
- package/src/services/channel-presence.ts +31 -7
- package/src/services/realtimeService.ts +1 -1
- package/src/utils/drizzle-conditions.ts +155 -5
package/dist/index.es.js
CHANGED
|
@@ -11,7 +11,7 @@ import { A as parsePgToolMajor, C as checkToolServerCompatibility, D as parseBac
|
|
|
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";
|
|
14
|
+
import { ApiError, createDdlBootstrapper, createEmailService, loadCollectionsFromDirectory, logger } from "@rebasepro/server";
|
|
15
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";
|
|
@@ -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
|
|
@@ -3830,6 +3932,92 @@ var FetchService = class FetchService {
|
|
|
3830
3932
|
return Number(result[0]?.count || 0);
|
|
3831
3933
|
}
|
|
3832
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
|
+
/**
|
|
3833
4021
|
* Check if a field value is unique
|
|
3834
4022
|
*/
|
|
3835
4023
|
async checkUniqueField(collectionPath, fieldName, value, excludeEntityId, _databaseId) {
|
|
@@ -7305,6 +7493,29 @@ var CdcListener = class {
|
|
|
7305
7493
|
}
|
|
7306
7494
|
};
|
|
7307
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
|
|
7308
7519
|
//#region src/services/channel-history.ts
|
|
7309
7520
|
/**
|
|
7310
7521
|
* Ordered, replayable per-channel message history.
|
|
@@ -7440,8 +7651,9 @@ var ChannelHistoryStore = class {
|
|
|
7440
7651
|
*/
|
|
7441
7652
|
async ensureTables() {
|
|
7442
7653
|
if (!this.enabled || this.tablesReady) return;
|
|
7443
|
-
|
|
7444
|
-
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", `
|
|
7445
7657
|
CREATE TABLE IF NOT EXISTS rebase.channel_messages (
|
|
7446
7658
|
channel TEXT NOT NULL,
|
|
7447
7659
|
seq BIGINT NOT NULL,
|
|
@@ -7452,18 +7664,23 @@ var ChannelHistoryStore = class {
|
|
|
7452
7664
|
PRIMARY KEY (channel, seq)
|
|
7453
7665
|
)
|
|
7454
7666
|
`);
|
|
7455
|
-
await
|
|
7667
|
+
await ddl.ensureObject("channel_messages created_at index", `
|
|
7456
7668
|
CREATE INDEX IF NOT EXISTS idx_channel_messages_created
|
|
7457
7669
|
ON rebase.channel_messages (created_at)
|
|
7458
7670
|
`);
|
|
7459
|
-
await
|
|
7671
|
+
await ddl.ensureObject("channel_cursors table", `
|
|
7460
7672
|
CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
|
|
7461
7673
|
channel TEXT PRIMARY KEY,
|
|
7462
7674
|
last_seq BIGINT NOT NULL
|
|
7463
7675
|
)
|
|
7464
7676
|
`);
|
|
7465
|
-
await
|
|
7466
|
-
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
|
+
}
|
|
7467
7684
|
this.tablesReady = true;
|
|
7468
7685
|
logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
|
|
7469
7686
|
}
|
|
@@ -7629,11 +7846,27 @@ var ChannelPresenceStore = class {
|
|
|
7629
7846
|
this.db = db;
|
|
7630
7847
|
this.instanceId = instanceId;
|
|
7631
7848
|
}
|
|
7632
|
-
/**
|
|
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
|
+
*/
|
|
7633
7865
|
async ensureTables() {
|
|
7634
7866
|
if (this.tablesReady) return;
|
|
7635
|
-
|
|
7636
|
-
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", `
|
|
7637
7870
|
CREATE TABLE IF NOT EXISTS rebase.channel_presence (
|
|
7638
7871
|
channel TEXT NOT NULL,
|
|
7639
7872
|
client_id TEXT NOT NULL,
|
|
@@ -7643,12 +7876,14 @@ var ChannelPresenceStore = class {
|
|
|
7643
7876
|
PRIMARY KEY (channel, client_id)
|
|
7644
7877
|
)
|
|
7645
7878
|
`);
|
|
7646
|
-
await
|
|
7879
|
+
await ddl.ensureObject("channel_presence last_seen index", `
|
|
7647
7880
|
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
7648
7881
|
ON rebase.channel_presence (last_seen)
|
|
7649
7882
|
`);
|
|
7650
|
-
await
|
|
7651
|
-
|
|
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
|
+
}
|
|
7652
7887
|
}
|
|
7653
7888
|
/** Record (or refresh) a client's presence. */
|
|
7654
7889
|
async track(channel, clientId, state) {
|
|
@@ -8983,7 +9218,7 @@ var RealtimeService = class RealtimeService extends EventEmitter {
|
|
|
8983
9218
|
if (this.memoryBusWarned) return;
|
|
8984
9219
|
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
8985
9220
|
this.memoryBusWarned = true;
|
|
8986
|
-
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.");
|
|
8987
9222
|
}
|
|
8988
9223
|
/** Leave a broadcast channel */
|
|
8989
9224
|
leaveChannel(clientId, channel) {
|
|
@@ -12848,6 +13083,43 @@ function resolveDriftCheckName(col, registeredTableNames) {
|
|
|
12848
13083
|
return (isRelationalCollectionConfig(col) ? col.table : void 0) ?? registeredTableNames.find((k) => k === col.slug) ?? col.slug;
|
|
12849
13084
|
}
|
|
12850
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
|
+
/**
|
|
12851
13123
|
* Is this the local database `rebase init` scaffolds — i.e. the one case where
|
|
12852
13124
|
* "you are connected as a superuser" is not news?
|
|
12853
13125
|
*
|
|
@@ -12897,10 +13169,34 @@ function isScaffoldedLocalDatabase(connectionString) {
|
|
|
12897
13169
|
*/
|
|
12898
13170
|
function createPostgresBootstrapper(pgConfig) {
|
|
12899
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
|
+
};
|
|
12900
13196
|
return {
|
|
12901
13197
|
type: "postgres",
|
|
12902
13198
|
async initializeDriver(config) {
|
|
12903
|
-
const { collections, collectionRegistry, introspectCollections, baas } = config;
|
|
13199
|
+
const { collections, collectionRegistry, introspectCollections, baas, schemaProvisioning } = config;
|
|
12904
13200
|
const unprotectedTables = baas?.unprotectedTables ?? "exclude";
|
|
12905
13201
|
const connection = pgConfig.connection;
|
|
12906
13202
|
const rawClient = connection && typeof connection === "object" && "$client" in connection ? connection.$client : connection;
|
|
@@ -13117,22 +13413,18 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13117
13413
|
" (`?options=-c%20search_path%3Dpublic`).",
|
|
13118
13414
|
""
|
|
13119
13415
|
];
|
|
13416
|
+
const cause = describeSchemaDriftCause(schemaProvisioning);
|
|
13120
13417
|
logger.warn([
|
|
13121
13418
|
"",
|
|
13122
13419
|
"⚠️ SCHEMA DRIFT — the database is missing tables this backend serves:",
|
|
13123
13420
|
...lines,
|
|
13124
13421
|
"",
|
|
13125
13422
|
...misplacedHelp,
|
|
13126
|
-
|
|
13127
|
-
"
|
|
13128
|
-
"
|
|
13129
|
-
" • Managed cloud:
|
|
13130
|
-
"
|
|
13131
|
-
" If the log above says the driver does not implement collection-table",
|
|
13132
|
-
" creation, THIS driver is too old to do it. A driver is installed from",
|
|
13133
|
-
" your bundle's dependencies, not supplied by the platform image, so a",
|
|
13134
|
-
" newer runtime will not update it: bump \"@rebasepro/server-postgres\"",
|
|
13135
|
-
" 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.",
|
|
13136
13428
|
" • Self-host: run `rebase db push` (dev) or `rebase db migrate` (prod)",
|
|
13137
13429
|
" against DATABASE_URL.",
|
|
13138
13430
|
""
|
|
@@ -13220,12 +13512,8 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13220
13512
|
* proved it can bootstrap with.
|
|
13221
13513
|
*/
|
|
13222
13514
|
async ensureCollectionSchema(collections, driverResult, log) {
|
|
13223
|
-
const
|
|
13224
|
-
const
|
|
13225
|
-
const plan = await ensureCollectionTables({ async query(text) {
|
|
13226
|
-
const result = await internals.db.execute(sql.raw(text));
|
|
13227
|
-
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
13228
|
-
} }, collections, log);
|
|
13515
|
+
const { ensureCollectionTables } = await import("./ensure-collection-tables-DT2eq859.js");
|
|
13516
|
+
const plan = await ensureCollectionTables(provisioningQueryable(driverResult), collections, log);
|
|
13229
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}`);
|
|
13230
13518
|
return { applied: plan.actions.length - plan.failures.length };
|
|
13231
13519
|
},
|
|
@@ -13245,12 +13533,9 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13245
13533
|
* stays RLS-enabled, so it denies rather than leaks.
|
|
13246
13534
|
*/
|
|
13247
13535
|
async ensureCollectionPolicies(collections, driverResult, log) {
|
|
13248
|
-
const
|
|
13249
|
-
const
|
|
13250
|
-
const outcome = await ensureCollectionPolicies(
|
|
13251
|
-
const result = await internals.db.execute(sql.raw(text));
|
|
13252
|
-
return { rows: result.rows ?? (Array.isArray(result) ? result : []) };
|
|
13253
|
-
} }, 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);
|
|
13254
13539
|
for (const skip of outcome.skipped) logger.warn(`🔐 [rls] Policies not applied to "${skip.table}": ${skip.reason}`);
|
|
13255
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}`);
|
|
13256
13541
|
const unrevoked = outcome.unsecured.filter((u) => !u.grantWithdrawn);
|
|
@@ -13258,9 +13543,7 @@ function createPostgresBootstrapper(pgConfig) {
|
|
|
13258
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.`);
|
|
13259
13544
|
try {
|
|
13260
13545
|
const { dropLegacyAuthSchema } = await import("./rls-bootstrap-sql-69hYT8nr.js").then((n) => n.n);
|
|
13261
|
-
await dropLegacyAuthSchema(async (text) => {
|
|
13262
|
-
return (await internals.db.execute(sql.raw(text))).rows ?? [];
|
|
13263
|
-
}, {
|
|
13546
|
+
await dropLegacyAuthSchema(async (text) => (await queryable.query(text)).rows, {
|
|
13264
13547
|
info: (m) => logger.info(m),
|
|
13265
13548
|
warn: (m) => logger.warn(m)
|
|
13266
13549
|
});
|
|
@@ -13314,6 +13597,6 @@ function createPostgresAdapter(pgConfig) {
|
|
|
13314
13597
|
};
|
|
13315
13598
|
}
|
|
13316
13599
|
//#endregion
|
|
13317
|
-
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 };
|
|
13318
13601
|
|
|
13319
13602
|
//# sourceMappingURL=index.es.js.map
|