@rebasepro/types 0.13.0 → 0.13.1-canary.g06dbe5b
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/call_context.d.ts +70 -4
- package/dist/controllers/client.d.ts +47 -74
- package/dist/controllers/data.d.ts +379 -36
- package/dist/controllers/data_driver.d.ts +71 -5
- package/dist/errors.d.ts +30 -4
- package/dist/index.es.js +204 -11
- package/dist/index.es.js.map +1 -1
- package/dist/types/admin_block.d.ts +38 -1
- package/dist/types/backend.d.ts +2 -0
- package/dist/types/collections.d.ts +46 -8
- package/dist/types/cron.d.ts +50 -9
- package/dist/types/entities.d.ts +11 -0
- package/dist/types/entity_callbacks.d.ts +2 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/policy.d.ts +13 -13
- package/dist/types/properties.d.ts +31 -6
- package/dist/types/rls-functions.d.ts +84 -0
- package/dist/types/search.d.ts +231 -0
- package/package.json +2 -2
- package/src/call_context.ts +68 -4
- package/src/controllers/client.ts +47 -95
- package/src/controllers/data.ts +390 -36
- package/src/controllers/data_driver.ts +109 -8
- package/src/errors.ts +43 -4
- package/src/types/admin_block.ts +85 -0
- package/src/types/backend.ts +2 -0
- package/src/types/collections.ts +47 -8
- package/src/types/cron.ts +51 -9
- package/src/types/entities.ts +12 -0
- package/src/types/entity_callbacks.ts +2 -1
- package/src/types/index.ts +2 -0
- package/src/types/policy.ts +13 -13
- package/src/types/properties.ts +32 -6
- package/src/types/rls-functions.ts +98 -0
- package/src/types/search.ts +247 -0
package/src/types/policy.ts
CHANGED
|
@@ -32,7 +32,7 @@ export type PolicyExpression =
|
|
|
32
32
|
| RawPolicyExpression;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* The id a request without a logged-in user reports as `
|
|
35
|
+
* The id a request without a logged-in user reports as `rebase.uid()`.
|
|
36
36
|
*
|
|
37
37
|
* A user-context request always sets `app.uid`: blank would read back as
|
|
38
38
|
* `NULL`, and `NULL` is how the trusted server context is recognised, so an
|
|
@@ -40,7 +40,7 @@ export type PolicyExpression =
|
|
|
40
40
|
* therefore substitutes this sentinel at the single chokepoint where the GUC
|
|
41
41
|
* is set.
|
|
42
42
|
*
|
|
43
|
-
* The consequence for policy authors is that **`
|
|
43
|
+
* The consequence for policy authors is that **`rebase.uid() IS NOT NULL` is a
|
|
44
44
|
* tautology on the user path** — it is true for anonymous visitors too. Use
|
|
45
45
|
* {@link policy.authenticated} to mean "signed in", and
|
|
46
46
|
* {@link policy.serverContext} to mean "the trusted server context". Do not
|
|
@@ -58,7 +58,7 @@ export const ANONYMOUS_USER_ID = "anonymous";
|
|
|
58
58
|
* JavaScript evaluator and the linter were all built on
|
|
59
59
|
* {@link ANONYMOUS_USER_ID}, while the request path scoped unauthenticated
|
|
60
60
|
* callers as `'anon'` — so `policy.authenticated()`, which compiled to
|
|
61
|
-
* `
|
|
61
|
+
* `rebase.uid() <> 'anonymous'`, was *true* for an anonymous visitor. The
|
|
62
62
|
* sanctioned way to write "signed in" granted to everyone, and the linter
|
|
63
63
|
* flagged the spelling that actually worked as a foreign convention.
|
|
64
64
|
*
|
|
@@ -117,7 +117,7 @@ export interface NotPolicyExpression {
|
|
|
117
117
|
export type PolicyCompareOperator = "eq" | "neq" | "lt" | "lte" | "gt" | "gte";
|
|
118
118
|
|
|
119
119
|
/**
|
|
120
|
-
* Compares two operands, e.g. `owner_id =
|
|
120
|
+
* Compares two operands, e.g. `owner_id = rebase.uid()`.
|
|
121
121
|
* @group Models
|
|
122
122
|
*/
|
|
123
123
|
export interface ComparePolicyExpression {
|
|
@@ -129,7 +129,7 @@ export interface ComparePolicyExpression {
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* True when the user holds *at least one* of the given application roles.
|
|
132
|
-
* Compiles to `string_to_array(
|
|
132
|
+
* Compiles to `string_to_array(rebase.roles(), ',') && ARRAY[...]`.
|
|
133
133
|
* @group Models
|
|
134
134
|
*/
|
|
135
135
|
export interface RolesOverlapPolicyExpression {
|
|
@@ -139,7 +139,7 @@ export interface RolesOverlapPolicyExpression {
|
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
141
|
* True when the user holds *all* of the given application roles.
|
|
142
|
-
* Compiles to `string_to_array(
|
|
142
|
+
* Compiles to `string_to_array(rebase.roles(), ',') @> ARRAY[...]`.
|
|
143
143
|
* @group Models
|
|
144
144
|
*/
|
|
145
145
|
export interface RolesContainPolicyExpression {
|
|
@@ -149,11 +149,11 @@ export interface RolesContainPolicyExpression {
|
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
151
|
* True when a signed-in user is making the request. Compiles to
|
|
152
|
-
* `
|
|
152
|
+
* `rebase.uid() IS NOT NULL AND rebase.uid() <> 'anonymous'`.
|
|
153
153
|
*
|
|
154
154
|
* Both halves are load-bearing. `IS NOT NULL` excludes the server context;
|
|
155
155
|
* the {@link ANONYMOUS_USER_ID} comparison excludes anonymous visitors, who
|
|
156
|
-
* *do* carry a non-null `
|
|
156
|
+
* *do* carry a non-null `rebase.uid()`. Checking only `IS NOT NULL` grants to
|
|
157
157
|
* everyone — see {@link ANONYMOUS_USER_ID}.
|
|
158
158
|
*
|
|
159
159
|
* `policy.not(policy.authenticated())` therefore means "anonymous visitor or
|
|
@@ -168,8 +168,8 @@ export interface AuthenticatedPolicyExpression {
|
|
|
168
168
|
/**
|
|
169
169
|
* True only in the trusted **server context** — the built-in flows that run
|
|
170
170
|
* without a user (signup, migrations, `dataAsAdmin`) set no user GUC, so
|
|
171
|
-
* `
|
|
172
|
-
* `
|
|
171
|
+
* `rebase.uid()` is `NULL` for them and only for them. Compiles to
|
|
172
|
+
* `rebase.uid() IS NULL`.
|
|
173
173
|
*
|
|
174
174
|
* This is what lets the owner connection satisfy a policy even under FORCE RLS.
|
|
175
175
|
* It is deliberately a primitive rather than `not(authenticated())`: the two
|
|
@@ -207,7 +207,7 @@ export interface ServerContextPolicyExpression {
|
|
|
207
207
|
* ),
|
|
208
208
|
* })
|
|
209
209
|
* // → EXISTS (SELECT 1 FROM team_members _ex0
|
|
210
|
-
* // WHERE _ex0.team_id = documents.team_id AND _ex0.user_id =
|
|
210
|
+
* // WHERE _ex0.team_id = documents.team_id AND _ex0.user_id = rebase.uid())
|
|
211
211
|
* ```
|
|
212
212
|
*
|
|
213
213
|
* Postgres-authoritative: like {@link RawPolicyExpression}, the JavaScript
|
|
@@ -272,14 +272,14 @@ export interface LiteralPolicyOperand {
|
|
|
272
272
|
value: string | number | boolean | null;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
/** The current user's id — compiles to `
|
|
275
|
+
/** The current user's id — compiles to `rebase.uid()`. @group Models */
|
|
276
276
|
export interface AuthUidPolicyOperand {
|
|
277
277
|
kind: "authUid";
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/**
|
|
281
281
|
* The current user's roles as an array — compiles to
|
|
282
|
-
* `string_to_array(
|
|
282
|
+
* `string_to_array(rebase.roles(), ',')`.
|
|
283
283
|
* @group Models
|
|
284
284
|
*/
|
|
285
285
|
export interface AuthRolesPolicyOperand {
|
package/src/types/properties.ts
CHANGED
|
@@ -218,14 +218,21 @@ export interface BaseProperty<CustomProps = unknown> {
|
|
|
218
218
|
validation?: PropertyValidationSchema;
|
|
219
219
|
|
|
220
220
|
/**
|
|
221
|
-
* Never
|
|
221
|
+
* Never mention this column on the API surface, in either direction.
|
|
222
222
|
*
|
|
223
223
|
* For secrets the server must store and read but no client should ever
|
|
224
224
|
* receive — password hashes, verification tokens. The value is still
|
|
225
225
|
* written and queryable server-side; it is stripped from every row the API
|
|
226
|
-
* serves, for every caller, including admins and service keys
|
|
226
|
+
* serves, for every caller, including admins and service keys, and it is
|
|
227
|
+
* absent from every generated description of the surface: the SDK's `Row`,
|
|
228
|
+
* `Insert` and `Update` types, and the OpenAPI schemas, filters and
|
|
229
|
+
* parameters.
|
|
227
230
|
*
|
|
228
|
-
*
|
|
231
|
+
* The generated types are a *description*, not a second enforcement point:
|
|
232
|
+
* the server still accepts such a field on a write, because that is how the
|
|
233
|
+
* value gets written in the first place. Nothing generated offers it.
|
|
234
|
+
*
|
|
235
|
+
* This is a server-side guarantee, unlike `admin.hideFromCollection`, which
|
|
229
236
|
* only stops the admin panel from *rendering* a field and leaves it in the
|
|
230
237
|
* JSON payload.
|
|
231
238
|
*/
|
|
@@ -1035,6 +1042,18 @@ export interface ImageResize {
|
|
|
1035
1042
|
*/
|
|
1036
1043
|
export type JsonLogicRule = Record<string, any>;
|
|
1037
1044
|
|
|
1045
|
+
/**
|
|
1046
|
+
* A condition that is either a JSON Logic rule or a literal answer.
|
|
1047
|
+
*
|
|
1048
|
+
* The unconditional case is the common one — "this field is never editable",
|
|
1049
|
+
* "this field is never shown" — and with only a rule accepted it had to be
|
|
1050
|
+
* spelled `{ "==": [1, 1] }`, which reads as a puzzle at the call site. A plain
|
|
1051
|
+
* `true` says the same thing.
|
|
1052
|
+
*
|
|
1053
|
+
* @group Entity properties
|
|
1054
|
+
*/
|
|
1055
|
+
export type ConditionRule = JsonLogicRule | boolean;
|
|
1056
|
+
|
|
1038
1057
|
/**
|
|
1039
1058
|
* Conditions for individual enum values within a property.
|
|
1040
1059
|
* @group Entity properties
|
|
@@ -1084,8 +1103,10 @@ export interface PropertyConditions {
|
|
|
1084
1103
|
* \`\`\`json
|
|
1085
1104
|
* { "==": [{ "var": "values.status" }, "archived"] }
|
|
1086
1105
|
* \`\`\`
|
|
1106
|
+
*
|
|
1107
|
+
* A literal `true` disables it unconditionally.
|
|
1087
1108
|
*/
|
|
1088
|
-
disabled?:
|
|
1109
|
+
disabled?: ConditionRule;
|
|
1089
1110
|
|
|
1090
1111
|
/**
|
|
1091
1112
|
* Message to display when the field is disabled by a condition.
|
|
@@ -1101,14 +1122,19 @@ export interface PropertyConditions {
|
|
|
1101
1122
|
/**
|
|
1102
1123
|
* Hide the field completely when this condition evaluates to true.
|
|
1103
1124
|
* The field is removed from the form (not just visually hidden).
|
|
1125
|
+
*
|
|
1126
|
+
* A literal `true` hides it unconditionally. This is the way to keep a
|
|
1127
|
+
* property out of the form without keeping it out of the collection.
|
|
1104
1128
|
*/
|
|
1105
|
-
hidden?:
|
|
1129
|
+
hidden?: ConditionRule;
|
|
1106
1130
|
|
|
1107
1131
|
/**
|
|
1108
1132
|
* Make the field read-only when this condition evaluates to true.
|
|
1109
1133
|
* Renders as a preview instead of an input.
|
|
1134
|
+
*
|
|
1135
|
+
* A literal `true` makes it read-only unconditionally.
|
|
1110
1136
|
*/
|
|
1111
|
-
readOnly?:
|
|
1137
|
+
readOnly?: ConditionRule;
|
|
1112
1138
|
|
|
1113
1139
|
// ═══════════════════════════════════════════════════════════════════════
|
|
1114
1140
|
// VALIDATION CONDITIONS
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SQL helper functions RLS policies call, and the schema they live in.
|
|
3
|
+
*
|
|
4
|
+
* ## One schema, and it is ours
|
|
5
|
+
*
|
|
6
|
+
* Rebase creates exactly one schema in a project's database: `rebase`. These
|
|
7
|
+
* three functions live in it alongside the framework's own tables, and that is
|
|
8
|
+
* the whole contract — a reader can look at a database and know precisely which
|
|
9
|
+
* namespace belongs to the framework and that nothing else was touched.
|
|
10
|
+
*
|
|
11
|
+
* It used to be two. `uid()`, `jwt()` and `roles()` sat in a schema called
|
|
12
|
+
* `auth`, which is Supabase's name, chosen so that a developer who had written
|
|
13
|
+
* Supabase RLS would recognise `auth.uid()`. The familiarity was real but the
|
|
14
|
+
* name was not Rebase's to take, and taking it had a concrete cost: pointing
|
|
15
|
+
* Rebase at a database that already had a Supabase `auth` schema meant
|
|
16
|
+
* `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` against Supabase's
|
|
17
|
+
* `RETURNS uuid`, which Postgres rejects outright —
|
|
18
|
+
*
|
|
19
|
+
* ERROR: cannot change return type of existing function
|
|
20
|
+
* HINT: Use DROP FUNCTION auth.uid() first.
|
|
21
|
+
*
|
|
22
|
+
* — and the failure landed inside a catch-all that logged a warning and carried
|
|
23
|
+
* on, leaving a database with auth tables, no helper functions, and policies
|
|
24
|
+
* calling functions that did not exist. Under `rebase db migrate` the same
|
|
25
|
+
* statements aborted the migration instead.
|
|
26
|
+
*
|
|
27
|
+
* `rebase.uid()` collides with nobody. A Supabase database keeps its `auth`
|
|
28
|
+
* schema untouched and gains a `rebase` one, which is what a gradual migration
|
|
29
|
+
* needs.
|
|
30
|
+
*
|
|
31
|
+
* ## Why functions at all, rather than inlining `current_setting`
|
|
32
|
+
*
|
|
33
|
+
* Because the indirection has already been spent once. `uid()` resolves
|
|
34
|
+
* `app.uid` and falls back to the pre-rename `app.user_id`, so that during a
|
|
35
|
+
* rolling deploy — old and new pods serving one database — both eras resolve
|
|
36
|
+
* the principal. That was a single `CREATE OR REPLACE`. Inlined into policy
|
|
37
|
+
* bodies it would have been a rewrite of every policy on every table.
|
|
38
|
+
*
|
|
39
|
+
* ## Why the name is not configurable
|
|
40
|
+
*
|
|
41
|
+
* A policy body is stored SQL: Postgres parses `USING (…)` once and keeps it, so
|
|
42
|
+
* these strings are written into every policy in every database Rebase has
|
|
43
|
+
* provisioned. Everything that reads policies back — the SQL-to-policy parser
|
|
44
|
+
* behind the admin UI, the drift checker, `rls-check` — would have to know the
|
|
45
|
+
* configured value to recognise its own output. One frozen name is the feature.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** The schema Rebase owns. The only schema Rebase creates. */
|
|
49
|
+
export const REBASE_SCHEMA = "rebase";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The principal of the current request, as text, or NULL in the server context.
|
|
53
|
+
*
|
|
54
|
+
* Never NULL for a user request — an anonymous one carries
|
|
55
|
+
* {@link ANONYMOUS_USER_ID} — which is what makes `IS NULL` a reliable test for
|
|
56
|
+
* the trusted server plane and `IS NOT NULL` a tautology.
|
|
57
|
+
*/
|
|
58
|
+
export const RLS_UID_SQL = `${REBASE_SCHEMA}.uid()`;
|
|
59
|
+
|
|
60
|
+
/** The request's roles as a comma-separated string, for `string_to_array`. */
|
|
61
|
+
export const RLS_ROLES_SQL = `${REBASE_SCHEMA}.roles()`;
|
|
62
|
+
|
|
63
|
+
/** The request's JWT claims as `jsonb`, or `{}`. */
|
|
64
|
+
export const RLS_JWT_SQL = `${REBASE_SCHEMA}.jwt()`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The pre-1.0 spellings, for recognising policies and hand-written SQL that
|
|
68
|
+
* predate the move.
|
|
69
|
+
*
|
|
70
|
+
* Kept because policies outlive the server that wrote them: a database migrated
|
|
71
|
+
* by an older release still holds `auth.uid()` in its policy bodies until the
|
|
72
|
+
* next push or boot recompiles them, and anything that reads policies back has
|
|
73
|
+
* to recognise both eras or report the framework's own output as foreign drift.
|
|
74
|
+
* Also used to give a project whose `securityRules` contain raw `auth.uid()` a
|
|
75
|
+
* message naming the replacement, instead of a parse failure.
|
|
76
|
+
*/
|
|
77
|
+
export const LEGACY_RLS_SCHEMA = "auth";
|
|
78
|
+
export const LEGACY_RLS_UID_SQL = `${LEGACY_RLS_SCHEMA}.uid()`;
|
|
79
|
+
export const LEGACY_RLS_ROLES_SQL = `${LEGACY_RLS_SCHEMA}.roles()`;
|
|
80
|
+
export const LEGACY_RLS_JWT_SQL = `${LEGACY_RLS_SCHEMA}.jwt()`;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Rewrites the pre-1.0 function calls in a fragment of policy SQL.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately anchored on a word boundary and the schema qualifier, so a column
|
|
86
|
+
* called `auth_uid` or a table named `auth` is left alone.
|
|
87
|
+
*/
|
|
88
|
+
export function rewriteLegacyRlsFunctions(sql: string): string {
|
|
89
|
+
return sql.replace(
|
|
90
|
+
/\bauth\.(uid|jwt|roles)\s*\(\s*\)/gi,
|
|
91
|
+
(_match, fn: string) => `${REBASE_SCHEMA}.${fn.toLowerCase()}()`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether a fragment of SQL still calls the pre-1.0 functions. */
|
|
96
|
+
export function usesLegacyRlsFunctions(sql: string): boolean {
|
|
97
|
+
return /\bauth\.(uid|jwt|roles)\s*\(\s*\)/i.test(sql);
|
|
98
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in full-text search configuration.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is opt-in
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* The default has three limits that no amount of tuning inside it can fix:
|
|
12
|
+
* it cannot reach inside `map` (JSONB) or `array` properties, it has no notion
|
|
13
|
+
* of relevance, and a leading `%` means it can never use an index. Collections
|
|
14
|
+
* that outgrow those limits declare what they want searched; collections that
|
|
15
|
+
* have not are left completely alone.
|
|
16
|
+
*
|
|
17
|
+
* ## What declaring it does
|
|
18
|
+
*
|
|
19
|
+
* One `tsvector` column, `GENERATED ALWAYS AS … STORED`, plus one GIN index on
|
|
20
|
+
* it. Postgres recomputes the column on every write of a source field, so it
|
|
21
|
+
* cannot drift from the row, and refuses any attempt to write it directly.
|
|
22
|
+
* `.search()` then compiles to `@@ websearch_to_tsquery(…)` against that
|
|
23
|
+
* column, which stems, drops stopwords, AND-es the terms, and ranks.
|
|
24
|
+
*
|
|
25
|
+
* These are stated consequences, not hidden ones: the column and the index
|
|
26
|
+
* appear in generated DDL, in `schema.generated.ts`, and in `rebase db push`
|
|
27
|
+
* output like any other declared object.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const talents: PostgresCollectionConfig = {
|
|
32
|
+
* slug: "talents",
|
|
33
|
+
* table: "talents",
|
|
34
|
+
* properties: { … },
|
|
35
|
+
* search: {
|
|
36
|
+
* language: "spanish",
|
|
37
|
+
* unaccent: true,
|
|
38
|
+
* fields: [
|
|
39
|
+
* { path: "full_name", weight: "A" },
|
|
40
|
+
* "location",
|
|
41
|
+
* "questionnaire.certifications" // into the JSONB
|
|
42
|
+
* ]
|
|
43
|
+
* }
|
|
44
|
+
* };
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @group Search
|
|
48
|
+
*/
|
|
49
|
+
export interface SearchConfig {
|
|
50
|
+
/**
|
|
51
|
+
* The fields to index, in the author's own words. Nothing is inferred: a
|
|
52
|
+
* field is searched if and only if it is named here.
|
|
53
|
+
*
|
|
54
|
+
* A bare string is shorthand for `{ path, weight: "B" }`.
|
|
55
|
+
*
|
|
56
|
+
* A path may address:
|
|
57
|
+
* - a top-level `string` property — `"full_name"`
|
|
58
|
+
* - a `string[]` property — `"tags"` (every element is indexed)
|
|
59
|
+
* - a path into a `map` property — `"questionnaire.certifications"`,
|
|
60
|
+
* which indexes every string found at or below that point, including
|
|
61
|
+
* nested objects and arrays of strings. JSON *keys* are never indexed,
|
|
62
|
+
* only values.
|
|
63
|
+
*
|
|
64
|
+
* A path that does not resolve to one of those is a boot-time error, not
|
|
65
|
+
* a silent omission — a search field you believe is live and is not is the
|
|
66
|
+
* failure this whole block exists to prevent.
|
|
67
|
+
*/
|
|
68
|
+
fields: readonly (string | SearchField)[];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Postgres text search configuration, which decides stemming and
|
|
72
|
+
* stopwords. `"spanish"` stems `auditores` to `auditor` and drops `de`;
|
|
73
|
+
* `"simple"` does neither.
|
|
74
|
+
*
|
|
75
|
+
* Defaults to `"simple"`, which is the only choice that is never wrong:
|
|
76
|
+
* a stemmer applied to the wrong language silently mangles lexemes. Set it
|
|
77
|
+
* to your content's language to get stemming.
|
|
78
|
+
*
|
|
79
|
+
* @default "simple"
|
|
80
|
+
*/
|
|
81
|
+
language?: string;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fold accents before indexing, so `auditoria` matches `auditoría`.
|
|
85
|
+
*
|
|
86
|
+
* This is not cosmetic in accented languages. Postgres stems the two
|
|
87
|
+
* spellings to *different* lexemes — `to_tsvector('spanish', 'auditoría')`
|
|
88
|
+
* yields `auditor` while `'auditoria'` yields `auditori` — so without this
|
|
89
|
+
* a query typed without accents misses the rows that carry them, which is
|
|
90
|
+
* most queries most users type.
|
|
91
|
+
*
|
|
92
|
+
* Requires the `unaccent` extension. Boot fails with an explicit message if
|
|
93
|
+
* it is not installed and cannot be created, rather than quietly indexing
|
|
94
|
+
* accented text as-is.
|
|
95
|
+
*
|
|
96
|
+
* @default false
|
|
97
|
+
*/
|
|
98
|
+
unaccent?: boolean;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Name of the generated column holding the `tsvector`.
|
|
102
|
+
*
|
|
103
|
+
* Only change this if `search_vector` collides with a column you already
|
|
104
|
+
* have. It is part of your schema once created: renaming it later is a
|
|
105
|
+
* column drop and recreate, which rewrites the table.
|
|
106
|
+
*
|
|
107
|
+
* @default "search_vector"
|
|
108
|
+
*/
|
|
109
|
+
column?: string;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Also match on trigram similarity, so near-misses and typos still rank —
|
|
113
|
+
* `iso14000` reaching `ISO 14001`, which no amount of stemming will do
|
|
114
|
+
* because they are simply different lexemes.
|
|
115
|
+
*
|
|
116
|
+
* Adds a second generated `text` column and a GIN trigram index alongside
|
|
117
|
+
* the `tsvector`, and requires the `pg_trgm` extension. Costs write time
|
|
118
|
+
* and disk; buys the single most common class of failed search.
|
|
119
|
+
*
|
|
120
|
+
* Also changes what `_score` means: the trigram similarity is added to
|
|
121
|
+
* `ts_rank`. It has to be. A typo matches nothing on the exact path, so
|
|
122
|
+
* every row this finds has a `ts_rank` of zero — ranking by that alone
|
|
123
|
+
* would order the results arbitrarily, which is the failure `fuzzy` exists
|
|
124
|
+
* to fix.
|
|
125
|
+
*
|
|
126
|
+
* @default false
|
|
127
|
+
*/
|
|
128
|
+
fuzzy?: boolean;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Similarity floor for {@link SearchConfig.fuzzy}, between 0 and 1. A row
|
|
132
|
+
* whose trigram similarity to the query falls below this never matches on
|
|
133
|
+
* the fuzzy path (it can still match on the exact one).
|
|
134
|
+
*
|
|
135
|
+
* Lower admits more typos and more noise. Ignored unless `fuzzy` is set.
|
|
136
|
+
*
|
|
137
|
+
* @default 0.3
|
|
138
|
+
*/
|
|
139
|
+
fuzzyThreshold?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One indexed field, with the weight it carries in the ranking.
|
|
144
|
+
*
|
|
145
|
+
* @group Search
|
|
146
|
+
*/
|
|
147
|
+
export interface SearchField {
|
|
148
|
+
/**
|
|
149
|
+
* Property name, or dotted path into a `map` property.
|
|
150
|
+
* @see SearchConfig.fields
|
|
151
|
+
*/
|
|
152
|
+
path: string;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Postgres weight class. `ts_rank` scores an `A` hit far above a `D` hit,
|
|
156
|
+
* which is how a name outranks a passing mention in a long description.
|
|
157
|
+
*
|
|
158
|
+
* The four classes are Postgres's own and there are exactly four.
|
|
159
|
+
*
|
|
160
|
+
* @default "B"
|
|
161
|
+
*/
|
|
162
|
+
weight?: SearchWeight;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Postgres tsvector weight classes, strongest to weakest.
|
|
167
|
+
*
|
|
168
|
+
* @group Search
|
|
169
|
+
*/
|
|
170
|
+
export type SearchWeight = "A" | "B" | "C" | "D";
|
|
171
|
+
|
|
172
|
+
/** The column name used when {@link SearchConfig.column} is not given. */
|
|
173
|
+
export const DEFAULT_SEARCH_COLUMN = "search_vector";
|
|
174
|
+
|
|
175
|
+
/** The text search configuration used when {@link SearchConfig.language} is not given. */
|
|
176
|
+
export const DEFAULT_SEARCH_LANGUAGE = "simple";
|
|
177
|
+
|
|
178
|
+
/** The weight a field carries when it does not name one. */
|
|
179
|
+
export const DEFAULT_SEARCH_WEIGHT: SearchWeight = "B";
|
|
180
|
+
|
|
181
|
+
/** The similarity floor used when {@link SearchConfig.fuzzyThreshold} is not given. */
|
|
182
|
+
export const DEFAULT_FUZZY_THRESHOLD = 0.3;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Sort keys a query computes rather than reads from a column.
|
|
186
|
+
*
|
|
187
|
+
* `orderBy` is otherwise typed against the row — `keyof M` — which is exactly
|
|
188
|
+
* right for a column and exactly wrong for relevance: `_score` is produced by
|
|
189
|
+
* the query, so it appears in no generated row type and a project with a
|
|
190
|
+
* generated SDK could not name it. The runtime accepted it, the docs told
|
|
191
|
+
* people to use it, and the types rejected it.
|
|
192
|
+
*
|
|
193
|
+
* Kept as a named union rather than a loose `string` so the other half of the
|
|
194
|
+
* guarantee survives: a typo'd column is still a compile error, and remains a
|
|
195
|
+
* 400 at runtime rather than a silently unsorted list.
|
|
196
|
+
*
|
|
197
|
+
* `_distance` is deliberately not here. A vector search orders by distance on
|
|
198
|
+
* its own and overrides `orderBy` outright, so naming it would imply a choice
|
|
199
|
+
* the caller does not have.
|
|
200
|
+
*
|
|
201
|
+
* @group Search
|
|
202
|
+
*/
|
|
203
|
+
export type ComputedSortField = typeof RELEVANCE_SORT_FIELD;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The relevance sort key. Valid only on a collection that declares a
|
|
207
|
+
* {@link SearchConfig} *and* on a query that carries a search string; anywhere
|
|
208
|
+
* else it is an unknown field and the request is refused.
|
|
209
|
+
*/
|
|
210
|
+
export const RELEVANCE_SORT_FIELD = "_score";
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One field that matched, and the text around the hit.
|
|
214
|
+
*
|
|
215
|
+
* Returned per row as `_matches` when a query asks for it — see the `explain`
|
|
216
|
+
* option on `.search()`. Answers the question a ranked list otherwise leaves
|
|
217
|
+
* open: *why is this row here?* A candidate surfacing for "iso 14001" because
|
|
218
|
+
* of a certification is a different result from one surfacing because the
|
|
219
|
+
* string appears in a paragraph about something else, and the score alone
|
|
220
|
+
* cannot tell them apart.
|
|
221
|
+
*
|
|
222
|
+
* @group Search
|
|
223
|
+
*/
|
|
224
|
+
export interface SearchMatch {
|
|
225
|
+
/**
|
|
226
|
+
* The declared field path that matched, exactly as written in
|
|
227
|
+
* {@link SearchConfig.fields} — e.g. `"questionnaire.certifications"`.
|
|
228
|
+
* Map it to a label for display; the path is stable, a label is yours.
|
|
229
|
+
*/
|
|
230
|
+
field: string;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The matching text, with each hit wrapped in `<mark>…</mark>`.
|
|
234
|
+
*
|
|
235
|
+
* Built by Postgres's `ts_headline` over the same normalized text that was
|
|
236
|
+
* indexed. With {@link SearchConfig.unaccent} on that means the snippet
|
|
237
|
+
* reads with accents folded — `Auditoria` rather than `Auditoría`. That is
|
|
238
|
+
* deliberate: `ts_headline` over the *original* text cannot find a hit the
|
|
239
|
+
* unaccented query produced, so it returns the text with nothing marked at
|
|
240
|
+
* all. A readable snippet that highlights beats a prettier one that
|
|
241
|
+
* silently does not.
|
|
242
|
+
*
|
|
243
|
+
* Contains markup by construction. Render it as HTML or strip the tags —
|
|
244
|
+
* do not display it raw, and do not trust it as plain text.
|
|
245
|
+
*/
|
|
246
|
+
snippet: string;
|
|
247
|
+
}
|