@rebasepro/server-postgres 0.12.1-canary.g389e9b2 → 0.12.1-canary.g4e7bcbf

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 (36) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/{backup-service-vAKWJkYL.js → backup-service-DLb2drIH.js} +7 -1
  3. package/dist/backup-service-DLb2drIH.js.map +1 -0
  4. package/dist/cli-helpers.d.ts +15 -0
  5. package/dist/{ensure-collection-policies-BjSwj0FM.js → ensure-collection-policies-Dv21KDMJ.js} +2 -2
  6. package/dist/{ensure-collection-policies-BjSwj0FM.js.map → ensure-collection-policies-Dv21KDMJ.js.map} +1 -1
  7. package/dist/{ensure-collection-tables-D6-XhqnQ.js → ensure-collection-tables-CT6xHB1d.js} +72 -12
  8. package/dist/ensure-collection-tables-CT6xHB1d.js.map +1 -0
  9. package/dist/index.es.js +74 -21
  10. package/dist/index.es.js.map +1 -1
  11. package/dist/schema/ensure-collection-tables.d.ts +21 -1
  12. package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -0
  13. package/dist/services/FetchService.d.ts +4 -1
  14. package/dist/services/dataService.d.ts +3 -1
  15. package/dist/services/row-pipeline.d.ts +1 -1
  16. package/dist/{src-C_NHNVW2.js → src-BkdpiQdw.js} +137 -18
  17. package/dist/src-BkdpiQdw.js.map +1 -0
  18. package/dist/{websocket-DB7TbFPT.js → websocket-B2LsrINK.js} +4 -3
  19. package/dist/websocket-B2LsrINK.js.map +1 -0
  20. package/package.json +6 -6
  21. package/src/PostgresAdapter.ts +7 -2
  22. package/src/PostgresBackendDriver.ts +4 -0
  23. package/src/cli-helpers.ts +26 -0
  24. package/src/cli.ts +2 -19
  25. package/src/schema/ensure-collection-tables.ts +81 -10
  26. package/src/schema/generate-postgres-ddl-logic.ts +50 -6
  27. package/src/schema/introspect-db-logic.ts +21 -3
  28. package/src/security/policy-drift.test.ts +11 -3
  29. package/src/services/FetchService.ts +76 -14
  30. package/src/services/dataService.ts +3 -1
  31. package/src/services/row-pipeline.ts +1 -1
  32. package/src/websocket.ts +15 -6
  33. package/dist/backup-service-vAKWJkYL.js.map +0 -1
  34. package/dist/ensure-collection-tables-D6-XhqnQ.js.map +0 -1
  35. package/dist/src-C_NHNVW2.js.map +0 -1
  36. package/dist/websocket-DB7TbFPT.js.map +0 -1
@@ -67,6 +67,24 @@ const IRREGULAR_SINGULARS: Record<string, string> = {
67
67
  phenomena: "phenomenon"
68
68
  };
69
69
 
70
+ /**
71
+ * Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
72
+ *
73
+ * A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
74
+ * ordinary "-ive" noun that happens to be plural along with it: `archives` ->
75
+ * `archif`, `objectives` -> `objectif`. The set of English words that genuinely
76
+ * swap f/fe for ves is small and closed, so it is listed rather than guessed —
77
+ * anything else ending in "ves" drops the trailing 's' like any other plural.
78
+ *
79
+ * Matched on the whole word, not on the suffix: `olives` ends in `lives`.
80
+ */
81
+ const VES_SINGULAR_ENDINGS: Record<string, "f" | "fe"> = {
82
+ calves: "f", dwarves: "f", elves: "f", halves: "f", hooves: "f",
83
+ leaves: "f", loaves: "f", scarves: "f", selves: "f", sheaves: "f",
84
+ shelves: "f", thieves: "f", wharves: "f", wolves: "f",
85
+ knives: "fe", lives: "fe", wives: "fe"
86
+ };
87
+
70
88
  /** Words ending in 's' that are already singular. */
71
89
  const UNCOUNTABLE = new Set([
72
90
  "status", "campus", "virus", "bus", "plus", "census",
@@ -100,9 +118,9 @@ export function singularize(word: string): string {
100
118
  if (lower.endsWith("ies") && lower.length > 3) {
101
119
  return word.slice(0, -3) + "y";
102
120
  }
103
- if (lower.endsWith("ves")) {
104
- // e.g. "wolves" -> "wolf", "leaves" -> "leaf"
105
- return word.slice(0, -3) + "f";
121
+ if (VES_SINGULAR_ENDINGS[lower]) {
122
+ // e.g. "wolves" -> "wolf", "knives" -> "knife"
123
+ return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
106
124
  }
107
125
  if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) {
108
126
  return word.slice(0, -2);
@@ -158,15 +158,23 @@ describe("checkPolicyDrift", () => {
158
158
  it("also flags the tautology in a WITH CHECK clause", async () => {
159
159
  const cols = [collection("posts")];
160
160
  const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
161
+ const withCheck = expected.filter((p) => p.hasWithCheck);
162
+ // Asserted rather than assumed: if the generator ever stopped emitting a
163
+ // WITH CHECK for these rules the fixture would carry no tautology at
164
+ // all, and every assertion below would be vacuously satisfied.
165
+ expect(withCheck.length).toBeGreaterThan(0);
166
+
161
167
  const live = expected.map((p) => liveRow(p, {
162
168
  with_check: p.hasWithCheck ? "(auth.uid() IS NOT NULL)" : null
163
169
  }));
164
170
 
165
171
  const drift = await checkPolicyDrift(dbWith(live), cols);
166
172
 
167
- const flagged = drift.insecure.some((i) => /WITH CHECK/.test(i.reason));
168
- // Only assert when the fixture actually had a WITH CHECK policy to carry it.
169
- if (expected.some((p) => p.hasWithCheck)) expect(flagged).toBe(true);
173
+ // Every WITH CHECK policy carries the tautology and no USING clause
174
+ // does, so anything flagged here came from the WITH CHECK scan — the
175
+ // half that a check reading only `qual` would miss entirely.
176
+ expect(drift.insecure.map((i) => i.policy.name)).toEqual(withCheck.map((p) => p.name));
177
+ expect(drift.insecure.every((i) => /WITH CHECK/.test(i.reason))).toBe(true);
170
178
  });
171
179
 
172
180
  it("parses roles when the driver returns the raw {a,b} text form", async () => {
@@ -1,10 +1,10 @@
1
- import { and, asc, count, desc, eq, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
1
+ import { and, asc, count, desc, eq, getTableColumns, getTableName, gt, lt, or, SQL, TableRelationalConfig, TablesRelationalConfig } from "drizzle-orm";
2
2
  import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
3
3
  import { CollectionConfig, FilterValues, ResolvedRelation, LogicalCondition, isManyToMany } from "@rebasepro/types";
4
4
  import type { VectorSearchParams } from "@rebasepro/types";
5
5
  import { resolveCollectionRelations, findRelation, createRelationRef, createRelationRefWithData } from "@rebasepro/common";
6
6
  import { generateForeignKeyName } from "@rebasepro/utils";
7
- import { DrizzleConditionBuilder, type FilterCompilationOptions } from "../utils/drizzle-conditions";
7
+ import { DrizzleConditionBuilder, getUnknownFilterFieldsMode, type FilterCompilationOptions } from "../utils/drizzle-conditions";
8
8
  import {
9
9
  getCollectionByPath,
10
10
  getTableForCollection,
@@ -20,9 +20,9 @@ import { RelationService } from "./RelationService";
20
20
  import { RelationalQueryBuilder } from "drizzle-orm/pg-core/query-builders/query";
21
21
  import { DrizzleClient } from "../interfaces";
22
22
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
23
- import { toCmsRow, toRestRow, isJunctionRelation } from "./row-pipeline";
23
+ import { toFlatRow, toRestRow, isJunctionRelation } from "./row-pipeline";
24
24
  import { isNestedPath, resolveNestedPath, type NestedPathHop } from "./nested-path";
25
- import { logger } from "@rebasepro/server";
25
+ import { ApiError, logger } from "@rebasepro/server";
26
26
 
27
27
  /** Type-safe accessor for Drizzle's relational query API via dynamic table name */
28
28
  type DbQueryAccessor = Record<string, RelationalQueryBuilder<any, any>> | undefined;
@@ -129,12 +129,10 @@ export class FetchService {
129
129
  if (direct) return direct;
130
130
 
131
131
  // Owning relation, resolved: the relation names its own local key.
132
- if (collection) {
133
- const relation = resolveCollectionRelations(collection)[orderBy];
134
- if (relation?.kind === "belongsTo") {
135
- const foreignKey = columnAt(relation.localKey);
136
- if (foreignKey) return foreignKey;
137
- }
132
+ const declaredRelation = collection ? resolveCollectionRelations(collection)[orderBy] : undefined;
133
+ if (declaredRelation?.kind === "belongsTo") {
134
+ const foreignKey = columnAt(declaredRelation.localKey);
135
+ if (foreignKey) return foreignKey;
138
136
  }
139
137
 
140
138
  // No collection in hand — the two shapes an owning relation's key takes
@@ -145,7 +143,61 @@ export class FetchService {
145
143
  if (foreignKey) return foreignKey;
146
144
  }
147
145
 
148
- return undefined;
146
+ // Nothing resolved. Returning `undefined` is exactly what the docblock
147
+ // above describes: the caller drops the ORDER BY and hands back rows in
148
+ // whatever order Postgres pleases, while the requester believes they
149
+ // are sorted. `?orderBy=titel` answered 200 with unsorted data and no
150
+ // hint that the sort had been ignored.
151
+ //
152
+ // A *filter* naming a field that does not exist is already refused for
153
+ // precisely this reason — it "used to widen results silently". An
154
+ // unresolvable sort field is the same drift between a query and the
155
+ // schema, so it answers the same way and honours the same switch: one
156
+ // knob, because a deployment that wants the lenient behaviour wants it
157
+ // for both.
158
+ const collectionName = collection?.slug ?? collection?.name;
159
+ const onCollection = collectionName ? ` on collection '${collectionName}'` : "";
160
+
161
+ // A declared to-many relation is a different mistake from a typo, and
162
+ // saying "unknown field" about a field the collection plainly declares
163
+ // sends the reader looking for a spelling error that is not there.
164
+ // There is simply no single value per row to order by — `posts.tags` is
165
+ // a set — so no ORDER BY exists to write, with or without a typo.
166
+ if (declaredRelation && declaredRelation.kind !== "belongsTo") {
167
+ throw ApiError.badRequest(
168
+ `Cannot sort by '${orderBy}'${onCollection}: it is a to-many relation ` +
169
+ `(${declaredRelation.kind}), which has no single value per row to order by.`,
170
+ "ORDER_BY_FIELD_NOT_SORTABLE",
171
+ { field: orderBy, kind: declaredRelation.kind, ...(collectionName && { collection: collectionName }) }
172
+ );
173
+ }
174
+
175
+ if (getUnknownFilterFieldsMode() === "warn") {
176
+ logger.warn(
177
+ `Sorting by field '${orderBy}'${onCollection}, but it does not exist in the table — ` +
178
+ "the ORDER BY was dropped and these rows are unsorted."
179
+ );
180
+ return undefined;
181
+ }
182
+
183
+ let validFields: string[] = [];
184
+ try {
185
+ validFields = Object.keys(getTableColumns(table)).sort();
186
+ } catch {
187
+ // A table stand-in without Drizzle's column symbols — the message
188
+ // is worth less without the list, but not worth failing over.
189
+ }
190
+
191
+ throw ApiError.badRequest(
192
+ `Unknown orderBy field '${orderBy}'${onCollection}` +
193
+ (validFields.length > 0 ? `. Valid fields: ${validFields.join(", ")}` : ""),
194
+ "UNKNOWN_ORDER_BY_FIELD",
195
+ {
196
+ field: orderBy,
197
+ ...(collectionName && { collection: collectionName }),
198
+ ...(validFields.length > 0 && { validFields })
199
+ }
200
+ );
149
201
  }
150
202
 
151
203
  /**
@@ -555,7 +607,7 @@ idColumn };
555
607
 
556
608
  if (!row) return undefined;
557
609
 
558
- const flatRow = toCmsRow(row, collection, this.registry);
610
+ const flatRow = toFlatRow(row, collection, this.registry);
559
611
 
560
612
  // Post-fetch joinPath relations that Drizzle's `with` can't express
561
613
  await this.resolveJoinPathRelations<M>(flatRow, collection, collectionPath, parsedId, databaseId);
@@ -682,7 +734,7 @@ idColumn };
682
734
  const results = await qb.findMany(queryOpts as Parameters<NonNullable<typeof qb>["findMany"]>[0]);
683
735
 
684
736
  const rows = (results as Record<string, unknown>[]).map(row =>
685
- toCmsRow(row, collection, this.registry)
737
+ toFlatRow(row, collection, this.registry)
686
738
  );
687
739
 
688
740
  return rows;
@@ -785,7 +837,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
785
837
  /**
786
838
  * Fallback path used when db.query is unavailable.
787
839
  *
788
- * The primary path runs the results through `toCmsRow`, which maps
840
+ * The primary path runs the results through `toFlatRow`, which maps
789
841
  * relations from what drizzle already nested — no query per row. This one
790
842
  * has no nesting to read, so it resolves relations itself, in batches.
791
843
  *
@@ -942,6 +994,7 @@ relatedTo: hop });
942
994
  collectionPath: string,
943
995
  options: {
944
996
  filter?: FilterValues<Extract<keyof M, string>>;
997
+ logical?: LogicalCondition;
945
998
  searchString?: string;
946
999
  databaseId?: string;
947
1000
  } = {}
@@ -973,6 +1026,13 @@ relatedTo: hop });
973
1026
  if (filterConditions.length > 0) allConditions.push(...filterConditions);
974
1027
  }
975
1028
 
1029
+ if (options.logical) {
1030
+ const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(
1031
+ options.logical, table, effectivePath, this.filterContext(effectivePath, table)
1032
+ );
1033
+ if (logicalCondition) allConditions.push(logicalCondition);
1034
+ }
1035
+
976
1036
  if (allConditions.length > 0) {
977
1037
  const finalCondition = DrizzleConditionBuilder.combineConditionsWithAnd(allConditions);
978
1038
  if (finalCondition) query = query.where(finalCondition);
@@ -1043,6 +1103,8 @@ relatedTo: hop });
1043
1103
  collectionPath: string,
1044
1104
  options: {
1045
1105
  filter?: FilterValues<Extract<keyof M, string>>;
1106
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
1107
+ logical?: LogicalCondition;
1046
1108
  orderBy?: string;
1047
1109
  order?: "desc" | "asc";
1048
1110
  limit?: number;
@@ -1,5 +1,5 @@
1
1
  // import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
- import { FilterValues } from "@rebasepro/types";
2
+ import { FilterValues, LogicalCondition } from "@rebasepro/types";
3
3
  import type { VectorSearchParams } from "@rebasepro/types";
4
4
  import { FetchService } from "./FetchService";
5
5
  import { PersistService } from "./PersistService";
@@ -97,6 +97,8 @@ export class DataService implements DataRepository {
97
97
  collectionPath: string,
98
98
  options: {
99
99
  filter?: FilterValues<Extract<keyof M, string>>;
100
+ /** An `or(...)`/`and(...)` group, applied alongside `filter`. */
101
+ logical?: LogicalCondition;
100
102
  searchString?: string;
101
103
  databaseId?: string;
102
104
  } = {}
@@ -164,7 +164,7 @@ export function relationTargetAddress(
164
164
  * expects real types. The row's own address is *not* among the columns — it is
165
165
  * derived by the consumer from the collection's primary keys.
166
166
  */
167
- export function toCmsRow(
167
+ export function toFlatRow(
168
168
  row: Record<string, unknown>,
169
169
  collection: CollectionConfig,
170
170
  registry: PostgresCollectionRegistry
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 } from "@rebasepro/server";
10
+ import { extractUserFromToken, AccessTokenPayload, safeCompare, resolveRequireAuth } from "@rebasepro/server";
11
11
  import { logger } from "@rebasepro/server";
12
12
 
13
13
  /** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
@@ -116,11 +116,20 @@ export function createPostgresWebSocket(
116
116
  logger.error("❌ [WebSocket Server] Error", { error: err });
117
117
  });
118
118
 
119
- // Auth is required when either: an adapter is present (secure by default),
120
- // OR the config has a jwtSecret and requireAuth !== false.
121
- const requireAuth = authAdapter
122
- ? true
123
- : (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
119
+ // The same predicate the HTTP data routes use, from the same function —
120
+ // this socket is the other enforcement point for one product decision, and
121
+ // while it computed the answer itself it computed a different one. See
122
+ // `resolveRequireAuth` for what its local copy got wrong and why a `false`
123
+ // here grants access rather than skipping a check.
124
+ const requireAuth = !!authAdapter || resolveRequireAuth(authConfig as never);
125
+
126
+ if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) {
127
+ logger.warn(
128
+ "🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or " +
129
+ "serviceKey is configured — no client can complete AUTH, so every realtime " +
130
+ "message will be refused with UNAUTHORIZED."
131
+ );
132
+ }
124
133
 
125
134
  wss.on("connection", (ws) => {
126
135
  const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;