@venizia/ignis-docs 0.2.0 → 0.2.1-1
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 +14 -14
- package/content/best-practices/api-usage-examples.md +39 -19
- package/content/best-practices/architectural-patterns.md +24 -13
- package/content/best-practices/architecture-decisions.md +29 -16
- package/content/best-practices/code-style-standards/advanced-patterns.md +43 -31
- package/content/best-practices/code-style-standards/constants-configuration.md +19 -4
- package/content/best-practices/code-style-standards/control-flow.md +33 -1
- package/content/best-practices/code-style-standards/documentation.md +28 -8
- package/content/best-practices/code-style-standards/function-patterns.md +15 -4
- package/content/best-practices/code-style-standards/index.md +7 -2
- package/content/best-practices/code-style-standards/naming-conventions.md +27 -3
- package/content/best-practices/code-style-standards/route-definitions.md +9 -5
- package/content/best-practices/code-style-standards/tooling.md +14 -1
- package/content/best-practices/code-style-standards/type-safety.md +39 -6
- package/content/best-practices/common-pitfalls.md +59 -35
- package/content/best-practices/contribution-workflow.md +8 -4
- package/content/best-practices/data-modeling.md +78 -62
- package/content/best-practices/deployment-strategies.md +56 -19
- package/content/best-practices/error-handling.md +247 -153
- package/content/best-practices/index.md +6 -0
- package/content/best-practices/performance-optimization.md +26 -13
- package/content/best-practices/security-guidelines.md +35 -9
- package/content/best-practices/testing-strategies.md +7 -2
- package/content/best-practices/troubleshooting-tips.md +27 -17
- package/content/extensions/components/api-reference.md +109 -323
- package/content/extensions/components/authentication/api.md +489 -602
- package/content/extensions/components/authentication/errors.md +135 -498
- package/content/extensions/components/authentication/index.md +89 -801
- package/content/extensions/components/authentication/usage.md +231 -955
- package/content/extensions/components/authorization/api.md +991 -644
- package/content/extensions/components/authorization/errors.md +204 -208
- package/content/extensions/components/authorization/getting-started.md +227 -0
- package/content/extensions/components/authorization/index.md +88 -795
- package/content/extensions/components/authorization/usage.md +219 -528
- package/content/extensions/components/health-check.md +77 -243
- package/content/extensions/components/index.md +24 -90
- package/content/extensions/components/mail/api.md +553 -285
- package/content/extensions/components/mail/errors.md +76 -62
- package/content/extensions/components/mail/index.md +111 -463
- package/content/extensions/components/mail/usage.md +139 -173
- package/content/extensions/components/request-tracker.md +70 -173
- package/content/extensions/components/socket-io/api.md +462 -785
- package/content/extensions/components/socket-io/errors.md +49 -51
- package/content/extensions/components/socket-io/index.md +69 -372
- package/content/extensions/components/socket-io/usage.md +212 -105
- package/content/extensions/components/static-asset/api.md +461 -141
- package/content/extensions/components/static-asset/errors.md +121 -53
- package/content/extensions/components/static-asset/index.md +84 -606
- package/content/extensions/components/static-asset/usage.md +184 -299
- package/content/extensions/components/template/index.md +3 -3
- package/content/extensions/components/websocket/api.md +311 -404
- package/content/extensions/components/websocket/errors.md +47 -56
- package/content/extensions/components/websocket/index.md +75 -407
- package/content/extensions/components/websocket/usage.md +120 -338
- package/content/extensions/helpers/cron/index.md +52 -160
- package/content/extensions/helpers/crypto/index.md +67 -480
- package/content/extensions/helpers/crypto/reference.md +528 -0
- package/content/extensions/helpers/env/index.md +64 -178
- package/content/extensions/helpers/error/index.md +286 -196
- package/content/extensions/helpers/index.md +61 -47
- package/content/extensions/helpers/inversion/index.md +76 -550
- package/content/extensions/helpers/inversion/reference.md +530 -0
- package/content/extensions/helpers/kafka/admin.md +23 -3
- package/content/extensions/helpers/kafka/compile-binary.md +83 -52
- package/content/extensions/helpers/kafka/consumer.md +65 -28
- package/content/extensions/helpers/kafka/examples.md +46 -253
- package/content/extensions/helpers/kafka/index.md +55 -617
- package/content/extensions/helpers/kafka/producer.md +156 -22
- package/content/extensions/helpers/kafka/schema-registry.md +59 -79
- package/content/extensions/helpers/logger/hf-logger.md +220 -0
- package/content/extensions/helpers/logger/index.md +78 -552
- package/content/extensions/helpers/logger/pino.md +105 -0
- package/content/extensions/helpers/logger/reference.md +937 -0
- package/content/extensions/helpers/network/api.md +276 -195
- package/content/extensions/helpers/network/index.md +87 -524
- package/content/extensions/helpers/queue/index.md +80 -897
- package/content/extensions/helpers/queue/reference.md +494 -0
- package/content/extensions/helpers/redis/index.md +86 -640
- package/content/extensions/helpers/redis/reference.md +784 -0
- package/content/extensions/helpers/secrets/index.md +136 -0
- package/content/extensions/helpers/socket-io/api.md +325 -204
- package/content/extensions/helpers/socket-io/index.md +79 -429
- package/content/extensions/helpers/storage/api.md +584 -464
- package/content/extensions/helpers/storage/index.md +80 -573
- package/content/extensions/helpers/types/index.md +75 -495
- package/content/extensions/helpers/types/reference.md +689 -0
- package/content/extensions/helpers/uid/index.md +176 -189
- package/content/extensions/helpers/websocket/api.md +366 -214
- package/content/extensions/helpers/websocket/index.md +68 -503
- package/content/extensions/helpers/worker-thread/index.md +62 -396
- package/content/extensions/helpers/worker-thread/reference.md +428 -0
- package/content/extensions/index.md +38 -39
- package/content/extensions/src-details/mcp-server.md +96 -548
- package/content/guides/core-concepts/persistent/datasources.md +2 -2
- package/content/guides/core-concepts/persistent/index.md +5 -1
- package/content/guides/core-concepts/persistent/models.md +1 -1
- package/content/guides/core-concepts/persistent/pglite.md +218 -0
- package/content/guides/core-concepts/persistent/postgres-drivers.md +21 -15
- package/content/guides/core-concepts/persistent/search-meilisearch.md +22 -12
- package/content/guides/core-concepts/persistent/search-typesense.md +28 -16
- package/content/guides/core-concepts/persistent/sqlite.md +296 -0
- package/content/guides/core-concepts/persistent/transactions.md +12 -11
- package/content/guides/core-concepts/secrets-vault.md +177 -0
- package/content/guides/core-concepts/services.md +1 -1
- package/content/guides/get-started/5-minute-quickstart.md +59 -206
- package/content/guides/get-started/philosophy.md +135 -670
- package/content/guides/get-started/setup.md +53 -74
- package/content/guides/migrations/redis-helpers-migration.md +5 -4
- package/content/guides/migrations/scoped-rbac-migration.md +5 -5
- package/content/guides/migrations/unified-connectors-migration.md +8 -7
- package/content/guides/tutorials/ecommerce-api.md +3 -8
- package/content/guides/tutorials/realtime-chat.md +1 -1
- package/content/references/base/application.md +64 -22
- package/content/references/base/components.md +3 -3
- package/content/references/base/connectors.md +79 -136
- package/content/references/base/controllers.md +12 -12
- package/content/references/base/datasources-reference.md +600 -0
- package/content/references/base/datasources.md +84 -444
- package/content/references/base/dependency-injection.md +25 -46
- package/content/references/base/filter-system/application-usage.md +95 -123
- package/content/references/base/filter-system/array-operators.md +33 -43
- package/content/references/base/filter-system/comparison-operators.md +55 -63
- package/content/references/base/filter-system/default-filter.md +144 -350
- package/content/references/base/filter-system/fields-order-pagination.md +116 -148
- package/content/references/base/filter-system/index.md +114 -253
- package/content/references/base/filter-system/json-filtering.md +52 -181
- package/content/references/base/filter-system/list-operators.md +28 -44
- package/content/references/base/filter-system/logical-operators.md +69 -114
- package/content/references/base/filter-system/null-operators.md +41 -98
- package/content/references/base/filter-system/pattern-matching.md +48 -51
- package/content/references/base/filter-system/quick-reference.md +93 -196
- package/content/references/base/filter-system/range-operators.md +22 -38
- package/content/references/base/filter-system/tips.md +70 -133
- package/content/references/base/filter-system/use-cases.md +173 -232
- package/content/references/base/grpc-controllers.md +53 -17
- package/content/references/base/index.md +5 -3
- package/content/references/base/middlewares.md +43 -28
- package/content/references/base/models-reference.md +886 -0
- package/content/references/base/models.md +81 -1452
- package/content/references/base/providers.md +8 -8
- package/content/references/base/repositories/advanced.md +281 -419
- package/content/references/base/repositories/index.md +84 -644
- package/content/references/base/repositories/mixins.md +25 -21
- package/content/references/base/repositories/relations.md +194 -375
- package/content/references/base/repositories/soft-deletable.md +67 -55
- package/content/references/base/secrets.md +267 -0
- package/content/references/base/services.md +8 -6
- package/content/references/configuration/environment-variables.md +73 -21
- package/content/references/configuration/index.md +51 -31
- package/content/references/index.md +1 -1
- package/content/references/quick-reference.md +3 -16
- package/content/references/utilities/crypto.md +35 -76
- package/content/references/utilities/date.md +33 -73
- package/content/references/utilities/duration.md +85 -0
- package/content/references/utilities/index.md +6 -2
- package/content/references/utilities/jsx-reference.md +298 -0
- package/content/references/utilities/jsx.md +82 -525
- package/content/references/utilities/module.md +81 -61
- package/content/references/utilities/parse.md +34 -64
- package/content/references/utilities/performance.md +33 -58
- package/content/references/utilities/promise.md +28 -62
- package/content/references/utilities/request.md +58 -218
- package/content/references/utilities/retry.md +139 -0
- package/content/references/utilities/schema.md +43 -137
- package/content/references/utilities/statuses-reference.md +361 -0
- package/content/references/utilities/statuses.md +63 -667
- package/dist/mcp-server/index.js +0 -0
- package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/github/list-project-files.tool.js +3 -3
- package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
- package/dist/mcp-server/tools/github/verify-dependencies.tool.js +1 -1
- package/dist/mcp-server/tools/github/view-source-file.tool.js +1 -1
- package/package.json +24 -23
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Search & Typesense
|
|
2
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
|
|
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. Documents replace Drizzle tables, and the query surface is intentionally narrower - search engines are not relational databases.
|
|
4
4
|
|
|
5
5
|
> [!IMPORTANT] Subpath-only import
|
|
6
|
-
> `typesense` is an **optional peer dependency
|
|
6
|
+
> `typesense` is an **optional peer dependency**. Unlike `postgres`, the typesense connector is not re-exported from the `@venizia/ignis` root barrel, so apps that don't use search never pull in the `typesense` client. Always import from `@venizia/ignis/typesense`:
|
|
7
7
|
>
|
|
8
8
|
> ```typescript
|
|
9
9
|
> import {
|
|
@@ -77,7 +77,7 @@ The same `@model` settings you use with Drizzle entities - `hiddenProperties`, `
|
|
|
77
77
|
| `field.geopoint(name, flags?)` | `geopoint` (`[number, number]`) | |
|
|
78
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
79
|
|
|
80
|
-
Each `flags` object may set `searchable`, `filterable`, `facet`, `sortable`, `optional` (all `boolean`). Note: `searchable`/`filterable` have no direct Typesense wire equivalent
|
|
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 - so they're 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
81
|
|
|
82
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
83
|
|
|
@@ -128,7 +128,7 @@ field.vector('embedding', {
|
|
|
128
128
|
});
|
|
129
129
|
```
|
|
130
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)
|
|
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
132
|
|
|
133
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
134
|
|
|
@@ -160,7 +160,7 @@ static override schema = defineSearchCollection({
|
|
|
160
160
|
});
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
-
Declarative `synonyms` are provisioned as one **synonym set** per collection (named `<collection>_synonyms`) and linked to it
|
|
163
|
+
Declarative `synonyms` are provisioned as one **synonym set** per collection (named `<collection>_synonyms`) and linked to it. That matches 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
164
|
|
|
165
165
|
```typescript
|
|
166
166
|
const connector = repository.dataSource.getConnector();
|
|
@@ -241,26 +241,38 @@ export class ArticleRepository extends DefaultSearchRepository<TArticleDocument>
|
|
|
241
241
|
}
|
|
242
242
|
```
|
|
243
243
|
|
|
244
|
-
`find()`/`findOne()`/`findById()`/`count()`/`existsWith()` accept the same `TFilter`/`TWhere` shape as PostgreSQL repositories
|
|
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
|
+
Search engines index asynchronously, so a document can be missing right after a write. `find()`/`findOne()`/`findById()` accept the same `options.retry` as PostgreSQL - re-read until the document shows up:
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
const article = await articleRepository.findById({
|
|
250
|
+
id,
|
|
251
|
+
options: { retry: { maxAttempts: 4 } },
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Same options, same rules as PostgreSQL - see [Read Retry](/references/base/repositories/advanced#read-retry-replica-lag). The only difference is moot: retry skips inside a transaction, and search has no transactions to begin with.
|
|
245
256
|
|
|
246
257
|
### Envelope differences vs. PostgreSQL
|
|
247
258
|
|
|
248
259
|
| | PostgreSQL repositories | Search repositories |
|
|
249
260
|
| ------------------------------------------ | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
250
261
|
| 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)
|
|
262
|
+
| `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. Passing `options.force: true` truncates the whole collection instead |
|
|
252
263
|
| 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
264
|
| `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
265
|
|
|
255
266
|
### Write error semantics (shared by both search engines)
|
|
256
267
|
|
|
257
|
-
- **`create()` with a duplicate id throws `409
|
|
258
|
-
-
|
|
268
|
+
- **`create()` with a duplicate id throws `409`** (`normalized.code: 'core.search_engine.already_exists'`) on both Typesense and Meilisearch. A duplicate is a conflict, never a silent overwrite - call `upsert()` explicitly for last-write-wins.
|
|
269
|
+
- On Meilisearch, the duplicate pre-check is not atomic, so two concurrent creates of the same id can both pass it. See the [Meilisearch guide](./search-meilisearch) and use `upsert()` when that's possible.
|
|
270
|
+
- **`updateById()` against a missing id throws `404` (`normalized.code: '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
271
|
- **`deleteById()` against a missing id is silent** (`{ count: 0, data: null }`), matching the relational connectors.
|
|
260
272
|
|
|
261
273
|
## Searching with `search({ mode })`
|
|
262
274
|
|
|
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
|
|
275
|
+
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
276
|
|
|
265
277
|
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
278
|
|
|
@@ -299,7 +311,7 @@ await repository.search({
|
|
|
299
311
|
```
|
|
300
312
|
|
|
301
313
|
> [!NOTE]
|
|
302
|
-
> `semantic` and `hybrid` default `prefix=false`: prefix matching is meaningless for vector search
|
|
314
|
+
> `semantic` and `hybrid` default `prefix=false`: prefix matching is meaningless for vector search. Remote embedders (OpenAI/Google/...) reject it too (`Prefix search is not supported for remote embedders`). Pass `engineParams: { prefix: true }` to override.
|
|
303
315
|
|
|
304
316
|
**`raw`** - native Typesense params, no `TFilter`/`defaultFilter`/`hiddenProperties` translation. Use it for any engine feature the modes above do not model:
|
|
305
317
|
|
|
@@ -313,9 +325,9 @@ await repository.search({
|
|
|
313
325
|
> [!NOTE]
|
|
314
326
|
> `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
327
|
|
|
316
|
-
All non-`raw` modes accept the same optional camelCase tuning fields, mapped to Typesense's snake_case at the dialect boundary
|
|
328
|
+
All non-`raw` modes accept the same optional camelCase tuning fields, mapped to Typesense's snake_case at the dialect boundary. That covers 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
329
|
|
|
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
|
|
330
|
+
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
331
|
|
|
320
332
|
```typescript
|
|
321
333
|
await repository.search({
|
|
@@ -381,7 +393,7 @@ await repository.dataSource.multiSearch({
|
|
|
381
393
|
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
394
|
|
|
383
395
|
> [!NOTE]
|
|
384
|
-
> `multiSearch` injects each collection's `@model hiddenProperties` into that entry's `excludeFields` automatically
|
|
396
|
+
> `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
397
|
|
|
386
398
|
## Search Controller Factory
|
|
387
399
|
|
|
@@ -403,7 +415,7 @@ const _SearchController = SearchControllerFactory.defineSearchController({
|
|
|
403
415
|
export const ArticleSearchController = _SearchController;
|
|
404
416
|
```
|
|
405
417
|
|
|
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`)
|
|
418
|
+
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`). It also registers `POST /multi-search`, which dispatches to `dataSource.multiSearch`. For anything the factory does not cover, write a custom `BaseRestController` calling `repository.search()` or `getClient()` directly.
|
|
407
419
|
|
|
408
420
|
## Transactions and Locking
|
|
409
421
|
|
|
@@ -413,7 +425,7 @@ The typesense connector has no transaction or row-level-locking model. Passing a
|
|
|
413
425
|
await articleRepository.updateById({
|
|
414
426
|
id: '123',
|
|
415
427
|
data: { title: 'New' },
|
|
416
|
-
options: { transaction: tx }, // throws: 501,
|
|
428
|
+
options: { transaction: tx }, // throws: 501, normalized.code 'core.not_supported'
|
|
417
429
|
});
|
|
418
430
|
```
|
|
419
431
|
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# SQLite
|
|
2
|
+
|
|
3
|
+
IGNIS ships a **SQLite connector** at `@venizia/ignis/sqlite`, driven by libsql. It is the second engine on the engine-neutral relational tier, so `@model`, `@repository`, `@datasource`, filters, transactions and soft delete all mean the same thing they mean on Postgres.
|
|
4
|
+
|
|
5
|
+
They do not all *do* the same thing. Read this table first - it is what a Postgres user needs before anything else.
|
|
6
|
+
|
|
7
|
+
| Capability | PostgreSQL | SQLite | What happens |
|
|
8
|
+
| :--- | :--- | :--- | :--- |
|
|
9
|
+
| `ilike` | native `ILIKE` | none | Maps onto `LIKE`, which already folds ASCII case |
|
|
10
|
+
| `like` | case-**sensitive** | case-**insensitive** | Silently matches more rows than on Postgres |
|
|
11
|
+
| `regexp`, `iregexp` | native | none | Throws `501` `core.not_supported` |
|
|
12
|
+
| `contains`, `containedBy`, `overlaps` | array operators | no array type | Throws `501` `core.not_supported` |
|
|
13
|
+
| `lock` (`SELECT ... FOR UPDATE`) | row locks | file locks only | Throws `501` `core.not_supported` |
|
|
14
|
+
| Isolation levels | three (`IsolationLevels`) | none | `isolationLevel` throws; pass `beginMode` instead |
|
|
15
|
+
| Storage classes | ~28 types | five | `jsonb` -> json-mode `text`, `bytea` -> `blob`, `boolean` -> 0/1 `integer` |
|
|
16
|
+
| JSON paths | `col #>> '{a,b}'` | `json_extract(col, '$."a"."b"')` | Same filter syntax, different SQL |
|
|
17
|
+
| Timestamps | `timestamptz` | no date type | ISO 8601 UTC strings in a `text` column |
|
|
18
|
+
| NULL sort order | NULL sorts **high** | NULL sorts **low** | `order: ['score ASC']` puts NULLs last on Postgres, first here |
|
|
19
|
+
|
|
20
|
+
Every refusal throws `501 Not Implemented` with `normalized.code: 'core.not_supported'`. None of them silently emits different SQL.
|
|
21
|
+
|
|
22
|
+
The two divergences that do not throw are the dangerous ones. `like` widens, and `nlike` narrows - on SQLite `{ nlike: 'alpha' }` **drops** the row holding `Alpha`. NULL ordering inverts. Both are pinned per engine by the conformance suite.
|
|
23
|
+
|
|
24
|
+
> [!IMPORTANT] Optional peer dependency
|
|
25
|
+
> `@libsql/client` is an optional peer, and the connector never reaches the `@venizia/ignis` root barrel. Import from the sub-path.
|
|
26
|
+
>
|
|
27
|
+
> ```bash
|
|
28
|
+
> bun add @libsql/client
|
|
29
|
+
> ```
|
|
30
|
+
|
|
31
|
+
## What does work
|
|
32
|
+
|
|
33
|
+
- **Every CRUD verb**, including `.returning()` on insert, update and delete. MySQL cannot do that, so nothing here is emulated with a read-then-write.
|
|
34
|
+
- **Real transactions**, with `commit()`, `rollback()` and the same transaction object your services already pass around.
|
|
35
|
+
- **The whole filter vocabulary** minus the four operators above - `and`/`or`, `between`, `inq`, `nin`, `gt`, `like`, null handling, nested logical groups.
|
|
36
|
+
- **JSON path filtering and JSON path updates**, through `json_extract` and `json_set`.
|
|
37
|
+
- **Relations, hidden properties, default filters, soft delete, `count`, `skip`/`limit`.**
|
|
38
|
+
- **One driver for four deployments**: `:memory:`, a local file, a remote Turso database and an embedded replica.
|
|
39
|
+
|
|
40
|
+
## Wiring the datasource
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
import { createClient } from '@libsql/client';
|
|
44
|
+
import type { Client } from '@libsql/client';
|
|
45
|
+
import { datasource } from '@venizia/ignis';
|
|
46
|
+
import type { TAnyDataSourceSchema, ValueOrPromise } from '@venizia/ignis';
|
|
47
|
+
import { BaseSqliteDataSource } from '@venizia/ignis/sqlite';
|
|
48
|
+
import type { ISqliteDataSourceSettings } from '@venizia/ignis/sqlite';
|
|
49
|
+
import { LibSqlDriver } from '@venizia/ignis/sqlite/libsql';
|
|
50
|
+
|
|
51
|
+
@datasource({ driver: LibSqlDriver })
|
|
52
|
+
export class SqliteDataSource extends BaseSqliteDataSource<
|
|
53
|
+
ISqliteDataSourceSettings,
|
|
54
|
+
TAnyDataSourceSchema,
|
|
55
|
+
{},
|
|
56
|
+
Client
|
|
57
|
+
> {
|
|
58
|
+
constructor() {
|
|
59
|
+
super({
|
|
60
|
+
name: SqliteDataSource.name,
|
|
61
|
+
config: { url: process.env.APP_ENV_SQLITE_URL ?? 'file:./data.db' },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override configure(): ValueOrPromise<void> {
|
|
66
|
+
this.client = createClient(this.settings);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
There is no `getConnectionString()` override. SQLite inherits one that returns `settings.url` - the libsql URL **is** the connection string. Postgres leaves the method abstract because no framework code can guess a `postgresql://` URL.
|
|
72
|
+
|
|
73
|
+
| Setting | Type | Meaning |
|
|
74
|
+
| :--- | :--- | :--- |
|
|
75
|
+
| `url` | `string` | `:memory:`, `file:./data.db`, `libsql://<host>`, or an embedded-replica URL |
|
|
76
|
+
| `authToken` | `string` | Turso credential; omit for a local file |
|
|
77
|
+
|
|
78
|
+
## Models
|
|
79
|
+
|
|
80
|
+
Same decorators, `sqliteTable` instead of `pgTable`, and the enrichers come from `@venizia/ignis/sqlite`:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { model } from '@venizia/ignis';
|
|
84
|
+
import {
|
|
85
|
+
BaseSqliteEntity,
|
|
86
|
+
generateIdColumnDefs,
|
|
87
|
+
generateTzColumnDefs,
|
|
88
|
+
} from '@venizia/ignis/sqlite';
|
|
89
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
90
|
+
|
|
91
|
+
@model({ type: 'entity' })
|
|
92
|
+
export class Product extends BaseSqliteEntity<typeof Product.schema> {
|
|
93
|
+
static override schema = sqliteTable('Product', {
|
|
94
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
95
|
+
...generateTzColumnDefs(),
|
|
96
|
+
name: text('name').notNull(),
|
|
97
|
+
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
|
98
|
+
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown>>(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Three column choices differ from the Postgres twin:
|
|
104
|
+
|
|
105
|
+
- **JSON is a json-mode `text` column.** Declare the mode. A JSON path on a plain `text` column is rejected rather than compiled into a `json_extract` that quietly returns `NULL`.
|
|
106
|
+
- **Booleans are `integer(..., { mode: 'boolean' })`** - SQLite stores 0 and 1.
|
|
107
|
+
- **Timestamps are ISO 8601 strings.** `generateTzColumnDefs()` handles this, defaulting to `(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`. Comparisons stay lexicographic, so ordering by a timestamp still works.
|
|
108
|
+
|
|
109
|
+
`generateIdColumnDefs` accepts `dataType: 'string'` and `dataType: 'number'`. There is no `'big-number'` - an integer primary key in SQLite is already the 64-bit rowid.
|
|
110
|
+
|
|
111
|
+
## Repositories
|
|
112
|
+
|
|
113
|
+
`DefaultSqliteRepository` is the base most repositories want. `ReadableSqliteRepository`, `PersistableSqliteRepository` and `SoftDeletableSqliteRepository` sit beside it, mirroring the Postgres chain.
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
import { repository } from '@venizia/ignis';
|
|
117
|
+
import { DefaultSqliteRepository } from '@venizia/ignis/sqlite';
|
|
118
|
+
|
|
119
|
+
@repository({ model: Product, dataSource: SqliteDataSource })
|
|
120
|
+
export class ProductRepository extends DefaultSqliteRepository<typeof Product.schema> {}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Reads look exactly like Postgres reads:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
await this.productRepository.find({
|
|
127
|
+
filter: {
|
|
128
|
+
where: { and: [{ isActive: true }, { name: { like: 'wid%' } }] },
|
|
129
|
+
order: ['createdAt DESC'],
|
|
130
|
+
limit: 20,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Remember that `like` is case-insensitive here, so `{ like: 'wid%' }` also matches `Widget`. Non-ASCII is not folded, so `'ÉCOLE' LIKE 'é%'` is still false. Never set `PRAGMA case_sensitive_like=ON` - it would break `ilike` too.
|
|
136
|
+
|
|
137
|
+
JSON paths use the same dot notation as Postgres and compile to SQLite syntax:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
{ where: { 'metadata.tier': { eq: 'gold' } } }
|
|
141
|
+
// SQL: json_extract("metadata", '$."tier"') = ?
|
|
142
|
+
|
|
143
|
+
{ where: { 'metadata.score': { gt: 50 } } }
|
|
144
|
+
// SQL: json_extract("metadata", '$."score"') > ? -- no cast; json_extract is already typed
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Postgres needs a numeric cast because `#>>` always returns text. SQLite does not, because `json_extract` hands back a JSON number as `INTEGER` or `REAL`. See [JSON/JSONB Filtering](/references/base/filter-system/json-filtering) for the path grammar.
|
|
148
|
+
|
|
149
|
+
## Transactions
|
|
150
|
+
|
|
151
|
+
SQLite has no isolation levels. Every SQLite transaction is already serializable, so the axis is a **locking mode**:
|
|
152
|
+
|
|
153
|
+
| `beginMode` | Statement | Takes the write lock |
|
|
154
|
+
| :--- | :--- | :--- |
|
|
155
|
+
| `SqliteBeginModes.IMMEDIATE` (default) | `BEGIN IMMEDIATE` | At `BEGIN` |
|
|
156
|
+
| `SqliteBeginModes.DEFERRED` | `BEGIN DEFERRED` | At the first write |
|
|
157
|
+
| `SqliteBeginModes.EXCLUSIVE` | `BEGIN EXCLUSIVE` | At `BEGIN`, blocking readers too |
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
import { SqliteBeginModes } from '@venizia/ignis/sqlite';
|
|
161
|
+
|
|
162
|
+
const transaction = await this.dataSource.beginTransaction({
|
|
163
|
+
beginMode: SqliteBeginModes.IMMEDIATE,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await this.productRepository.create({ data: { name: 'widget' }, options: { transaction } });
|
|
168
|
+
await transaction.commit();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Nested: a first rollback that itself fails throws, and would replace the original error.
|
|
171
|
+
try {
|
|
172
|
+
await transaction.rollback();
|
|
173
|
+
} catch (rollbackError) {
|
|
174
|
+
this.logger.error('Rollback failed | %s', rollbackError);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`commit()` and `rollback()` throw on failure, exactly as they do on Postgres. See [Transactions](./transactions) for the full contract.
|
|
182
|
+
|
|
183
|
+
The default is `IMMEDIATE`, not SQLite's own `DEFERRED`. A deferred transaction takes its write lock at the first write, and that upgrade fails outright with `SQLITE_BUSY` when another writer got there first. `IMMEDIATE` waits on the busy timeout instead.
|
|
184
|
+
|
|
185
|
+
Passing `isolationLevel` throws rather than being ignored. Ignoring it would leave you believing `SERIALIZABLE` was honoured.
|
|
186
|
+
|
|
187
|
+
A `beginMode` outside those three throws too. The mode is written straight into the `BEGIN` statement, which the driver runs verbatim, so it has to come from the closed set. Matching is exact - `'immediate'` is refused, because nothing upper-cases it for you.
|
|
188
|
+
|
|
189
|
+
### One connection, so writes serialise
|
|
190
|
+
|
|
191
|
+
`beginTransaction()` borrows the single libsql connection from a 1-slot pool. A second transaction waits. SQLite allows one writer at a time regardless, so this costs no throughput SQLite was going to give.
|
|
192
|
+
|
|
193
|
+
Reads are the part to watch. A query on the pooled connector while a transaction is open runs **inside** that transaction. Pass `options.transaction` when the work belongs to the transaction, and route work that must stay outside through the driver's `acquire()`.
|
|
194
|
+
|
|
195
|
+
> [!WARNING] Transactions need a local database
|
|
196
|
+
> `beginTransaction()` throws `501` when the libsql client is not a `file:` or `:memory:` one. A remote client opens a stream per statement and closes it, so `BEGIN` would neither hold nor error - the transaction would silently not exist. Use a local file or an embedded replica.
|
|
197
|
+
|
|
198
|
+
### The acquire timeout
|
|
199
|
+
|
|
200
|
+
A transaction that never commits holds the one slot forever. Without a bound, every later `beginTransaction()` in the process hangs silently. So the wait is bounded:
|
|
201
|
+
|
|
202
|
+
| Option | Type | Default | Meaning |
|
|
203
|
+
| :--- | :--- | :--- | :--- |
|
|
204
|
+
| `acquireTimeoutMs` | `number` | `LibSqlDriver.DEFAULT_ACQUIRE_TIMEOUT_MS` (30000) | Max ms to wait for the connection before rejecting |
|
|
205
|
+
| `maxWaitingClients` | `number` | unlimited | Max queued waiters; over the limit, `acquire()` rejects immediately |
|
|
206
|
+
| `scope` | `string` | `'LibSqlDriver'` | Logger scope |
|
|
207
|
+
| `client` | `Client` | required | The libsql client the driver borrows |
|
|
208
|
+
|
|
209
|
+
The timeout bounds the **wait for the connection**, never the transaction itself. It fires only under contention, so a migration or a bulk seed running alone never trips it. When it does fire you get a named error, not a dead process:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
[LibSqlDriver][acquire] Could not borrow the single libsql connection
|
|
213
|
+
| [acquire] Acquire timed out after 30000ms.
|
|
214
|
+
| An unreleased transaction still holds it - commit/rollback it, or raise `acquireTimeoutMs`
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
To change any of these, construct the driver yourself with `useDriver()`. That assigns the driver and builds the connector in one step, and bypasses `@datasource({ driver })`:
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
override configure(): void {
|
|
221
|
+
const client = createClient(this.settings);
|
|
222
|
+
|
|
223
|
+
this.useDriver({
|
|
224
|
+
driver: new LibSqlDriver({ client, acquireTimeoutMs: 120_000 }),
|
|
225
|
+
schema: this.getSchema(),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
`size` is not an option. One slot **is** the mutual exclusion the single connection lacks, so it is pinned at 1.
|
|
231
|
+
|
|
232
|
+
## Schema and migrations
|
|
233
|
+
|
|
234
|
+
drizzle-kit, with the `sqlite` dialect:
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
// drizzle.config.ts
|
|
238
|
+
import { defineConfig } from 'drizzle-kit';
|
|
239
|
+
|
|
240
|
+
export default defineConfig({
|
|
241
|
+
dialect: 'sqlite',
|
|
242
|
+
schema: './src/models/**/*.model.ts',
|
|
243
|
+
out: './migrations',
|
|
244
|
+
dbCredentials: { url: 'file:./data.db' },
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Use `dialect: 'turso'` instead for a remote database - it adds `authToken` to `dbCredentials`.
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
bun run drizzle-kit generate
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Apply them at boot, before the first repository call:
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import { drizzle } from 'drizzle-orm/libsql';
|
|
258
|
+
import { migrate } from 'drizzle-orm/libsql/migrator';
|
|
259
|
+
|
|
260
|
+
export const applyMigrations = async (opts: { dataSource: SqliteDataSource }): Promise<void> => {
|
|
261
|
+
const client = opts.dataSource.getClient();
|
|
262
|
+
|
|
263
|
+
await migrate(drizzle({ client }), { migrationsFolder: './migrations' });
|
|
264
|
+
};
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Out of scope in this release
|
|
268
|
+
|
|
269
|
+
Three components declare their models as `pgTable` and are Postgres-only for now. A SQLite datasource cannot back them:
|
|
270
|
+
|
|
271
|
+
| Component | Why |
|
|
272
|
+
| :--- | :--- |
|
|
273
|
+
| **Authentication** | `User`, `Role`, `Permission` and `PolicyDefinition` are `pgTable` models |
|
|
274
|
+
| **Authorization** | The Casbin adapters type their connector as Drizzle's `PgDatabase` |
|
|
275
|
+
| **StaticAssetComponent** | Its base model is a `pgTable` with `jsonb` and Postgres indexes |
|
|
276
|
+
|
|
277
|
+
Everything else - your own models, repositories, services, controllers, REST routes - works on either engine.
|
|
278
|
+
|
|
279
|
+
## Runnable example
|
|
280
|
+
|
|
281
|
+
`examples/sqlite-quickstart/` is this page as an app: a `sqliteTable` model with a `text` id and `text({ mode: 'json' })` metadata, `generateTzColumnDefs` for the timestamp default, and the same repository and controller the PGlite example uses.
|
|
282
|
+
|
|
283
|
+
```bash
|
|
284
|
+
cd examples/sqlite-quickstart
|
|
285
|
+
bun install
|
|
286
|
+
bun run start # http://localhost:3000/api/notes
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## See Also
|
|
290
|
+
|
|
291
|
+
- [PGlite](./pglite) - the other embedded engine, with real Postgres semantics
|
|
292
|
+
- [Postgres Drivers & Supabase](./postgres-drivers) - the driver seam
|
|
293
|
+
- [DataSources](./datasources) - schema auto-discovery and `configure()`
|
|
294
|
+
- [Transactions](./transactions) - the transaction object and connection safety
|
|
295
|
+
- [JSON/JSONB Filtering](/references/base/filter-system/json-filtering) - the JSON path grammar
|
|
296
|
+
- [Connectors](/references/base/connectors) - the engine-neutral contract
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
IGNIS supports explicit transaction objects that can be passed across multiple services and repositories, allowing for complex, multi-step business logic to be atomic.
|
|
4
4
|
|
|
5
5
|
> [!NOTE] PostgreSQL-only capability
|
|
6
|
-
> Real transactions are a **PostgreSQL connector** capability - `BasePostgresDataSource.getCapabilities()` returns `{ transactions: true }` and its `beginTransaction()` opens a real database transaction, as documented below. The typesense connector inherits the engine-neutral `AbstractDataSource` default
|
|
6
|
+
> Real transactions are a **PostgreSQL connector** capability - `BasePostgresDataSource.getCapabilities()` returns `{ transactions: true }` and its `beginTransaction()` opens a real database transaction, as documented below. The typesense connector inherits the engine-neutral `AbstractDataSource` default. Calling `beginTransaction()` on it throws a `501 Not Implemented` (`normalized.code: 'core.not_supported'`) via the shared `throwNotSupported` utility. See [Connectors](/references/base/connectors) for the capabilities model.
|
|
7
7
|
|
|
8
8
|
## Using Transactions
|
|
9
9
|
|
|
@@ -51,18 +51,18 @@ try {
|
|
|
51
51
|
> `COMMIT` can genuinely fail - a deadlock, a serialization failure under `SERIALIZABLE`, a dropped
|
|
52
52
|
> connection, a deferred constraint firing at commit time. When it does, `commit()` throws rather
|
|
53
53
|
> than resolving, so you never report success on a write that was never persisted. The connection is
|
|
54
|
-
> then destroyed instead of being returned to the pool
|
|
54
|
+
> then destroyed instead of being returned to the pool. It may still hold an open
|
|
55
55
|
> transaction that the next borrower would inherit.
|
|
56
56
|
>
|
|
57
57
|
> `rollback()` behaves the same way when it is the FIRST verb to fail. One deliberate exception
|
|
58
|
-
> keeps the everyday `catch { await tx.rollback(); throw error; }` pattern safe
|
|
58
|
+
> keeps the everyday `catch { await tx.rollback(); throw error; }` pattern safe. Calling
|
|
59
59
|
> `rollback()` on a transaction that already ended BY FAILURE (a failed `COMMIT` or a failed prior
|
|
60
|
-
> `ROLLBACK`) is a silent no-op
|
|
60
|
+
> `ROLLBACK`) is a silent no-op. Nothing was committed and the connection is already destroyed, so
|
|
61
61
|
> the rollback's goal is achieved and your original error survives. The nested-try form above is
|
|
62
62
|
> still the safest general pattern, because a FIRST rollback that itself fails does throw.
|
|
63
63
|
>
|
|
64
|
-
> The destroy half is **driver-specific**: node-postgres (`pg`) discards the poisoned connection
|
|
65
|
-
>
|
|
64
|
+
> The destroy half is **driver-specific**: node-postgres (`pg`) discards the poisoned connection.
|
|
65
|
+
> But postgres-js has no destroy semantics (`ReservedSql.release()` takes no argument), so under the
|
|
66
66
|
> postgres-js driver the connection returns to the pool anyway. See
|
|
67
67
|
> [Postgres Drivers & Supabase](./postgres-drivers) for the full asymmetry.
|
|
68
68
|
|
|
@@ -78,7 +78,7 @@ try {
|
|
|
78
78
|
| `commit()` | `Promise<void>` | Commit and release the connection. **Throws** if `COMMIT` fails, and destroys the connection rather than pooling it |
|
|
79
79
|
| `rollback()` | `Promise<void>` | Rollback and release the connection. **Throws** if `ROLLBACK` fails, and destroys the connection rather than pooling it |
|
|
80
80
|
|
|
81
|
-
Calling `commit()` or `rollback()` on an already-ended transaction throws an error
|
|
81
|
+
Calling `commit()` or `rollback()` on an already-ended transaction throws an error. The one exception: `rollback()` after the transaction ended BY FAILURE is a silent no-op (see the warning above).
|
|
82
82
|
|
|
83
83
|
## Isolation Levels
|
|
84
84
|
|
|
@@ -95,9 +95,10 @@ IGNIS supports standard PostgreSQL isolation levels:
|
|
|
95
95
|
|
|
96
96
|
## Best Practices
|
|
97
97
|
|
|
98
|
-
1. **Always use `try...catch`, and nest the rollback**: `rollback()` throws when `ROLLBACK` fails
|
|
99
|
-
2. **
|
|
100
|
-
3. **
|
|
98
|
+
1. **Always use `try...catch`, and nest the rollback**: `rollback()` throws when `ROLLBACK` fails. A bare `await tx.rollback()` inside a `catch` would discard the error that sent you there.
|
|
99
|
+
2. **Wrap the rollback in its own `try...catch`**: log the rollback failure, and rethrow the original cause.
|
|
100
|
+
3. **Keep it short**: Long-running transactions hold database connections from the pool and can cause connection exhaustion.
|
|
101
|
+
4. **Pass explicit options**: When calling other services inside a transaction, ensure they accept and use the `transaction` option.
|
|
101
102
|
|
|
102
103
|
```typescript
|
|
103
104
|
// Service method supporting transactions
|
|
@@ -196,7 +197,7 @@ export class OrderController extends BaseRestController {
|
|
|
196
197
|
|
|
197
198
|
## How Transactions Work Internally
|
|
198
199
|
|
|
199
|
-
When you pass a `transaction` option to a repository method, the repository uses the transaction's `connector`
|
|
200
|
+
When you pass a `transaction` option to a repository method, the repository uses the transaction's `connector` instead of the default datasource connector. That connector is a Drizzle instance bound to the transaction's dedicated connection. All operations within the transaction therefore use the same database connection and see a consistent view of the data.
|
|
200
201
|
|
|
201
202
|
```typescript
|
|
202
203
|
// Inside PostgresBaseRepository (simplified)
|