@rebasepro/server-postgres 0.11.1-canary.gfd39654 → 0.12.0
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 +8 -0
- package/dist/collections/buildRegistry.d.ts +1 -1
- package/dist/{ensure-collection-tables-DGMYK0fr.js → ensure-collection-tables-CNTcZGvn.js} +3 -3
- package/dist/{ensure-collection-tables-DGMYK0fr.js.map → ensure-collection-tables-CNTcZGvn.js.map} +1 -1
- package/dist/history/HistoryService.d.ts +9 -29
- package/dist/index.es.js +397 -53
- package/dist/index.es.js.map +1 -1
- package/dist/schema/dynamic-tables.d.ts +1 -1
- package/dist/schema/introspect-runtime.d.ts +1 -1
- package/dist/services/FetchService.d.ts +36 -1
- package/dist/services/row-pipeline.d.ts +3 -1
- package/dist/{src-3VmUJ8Xn.js → src-BbFOPJ1S.js} +197 -18
- package/dist/src-BbFOPJ1S.js.map +1 -0
- package/dist/{src-D5xBTl32.js → src-Zqwaw3P5.js} +136 -90
- package/dist/src-Zqwaw3P5.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +157 -3
- package/dist/utils/pg-error-utils.d.ts +6 -3
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +23 -6
- package/src/collections/buildRegistry.ts +1 -1
- package/src/history/HistoryService.ts +13 -31
- package/src/schema/dynamic-tables.ts +1 -1
- package/src/schema/generate-drizzle-schema-logic.ts +10 -2
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/services/FetchService.ts +79 -11
- package/src/services/row-pipeline.ts +3 -1
- package/src/utils/drizzle-conditions.ts +509 -45
- package/src/utils/pg-error-utils.ts +52 -3
- package/dist/src-3VmUJ8Xn.js.map +0 -1
- package/dist/src-D5xBTl32.js.map +0 -1
|
@@ -1,7 +1,54 @@
|
|
|
1
1
|
import { SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn, PgTable } from "drizzle-orm/pg-core";
|
|
3
|
-
import { FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation } from "@rebasepro/types";
|
|
3
|
+
import { CollectionConfig, FilterValues, WhereFilterOp, LogicalCondition, FilterCondition, ResolvedRelation, ResolvedForeignKeyOnTarget, ResolvedManyToMany } from "@rebasepro/types";
|
|
4
4
|
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
5
|
+
/**
|
|
6
|
+
* What to do with a filter field that resolves to no column at all.
|
|
7
|
+
*
|
|
8
|
+
* - `"error"` (default) — reject the request. A filter that cannot be
|
|
9
|
+
* compiled is *dropped*, and dropping a condition can only ever widen the
|
|
10
|
+
* result set. On a data plane where row-level security is the last line of
|
|
11
|
+
* defence, a typo'd or renamed filter key therefore runs the query without
|
|
12
|
+
* that condition and returns everything RLS happens to allow.
|
|
13
|
+
* - `"warn"` — the historical behaviour: log and silently drop the condition.
|
|
14
|
+
* Only for a deployment that knowingly sends filter keys the table does not
|
|
15
|
+
* have and has satisfied itself that widening is safe there.
|
|
16
|
+
*/
|
|
17
|
+
export type UnknownFilterFieldsMode = "error" | "warn";
|
|
18
|
+
/** Set the process-wide behaviour for unresolvable filter fields. */
|
|
19
|
+
export declare function configureUnknownFilterFields(mode: UnknownFilterFieldsMode): void;
|
|
20
|
+
/** The process-wide behaviour for unresolvable filter fields. */
|
|
21
|
+
export declare function getUnknownFilterFieldsMode(): UnknownFilterFieldsMode;
|
|
22
|
+
/** Per-call context for compiling a filter into SQL. */
|
|
23
|
+
export interface FilterCompilationOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Overrides the process-wide {@link UnknownFilterFieldsMode} for this call.
|
|
26
|
+
*/
|
|
27
|
+
unknownFields?: UnknownFilterFieldsMode;
|
|
28
|
+
/**
|
|
29
|
+
* The collection the filter is written against. Its resolved relations are
|
|
30
|
+
* what turn an owning-relation filter key into the foreign-key column it
|
|
31
|
+
* actually lives in; without it only the default key shapes can be guessed.
|
|
32
|
+
*/
|
|
33
|
+
collection?: CollectionConfig;
|
|
34
|
+
/**
|
|
35
|
+
* The driver's registry, for relations whose link is not on this row at
|
|
36
|
+
* all. A `manyToMany` compiles to an `EXISTS` over its junction and a
|
|
37
|
+
* `hasMany`/`hasOne` to one over the target table — neither of which this
|
|
38
|
+
* builder can reach from the collection alone.
|
|
39
|
+
*/
|
|
40
|
+
registry?: PostgresCollectionRegistry;
|
|
41
|
+
/**
|
|
42
|
+
* The key column of the table being filtered — what those `EXISTS`
|
|
43
|
+
* subqueries correlate back to.
|
|
44
|
+
*
|
|
45
|
+
* It has to be the Drizzle column object rather than a name: a column
|
|
46
|
+
* renders qualified with its own table, which is what binds it to the
|
|
47
|
+
* *outer* row instead of to the junction or target aliased inside the
|
|
48
|
+
* subquery. See {@link DrizzleConditionBuilder.buildRelationFilterCondition}.
|
|
49
|
+
*/
|
|
50
|
+
sourceIdColumn?: AnyPgColumn;
|
|
51
|
+
}
|
|
5
52
|
/** Drizzle dynamic query builder — accepts innerJoin + where chaining */
|
|
6
53
|
export interface DrizzleDynamicQuery {
|
|
7
54
|
innerJoin(table: PgTable<any>, condition: SQL): this;
|
|
@@ -59,14 +106,121 @@ export declare class DrizzleConditionBuilder {
|
|
|
59
106
|
* that revisits a table (a self-referencing many-to-many) unambiguous.
|
|
60
107
|
*/
|
|
61
108
|
private static buildJoinPathScopeCondition;
|
|
109
|
+
/**
|
|
110
|
+
* What a filter field names, or `undefined` if it names nothing.
|
|
111
|
+
*
|
|
112
|
+
* Three ways a field resolves. It may address its column directly; it may
|
|
113
|
+
* be an owning relation, whose foreign key is a column here; or it may be
|
|
114
|
+
* a relation whose link lives on another table entirely, which compiles to
|
|
115
|
+
* a subquery instead of a column. Only a field that resolves to *none* of
|
|
116
|
+
* them is an error, and by default it is one: see
|
|
117
|
+
* {@link UnknownFilterFieldsMode} for why silently dropping it is a
|
|
118
|
+
* data-exposure primitive rather than a convenience.
|
|
119
|
+
*
|
|
120
|
+
* For an owning relation the relation's own `localKey` is the authority,
|
|
121
|
+
* not `<field>_id`. The default local key is `generateForeignKeyName`,
|
|
122
|
+
* which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
|
|
123
|
+
* `users` → `user_id` — and it can be overridden outright. Guessing
|
|
124
|
+
* `<field>_id` therefore misses perfectly ordinary owning relations, and
|
|
125
|
+
* with this resolution failing closed that miss is a 400 on a filter that
|
|
126
|
+
* has nothing wrong with it. The guesses stay, last, for callers that hand
|
|
127
|
+
* over no collection to resolve against.
|
|
128
|
+
*
|
|
129
|
+
* The subquery kinds need a registry and the source table's key column on
|
|
130
|
+
* top of the collection. A caller that supplies neither gets the behaviour
|
|
131
|
+
* it had before they were compilable — unresolvable, and so fail-closed —
|
|
132
|
+
* rather than a half-built condition.
|
|
133
|
+
*/
|
|
134
|
+
private static resolveFilterTarget;
|
|
62
135
|
/**
|
|
63
136
|
* Build filter conditions from FilterValues
|
|
64
137
|
*/
|
|
65
|
-
static buildFilterConditions<M extends Record<string, unknown>>(filter: FilterValues<Extract<keyof M, string>>, table: PgTable<any>, collectionPath: string): SQL[];
|
|
138
|
+
static buildFilterConditions<M extends Record<string, unknown>>(filter: FilterValues<Extract<keyof M, string>>, table: PgTable<any>, collectionPath: string, options?: FilterCompilationOptions): SQL[];
|
|
66
139
|
/**
|
|
67
140
|
* Build logical conditions recursively from LogicalCondition or FilterCondition
|
|
68
141
|
*/
|
|
69
|
-
static buildLogicalConditions(cond: LogicalCondition | FilterCondition, table: PgTable<any>, collectionPath: string): SQL | null;
|
|
142
|
+
static buildLogicalConditions(cond: LogicalCondition | FilterCondition, table: PgTable<any>, collectionPath: string, options?: FilterCompilationOptions): SQL | null;
|
|
143
|
+
/** Dispatch a resolved filter field onto the shape it actually compiles to. */
|
|
144
|
+
private static compileFilterTarget;
|
|
145
|
+
/**
|
|
146
|
+
* A filter on a relation that owns no column on this row — `EXISTS` over
|
|
147
|
+
* the rows it reaches.
|
|
148
|
+
*
|
|
149
|
+
* `posts` filtered by `tags == <tagId>` is not a comparison on `posts`; it
|
|
150
|
+
* is a question about the junction:
|
|
151
|
+
*
|
|
152
|
+
* EXISTS (SELECT 1 FROM posts_tags AS j
|
|
153
|
+
* WHERE j.post_id = posts.id AND j.tag_id = <tagId>)
|
|
154
|
+
*
|
|
155
|
+
* which is {@link buildRelationScopeCondition}'s many-to-many shape with
|
|
156
|
+
* source and target swapped — there the junction's *target* column
|
|
157
|
+
* correlates and the source is pinned; here the *source* column correlates
|
|
158
|
+
* and the target is what the filter constrains.
|
|
159
|
+
*
|
|
160
|
+
* `hasMany`/`hasOne` are the same shape one table over: the target row
|
|
161
|
+
* carries the foreign key, so the correlation is on that key and the
|
|
162
|
+
* compared column is the target's own id.
|
|
163
|
+
*
|
|
164
|
+
* `EXISTS` and not a join, for the reason the scope condition gives: a join
|
|
165
|
+
* through a junction multiplies the outer rows by the number of matching
|
|
166
|
+
* links, which duplicates results and silently breaks `limit`/`offset`.
|
|
167
|
+
*
|
|
168
|
+
* Everything inside the subquery is referenced by identifier against a
|
|
169
|
+
* local alias, and only `sourceIdColumn` stays a Drizzle column object —
|
|
170
|
+
* again see {@link buildRelationScopeCondition}, which explains why a
|
|
171
|
+
* column object renders against whatever table the surrounding builder
|
|
172
|
+
* thinks is current and so cannot be used for the inner references. The
|
|
173
|
+
* alias is also what keeps a self-referential relation unambiguous
|
|
174
|
+
* (`categories.children`, or a many-to-many whose junction and target are
|
|
175
|
+
* the same table), where the subquery's table and the outer one coincide.
|
|
176
|
+
*/
|
|
177
|
+
static buildRelationFilterCondition(relation: ResolvedForeignKeyOnTarget | ResolvedManyToMany, op: WhereFilterOp, value: unknown, sourceIdColumn: AnyPgColumn, registry: PostgresCollectionRegistry, field: string, collectionPath: string): SQL;
|
|
178
|
+
/**
|
|
179
|
+
* The inner predicate of a relation filter, and whether the `EXISTS`
|
|
180
|
+
* wrapping it is negated.
|
|
181
|
+
*
|
|
182
|
+
* Negation is `NOT EXISTS` of the *positive* predicate, never `EXISTS` of a
|
|
183
|
+
* negated one. On a many-valued relation the two are different questions:
|
|
184
|
+
* `EXISTS (… AND tag_id != X)` asks "does some tag differ from X", which is
|
|
185
|
+
* true of nearly every post with more than one tag and answers nothing
|
|
186
|
+
* anybody asked. `NOT EXISTS (… AND tag_id = X)` asks "is X absent", which
|
|
187
|
+
* is what unticking a value in a filter control means — and it makes `==`
|
|
188
|
+
* and `!=` partition the rows, the way a filter implies they do.
|
|
189
|
+
*
|
|
190
|
+
* `is-null`/`is-not-null` drop the predicate entirely: with nothing but the
|
|
191
|
+
* correlation left, they become "has no related row at all" and "has at
|
|
192
|
+
* least one", which is the only reading of null a link can have.
|
|
193
|
+
*
|
|
194
|
+
* Under RLS, "no related row" means *no row this reader can see*. A junction
|
|
195
|
+
* with row-level security but no `SELECT` policy for `rebase_user` is opaque
|
|
196
|
+
* to it, so every row comes back looking unlinked and `is-null` matches all
|
|
197
|
+
* of them. That is not a leak — the outer table's own policies still decide
|
|
198
|
+
* which rows exist at all, and the positive direction correctly returns
|
|
199
|
+
* nothing — but it over-reports, and the cause is a missing junction policy
|
|
200
|
+
* rather than anything here. Rebase derives one for a declared many-to-many;
|
|
201
|
+
* a hand-written schema has to supply it.
|
|
202
|
+
*
|
|
203
|
+
* `in`/`not-in` against a *null value* mean the same thing, rather than
|
|
204
|
+
* membership of an empty list. Membership against null is not a membership
|
|
205
|
+
* question, and the admin's "filter for null values" control emits the
|
|
206
|
+
* operator that happens to be selected — on a to-many relation that is
|
|
207
|
+
* always `in` or `not-in`, because those are the only ones the multi-select
|
|
208
|
+
* can produce. Reading `["in", null]` as an empty list would answer "posts
|
|
209
|
+
* with no tags" with no posts at all.
|
|
210
|
+
*
|
|
211
|
+
* An empty `in` list compiles to `FALSE` rather than being dropped. Dropped
|
|
212
|
+
* is what the column path does, and dropping a condition widens the result
|
|
213
|
+
* — the whole reason this resolution fails closed. `in []` matches nothing
|
|
214
|
+
* and `not-in []` matches everything, and `NOT EXISTS (… AND FALSE)` gives
|
|
215
|
+
* the second for free.
|
|
216
|
+
*
|
|
217
|
+
* Anything else is rejected. Returning `null` for an operator this cannot
|
|
218
|
+
* express would drop the condition, and the operators the admin offers for
|
|
219
|
+
* a relation are exactly the six below.
|
|
220
|
+
*/
|
|
221
|
+
private static buildRelationFilterPredicate;
|
|
222
|
+
/** The column a table's rows are keyed by: its primary key, else `id`. */
|
|
223
|
+
private static primaryKeyColumn;
|
|
70
224
|
/**
|
|
71
225
|
* Build a single filter condition for a specific operator and value
|
|
72
226
|
*/
|
|
@@ -52,12 +52,15 @@ export declare function pgErrorToFriendlyMessage(pgError: PostgresError, context
|
|
|
52
52
|
/**
|
|
53
53
|
* Sanitize any error into a message safe and helpful for the client.
|
|
54
54
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
55
|
+
* A deliberate 4xx (`ApiError`) passes through untouched — the server already
|
|
56
|
+
* decided what the client should read. Otherwise the PG error is extracted
|
|
57
|
+
* from the Drizzle cause chain, falling back to a generic message that
|
|
58
|
+
* doesn't leak SQL.
|
|
57
59
|
*
|
|
58
60
|
* @param error - The raw caught error
|
|
59
61
|
* @param context - A human-readable context string (e.g. collection path)
|
|
60
|
-
* @returns An object with `message` (user-friendly) and optional `code`
|
|
62
|
+
* @returns An object with `message` (user-friendly) and optional `code`
|
|
63
|
+
* (the `ApiError` code, or the PG SQLSTATE).
|
|
61
64
|
*/
|
|
62
65
|
export declare function sanitizeErrorForClient(error: unknown, context: string): {
|
|
63
66
|
message: string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"pg": "^8.21.0",
|
|
49
49
|
"ws": "^8.21.0",
|
|
50
|
-
"@rebasepro/codegen": "0.
|
|
51
|
-
"@rebasepro/server": "0.
|
|
52
|
-
"@rebasepro/common": "0.
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/
|
|
50
|
+
"@rebasepro/codegen": "0.12.0",
|
|
51
|
+
"@rebasepro/server": "0.12.0",
|
|
52
|
+
"@rebasepro/common": "0.12.0",
|
|
53
|
+
"@rebasepro/utils": "0.12.0",
|
|
54
|
+
"@rebasepro/types": "0.12.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@hono/node-server": "^2.0.11",
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DatabaseAdmin,
|
|
16
16
|
type DataDriver,
|
|
17
17
|
CollectionConfig,
|
|
18
|
+
isRelationalCollectionConfig,
|
|
18
19
|
type HistoryConfig,
|
|
19
20
|
InitializedDriver,
|
|
20
21
|
RealtimeProvider,
|
|
@@ -41,6 +42,7 @@ import { provisionTriggerCdc, type CdcTableRef } from "./services/cdc/trigger-cd
|
|
|
41
42
|
import { collectJunctionLinks } from "./services/cdc/junction-tables";
|
|
42
43
|
import { createChannelBus, resolveChannelBusSetting } from "./services/channel-bus";
|
|
43
44
|
import { isChannelBusInstance } from "@rebasepro/types";
|
|
45
|
+
import { configureUnknownFilterFields, type UnknownFilterFieldsMode } from "./utils/drizzle-conditions";
|
|
44
46
|
|
|
45
47
|
export interface PostgresDriverConfig {
|
|
46
48
|
connectionString?: string;
|
|
@@ -67,6 +69,13 @@ export interface PostgresDriverConfig {
|
|
|
67
69
|
* instance and wrong for two. See {@link ChannelBusConfig}.
|
|
68
70
|
*/
|
|
69
71
|
realtime?: RealtimeChannelsConfig;
|
|
72
|
+
/**
|
|
73
|
+
* What to do with a filter field that resolves to no column at all.
|
|
74
|
+
* Defaults to `"error"` — a filter that cannot be compiled would otherwise
|
|
75
|
+
* be dropped, and a dropped condition can only widen the result set.
|
|
76
|
+
* Set to `"warn"` to restore the pre-fix behaviour of dropping it silently.
|
|
77
|
+
*/
|
|
78
|
+
unknownFilterFields?: UnknownFilterFieldsMode;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
@@ -106,16 +115,23 @@ import { isEconnrefused } from "./cli-errors";
|
|
|
106
115
|
* ```
|
|
107
116
|
*/
|
|
108
117
|
export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): BackendBootstrapper {
|
|
118
|
+
// Applied at construction rather than threaded through every read: the
|
|
119
|
+
// condition builder's static methods are reached from call sites that
|
|
120
|
+
// carry no config. See `UnknownFilterFieldsMode`.
|
|
121
|
+
if (pgConfig.unknownFilterFields) {
|
|
122
|
+
configureUnknownFilterFields(pgConfig.unknownFilterFields);
|
|
123
|
+
}
|
|
124
|
+
|
|
109
125
|
return {
|
|
110
126
|
type: "postgres",
|
|
111
127
|
|
|
112
128
|
async initializeDriver(config: unknown): Promise<InitializedDriver> {
|
|
113
129
|
// config is passed from coordinator, we merge it with our internal pgConfig if needed
|
|
114
130
|
// Currently config from init.ts is `{ collections, collectionRegistry, mode }`
|
|
115
|
-
const { collections, collectionRegistry,
|
|
131
|
+
const { collections, collectionRegistry, introspectCollections, baas } = config as {
|
|
116
132
|
collections?: CollectionConfig[];
|
|
117
133
|
collectionRegistry?: unknown;
|
|
118
|
-
|
|
134
|
+
introspectCollections?: boolean;
|
|
119
135
|
baas?: { unprotectedTables?: "exclude" | "serve" };
|
|
120
136
|
};
|
|
121
137
|
// Secure by default: a table with no RLS is not served.
|
|
@@ -126,13 +142,13 @@ export function createPostgresBootstrapper(pgConfig: PostgresDriverConfig): Back
|
|
|
126
142
|
? (connection as Record<string, unknown>).$client
|
|
127
143
|
: connection) as import("pg").Pool;
|
|
128
144
|
|
|
129
|
-
// ──
|
|
145
|
+
// ── No declared collections: derive the schema from the database ──
|
|
130
146
|
// No collection files and no generated drizzle schema exist, so read
|
|
131
147
|
// the live database and build both from what is actually there.
|
|
132
148
|
let introspectedCollections: CollectionConfig[] | undefined;
|
|
133
149
|
let introspectedTables: Record<string, PgTable> | undefined;
|
|
134
150
|
let introspectedRelations: Record<string, Relations> | undefined;
|
|
135
|
-
if (
|
|
151
|
+
if (introspectCollections && (!collections || collections.length === 0)) {
|
|
136
152
|
const pgSchemaName = pgConfig.introspectionSchema ?? "public";
|
|
137
153
|
const schema = await introspectSchema(rawClient, pgSchemaName);
|
|
138
154
|
|
|
@@ -497,10 +513,11 @@ table: link.table });
|
|
|
497
513
|
if ((col as { auth?: { enabled?: boolean } }).auth?.enabled) continue;
|
|
498
514
|
|
|
499
515
|
const schemaName = "schema" in col && col.schema ? col.schema : "public";
|
|
516
|
+
const declaredTable = isRelationalCollectionConfig(col) ? col.table : undefined;
|
|
500
517
|
const tableName = registry.hasTableForCollection(
|
|
501
|
-
|
|
518
|
+
declaredTable ?? col.slug
|
|
502
519
|
)
|
|
503
|
-
? (
|
|
520
|
+
? (declaredTable ?? col.slug)
|
|
504
521
|
: col.slug;
|
|
505
522
|
// Resolve the actual table name the registry stored
|
|
506
523
|
const resolvedTable = registry.getTableNames().find((k) =>
|
|
@@ -9,7 +9,7 @@ import { assertRelationsResolve } from "./validate-relations";
|
|
|
9
9
|
/**
|
|
10
10
|
* Everything a registry is built from: the collections, and the drizzle schema
|
|
11
11
|
* they are backed by. In BaaS mode all of it is introspected from the live
|
|
12
|
-
* database;
|
|
12
|
+
* database; when collections are declared it comes from the config and the generated schema.
|
|
13
13
|
*/
|
|
14
14
|
export interface RegistrySchema {
|
|
15
15
|
collections?: CollectionConfig[];
|
|
@@ -1,39 +1,21 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { logger } from "@rebasepro/server";
|
|
4
|
+
import type { EntityHistoryEntry } from "@rebasepro/types";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
values: Record<string, unknown> | null;
|
|
12
|
-
previous_values: Record<string, unknown> | null;
|
|
13
|
-
updated_by: string | null;
|
|
14
|
-
updated_at: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export interface RecordHistoryParams {
|
|
18
|
-
tableName: string;
|
|
19
|
-
id: string;
|
|
20
|
-
action: "create" | "update" | "delete";
|
|
21
|
-
values?: Record<string, unknown> | null;
|
|
22
|
-
previousValues?: Record<string, unknown> | null;
|
|
23
|
-
updatedBy?: string | null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface FetchHistoryOptions {
|
|
27
|
-
limit?: number;
|
|
28
|
-
offset?: number;
|
|
29
|
-
}
|
|
6
|
+
export type {
|
|
7
|
+
RecordHistoryParams,
|
|
8
|
+
FetchHistoryOptions,
|
|
9
|
+
HistoryRetentionConfig
|
|
10
|
+
} from "@rebasepro/types";
|
|
11
|
+
import type { RecordHistoryParams, FetchHistoryOptions, HistoryRetentionConfig } from "@rebasepro/types";
|
|
30
12
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A Postgres history row is already the wire shape — `updated_at` comes back
|
|
15
|
+
* from the driver as a string. Kept as an alias because the name is used
|
|
16
|
+
* throughout this package and in `PostgresBackendDriver`.
|
|
17
|
+
*/
|
|
18
|
+
export type HistoryEntry = EntityHistoryEntry;
|
|
37
19
|
|
|
38
20
|
const DEFAULT_RETENTION: HistoryRetentionConfig = {
|
|
39
21
|
maxEntries: 200,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build drizzle tables at runtime from an introspected schema.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* A project with declared collections gets its drizzle tables from a generated `schema.generated.ts` that
|
|
5
5
|
* the developer commits. BaaS mode has no such file — it points at a database
|
|
6
6
|
* and serves it — so the equivalent table objects are constructed here from
|
|
7
7
|
* `information_schema` metadata.
|
|
@@ -708,9 +708,17 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
|
|
|
708
708
|
emittedRelationNames.add(deduplicationKey);
|
|
709
709
|
|
|
710
710
|
switch (rel.kind) {
|
|
711
|
-
case "belongsTo":
|
|
712
|
-
|
|
711
|
+
case "belongsTo": {
|
|
712
|
+
// `localKey` is a COLUMN name; the generated Drizzle
|
|
713
|
+
// object is keyed by PROPERTY. They differ whenever
|
|
714
|
+
// the property is camelCase — `user_id` is exposed
|
|
715
|
+
// as `userId` — and emitting the column produces a
|
|
716
|
+
// schema that does not compile. The three other
|
|
717
|
+
// emission sites normalise; this one did not.
|
|
718
|
+
const localFieldKey = resolvePropertyKeyForColumn(collection, rel.localKey);
|
|
719
|
+
tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${localFieldKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
|
|
713
720
|
break;
|
|
721
|
+
}
|
|
714
722
|
|
|
715
723
|
case "hasOne":
|
|
716
724
|
// The foreign key lives on the TARGET table. Drizzle pairs
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* single config file.
|
|
8
8
|
*
|
|
9
9
|
* Distinct from `introspect-db.ts`, which runs the same queries but emits
|
|
10
|
-
* TypeScript *source* for a developer to edit and commit (
|
|
10
|
+
* TypeScript *source* for a developer to edit and commit (declared collections). The two
|
|
11
11
|
* share the mapping helpers in `introspect-db-logic.ts` so a table is described
|
|
12
12
|
* the same way whether it was generated or introspected.
|
|
13
13
|
*/
|
|
@@ -3,10 +3,12 @@ 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
|
-
import {
|
|
6
|
+
import { generateForeignKeyName } from "@rebasepro/utils";
|
|
7
|
+
import { DrizzleConditionBuilder, type FilterCompilationOptions } from "../utils/drizzle-conditions";
|
|
7
8
|
import {
|
|
8
9
|
getCollectionByPath,
|
|
9
10
|
getTableForCollection,
|
|
11
|
+
getPrimaryKeys,
|
|
10
12
|
requirePrimaryKeys,
|
|
11
13
|
deriveRowAddress,
|
|
12
14
|
parseIdValues,
|
|
@@ -45,6 +47,45 @@ export class FetchService {
|
|
|
45
47
|
return query?.[tableName] as RelationalQueryBuilder<TablesRelationalConfig, TableRelationalConfig> | undefined;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* The context the condition builder needs to compile a filter key that is
|
|
52
|
+
* not a column name outright.
|
|
53
|
+
*
|
|
54
|
+
* Two such keys. An owning relation's key resolves through the collection's
|
|
55
|
+
* relations to its foreign-key column; a relation whose link lives on the
|
|
56
|
+
* target table or in a junction resolves to a correlated `EXISTS`, which
|
|
57
|
+
* needs the registry to reach that other table and this table's key column
|
|
58
|
+
* to correlate back.
|
|
59
|
+
*
|
|
60
|
+
* Looked up rather than passed: every read path already has the path, only
|
|
61
|
+
* some have the collection, and a path that names no registered collection
|
|
62
|
+
* (a nested/derived one) is not an error here — the builder simply falls
|
|
63
|
+
* back to guessing the default key shapes, and a relation filter it cannot
|
|
64
|
+
* compile stays unresolvable and so fails closed.
|
|
65
|
+
*/
|
|
66
|
+
private filterContext(collectionPath: string, table: PgTable<any>): FilterCompilationOptions {
|
|
67
|
+
const collection = this.registry.getCollectionByPath(collectionPath) ?? undefined;
|
|
68
|
+
return {
|
|
69
|
+
collection,
|
|
70
|
+
registry: this.registry,
|
|
71
|
+
sourceIdColumn: collection ? this.resolveIdColumn(collection, table) : undefined
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The table column this collection's rows are keyed by, or `undefined`.
|
|
77
|
+
*
|
|
78
|
+
* `getPrimaryKeys` rather than `requirePrimaryKeys`: a collection with no
|
|
79
|
+
* resolvable key is not an error on the filter path — it only means the
|
|
80
|
+
* relation filters that would correlate on it cannot be compiled, which
|
|
81
|
+
* the builder already handles by failing that field closed.
|
|
82
|
+
*/
|
|
83
|
+
private resolveIdColumn(collection: CollectionConfig, table: PgTable<any>): AnyPgColumn | undefined {
|
|
84
|
+
const [idInfo] = getPrimaryKeys(collection, this.registry);
|
|
85
|
+
if (!idInfo) return undefined;
|
|
86
|
+
return table[idInfo.fieldName as keyof typeof table] as AnyPgColumn | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
48
89
|
/**
|
|
49
90
|
* Build filter conditions from FilterValues
|
|
50
91
|
* Delegates to DrizzleConditionBuilder.buildFilterConditions
|
|
@@ -54,7 +95,9 @@ export class FetchService {
|
|
|
54
95
|
table: PgTable<any>,
|
|
55
96
|
collectionPath: string
|
|
56
97
|
): SQL[] {
|
|
57
|
-
return DrizzleConditionBuilder.buildFilterConditions(
|
|
98
|
+
return DrizzleConditionBuilder.buildFilterConditions(
|
|
99
|
+
filter, table, collectionPath, this.filterContext(collectionPath, table)
|
|
100
|
+
);
|
|
58
101
|
}
|
|
59
102
|
|
|
60
103
|
// =============================================================
|
|
@@ -64,20 +107,45 @@ export class FetchService {
|
|
|
64
107
|
/**
|
|
65
108
|
* Resolves the correct Drizzle column for sorting.
|
|
66
109
|
* Automatically maps owning relation property keys to their underlying foreign key column.
|
|
110
|
+
*
|
|
111
|
+
* The relation's own `localKey` is the authority for that foreign key, not
|
|
112
|
+
* `<field>_id`. The default local key comes from `generateForeignKeyName`,
|
|
113
|
+
* which snake-cases *and singularises* — `userProfile` → `user_profile_id`,
|
|
114
|
+
* `users` → `user_id` — and an author can override it outright. A wrong
|
|
115
|
+
* guess resolves to nothing, the caller drops the `ORDER BY`, and the rows
|
|
116
|
+
* come back in whatever order Postgres pleases: paging over that repeats
|
|
117
|
+
* and skips rows rather than erroring. The guesses stay, last, for a
|
|
118
|
+
* caller that hands over no collection to resolve against.
|
|
67
119
|
*/
|
|
68
120
|
private resolveOrderByField(
|
|
69
121
|
table: PgTable<any>,
|
|
70
122
|
orderBy: string,
|
|
71
123
|
collection?: CollectionConfig
|
|
72
124
|
): AnyPgColumn | undefined {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
const columnAt = (key: string): AnyPgColumn | undefined =>
|
|
126
|
+
(key in table ? table[key as keyof typeof table] as AnyPgColumn : undefined) || undefined;
|
|
127
|
+
|
|
128
|
+
const direct = columnAt(orderBy);
|
|
129
|
+
if (direct) return direct;
|
|
130
|
+
|
|
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;
|
|
78
137
|
}
|
|
79
138
|
}
|
|
80
|
-
|
|
139
|
+
|
|
140
|
+
// No collection in hand — the two shapes an owning relation's key takes
|
|
141
|
+
// by default (e.g. `project` → `project_id`, `userProfile` →
|
|
142
|
+
// `user_profile_id`).
|
|
143
|
+
for (const guess of [`${orderBy}_id`, generateForeignKeyName(orderBy)]) {
|
|
144
|
+
const foreignKey = columnAt(guess);
|
|
145
|
+
if (foreignKey) return foreignKey;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return undefined;
|
|
81
149
|
}
|
|
82
150
|
|
|
83
151
|
/**
|
|
@@ -85,7 +153,7 @@ export class FetchService {
|
|
|
85
153
|
* Converts collection relations to a Drizzle-compatible `with` object.
|
|
86
154
|
*
|
|
87
155
|
* When `include` is provided, only those relations are loaded.
|
|
88
|
-
* When `include` is absent, ALL relations are loaded (
|
|
156
|
+
* When `include` is absent, ALL relations are loaded (the admin path).
|
|
89
157
|
*
|
|
90
158
|
* Automatically detects many-to-many junction tables and nests
|
|
91
159
|
* the target relation so actual row data is returned.
|
|
@@ -311,7 +379,7 @@ export class FetchService {
|
|
|
311
379
|
}
|
|
312
380
|
|
|
313
381
|
if (options.logical) {
|
|
314
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
|
|
382
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
315
383
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
316
384
|
}
|
|
317
385
|
|
|
@@ -656,7 +724,7 @@ _distance: vectorMeta.distanceSelect }).from(table).$dynamic()
|
|
|
656
724
|
}
|
|
657
725
|
|
|
658
726
|
if (options.logical) {
|
|
659
|
-
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath);
|
|
727
|
+
const logicalCondition = DrizzleConditionBuilder.buildLogicalConditions(options.logical, table, collectionPath, this.filterContext(collectionPath, table));
|
|
660
728
|
if (logicalCondition) allConditions.push(logicalCondition);
|
|
661
729
|
}
|
|
662
730
|
|
|
@@ -14,7 +14,9 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
|
|
|
14
14
|
*
|
|
15
15
|
* - `"ref"` — a `{ id, path, __type: "relation" }` reference carrying the
|
|
16
16
|
* target's values. This is what the admin renders.
|
|
17
|
-
* - `"inline"` — the target's own columns, flat. This is what REST serves
|
|
17
|
+
* - `"inline"` — the target's own columns, flat. This is what REST serves, and
|
|
18
|
+
* — since the in-process SDK reads through the same pipeline — what
|
|
19
|
+
* `rebase.data` / `context.data` serve too. A developer never sees a ref.
|
|
18
20
|
*
|
|
19
21
|
* They used to be two functions that happened to agree, and the agreement was
|
|
20
22
|
* not enforced by anything: the row-identity bug had to be fixed five times
|