@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.
@@ -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
@@ -3,10 +3,11 @@
3
3
  *
4
4
  * ## Why this is opt-in
5
5
  *
6
- * Without a `search` block, `.search()` behaves exactly as it always has: an
7
- * `ILIKE '%term%'` OR-ed across the collection's top-level, non-enum `string`
8
- * properties. That default is unchanged and will stay unchanged — declaring
9
- * this block is the only way to get anything else.
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebasepro/types",
3
- "version": "0.21.2-canary.g1ea48be",
3
+ "version": "0.22.0",
4
4
  "description": "Rebase type definitions — shared interfaces and controller types",
5
5
  "keywords": [
6
6
  "rebase",