@venizia/ignis-docs 0.0.8 → 0.2.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/README.md +7 -7
- package/content/best-practices/api-usage-examples.md +15 -12
- package/content/best-practices/architectural-patterns.md +70 -78
- package/content/best-practices/architecture-decisions.md +91 -60
- package/content/best-practices/code-style-standards/advanced-patterns.md +56 -44
- package/content/best-practices/code-style-standards/constants-configuration.md +11 -11
- package/content/best-practices/code-style-standards/control-flow.md +5 -2
- package/content/best-practices/code-style-standards/documentation.md +13 -13
- package/content/best-practices/code-style-standards/function-patterns.md +9 -10
- package/content/best-practices/code-style-standards/index.md +1 -1
- package/content/best-practices/code-style-standards/naming-conventions.md +10 -8
- package/content/best-practices/code-style-standards/route-definitions.md +30 -12
- package/content/best-practices/code-style-standards/tooling.md +8 -5
- package/content/best-practices/code-style-standards/type-safety.md +13 -12
- package/content/best-practices/common-pitfalls.md +56 -37
- package/content/best-practices/contribution-workflow.md +13 -14
- package/content/best-practices/data-modeling.md +46 -22
- package/content/best-practices/deployment-strategies.md +28 -27
- package/content/best-practices/error-handling.md +48 -24
- package/content/best-practices/index.md +5 -5
- package/content/best-practices/performance-optimization.md +40 -31
- package/content/best-practices/security-guidelines.md +52 -23
- package/content/best-practices/testing-strategies.md +65 -51
- package/content/best-practices/troubleshooting-tips.md +24 -24
- package/content/extensions/components/{swagger.md → api-reference.md} +40 -31
- package/content/extensions/components/authentication/api.md +19 -19
- package/content/extensions/components/authentication/errors.md +7 -7
- package/content/extensions/components/authentication/index.md +10 -8
- package/content/extensions/components/authentication/usage.md +101 -6
- package/content/extensions/components/authorization/api.md +45 -25
- package/content/extensions/components/authorization/errors.md +6 -6
- package/content/extensions/components/authorization/index.md +11 -10
- package/content/extensions/components/authorization/usage.md +21 -21
- package/content/extensions/components/health-check.md +1 -1
- package/content/extensions/components/index.md +5 -5
- package/content/extensions/components/mail/errors.md +15 -15
- package/content/extensions/components/mail/index.md +1 -2
- package/content/extensions/components/mail/usage.md +1 -1
- package/content/extensions/components/request-tracker.md +1 -1
- package/content/extensions/components/socket-io/api.md +9 -9
- package/content/extensions/components/socket-io/errors.md +5 -5
- package/content/extensions/components/socket-io/index.md +8 -8
- package/content/extensions/components/socket-io/usage.md +1 -1
- package/content/extensions/components/static-asset/api.md +17 -4
- package/content/extensions/components/static-asset/errors.md +4 -4
- package/content/extensions/components/static-asset/index.md +26 -28
- package/content/extensions/components/static-asset/usage.md +13 -12
- package/content/extensions/components/template/index.md +2 -2
- package/content/extensions/components/template/setup-page.md +1 -1
- package/content/extensions/components/websocket/api.md +3 -3
- package/content/extensions/components/websocket/errors.md +5 -5
- package/content/extensions/components/websocket/index.md +5 -5
- package/content/extensions/components/websocket/usage.md +3 -3
- package/content/extensions/helpers/cron/index.md +2 -2
- package/content/extensions/helpers/crypto/index.md +1 -1
- package/content/extensions/helpers/env/index.md +27 -12
- package/content/extensions/helpers/error/index.md +81 -25
- package/content/extensions/helpers/index.md +2 -3
- package/content/extensions/helpers/inversion/index.md +15 -7
- package/content/extensions/helpers/kafka/compile-binary.md +92 -0
- package/content/extensions/helpers/kafka/examples.md +1 -1
- package/content/extensions/helpers/kafka/index.md +3 -0
- package/content/extensions/helpers/logger/index.md +32 -2
- package/content/extensions/helpers/network/index.md +6 -0
- package/content/extensions/helpers/queue/index.md +14 -17
- package/content/extensions/helpers/redis/index.md +548 -323
- package/content/extensions/helpers/socket-io/index.md +14 -10
- package/content/extensions/helpers/storage/api.md +44 -8
- package/content/extensions/helpers/storage/index.md +43 -7
- package/content/extensions/helpers/template/index.md +6 -3
- package/content/extensions/helpers/types/index.md +11 -8
- package/content/extensions/helpers/websocket/api.md +9 -9
- package/content/extensions/helpers/websocket/index.md +7 -7
- package/content/extensions/helpers/worker-thread/index.md +2 -2
- package/content/extensions/index.md +3 -4
- package/content/extensions/src-details/mcp-server.md +18 -24
- package/content/guides/core-concepts/application/bootstrapping.md +11 -14
- package/content/guides/core-concepts/application/index.md +3 -3
- package/content/guides/core-concepts/components.md +19 -10
- package/content/guides/core-concepts/dependency-injection.md +6 -3
- package/content/guides/core-concepts/grpc-controllers.md +6 -5
- package/content/guides/core-concepts/persistent/datasources.md +42 -43
- package/content/guides/core-concepts/persistent/index.md +16 -7
- package/content/guides/core-concepts/persistent/models.md +24 -20
- package/content/guides/core-concepts/persistent/postgres-drivers.md +201 -0
- package/content/guides/core-concepts/persistent/repositories.md +40 -23
- package/content/guides/core-concepts/persistent/search-meilisearch.md +185 -0
- package/content/guides/core-concepts/persistent/search-typesense.md +431 -0
- package/content/guides/core-concepts/persistent/transactions.md +61 -25
- package/content/guides/core-concepts/rest-controllers.md +12 -9
- package/content/guides/core-concepts/services.md +330 -60
- package/content/guides/get-started/5-minute-quickstart.md +15 -15
- package/content/guides/get-started/philosophy.md +36 -36
- package/content/guides/get-started/setup.md +3 -3
- package/content/guides/index.md +3 -3
- package/content/guides/migrations/redis-helpers-migration.md +177 -0
- package/content/guides/migrations/scoped-rbac-migration.md +17 -17
- package/content/guides/migrations/unified-connectors-migration.md +113 -0
- package/content/guides/reference/glossary.md +19 -12
- package/content/guides/reference/mcp-docs-server.md +22 -18
- package/content/guides/tutorials/building-a-crud-api.md +37 -44
- package/content/guides/tutorials/complete-installation.md +17 -17
- package/content/guides/tutorials/ecommerce-api.md +163 -124
- package/content/guides/tutorials/realtime-chat.md +181 -135
- package/content/guides/tutorials/testing.md +65 -523
- package/content/index.md +2 -180
- package/content/public/apple-touch-icon.png +0 -0
- package/content/public/og-image.png +0 -0
- package/content/public/site.webmanifest +11 -0
- package/content/references/base/application.md +4 -5
- package/content/references/base/bootstrapping.md +18 -5
- package/content/references/base/components.md +149 -120
- package/content/references/base/connectors.md +178 -0
- package/content/references/base/controllers.md +41 -30
- package/content/references/base/datasources.md +163 -92
- package/content/references/base/dependency-injection.md +34 -22
- package/content/references/base/filter-system/application-usage.md +17 -14
- package/content/references/base/filter-system/array-operators.md +7 -2
- package/content/references/base/filter-system/comparison-operators.md +3 -0
- package/content/references/base/filter-system/default-filter.md +89 -71
- package/content/references/base/filter-system/fields-order-pagination.md +22 -22
- package/content/references/base/filter-system/index.md +6 -3
- package/content/references/base/filter-system/json-filtering.md +20 -1
- package/content/references/base/filter-system/list-operators.md +1 -1
- package/content/references/base/filter-system/logical-operators.md +33 -1
- package/content/references/base/filter-system/null-operators.md +30 -1
- package/content/references/base/filter-system/quick-reference.md +23 -4
- package/content/references/base/filter-system/tips.md +5 -5
- package/content/references/base/filter-system/use-cases.md +12 -12
- package/content/references/base/grpc-controllers.md +13 -13
- package/content/references/base/index.md +24 -12
- package/content/references/base/middlewares.md +265 -327
- package/content/references/base/models.md +63 -49
- package/content/references/base/providers.md +136 -130
- package/content/references/base/repositories/advanced.md +59 -58
- package/content/references/base/repositories/index.md +115 -91
- package/content/references/base/repositories/mixins.md +55 -291
- package/content/references/base/repositories/relations.md +54 -64
- package/content/references/base/repositories/soft-deletable.md +31 -30
- package/content/references/base/services.md +296 -93
- package/content/references/configuration/environment-variables.md +49 -31
- package/content/references/configuration/index.md +6 -6
- package/content/references/index.md +17 -12
- package/content/references/quick-reference.md +65 -106
- package/content/references/utilities/crypto.md +65 -23
- package/content/references/utilities/index.md +3 -3
- package/content/references/utilities/jsx.md +6 -4
- package/content/references/utilities/module.md +68 -20
- package/content/references/utilities/parse.md +4 -14
- package/content/references/utilities/promise.md +9 -7
- package/content/references/utilities/schema.md +5 -3
- package/dist/mcp-server/common/guards.d.ts +8 -0
- package/dist/mcp-server/common/guards.d.ts.map +1 -0
- package/dist/mcp-server/common/guards.js +14 -0
- package/dist/mcp-server/common/guards.js.map +1 -0
- package/dist/mcp-server/common/index.d.ts +1 -0
- package/dist/mcp-server/common/index.d.ts.map +1 -1
- package/dist/mcp-server/common/index.js +1 -0
- package/dist/mcp-server/common/index.js.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.d.ts.map +1 -1
- package/dist/mcp-server/helpers/docs.helper.js +4 -2
- package/dist/mcp-server/helpers/docs.helper.js.map +1 -1
- package/dist/mcp-server/helpers/github.helper.js +1 -1
- package/dist/mcp-server/index.js +7 -2
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/mcp-server/tools/base.tool.d.ts +6 -2
- package/dist/mcp-server/tools/base.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/base.tool.js.map +1 -1
- package/dist/mcp-server/tools/docs/search-documents.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/search-code.tool.js +4 -1
- package/dist/mcp-server/tools/github/search-code.tool.js.map +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js +3 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js.map +1 -1
- package/package.json +9 -9
- package/content/extensions/helpers/testing/index.md +0 -510
- package/content/references/base/middleware.md +0 -347
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Search & Meilisearch
|
|
2
|
+
|
|
3
|
+
IGNIS ships a **meilisearch connector** under `@venizia/ignis/meilisearch`. It shares the entire search paradigm with the Typesense connector - the same `@model`/`@repository`/`@datasource` decorators, the same `defineSearchCollection` field DSL, the same repository tiers and controller factory - all of which live in the engine-neutral `@venizia/ignis/search` module.
|
|
4
|
+
|
|
5
|
+
What it does **not** share is the pretence that the two engines are the same. Where Meilisearch differs from Typesense, IGNIS surfaces the difference rather than papering over it.
|
|
6
|
+
|
|
7
|
+
> [!IMPORTANT] Subpath-only import
|
|
8
|
+
> `meilisearch` is an **optional peer dependency**. The connector is never re-exported from the `@venizia/ignis` root barrel, so apps that don't use it never pull in the client.
|
|
9
|
+
>
|
|
10
|
+
> ```typescript
|
|
11
|
+
> import { MeilisearchDataSource } from '@venizia/ignis/meilisearch';
|
|
12
|
+
> import { BaseSearchEntity, defineSearchCollection, field } from '@venizia/ignis/search';
|
|
13
|
+
> ```
|
|
14
|
+
>
|
|
15
|
+
> Install it alongside the framework:
|
|
16
|
+
>
|
|
17
|
+
> ```bash
|
|
18
|
+
> bun add meilisearch
|
|
19
|
+
> ```
|
|
20
|
+
|
|
21
|
+
## Defining a Collection
|
|
22
|
+
|
|
23
|
+
The collection DSL is the same one the Typesense connector uses. What changes is which flags matter.
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { BaseSearchEntity, defineSearchCollection, field } from '@venizia/ignis/search';
|
|
27
|
+
import { model } from '@venizia/ignis';
|
|
28
|
+
|
|
29
|
+
@model({ type: 'entity' })
|
|
30
|
+
export class ArticleDocument extends BaseSearchEntity {
|
|
31
|
+
static override schema = defineSearchCollection({
|
|
32
|
+
name: 'articles',
|
|
33
|
+
fields: [
|
|
34
|
+
field.id(),
|
|
35
|
+
field.string('title', { searchable: true }),
|
|
36
|
+
field.string('body', { searchable: true }),
|
|
37
|
+
field.number('score', { filterable: true, sortable: true }),
|
|
38
|
+
],
|
|
39
|
+
synonyms: [{ id: 'jacket', synonyms: ['jacket', 'coat', 'blazer'] }],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Meilisearch is **schemaless**: it has no field schema at all. A collection therefore compiles to an index uid plus a settings object, not to a list of typed fields.
|
|
45
|
+
|
|
46
|
+
| DSL | Meilisearch |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `field.id()` | `primaryKey` |
|
|
49
|
+
| `searchable: true` | `searchableAttributes` |
|
|
50
|
+
| `filterable: true` | `filterableAttributes` |
|
|
51
|
+
| `sortable: true` | `sortableAttributes` |
|
|
52
|
+
| `field.vector(...)` | an `embedders` entry |
|
|
53
|
+
| `synonyms` | a flat `synonyms` dictionary |
|
|
54
|
+
| `engineOverrides.meilisearch` | merged last onto settings |
|
|
55
|
+
|
|
56
|
+
The `searchable` and `filterable` flags are **load-bearing here** and are silently dropped by the Typesense compiler, which indexes every field by default. If no field declares `searchable`, IGNIS leaves `searchableAttributes` at Meilisearch's own `['*']` default rather than emitting an empty array, which would disable search entirely.
|
|
57
|
+
|
|
58
|
+
## The DataSource
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { datasource } from '@venizia/ignis';
|
|
62
|
+
import { MeilisearchDataSource } from '@venizia/ignis/meilisearch';
|
|
63
|
+
|
|
64
|
+
@datasource()
|
|
65
|
+
export class ArticleSearchDataSource extends MeilisearchDataSource {}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
No `driver` in the decorator, and that is not an omission. A relational datasource has to name one, because a single `BasePostgresDataSource` runs on either `pg` or `postgres` and something must pick. A search datasource has already picked: `extends MeilisearchDataSource` **is** the engine reference, and it is what carries the `meilisearch` package into your bundle. Naming the engine twice would just be a second chance to disagree with yourself.
|
|
69
|
+
|
|
70
|
+
Configured through `IMeilisearchDataSourceSettings`:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
new ArticleSearchDataSource({
|
|
74
|
+
name: 'search',
|
|
75
|
+
config: {
|
|
76
|
+
host: process.env.APP_ENV_MEILISEARCH_HOST,
|
|
77
|
+
apiKey: process.env.APP_ENV_MEILISEARCH_API_KEY,
|
|
78
|
+
taskTimeoutMs: 5 * 60_000,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Provisioning is governed by the same `APP_ENV_AUTO_PROVISION_COLLECTION` flag as every other search datasource. It is **off** unless set to `true` or `1`.
|
|
84
|
+
|
|
85
|
+
## Writes await their task
|
|
86
|
+
|
|
87
|
+
Every Meilisearch write returns a `taskUid` and the document is **not searchable until that task reaches `succeeded`**. Typesense writes are synchronous.
|
|
88
|
+
|
|
89
|
+
IGNIS resolves this by awaiting the task inside the connector, so the repository contract is identical on both engines:
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
await repository.create({ data: article });
|
|
93
|
+
// The document is retrievable here. No sleep, no retry loop.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The cost is real and worth knowing: each write polls until its task completes. The client SDK's own `waitForTask` defaults to a 5-second timeout, which is far too short for a bulk import, so IGNIS never relies on it - configure `taskTimeoutMs` instead. A task that fails raises the standard sanitized dependency error; one that never terminates raises a `core.search_engine.task_timeout`.
|
|
97
|
+
|
|
98
|
+
If you want fire-and-forget writes, reach the raw client:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
const client = dataSource.getClient(); // the raw `Meilisearch` SDK client
|
|
102
|
+
await client.index('articles').addDocuments([article]); // returns a taskUid, does not await
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## `count()` is exact
|
|
106
|
+
|
|
107
|
+
Meilisearch's search route reports `estimatedTotalHits`, and its exhaustive `totalHits` is capped by the index-level `pagination.maxTotalHits` setting (default **1000**).
|
|
108
|
+
|
|
109
|
+
IGNIS never counts through the search route. `count()` calls `POST /documents/fetch` with `limit: 0` and reads the exact `total`, which the engine's own documentation states is unaffected by `maxTotalHits`. A filtered count over 50,000 matching documents returns 50,000.
|
|
110
|
+
|
|
111
|
+
`ISearchResult.isFoundExact` reports the truth for the **search** route: always `true` on Typesense, and on Meilisearch `true` only in the exhaustive page mode. It travels all the way out through the generated REST response, so an API consumer paginating your endpoint can tell an estimate from an exhaustive count.
|
|
112
|
+
|
|
113
|
+
## Capability differences are compile-time, not runtime
|
|
114
|
+
|
|
115
|
+
`SearchBaseRepository` is generic on its datasource, so `this.connector` resolves to the concrete connector type. Engine-only verbs are therefore a matter for the compiler, not a runtime capability check:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
class ArticleRepository extends DefaultSearchRepository<TArticle, MeilisearchDataSource> {
|
|
119
|
+
swap() {
|
|
120
|
+
return this.connector.swap.indexes({ pairs: [['articles', 'articles_next']] }); // required
|
|
121
|
+
// this.connector.alias -> compile error: property does not exist
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class PortableRepository extends DefaultSearchRepository<TDocument> {
|
|
126
|
+
swap() {
|
|
127
|
+
return this.connector.alias?.upsert({ name, collection }); // the `?.` is forced
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Verb group | Typesense | Meilisearch |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| `collection`, `document`, `search`, `multiSearch` | required | required |
|
|
135
|
+
| `document.updateBy` | native | emulated (see below) |
|
|
136
|
+
| `document.count` | `per_page: 0` search | `documents/fetch` `limit: 0` |
|
|
137
|
+
| `alias` | required | **absent** |
|
|
138
|
+
| `swap` | absent | **required** |
|
|
139
|
+
| `synonymSet` (named, linkable) | required | absent |
|
|
140
|
+
| `synonyms` (flat dictionary) | absent | required |
|
|
141
|
+
|
|
142
|
+
## Behaviours to know before you commit
|
|
143
|
+
|
|
144
|
+
**`create()` rejects duplicates, but the check is not atomic.** Meilisearch's `addDocuments` is add-or-replace; it has no conditional insert. To honour the neutral contract (Typesense's `create()` returns 409 on a duplicate id), IGNIS reads the id first and throws `409 core.search_engine.already_exists` if it exists. Two concurrent creates of the same id can both pass that check, and the second write wins. If you want last-write-wins, call `upsert()` and say so.
|
|
145
|
+
|
|
146
|
+
**`updateById` on a missing id throws `404`, it does not upsert.** Meilisearch's `updateDocuments` is add-or-update, so a missing id would silently fabricate a record. IGNIS checks existence before any write and throws `404 core.search_engine.not_found` instead - matching Typesense and the neutral contract. (This is the deliberate search-family divergence from the PostgreSQL connector, where a missing-id `updateById` is a silent `{ count: 0 }`.)
|
|
147
|
+
|
|
148
|
+
**`updateBy` is emulated and is not atomic.** Meilisearch has no update-by-filter endpoint. IGNIS pages primary keys out of `documents/fetch`, then issues merge-`PUT` batches. A concurrent write to a matched document may be overwritten. The experimental `documents/edit` route would do this server-side, but it is gated behind a feature flag, is documented as breaking between minor versions, and has an open correctness bug - so IGNIS does not use it.
|
|
149
|
+
|
|
150
|
+
**`import`/`createAll` is batch-atomic.** Documents are added in batches, each batch's task awaited before the next starts. A batch either lands whole or throws, so `count.fail` is structurally always `0`. When a batch fails, the batches after the failure point did not land and the thrown error's `details` carry `{ totalCount, processedCount }` so a caller can resume from `processedCount` rather than replaying the whole import.
|
|
151
|
+
|
|
152
|
+
**Meilisearch error classification reads the real SDK error shape.** A thrown `MeilisearchApiError` carries its body under `error.cause.code` and its HTTP status under `error.response.status`, not top-level `code`/`httpStatus`. Reading only the flat shape misclassifies a legitimate 404 (for example the existence probe inside `create()`) as an unrelated 503 dependency failure; the classifier checks both the flat shape (task-error responses) and the nested `cause`/`response` shape (thrown SDK errors).
|
|
153
|
+
|
|
154
|
+
**`createCollection`/`ensureCollection` are idempotent.** An `index_already_exists` failure is tolerated and logged rather than thrown, so boot-time provisioning against an already-provisioned collection (a restart, a second app instance) succeeds instead of crashing.
|
|
155
|
+
|
|
156
|
+
**`hiddenProperties` is supported on Meilisearch via JS-side stripping, not engine-side exclusion.** Meilisearch has no per-query field-exclusion param (only the index-level `displayedAttributes` setting), so the dialect does not attempt to translate `hiddenFields` - it deliberately passes them through untranslated. The guarantee is upheld one layer up: `SearchBaseRepository` strips hidden fields from every document `find()`/`findOne()`/`search()` returns before it reaches the caller, so a model with `hiddenProperties` works identically to Typesense from the caller's point of view - no throw, no leak.
|
|
157
|
+
|
|
158
|
+
**A geopoint field must be named `_geo`.** Meilisearch supports exactly one geo field, shaped `{ lat, lng }`, under that reserved name. The compiler throws on any other name, and on a second geo field.
|
|
159
|
+
|
|
160
|
+
**Vector distance is cosine only.** `field.vector(..., { distance: 'l2' })` throws.
|
|
161
|
+
|
|
162
|
+
**Engine-specific knobs never reach the neutral schema.** The neutral search input carries only fields every engine supports; Typesense-only tuning (`num_typos`, `pinned_hits`, `preset`, ...) travels through `engineParams` with wire names and is passed verbatim to the engine - so sending Typesense knobs to a Meilisearch datasource is a caller error, and the dialect still raises a named error for known-foreign knobs arriving via untyped input. Use `engineParams` (or `mode: 'raw'`) for Meilisearch-native tuning such as `rankingScoreThreshold`.
|
|
163
|
+
|
|
164
|
+
**`multiSearch` batches but does not merge.** `union: true` throws. Meilisearch merges results through its `federation` option, which this connector does not model, so `getCapabilities()` honestly reports `union: false`.
|
|
165
|
+
|
|
166
|
+
**Result grouping does not exist.** `groupBy`, `groupLimit` and `groupMissingValues` all throw. Meilisearch's `distinct` search param deduplicates on one attribute; it does not group hits. Reach the raw client if that is what you want.
|
|
167
|
+
|
|
168
|
+
## Vector and hybrid search
|
|
169
|
+
|
|
170
|
+
Vector fields compile to `embedders`, and `mode: 'semantic'` / `mode: 'hybrid'` translate to Meilisearch's `hybrid: { semanticRatio, embedder }` plus an optional `vector`. The neutral `alpha` maps to `semanticRatio`; `mode: 'semantic'` pins it to `1.0`, and an omitted `alpha` on `hybrid` defaults to `0.5`.
|
|
171
|
+
|
|
172
|
+
This path is compiled and translated but is **not exercised end to end** by the framework's test suite, and Meilisearch has moved its vector store in and out of `/experimental-features` across server versions. Verify against your target server version before depending on it.
|
|
173
|
+
|
|
174
|
+
## Adding another search engine
|
|
175
|
+
|
|
176
|
+
The neutral paradigm lives in `@venizia/ignis/search` and imports no engine. To add one:
|
|
177
|
+
|
|
178
|
+
1. Implement `ISearchConnector`, declaring **only** the optional verb groups your engine actually has.
|
|
179
|
+
2. Write a compiler from `ISearchCollectionDefinition` to the engine's schema or settings.
|
|
180
|
+
3. Write an `ISearchQueryDialect` - `build`, `toWhere`, `applySearchInput`, `toWireParams`.
|
|
181
|
+
4. Subclass `BaseSearchDataSource`.
|
|
182
|
+
5. Register a sub-path export and an optional peer dependency.
|
|
183
|
+
6. Pass `runConnectorConformance`.
|
|
184
|
+
|
|
185
|
+
That last step is the contract. A seam only one engine can satisfy is not a seam.
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# Search & Typesense
|
|
2
|
+
|
|
3
|
+
IGNIS ships a **typesense connector** under `@venizia/ignis/typesense` for full-text and faceted search. It plugs into the same `@model`/`@repository`/`@datasource` decorators as the PostgreSQL connector, but documents replace Drizzle tables and the query surface is intentionally narrower - search engines are not relational databases.
|
|
4
|
+
|
|
5
|
+
> [!IMPORTANT] Subpath-only import
|
|
6
|
+
> `typesense` is an **optional peer dependency** - the typesense connector is not re-exported from the `@venizia/ignis` root barrel (unlike `postgres`), so apps that don't use search never pull in the `typesense` client. Always import from `@venizia/ignis/typesense`:
|
|
7
|
+
>
|
|
8
|
+
> ```typescript
|
|
9
|
+
> import {
|
|
10
|
+
> TypesenseDataSource,
|
|
11
|
+
> BaseSearchEntity,
|
|
12
|
+
> defineSearchCollection,
|
|
13
|
+
> field,
|
|
14
|
+
> } from '@venizia/ignis/typesense';
|
|
15
|
+
> ```
|
|
16
|
+
>
|
|
17
|
+
> See [Connectors](/references/base/connectors) for the full dual-door export model.
|
|
18
|
+
|
|
19
|
+
## Defining a Search Collection
|
|
20
|
+
|
|
21
|
+
Instead of a Drizzle `pgTable`, define a collection with `defineSearchCollection` and the `field` DSL:
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// src/models/entities/article.model.ts
|
|
25
|
+
import { model } from '@venizia/ignis';
|
|
26
|
+
import {
|
|
27
|
+
BaseSearchEntity,
|
|
28
|
+
defineSearchCollection,
|
|
29
|
+
field,
|
|
30
|
+
TSearchDocument,
|
|
31
|
+
} from '@venizia/ignis/typesense';
|
|
32
|
+
|
|
33
|
+
@model({
|
|
34
|
+
type: 'entity',
|
|
35
|
+
settings: {
|
|
36
|
+
hiddenProperties: ['internalNote'],
|
|
37
|
+
defaultFilter: { where: { status: 'published' } },
|
|
38
|
+
defaultLimit: 20,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
export class ArticleDocument extends BaseSearchEntity<typeof ArticleDocument.schema> {
|
|
42
|
+
static override schema = defineSearchCollection({
|
|
43
|
+
name: 'articles',
|
|
44
|
+
fields: [
|
|
45
|
+
field.id(),
|
|
46
|
+
field.string('title', { searchable: true, sortable: true }),
|
|
47
|
+
field.string('content', { searchable: true }),
|
|
48
|
+
field.string('category', { facet: true }),
|
|
49
|
+
field.string('status', { facet: true }),
|
|
50
|
+
field.number('views', { sortable: true, filterable: true }),
|
|
51
|
+
field.number('publishedAt', { sortable: true, filterable: true }),
|
|
52
|
+
field.strings('tags', { facet: true, optional: true }),
|
|
53
|
+
field.string('internalNote', { optional: true }),
|
|
54
|
+
],
|
|
55
|
+
defaultSort: 'publishedAt',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type TArticleDocument = TSearchDocument<typeof ArticleDocument.schema>;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The same `@model` settings you use with Drizzle entities - `hiddenProperties`, `defaultFilter`, `defaultLimit` - work identically here. `hiddenProperties` compiles to Typesense's `exclude_fields`; `defaultFilter.where` is AND-merged into every read's `filter_by` unless the caller passes `shouldSkipDefaultFilter: true`.
|
|
63
|
+
|
|
64
|
+
### The `field` DSL
|
|
65
|
+
|
|
66
|
+
`field.*` covers the scalar and array wire types plus a `vector` builder for semantic search - there is no date or object builder:
|
|
67
|
+
|
|
68
|
+
| Builder | Wire type | Notes |
|
|
69
|
+
| ------------------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `field.id()` | `string` | Takes no arguments; hardcoded `{ name: 'id', type: 'string' }`. Auto-prepended if you omit it. |
|
|
71
|
+
| `field.string(name, flags?)` | `string` | |
|
|
72
|
+
| `field.strings(name, flags?)` | `string[]` | |
|
|
73
|
+
| `field.number(name, flags?)` | `number` | |
|
|
74
|
+
| `field.numbers(name, flags?)` | `number[]` | |
|
|
75
|
+
| `field.boolean(name, flags?)` | `boolean` | |
|
|
76
|
+
| `field.booleans(name, flags?)` | `boolean[]` | |
|
|
77
|
+
| `field.geopoint(name, flags?)` | `geopoint` (`[number, number]`) | |
|
|
78
|
+
| `field.vector(name, opts)` | `float[]` | Embedding vector for semantic/hybrid search. Its `opts` carry `dimensions`/`distance`/`embed` rather than the shared flags - see [Vector Fields](#vector-fields-for-semantic-search). |
|
|
79
|
+
|
|
80
|
+
Each `flags` object may set `searchable`, `filterable`, `facet`, `sortable`, `optional` (all `boolean`). Note: `searchable`/`filterable` have no direct Typesense wire equivalent (Typesense indexes every field by default) and are dropped at compile time - only `facet`, `optional`, and `sortable` (mapped to Typesense's `sort`) actually reach the compiled collection schema. They're still worth setting for documentation/intent and for future engines that do distinguish them.
|
|
81
|
+
|
|
82
|
+
`defineSearchCollection` validates at call time: throws on an empty `name`, empty `fields`, duplicate field names, a non-`string` `id` field, or an unknown `defaultSort` field reference.
|
|
83
|
+
|
|
84
|
+
### `TSearchDocument<T>`
|
|
85
|
+
|
|
86
|
+
Derives the document's TypeScript shape directly from the collection definition - no hand-maintained duplicate type:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
export type TArticleDocument = TSearchDocument<typeof ArticleDocument.schema>;
|
|
90
|
+
// {
|
|
91
|
+
// id: string;
|
|
92
|
+
// title: string;
|
|
93
|
+
// content: string;
|
|
94
|
+
// category: string;
|
|
95
|
+
// status: string;
|
|
96
|
+
// views: number;
|
|
97
|
+
// publishedAt: number;
|
|
98
|
+
// tags?: string[]; // optional: true -> optional property
|
|
99
|
+
// internalNote?: string; // optional: true -> optional property
|
|
100
|
+
// }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`id` is always `string` and required. Every non-`optional` field is required; every `optional: true` field becomes an optional property.
|
|
104
|
+
|
|
105
|
+
## Vector Fields for Semantic Search
|
|
106
|
+
|
|
107
|
+
`field.vector(name, opts)` declares a `float[]` embedding column. There are two ways to populate it.
|
|
108
|
+
|
|
109
|
+
**Server-side auto-embedding** - Typesense builds the vector from other fields at index time, so your app never computes an embedding. Point `embed.from` at the source fields and name an embedding model:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// Local built-in model - runs on the Typesense server, no API key, no external calls:
|
|
113
|
+
field.vector('embedding', {
|
|
114
|
+
embed: { from: ['title', 'content'], model: { name: 'ts/all-MiniLM-L6-v2' } },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Remote provider - the API key is read from env, never hardcoded. This example is Gemini via
|
|
118
|
+
// Google's OpenAI-compatible endpoint (Typesense's `openai/` provider plus a custom `url`):
|
|
119
|
+
field.vector('embedding', {
|
|
120
|
+
embed: {
|
|
121
|
+
from: ['title', 'content'],
|
|
122
|
+
model: {
|
|
123
|
+
url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
|
124
|
+
name: 'openai/gemini-embedding-001',
|
|
125
|
+
apiKey: process.env.APP_ENV_GOOGLE_API_KEY,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The `model` config is camelCase and maps to Typesense's snake_case `model_config` at compile time (`name` becomes `model_name`, `apiKey` becomes `api_key`, `accessToken` becomes `access_token`, and so on); any provider field not modeled is passed through unchanged. Never hardcode `apiKey` into a committed schema - source it from an environment variable.
|
|
132
|
+
|
|
133
|
+
**Client-provided vectors** - you compute the embedding yourself and send it with each document. Declare `dimensions` and a distance metric instead of `embed`:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
import { VectorDistances } from '@venizia/ignis/typesense';
|
|
137
|
+
|
|
138
|
+
field.vector('embedding', { dimensions: 384, distance: VectorDistances.COSINE });
|
|
139
|
+
// VectorDistances: COSINE ('cosine'), INNER_PRODUCT ('ip'), L2 ('l2')
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
An auto-embedded field (one with `embed`) is **omitted from `TSearchDocument`** - the server owns it, so it never appears in your create/update payloads. A client-provided vector field (dimensions only, no `embed`) stays in the document type as `number[]`.
|
|
143
|
+
|
|
144
|
+
See [Searching](#searching-with-search-mode) for querying these fields with `mode: 'semantic'` and `mode: 'hybrid'`.
|
|
145
|
+
|
|
146
|
+
## Synonyms
|
|
147
|
+
|
|
148
|
+
Declare synonym sets on the collection; they are provisioned alongside it at `configure()`:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
static override schema = defineSearchCollection({
|
|
152
|
+
name: 'articles',
|
|
153
|
+
fields: [ /* ... */ ],
|
|
154
|
+
synonyms: [
|
|
155
|
+
// Multi-way (no `root`): every term matches the others.
|
|
156
|
+
{ id: 'ml', synonyms: ['ml', 'machine learning', 'deep learning'] },
|
|
157
|
+
// One-way (`root` set): a query for the root also matches the synonyms, not the reverse.
|
|
158
|
+
{ id: 'js', root: 'javascript', synonyms: ['js', 'ecmascript', 'nodejs'] },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Declarative `synonyms` are provisioned as one **synonym set** per collection (named `<collection>_synonyms`) and linked to it - matching Typesense v30+'s global synonym-sets model (the pre-v30 per-collection synonyms API was removed). For runtime management outside the declarative schema, the connector exposes the synonym-set verbs through the raw client escape hatch:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
const connector = repository.dataSource.getConnector();
|
|
167
|
+
await connector.upsertSynonymSet({
|
|
168
|
+
name: 'articles_synonyms',
|
|
169
|
+
items: [{ id: 'db', synonyms: ['db', 'database', 'datastore'] }],
|
|
170
|
+
});
|
|
171
|
+
await connector.linkSynonymSets({ collection: 'articles', synonymSets: ['articles_synonyms'] });
|
|
172
|
+
// getSynonymSet({ name }) / listSynonymSets() / deleteSynonymSet({ name }) round out the set.
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Configuring a DataSource
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// src/datasources/search.datasource.ts
|
|
179
|
+
import { datasource } from '@venizia/ignis';
|
|
180
|
+
import { TypesenseDataSource } from '@venizia/ignis/typesense';
|
|
181
|
+
import { applicationEnvironment, int } from '@venizia/ignis-helpers';
|
|
182
|
+
|
|
183
|
+
@datasource()
|
|
184
|
+
export class SearchDataSource extends TypesenseDataSource {
|
|
185
|
+
constructor() {
|
|
186
|
+
super({
|
|
187
|
+
name: SearchDataSource.name,
|
|
188
|
+
config: {
|
|
189
|
+
nodes: [
|
|
190
|
+
{
|
|
191
|
+
host: applicationEnvironment.get<string>('APP_ENV_TYPESENSE_HOST'),
|
|
192
|
+
port: int(applicationEnvironment.get<string>('APP_ENV_TYPESENSE_PORT')),
|
|
193
|
+
protocol: applicationEnvironment.get<string>('APP_ENV_TYPESENSE_PROTOCOL'),
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
apiKey: applicationEnvironment.get<string>('APP_ENV_TYPESENSE_API_KEY'),
|
|
197
|
+
},
|
|
198
|
+
// NO schema property - collections auto-discovered from @repository bindings, same convention as postgres.
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
No `driver` in the decorator, and that is not an omission. A relational datasource has to name one, because a single `BasePostgresDataSource` runs on either `pg` or `postgres` and something must pick. A search datasource has already picked: `extends TypesenseDataSource` **is** the engine reference, and it is what carries the `typesense` package into your bundle. Naming the engine twice would just be a second chance to disagree with yourself.
|
|
205
|
+
|
|
206
|
+
`TypesenseDataSource` extends `BaseSearchDataSource` (adds auto-discovery/provisioning) which extends `AbstractSearchDataSource` (engine contract: `getDriver()`, `getQueryDialect()`, `compileCollection()`, `ensureCollection()`) which extends the engine-neutral `AbstractDataSource`. Since `TypesenseDataSource` never overrides `beginTransaction()`, it inherits the neutral `NotSupported` default - see [Connectors](/references/base/connectors).
|
|
207
|
+
|
|
208
|
+
On `configure()`, the datasource auto-provisions every discovered collection (`ensureCollection()` per definition, plus any declared `synonyms`) unless constructed with `autoProvision: false`.
|
|
209
|
+
|
|
210
|
+
`getCapabilities()` reports what the engine supports so callers can probe before relying on a feature across engines:
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
dataSource.getCapabilities();
|
|
214
|
+
// { transactions: false, search: { vector: true, multi: true, union: true, synonyms: true } }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Repository Tiers
|
|
218
|
+
|
|
219
|
+
Mirrors the PostgreSQL connector's ladder, but for documents instead of rows:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
AbstractRepository (engine-neutral)
|
|
223
|
+
-> TypesenseBaseRepository (narrows dataSource/entity to TypesenseDataSource/BaseSearchEntity)
|
|
224
|
+
-> ReadableSearchRepository (count, existsWith, find, findOne, findById, search<TResult>())
|
|
225
|
+
-> PersistableSearchRepository (create, createAll, updateById, updateAll, import)
|
|
226
|
+
-> DefaultSearchRepository (+ deleteById, deleteAll)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// src/repositories/article.repository.ts
|
|
231
|
+
import { SearchDataSource } from '@/datasources/search.datasource';
|
|
232
|
+
import { ArticleDocument, TArticleDocument } from '@/models/entities';
|
|
233
|
+
import { repository } from '@venizia/ignis';
|
|
234
|
+
import { DefaultSearchRepository } from '@venizia/ignis/typesense';
|
|
235
|
+
|
|
236
|
+
@repository({ model: ArticleDocument, dataSource: SearchDataSource })
|
|
237
|
+
export class ArticleRepository extends DefaultSearchRepository<TArticleDocument> {
|
|
238
|
+
findByCategory(opts: { category: string }) {
|
|
239
|
+
return this.find({ filter: { where: { category: opts.category } } });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`find()`/`findOne()`/`findById()`/`count()`/`existsWith()` accept the same `TFilter`/`TWhere` shape as PostgreSQL repositories - the `TypesenseQueryDialect` translates `where` into Typesense's `filter_by` syntax, `order` into `sort_by` (max 3 fields - a Typesense limit), `limit`/`skip` into `per_page`/`page`, and `fields` into `include_fields`. `filter.include` is **not supported** and throws - there is no relation model for documents.
|
|
245
|
+
|
|
246
|
+
### Envelope differences vs. PostgreSQL
|
|
247
|
+
|
|
248
|
+
| | PostgreSQL repositories | Search repositories |
|
|
249
|
+
| ------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
250
|
+
| Range queries | Extra `COUNT(*)` query for `shouldQueryRange: true` | Single search call already returns `found` - no second query needed |
|
|
251
|
+
| `updateAll`/`deleteAll` with empty `where` | Requires `force: true` | `updateAll` **refuses** an unfiltered bulk update outright (throws, no override); `deleteAll` with no effective filter (no `where` and no `@model defaultFilter`) also throws unless `options.force: true` is passed - only then does it truncate the whole collection |
|
|
252
|
+
| Returning updated/deleted rows | Native SQL `RETURNING` | No `RETURNING` equivalent - `updateAll`/`deleteAll` are **count-only**: `data` is always `null` and `shouldReturn` is rejected at the type level (no hidden engine read is bolted on). Read the affected documents explicitly before the write when you need them |
|
|
253
|
+
| `createAll` | Native bulk insert, returns each row | Delegates to Typesense's bulk import; "created" rows are the input rows whose per-row response didn't report `success: false` |
|
|
254
|
+
|
|
255
|
+
### Write error semantics (shared by both search engines)
|
|
256
|
+
|
|
257
|
+
- **`create()` with a duplicate id throws `409` (`messageCode: 'core.search_engine.already_exists'`)** on both Typesense and Meilisearch - a duplicate is a conflict, never a silent overwrite. If you want last-write-wins, call `upsert()` explicitly. (On Meilisearch the pre-check is not atomic - see the [Meilisearch guide](./search-meilisearch); use `upsert()` when concurrent same-id creates are possible.)
|
|
258
|
+
- **`updateById()` against a missing id throws `404` (`messageCode: 'core.search_engine.not_found'`)** on both engines. This is the deliberate divergence from the PostgreSQL connector, where a missing-id `updateById` is a silent `{ count: 0 }`.
|
|
259
|
+
- **`deleteById()` against a missing id is silent** (`{ count: 0, data: null }`), matching the relational connectors.
|
|
260
|
+
|
|
261
|
+
## Searching with `search({ mode })`
|
|
262
|
+
|
|
263
|
+
Beyond the `find()` tier (filter-only reads that return documents), `search()` is the search-engine entry point. It is one method whose shape is selected by `mode`, so TypeScript narrows the options to just that mode's fields - you never juggle every parameter at once. `mode` defaults to `keyword`.
|
|
264
|
+
|
|
265
|
+
The `keyword`/`semantic`/`hybrid` modes reuse the same filter translation as `find()` (`@model defaultFilter`, `hiddenProperties`, `where`/`order`/`limit`), then layer the search term or vector on top. The `raw` mode is a full passthrough with no translation.
|
|
266
|
+
|
|
267
|
+
**`keyword`** - full-text search:
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
await repository.search({
|
|
271
|
+
mode: 'keyword',
|
|
272
|
+
query: 'renewable energy',
|
|
273
|
+
queryBy: ['title', 'content'], // which fields to match
|
|
274
|
+
filter: { where: { published: true }, limit: 20 }, // optional, same TFilter as find()
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**`semantic`** - vector search. Supply a precomputed vector, or `queryText` against a server-side auto-embed field:
|
|
279
|
+
|
|
280
|
+
```typescript
|
|
281
|
+
// Client-provided vector:
|
|
282
|
+
await repository.search({ mode: 'semantic', vectorField: 'embedding', nearVector: [0.12, ...], k: 10 });
|
|
283
|
+
|
|
284
|
+
// Server-side auto-embed (the field was declared with `embed`) - Typesense embeds the text for you:
|
|
285
|
+
await repository.search({ mode: 'semantic', vectorField: 'embedding', queryText: 'renewable energy', k: 10 });
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**`hybrid`** - keyword and vector blended. `alpha` weights vector against text (0 to 1):
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
await repository.search({
|
|
292
|
+
mode: 'hybrid',
|
|
293
|
+
query: 'renewable energy',
|
|
294
|
+
queryBy: ['title'],
|
|
295
|
+
vectorField: 'embedding',
|
|
296
|
+
alpha: 0.5,
|
|
297
|
+
k: 10,
|
|
298
|
+
});
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
> [!NOTE]
|
|
302
|
+
> `semantic` and `hybrid` default `prefix=false`: prefix matching is meaningless for vector search, and remote embedders (OpenAI/Google/...) reject it (`Prefix search is not supported for remote embedders`). Pass `engineParams: { prefix: true }` to override.
|
|
303
|
+
|
|
304
|
+
**`raw`** - native Typesense params, no `TFilter`/`defaultFilter`/`hiddenProperties` translation. Use it for any engine feature the modes above do not model:
|
|
305
|
+
|
|
306
|
+
```typescript
|
|
307
|
+
await repository.search({
|
|
308
|
+
mode: 'raw',
|
|
309
|
+
params: { q: '*', filter_by: 'price:>100', vector_query: 'embedding:([...], k:10)' },
|
|
310
|
+
});
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
> [!NOTE]
|
|
314
|
+
> `raw` skips `@model defaultFilter`, so it can surface documents the `find()`/`keyword` tiers would hide (for example non-`published` articles). Apply any tenant or visibility filtering explicitly in `params.filter_by` when exposing raw search over HTTP.
|
|
315
|
+
|
|
316
|
+
All non-`raw` modes accept the same optional camelCase tuning fields, mapped to Typesense's snake_case at the dialect boundary: faceting (`facetBy`, `facetQuery`, `maxFacetValues`), highlighting (`highlightFields`, `highlightFullFields`, `highlightStartTag`/`highlightEndTag`, `snippetThreshold`), grouping (`groupBy`, `groupLimit`, `groupMissingValues`), and `queryByWeights`. These are the ENGINE-NEUTRAL fields - every search engine supports them.
|
|
317
|
+
|
|
318
|
+
Typesense-specific tuning (`num_typos`, `prefix`, `infix`, `use_cache`, `cache_ttl`, `exhaustive_search`, `pinned_hits`, `hidden_hits`, `prioritize_exact_match`, `drop_tokens_threshold`, `preset`) goes through `engineParams`, passed verbatim to the engine with WIRE names - the neutral input schema no longer carries knobs only one engine understands:
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
await repository.search({
|
|
322
|
+
mode: 'keyword',
|
|
323
|
+
query: 'shoes',
|
|
324
|
+
queryBy: ['name'],
|
|
325
|
+
engineParams: { num_typos: 1, prefix: false, preset: 'listing' },
|
|
326
|
+
});
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
await repository.search({
|
|
331
|
+
mode: 'keyword',
|
|
332
|
+
query: 'shoes',
|
|
333
|
+
queryBy: ['name'],
|
|
334
|
+
facetBy: ['brand', 'color'], // faceted counts
|
|
335
|
+
highlightFields: ['name'], // marked matches
|
|
336
|
+
groupBy: ['brand'],
|
|
337
|
+
groupLimit: 3, // grouped results
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`mode` values are also available as the `SearchModes` const (`SearchModes.KEYWORD`/`SEMANTIC`/`HYBRID`/`RAW`) if you prefer named constants over string literals.
|
|
342
|
+
|
|
343
|
+
### The result envelope
|
|
344
|
+
|
|
345
|
+
`search()` returns the engine's search envelope (unlike `find()`, which returns documents):
|
|
346
|
+
|
|
347
|
+
```typescript
|
|
348
|
+
interface ISearchResult<TDocument extends object = object> {
|
|
349
|
+
found: number;
|
|
350
|
+
hits?: Array<{ document: TDocument; highlight?: unknown; textMatch?: number }>;
|
|
351
|
+
facetCounts?: unknown[];
|
|
352
|
+
groupedHits?: unknown[];
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## Cross-Collection Multi-Search
|
|
357
|
+
|
|
358
|
+
A single search targets one collection, so it lives on the repository. Searching **many** collections in one round trip lives on the **datasource**:
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
await repository.dataSource.multiSearch({
|
|
362
|
+
searches: [
|
|
363
|
+
{ collection: 'products', query: 'shoes', queryBy: ['name'] },
|
|
364
|
+
{ collection: 'brands', query: 'shoe' },
|
|
365
|
+
],
|
|
366
|
+
commonParams: { perPage: 20 }, // optional defaults applied to every search
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Federated by default - the result is one entry per search, side by side. Pass `union: true` to merge them into a single ranked result set instead:
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
await repository.dataSource.multiSearch({
|
|
374
|
+
searches: [
|
|
375
|
+
/* ... */
|
|
376
|
+
],
|
|
377
|
+
union: true,
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Both `searches` entries and `commonParams` are camelCase (`filterBy`, `queryBy`, `perPage`); the datasource maps them to Typesense's snake_case wire format the same way single-collection `search()` does. For native snake_case access, `getConnector().multiSearch(...)` takes the engine's own params.
|
|
382
|
+
|
|
383
|
+
> [!NOTE]
|
|
384
|
+
> `multiSearch` injects each collection's `@model hiddenProperties` into that entry's `excludeFields` automatically - hidden fields never leak on the multi-search route, just as they don't on single-collection `search()`. An entry naming a collection IGNIS did not discover (not a bound `@repository` model) passes through untouched: the **caller owns exclusion** for unknown collections. The raw `getConnector().multiSearch(...)` escape hatch performs no injection at all.
|
|
385
|
+
|
|
386
|
+
## Search Controller Factory
|
|
387
|
+
|
|
388
|
+
`SearchControllerFactory.defineSearchController` generates turnkey search endpoints, mirroring how `ControllerFactory.defineCrudController` generates CRUD endpoints:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
// src/controllers/search.controller.ts
|
|
392
|
+
import { ArticleDocument } from '@/models/entities';
|
|
393
|
+
import { ArticleRepository } from '@/repositories';
|
|
394
|
+
import { SearchControllerFactory } from '@venizia/ignis/typesense/controllers';
|
|
395
|
+
|
|
396
|
+
const _SearchController = SearchControllerFactory.defineSearchController({
|
|
397
|
+
entity: ArticleDocument,
|
|
398
|
+
repository: { name: ArticleRepository.name },
|
|
399
|
+
controller: { name: 'ArticleSearchController', basePath: '/articles' },
|
|
400
|
+
// routes: { multiSearch: { enabled: false } }, // each route is customizable / disable-able
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
export const ArticleSearchController = _SearchController;
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
It registers `POST /search` (request body is the mode-discriminated schema, so validation and OpenAPI come for free and the handler dispatches to `repository.search`) and `POST /multi-search` (dispatches to `dataSource.multiSearch`). For anything the factory does not cover, write a custom `BaseRestController` calling `repository.search()` or `getClient()` directly.
|
|
407
|
+
|
|
408
|
+
## Transactions and Locking
|
|
409
|
+
|
|
410
|
+
The typesense connector has no transaction or row-level-locking model. Passing a `transaction` or `lock` option to any repository method throws:
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
await articleRepository.updateById({
|
|
414
|
+
id: '123',
|
|
415
|
+
data: { title: 'New' },
|
|
416
|
+
options: { transaction: tx }, // throws: 501, messageCode 'core.not_supported'
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
This is the same `throwNotSupported` convention used everywhere in the framework for capabilities an engine doesn't implement - see [Connectors](/references/base/connectors) and [Transactions](./transactions).
|
|
421
|
+
|
|
422
|
+
## See Also
|
|
423
|
+
|
|
424
|
+
- **Related Concepts:**
|
|
425
|
+
- [Connectors](/references/base/connectors) - Base-vs-connectors architecture, dual-door exports
|
|
426
|
+
- [DataSources](/references/base/datasources) - Engine-neutral DataSource contract + PostgreSQL connector
|
|
427
|
+
- [Repositories](/references/base/repositories/) - PostgreSQL connector repository reference (for comparison)
|
|
428
|
+
- [Models](./models) - `@model` settings shared across connectors
|
|
429
|
+
|
|
430
|
+
- **Example App:**
|
|
431
|
+
- `examples/typesense-search/` - Full end-to-end app (model, datasource, repository, CRUD + search controllers, seed script)
|