@rebasepro/server-postgres 0.9.1-canary.7dddf96 → 0.9.1-canary.97e305f
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/PostgresBootstrapper.d.ts +10 -0
- package/dist/auth/services.d.ts +16 -0
- package/dist/connection.d.ts +21 -0
- package/dist/index.es.js +455 -243
- package/dist/index.es.js.map +1 -1
- package/dist/schema/doctor.d.ts +1 -1
- package/dist/services/FetchService.d.ts +4 -38
- package/dist/services/RelationService.d.ts +34 -0
- package/dist/services/collection-helpers.d.ts +34 -7
- package/dist/services/row-pipeline.d.ts +63 -0
- package/package.json +10 -7
- package/src/PostgresBackendDriver.ts +18 -5
- package/src/PostgresBootstrapper.ts +51 -2
- package/src/auth/ensure-tables.ts +73 -11
- package/src/auth/services.ts +49 -19
- package/src/connection.ts +61 -1
- package/src/databasePoolManager.ts +2 -0
- package/src/schema/doctor.ts +45 -20
- package/src/schema/introspect-db.ts +19 -2
- package/src/services/FetchService.ts +29 -163
- package/src/services/PersistService.ts +9 -2
- package/src/services/RelationService.ts +153 -93
- package/src/services/collection-helpers.ts +59 -27
- package/src/services/realtimeService.ts +4 -2
- package/src/services/row-pipeline.ts +239 -0
- package/src/utils/drizzle-conditions.ts +13 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { CollectionConfig, Property, Relation } from "@rebasepro/types";
|
|
2
|
+
import { resolveCollectionRelations, findRelation, createRelationRefWithData } from "@rebasepro/common";
|
|
3
|
+
import { normalizeDbValues } from "../data-transformer";
|
|
4
|
+
import { deriveRowAddress } from "./collection-helpers";
|
|
5
|
+
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Turning a drizzle result into a row we serve.
|
|
9
|
+
*
|
|
10
|
+
* There are two shapes, and they are the same walk. Both take a row whose
|
|
11
|
+
* relation fields hold nested objects, both unwrap junction rows to reach the
|
|
12
|
+
* target behind them, and both leave every other column alone. They differ only
|
|
13
|
+
* in what they put where the relation was:
|
|
14
|
+
*
|
|
15
|
+
* - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
|
|
16
|
+
* target's values. This is what the admin renders.
|
|
17
|
+
* - `"inline"` — the target's own columns, flat. This is what REST serves.
|
|
18
|
+
*
|
|
19
|
+
* They used to be two functions that happened to agree, and the agreement was
|
|
20
|
+
* not enforced by anything: the row-identity bug had to be fixed five times
|
|
21
|
+
* across differently-shaped copies of this walk, and one of the copies was
|
|
22
|
+
* dead code nobody had noticed. Whatever the next cross-cutting change is, it
|
|
23
|
+
* is one edit here.
|
|
24
|
+
*/
|
|
25
|
+
export type RelationStyle = "ref" | "inline";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a many-relation reaches its target through a junction table.
|
|
29
|
+
*
|
|
30
|
+
* Also used to build the drizzle `with` config, which is why it is exported:
|
|
31
|
+
* the query has to nest one level deeper for a junction, and the row walk has
|
|
32
|
+
* to unwrap that same level back out.
|
|
33
|
+
*/
|
|
34
|
+
export function isJunctionRelation(relation: Relation): boolean {
|
|
35
|
+
// An explicit `through` says so outright.
|
|
36
|
+
if (relation.through) return true;
|
|
37
|
+
// A joinPath with an intermediate table is the same thing, spelled longhand.
|
|
38
|
+
if (relation.joinPath && relation.joinPath.length > 1) return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reach the target row inside a junction row.
|
|
44
|
+
*
|
|
45
|
+
* A junction row looks like `{ post_id: 1, tag_id: { id: 5, name: "ts" } }` —
|
|
46
|
+
* the foreign keys, and the target nested under one of them. The target is the
|
|
47
|
+
* only object among them, so that is how it is found. A junction row that has
|
|
48
|
+
* not been nested (no `with` on the join) has no object and is returned as-is.
|
|
49
|
+
*/
|
|
50
|
+
function unwrapJunctionRow(item: Record<string, unknown>): Record<string, unknown> {
|
|
51
|
+
const nestedKey = Object.keys(item).find(
|
|
52
|
+
key => typeof item[key] === "object" && item[key] !== null && !Array.isArray(item[key])
|
|
53
|
+
);
|
|
54
|
+
return nestedKey ? item[nestedKey] as Record<string, unknown> : item;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Give back the number a `number` property was declared to be.
|
|
59
|
+
*
|
|
60
|
+
* Postgres returns NUMERIC as a string and drizzle keeps it that way, since a
|
|
61
|
+
* numeric can hold more precision than a double. But the collection declared
|
|
62
|
+
* this property `number` and the OpenAPI spec this server publishes says
|
|
63
|
+
* `type: number`, so serving `"9.99"` breaks its own contract — and breaks it
|
|
64
|
+
* asymmetrically, because a create answers with the number and the read that
|
|
65
|
+
* follows answers with the string. Any client that multiplies a price works
|
|
66
|
+
* until the first refresh.
|
|
67
|
+
*
|
|
68
|
+
* Declaring a property `number` already accepts double precision — that is what
|
|
69
|
+
* the admin has always parsed it to. Columns REST serves that no property
|
|
70
|
+
* declares are left exactly as the database returned them.
|
|
71
|
+
*/
|
|
72
|
+
function coerceDeclaredNumber(value: unknown, property: Property | undefined): unknown {
|
|
73
|
+
if (property?.type !== "number" || typeof value !== "string") return value;
|
|
74
|
+
const parsed = parseFloat(value);
|
|
75
|
+
return isNaN(parsed) ? null : parsed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Apply {@link coerceDeclaredNumber} across a row, leaving every other column alone. */
|
|
79
|
+
function coerceDeclaredNumbers(
|
|
80
|
+
row: Record<string, unknown>,
|
|
81
|
+
collection: CollectionConfig
|
|
82
|
+
): Record<string, unknown> {
|
|
83
|
+
const properties = collection.properties;
|
|
84
|
+
if (!properties) return row;
|
|
85
|
+
|
|
86
|
+
const out: Record<string, unknown> = {};
|
|
87
|
+
for (const [key, value] of Object.entries(row)) {
|
|
88
|
+
out[key] = coerceDeclaredNumber(value, properties[key] as Property | undefined);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Render one target row in the requested style. */
|
|
94
|
+
/**
|
|
95
|
+
* Drop every column the collection marked `excludeFromApi`.
|
|
96
|
+
*
|
|
97
|
+
* Password hashes and verification tokens have to be readable server-side but
|
|
98
|
+
* must never reach a client — and "never" has to mean every exit from this
|
|
99
|
+
* pipeline, including relation targets, or a secret leaks through whichever
|
|
100
|
+
* path was overlooked. Keyed by both the property name and its column name,
|
|
101
|
+
* since a row can arrive keyed either way depending on the caller.
|
|
102
|
+
*/
|
|
103
|
+
function stripExcluded(
|
|
104
|
+
row: Record<string, unknown>,
|
|
105
|
+
collection: CollectionConfig
|
|
106
|
+
): Record<string, unknown> {
|
|
107
|
+
const properties = collection.properties as Record<string, Property> | undefined;
|
|
108
|
+
if (!properties) return row;
|
|
109
|
+
|
|
110
|
+
for (const [key, property] of Object.entries(properties)) {
|
|
111
|
+
if (!property?.excludeFromApi) continue;
|
|
112
|
+
delete row[key];
|
|
113
|
+
if (property.columnName) delete row[property.columnName];
|
|
114
|
+
}
|
|
115
|
+
return row;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderTarget(
|
|
119
|
+
targetRow: Record<string, unknown>,
|
|
120
|
+
targetCollection: CollectionConfig,
|
|
121
|
+
style: RelationStyle,
|
|
122
|
+
registry: PostgresCollectionRegistry
|
|
123
|
+
): unknown {
|
|
124
|
+
if (style === "inline") {
|
|
125
|
+
// The target's columns, and only those: its address is the consumer's
|
|
126
|
+
// to derive, and merging one in overwrites a real `id` column.
|
|
127
|
+
return stripExcluded(coerceDeclaredNumbers({ ...targetRow }, targetCollection), targetCollection);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const address = relationTargetAddress(targetRow, targetCollection, registry);
|
|
131
|
+
const path = targetCollection.slug;
|
|
132
|
+
return createRelationRefWithData(address, path, {
|
|
133
|
+
id: address,
|
|
134
|
+
path,
|
|
135
|
+
values: normalizeDbValues(targetRow, targetCollection)
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The address a relation ref points at.
|
|
141
|
+
*
|
|
142
|
+
* The whole key, not its first column: a composite-keyed target addressed by
|
|
143
|
+
* `tenant_id` alone points at every row that shares it. A target whose key
|
|
144
|
+
* cannot be resolved at all used to throw here — reading `[0]` of an empty
|
|
145
|
+
* array — taking down the parent's fetch over a relation it may not even have
|
|
146
|
+
* asked for. The first column is a guess, but a ref that resolves to nothing
|
|
147
|
+
* beats no rows at all.
|
|
148
|
+
*/
|
|
149
|
+
export function relationTargetAddress(
|
|
150
|
+
targetRow: Record<string, unknown>,
|
|
151
|
+
targetCollection: CollectionConfig,
|
|
152
|
+
registry: PostgresCollectionRegistry
|
|
153
|
+
): string {
|
|
154
|
+
const address = deriveRowAddress(targetRow, targetCollection, registry);
|
|
155
|
+
if (address) return address;
|
|
156
|
+
return String(targetRow[Object.keys(targetRow)[0]] ?? "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The row the admin renders: every column, with relations as references.
|
|
161
|
+
*
|
|
162
|
+
* Values are normalized (dates, numbers, NaN) because the admin's view-model
|
|
163
|
+
* expects real types. The row's own address is *not* among the columns — it is
|
|
164
|
+
* derived by the consumer from the collection's primary keys.
|
|
165
|
+
*/
|
|
166
|
+
export function toCmsRow(
|
|
167
|
+
row: Record<string, unknown>,
|
|
168
|
+
collection: CollectionConfig,
|
|
169
|
+
registry: PostgresCollectionRegistry
|
|
170
|
+
): Record<string, unknown> {
|
|
171
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
172
|
+
const normalized = normalizeDbValues(row, collection) as Record<string, unknown>;
|
|
173
|
+
|
|
174
|
+
// Keyed by relation, reading the drizzle relation name off the raw row: the
|
|
175
|
+
// relation's property key and the name drizzle nested it under are not
|
|
176
|
+
// always the same, and the value is written back under the property key.
|
|
177
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
178
|
+
const relData = row[relation.relationName || key];
|
|
179
|
+
if (relData === undefined || relData === null) continue;
|
|
180
|
+
|
|
181
|
+
if (relation.cardinality === "many" && Array.isArray(relData)) {
|
|
182
|
+
const targetCollection = relation.target();
|
|
183
|
+
normalized[key] = relData.map((item: Record<string, unknown>) =>
|
|
184
|
+
renderTarget(
|
|
185
|
+
isJunctionRelation(relation) ? unwrapJunctionRow(item) : item,
|
|
186
|
+
targetCollection,
|
|
187
|
+
"ref",
|
|
188
|
+
registry
|
|
189
|
+
));
|
|
190
|
+
} else if (relation.cardinality === "one" && typeof relData === "object" && !Array.isArray(relData)) {
|
|
191
|
+
normalized[key] = renderTarget(relData as Record<string, unknown>, relation.target(), "ref", registry);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return stripExcluded(normalized, collection);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The row REST serves: every column under its own name, with the value Postgres
|
|
200
|
+
* returned, and relations inlined as the target's columns.
|
|
201
|
+
*
|
|
202
|
+
* Values are the ones the database returned, except where that contradicts the
|
|
203
|
+
* declared type: a `number` property is served as a number (see
|
|
204
|
+
* {@link coerceDeclaredNumber}). Dates stay as the database returned them —
|
|
205
|
+
* JSON has its own opinions about dates that the admin's view-model does not
|
|
206
|
+
* share.
|
|
207
|
+
*
|
|
208
|
+
* Keyed by the row rather than by the relation list — a REST fetch only loads
|
|
209
|
+
* the relations `include` asked for, so the row is the authority on which are
|
|
210
|
+
* actually there.
|
|
211
|
+
*/
|
|
212
|
+
export function toRestRow(
|
|
213
|
+
row: Record<string, unknown>,
|
|
214
|
+
collection: CollectionConfig,
|
|
215
|
+
registry: PostgresCollectionRegistry
|
|
216
|
+
): Record<string, unknown> {
|
|
217
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
218
|
+
const flat: Record<string, unknown> = {};
|
|
219
|
+
|
|
220
|
+
for (const [key, value] of Object.entries(row)) {
|
|
221
|
+
const relation = findRelation(resolvedRelations, key);
|
|
222
|
+
|
|
223
|
+
if (relation && Array.isArray(value)) {
|
|
224
|
+
flat[key] = value.map((item: Record<string, unknown>) =>
|
|
225
|
+
renderTarget(
|
|
226
|
+
isJunctionRelation(relation) ? unwrapJunctionRow(item) : item,
|
|
227
|
+
relation.target(),
|
|
228
|
+
"inline",
|
|
229
|
+
registry
|
|
230
|
+
));
|
|
231
|
+
} else if (relation && typeof value === "object" && value !== null) {
|
|
232
|
+
flat[key] = renderTarget(value as Record<string, unknown>, relation.target(), "inline", registry);
|
|
233
|
+
} else {
|
|
234
|
+
flat[key] = coerceDeclaredNumber(value, collection.properties?.[key] as Property | undefined);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return stripExcluded(flat, collection);
|
|
239
|
+
}
|
|
@@ -1126,6 +1126,19 @@ export class DrizzleConditionBuilder {
|
|
|
1126
1126
|
throw new Error(`Vector column '${vectorSearch.property}' not found in table`);
|
|
1127
1127
|
}
|
|
1128
1128
|
|
|
1129
|
+
// The vector is interpolated as a raw SQL literal below (pgvector has no
|
|
1130
|
+
// bind form for the `::vector` cast), so every element must be a finite
|
|
1131
|
+
// number. The REST query parser already enforces this, but this builder
|
|
1132
|
+
// is a shared entry point — validate here too so no future caller can
|
|
1133
|
+
// turn an unchecked value into SQL injection.
|
|
1134
|
+
if (
|
|
1135
|
+
!Array.isArray(vectorSearch.vector) ||
|
|
1136
|
+
vectorSearch.vector.length === 0 ||
|
|
1137
|
+
!vectorSearch.vector.every((n) => typeof n === "number" && Number.isFinite(n))
|
|
1138
|
+
) {
|
|
1139
|
+
throw new Error("Vector search requires a non-empty array of finite numbers");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1129
1142
|
const vectorLiteral = `'[${vectorSearch.vector.join(",")}]'::vector`;
|
|
1130
1143
|
const distanceFn = vectorSearch.distance || "cosine";
|
|
1131
1144
|
|