@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
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { sql } from "drizzle-orm";
|
|
30
30
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
31
31
|
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
32
|
+
import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
|
|
32
33
|
|
|
33
34
|
/** A tracked client, as any instance sees it. */
|
|
34
35
|
export interface PresenceRow {
|
|
@@ -45,17 +46,34 @@ export class ChannelPresenceStore {
|
|
|
45
46
|
private readonly instanceId: string
|
|
46
47
|
) {}
|
|
47
48
|
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Create the roster table. Idempotent, and safe to run on every instance at
|
|
51
|
+
* once.
|
|
52
|
+
*
|
|
53
|
+
* Written as separate contained steps rather than one straight sequence for
|
|
54
|
+
* a reason that only bites with more than one replica, which is exactly the
|
|
55
|
+
* deployment shape this table exists to serve: `CREATE … IF NOT EXISTS`
|
|
56
|
+
* reads the catalog and then writes to it non-atomically, so peers booting
|
|
57
|
+
* together collide, and the loser used to abandon everything after it —
|
|
58
|
+
* including the trailing `REVOKE`. That revoke is the only thing keeping the
|
|
59
|
+
* roster off the end-user role, so losing a boot race silently left the
|
|
60
|
+
* whole channel roster readable by every signed-in user.
|
|
61
|
+
*
|
|
62
|
+
* `tablesReady` is now set from a probe of what exists, not from having been
|
|
63
|
+
* the instance that created it.
|
|
64
|
+
*/
|
|
49
65
|
async ensureTables(): Promise<void> {
|
|
50
66
|
if (this.tablesReady) return;
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
const ddl = drizzleDdlBootstrapper(this.db, "channel-presence");
|
|
69
|
+
|
|
70
|
+
await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
|
|
53
71
|
|
|
54
72
|
// Keyed by (channel, client_id): a client id is globally unique, so the
|
|
55
73
|
// instance is a column rather than part of the identity — a client that
|
|
56
74
|
// reconnects onto another replica replaces its own row instead of
|
|
57
75
|
// appearing twice in the roster.
|
|
58
|
-
await
|
|
76
|
+
await ddl.ensureObject("channel_presence table", `
|
|
59
77
|
CREATE TABLE IF NOT EXISTS rebase.channel_presence (
|
|
60
78
|
channel TEXT NOT NULL,
|
|
61
79
|
client_id TEXT NOT NULL,
|
|
@@ -67,7 +85,7 @@ export class ChannelPresenceStore {
|
|
|
67
85
|
`);
|
|
68
86
|
|
|
69
87
|
// The sweep's access path; the roster read rides the primary key.
|
|
70
|
-
await
|
|
88
|
+
await ddl.ensureObject("channel_presence last_seen index", `
|
|
71
89
|
CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
|
|
72
90
|
ON rebase.channel_presence (last_seen)
|
|
73
91
|
`);
|
|
@@ -82,9 +100,15 @@ export class ChannelPresenceStore {
|
|
|
82
100
|
// see `docs/channel-authorization.md` for what it does *not*
|
|
83
101
|
// yet decide. Revoke the schema-wide grant the driver handed out
|
|
84
102
|
// before this table existed.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this
|
|
103
|
+
//
|
|
104
|
+
// Driven off the probe, not off who won the create: the privilege has to
|
|
105
|
+
// come off whether this instance created the table or found it.
|
|
106
|
+
if (await ddl.isReadable("rebase.channel_presence")) {
|
|
107
|
+
await ddl.step("channel_presence revoke", () =>
|
|
108
|
+
this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")))
|
|
109
|
+
);
|
|
110
|
+
this.tablesReady = true;
|
|
111
|
+
}
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
/** Record (or refresh) a client's presence. */
|
|
@@ -1470,7 +1470,7 @@ roles: activeAuth.roles },
|
|
|
1470
1470
|
logger.warn(
|
|
1471
1471
|
"⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
|
|
1472
1472
|
"instance have been seen — this deployment runs more than one process. Broadcast and presence " +
|
|
1473
|
-
"reach only the clients connected to this one. Set `realtime.bus` (or
|
|
1473
|
+
"reach only the clients connected to this one. Set `realtime.bus` (or REALTIME_CHANNEL_BUS=postgres) " +
|
|
1474
1474
|
"to make channels cross-instance."
|
|
1475
1475
|
);
|
|
1476
1476
|
}
|
|
@@ -199,6 +199,13 @@ export interface FilterCompilationOptions {
|
|
|
199
199
|
*/
|
|
200
200
|
type FilterTarget =
|
|
201
201
|
| { kind: "column"; column: AnyPgColumn }
|
|
202
|
+
| {
|
|
203
|
+
/** A path *inside* a json/jsonb column — `metadata->>country`. */
|
|
204
|
+
kind: "json";
|
|
205
|
+
column: AnyPgColumn;
|
|
206
|
+
/** The keys to walk, outermost first. Always at least one. */
|
|
207
|
+
path: string[];
|
|
208
|
+
}
|
|
202
209
|
| {
|
|
203
210
|
kind: "relation";
|
|
204
211
|
relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany;
|
|
@@ -207,6 +214,31 @@ type FilterTarget =
|
|
|
207
214
|
sourceIdColumn: AnyPgColumn;
|
|
208
215
|
};
|
|
209
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Split `metadata->address->>city` into its column and its path.
|
|
219
|
+
*
|
|
220
|
+
* The arrows are PostgREST's spelling and Postgres's own, so the filter reads
|
|
221
|
+
* the same as the SQL it becomes — and, more usefully, the same as what someone
|
|
222
|
+
* would have written by hand in the SQL console while working out what to ask
|
|
223
|
+
* for. A field with no arrow is not a JSON path and returns `undefined`, which
|
|
224
|
+
* leaves every existing filter on exactly the path it took before.
|
|
225
|
+
*
|
|
226
|
+
* Both arrows are accepted and mean the same thing here: the extraction is
|
|
227
|
+
* always compiled to `->>` (text) at the leaf, because that is the only form a
|
|
228
|
+
* comparison can be made against. `->` is allowed because people write it out
|
|
229
|
+
* of habit, and refusing it would be pedantry about a distinction this layer
|
|
230
|
+
* erases anyway.
|
|
231
|
+
*/
|
|
232
|
+
function parseJsonFieldPath(field: string): { columnKey: string; path: string[] } | undefined {
|
|
233
|
+
if (!field.includes("->")) return undefined;
|
|
234
|
+
|
|
235
|
+
const segments = field.split(/->>?/).map(s => s.trim()).filter(Boolean);
|
|
236
|
+
if (segments.length < 2) return undefined;
|
|
237
|
+
|
|
238
|
+
const [columnKey, ...path] = segments;
|
|
239
|
+
return { columnKey, path };
|
|
240
|
+
}
|
|
241
|
+
|
|
210
242
|
/**
|
|
211
243
|
* Filter values may arrive as relation wire objects — `EntityRelation`
|
|
212
244
|
* instances or their JSON form `{ __type: "relation", id, path }` — e.g. when
|
|
@@ -484,6 +516,26 @@ export class DrizzleConditionBuilder {
|
|
|
484
516
|
const direct = columnAt(field);
|
|
485
517
|
if (direct) return { kind: "column", column: direct };
|
|
486
518
|
|
|
519
|
+
// Checked after the direct lookup, so a column literally named with an
|
|
520
|
+
// arrow — which Postgres permits, if someone quoted it — still wins.
|
|
521
|
+
const jsonPath = parseJsonFieldPath(field);
|
|
522
|
+
if (jsonPath) {
|
|
523
|
+
const base = columnAt(jsonPath.columnKey);
|
|
524
|
+
if (base) {
|
|
525
|
+
const meta = getColumnMeta(base);
|
|
526
|
+
// Refused rather than compiled: `->>` on a text column is a
|
|
527
|
+
// Postgres error at execution time, which surfaces as a 500 on
|
|
528
|
+
// a request whose only fault is a typo'd column name.
|
|
529
|
+
if (meta.dataType !== "json" && meta.columnType !== "PgJsonb" && meta.columnType !== "PgJson") {
|
|
530
|
+
throw ApiError.badRequest(
|
|
531
|
+
`Cannot filter inside "${jsonPath.columnKey}" — it is not a json or jsonb column.`,
|
|
532
|
+
"INVALID_FILTER_FIELD"
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
return { kind: "json", column: base, path: jsonPath.path };
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
487
539
|
if (collection) {
|
|
488
540
|
const relation = resolveCollectionRelations(collection)[field];
|
|
489
541
|
|
|
@@ -630,11 +682,109 @@ export class DrizzleConditionBuilder {
|
|
|
630
682
|
field: string,
|
|
631
683
|
collectionPath: string
|
|
632
684
|
): SQL | null {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
);
|
|
685
|
+
if (target.kind === "column") {
|
|
686
|
+
return this.buildSingleFilterCondition(target.column, op, value);
|
|
687
|
+
}
|
|
688
|
+
if (target.kind === "json") {
|
|
689
|
+
return this.buildJsonPathCondition(target.column, target.path, op, value);
|
|
690
|
+
}
|
|
691
|
+
return this.buildRelationFilterCondition(
|
|
692
|
+
target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* A comparison against a value extracted from a json/jsonb column.
|
|
698
|
+
*
|
|
699
|
+
* The path is walked with `->` and the leaf taken with `->>`, so what comes
|
|
700
|
+
* out is always **text**. That is the whole of the type story, and it is
|
|
701
|
+
* the part worth being explicit about, because the alternatives are all
|
|
702
|
+
* worse:
|
|
703
|
+
*
|
|
704
|
+
* - text comparison alone makes `["<", 100]` compare lexically, where
|
|
705
|
+
* `"9"` is greater than `"100"`;
|
|
706
|
+
* - casting unconditionally makes every filter on a non-numeric value a
|
|
707
|
+
* runtime `invalid input syntax for type numeric` — a 500 on a row whose
|
|
708
|
+
* JSON simply holds a string.
|
|
709
|
+
*
|
|
710
|
+
* So the *filter value* decides. A number on an ordering comparison casts
|
|
711
|
+
* both sides to numeric; everything else compares as text, with booleans
|
|
712
|
+
* rendered the way `->>` renders them (`"true"` / `"false"`). A row whose
|
|
713
|
+
* JSON holds a non-numeric value at a path being compared numerically is
|
|
714
|
+
* excluded rather than fatal, which is what `IS NOT NULL`-style filtering
|
|
715
|
+
* means everywhere else in this file.
|
|
716
|
+
*
|
|
717
|
+
* The path segments are bound as parameters, never interpolated: they come
|
|
718
|
+
* from a query string, and `->>` takes a text parameter perfectly well.
|
|
719
|
+
*/
|
|
720
|
+
private static buildJsonPathCondition(
|
|
721
|
+
column: AnyPgColumn,
|
|
722
|
+
path: string[],
|
|
723
|
+
op: WhereFilterOp,
|
|
724
|
+
value: unknown
|
|
725
|
+
): SQL | null {
|
|
726
|
+
// Every segment but the last with `->` (staying in json), the last
|
|
727
|
+
// with `->>` (leaving as text).
|
|
728
|
+
let expr: SQL = sql`${column}`;
|
|
729
|
+
for (const key of path.slice(0, -1)) {
|
|
730
|
+
expr = sql`${expr} -> ${key}`;
|
|
731
|
+
}
|
|
732
|
+
const leaf = sql`${expr} ->> ${path[path.length - 1]}`;
|
|
733
|
+
|
|
734
|
+
const numericComparison = typeof value === "number" &&
|
|
735
|
+
(op === ">" || op === ">=" || op === "<" || op === "<=");
|
|
736
|
+
|
|
737
|
+
if (numericComparison) {
|
|
738
|
+
// The guard is what keeps this from being a 500: rows whose value
|
|
739
|
+
// at this path is not a number are excluded, not fatal.
|
|
740
|
+
const numeric = sql`CASE WHEN ${leaf} ~ '^-?[0-9]+(\\.[0-9]+)?$' THEN (${leaf})::numeric END`;
|
|
741
|
+
switch (op) {
|
|
742
|
+
case ">": return sql`${numeric} > ${value}`;
|
|
743
|
+
case ">=": return sql`${numeric} >= ${value}`;
|
|
744
|
+
case "<": return sql`${numeric} < ${value}`;
|
|
745
|
+
case "<=": return sql`${numeric} <= ${value}`;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const asText = (v: unknown): string => typeof v === "boolean" ? String(v) : String(v);
|
|
750
|
+
|
|
751
|
+
switch (op) {
|
|
752
|
+
case "==":
|
|
753
|
+
return value === null || value === undefined ? sql`${leaf} IS NULL` : sql`${leaf} = ${asText(value)}`;
|
|
754
|
+
case "!=":
|
|
755
|
+
return value === null || value === undefined ? sql`${leaf} IS NOT NULL` : sql`${leaf} != ${asText(value)}`;
|
|
756
|
+
case ">": return sql`${leaf} > ${asText(value)}`;
|
|
757
|
+
case ">=": return sql`${leaf} >= ${asText(value)}`;
|
|
758
|
+
case "<": return sql`${leaf} < ${asText(value)}`;
|
|
759
|
+
case "<=": return sql`${leaf} <= ${asText(value)}`;
|
|
760
|
+
case "like": return sql`${leaf} LIKE ${asText(value)}`;
|
|
761
|
+
case "ilike": return sql`${leaf} ILIKE ${asText(value)}`;
|
|
762
|
+
case "not-like": return sql`${leaf} NOT LIKE ${asText(value)}`;
|
|
763
|
+
case "not-ilike": return sql`${leaf} NOT ILIKE ${asText(value)}`;
|
|
764
|
+
case "is-null": return sql`${leaf} IS NULL`;
|
|
765
|
+
case "is-not-null": return sql`${leaf} IS NOT NULL`;
|
|
766
|
+
case "in":
|
|
767
|
+
case "not-in": {
|
|
768
|
+
if (value === null || value === undefined) {
|
|
769
|
+
return op === "in" ? sql`${leaf} IS NULL` : sql`${leaf} IS NOT NULL`;
|
|
770
|
+
}
|
|
771
|
+
const values = toMembershipList(value).map(asText);
|
|
772
|
+
// Same inversion guard as the column path: an empty list
|
|
773
|
+
// matches nothing, and dropping the condition would match
|
|
774
|
+
// everything.
|
|
775
|
+
if (values.length === 0) return op === "in" ? sql`FALSE` : sql`TRUE`;
|
|
776
|
+
const list = sql.join(values.map(v => sql`${v}`), sql`, `);
|
|
777
|
+
return op === "in" ? sql`${leaf} IN (${list})` : sql`${leaf} NOT IN (${list})`;
|
|
778
|
+
}
|
|
779
|
+
default:
|
|
780
|
+
// `array-contains` and friends are about the column, not a
|
|
781
|
+
// scalar inside it — `metadata @> '{"tags":["x"]}'` is the
|
|
782
|
+
// question, and it is asked of the column directly.
|
|
783
|
+
throw ApiError.badRequest(
|
|
784
|
+
`Operator "${op}" is not supported on a JSON path. Use it on the column itself.`,
|
|
785
|
+
"INVALID_FILTER_OPERATOR"
|
|
786
|
+
);
|
|
787
|
+
}
|
|
638
788
|
}
|
|
639
789
|
|
|
640
790
|
/**
|