@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.
Files changed (43) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +19 -0
  3. package/dist/auth/services.d.ts +10 -0
  4. package/dist/{auth-users-columns-BfQHf9JE.js → auth-users-columns-C-FDnL_e.js} +245 -15
  5. package/dist/auth-users-columns-C-FDnL_e.js.map +1 -0
  6. package/dist/data_driver-ULAyJEi9.js.map +1 -1
  7. package/dist/{ensure-collection-policies-8vuu-n4r.js → ensure-collection-policies-DoHwhVf8.js} +3 -3
  8. package/dist/{ensure-collection-policies-8vuu-n4r.js.map → ensure-collection-policies-DoHwhVf8.js.map} +1 -1
  9. package/dist/{ensure-collection-tables-CbvaGuVn.js → ensure-collection-tables-DT2eq859.js} +45 -5
  10. package/dist/{ensure-collection-tables-CbvaGuVn.js.map → ensure-collection-tables-DT2eq859.js.map} +1 -1
  11. package/dist/index.es.js +543 -105
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/{rls-enforcement-BJ_3wxwg.js → rls-enforcement-gUNDfm7l.js} +2 -2
  14. package/dist/{rls-enforcement-BJ_3wxwg.js.map → rls-enforcement-gUNDfm7l.js.map} +1 -1
  15. package/dist/schema/drizzle-ddl.d.ts +9 -0
  16. package/dist/services/FetchService.d.ts +81 -5
  17. package/dist/services/RelationService.d.ts +3 -3
  18. package/dist/services/channel-presence.d.ts +16 -1
  19. package/dist/services/dataService.d.ts +6 -4
  20. package/dist/services/realtimeService.d.ts +54 -10
  21. package/dist/src-DCdn3Val.js.map +1 -1
  22. package/dist/utils/drizzle-conditions.d.ts +25 -0
  23. package/dist/{websocket-C8ZqVBiV.js → websocket-D2jXv0Ds.js} +29 -2
  24. package/dist/websocket-D2jXv0Ds.js.map +1 -0
  25. package/package.json +6 -6
  26. package/src/PostgresBackendDriver.ts +7 -3
  27. package/src/PostgresBootstrapper.ts +105 -41
  28. package/src/auth/services.ts +26 -5
  29. package/src/schema/drizzle-ddl.ts +33 -0
  30. package/src/schema/ensure-collection-tables.test.ts +99 -0
  31. package/src/schema/ensure-collection-tables.ts +65 -3
  32. package/src/schema/generate-drizzle-schema-logic.ts +19 -1
  33. package/src/services/FetchService.ts +310 -63
  34. package/src/services/RelationService.ts +3 -3
  35. package/src/services/channel-history.ts +38 -6
  36. package/src/services/channel-presence.ts +31 -7
  37. package/src/services/dataService.ts +6 -4
  38. package/src/services/pg-notify-listener.ts +14 -0
  39. package/src/services/realtimeService.ts +161 -41
  40. package/src/utils/drizzle-conditions.ts +155 -5
  41. package/src/websocket.ts +44 -1
  42. package/dist/auth-users-columns-BfQHf9JE.js.map +0 -1
  43. package/dist/websocket-C8ZqVBiV.js.map +0 -1
@@ -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
  /**
package/src/websocket.ts CHANGED
@@ -7,7 +7,7 @@ import type { User } from "@rebasepro/types";
7
7
  import { WebSocketServer, WebSocket } from "ws";
8
8
  import { Server } from "http";
9
9
  import { inspect } from "util";
10
- import { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth } from "@rebasepro/server";
10
+ import { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth, assertWriteRequestValid, ApiError } from "@rebasepro/server";
11
11
  import { logger } from "@rebasepro/server";
12
12
 
13
13
  /** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
@@ -320,6 +320,20 @@ roles: verifiedUser.roles }
320
320
  }
321
321
  }
322
322
 
323
+ /**
324
+ * Apply the REST layer's write checks to a socket payload.
325
+ *
326
+ * Silent when the path names no registered collection: the
327
+ * driver decides what a path means, and refusing here would
328
+ * turn "unknown collection" into a validation error.
329
+ */
330
+ const assertWriteRequest = (path: string | undefined, values: unknown): void => {
331
+ if (!path || !values || typeof values !== "object") return;
332
+ const collection = driver.registry?.getCollectionByPath(path);
333
+ if (!collection) return;
334
+ assertWriteRequestValid(values as Record<string, unknown>, collection);
335
+ };
336
+
323
337
  // Helper to get correctly scoped delegate for the current request
324
338
  const getScopedDelegate = async (): Promise<DataDriver> => {
325
339
  const session = clientSessions.get(clientId);
@@ -404,6 +418,18 @@ roles: verifiedUser.roles }
404
418
  const request: SaveProps = payload;
405
419
  wsDebug("💾 [WebSocket Server] Saving row with request:", inspect(request, { depth: null,
406
420
  colors: true }));
421
+ // The same two checks the REST write routes run, on the
422
+ // same input, at the same point. This socket is the
423
+ // other request boundary — the comment on `requireAuth`
424
+ // above says so — and it used to hand the client's
425
+ // payload straight to the driver, so a value the HTTP
426
+ // API answers 400 for was written when it arrived here.
427
+ //
428
+ // The collection comes from the registry by path, never
429
+ // from `request.collection`: that field is client-
430
+ // supplied, and reading the rules out of it would let
431
+ // the caller choose which rules to be checked against.
432
+ assertWriteRequest(request.path, request.values as Record<string, unknown>);
407
433
  const delegate = await getScopedDelegate();
408
434
  const row = await delegate.save(request);
409
435
  wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, { depth: null,
@@ -741,6 +767,23 @@ code: "INVALID_LIMIT" } }
741
767
  }));
742
768
  return;
743
769
  }
770
+ // A refused write is the caller's mistake, and its message is
771
+ // the only thing that says what to send instead — the same
772
+ // reasoning as `ListLimitError` above. Left to the generic
773
+ // branch it becomes INTERNAL_ERROR with the text dropped in
774
+ // production, so the socket would refuse the write and decline
775
+ // to say why.
776
+ if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
777
+ const apiError = error as ApiError;
778
+ logger.warn(`[WebSocket Server] Refused a write: ${apiError.message}`);
779
+ ws.send(JSON.stringify({
780
+ type: "ERROR",
781
+ requestId,
782
+ payload: { error: { message: apiError.message,
783
+ code: apiError.code } }
784
+ }));
785
+ return;
786
+ }
744
787
  logger.error("💥 [WebSocket Server] Error handling message", { error: error });
745
788
  if (error instanceof Error) {
746
789
  logger.error("Stack trace", { detail: error.stack });