@rebasepro/server-postgres 0.12.1-canary.g386667d → 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.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/{backup-service-vAKWJkYL.js → backup-service-DLb2drIH.js} +7 -1
- package/dist/backup-service-DLb2drIH.js.map +1 -0
- package/dist/cli-helpers.d.ts +15 -0
- package/dist/{ensure-collection-policies-BjSwj0FM.js → ensure-collection-policies-Dv21KDMJ.js} +2 -2
- package/dist/{ensure-collection-policies-BjSwj0FM.js.map → ensure-collection-policies-Dv21KDMJ.js.map} +1 -1
- package/dist/{ensure-collection-tables-D6-XhqnQ.js → ensure-collection-tables-CT6xHB1d.js} +72 -12
- package/dist/ensure-collection-tables-CT6xHB1d.js.map +1 -0
- package/dist/index.es.js +74 -21
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +21 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +19 -0
- package/dist/services/FetchService.d.ts +4 -1
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-C_NHNVW2.js → src-BkdpiQdw.js} +137 -18
- package/dist/src-BkdpiQdw.js.map +1 -0
- package/dist/{websocket-DB7TbFPT.js → websocket-B2LsrINK.js} +4 -3
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresAdapter.ts +7 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/cli-helpers.ts +26 -0
- package/src/cli.ts +2 -19
- package/src/schema/ensure-collection-tables.ts +81 -10
- package/src/schema/generate-postgres-ddl-logic.ts +50 -6
- package/src/schema/introspect-db-logic.ts +21 -3
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/FetchService.ts +76 -14
- package/src/services/dataService.ts +3 -1
- package/src/services/row-pipeline.ts +1 -1
- package/src/websocket.ts +15 -6
- package/dist/backup-service-vAKWJkYL.js.map +0 -1
- package/dist/ensure-collection-tables-D6-XhqnQ.js.map +0 -1
- package/dist/src-C_NHNVW2.js.map +0 -1
- 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
|
|
104
|
-
// e.g. "wolves" -> "wolf", "
|
|
105
|
-
return word.slice(0, -3) +
|
|
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
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
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 {
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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)}`;
|