@deorta-dev/nestjs-repository-core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.es.md +260 -0
  3. package/README.md +259 -0
  4. package/dist/base-repository.service.d.ts +73 -0
  5. package/dist/base-repository.service.d.ts.map +1 -0
  6. package/dist/base-repository.service.js +309 -0
  7. package/dist/base-repository.service.js.map +1 -0
  8. package/dist/decorators/index.d.ts +2 -0
  9. package/dist/decorators/index.d.ts.map +1 -0
  10. package/dist/decorators/index.js +18 -0
  11. package/dist/decorators/index.js.map +1 -0
  12. package/dist/decorators/repository-inject.decorator.d.ts +20 -0
  13. package/dist/decorators/repository-inject.decorator.d.ts.map +1 -0
  14. package/dist/decorators/repository-inject.decorator.js +29 -0
  15. package/dist/decorators/repository-inject.decorator.js.map +1 -0
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +23 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/repository.module.d.ts +13 -0
  21. package/dist/repository.module.d.ts.map +1 -0
  22. package/dist/repository.module.js +92 -0
  23. package/dist/repository.module.js.map +1 -0
  24. package/dist/schema/index.d.ts +4 -0
  25. package/dist/schema/index.d.ts.map +1 -0
  26. package/dist/schema/index.js +20 -0
  27. package/dist/schema/index.js.map +1 -0
  28. package/dist/schema/sync-checkpoint.schema.d.ts +22 -0
  29. package/dist/schema/sync-checkpoint.schema.d.ts.map +1 -0
  30. package/dist/schema/sync-checkpoint.schema.js +13 -0
  31. package/dist/schema/sync-checkpoint.schema.js.map +1 -0
  32. package/dist/schema/tombstone.schema.d.ts +24 -0
  33. package/dist/schema/tombstone.schema.d.ts.map +1 -0
  34. package/dist/schema/tombstone.schema.js +15 -0
  35. package/dist/schema/tombstone.schema.js.map +1 -0
  36. package/dist/schema/with-cache-ttl.d.ts +14 -0
  37. package/dist/schema/with-cache-ttl.d.ts.map +1 -0
  38. package/dist/schema/with-cache-ttl.js +24 -0
  39. package/dist/schema/with-cache-ttl.js.map +1 -0
  40. package/dist/sync/backup-sync.service.d.ts +61 -0
  41. package/dist/sync/backup-sync.service.d.ts.map +1 -0
  42. package/dist/sync/backup-sync.service.js +156 -0
  43. package/dist/sync/backup-sync.service.js.map +1 -0
  44. package/dist/sync/index.d.ts +2 -0
  45. package/dist/sync/index.d.ts.map +1 -0
  46. package/dist/sync/index.js +18 -0
  47. package/dist/sync/index.js.map +1 -0
  48. package/dist/types.d.ts +122 -0
  49. package/dist/types.d.ts.map +1 -0
  50. package/dist/types.js +3 -0
  51. package/dist/types.js.map +1 -0
  52. package/dist/utils/index.d.ts +3 -0
  53. package/dist/utils/index.d.ts.map +1 -0
  54. package/dist/utils/index.js +19 -0
  55. package/dist/utils/index.js.map +1 -0
  56. package/dist/utils/pending-ops-queue.d.ts +44 -0
  57. package/dist/utils/pending-ops-queue.d.ts.map +1 -0
  58. package/dist/utils/pending-ops-queue.js +82 -0
  59. package/dist/utils/pending-ops-queue.js.map +1 -0
  60. package/dist/utils/repository-token.util.d.ts +7 -0
  61. package/dist/utils/repository-token.util.d.ts.map +1 -0
  62. package/dist/utils/repository-token.util.js +21 -0
  63. package/dist/utils/repository-token.util.js.map +1 -0
  64. package/package.json +45 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.es.md ADDED
@@ -0,0 +1,260 @@
1
+ **Idioma:** Español · [English](./README.md)
2
+
3
+ # @deorta-dev/nestjs-repository-core
4
+
5
+ Librería para NestJS + Mongoose que genera, para cualquier entidad, un
6
+ servicio de repositorio genérico (`BaseRepositoryService<T>`) con caché
7
+ read-through y réplicas de respaldo (backups), **sin tener que crear una
8
+ clase `XxxOrmService` / `XxxOrmModule` por cada entidad**.
9
+
10
+ Reemplaza el patrón de `PositionOrmService` + `PositionOrmModule` por entidad
11
+ con una sola llamada a `RepositoryOrmModule.register(...)`.
12
+
13
+ Compilado y verificado con `tsc --strict` contra `@nestjs/common`,
14
+ `@nestjs/mongoose`, `mongoose` y `class-transformer`.
15
+
16
+ ## Instalación
17
+
18
+ ```bash
19
+ npm install @deorta-dev/nestjs-repository-core
20
+ ```
21
+
22
+ Necesitas tener instalados (son `peerDependencies`, no se instalan solos):
23
+
24
+ ```bash
25
+ npm install @nestjs/common @nestjs/mongoose mongoose class-transformer reflect-metadata rxjs
26
+ ```
27
+
28
+ ## Uso básico
29
+
30
+ ```ts
31
+ import { RepositoryOrmModule, RepositoryInject, IBaseRepositoryService } from '@deorta-dev/nestjs-repository-core';
32
+
33
+ // position-repository.module.ts
34
+ export const PositionRepositoryModule = RepositoryOrmModule.register({
35
+ entity: Position,
36
+ schema: positionSchema,
37
+ connectionName: ConnectionNames.OPERATION_MDB,
38
+ });
39
+
40
+ // en cualquier @Module:
41
+ @Module({ imports: [PositionRepositoryModule] })
42
+ export class SomeModule {}
43
+
44
+ // en cualquier servicio: inyecta contra la INTERFAZ, no contra la clase concreta
45
+ // (así, si luego cambias a un customService, no tienes que tocar nada aquí).
46
+ constructor(
47
+ @RepositoryInject(PositionRepositoryModule)
48
+ private readonly positionRepository: IBaseRepositoryService<Position>,
49
+ ) {}
50
+ ```
51
+
52
+ El token de inyección siempre es `${Entidad.name}RepositoryService` (ej.
53
+ `PositionRepositoryService`), así que no importa cuántas veces llames
54
+ `.register()` para la misma entidad: el token es consistente y
55
+ `@RepositoryInject(loQueSeaQueDevolvióRegister)` siempre apunta al mismo
56
+ provider.
57
+
58
+ Ver `src/examples/position-repository.example.ts` para el ejemplo completo
59
+ migrando `Position`.
60
+
61
+ ## API de `BaseRepositoryService<T>` (contrato `IBaseRepositoryService<T>`)
62
+
63
+ | Método | Qué hace |
64
+ |---|---|
65
+ | `findOne(filter, opts?)` | Por defecto: caché primero, si no encuentra cae a `main` y repuebla caché. `opts.target = 'main' \| 'cache'` para forzar una conexión específica (sin fallback). |
66
+ | `find(filter, opts?)` | Igual que `findOne` pero lista. Soporta `opts.sort/limit/skip/projection`. |
67
+ | `create(dto)` | Inserta en `main`; el documento resultante (con su `_id`) se replica en caché y en todos los backups. |
68
+ | `insertMany(dtos[])` | Igual que `create` pero en bulk (`insertMany` + `bulkWrite` hacia caché/backups). |
69
+ | `updateOne(filter, update)` | Actualiza `main` primero, luego replica el documento resultante en caché y backups. |
70
+ | `updateMany(filter, update)` | Igual en bulk. |
71
+ | `deleteOne(filter)` / `deleteMany(filter)` | Borra en `main` primero, luego en caché y backups, y registra un "tombstone" para que la sincronización periódica también lo sepa. |
72
+
73
+ ## Resiliencia: ¿qué pasa si caché o backups están caídos?
74
+
75
+ **Si `main` funciona, el servicio funciona**, sin importar el estado de
76
+ `cache` o de los `backups`.
77
+
78
+ - **Lecturas (`findOne`/`find`)**: si la conexión de caché no está lista o la
79
+ consulta falla, se trata como "cache miss" y se consulta `main`
80
+ directamente — nunca se propaga el error.
81
+ - **Escrituras (`create`/`insertMany`/`updateOne`/`updateMany`/`deleteOne`/`deleteMany`)**:
82
+ siempre se ejecutan en `main` primero. La propagación hacia `cache` y cada
83
+ `backup` se intenta de inmediato; si una conexión no está lista
84
+ (`readyState !== 1`) o la operación falla, **esa operación queda pendiente
85
+ en una cola en memoria** (una por conexión secundaria) en vez de hacer
86
+ fallar la operación completa.
87
+ - La cola de pendientes se reintenta sola:
88
+ - Cada `pendingOps.retryIntervalMs` (default 5000 ms).
89
+ - Apenas la conexión emite el evento `connected` de mongoose (reacción
90
+ inmediata, no espera al próximo tick).
91
+ - Si se acumulan más de `pendingOps.maxQueueSize` operaciones (default
92
+ 1000) porque una conexión estuvo caída mucho tiempo, se descartan las
93
+ más antiguas para no consumir memoria indefinidamente — eso está bien
94
+ porque, para backups, `BackupSyncService` los pone al día de todos
95
+ modos comparando contra `main`; y para caché, el próximo `find`/`findOne`
96
+ simplemente la repuebla.
97
+ - Las operaciones encoladas son siempre upserts/deletes por `_id`
98
+ (idempotentes), así que reintentarlas en orden, incluso varias veces, es
99
+ seguro.
100
+ - **Cada backup es independiente**: si tienes dos backups y uno está caído,
101
+ el otro sigue avanzando con su propio checkpoint; el caído se pondrá al
102
+ día solo cuando vuelva (no hay un checkpoint compartido que se bloquee por
103
+ una sola conexión problemática).
104
+ - Los tombstones (usados para propagar deletes a los backups) solo se borran
105
+ de la colección una vez que **todos** los backups configurados ya los
106
+ aplicaron — así uno que estuvo caído no se queda sin la información que
107
+ necesita para ponerse al día.
108
+
109
+ ```ts
110
+ RepositoryOrmModule.register({
111
+ // ...
112
+ pendingOps: {
113
+ retryIntervalMs: 5000, // cada cuánto se reintentan las operaciones pendientes
114
+ maxQueueSize: 1000, // tope en memoria por conexión secundaria
115
+ },
116
+ });
117
+ ```
118
+
119
+ ## Servicio personalizado (`customService`)
120
+
121
+ Por defecto, `register(...)` usa `BaseRepositoryService`. Si necesitas una
122
+ lógica distinta para una entidad en particular, puedes pasar tu propia clase
123
+ en `customService`:
124
+
125
+ ```ts
126
+ RepositoryOrmModule.register({
127
+ entity: Position,
128
+ schema: positionSchema,
129
+ connectionName: ConnectionNames.OPERATION_MDB,
130
+ customService: PositionRepositoryService, // tu clase
131
+ });
132
+ ```
133
+
134
+ `customService` está tipado como `Type<IBaseRepositoryService<T>>`, así que
135
+ **TypeScript no te deja asignar ahí una clase que no cumpla la interfaz**
136
+ (`findOne`, `find`, `create`, `insertMany`, `updateOne`, `updateMany`,
137
+ `deleteOne`, `deleteMany`, con las firmas exactas de `IBaseRepositoryService<T>`).
138
+
139
+ Dos formas de escribirla (ambas en `src/examples/custom-repository-service.example.ts`):
140
+
141
+ 1. **Extender `BaseRepositoryService<T>`** (recomendado): heredas toda la
142
+ resiliencia de caché/backups y solo sobreescribes el método que te
143
+ interese, llamando `super.metodo(...)` si quieres conservar el
144
+ comportamiento original.
145
+
146
+ ```ts
147
+ class PositionRepositoryService extends BaseRepositoryService<Position> {
148
+ async create(dto: Partial<Position>) {
149
+ const created = await super.create(dto);
150
+ console.log('Position creada:', created);
151
+ return created;
152
+ }
153
+ }
154
+ ```
155
+
156
+ 2. **Implementar `IBaseRepositoryService<T>` desde cero**: útil si quieres
157
+ una estrategia totalmente distinta (ej. ignorar caché/backups). El
158
+ constructor que recibe debe aceptar los mismos 9 parámetros que
159
+ `register(...)` ya resuelve por ti: `entity, options, mainModel,
160
+ cacheModel, cacheConfig, backupModels, backupLabels, tombstoneModel,
161
+ pendingOpsConfig` (aunque no los uses todos).
162
+
163
+ Sea cualquiera de las dos, inyectas tu servicio personalizado exactamente
164
+ igual que el default, con `@RepositoryInject(...)` — no cambia nada en el
165
+ resto de tu código, porque ambos cumplen `IBaseRepositoryService<T>`.
166
+
167
+ ## Configuración de `RepositoryOrmModule.register(...)`
168
+
169
+ ```ts
170
+ {
171
+ entity: Position, // clase de la entidad
172
+ schema: positionSchema, // schema de mongoose
173
+ connectionName: '...', // conexión principal
174
+ options: {}, // tus BaseOrmOptions actuales
175
+
176
+ cache: { // OPCIONAL
177
+ connectionName: '...',
178
+ ttlSeconds: 300, // TTL del documento en la conexión de caché
179
+ },
180
+
181
+ backups: [ // OPCIONAL, array de conexiones de solo-escritura
182
+ { connectionName: '...' },
183
+ { connectionName: '...' },
184
+ ],
185
+
186
+ backupSync: { // OPCIONAL, solo aplica si hay `backups`
187
+ enabled: true, // si es false, nadie sincroniza automáticamente
188
+ intervalMs: 60_000, // cada cuánto se revisa main vs backups
189
+ runOnStart: true, // corre una verificación apenas arranca el módulo
190
+ batchSize: 500, // documentos por lote en cada verificación
191
+ },
192
+
193
+ pendingOps: { // OPCIONAL
194
+ retryIntervalMs: 5000,
195
+ maxQueueSize: 1000,
196
+ },
197
+
198
+ customService: PositionRepositoryService, // OPCIONAL, default: BaseRepositoryService
199
+ }
200
+ ```
201
+
202
+ ## Notas de diseño
203
+
204
+ Algunas decisiones de implementación que vale la pena conocer si vas a
205
+ extender la librería:
206
+
207
+ 1. **Caché vs. main en lecturas**: `findOne`/`find` sin `target` explícito
208
+ consultan primero caché y si no hay nada caen a `main` (y repueblan la
209
+ caché en segundo plano, sin bloquear la respuesta). Con `target: 'main'`
210
+ o `target: 'cache'` consultan *solo* esa conexión, sin fallback.
211
+
212
+ 2. **TTL de caché**: usa un índice TTL "a fecha exacta"
213
+ (`expireAfterSeconds: 0` sobre un campo `_cacheExpiresAt`) en vez del TTL
214
+ clásico de Mongo, porque así cada escritura define su propio vencimiento
215
+ según `cache.ttlSeconds`, sin depender de cuándo se creó el índice.
216
+
217
+ 3. **Cómo se detecta qué le falta a un backup**: para inserts/updates se
218
+ compara `updatedTime` contra un checkpoint guardado por entidad (asume
219
+ que tu modelo mantiene `updatedTime` actualizado en cada escritura). Para
220
+ deletes, en vez de comparar todo el set de `_id` (caro a escala),
221
+ `deleteOne`/`deleteMany` registran un "tombstone" (`_id` + fecha de
222
+ borrado) en la conexión `main`, y el sync lo consume y lo borra.
223
+ > Si tus "deletes" en realidad son soft-deletes (un flag como
224
+ > `trashed: true`), el mecanismo de tombstones simplemente no se usa —
225
+ > la sincronización por `updatedTime` ya es suficiente, porque marcar
226
+ > `trashed: true` también actualiza `updatedTime`.
227
+
228
+ 4. **Disparo de la sincronización de backups**: por defecto, si
229
+ `backupSync.enabled` es `true`, el propio servicio arranca un
230
+ `setInterval` interno (`onModuleInit`/`onModuleDestroy`). Si prefieres que
231
+ un proceso externo de bajo esfuerzo decida cuándo sincronizar, deja
232
+ `enabled: false` y llama tú mismo al método público `syncNow()` del
233
+ `BackupSyncService` (expuesto como provider `${Entidad}BackupSyncService`)
234
+ desde donde quieras (cron externo, endpoint, etc.).
235
+
236
+ 5. **`BaseOrmOptions`** se deja intencionalmente abierta
237
+ (`{ [key: string]: any }`) para que la librería no dependa de la forma
238
+ exacta de opciones de ningún proyecto en particular. Si quieres tipado
239
+ estricto, define tu propia interfaz y úsala en su lugar.
240
+
241
+ 6. **Superficie de CRUD**: `findOne`/`find`, `create`/`insertMany`,
242
+ `updateOne`/`updateMany`, `deleteOne`/`deleteMany` cubren las operaciones
243
+ más comunes. Si necesitas más (`count`, `exists`, `aggregate`,
244
+ paginación, etc.), agrégalas a `IBaseRepositoryService`/
245
+ `BaseRepositoryService` siguiendo el mismo patrón de propagación a
246
+ caché/backups, o impleméntalas en un `customService`.
247
+
248
+ ## Limitación conocida
249
+
250
+ En `updateMany`, para propagar a caché/backups se vuelve a consultar `main`
251
+ con el mismo `filter` original. Si el `update` cambia campos que forman
252
+ parte de ese `filter` (ej. `updateMany({ status: 'pending' }, { status:
253
+ 'done' })`), esos documentos ya no harán match y no se propagarán
254
+ correctamente. Si esto te afecta, la alternativa es capturar los `_id`
255
+ afectados *antes* de actualizar — puedes hacerlo sobreescribiendo
256
+ `updateMany` en un `customService`.
257
+
258
+ ## Licencia
259
+
260
+ MIT
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ **Language:** English · [Español](./README.es.md)
2
+
3
+ # @deorta-dev/nestjs-repository-core
4
+
5
+ A NestJS + Mongoose library that generates a generic repository service
6
+ (`BaseRepositoryService<T>`) for any entity — with read-through caching and
7
+ write-only backup replicas — **without having to write an `XxxOrmService` /
8
+ `XxxOrmModule` class per entity**.
9
+
10
+ Replaces the per-entity `PositionOrmService` + `PositionOrmModule` pattern
11
+ with a single `RepositoryOrmModule.register(...)` call.
12
+
13
+ Built and verified with `tsc --strict` against `@nestjs/common`,
14
+ `@nestjs/mongoose`, `mongoose` and `class-transformer`.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @deorta-dev/nestjs-repository-core
20
+ ```
21
+
22
+ You also need these installed (they're `peerDependencies`, not installed
23
+ automatically):
24
+
25
+ ```bash
26
+ npm install @nestjs/common @nestjs/mongoose mongoose class-transformer reflect-metadata rxjs
27
+ ```
28
+
29
+ ## Basic usage
30
+
31
+ ```ts
32
+ import { RepositoryOrmModule, RepositoryInject, IBaseRepositoryService } from '@deorta-dev/nestjs-repository-core';
33
+
34
+ // position-repository.module.ts
35
+ export const PositionRepositoryModule = RepositoryOrmModule.register({
36
+ entity: Position,
37
+ schema: positionSchema,
38
+ connectionName: ConnectionNames.OPERATION_MDB,
39
+ });
40
+
41
+ // in any @Module:
42
+ @Module({ imports: [PositionRepositoryModule] })
43
+ export class SomeModule {}
44
+
45
+ // in any service: inject against the INTERFACE, not the concrete class
46
+ // (so swapping in a customService later doesn't require touching this).
47
+ constructor(
48
+ @RepositoryInject(PositionRepositoryModule)
49
+ private readonly positionRepository: IBaseRepositoryService<Position>,
50
+ ) {}
51
+ ```
52
+
53
+ The injection token is always `${Entity.name}RepositoryService` (e.g.
54
+ `PositionRepositoryService`), so it doesn't matter how many times you call
55
+ `.register()` for the same entity — the token is consistent, and
56
+ `@RepositoryInject(whateverRegisterReturned)` always resolves to the same
57
+ provider.
58
+
59
+ See `src/examples/position-repository.example.ts` for a complete migration
60
+ example using `Position`.
61
+
62
+ ## `BaseRepositoryService<T>` API (the `IBaseRepositoryService<T>` contract)
63
+
64
+ | Method | What it does |
65
+ |---|---|
66
+ | `findOne(filter, opts?)` | Cache-first by default; falls back to `main` on a miss. `opts.target = 'main' \| 'cache'` forces a specific connection (no fallback). |
67
+ | `find(filter, opts?)` | Same as `findOne` but returns a list. Supports `opts.sort/limit/skip/projection`. |
68
+ | `create(dto)` | Inserts into `main`; the resulting document (with its `_id`) is replicated to cache and all backups. |
69
+ | `insertMany(dtos[])` | Same as `create`, in bulk (`insertMany` + `bulkWrite` against cache/backups). |
70
+ | `updateOne(filter, update)` | Updates `main` first, then replicates the resulting document to cache and backups. |
71
+ | `updateMany(filter, update)` | Same, in bulk. |
72
+ | `deleteOne(filter)` / `deleteMany(filter)` | Deletes from `main` first, then from cache and backups, and records a "tombstone" so periodic sync knows about it too. |
73
+
74
+ ## Resilience: what happens if cache or backups are down?
75
+
76
+ **As long as `main` is up, the service works** — regardless of the state of
77
+ `cache` or any `backup` connection.
78
+
79
+ - **Reads (`findOne`/`find`)**: if the cache connection isn't ready or the
80
+ query fails, it's treated as a cache miss and `main` is queried directly —
81
+ the error never propagates.
82
+ - **Writes (`create`/`insertMany`/`updateOne`/`updateMany`/`deleteOne`/`deleteMany`)**:
83
+ always run against `main` first. Propagation to `cache` and each `backup`
84
+ is attempted immediately; if a connection isn't ready (`readyState !== 1`)
85
+ or the operation fails, **that write is queued in memory** (one queue per
86
+ secondary connection) instead of failing the whole operation.
87
+ - The pending-ops queue retries itself:
88
+ - Every `pendingOps.retryIntervalMs` (default 5000 ms).
89
+ - As soon as the connection emits mongoose's `connected` event (immediate
90
+ reaction, no need to wait for the next tick).
91
+ - If more than `pendingOps.maxQueueSize` operations pile up (default
92
+ 1000) because a connection has been down for a while, the oldest ones
93
+ are dropped to avoid unbounded memory growth — that's fine, because
94
+ `BackupSyncService` catches backups up against `main` anyway, and for
95
+ cache, the next `find`/`findOne` simply repopulates it.
96
+ - Queued operations are always `_id`-based upserts/deletes (idempotent), so
97
+ retrying them in order, even multiple times, is safe.
98
+ - **Each backup is independent**: if you have two backups and one is down,
99
+ the other keeps advancing with its own checkpoint; the one that was down
100
+ catches up on its own once it's back (there's no shared checkpoint that
101
+ one problematic connection can block).
102
+ - Tombstones (used to propagate deletes to backups) are only purged from
103
+ the collection once **every** configured backup has already applied them
104
+ — so a backup that was down doesn't lose the information it needs to
105
+ catch up.
106
+
107
+ ```ts
108
+ RepositoryOrmModule.register({
109
+ // ...
110
+ pendingOps: {
111
+ retryIntervalMs: 5000, // how often pending writes are retried
112
+ maxQueueSize: 1000, // in-memory cap per secondary connection
113
+ },
114
+ });
115
+ ```
116
+
117
+ ## Custom service (`customService`)
118
+
119
+ By default, `register(...)` uses `BaseRepositoryService`. If you need
120
+ different behavior for a particular entity, you can pass your own class via
121
+ `customService`:
122
+
123
+ ```ts
124
+ RepositoryOrmModule.register({
125
+ entity: Position,
126
+ schema: positionSchema,
127
+ connectionName: ConnectionNames.OPERATION_MDB,
128
+ customService: PositionRepositoryService, // your class
129
+ });
130
+ ```
131
+
132
+ `customService` is typed as `Type<IBaseRepositoryService<T>>`, so
133
+ **TypeScript won't let you assign a class that doesn't satisfy the
134
+ interface** (`findOne`, `find`, `create`, `insertMany`, `updateOne`,
135
+ `updateMany`, `deleteOne`, `deleteMany`, matching the exact
136
+ `IBaseRepositoryService<T>` signatures).
137
+
138
+ Two ways to write one (both shown in
139
+ `src/examples/custom-repository-service.example.ts`):
140
+
141
+ 1. **Extend `BaseRepositoryService<T>`** (recommended): you inherit all the
142
+ cache/backup resilience and only override the method(s) you care about,
143
+ calling `super.method(...)` if you want to keep the original behavior.
144
+
145
+ ```ts
146
+ class PositionRepositoryService extends BaseRepositoryService<Position> {
147
+ async create(dto: Partial<Position>) {
148
+ const created = await super.create(dto);
149
+ console.log('Position created:', created);
150
+ return created;
151
+ }
152
+ }
153
+ ```
154
+
155
+ 2. **Implement `IBaseRepositoryService<T>` from scratch**: useful if you
156
+ want a completely different strategy (e.g. skip cache/backups
157
+ entirely). Its constructor must accept the same 9 parameters that
158
+ `register(...)` already resolves for you: `entity, options, mainModel,
159
+ cacheModel, cacheConfig, backupModels, backupLabels, tombstoneModel,
160
+ pendingOpsConfig` (even if you don't use all of them).
161
+
162
+ Either way, you inject your custom service exactly like the default one,
163
+ with `@RepositoryInject(...)` — nothing else in your code changes, because
164
+ both satisfy `IBaseRepositoryService<T>`.
165
+
166
+ ## `RepositoryOrmModule.register(...)` configuration reference
167
+
168
+ ```ts
169
+ {
170
+ entity: Position, // entity class
171
+ schema: positionSchema, // mongoose schema
172
+ connectionName: '...', // main connection
173
+ options: {}, // your BaseOrmOptions
174
+
175
+ cache: { // OPTIONAL
176
+ connectionName: '...',
177
+ ttlSeconds: 300, // how long a document lives in the cache connection
178
+ },
179
+
180
+ backups: [ // OPTIONAL, array of write-only connections
181
+ { connectionName: '...' },
182
+ { connectionName: '...' },
183
+ ],
184
+
185
+ backupSync: { // OPTIONAL, only applies if `backups` is set
186
+ enabled: true, // if false, nothing syncs automatically
187
+ intervalMs: 60_000, // how often main vs. backups is checked
188
+ runOnStart: true, // run a check as soon as the module starts
189
+ batchSize: 500, // documents per batch per check
190
+ },
191
+
192
+ pendingOps: { // OPTIONAL
193
+ retryIntervalMs: 5000,
194
+ maxQueueSize: 1000,
195
+ },
196
+
197
+ customService: PositionRepositoryService, // OPTIONAL, default: BaseRepositoryService
198
+ }
199
+ ```
200
+
201
+ ## Design notes
202
+
203
+ A few implementation choices worth knowing about if you're extending this
204
+ library:
205
+
206
+ 1. **Cache vs. main on reads**: `findOne`/`find` without an explicit
207
+ `target` query cache first and fall back to `main` on a miss (and
208
+ repopulate cache in the background, without blocking the response).
209
+ With `target: 'main'` or `target: 'cache'`, only that connection is
210
+ queried, with no fallback.
211
+
212
+ 2. **Cache TTL**: uses an "expire at a specific time" TTL index
213
+ (`expireAfterSeconds: 0` on a `_cacheExpiresAt` field) instead of
214
+ classic Mongo TTL, so every write can set its own expiration based on
215
+ `cache.ttlSeconds`, independent of when the index was created.
216
+
217
+ 3. **How backup catch-up is detected**: for inserts/updates, `updatedTime`
218
+ is compared against a per-entity, per-backup checkpoint (this assumes
219
+ your model keeps `updatedTime` current on every write). For deletes,
220
+ instead of diffing the entire `_id` set (expensive at scale),
221
+ `deleteOne`/`deleteMany` record a "tombstone" (`_id` + deletion time) on
222
+ the `main` connection, which the sync process consumes and clears.
223
+ > If your "deletes" are actually soft-deletes (a `trashed: true` flag),
224
+ > the tombstone mechanism simply goes unused — `updatedTime`-based sync
225
+ > already covers it, since flipping `trashed` also bumps `updatedTime`.
226
+
227
+ 4. **Triggering backup sync**: by default, if `backupSync.enabled` is
228
+ `true`, the service starts its own internal `setInterval`
229
+ (`onModuleInit`/`onModuleDestroy`). If you'd rather have a lightweight
230
+ external process decide when to sync, set `enabled: false` and call the
231
+ public `syncNow()` method on `BackupSyncService` yourself (exposed as
232
+ the `${Entity}BackupSyncService` provider) from wherever makes sense
233
+ (an external cron, an endpoint, etc.).
234
+
235
+ 5. **`BaseOrmOptions`** is intentionally left open
236
+ (`{ [key: string]: any }`) so the library doesn't depend on any
237
+ particular project's option shape. Define and use your own typed
238
+ interface if you want strict typing.
239
+
240
+ 6. **CRUD surface**: `findOne`/`find`, `create`/`insertMany`,
241
+ `updateOne`/`updateMany`, `deleteOne`/`deleteMany` cover the most common
242
+ operations. If you need more (`count`, `exists`, `aggregate`,
243
+ pagination, etc.), add them to `IBaseRepositoryService`/
244
+ `BaseRepositoryService` following the same cache/backup propagation
245
+ pattern, or implement them in a `customService`.
246
+
247
+ ## Known limitation
248
+
249
+ `updateMany` re-queries `main` with the original `filter` to know which
250
+ documents to propagate to cache/backups. If `update` changes a field that's
251
+ part of `filter` (e.g. `updateMany({ status: 'pending' }, { status: 'done'
252
+ })`), those documents will no longer match and won't be propagated
253
+ correctly. If this affects you, the workaround is to capture the affected
254
+ `_id`s *before* updating — you can do this by overriding `updateMany` in a
255
+ `customService`.
256
+
257
+ ## License
258
+
259
+ MIT
@@ -0,0 +1,73 @@
1
+ import { Logger, OnModuleDestroy, OnModuleInit, Type } from '@nestjs/common';
2
+ import { FilterQuery, Model, UpdateQuery } from 'mongoose';
3
+ import { BaseOrmOptions, CacheConnectionConfig, FindOptions, PendingOpsConfig } from './types';
4
+ import { PendingOpsQueue } from './utils';
5
+ import { Observable } from 'rxjs';
6
+ interface PropagationEntry {
7
+ model: Model<any>;
8
+ queue: PendingOpsQueue;
9
+ label: string;
10
+ op: () => Promise<any>;
11
+ }
12
+ /**
13
+ * Reemplazo genérico de los antiguos `XxxOrmService` (ej. PositionOrmService).
14
+ * Una sola clase sirve para cualquier entidad: la instancia la crea
15
+ * `RepositoryOrmModule.register(...)`, tú nunca extiendes esta clase.
16
+ *
17
+ * Resiliencia: si la conexión de caché o alguna de backup no está lista o
18
+ * falla al escribir, la operación sobre `main` se completa igual y la
19
+ * escritura hacia esa conexión secundaria queda pendiente en una cola en
20
+ * memoria que se reintenta sola (ver `utils/pending-ops-queue.ts`).
21
+ */
22
+ export declare class BaseRepositoryService<T = any> implements OnModuleInit, OnModuleDestroy {
23
+ protected readonly entity: Type<T>;
24
+ protected readonly options: BaseOrmOptions;
25
+ protected readonly mainModel: Model<T>;
26
+ protected readonly cacheModel: Model<T> | undefined;
27
+ protected readonly cacheConfig: CacheConnectionConfig | undefined;
28
+ protected readonly backupModels: Model<T>[];
29
+ protected readonly tombstoneModel: Model<any> | undefined;
30
+ protected readonly logger: Logger;
31
+ protected readonly cacheLabel?: string;
32
+ protected readonly cacheQueue?: PendingOpsQueue;
33
+ protected readonly backupLabels: string[];
34
+ protected readonly backupQueues: PendingOpsQueue[];
35
+ constructor(entity: Type<T>, options: BaseOrmOptions, mainModel: Model<T>, cacheModel: Model<T> | undefined, cacheConfig: CacheConnectionConfig | undefined, backupModels: Model<T>[] | undefined, backupConnectionNames: string[] | undefined, tombstoneModel: Model<any> | undefined, pendingOpsConfig?: PendingOpsConfig);
36
+ onModuleInit(): void;
37
+ onModuleDestroy(): void;
38
+ findOne(filter: FilterQuery<T>, opts?: FindOptions): Observable<T | null>;
39
+ find(filter?: FilterQuery<T>, opts?: FindOptions): Observable<T[]>;
40
+ count(filter?: FilterQuery<T>): Observable<number>;
41
+ aggregate<R = any>(pipeline: any[]): Observable<R[]>;
42
+ create(dto: Partial<T>): Observable<T>;
43
+ insertMany(dtos: Partial<T>[]): Observable<T[]>;
44
+ updateOne(filter: FilterQuery<T>, update: UpdateQuery<T>): Observable<T | null>;
45
+ updateMany(filter: FilterQuery<T>, update: UpdateQuery<T>): Observable<{
46
+ matched: number;
47
+ modified: number;
48
+ }>;
49
+ updateObject(object: any): Observable<T | null>;
50
+ upsertBulk(bulkData: any[]): Observable<any>;
51
+ updateBulk(bulkData: any[]): Observable<any>;
52
+ deleteOne(filter: FilterQuery<T>): Observable<boolean>;
53
+ deleteMany(filter: FilterQuery<T>): Observable<number>;
54
+ protected propagate(entries: PropagationEntry[]): Observable<void>;
55
+ protected guard(model: Model<any>, label: string, op: () => Promise<any>): () => Promise<void>;
56
+ protected buildSingleEntries(doc: any, kind: 'upsert' | 'delete'): PropagationEntry[];
57
+ protected buildBulkEntries(docs: any[], kind: 'upsert' | 'delete'): PropagationEntry[];
58
+ protected tryCacheRead<R>(fn: () => Promise<R>): Observable<R | undefined>;
59
+ protected scheduleWriteToCache(doc: any): void;
60
+ protected withUpdatedTime(update: UpdateQuery<T>): UpdateQuery<T>;
61
+ protected upsertInto(model: Model<T>, doc: any): Promise<void>;
62
+ protected bulkUpsertInto(model: Model<any>, docs: any[], isCache: boolean): Promise<void>;
63
+ protected withCacheExpiry(doc: any): any;
64
+ protected recordTombstone(id: any): Promise<void>;
65
+ protected applyCursorOptions<Q extends {
66
+ sort: Function;
67
+ skip: Function;
68
+ limit: Function;
69
+ }>(query: Q, opts: FindOptions): Q;
70
+ protected toEntity(doc: any): T;
71
+ }
72
+ export {};
73
+ //# sourceMappingURL=base-repository.service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base-repository.service.d.ts","sourceRoot":"","sources":["../src/base-repository.service.ts"],"names":[],"mappings":"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"}