@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.
@@ -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
- /** Create the roster table. Idempotent. */
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
- await this.db.execute(sql`CREATE SCHEMA IF NOT EXISTS rebase`);
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 this.db.execute(sql`
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 this.db.execute(sql`
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
- await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")));
86
-
87
- this.tablesReady = true;
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 REBASE_REALTIME_BUS=postgres) " +
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
- return target.kind === "column"
634
- ? this.buildSingleFilterCondition(target.column, op, value)
635
- : this.buildRelationFilterCondition(
636
- target.relation, op, value, target.sourceIdColumn, target.registry, field, collectionPath
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
  /**