@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
|
@@ -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
|
/**
|
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 });
|