@rebasepro/types 0.21.2-canary.g1ea48be → 0.22.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/index.es.js +3 -1
- package/dist/index.es.js.map +1 -1
- package/dist/types/backend.d.ts +20 -1
- package/dist/types/entity_callbacks.d.ts +195 -1
- package/dist/types/search.d.ts +60 -4
- package/package.json +1 -1
package/dist/types/backend.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CollectionConfig, FilterValues, WhereFilterOp } from "./collections.js";
|
|
2
|
+
import type { CollectionCallbacks } from "./entity_callbacks.js";
|
|
2
3
|
import type { OrderByTuple } from "./filter-operators.js";
|
|
3
4
|
import type { LogicalCondition } from "../controllers/data.js";
|
|
4
5
|
import type { AuthAdapter } from "./auth_adapter.js";
|
|
@@ -88,7 +89,14 @@ export interface ConditionBuilder<T = unknown> {
|
|
|
88
89
|
*/
|
|
89
90
|
buildFilterConditions<M extends Record<string, unknown>>(filter: FilterValues<Extract<keyof M, string>>, collectionPath: string, ...args: unknown[]): T[];
|
|
90
91
|
/**
|
|
91
|
-
* Build search conditions for text search
|
|
92
|
+
* Build search conditions for text search.
|
|
93
|
+
*
|
|
94
|
+
* At most one condition comes back, already complete: callers OR what they
|
|
95
|
+
* get, and OR is right across the searchable *fields* but wrong across the
|
|
96
|
+
* *terms* of the search string — a person typing two words means both, and
|
|
97
|
+
* the two halves of a name are two different fields. The empty array still
|
|
98
|
+
* means "nothing here can be searched", which every caller reads as "no
|
|
99
|
+
* rows".
|
|
92
100
|
*/
|
|
93
101
|
buildSearchConditions(searchString: string, properties: Record<string, unknown>, ...args: unknown[]): T[];
|
|
94
102
|
/**
|
|
@@ -307,6 +315,17 @@ export interface CollectionRegistryInterface {
|
|
|
307
315
|
* Get the currently registered global callbacks, if any.
|
|
308
316
|
*/
|
|
309
317
|
getGlobalCallbacks(): any | undefined;
|
|
318
|
+
/**
|
|
319
|
+
* Take the global callbacks declared on `initializeRebaseBackend({ callbacks })`.
|
|
320
|
+
*
|
|
321
|
+
* A driver resolves callbacks from the registry it builds for itself, so
|
|
322
|
+
* this is how the backend's global ones reach it: the coordinator hands
|
|
323
|
+
* them over after `initializeDriver` returns. Optional so a registry for a
|
|
324
|
+
* driver that runs no callbacks still type-checks, but a project that
|
|
325
|
+
* declares global callbacks over a registry without it is refused at boot
|
|
326
|
+
* — hooks that never run look exactly like hooks that passed.
|
|
327
|
+
*/
|
|
328
|
+
setGlobalCallbacks?(callbacks: CollectionCallbacks): void;
|
|
310
329
|
}
|
|
311
330
|
/**
|
|
312
331
|
* Abstract data transformer interface.
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { CollectionConfig } from "./collections.js";
|
|
1
|
+
import type { CollectionConfig, FilterValues } from "./collections.js";
|
|
2
|
+
import type { OrderByTuple } from "./filter-operators.js";
|
|
2
3
|
import type { EntityStatus, EntityValues } from "./entities.js";
|
|
3
4
|
import type { User } from "../users/index.js";
|
|
4
5
|
import type { RebaseCallContext } from "../call_context.js";
|
|
6
|
+
import type { LogicalCondition } from "../controllers/data.js";
|
|
5
7
|
/**
|
|
6
8
|
* Lifecycle callbacks for entity CRUD operations.
|
|
7
9
|
*
|
|
@@ -25,6 +27,78 @@ export type CollectionCallbacks<M extends Record<string, unknown> = Record<strin
|
|
|
25
27
|
* @param props
|
|
26
28
|
*/
|
|
27
29
|
afterRead?(props: AfterReadProps<M, USER>): Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Callback used **before** a read is compiled, to narrow which rows it asks
|
|
32
|
+
* for.
|
|
33
|
+
*
|
|
34
|
+
* {@link CollectionCallbacks.afterRead} sees rows that have already been
|
|
35
|
+
* fetched, so nothing in userland could influence *which* rows a read
|
|
36
|
+
* requests — a tenant scope, a visibility window, a per-role row filter all
|
|
37
|
+
* had to be either pushed into every call site or written as an RLS policy.
|
|
38
|
+
* This is the hook for that.
|
|
39
|
+
*
|
|
40
|
+
* Return conditions to **add**. They are AND-ed into the query the caller
|
|
41
|
+
* sent, alongside its own `filter`, its `logical` group, the soft-delete
|
|
42
|
+
* condition and the relation scope of a nested path.
|
|
43
|
+
*
|
|
44
|
+
* ### Additive by construction
|
|
45
|
+
*
|
|
46
|
+
* The return type cannot express a removal, a replacement or an `OR` with
|
|
47
|
+
* the caller's own conditions: it is a filter to AND in, and `AND(q, c)` is
|
|
48
|
+
* a subset of `q` for every `c`. That is deliberate and it is the reason
|
|
49
|
+
* the hook is shaped this way rather than as `(query) => query`. A hook
|
|
50
|
+
* handed the parsed query and asked to return one could drop a condition,
|
|
51
|
+
* and on an RLS data plane a dropped condition is a widened read — the same
|
|
52
|
+
* reasoning that makes `UnknownFilterFieldsMode` in
|
|
53
|
+
* `@rebasepro/server-postgres` default to `"error"` rather than to dropping
|
|
54
|
+
* what it cannot compile.
|
|
55
|
+
*
|
|
56
|
+
* For the same reason the returned filter is compiled with unknown fields
|
|
57
|
+
* **always** fatal, whatever the process-wide mode is: a scoping condition
|
|
58
|
+
* on a renamed column must refuse the request, never run without it.
|
|
59
|
+
*
|
|
60
|
+
* ### Every read path, no bypass
|
|
61
|
+
*
|
|
62
|
+
* It fires on the listing, the single get, the count, the aggregate, the
|
|
63
|
+
* search, the vector read, the nested-path listing, the realtime refetch
|
|
64
|
+
* that builds subscription frames, and on the rows loaded for a relation or
|
|
65
|
+
* an `include` — where it is the **target** collection's hook that applies,
|
|
66
|
+
* because those are the target's rows. A hook honoured by the listing and
|
|
67
|
+
* not by the count is a page that says "1 of 4 results".
|
|
68
|
+
*
|
|
69
|
+
* Two reads are deliberately not narrowed, and both would be wrong to
|
|
70
|
+
* narrow:
|
|
71
|
+
*
|
|
72
|
+
* - **`checkUniqueField`.** It asks whether a value exists *anywhere* in
|
|
73
|
+
* the table. Narrowed, it would answer "unique" for a value a row the
|
|
74
|
+
* caller cannot see already holds, and the insert would then fail on the
|
|
75
|
+
* constraint instead.
|
|
76
|
+
* - **A write's own pre-read** is narrowed, not exempt: an update or delete
|
|
77
|
+
* addressed at a row this hook excludes answers "not found", which is the
|
|
78
|
+
* same answer the read gives.
|
|
79
|
+
*
|
|
80
|
+
* ### Postgres only, for now
|
|
81
|
+
*
|
|
82
|
+
* Implemented by `@rebasepro/server-postgres`. A collection served by
|
|
83
|
+
* another engine that declares one is **refused at boot** rather than
|
|
84
|
+
* served with the hook silently inert — see `assertBeforeQueryIsPostgresOnly`.
|
|
85
|
+
* That is the same treatment a `search` block gets on a non-Postgres
|
|
86
|
+
* collection, and for the same reason: a security-shaped hook that does
|
|
87
|
+
* nothing is worse than one that is absent.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* callbacks: {
|
|
92
|
+
* beforeQuery: ({ context }) => {
|
|
93
|
+
* if (context.user?.roles?.includes("admin")) return;
|
|
94
|
+
* return { filter: { owner_id: ["==", context.user?.uid ?? null] } };
|
|
95
|
+
* }
|
|
96
|
+
* }
|
|
97
|
+
* ```
|
|
98
|
+
*
|
|
99
|
+
* @param props
|
|
100
|
+
*/
|
|
101
|
+
beforeQuery?(props: BeforeQueryProps<M, USER>): Promise<QueryNarrowing<M> | void> | QueryNarrowing<M> | void;
|
|
28
102
|
/**
|
|
29
103
|
* Callback used before saving, you need to return the values that will get
|
|
30
104
|
* saved. If you throw an error in this method the process stops, and an
|
|
@@ -83,6 +157,126 @@ export interface AfterReadProps<M extends Record<string, unknown> = Record<strin
|
|
|
83
157
|
*/
|
|
84
158
|
context: RebaseCallContext<USER>;
|
|
85
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Which read a {@link CollectionCallbacks.beforeQuery} hook is narrowing.
|
|
162
|
+
*
|
|
163
|
+
* Named rather than inferred because the four are not interchangeable to a
|
|
164
|
+
* hook that logs, measures or short-circuits: `"count"` and `"aggregate"` serve
|
|
165
|
+
* a total rather than rows, and `"relation"` is the target collection's own
|
|
166
|
+
* hook firing over rows reached from a parent.
|
|
167
|
+
*
|
|
168
|
+
* @group Models
|
|
169
|
+
*/
|
|
170
|
+
export type ReadOperation = "list" | "get" | "count" | "aggregate" | "relation";
|
|
171
|
+
/**
|
|
172
|
+
* The parsed read, as a {@link CollectionCallbacks.beforeQuery} hook sees it.
|
|
173
|
+
*
|
|
174
|
+
* Read-only throughout, and that is the point rather than a courtesy: a hook
|
|
175
|
+
* that could edit this object would be able to *widen* the read, which is the
|
|
176
|
+
* one thing this hook is built not to permit. Mutating it is a compile error;
|
|
177
|
+
* narrowing is what the return value is for.
|
|
178
|
+
*
|
|
179
|
+
* Fields are present exactly when the caller sent them. An absent `filter` is
|
|
180
|
+
* a query with no filter, not an empty one.
|
|
181
|
+
*
|
|
182
|
+
* @group Models
|
|
183
|
+
*/
|
|
184
|
+
export interface ReadQuery<M extends Record<string, unknown> = Record<string, unknown>> {
|
|
185
|
+
/** The caller's own field filter. */
|
|
186
|
+
readonly filter?: Readonly<FilterValues<Extract<keyof M, string>>>;
|
|
187
|
+
/** The caller's `or(...)` / `and(...)` group, if any. */
|
|
188
|
+
readonly logical?: Readonly<LogicalCondition>;
|
|
189
|
+
/** The text typed into a search box, if this is a search. */
|
|
190
|
+
readonly searchString?: string;
|
|
191
|
+
/** Page size, when the caller asked for one. */
|
|
192
|
+
readonly limit?: number;
|
|
193
|
+
/** Offset pagination, when the caller asked for one. */
|
|
194
|
+
readonly offset?: number;
|
|
195
|
+
/**
|
|
196
|
+
* Sort keys, normalized to tuples — one entry per key, in order of
|
|
197
|
+
* significance, whichever of the several authoring forms the caller used.
|
|
198
|
+
*/
|
|
199
|
+
readonly orderBy?: readonly Readonly<OrderByTuple>[];
|
|
200
|
+
/** Column projection, when the caller narrowed it with `fields`. */
|
|
201
|
+
readonly fields?: readonly string[];
|
|
202
|
+
/**
|
|
203
|
+
* The parent this read hangs off, for `operation: "relation"` and for a
|
|
204
|
+
* nested path like `authors/1/posts`.
|
|
205
|
+
*
|
|
206
|
+
* A hook that scopes by tenant usually ignores it. A hook that needs to
|
|
207
|
+
* know it is looking at *someone's* rows rather than all of them does not.
|
|
208
|
+
*/
|
|
209
|
+
readonly relatedTo?: {
|
|
210
|
+
readonly parentSlug: string;
|
|
211
|
+
readonly parentId?: string | number;
|
|
212
|
+
readonly relationName: string;
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* What a {@link CollectionCallbacks.beforeQuery} hook returns: conditions to
|
|
217
|
+
* AND into the read.
|
|
218
|
+
*
|
|
219
|
+
* Returning nothing (`undefined`, or no `return` at all) adds nothing and
|
|
220
|
+
* leaves the compiled SQL byte-identical to what it would have been.
|
|
221
|
+
*
|
|
222
|
+
* Both fields are the same declarative language a caller's own query uses, so
|
|
223
|
+
* a relation path (`author.tenant_id`), a JSON path (`meta->>tier`) and every
|
|
224
|
+
* `WhereFilterOp` works here too. There is no raw-SQL member, and that is
|
|
225
|
+
* deliberate: raw SQL could be `OR`-ed against the caller's conditions and so
|
|
226
|
+
* could widen the read.
|
|
227
|
+
*
|
|
228
|
+
* @group Models
|
|
229
|
+
*/
|
|
230
|
+
export interface QueryNarrowing<M extends Record<string, unknown> = Record<string, unknown>> {
|
|
231
|
+
/**
|
|
232
|
+
* A field filter to AND in.
|
|
233
|
+
*
|
|
234
|
+
* Compiled with unknown fields fatal, always: a scope condition that names
|
|
235
|
+
* a column the table does not have refuses the request rather than running
|
|
236
|
+
* without it.
|
|
237
|
+
*/
|
|
238
|
+
filter?: FilterValues<Extract<keyof M, string>>;
|
|
239
|
+
/**
|
|
240
|
+
* An `or(...)` / `and(...)` group to AND in, for a scope that is a
|
|
241
|
+
* disjunction — "mine, or shared with me", say.
|
|
242
|
+
*
|
|
243
|
+
* Still additive: the group is AND-ed into the query as a whole, so an
|
|
244
|
+
* `or` inside it can only ever choose between rows the rest of the query
|
|
245
|
+
* already admits.
|
|
246
|
+
*/
|
|
247
|
+
logical?: LogicalCondition;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Parameters passed to a {@link CollectionCallbacks.beforeQuery} hook.
|
|
251
|
+
*
|
|
252
|
+
* @group Models
|
|
253
|
+
*/
|
|
254
|
+
export interface BeforeQueryProps<M extends Record<string, unknown> = Record<string, unknown>, USER extends User = User> {
|
|
255
|
+
/**
|
|
256
|
+
* Collection being read. For `operation: "relation"` this is the
|
|
257
|
+
* **target** collection — the one the rows belong to.
|
|
258
|
+
*/
|
|
259
|
+
collection: CollectionConfig<M>;
|
|
260
|
+
/**
|
|
261
|
+
* Full path being read. Might contain unresolved aliases, and for a nested
|
|
262
|
+
* read it is the target collection's own path rather than the nested one —
|
|
263
|
+
* the nested parent is on {@link ReadQuery.relatedTo}.
|
|
264
|
+
*/
|
|
265
|
+
path: string;
|
|
266
|
+
/** Which read this is. */
|
|
267
|
+
operation: ReadOperation;
|
|
268
|
+
/** The parsed read, read-only. */
|
|
269
|
+
query: ReadQuery<M>;
|
|
270
|
+
/**
|
|
271
|
+
* Context of the app status.
|
|
272
|
+
*
|
|
273
|
+
* `context.data` is the caller's own plane, so a lookup made here is
|
|
274
|
+
* itself RLS-scoped — and it is a read issued from inside a read, on the
|
|
275
|
+
* same connection. Keep it to something cheap and cacheable, or read it
|
|
276
|
+
* once per request outside the hook.
|
|
277
|
+
*/
|
|
278
|
+
context: RebaseCallContext<USER>;
|
|
279
|
+
}
|
|
86
280
|
/**
|
|
87
281
|
* Parameters passed to hooks before a entity is saved
|
|
88
282
|
* @group Models
|
package/dist/types/search.d.ts
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ## Why this is opt-in
|
|
5
5
|
*
|
|
6
|
-
* Without a `search` block, `.search()`
|
|
7
|
-
*
|
|
8
|
-
* properties
|
|
9
|
-
* this block is the only way
|
|
6
|
+
* Without a `search` block, `.search()` is `ILIKE '%term%'` per whitespace-
|
|
7
|
+
* separated term, OR-ed across the collection's top-level, non-enum `string`
|
|
8
|
+
* properties and AND-ed across the terms — so two typed words may be found in
|
|
9
|
+
* two different columns, as they are here. Declaring this block is the only way
|
|
10
|
+
* to get anything more than that.
|
|
10
11
|
*
|
|
11
12
|
* The default has three limits that no amount of tuning inside it can fix:
|
|
12
13
|
* it cannot reach inside `map` (JSONB) or `array` properties, it has no notion
|
|
@@ -66,6 +67,53 @@ export interface SearchConfig {
|
|
|
66
67
|
* failure this whole block exists to prevent.
|
|
67
68
|
*/
|
|
68
69
|
fields: readonly (string | SearchField)[];
|
|
70
|
+
/**
|
|
71
|
+
* How a search string is matched against the indexed fields.
|
|
72
|
+
*
|
|
73
|
+
* - `"fts"` (default) — one `@@ websearch_to_tsquery` against the generated
|
|
74
|
+
* `tsvector`. Stems, drops stopwords, reaches inside JSONB and arrays,
|
|
75
|
+
* uses the GIN index, and ranks. It matches **whole lexemes**, so `seb`
|
|
76
|
+
* does not find `sebastian` and `audit` does not find `Auditor`.
|
|
77
|
+
* - `"hybrid"` — that predicate `OR` a substring match over the same
|
|
78
|
+
* declared fields, with accents folded on both sides. So one collection
|
|
79
|
+
* gets accent folding *and* substring/prefix matching, which is what a
|
|
80
|
+
* search box is: measured on five rows in `search-mode-matrix.test.ts`,
|
|
81
|
+
* `munoz` finds `Sebastian Munoz`, `seb` finds both Sebastians, `audit`
|
|
82
|
+
* finds the `ISO 14001 Lead Auditor`, and `iso 14001` does **not** drag
|
|
83
|
+
* in the `ISO 9001` row the way a loose `fuzzy` threshold does.
|
|
84
|
+
*
|
|
85
|
+
* ### Why this is a mode rather than the default
|
|
86
|
+
*
|
|
87
|
+
* The substring half cannot use the GIN index — a leading `%` never can —
|
|
88
|
+
* so it is a scan over the declared fields' text, evaluated per row. The
|
|
89
|
+
* `@@` half still runs first and still uses the index; what the mode costs
|
|
90
|
+
* is the rows the index rejected, which the planner has to look at anyway
|
|
91
|
+
* to apply the `OR`. On a large table that is the difference between an
|
|
92
|
+
* index scan and a sequential one, and that is the author's call to make
|
|
93
|
+
* rather than this default's.
|
|
94
|
+
*
|
|
95
|
+
* ### Changing this on a live collection
|
|
96
|
+
*
|
|
97
|
+
* Safe, and deliberately so. `mode` is **query-side only**: it changes no
|
|
98
|
+
* generated column, no generation expression and no index, so it does not
|
|
99
|
+
* trip the boot-time refusal a changed `search` block otherwise gets
|
|
100
|
+
* (`searchDriftMessage` — rebuilding a STORED generated column rewrites the
|
|
101
|
+
* table under an ACCESS EXCLUSIVE lock). Turning `"hybrid"` on for a
|
|
102
|
+
* collection whose column already exists takes a deploy and nothing else.
|
|
103
|
+
*
|
|
104
|
+
* The accent folding on the substring half is likewise query-side and
|
|
105
|
+
* unconditional under `"hybrid"` — it does **not** require
|
|
106
|
+
* {@link SearchConfig.unaccent}, which is what makes the switch free. What
|
|
107
|
+
* `unaccent` still buys is folding on the `@@` half, where the lexemes are
|
|
108
|
+
* stored, and that one *is* a column rebuild.
|
|
109
|
+
*
|
|
110
|
+
* It does add the `unaccent` extension and one IMMUTABLE helper function to
|
|
111
|
+
* the database if they are not there already. Both are `IF NOT EXISTS` /
|
|
112
|
+
* `CREATE OR REPLACE`, so both are additive and idempotent.
|
|
113
|
+
*
|
|
114
|
+
* @default "fts"
|
|
115
|
+
*/
|
|
116
|
+
mode?: SearchMode;
|
|
69
117
|
/**
|
|
70
118
|
* The Postgres text search configuration, which decides stemming and
|
|
71
119
|
* stopwords. `"spanish"` stems `auditores` to `auditor` and drops `de`;
|
|
@@ -154,6 +202,12 @@ export interface SearchField {
|
|
|
154
202
|
*/
|
|
155
203
|
weight?: SearchWeight;
|
|
156
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* How {@link SearchConfig.mode} matches a search string.
|
|
207
|
+
*
|
|
208
|
+
* @group Search
|
|
209
|
+
*/
|
|
210
|
+
export type SearchMode = "fts" | "hybrid";
|
|
157
211
|
/**
|
|
158
212
|
* Postgres tsvector weight classes, strongest to weakest.
|
|
159
213
|
*
|
|
@@ -162,6 +216,8 @@ export interface SearchField {
|
|
|
162
216
|
export type SearchWeight = "A" | "B" | "C" | "D";
|
|
163
217
|
/** The column name used when {@link SearchConfig.column} is not given. */
|
|
164
218
|
export declare const DEFAULT_SEARCH_COLUMN = "search_vector";
|
|
219
|
+
/** The matching strategy used when {@link SearchConfig.mode} is not given. */
|
|
220
|
+
export declare const DEFAULT_SEARCH_MODE: SearchMode;
|
|
165
221
|
/** The text search configuration used when {@link SearchConfig.language} is not given. */
|
|
166
222
|
export declare const DEFAULT_SEARCH_LANGUAGE = "simple";
|
|
167
223
|
/** The weight a field carries when it does not name one. */
|