@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.
Files changed (174) hide show
  1. package/README.md +14 -14
  2. package/content/best-practices/api-usage-examples.md +39 -19
  3. package/content/best-practices/architectural-patterns.md +24 -13
  4. package/content/best-practices/architecture-decisions.md +29 -16
  5. package/content/best-practices/code-style-standards/advanced-patterns.md +43 -31
  6. package/content/best-practices/code-style-standards/constants-configuration.md +19 -4
  7. package/content/best-practices/code-style-standards/control-flow.md +33 -1
  8. package/content/best-practices/code-style-standards/documentation.md +28 -8
  9. package/content/best-practices/code-style-standards/function-patterns.md +15 -4
  10. package/content/best-practices/code-style-standards/index.md +7 -2
  11. package/content/best-practices/code-style-standards/naming-conventions.md +27 -3
  12. package/content/best-practices/code-style-standards/route-definitions.md +9 -5
  13. package/content/best-practices/code-style-standards/tooling.md +14 -1
  14. package/content/best-practices/code-style-standards/type-safety.md +39 -6
  15. package/content/best-practices/common-pitfalls.md +59 -35
  16. package/content/best-practices/contribution-workflow.md +8 -4
  17. package/content/best-practices/data-modeling.md +78 -62
  18. package/content/best-practices/deployment-strategies.md +56 -19
  19. package/content/best-practices/error-handling.md +247 -153
  20. package/content/best-practices/index.md +6 -0
  21. package/content/best-practices/performance-optimization.md +26 -13
  22. package/content/best-practices/security-guidelines.md +35 -9
  23. package/content/best-practices/testing-strategies.md +7 -2
  24. package/content/best-practices/troubleshooting-tips.md +27 -17
  25. package/content/extensions/components/api-reference.md +109 -323
  26. package/content/extensions/components/authentication/api.md +489 -602
  27. package/content/extensions/components/authentication/errors.md +135 -498
  28. package/content/extensions/components/authentication/index.md +89 -801
  29. package/content/extensions/components/authentication/usage.md +231 -955
  30. package/content/extensions/components/authorization/api.md +991 -644
  31. package/content/extensions/components/authorization/errors.md +204 -208
  32. package/content/extensions/components/authorization/getting-started.md +227 -0
  33. package/content/extensions/components/authorization/index.md +88 -795
  34. package/content/extensions/components/authorization/usage.md +219 -528
  35. package/content/extensions/components/health-check.md +77 -243
  36. package/content/extensions/components/index.md +24 -90
  37. package/content/extensions/components/mail/api.md +553 -285
  38. package/content/extensions/components/mail/errors.md +76 -62
  39. package/content/extensions/components/mail/index.md +111 -463
  40. package/content/extensions/components/mail/usage.md +139 -173
  41. package/content/extensions/components/request-tracker.md +70 -173
  42. package/content/extensions/components/socket-io/api.md +462 -785
  43. package/content/extensions/components/socket-io/errors.md +49 -51
  44. package/content/extensions/components/socket-io/index.md +69 -372
  45. package/content/extensions/components/socket-io/usage.md +212 -105
  46. package/content/extensions/components/static-asset/api.md +461 -141
  47. package/content/extensions/components/static-asset/errors.md +121 -53
  48. package/content/extensions/components/static-asset/index.md +84 -606
  49. package/content/extensions/components/static-asset/usage.md +184 -299
  50. package/content/extensions/components/template/index.md +3 -3
  51. package/content/extensions/components/websocket/api.md +311 -404
  52. package/content/extensions/components/websocket/errors.md +47 -56
  53. package/content/extensions/components/websocket/index.md +75 -407
  54. package/content/extensions/components/websocket/usage.md +120 -338
  55. package/content/extensions/helpers/cron/index.md +52 -160
  56. package/content/extensions/helpers/crypto/index.md +67 -480
  57. package/content/extensions/helpers/crypto/reference.md +528 -0
  58. package/content/extensions/helpers/env/index.md +64 -178
  59. package/content/extensions/helpers/error/index.md +286 -196
  60. package/content/extensions/helpers/index.md +61 -47
  61. package/content/extensions/helpers/inversion/index.md +76 -550
  62. package/content/extensions/helpers/inversion/reference.md +530 -0
  63. package/content/extensions/helpers/kafka/admin.md +23 -3
  64. package/content/extensions/helpers/kafka/compile-binary.md +83 -52
  65. package/content/extensions/helpers/kafka/consumer.md +65 -28
  66. package/content/extensions/helpers/kafka/examples.md +46 -253
  67. package/content/extensions/helpers/kafka/index.md +55 -617
  68. package/content/extensions/helpers/kafka/producer.md +156 -22
  69. package/content/extensions/helpers/kafka/schema-registry.md +59 -79
  70. package/content/extensions/helpers/logger/hf-logger.md +220 -0
  71. package/content/extensions/helpers/logger/index.md +78 -552
  72. package/content/extensions/helpers/logger/pino.md +105 -0
  73. package/content/extensions/helpers/logger/reference.md +937 -0
  74. package/content/extensions/helpers/network/api.md +276 -195
  75. package/content/extensions/helpers/network/index.md +87 -524
  76. package/content/extensions/helpers/queue/index.md +80 -897
  77. package/content/extensions/helpers/queue/reference.md +494 -0
  78. package/content/extensions/helpers/redis/index.md +86 -640
  79. package/content/extensions/helpers/redis/reference.md +784 -0
  80. package/content/extensions/helpers/secrets/index.md +136 -0
  81. package/content/extensions/helpers/socket-io/api.md +325 -204
  82. package/content/extensions/helpers/socket-io/index.md +79 -429
  83. package/content/extensions/helpers/storage/api.md +584 -464
  84. package/content/extensions/helpers/storage/index.md +80 -573
  85. package/content/extensions/helpers/types/index.md +75 -495
  86. package/content/extensions/helpers/types/reference.md +689 -0
  87. package/content/extensions/helpers/uid/index.md +176 -189
  88. package/content/extensions/helpers/websocket/api.md +366 -214
  89. package/content/extensions/helpers/websocket/index.md +68 -503
  90. package/content/extensions/helpers/worker-thread/index.md +62 -396
  91. package/content/extensions/helpers/worker-thread/reference.md +428 -0
  92. package/content/extensions/index.md +38 -39
  93. package/content/extensions/src-details/mcp-server.md +96 -548
  94. package/content/guides/core-concepts/persistent/datasources.md +2 -2
  95. package/content/guides/core-concepts/persistent/index.md +5 -1
  96. package/content/guides/core-concepts/persistent/models.md +1 -1
  97. package/content/guides/core-concepts/persistent/pglite.md +218 -0
  98. package/content/guides/core-concepts/persistent/postgres-drivers.md +21 -15
  99. package/content/guides/core-concepts/persistent/search-meilisearch.md +22 -12
  100. package/content/guides/core-concepts/persistent/search-typesense.md +28 -16
  101. package/content/guides/core-concepts/persistent/sqlite.md +296 -0
  102. package/content/guides/core-concepts/persistent/transactions.md +12 -11
  103. package/content/guides/core-concepts/secrets-vault.md +177 -0
  104. package/content/guides/core-concepts/services.md +1 -1
  105. package/content/guides/get-started/5-minute-quickstart.md +59 -206
  106. package/content/guides/get-started/philosophy.md +135 -670
  107. package/content/guides/get-started/setup.md +53 -74
  108. package/content/guides/migrations/redis-helpers-migration.md +5 -4
  109. package/content/guides/migrations/scoped-rbac-migration.md +5 -5
  110. package/content/guides/migrations/unified-connectors-migration.md +8 -7
  111. package/content/guides/tutorials/ecommerce-api.md +3 -8
  112. package/content/guides/tutorials/realtime-chat.md +1 -1
  113. package/content/references/base/application.md +64 -22
  114. package/content/references/base/components.md +3 -3
  115. package/content/references/base/connectors.md +79 -136
  116. package/content/references/base/controllers.md +12 -12
  117. package/content/references/base/datasources-reference.md +600 -0
  118. package/content/references/base/datasources.md +84 -444
  119. package/content/references/base/dependency-injection.md +25 -46
  120. package/content/references/base/filter-system/application-usage.md +95 -123
  121. package/content/references/base/filter-system/array-operators.md +33 -43
  122. package/content/references/base/filter-system/comparison-operators.md +55 -63
  123. package/content/references/base/filter-system/default-filter.md +144 -350
  124. package/content/references/base/filter-system/fields-order-pagination.md +116 -148
  125. package/content/references/base/filter-system/index.md +114 -253
  126. package/content/references/base/filter-system/json-filtering.md +52 -181
  127. package/content/references/base/filter-system/list-operators.md +28 -44
  128. package/content/references/base/filter-system/logical-operators.md +69 -114
  129. package/content/references/base/filter-system/null-operators.md +41 -98
  130. package/content/references/base/filter-system/pattern-matching.md +48 -51
  131. package/content/references/base/filter-system/quick-reference.md +93 -196
  132. package/content/references/base/filter-system/range-operators.md +22 -38
  133. package/content/references/base/filter-system/tips.md +70 -133
  134. package/content/references/base/filter-system/use-cases.md +173 -232
  135. package/content/references/base/grpc-controllers.md +53 -17
  136. package/content/references/base/index.md +5 -3
  137. package/content/references/base/middlewares.md +43 -28
  138. package/content/references/base/models-reference.md +886 -0
  139. package/content/references/base/models.md +81 -1452
  140. package/content/references/base/providers.md +8 -8
  141. package/content/references/base/repositories/advanced.md +281 -419
  142. package/content/references/base/repositories/index.md +84 -644
  143. package/content/references/base/repositories/mixins.md +25 -21
  144. package/content/references/base/repositories/relations.md +194 -375
  145. package/content/references/base/repositories/soft-deletable.md +67 -55
  146. package/content/references/base/secrets.md +267 -0
  147. package/content/references/base/services.md +8 -6
  148. package/content/references/configuration/environment-variables.md +73 -21
  149. package/content/references/configuration/index.md +51 -31
  150. package/content/references/index.md +1 -1
  151. package/content/references/quick-reference.md +3 -16
  152. package/content/references/utilities/crypto.md +35 -76
  153. package/content/references/utilities/date.md +33 -73
  154. package/content/references/utilities/duration.md +85 -0
  155. package/content/references/utilities/index.md +6 -2
  156. package/content/references/utilities/jsx-reference.md +298 -0
  157. package/content/references/utilities/jsx.md +82 -525
  158. package/content/references/utilities/module.md +81 -61
  159. package/content/references/utilities/parse.md +34 -64
  160. package/content/references/utilities/performance.md +33 -58
  161. package/content/references/utilities/promise.md +28 -62
  162. package/content/references/utilities/request.md +58 -218
  163. package/content/references/utilities/retry.md +139 -0
  164. package/content/references/utilities/schema.md +43 -137
  165. package/content/references/utilities/statuses-reference.md +361 -0
  166. package/content/references/utilities/statuses.md +63 -667
  167. package/dist/mcp-server/index.js +0 -0
  168. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts +1 -1
  169. package/dist/mcp-server/tools/github/list-project-files.tool.d.ts.map +1 -1
  170. package/dist/mcp-server/tools/github/list-project-files.tool.js +3 -3
  171. package/dist/mcp-server/tools/github/search-code.tool.js +1 -1
  172. package/dist/mcp-server/tools/github/verify-dependencies.tool.js +1 -1
  173. package/dist/mcp-server/tools/github/view-source-file.tool.js +1 -1
  174. 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, but documents replace Drizzle tables and the query surface is intentionally narrower - search engines are not relational databases.
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** - 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`:
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 (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.
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); any provider field not modeled is passed through unchanged. Never hardcode `apiKey` into a committed schema - source it from an environment variable.
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 - 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:
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 - 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.
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); `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 |
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` (`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 }`.
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 - you never juggle every parameter at once. `mode` defaults to `keyword`.
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, and remote embedders (OpenAI/Google/...) reject it (`Prefix search is not supported for remote embedders`). Pass `engineParams: { prefix: true }` to override.
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: 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.
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 - the neutral input schema no longer carries knobs only one engine understands:
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 - 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.
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`) 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.
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, messageCode 'core.not_supported'
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: calling `beginTransaction()` on it throws a `501 Not Implemented` (`messageCode: 'core.not_supported'`) via the shared `throwNotSupported` utility. See [Connectors](/references/base/connectors) for the capabilities model.
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, because it may still hold an open
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: calling
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 - nothing was committed and the connection is already destroyed, so
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
- > but postgres-js has no destroy semantics (`ReservedSql.release()` takes no argument), so under the
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, with one exception: `rollback()` after the transaction ended BY FAILURE is a silent no-op (see the warning above).
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, so a bare `await tx.rollback()` inside a `catch` would discard the error that sent you there. Wrap it in its own `try...catch`, log the rollback failure, and rethrow the original cause.
99
- 2. **Keep it short**: Long-running transactions hold database connections from the pool and can cause connection exhaustion.
100
- 3. **Pass explicit options**: When calling other services inside a transaction, ensure they accept and use the `transaction` option.
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` (a Drizzle instance bound to the transaction's dedicated connection) instead of the default datasource connector. This ensures all operations within the transaction use the same database connection and see a consistent view of the data.
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)