@rebasepro/types 0.21.2-canary.g1ea48be → 0.23.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.
@@ -490,6 +490,40 @@ export interface AuthAdapter {
490
490
  id: string;
491
491
  values: Record<string, unknown>;
492
492
  }, clearPassword?: string): Promise<UserCreationFinalizeResult>;
493
+ /**
494
+ * Check, and put in stored form, updates to rows of the auth collection
495
+ * that arrive through the data API — `PATCH /<users>/:id`, `PATCH /bulk`,
496
+ * `_batch`, the realtime socket.
497
+ *
498
+ * Those rows are the users, so an update to one is user administration,
499
+ * and has to hold to what the admin user routes hold to: no update that
500
+ * leaves the project without an administrator, and an email stored the
501
+ * way sign-in looks it up. Throws to refuse; the updates are judged
502
+ * together, so a bulk demotion of every admin is refused even though each
503
+ * row alone would pass.
504
+ *
505
+ * If not implemented, the values are written as they arrived.
506
+ *
507
+ * @returns Each update's values, as they should be written, in order.
508
+ */
509
+ prepareUserUpdates?(updates: ReadonlyArray<{
510
+ uid: string;
511
+ values: Record<string, unknown>;
512
+ }>): Promise<Record<string, unknown>[]>;
513
+ /**
514
+ * Check a deletion of these users through the data API before any row goes,
515
+ * and run the hooks that may veto it (`beforeUserDelete`). Throws to
516
+ * refuse — for one that would leave the project without an administrator,
517
+ * among others.
518
+ *
519
+ * If not implemented, the rows are deleted as any other.
520
+ */
521
+ prepareUserDeletions?(uids: readonly string[]): Promise<void>;
522
+ /**
523
+ * After those users' rows are deleted through the data API: end their
524
+ * sessions and run the after-delete hooks (`afterUserDelete`).
525
+ */
526
+ finalizeUserDeletions?(uids: readonly string[]): Promise<void>;
493
527
  /**
494
528
  * A static secret key for server-to-server / script authentication.
495
529
  *
@@ -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.
@@ -331,11 +350,18 @@ export interface DataTransformer {
331
350
  export interface SQLAdmin {
332
351
  /**
333
352
  * Execute raw SQL against the database.
353
+ *
354
+ * `isolateSession` runs it on a connection of its own and resets that
355
+ * connection's session state — role, session authorization, settings —
356
+ * before it is reused. For SQL a person wrote (the Studio editor), which
357
+ * may `SET ROLE` and must not leave that on a pooled connection the
358
+ * server's own queries use next.
334
359
  */
335
360
  executeSql(sql: string, options?: {
336
361
  database?: string;
337
362
  role?: string;
338
363
  params?: unknown[];
364
+ isolateSession?: boolean;
339
365
  }): Promise<Record<string, unknown>[]>;
340
366
  /**
341
367
  * Fetch the available databases on the server.
@@ -560,6 +586,47 @@ export interface BackendInstance extends BackendLifecycle {
560
586
  * Factory function type for creating backend instances
561
587
  */
562
588
  export type BackendFactory<TConfig extends BackendConfig = BackendConfig> = (config: TConfig) => BackendInstance;
589
+ /**
590
+ * A caller of the data API as its rate limiter buckets one outside an HTTP
591
+ * request: a realtime socket's frame, counted in the same bucket the same
592
+ * person's HTTP requests are.
593
+ * @group Backend
594
+ */
595
+ export interface DataRateLimitCaller {
596
+ /**
597
+ * The signed-in user's uid. Absent, or the anonymous principal, buckets
598
+ * the caller by address at the anonymous allowance.
599
+ */
600
+ uid?: string;
601
+ /**
602
+ * A header of the request that opened the connection, by lower-case name:
603
+ * the proxy headers an address is read from when proxies are declared.
604
+ */
605
+ header(name: string): string | undefined;
606
+ /** The address the connection comes from. */
607
+ socketAddress?: string;
608
+ }
609
+ /**
610
+ * The data API's request limits, handed to the realtime socket so its frames
611
+ * carry the ones an HTTP request to the same rows does.
612
+ * @group Backend
613
+ */
614
+ export interface RealtimeSocketLimits {
615
+ /**
616
+ * The largest frame accepted, in bytes — the data API's body limit. `0` or
617
+ * less for none. Defaults to the server's default body limit.
618
+ */
619
+ maxPayload?: number;
620
+ /**
621
+ * Count one data request from `caller` in the data API's per-caller
622
+ * buckets and say whether it is allowed; `null` for a caller who is not
623
+ * limited. Absent when the deployment has rate limiting off.
624
+ */
625
+ dataRateLimit?: (caller: DataRateLimitCaller) => Promise<{
626
+ allowed: boolean;
627
+ retryAfterMs: number;
628
+ } | null>;
629
+ }
563
630
  /**
564
631
  * A `BackendBootstrapper` encapsulates all driver-specific initialization logic.
565
632
  *
@@ -765,7 +832,7 @@ export interface BackendBootstrapper {
765
832
  /**
766
833
  * Initialize WebSocket server for realtime operations.
767
834
  */
768
- initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: import("../controllers/data_driver.js").DataDriver, config?: unknown, authAdapter?: AuthAdapter): Promise<void> | void;
835
+ initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: import("../controllers/data_driver.js").DataDriver, config?: unknown, authAdapter?: AuthAdapter, limits?: RealtimeSocketLimits): Promise<void> | void;
769
836
  }
770
837
  /**
771
838
  * Result of `BackendBootstrapper.initializeDriver()`.
@@ -43,7 +43,9 @@ export interface CronJobDefinition {
43
43
  enabled?: boolean;
44
44
  /**
45
45
  * Maximum number of seconds the handler may run before being
46
- * considered timed-out. Default: 300 (5 min).
46
+ * considered timed-out. Default: 300 (5 min). `Infinity` means no
47
+ * timeout. Zero, a negative number or `NaN` is refused when the job
48
+ * loads, and the job is listed as rejected.
47
49
  */
48
50
  timeoutSeconds?: number;
49
51
  /**
@@ -95,7 +97,8 @@ export interface CronJobContext {
95
97
  /** A simple logger scoped to this job run. */
96
98
  log: (...args: unknown[]) => void;
97
99
  /**
98
- * Aborted when the run exceeds `timeoutSeconds`.
100
+ * Aborted when the run exceeds `timeoutSeconds`, or when the server
101
+ * shuts down and the run has not finished within the shutdown's wait.
99
102
  *
100
103
  * The timeout has always stopped the scheduler *waiting* — it loses the
101
104
  * race and the run is recorded as failed. It has never stopped the handler:
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import type { DataDriver } from "../controllers/data_driver.js";
23
23
  import type { CollectionConfig } from "./collections.js";
24
- import type { CollectionRegistryInterface, DatabaseAdmin, InitializedDriver, RealtimeProvider, BootstrappedAuth } from "./backend.js";
24
+ import type { CollectionRegistryInterface, DatabaseAdmin, InitializedDriver, RealtimeProvider, RealtimeSocketLimits, BootstrappedAuth } from "./backend.js";
25
25
  import type { HistoryConfig } from "../controllers/client.js";
26
26
  /**
27
27
  * A `DatabaseAdapter` provides data persistence for Rebase.
@@ -73,8 +73,12 @@ export interface DatabaseAdapter {
73
73
  * already passed it, so it was dropped at every adapter that routed through
74
74
  * here — turning an adapter-authenticated server's socket into one that
75
75
  * accepted every client as already authenticated.
76
+ *
77
+ * `limits` are the data API's body and per-caller rate limits, for the
78
+ * socket's frames: it serves the same rows, and without them a frame had
79
+ * neither. Forwarded for the same reason `adapter` is.
76
80
  */
77
- initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: DataDriver, config?: unknown, adapter?: import("./auth_adapter.js").AuthAdapter): Promise<void> | void;
81
+ initializeWebsockets?(server: unknown, realtimeService: RealtimeProvider, driver: DataDriver, config?: unknown, adapter?: import("./auth_adapter.js").AuthAdapter, limits?: RealtimeSocketLimits): Promise<void> | void;
78
82
  /**
79
83
  * Ask the database whether it is there, before boot's first real query. See
80
84
  * `BackendBootstrapper.verifyConnection` for the contract.
@@ -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
@@ -56,8 +56,18 @@ export interface TableJunctionInfo {
56
56
  */
57
57
  export interface TablePolicyInfo {
58
58
  policy_name: string;
59
+ /**
60
+ * The policy's `TO` list — *database* roles, `public` for every
61
+ * connection — as `pg_policies.roles` reports it.
62
+ */
59
63
  roles: string[];
64
+ /** `SELECT`, `INSERT`, `UPDATE`, `DELETE` or `ALL`, as `pg_policies.cmd` reports it. */
60
65
  cmd: string;
66
+ /**
67
+ * `pg_policies.permissive`. Absent means `PERMISSIVE`, Postgres's own
68
+ * default.
69
+ */
70
+ permissive?: "PERMISSIVE" | "RESTRICTIVE";
61
71
  qual?: string;
62
72
  with_check?: string;
63
73
  }
@@ -24,7 +24,7 @@
24
24
  * changing a type, moving a primary key.
25
25
  */
26
26
  export type SchemaChangeVerdict = "safe" | "diverges" | "needs-migration";
27
- export type SchemaChangeKind = "add-collection" | "remove-collection" | "add-property" | "remove-property" | "change-property-type" | "rename-column" | "add-enum-value" | "remove-enum-value" | "change-required" | "change-primary-key";
27
+ export type SchemaChangeKind = "add-collection" | "remove-collection" | "add-property" | "remove-property" | "change-property-type" | "rename-column" | "add-enum-value" | "remove-enum-value" | "change-required" | "change-primary-key" | "rename-table";
28
28
  export interface SchemaChange {
29
29
  kind: SchemaChangeKind;
30
30
  verdict: SchemaChangeVerdict;
@@ -106,7 +106,7 @@ export interface SchemaChangePlan {
106
106
  export interface WithheldSchemaConstraint {
107
107
  /** `schema.table.column`. */
108
108
  target: string;
109
- kind: "not-null";
109
+ kind: "not-null" | "unique";
110
110
  /** What is in the way, naming the obstacle rather than the rule. */
111
111
  reason: string;
112
112
  /** What would make it applicable. */
@@ -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. */
@@ -53,7 +53,7 @@ export type SecurityOperation = "select" | "insert" | "update" | "delete" | "all
53
53
  *
54
54
  * @group Models
55
55
  */
56
- export type SecurityRule = OwnerSecurityRule | PublicSecurityRule | StructuredSecurityRule | RawSQLSecurityRule | RolesOnlySecurityRule;
56
+ export type SecurityRule = OwnerSecurityRule | PublicSecurityRule | StructuredSecurityRule | RawSQLSecurityRule | RawSQLCheckOnlySecurityRule | RolesOnlySecurityRule;
57
57
  /**
58
58
  * Shared fields for all SecurityRule variants.
59
59
  * @group Models
@@ -297,6 +297,33 @@ export interface RawSQLSecurityRule extends SecurityRuleBase {
297
297
  condition?: never;
298
298
  check?: never;
299
299
  }
300
+ /**
301
+ * Security rule with a raw SQL `WITH CHECK` clause and no `USING` clause — the
302
+ * shape of every INSERT policy, which checks the row being written and has no
303
+ * existing row to filter.
304
+ *
305
+ * On an operation that takes a `USING` clause, the missing one grants no rows,
306
+ * as a Postgres policy without one does.
307
+ *
308
+ * @example
309
+ * // Anyone may insert a row they own
310
+ * { operation: "insert", withCheck: "{owner_id} = rebase.uid()" }
311
+ *
312
+ * @group Models
313
+ */
314
+ export interface RawSQLCheckOnlySecurityRule extends SecurityRuleBase {
315
+ /**
316
+ * Raw SQL expression for the `WITH CHECK` clause.
317
+ * This controls which *new/updated* row values are allowed.
318
+ * Applied to INSERT and UPDATE.
319
+ */
320
+ withCheck: string;
321
+ using?: never;
322
+ ownerField?: never;
323
+ access?: never;
324
+ condition?: never;
325
+ check?: never;
326
+ }
300
327
  /**
301
328
  * Security rule that only filters by application roles, without any
302
329
  * row-level condition (USING/WITH CHECK).
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.23.0",
4
4
  "description": "Rebase type definitions — shared interfaces and controller types",
5
5
  "keywords": [
6
6
  "rebase",
@@ -46,7 +46,7 @@
46
46
  "@types/node": "^26.1.2",
47
47
  "@types/object-hash": "^3.0.6",
48
48
  "@types/react-measure": "^2.0.12",
49
- "hono": "^4.13.0",
49
+ "hono": "^4.13.5",
50
50
  "jest": "^30.4.2",
51
51
  "ts-jest": "^29.4.12",
52
52
  "typescript": "^6.0.3",