@entifix/mongo 0.7.1 → 0.7.2
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 +4 -4
- package/dist/adapters/make-mongo-repository.d.ts +8 -2
- package/dist/adapters/make-mongo-repository.d.ts.map +1 -1
- package/dist/adapters/make-mongo-repository.js +15 -7
- package/dist/adapters/make-mongo-repository.js.map +1 -1
- package/dist/adapters/mongo-repository-factory.d.ts +17 -0
- package/dist/adapters/mongo-repository-factory.d.ts.map +1 -0
- package/dist/adapters/mongo-repository-factory.js +30 -0
- package/dist/adapters/mongo-repository-factory.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mongo-database/mongo-tenant-directory.d.ts +16 -0
- package/dist/mongo-database/mongo-tenant-directory.d.ts.map +1 -0
- package/dist/mongo-database/mongo-tenant-directory.js +31 -0
- package/dist/mongo-database/mongo-tenant-directory.js.map +1 -0
- package/dist/outbox/relay.d.ts +39 -2
- package/dist/outbox/relay.d.ts.map +1 -1
- package/dist/outbox/relay.js +86 -5
- package/dist/outbox/relay.js.map +1 -1
- package/dist/saga/store.d.ts +12 -1
- package/dist/saga/store.d.ts.map +1 -1
- package/dist/saga/store.js +14 -1
- package/dist/saga/store.js.map +1 -1
- package/dist/transactions/index.d.ts +2 -0
- package/dist/transactions/index.d.ts.map +1 -1
- package/dist/transactions/index.js +2 -0
- package/dist/transactions/index.js.map +1 -1
- package/dist/unit-of-work/command-inbox.d.ts +33 -0
- package/dist/unit-of-work/command-inbox.d.ts.map +1 -0
- package/dist/unit-of-work/command-inbox.js +55 -0
- package/dist/unit-of-work/command-inbox.js.map +1 -0
- package/dist/unit-of-work/mongo-unit-of-work.d.ts +41 -0
- package/dist/unit-of-work/mongo-unit-of-work.d.ts.map +1 -0
- package/dist/unit-of-work/mongo-unit-of-work.js +203 -0
- package/dist/unit-of-work/mongo-unit-of-work.js.map +1 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -12,10 +12,10 @@ pnpm add @entifix/mongo effect
|
|
|
12
12
|
|
|
13
13
|
## Entry points
|
|
14
14
|
|
|
15
|
-
| Import | What it holds
|
|
16
|
-
| ----------------------------- |
|
|
17
|
-
| `@entifix/mongo` | Repository, link resolver, query translation, client and database Layers, tenant resolver, health probe
|
|
18
|
-
| `@entifix/mongo/transactions` |
|
|
15
|
+
| Import | What it holds |
|
|
16
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
17
|
+
| `@entifix/mongo` | Repository (optionally inside a session), repository factories (`MongoRepositoryFactoryLayer`, `MongoTenantRepositoryFactoryLayer`), link resolver, query translation, client and database Layers, tenant resolver and tenant directory, health probe |
|
|
18
|
+
| `@entifix/mongo/transactions` | The unit of work (`makeMongoUnitOfWork`, `MongoUnitOfWorkLayer`) and its command inbox, the transactional outbox and inbox, the relays that drain the outbox (one database, or every organization's) and the inline `drainOutboxNow`, and the saga store (`makeMongoSagaStore`, `MongoSagaStoreLayer`, `mongoSagaStoreLayer({ database })`) |
|
|
19
19
|
|
|
20
20
|
## Optional peers
|
|
21
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { EntityRepository } from '@entifix/business';
|
|
2
2
|
import { EntifixError, Entity, EntityConstructor } from '@entifix/core';
|
|
3
|
-
import type { Db } from 'mongodb';
|
|
3
|
+
import type { ClientSession, Db } from 'mongodb';
|
|
4
4
|
/**
|
|
5
5
|
* Builds an {@link EntityRepository} for a single entity type, backed by a live
|
|
6
6
|
* mongodb {@link Db}. The `db` and `entityConstructor` are captured in closures,
|
|
@@ -13,7 +13,13 @@ import type { Db } from 'mongodb';
|
|
|
13
13
|
* {@link EntifixConnError}.
|
|
14
14
|
*
|
|
15
15
|
* This is the Mongo mirror of the REST `buildEntityRestAdapter*` builders.
|
|
16
|
+
*
|
|
17
|
+
* Pass `session` to make the repository part of a transaction — a unit of
|
|
18
|
+
* work's handle — so its writes commit with the unit and its reads see what the
|
|
19
|
+
* unit has already written.
|
|
16
20
|
*/
|
|
17
|
-
export declare function makeMongoRepository<TEntity extends Entity>(db: Db, entityConstructor: EntityConstructor<TEntity
|
|
21
|
+
export declare function makeMongoRepository<TEntity extends Entity>(db: Db, entityConstructor: EntityConstructor<TEntity>, options?: {
|
|
22
|
+
readonly session?: ClientSession;
|
|
23
|
+
}): EntityRepository;
|
|
18
24
|
export type { EntifixError };
|
|
19
25
|
//# sourceMappingURL=make-mongo-repository.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"make-mongo-repository.d.ts","sourceRoot":"","sources":["../../src/adapters/make-mongo-repository.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAIL,YAAY,EACZ,MAAM,EACN,iBAAiB,EAMlB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"make-mongo-repository.d.ts","sourceRoot":"","sources":["../../src/adapters/make-mongo-repository.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAC1D,OAAO,EAIL,YAAY,EACZ,MAAM,EACN,iBAAiB,EAMlB,MAAM,eAAe,CAAC;AAEvB,OAAO,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAoBjD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,SAAS,MAAM,EACxD,EAAE,EAAE,EAAE,EACN,iBAAiB,EAAE,iBAAiB,CAAC,OAAO,CAAC,EAC7C,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAA;CAAE,GAC7C,gBAAgB,CA0GlB;AAED,YAAY,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -28,8 +28,16 @@ function toEntityId(entityOrId) {
|
|
|
28
28
|
* {@link EntifixConnError}.
|
|
29
29
|
*
|
|
30
30
|
* This is the Mongo mirror of the REST `buildEntityRestAdapter*` builders.
|
|
31
|
-
|
|
31
|
+
*
|
|
32
|
+
* Pass `session` to make the repository part of a transaction — a unit of
|
|
33
|
+
* work's handle — so its writes commit with the unit and its reads see what the
|
|
34
|
+
* unit has already written.
|
|
35
|
+
*/ export function makeMongoRepository(db, entityConstructor, options) {
|
|
32
36
|
const collection = db.collection(collectionName(entityConstructor));
|
|
37
|
+
// Spread into every call, so a repository with no session passes none.
|
|
38
|
+
const inSession = (options == null ? void 0 : options.session) ? {
|
|
39
|
+
session: options.session
|
|
40
|
+
} : {};
|
|
33
41
|
const fail = (message, error, details)=>new EntifixConnError(message, error, _extends({
|
|
34
42
|
entity: entityConstructor.name
|
|
35
43
|
}, details));
|
|
@@ -41,11 +49,11 @@ function toEntityId(entityOrId) {
|
|
|
41
49
|
const pageSize = (_request_pageSize = request.pageSize) != null ? _request_pageSize : 10;
|
|
42
50
|
const skip = (page - 1) * pageSize;
|
|
43
51
|
const docs = yield* Effect.tryPromise({
|
|
44
|
-
try: ()=>collection.find(filter, WITHOUT_MONGO_ID).sort(sort).skip(skip).limit(pageSize).toArray(),
|
|
52
|
+
try: ()=>collection.find(filter, _extends({}, WITHOUT_MONGO_ID, inSession)).sort(sort).skip(skip).limit(pageSize).toArray(),
|
|
45
53
|
catch: (error)=>fail('Failed to load entities from MongoDB', error)
|
|
46
54
|
});
|
|
47
55
|
const total = yield* Effect.tryPromise({
|
|
48
|
-
try: ()=>collection.countDocuments(filter),
|
|
56
|
+
try: ()=>collection.countDocuments(filter, inSession),
|
|
49
57
|
catch: (error)=>fail('Failed to count entities in MongoDB', error)
|
|
50
58
|
});
|
|
51
59
|
const items = yield* deserializeEntityCollection(entityConstructor, docs);
|
|
@@ -59,7 +67,7 @@ function toEntityId(entityOrId) {
|
|
|
59
67
|
const doc = yield* Effect.tryPromise({
|
|
60
68
|
try: ()=>collection.findOne({
|
|
61
69
|
id
|
|
62
|
-
}, WITHOUT_MONGO_ID),
|
|
70
|
+
}, _extends({}, WITHOUT_MONGO_ID, inSession)),
|
|
63
71
|
catch: (error)=>fail('Failed to read entity from MongoDB', error, {
|
|
64
72
|
id
|
|
65
73
|
})
|
|
@@ -87,9 +95,9 @@ function toEntityId(entityOrId) {
|
|
|
87
95
|
id
|
|
88
96
|
}, _extends({}, doc, {
|
|
89
97
|
id
|
|
90
|
-
}), {
|
|
98
|
+
}), _extends({
|
|
91
99
|
upsert: true
|
|
92
|
-
}),
|
|
100
|
+
}, inSession)),
|
|
93
101
|
catch: (error)=>fail('Failed to save entity to MongoDB', error, {
|
|
94
102
|
id
|
|
95
103
|
})
|
|
@@ -102,7 +110,7 @@ function toEntityId(entityOrId) {
|
|
|
102
110
|
yield* Effect.tryPromise({
|
|
103
111
|
try: ()=>collection.deleteOne({
|
|
104
112
|
id
|
|
105
|
-
}),
|
|
113
|
+
}, inSession),
|
|
106
114
|
catch: (error)=>fail('Failed to delete entity from MongoDB', error, {
|
|
107
115
|
id
|
|
108
116
|
})
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/adapters/make-mongo-repository.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { EntityRepository } from '@entifix/business';\nimport {\n deserializeEntityCollection,\n deserializeSingleEntity,\n EntifixConnError,\n EntifixError,\n Entity,\n EntityConstructor,\n EntityId,\n EntityLoadRequest,\n EntityPage,\n extractMetaEntity,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { translateFiltering, translateSorting } from './filter-translator.js';\n\n/** Projection that drops Mongo's internal `_id` from read results. */\nconst WITHOUT_MONGO_ID = { projection: { _id: 0 } } as const;\n\nfunction collectionName<TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n): string {\n const metaEntity = extractMetaEntity(entityConstructor);\n return metaEntity.key ?? metaEntity.name;\n}\n\nfunction toEntityId(entityOrId: EntityId | Entity): EntityId {\n return entityOrId != null && typeof entityOrId === 'object'\n ? (entityOrId as Entity).id\n : (entityOrId as EntityId);\n}\n\n/**\n * Builds an {@link EntityRepository} for a single entity type, backed by a live\n * mongodb {@link Db}. The `db` and `entityConstructor` are captured in closures,\n * so every method's Effect has no outstanding requirements (`R = never`), which\n * is exactly what the interface asks.\n *\n * Collection name = the entity's `key` (falling back to its class name). Reads\n * reuse the shared entifix deserializer (rebuilding links from embedded objects\n * or scalar FKs); writes reuse the shared serializer. Mongo failures surface as\n * {@link EntifixConnError}.\n *\n * This is the Mongo mirror of the REST `buildEntityRestAdapter*` builders.\n */\nexport function makeMongoRepository<TEntity extends Entity>(\n db: Db,\n entityConstructor: EntityConstructor<TEntity>,\n): EntityRepository {\n const collection = db.collection(collectionName(entityConstructor));\n\n const fail = (\n message: string,\n error: unknown,\n details?: Record<string, unknown>,\n ) =>\n new EntifixConnError(message, error, {\n entity: entityConstructor.name,\n ...details,\n });\n\n const load = <T extends Entity>(request: EntityLoadRequest<T>) =>\n Effect.gen(function* () {\n const filter = translateFiltering(request.filtering);\n const sort = translateSorting(request.sorting);\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const skip = (page - 1) * pageSize;\n\n const docs = yield* Effect.tryPromise({\n try: () =>\n collection\n .find(filter, WITHOUT_MONGO_ID)\n .sort(sort)\n .skip(skip)\n .limit(pageSize)\n .toArray(),\n catch: error => fail('Failed to load entities from MongoDB', error),\n });\n const total = yield* Effect.tryPromise({\n try: () => collection.countDocuments(filter),\n catch: error => fail('Failed to count entities in MongoDB', error),\n });\n\n const items = yield* deserializeEntityCollection(entityConstructor, docs);\n\n return {\n items: items as unknown as T[],\n total,\n request,\n } satisfies EntityPage<T>;\n });\n\n const get = <T extends Entity>(id: EntityId) =>\n Effect.gen(function* () {\n const doc = yield* Effect.tryPromise({\n try: ()
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/make-mongo-repository.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\n\nimport type { EntityRepository } from '@entifix/business';\nimport {\n deserializeEntityCollection,\n deserializeSingleEntity,\n EntifixConnError,\n EntifixError,\n Entity,\n EntityConstructor,\n EntityId,\n EntityLoadRequest,\n EntityPage,\n extractMetaEntity,\n serializeEntity,\n} from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { ClientSession, Db } from 'mongodb';\n\nimport { translateFiltering, translateSorting } from './filter-translator.js';\n\n/** Projection that drops Mongo's internal `_id` from read results. */\nconst WITHOUT_MONGO_ID = { projection: { _id: 0 } } as const;\n\nfunction collectionName<TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n): string {\n const metaEntity = extractMetaEntity(entityConstructor);\n return metaEntity.key ?? metaEntity.name;\n}\n\nfunction toEntityId(entityOrId: EntityId | Entity): EntityId {\n return entityOrId != null && typeof entityOrId === 'object'\n ? (entityOrId as Entity).id\n : (entityOrId as EntityId);\n}\n\n/**\n * Builds an {@link EntityRepository} for a single entity type, backed by a live\n * mongodb {@link Db}. The `db` and `entityConstructor` are captured in closures,\n * so every method's Effect has no outstanding requirements (`R = never`), which\n * is exactly what the interface asks.\n *\n * Collection name = the entity's `key` (falling back to its class name). Reads\n * reuse the shared entifix deserializer (rebuilding links from embedded objects\n * or scalar FKs); writes reuse the shared serializer. Mongo failures surface as\n * {@link EntifixConnError}.\n *\n * This is the Mongo mirror of the REST `buildEntityRestAdapter*` builders.\n *\n * Pass `session` to make the repository part of a transaction — a unit of\n * work's handle — so its writes commit with the unit and its reads see what the\n * unit has already written.\n */\nexport function makeMongoRepository<TEntity extends Entity>(\n db: Db,\n entityConstructor: EntityConstructor<TEntity>,\n options?: { readonly session?: ClientSession },\n): EntityRepository {\n const collection = db.collection(collectionName(entityConstructor));\n // Spread into every call, so a repository with no session passes none.\n const inSession = options?.session ? { session: options.session } : {};\n\n const fail = (\n message: string,\n error: unknown,\n details?: Record<string, unknown>,\n ) =>\n new EntifixConnError(message, error, {\n entity: entityConstructor.name,\n ...details,\n });\n\n const load = <T extends Entity>(request: EntityLoadRequest<T>) =>\n Effect.gen(function* () {\n const filter = translateFiltering(request.filtering);\n const sort = translateSorting(request.sorting);\n const page = request.page ?? 1;\n const pageSize = request.pageSize ?? 10;\n const skip = (page - 1) * pageSize;\n\n const docs = yield* Effect.tryPromise({\n try: () =>\n collection\n .find(filter, { ...WITHOUT_MONGO_ID, ...inSession })\n .sort(sort)\n .skip(skip)\n .limit(pageSize)\n .toArray(),\n catch: error => fail('Failed to load entities from MongoDB', error),\n });\n const total = yield* Effect.tryPromise({\n try: () => collection.countDocuments(filter, inSession),\n catch: error => fail('Failed to count entities in MongoDB', error),\n });\n\n const items = yield* deserializeEntityCollection(entityConstructor, docs);\n\n return {\n items: items as unknown as T[],\n total,\n request,\n } satisfies EntityPage<T>;\n });\n\n const get = <T extends Entity>(id: EntityId) =>\n Effect.gen(function* () {\n const doc = yield* Effect.tryPromise({\n try: () =>\n collection.findOne({ id }, { ...WITHOUT_MONGO_ID, ...inSession }),\n catch: error =>\n fail('Failed to read entity from MongoDB', error, { id }),\n });\n const entity = yield* deserializeSingleEntity(entityConstructor, doc);\n if (entity === undefined) {\n return yield* Effect.fail(fail(`Entity not found`, undefined, { id }));\n }\n return entity as unknown as T;\n });\n\n const save = <T extends Entity>(entity: T) =>\n Effect.gen(function* () {\n const doc = serializeEntity(\n entityConstructor,\n entity as unknown as TEntity,\n );\n // A create arrives without an id, so the store mints one. Entities are\n // addressed by their own `id` field (never Mongo's `_id`, which reads\n // project away), so an inserted document without one would be\n // unreachable by `get` and by any later `replaceOne`.\n const id = entity.id ?? randomUUID();\n yield* Effect.tryPromise({\n // `doc` omits `id` when it was undefined (the serializer drops\n // undefined), hence spreading the resolved id back in.\n try: () =>\n collection.replaceOne(\n { id },\n { ...doc, id },\n { upsert: true, ...inSession },\n ),\n catch: error => fail('Failed to save entity to MongoDB', error, { id }),\n });\n entity.id = id;\n return entity;\n });\n\n const del = <T extends Entity>(entityOrId: EntityId | T) =>\n Effect.gen(function* () {\n const id = toEntityId(entityOrId);\n yield* Effect.tryPromise({\n try: () => collection.deleteOne({ id }, inSession),\n catch: error =>\n fail('Failed to delete entity from MongoDB', error, { id }),\n });\n });\n\n // Cast through the interface to keep its generic signatures: each method here\n // is written against this entity's type, the interface against any entity's.\n return {\n load,\n get,\n save,\n delete: del,\n } as unknown as EntityRepository;\n}\n\nexport type { EntifixError };\n"],"names":["randomUUID","deserializeEntityCollection","deserializeSingleEntity","EntifixConnError","extractMetaEntity","serializeEntity","Effect","translateFiltering","translateSorting","WITHOUT_MONGO_ID","projection","_id","collectionName","entityConstructor","metaEntity","key","name","toEntityId","entityOrId","id","makeMongoRepository","db","options","collection","inSession","session","fail","message","error","details","entity","load","request","gen","filter","filtering","sort","sorting","page","pageSize","skip","docs","tryPromise","try","find","limit","toArray","catch","total","countDocuments","items","get","doc","findOne","undefined","save","replaceOne","upsert","del","deleteOne","delete"],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EntityRepositoryFactoryTag, TenantDatabaseResolverTag } from '@entifix/business';
|
|
2
|
+
import { Layer } from 'effect';
|
|
3
|
+
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
4
|
+
/**
|
|
5
|
+
* Repositories over the service's one database, whoever the request is for.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MongoRepositoryFactoryLayer: Layer.Layer<EntityRepositoryFactoryTag, never, MongoDatabaseTag>;
|
|
8
|
+
/**
|
|
9
|
+
* Repositories over the requesting organization's own database, resolved per
|
|
10
|
+
* request through the host's {@link TenantDatabaseResolverTag}.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ A request that names no organization is **refused**, never served from a
|
|
13
|
+
* default: on a per-organization store a default database is some other
|
|
14
|
+
* organization's.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MongoTenantRepositoryFactoryLayer: Layer.Layer<EntityRepositoryFactoryTag, never, TenantDatabaseResolverTag>;
|
|
17
|
+
//# sourceMappingURL=mongo-repository-factory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mongo-repository-factory.d.ts","sourceRoot":"","sources":["../../src/adapters/mongo-repository-factory.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAE1B,yBAAyB,EAC1B,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EAAU,KAAK,EAAE,MAAM,QAAQ,CAAC;AAGvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAGvE;;GAEG;AACH,eAAO,MAAM,2BAA2B,kEASvC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,2EAoB7C,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { EntityRepositoryFactoryTag, TenantDatabaseResolverTag } from '@entifix/business';
|
|
2
|
+
import { EntifixConnError } from '@entifix/core';
|
|
3
|
+
import { Effect, Layer } from 'effect';
|
|
4
|
+
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
|
+
import { makeMongoRepository } from './make-mongo-repository.js';
|
|
6
|
+
/**
|
|
7
|
+
* Repositories over the service's one database, whoever the request is for.
|
|
8
|
+
*/ export const MongoRepositoryFactoryLayer = Layer.effect(EntityRepositoryFactoryTag, Effect.gen(function*() {
|
|
9
|
+
const db = yield* MongoDatabaseTag;
|
|
10
|
+
return {
|
|
11
|
+
for: (entityConstructor)=>Effect.succeed(makeMongoRepository(db, entityConstructor))
|
|
12
|
+
};
|
|
13
|
+
}));
|
|
14
|
+
/**
|
|
15
|
+
* Repositories over the requesting organization's own database, resolved per
|
|
16
|
+
* request through the host's {@link TenantDatabaseResolverTag}.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ A request that names no organization is **refused**, never served from a
|
|
19
|
+
* default: on a per-organization store a default database is some other
|
|
20
|
+
* organization's.
|
|
21
|
+
*/ export const MongoTenantRepositoryFactoryLayer = Layer.effect(EntityRepositoryFactoryTag, Effect.gen(function*() {
|
|
22
|
+
const resolver = yield* TenantDatabaseResolverTag;
|
|
23
|
+
return {
|
|
24
|
+
for: (entityConstructor, scope)=>scope.organizationId === undefined ? Effect.fail(new EntifixConnError('A per-organization store was asked for a repository with no organization', undefined, {
|
|
25
|
+
entity: entityConstructor.name
|
|
26
|
+
})) : Effect.map(resolver.forOrganization(scope.organizationId), (db)=>makeMongoRepository(db, entityConstructor))
|
|
27
|
+
};
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
//# sourceMappingURL=mongo-repository-factory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/adapters/mongo-repository-factory.ts"],"sourcesContent":["import {\n EntityRepositoryFactoryTag,\n type TenantDatabaseResolver,\n TenantDatabaseResolverTag,\n} from '@entifix/business';\nimport { EntifixConnError } from '@entifix/core';\nimport { Effect, Layer } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\nimport { makeMongoRepository } from './make-mongo-repository.js';\n\n/**\n * Repositories over the service's one database, whoever the request is for.\n */\nexport const MongoRepositoryFactoryLayer = Layer.effect(\n EntityRepositoryFactoryTag,\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n return {\n for: entityConstructor =>\n Effect.succeed(makeMongoRepository(db, entityConstructor)),\n };\n }),\n);\n\n/**\n * Repositories over the requesting organization's own database, resolved per\n * request through the host's {@link TenantDatabaseResolverTag}.\n *\n * ⚠️ A request that names no organization is **refused**, never served from a\n * default: on a per-organization store a default database is some other\n * organization's.\n */\nexport const MongoTenantRepositoryFactoryLayer = Layer.effect(\n EntityRepositoryFactoryTag,\n Effect.gen(function* () {\n const resolver =\n (yield* TenantDatabaseResolverTag) as TenantDatabaseResolver<Db>;\n return {\n for: (entityConstructor, scope) =>\n scope.organizationId === undefined\n ? Effect.fail(\n new EntifixConnError(\n 'A per-organization store was asked for a repository with no organization',\n undefined,\n { entity: entityConstructor.name },\n ),\n )\n : Effect.map(resolver.forOrganization(scope.organizationId), db =>\n makeMongoRepository(db, entityConstructor),\n ),\n };\n }),\n);\n"],"names":["EntityRepositoryFactoryTag","TenantDatabaseResolverTag","EntifixConnError","Effect","Layer","MongoDatabaseTag","makeMongoRepository","MongoRepositoryFactoryLayer","effect","gen","db","for","entityConstructor","succeed","MongoTenantRepositoryFactoryLayer","resolver","scope","organizationId","undefined","fail","entity","name","map","forOrganization"],"mappings":"AAAA,SACEA,0BAA0B,EAE1BC,yBAAyB,QACpB,oBAAoB;AAC3B,SAASC,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,gBAAgB,QAAQ,sCAAsC;AACvE,SAASC,mBAAmB,QAAQ,6BAA6B;AAEjE;;CAEC,GACD,OAAO,MAAMC,8BAA8BH,MAAMI,MAAM,CACrDR,4BACAG,OAAOM,GAAG,CAAC;IACT,MAAMC,KAAK,OAAOL;IAClB,OAAO;QACLM,KAAKC,CAAAA,oBACHT,OAAOU,OAAO,CAACP,oBAAoBI,IAAIE;IAC3C;AACF,IACA;AAEF;;;;;;;CAOC,GACD,OAAO,MAAME,oCAAoCV,MAAMI,MAAM,CAC3DR,4BACAG,OAAOM,GAAG,CAAC;IACT,MAAMM,WACH,OAAOd;IACV,OAAO;QACLU,KAAK,CAACC,mBAAmBI,QACvBA,MAAMC,cAAc,KAAKC,YACrBf,OAAOgB,IAAI,CACT,IAAIjB,iBACF,4EACAgB,WACA;gBAAEE,QAAQR,kBAAkBS,IAAI;YAAC,MAGrClB,OAAOmB,GAAG,CAACP,SAASQ,eAAe,CAACP,MAAMC,cAAc,GAAGP,CAAAA,KACzDJ,oBAAoBI,IAAIE;IAElC;AACF,IACA"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export * from './adapters/filter-translator.js';
|
|
2
2
|
export * from './adapters/make-mongo-link-resolver.js';
|
|
3
3
|
export * from './adapters/make-mongo-repository.js';
|
|
4
|
+
export * from './adapters/mongo-repository-factory.js';
|
|
4
5
|
export * from './mongo-database/mongo-database.js';
|
|
5
6
|
export * from './mongo-database/mongo-health-probe.js';
|
|
7
|
+
export * from './mongo-database/mongo-tenant-directory.js';
|
|
6
8
|
export * from './mongo-database/mongo-tenant-resolver.js';
|
|
7
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,wCAAwC,CAAC;AACvD,cAAc,qCAAqC,CAAC;AACpD,cAAc,oCAAoC,CAAC;AACnD,cAAc,wCAAwC,CAAC;AACvD,cAAc,2CAA2C,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,iCAAiC,CAAC;AAChD,cAAc,wCAAwC,CAAC;AACvD,cAAc,qCAAqC,CAAC;AACpD,cAAc,wCAAwC,CAAC;AACvD,cAAc,oCAAoC,CAAC;AACnD,cAAc,wCAAwC,CAAC;AACvD,cAAc,4CAA4C,CAAC;AAC3D,cAAc,2CAA2C,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export * from './adapters/filter-translator.js';
|
|
2
2
|
export * from './adapters/make-mongo-link-resolver.js';
|
|
3
3
|
export * from './adapters/make-mongo-repository.js';
|
|
4
|
+
export * from './adapters/mongo-repository-factory.js';
|
|
4
5
|
export * from './mongo-database/mongo-database.js';
|
|
5
6
|
export * from './mongo-database/mongo-health-probe.js';
|
|
7
|
+
export * from './mongo-database/mongo-tenant-directory.js';
|
|
6
8
|
export * from './mongo-database/mongo-tenant-resolver.js';
|
|
7
9
|
|
|
8
10
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './adapters/filter-translator.js';\nexport * from './adapters/make-mongo-link-resolver.js';\nexport * from './adapters/make-mongo-repository.js';\nexport * from './mongo-database/mongo-database.js';\nexport * from './mongo-database/mongo-health-probe.js';\nexport * from './mongo-database/mongo-tenant-resolver.js';\n"],"names":[],"mappings":"AAAA,cAAc,kCAAkC;AAChD,cAAc,yCAAyC;AACvD,cAAc,sCAAsC;AACpD,cAAc,qCAAqC;AACnD,cAAc,yCAAyC;AACvD,cAAc,4CAA4C"}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './adapters/filter-translator.js';\nexport * from './adapters/make-mongo-link-resolver.js';\nexport * from './adapters/make-mongo-repository.js';\nexport * from './adapters/mongo-repository-factory.js';\nexport * from './mongo-database/mongo-database.js';\nexport * from './mongo-database/mongo-health-probe.js';\nexport * from './mongo-database/mongo-tenant-directory.js';\nexport * from './mongo-database/mongo-tenant-resolver.js';\n"],"names":[],"mappings":"AAAA,cAAc,kCAAkC;AAChD,cAAc,yCAAyC;AACvD,cAAc,sCAAsC;AACpD,cAAc,yCAAyC;AACvD,cAAc,qCAAqC;AACnD,cAAc,yCAAyC;AACvD,cAAc,6CAA6C;AAC3D,cAAc,4CAA4C"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TenantDirectory } from '@entifix/business';
|
|
2
|
+
import type { Db, MongoClient } from 'mongodb';
|
|
3
|
+
/**
|
|
4
|
+
* Lists every database named `<prefix><organizationId>` on one client — the
|
|
5
|
+
* same naming {@link makeMongoTenantResolver} resolves one organization by.
|
|
6
|
+
*
|
|
7
|
+
* `listDatabases` needs cluster-wide rights and a round trip, which is why it
|
|
8
|
+
* is a directory for background work and never a request's path: a request
|
|
9
|
+
* already knows its organization and resolves it directly.
|
|
10
|
+
*
|
|
11
|
+
* Only a database that exists is listed, and Mongo creates one lazily on its
|
|
12
|
+
* first write. An organization that has never written to this store is not
|
|
13
|
+
* here, which is right for every sweep: it has nothing to sweep.
|
|
14
|
+
*/
|
|
15
|
+
export declare const makeMongoTenantDirectory: (client: MongoClient, prefix: string) => TenantDirectory<Db>;
|
|
16
|
+
//# sourceMappingURL=mongo-tenant-directory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mongo-tenant-directory.d.ts","sourceRoot":"","sources":["../../src/mongo-database/mongo-tenant-directory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAGzD,OAAO,KAAK,EAAE,EAAE,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,wBAAwB,GACnC,QAAQ,WAAW,EACnB,QAAQ,MAAM,KACb,eAAe,CAAC,EAAE,CAuBnB,CAAC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
/**
|
|
4
|
+
* Lists every database named `<prefix><organizationId>` on one client — the
|
|
5
|
+
* same naming {@link makeMongoTenantResolver} resolves one organization by.
|
|
6
|
+
*
|
|
7
|
+
* `listDatabases` needs cluster-wide rights and a round trip, which is why it
|
|
8
|
+
* is a directory for background work and never a request's path: a request
|
|
9
|
+
* already knows its organization and resolves it directly.
|
|
10
|
+
*
|
|
11
|
+
* Only a database that exists is listed, and Mongo creates one lazily on its
|
|
12
|
+
* first write. An organization that has never written to this store is not
|
|
13
|
+
* here, which is right for every sweep: it has nothing to sweep.
|
|
14
|
+
*/ export const makeMongoTenantDirectory = (client, prefix)=>({
|
|
15
|
+
list: ()=>Effect.tryPromise({
|
|
16
|
+
try: async ()=>{
|
|
17
|
+
const { databases } = await client.db('admin').admin().listDatabases({
|
|
18
|
+
nameOnly: true
|
|
19
|
+
});
|
|
20
|
+
return databases.map((database)=>database.name).filter((name)=>name.startsWith(prefix) && name.length > prefix.length).sort().map((name)=>({
|
|
21
|
+
organizationId: name.slice(prefix.length),
|
|
22
|
+
handle: client.db(name)
|
|
23
|
+
}));
|
|
24
|
+
},
|
|
25
|
+
catch: (error)=>new EntifixConnError('Failed to list tenant databases', error, {
|
|
26
|
+
prefix
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
//# sourceMappingURL=mongo-tenant-directory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/mongo-database/mongo-tenant-directory.ts"],"sourcesContent":["import type { TenantDirectory } from '@entifix/business';\nimport { EntifixConnError } from '@entifix/core';\nimport { Effect } from 'effect';\nimport type { Db, MongoClient } from 'mongodb';\n\n/**\n * Lists every database named `<prefix><organizationId>` on one client — the\n * same naming {@link makeMongoTenantResolver} resolves one organization by.\n *\n * `listDatabases` needs cluster-wide rights and a round trip, which is why it\n * is a directory for background work and never a request's path: a request\n * already knows its organization and resolves it directly.\n *\n * Only a database that exists is listed, and Mongo creates one lazily on its\n * first write. An organization that has never written to this store is not\n * here, which is right for every sweep: it has nothing to sweep.\n */\nexport const makeMongoTenantDirectory = (\n client: MongoClient,\n prefix: string,\n): TenantDirectory<Db> => ({\n list: () =>\n Effect.tryPromise({\n try: async () => {\n const { databases } = await client.db('admin').admin().listDatabases({\n nameOnly: true,\n });\n return databases\n .map(database => database.name)\n .filter(\n name => name.startsWith(prefix) && name.length > prefix.length,\n )\n .sort()\n .map(name => ({\n organizationId: name.slice(prefix.length),\n handle: client.db(name),\n }));\n },\n catch: error =>\n new EntifixConnError('Failed to list tenant databases', error, {\n prefix,\n }),\n }),\n});\n"],"names":["EntifixConnError","Effect","makeMongoTenantDirectory","client","prefix","list","tryPromise","try","databases","db","admin","listDatabases","nameOnly","map","database","name","filter","startsWith","length","sort","organizationId","slice","handle","catch","error"],"mappings":"AACA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,MAAM,QAAQ,SAAS;AAGhC;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,2BAA2B,CACtCC,QACAC,SACyB,CAAA;QACzBC,MAAM,IACJJ,OAAOK,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAM,EAAEC,SAAS,EAAE,GAAG,MAAML,OAAOM,EAAE,CAAC,SAASC,KAAK,GAAGC,aAAa,CAAC;wBACnEC,UAAU;oBACZ;oBACA,OAAOJ,UACJK,GAAG,CAACC,CAAAA,WAAYA,SAASC,IAAI,EAC7BC,MAAM,CACLD,CAAAA,OAAQA,KAAKE,UAAU,CAACb,WAAWW,KAAKG,MAAM,GAAGd,OAAOc,MAAM,EAE/DC,IAAI,GACJN,GAAG,CAACE,CAAAA,OAAS,CAAA;4BACZK,gBAAgBL,KAAKM,KAAK,CAACjB,OAAOc,MAAM;4BACxCI,QAAQnB,OAAOM,EAAE,CAACM;wBACpB,CAAA;gBACJ;gBACAQ,OAAOC,CAAAA,QACL,IAAIxB,iBAAiB,mCAAmCwB,OAAO;wBAC7DpB;oBACF;YACJ;IACJ,CAAA,EAAG"}
|
package/dist/outbox/relay.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ShutdownRegistryTag } from '@entifix/business';
|
|
1
|
+
import { ShutdownRegistryTag, type TenantDirectory, TenantDirectoryTag } from '@entifix/business';
|
|
2
2
|
import { type EventBus, EventBusTag, type OutboxStats, type TransactionOutbox } from '@entifix/transactions';
|
|
3
3
|
import { Context, Duration, Effect } from 'effect';
|
|
4
|
+
import type { Db } from 'mongodb';
|
|
4
5
|
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
6
|
/** How often the sweep looks for entries the fast path did not carry. */
|
|
6
7
|
export declare const SWEEP_INTERVAL: Duration.Duration;
|
|
@@ -53,7 +54,7 @@ export type OutboxStatsSink = (database: string, stats: OutboxStats) => Effect.E
|
|
|
53
54
|
/**
|
|
54
55
|
* Drain the one database this service owns, then sample its depth.
|
|
55
56
|
*
|
|
56
|
-
* The single-database counterpart to
|
|
57
|
+
* The single-database counterpart to `startTenantOutboxRelay`. A
|
|
57
58
|
* platform-plane store is one named database, so there is nothing to enumerate
|
|
58
59
|
* and `listDatabases` — which needs a `MongoClient` and cluster-wide rights —
|
|
59
60
|
* never comes into it.
|
|
@@ -85,5 +86,41 @@ export declare const sweepOutbox: (outbox: TransactionOutbox, bus: EventBus, opt
|
|
|
85
86
|
export declare const startOutboxRelay: (options?: {
|
|
86
87
|
readonly onStats?: OutboxStatsSink;
|
|
87
88
|
}) => Effect.Effect<void, import("@entifix/core").EntifixConnError, ShutdownRegistryTag | MongoDatabaseTag | EventBusTag | OutboxMaxAttempts>;
|
|
89
|
+
/**
|
|
90
|
+
* One pass over every organization's outbox in a per-organization store.
|
|
91
|
+
*
|
|
92
|
+
* ⚠️ **Each tenant is caught on its own.** A failure that recurs — an index
|
|
93
|
+
* conflict on one old database, say — would otherwise abandon every tenant
|
|
94
|
+
* listed after it on every pass, while the relay went on looking healthy.
|
|
95
|
+
*
|
|
96
|
+
* Indexes are ensured once per database per process (`ensured`), not on every
|
|
97
|
+
* pass: a sweep visits every tenant every interval, and the index build is the
|
|
98
|
+
* same request each time.
|
|
99
|
+
*/
|
|
100
|
+
export declare const sweepTenantOutboxes: (directory: TenantDirectory<Db>, bus: EventBus, options: {
|
|
101
|
+
readonly maxAttempts: number;
|
|
102
|
+
readonly onStats?: OutboxStatsSink;
|
|
103
|
+
readonly ensured?: Set<string>;
|
|
104
|
+
}) => Effect.Effect<void, never, never>;
|
|
105
|
+
/**
|
|
106
|
+
* Start the relay for a store with one database per organization, listed by
|
|
107
|
+
* the {@link TenantDirectoryTag} the host provides.
|
|
108
|
+
*
|
|
109
|
+
* The same two speeds as {@link startOutboxRelay}: the write path drains its
|
|
110
|
+
* own tenant inline with {@link drainOutboxNow}, and this sweep carries
|
|
111
|
+
* whatever that missed.
|
|
112
|
+
*/
|
|
113
|
+
export declare const startTenantOutboxRelay: (options?: {
|
|
114
|
+
readonly onStats?: OutboxStatsSink;
|
|
115
|
+
}) => Effect.Effect<void, never, ShutdownRegistryTag | TenantDirectoryTag | EventBusTag | OutboxMaxAttempts>;
|
|
116
|
+
/**
|
|
117
|
+
* Drain one database's outbox now, for a write path that has just committed.
|
|
118
|
+
*
|
|
119
|
+
* The fast path the sweep is the fallback for: an event drained here leaves as
|
|
120
|
+
* soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}
|
|
121
|
+
* later. Every failure is logged and swallowed — the entry is still in the
|
|
122
|
+
* outbox, and the sweep will carry it.
|
|
123
|
+
*/
|
|
124
|
+
export declare const drainOutboxNow: (db: Db) => Effect.Effect<void, never, EventBusTag | OutboxMaxAttempts>;
|
|
88
125
|
export {};
|
|
89
126
|
//# sourceMappingURL=relay.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/outbox/relay.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../../src/outbox/relay.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,KAAK,eAAe,EACpB,kBAAkB,EACnB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EACL,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAiB,MAAM,QAAQ,CAAC;AAClE,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,OAAO,EAAE,gBAAgB,EAAE,MAAM,qCAAqC,CAAC;AAIvE,yEAAyE;AACzE,eAAO,MAAM,cAAc,mBAAuB,CAAC;AACnD,8CAA8C;AAC9C,eAAO,MAAM,KAAK,MAAM,CAAC;;AAEzB;;;;;;;;;;;;;GAaG;AACH,qBAAa,iBAAkB,SAAQ,sBAGf;CAAG;AAE3B,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,iBAAiB,EACzB,KAAK,QAAQ,EACb,SAAS,YAAY,2EAkCnB,CAAC;AAEL;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,CAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,WAAW,KACf,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;AAEzB;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW,GACtB,QAAQ,iBAAiB,EACzB,KAAK,QAAQ,EACb,SAAS,YAAY,GAAG;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAA;CAAE,sCAe7D,CAAC;AAEJ;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAI,UAAU;IACzC,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;CACpC,4IAgBG,CAAC;AAyBL;;;;;;;;;;GAUG;AACH,eAAO,MAAM,mBAAmB,GAC9B,WAAW,eAAe,CAAC,EAAE,CAAC,EAC9B,KAAK,QAAQ,EACb,SAAS;IACP,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;IACnC,QAAQ,CAAC,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CAChC,sCAgCA,CAAC;AAEJ;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,UAAU;IAC/C,QAAQ,CAAC,OAAO,CAAC,EAAE,eAAe,CAAC;CACpC,2GAcG,CAAC;AAEL;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,IAAI,EAAE,gEAiBlC,CAAC"}
|
package/dist/outbox/relay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { ShutdownRegistryTag } from '@entifix/business';
|
|
2
|
+
import { ShutdownRegistryTag, TenantDirectoryTag } from '@entifix/business';
|
|
3
3
|
import { EventBusTag } from '@entifix/transactions';
|
|
4
4
|
import { Context, Duration, Effect, Fiber, Result } from 'effect';
|
|
5
5
|
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
@@ -62,7 +62,7 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
62
62
|
/**
|
|
63
63
|
* Drain the one database this service owns, then sample its depth.
|
|
64
64
|
*
|
|
65
|
-
* The single-database counterpart to
|
|
65
|
+
* The single-database counterpart to `startTenantOutboxRelay`. A
|
|
66
66
|
* platform-plane store is one named database, so there is nothing to enumerate
|
|
67
67
|
* and `listDatabases` — which needs a `MongoClient` and cluster-wide rights —
|
|
68
68
|
* never comes into it.
|
|
@@ -99,15 +99,24 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
99
99
|
const db = yield* MongoDatabaseTag;
|
|
100
100
|
const bus = yield* EventBusTag;
|
|
101
101
|
const maxAttempts = yield* OutboxMaxAttempts;
|
|
102
|
-
const shutdown = yield* ShutdownRegistryTag;
|
|
103
102
|
yield* ensureOutboxIndexes(db);
|
|
104
103
|
const outbox = makeMongoOutbox(db);
|
|
105
|
-
|
|
104
|
+
yield* runRelayDaemon(sweepOutbox(outbox, bus, _extends({
|
|
106
105
|
maxAttempts,
|
|
107
106
|
database: db.databaseName
|
|
108
107
|
}, (options == null ? void 0 : options.onStats) ? {
|
|
109
108
|
onStats: options.onStats
|
|
110
|
-
} : {}));
|
|
109
|
+
} : {})));
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* Runs `sweepOnce` every {@link SWEEP_INTERVAL}, and once more on the way out.
|
|
113
|
+
*
|
|
114
|
+
* Interrupting the daemon before that last pass is what stops two sweeps
|
|
115
|
+
* publishing the same entry; running the pass is what the hook is for — an
|
|
116
|
+
* event committed a moment before shutdown would otherwise wait out the next
|
|
117
|
+
* process's whole interval.
|
|
118
|
+
*/ const runRelayDaemon = (sweepOnce)=>Effect.gen(function*() {
|
|
119
|
+
const shutdown = yield* ShutdownRegistryTag;
|
|
111
120
|
const daemon = yield* Effect.forkDetach(sweepOnce.pipe(Effect.delay(SWEEP_INTERVAL), Effect.forever));
|
|
112
121
|
yield* shutdown.register({
|
|
113
122
|
name: 'outbox-relay',
|
|
@@ -115,5 +124,77 @@ import { ensureOutboxIndexes, makeMongoOutbox } from './store.js';
|
|
|
115
124
|
run: Fiber.interrupt(daemon).pipe(Effect.andThen(sweepOnce))
|
|
116
125
|
});
|
|
117
126
|
});
|
|
127
|
+
/**
|
|
128
|
+
* One pass over every organization's outbox in a per-organization store.
|
|
129
|
+
*
|
|
130
|
+
* ⚠️ **Each tenant is caught on its own.** A failure that recurs — an index
|
|
131
|
+
* conflict on one old database, say — would otherwise abandon every tenant
|
|
132
|
+
* listed after it on every pass, while the relay went on looking healthy.
|
|
133
|
+
*
|
|
134
|
+
* Indexes are ensured once per database per process (`ensured`), not on every
|
|
135
|
+
* pass: a sweep visits every tenant every interval, and the index build is the
|
|
136
|
+
* same request each time.
|
|
137
|
+
*/ export const sweepTenantOutboxes = (directory, bus, options)=>Effect.gen(function*() {
|
|
138
|
+
var _options_ensured;
|
|
139
|
+
const ensured = (_options_ensured = options.ensured) != null ? _options_ensured : new Set();
|
|
140
|
+
const tenants = yield* directory.list();
|
|
141
|
+
for (const { handle: db } of tenants){
|
|
142
|
+
const database = db.databaseName;
|
|
143
|
+
yield* Effect.gen(function*() {
|
|
144
|
+
if (!ensured.has(database)) {
|
|
145
|
+
yield* ensureOutboxIndexes(db);
|
|
146
|
+
ensured.add(database);
|
|
147
|
+
}
|
|
148
|
+
yield* sweepOutbox(makeMongoOutbox(db), bus, _extends({
|
|
149
|
+
maxAttempts: options.maxAttempts,
|
|
150
|
+
database
|
|
151
|
+
}, options.onStats ? {
|
|
152
|
+
onStats: options.onStats
|
|
153
|
+
} : {}));
|
|
154
|
+
}).pipe(Effect.catch((error)=>Effect.logError('draining a tenant outbox failed').pipe(Effect.annotateLogs({
|
|
155
|
+
database,
|
|
156
|
+
error: String(error)
|
|
157
|
+
}))));
|
|
158
|
+
}
|
|
159
|
+
}).pipe(Effect.catch((error)=>Effect.logError('tenant outbox sweep failed').pipe(Effect.annotateLogs({
|
|
160
|
+
error: String(error)
|
|
161
|
+
}))));
|
|
162
|
+
/**
|
|
163
|
+
* Start the relay for a store with one database per organization, listed by
|
|
164
|
+
* the {@link TenantDirectoryTag} the host provides.
|
|
165
|
+
*
|
|
166
|
+
* The same two speeds as {@link startOutboxRelay}: the write path drains its
|
|
167
|
+
* own tenant inline with {@link drainOutboxNow}, and this sweep carries
|
|
168
|
+
* whatever that missed.
|
|
169
|
+
*/ export const startTenantOutboxRelay = (options)=>Effect.gen(function*() {
|
|
170
|
+
const directory = yield* TenantDirectoryTag;
|
|
171
|
+
const bus = yield* EventBusTag;
|
|
172
|
+
const maxAttempts = yield* OutboxMaxAttempts;
|
|
173
|
+
const ensured = new Set();
|
|
174
|
+
yield* runRelayDaemon(sweepTenantOutboxes(directory, bus, _extends({
|
|
175
|
+
maxAttempts,
|
|
176
|
+
ensured
|
|
177
|
+
}, (options == null ? void 0 : options.onStats) ? {
|
|
178
|
+
onStats: options.onStats
|
|
179
|
+
} : {})));
|
|
180
|
+
});
|
|
181
|
+
/**
|
|
182
|
+
* Drain one database's outbox now, for a write path that has just committed.
|
|
183
|
+
*
|
|
184
|
+
* The fast path the sweep is the fallback for: an event drained here leaves as
|
|
185
|
+
* soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}
|
|
186
|
+
* later. Every failure is logged and swallowed — the entry is still in the
|
|
187
|
+
* outbox, and the sweep will carry it.
|
|
188
|
+
*/ export const drainOutboxNow = (db)=>Effect.gen(function*() {
|
|
189
|
+
const bus = yield* EventBusTag;
|
|
190
|
+
const maxAttempts = yield* OutboxMaxAttempts;
|
|
191
|
+
yield* drainOutbox(makeMongoOutbox(db), bus, {
|
|
192
|
+
maxAttempts,
|
|
193
|
+
database: db.databaseName
|
|
194
|
+
});
|
|
195
|
+
}).pipe(Effect.catch((error)=>Effect.logWarning('inline outbox drain failed').pipe(Effect.annotateLogs({
|
|
196
|
+
database: db.databaseName,
|
|
197
|
+
error: String(error)
|
|
198
|
+
}))));
|
|
118
199
|
|
|
119
200
|
//# sourceMappingURL=relay.js.map
|
package/dist/outbox/relay.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/outbox/relay.ts"],"sourcesContent":["import { ShutdownRegistryTag } from '@entifix/business';\nimport {\n type EventBus,\n EventBusTag,\n type OutboxStats,\n type TransactionOutbox,\n} from '@entifix/transactions';\nimport { Context, Duration, Effect, Fiber, Result } from 'effect';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\nimport { recordOutboxStats } from './metrics.js';\nimport { ensureOutboxIndexes, makeMongoOutbox } from './store.js';\n\n/** How often the sweep looks for entries the fast path did not carry. */\nexport const SWEEP_INTERVAL = Duration.seconds(15);\n/** Entries drained per pass, per database. */\nexport const BATCH = 100;\n\n/**\n * Publish attempts an outbox entry gets before it is quarantined, from\n * config-service.\n *\n * Configuration rather than a constant because it is a genuine operational\n * tunable: raising it while a flaky broker settles is a config edit, and the\n * next sweep reads the new value. That is the opposite of a subscription's\n * `maxAttempts`, which becomes an immutable queue argument and therefore stays\n * a literal beside its declaration.\n *\n * ⚠️ **Read it with `getNumber`, never a cast.** A value of `'five'` casts to\n * `NaN`, every `attempts >= maxAttempts` comparison goes false, and nothing is\n * ever quarantined — a relay that looks healthy while its head never moves.\n */\nexport class OutboxMaxAttempts extends Context.Service<\n OutboxMaxAttempts,\n number\n>()('OutboxMaxAttempts') {}\n\n/** What {@link drainOutbox} needs beyond the ports it publishes through. */\nexport interface DrainOptions {\n readonly maxAttempts: number;\n /** The database being drained; carried so a quarantine log names it. */\n readonly database: string;\n}\n\n/**\n * Publish one batch of pending entries, oldest first, and answer how many went.\n *\n * Two orderings are load-bearing here and neither is obvious:\n *\n * - A **still-deliverable** failure returns immediately rather than continuing.\n * The entries behind this one keep their order relative to it, which is what\n * stops a terminal event overtaking the `accepted` for the same transaction.\n * - A **quarantined** failure continues. That is the deliberate\n * skip-so-the-head-moves: an entry that can never publish must stop blocking\n * every entry behind it forever.\n */\nexport const drainOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions,\n) =>\n Effect.gen(function* () {\n const entries = yield* outbox.pending(BATCH);\n let sent = 0;\n for (const entry of entries) {\n const outcome = yield* Effect.result(bus.publish(entry.event));\n\n if (Result.isFailure(outcome)) {\n const attempts = entry.attempts + 1;\n const quarantine = attempts >= options.maxAttempts;\n\n yield* outbox.recordFailure(entry, outcome.failure.message, quarantine);\n\n if (!quarantine) {\n return sent;\n }\n\n yield* Effect.logError('outbox entry quarantined').pipe(\n Effect.annotateLogs({\n database: options.database,\n eventId: entry.eventId,\n eventName: entry.event.name,\n attempts,\n lastError: outcome.failure.message,\n }),\n );\n continue;\n }\n\n yield* outbox.markSent(entry);\n sent += 1;\n }\n return sent;\n });\n\n/**\n * Sample a sweep's depth and age.\n *\n * Defaults to {@link recordOutboxStats}, and a caller overrides it only to\n * assert what was sampled. There is no \"no sink\" option on purpose: an outbox\n * whose depth nothing reports is the failure the gauges exist to make visible.\n */\nexport type OutboxStatsSink = (\n database: string,\n stats: OutboxStats,\n) => Effect.Effect<void>;\n\n/**\n * Drain the one database this service owns, then sample its depth.\n *\n * The single-database counterpart to marketplace-admin's tenant walk. A\n * platform-plane store is one named database, so there is nothing to enumerate\n * and `listDatabases` — which needs a `MongoClient` and cluster-wide rights —\n * never comes into it.\n *\n * Stats are sampled **after** the drain, so the gauge reports what is still\n * waiting rather than what was waiting when the pass began.\n *\n * Every failure is caught and logged. This runs inside `Effect.forever`, and an\n * error that escapes ends the daemon silently — a relay that stopped is\n * indistinguishable from a relay with nothing to do.\n */\nexport const sweepOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions & { readonly onStats?: OutboxStatsSink },\n) =>\n Effect.gen(function* () {\n yield* drainOutbox(outbox, bus, options);\n const sink = options.onStats ?? recordOutboxStats;\n yield* sink(options.database, yield* outbox.stats());\n }).pipe(\n Effect.catch(error =>\n Effect.logError('outbox sweep failed').pipe(\n Effect.annotateLogs({\n database: options.database,\n error: String(error),\n }),\n ),\n ),\n );\n\n/**\n * Start the relay for a service that owns exactly one database.\n *\n * ⚠️ **The shutdown hook is `flush`, not `stop-intake`, and the phase is the\n * whole point.** `AmqpEventBusLayer` registers `stop-intake` to cancel consumers\n * and drain deliveries; this runs after that, so the last sweep publishes into a\n * connection that is still open. Registered the other way round it would try to\n * publish through a channel already closing, and the entries it was holding\n * would sit until the next boot.\n *\n * The daemon is `delay |> forever`, so the first sweep is one interval away\n * rather than at boot — the write path drains inline, and a sweep racing the\n * index bootstrap on a cold database only produces noise.\n */\nexport const startOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n}) =>\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n const shutdown = yield* ShutdownRegistryTag;\n\n yield* ensureOutboxIndexes(db);\n const outbox = makeMongoOutbox(db);\n\n const sweepOnce = sweepOutbox(outbox, bus, {\n maxAttempts,\n database: db.databaseName,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n });\n\n const daemon = yield* Effect.forkDetach(\n sweepOnce.pipe(Effect.delay(SWEEP_INTERVAL), Effect.forever),\n );\n\n yield* shutdown.register({\n name: 'outbox-relay',\n phase: 'flush',\n run: Fiber.interrupt(daemon).pipe(Effect.andThen(sweepOnce)),\n });\n });\n"],"names":["ShutdownRegistryTag","EventBusTag","Context","Duration","Effect","Fiber","Result","MongoDatabaseTag","recordOutboxStats","ensureOutboxIndexes","makeMongoOutbox","SWEEP_INTERVAL","seconds","BATCH","OutboxMaxAttempts","Service","drainOutbox","outbox","bus","options","gen","entries","pending","sent","entry","outcome","result","publish","event","isFailure","attempts","quarantine","maxAttempts","recordFailure","failure","message","logError","pipe","annotateLogs","database","eventId","eventName","name","lastError","markSent","sweepOutbox","sink","onStats","stats","catch","error","String","startOutboxRelay","db","shutdown","sweepOnce","databaseName","daemon","forkDetach","delay","forever","register","phase","run","interrupt","andThen"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/outbox/relay.ts"],"sourcesContent":["import {\n ShutdownRegistryTag,\n type TenantDirectory,\n TenantDirectoryTag,\n} from '@entifix/business';\nimport {\n type EventBus,\n EventBusTag,\n type OutboxStats,\n type TransactionOutbox,\n} from '@entifix/transactions';\nimport { Context, Duration, Effect, Fiber, Result } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\nimport { recordOutboxStats } from './metrics.js';\nimport { ensureOutboxIndexes, makeMongoOutbox } from './store.js';\n\n/** How often the sweep looks for entries the fast path did not carry. */\nexport const SWEEP_INTERVAL = Duration.seconds(15);\n/** Entries drained per pass, per database. */\nexport const BATCH = 100;\n\n/**\n * Publish attempts an outbox entry gets before it is quarantined, from\n * config-service.\n *\n * Configuration rather than a constant because it is a genuine operational\n * tunable: raising it while a flaky broker settles is a config edit, and the\n * next sweep reads the new value. That is the opposite of a subscription's\n * `maxAttempts`, which becomes an immutable queue argument and therefore stays\n * a literal beside its declaration.\n *\n * ⚠️ **Read it with `getNumber`, never a cast.** A value of `'five'` casts to\n * `NaN`, every `attempts >= maxAttempts` comparison goes false, and nothing is\n * ever quarantined — a relay that looks healthy while its head never moves.\n */\nexport class OutboxMaxAttempts extends Context.Service<\n OutboxMaxAttempts,\n number\n>()('OutboxMaxAttempts') {}\n\n/** What {@link drainOutbox} needs beyond the ports it publishes through. */\nexport interface DrainOptions {\n readonly maxAttempts: number;\n /** The database being drained; carried so a quarantine log names it. */\n readonly database: string;\n}\n\n/**\n * Publish one batch of pending entries, oldest first, and answer how many went.\n *\n * Two orderings are load-bearing here and neither is obvious:\n *\n * - A **still-deliverable** failure returns immediately rather than continuing.\n * The entries behind this one keep their order relative to it, which is what\n * stops a terminal event overtaking the `accepted` for the same transaction.\n * - A **quarantined** failure continues. That is the deliberate\n * skip-so-the-head-moves: an entry that can never publish must stop blocking\n * every entry behind it forever.\n */\nexport const drainOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions,\n) =>\n Effect.gen(function* () {\n const entries = yield* outbox.pending(BATCH);\n let sent = 0;\n for (const entry of entries) {\n const outcome = yield* Effect.result(bus.publish(entry.event));\n\n if (Result.isFailure(outcome)) {\n const attempts = entry.attempts + 1;\n const quarantine = attempts >= options.maxAttempts;\n\n yield* outbox.recordFailure(entry, outcome.failure.message, quarantine);\n\n if (!quarantine) {\n return sent;\n }\n\n yield* Effect.logError('outbox entry quarantined').pipe(\n Effect.annotateLogs({\n database: options.database,\n eventId: entry.eventId,\n eventName: entry.event.name,\n attempts,\n lastError: outcome.failure.message,\n }),\n );\n continue;\n }\n\n yield* outbox.markSent(entry);\n sent += 1;\n }\n return sent;\n });\n\n/**\n * Sample a sweep's depth and age.\n *\n * Defaults to {@link recordOutboxStats}, and a caller overrides it only to\n * assert what was sampled. There is no \"no sink\" option on purpose: an outbox\n * whose depth nothing reports is the failure the gauges exist to make visible.\n */\nexport type OutboxStatsSink = (\n database: string,\n stats: OutboxStats,\n) => Effect.Effect<void>;\n\n/**\n * Drain the one database this service owns, then sample its depth.\n *\n * The single-database counterpart to `startTenantOutboxRelay`. A\n * platform-plane store is one named database, so there is nothing to enumerate\n * and `listDatabases` — which needs a `MongoClient` and cluster-wide rights —\n * never comes into it.\n *\n * Stats are sampled **after** the drain, so the gauge reports what is still\n * waiting rather than what was waiting when the pass began.\n *\n * Every failure is caught and logged. This runs inside `Effect.forever`, and an\n * error that escapes ends the daemon silently — a relay that stopped is\n * indistinguishable from a relay with nothing to do.\n */\nexport const sweepOutbox = (\n outbox: TransactionOutbox,\n bus: EventBus,\n options: DrainOptions & { readonly onStats?: OutboxStatsSink },\n) =>\n Effect.gen(function* () {\n yield* drainOutbox(outbox, bus, options);\n const sink = options.onStats ?? recordOutboxStats;\n yield* sink(options.database, yield* outbox.stats());\n }).pipe(\n Effect.catch(error =>\n Effect.logError('outbox sweep failed').pipe(\n Effect.annotateLogs({\n database: options.database,\n error: String(error),\n }),\n ),\n ),\n );\n\n/**\n * Start the relay for a service that owns exactly one database.\n *\n * ⚠️ **The shutdown hook is `flush`, not `stop-intake`, and the phase is the\n * whole point.** `AmqpEventBusLayer` registers `stop-intake` to cancel consumers\n * and drain deliveries; this runs after that, so the last sweep publishes into a\n * connection that is still open. Registered the other way round it would try to\n * publish through a channel already closing, and the entries it was holding\n * would sit until the next boot.\n *\n * The daemon is `delay |> forever`, so the first sweep is one interval away\n * rather than at boot — the write path drains inline, and a sweep racing the\n * index bootstrap on a cold database only produces noise.\n */\nexport const startOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n}) =>\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n\n yield* ensureOutboxIndexes(db);\n const outbox = makeMongoOutbox(db);\n\n yield* runRelayDaemon(\n sweepOutbox(outbox, bus, {\n maxAttempts,\n database: db.databaseName,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n }),\n );\n });\n\n/**\n * Runs `sweepOnce` every {@link SWEEP_INTERVAL}, and once more on the way out.\n *\n * Interrupting the daemon before that last pass is what stops two sweeps\n * publishing the same entry; running the pass is what the hook is for — an\n * event committed a moment before shutdown would otherwise wait out the next\n * process's whole interval.\n */\nconst runRelayDaemon = (sweepOnce: Effect.Effect<void>) =>\n Effect.gen(function* () {\n const shutdown = yield* ShutdownRegistryTag;\n\n const daemon = yield* Effect.forkDetach(\n sweepOnce.pipe(Effect.delay(SWEEP_INTERVAL), Effect.forever),\n );\n\n yield* shutdown.register({\n name: 'outbox-relay',\n phase: 'flush',\n run: Fiber.interrupt(daemon).pipe(Effect.andThen(sweepOnce)),\n });\n });\n\n/**\n * One pass over every organization's outbox in a per-organization store.\n *\n * ⚠️ **Each tenant is caught on its own.** A failure that recurs — an index\n * conflict on one old database, say — would otherwise abandon every tenant\n * listed after it on every pass, while the relay went on looking healthy.\n *\n * Indexes are ensured once per database per process (`ensured`), not on every\n * pass: a sweep visits every tenant every interval, and the index build is the\n * same request each time.\n */\nexport const sweepTenantOutboxes = (\n directory: TenantDirectory<Db>,\n bus: EventBus,\n options: {\n readonly maxAttempts: number;\n readonly onStats?: OutboxStatsSink;\n readonly ensured?: Set<string>;\n },\n) =>\n Effect.gen(function* () {\n const ensured = options.ensured ?? new Set<string>();\n const tenants = yield* directory.list();\n\n for (const { handle: db } of tenants) {\n const database = db.databaseName;\n yield* Effect.gen(function* () {\n if (!ensured.has(database)) {\n yield* ensureOutboxIndexes(db);\n ensured.add(database);\n }\n yield* sweepOutbox(makeMongoOutbox(db), bus, {\n maxAttempts: options.maxAttempts,\n database,\n ...(options.onStats ? { onStats: options.onStats } : {}),\n });\n }).pipe(\n Effect.catch(error =>\n Effect.logError('draining a tenant outbox failed').pipe(\n Effect.annotateLogs({ database, error: String(error) }),\n ),\n ),\n );\n }\n }).pipe(\n Effect.catch(error =>\n Effect.logError('tenant outbox sweep failed').pipe(\n Effect.annotateLogs({ error: String(error) }),\n ),\n ),\n );\n\n/**\n * Start the relay for a store with one database per organization, listed by\n * the {@link TenantDirectoryTag} the host provides.\n *\n * The same two speeds as {@link startOutboxRelay}: the write path drains its\n * own tenant inline with {@link drainOutboxNow}, and this sweep carries\n * whatever that missed.\n */\nexport const startTenantOutboxRelay = (options?: {\n readonly onStats?: OutboxStatsSink;\n}) =>\n Effect.gen(function* () {\n const directory = (yield* TenantDirectoryTag) as TenantDirectory<Db>;\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n const ensured = new Set<string>();\n\n yield* runRelayDaemon(\n sweepTenantOutboxes(directory, bus, {\n maxAttempts,\n ensured,\n ...(options?.onStats ? { onStats: options.onStats } : {}),\n }),\n );\n });\n\n/**\n * Drain one database's outbox now, for a write path that has just committed.\n *\n * The fast path the sweep is the fallback for: an event drained here leaves as\n * soon as its transaction commits, rather than up to {@link SWEEP_INTERVAL}\n * later. Every failure is logged and swallowed — the entry is still in the\n * outbox, and the sweep will carry it.\n */\nexport const drainOutboxNow = (db: Db) =>\n Effect.gen(function* () {\n const bus = yield* EventBusTag;\n const maxAttempts = yield* OutboxMaxAttempts;\n yield* drainOutbox(makeMongoOutbox(db), bus, {\n maxAttempts,\n database: db.databaseName,\n });\n }).pipe(\n Effect.catch(error =>\n Effect.logWarning('inline outbox drain failed').pipe(\n Effect.annotateLogs({\n database: db.databaseName,\n error: String(error),\n }),\n ),\n ),\n );\n"],"names":["ShutdownRegistryTag","TenantDirectoryTag","EventBusTag","Context","Duration","Effect","Fiber","Result","MongoDatabaseTag","recordOutboxStats","ensureOutboxIndexes","makeMongoOutbox","SWEEP_INTERVAL","seconds","BATCH","OutboxMaxAttempts","Service","drainOutbox","outbox","bus","options","gen","entries","pending","sent","entry","outcome","result","publish","event","isFailure","attempts","quarantine","maxAttempts","recordFailure","failure","message","logError","pipe","annotateLogs","database","eventId","eventName","name","lastError","markSent","sweepOutbox","sink","onStats","stats","catch","error","String","startOutboxRelay","db","runRelayDaemon","databaseName","sweepOnce","shutdown","daemon","forkDetach","delay","forever","register","phase","run","interrupt","andThen","sweepTenantOutboxes","directory","ensured","Set","tenants","list","handle","has","add","startTenantOutboxRelay","drainOutboxNow","logWarning"],"mappings":"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"}
|
package/dist/saga/store.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { EntifixConnError } from '@entifix/core';
|
|
|
2
2
|
import { type SagaStore, SagaStoreTag } from '@entifix/transactions';
|
|
3
3
|
import { Effect, Layer } from 'effect';
|
|
4
4
|
import type { Db } from 'mongodb';
|
|
5
|
-
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
|
+
import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
6
6
|
/**
|
|
7
7
|
* Saga instances: one document per flow, the durable record a coordinator
|
|
8
8
|
* resumes from, so where to resume is derived from the outcomes rather than
|
|
@@ -22,4 +22,15 @@ export declare const makeMongoSagaStore: (db: Db) => SagaStore;
|
|
|
22
22
|
* indexes first.
|
|
23
23
|
*/
|
|
24
24
|
export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag, EntifixConnError, MongoDatabaseTag>;
|
|
25
|
+
/**
|
|
26
|
+
* Provides {@link SagaStoreTag} from a database the host **names**, resolved on
|
|
27
|
+
* the shared client — for a process whose saga store is one of several stores
|
|
28
|
+
* it holds, rather than the one database {@link MongoDatabaseTag} carries.
|
|
29
|
+
*
|
|
30
|
+
* Same pool, same indexes, same store; only where the handle comes from
|
|
31
|
+
* differs.
|
|
32
|
+
*/
|
|
33
|
+
export declare const mongoSagaStoreLayer: (options: {
|
|
34
|
+
readonly database: string;
|
|
35
|
+
}) => Layer.Layer<SagaStoreTag, EntifixConnError, MongoClientTag>;
|
|
25
36
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/saga/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/saga/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAIL,KAAK,SAAS,EACd,YAAY,EACb,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,OAAO,
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/saga/store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAIL,KAAK,SAAS,EACd,YAAY,EACb,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAC;AACvC,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAElC,OAAO,EACL,cAAc,EACd,gBAAgB,EACjB,MAAM,qCAAqC,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAahD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,EAAE,iDAUpC,CAAC;AAEL,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,GAAI,IAAI,EAAE,KAAG,SAoI3C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,+DAO/B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,gEASvE,CAAC"}
|
package/dist/saga/store.js
CHANGED
|
@@ -2,7 +2,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
2
2
|
import { EntifixConnError } from '@entifix/core';
|
|
3
3
|
import { SagaStoreTag } from '@entifix/transactions';
|
|
4
4
|
import { Effect, Layer } from 'effect';
|
|
5
|
-
import { MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
|
+
import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
6
6
|
/**
|
|
7
7
|
* Saga instances: one document per flow, the durable record a coordinator
|
|
8
8
|
* resumes from, so where to resume is derived from the outcomes rather than
|
|
@@ -207,5 +207,18 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
207
207
|
yield* ensureSagaIndexes(db);
|
|
208
208
|
return makeMongoSagaStore(db);
|
|
209
209
|
}));
|
|
210
|
+
/**
|
|
211
|
+
* Provides {@link SagaStoreTag} from a database the host **names**, resolved on
|
|
212
|
+
* the shared client — for a process whose saga store is one of several stores
|
|
213
|
+
* it holds, rather than the one database {@link MongoDatabaseTag} carries.
|
|
214
|
+
*
|
|
215
|
+
* Same pool, same indexes, same store; only where the handle comes from
|
|
216
|
+
* differs.
|
|
217
|
+
*/ export const mongoSagaStoreLayer = (options)=>Layer.effect(SagaStoreTag, Effect.gen(function*() {
|
|
218
|
+
const client = yield* MongoClientTag;
|
|
219
|
+
const db = client.db(options.database);
|
|
220
|
+
yield* ensureSagaIndexes(db);
|
|
221
|
+
return makeMongoSagaStore(db);
|
|
222
|
+
}));
|
|
210
223
|
|
|
211
224
|
//# sourceMappingURL=store.js.map
|
package/dist/saga/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/saga/store.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport {\n type SagaInstance,\n type SagaState,\n type SagaStepOutcome,\n type SagaStore,\n SagaStoreTag,\n} from '@entifix/transactions';\nimport { Effect, Layer } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { MongoDatabaseTag } from '../mongo-database/mongo-database.js';\n\n/**\n * Saga instances: one document per flow, the durable record a coordinator\n * resumes from, so where to resume is derived from the outcomes rather than\n * stored beside them.\n */\nexport const SAGA_COLLECTION = 'saga_instances';\n\n/** The two states a sweep treats as unfinished. */\nconst UNFINISHED: readonly SagaState[] = ['RUNNING', 'COMPENSATING'];\n\nconst failed = (message: string, sagaId: string) => (error: unknown) =>\n new EntifixConnError(message, error, { sagaId });\n\nconst sagaCollection = (db: Db) => db.collection<SagaInstance>(SAGA_COLLECTION);\n\nconst deadline = (olderThanMs: number) =>\n new Date(Date.now() - olderThanMs).toISOString();\n\n/**\n * A unique index on `sagaId`, so a resumed coordinator can never create a\n * second instance for one flow, and `{ state, updatedAt }` for the sweep —\n * without it every pass is a scan of every saga ever run, on a schedule.\n */\nexport const ensureSagaIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n await sagaCollection(db).createIndex({ sagaId: 1 }, { unique: true });\n await sagaCollection(db).createIndex({ state: 1, updatedAt: 1 });\n },\n catch: error =>\n new EntifixConnError('Failed to ensure saga indexes', error, {\n database: db.databaseName,\n }),\n });\n\n/** {@link SagaStore} over one Mongo database. */\nexport const makeMongoSagaStore = (db: Db): SagaStore => {\n const collection = sagaCollection(db);\n const stamp = () => new Date().toISOString();\n\n return {\n start: instance =>\n Effect.tryPromise({\n try: async () => {\n // `$setOnInsert` with an upsert rather than `insertOne`: a coordinator\n // restarted mid-flight re-enters with an id it already wrote, and\n // refusing that would turn a recoverable resume into a hard failure.\n await collection.updateOne(\n { sagaId: instance.sagaId },\n { $setOnInsert: { ...instance, updatedAt: stamp() } },\n { upsert: true },\n );\n },\n catch: failed('Failed to start the saga instance', instance.sagaId),\n }),\n\n beginStep: (sagaId, stepIndex) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { stepIndex, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step transition', sagaId),\n }),\n\n recordOutcome: (sagaId, outcome: SagaStepOutcome) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n // `$push`, never a read-modify-write of the array: two outcomes\n // recorded from one process would otherwise lose one another, and a\n // lost outcome is a compensation that cannot address what it undoes.\n { $push: { outcomes: outcome }, $set: { updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step outcome', sagaId),\n }),\n\n settle: (sagaId, state, error) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { state, error, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to settle the saga instance', sagaId),\n }),\n\n /**\n * Take ownership of a stale instance, or answer `undefined`.\n *\n * ⚠️ **One conditional write, not a read and then a write.** The filter\n * repeats `findStale`'s predicate, so two sweepers racing the same instance\n * produce one winner: the loser's filter no longer matches because the\n * winner re-stamped `updatedAt`. That stamp is also what stops the next tick\n * finding the instance this sweep is still working on.\n */\n claimForResume: (sagaId, olderThanMs) =>\n Effect.tryPromise({\n try: async () => {\n const claimed = await collection.findOneAndUpdate(\n {\n sagaId,\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { $inc: { resumeAttempts: 1 }, $set: { updatedAt: stamp() } },\n { returnDocument: 'after', projection: { _id: 0 } },\n );\n return (claimed ?? undefined) as SagaInstance | undefined;\n },\n catch: failed('Failed to claim the saga instance', sagaId),\n }),\n\n /**\n * Flag one step's calls as given back, addressing the outcome by its step\n * id: outcomes are `$push`ed, so an index is a fact about arrival order and\n * a resumed walk must not depend on one.\n */\n markCompensated: (sagaId, stepId) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n {\n $set: {\n 'outcomes.$[entry].compensated': true,\n updatedAt: stamp(),\n },\n },\n { arrayFilters: [{ 'entry.stepId': stepId }] },\n );\n },\n catch: failed('Failed to mark the step compensated', sagaId),\n }),\n\n get: sagaId =>\n Effect.tryPromise({\n try: async () =>\n ((await collection.findOne({ sagaId }, { projection: { _id: 0 } })) ??\n undefined) as SagaInstance | undefined,\n catch: failed('Failed to read the saga instance', sagaId),\n }),\n\n /**\n * Instances stuck mid-flight past a deadline. ⚠️ `COMPENSATING` counts: a\n * coordinator that died while unwinding is the state that leaves holds in\n * place, and the one an operator most needs to find.\n */\n findStale: olderThanMs =>\n Effect.tryPromise({\n try: async () =>\n (await collection\n .find(\n {\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { projection: { _id: 0 } },\n )\n .toArray()) as SagaInstance[],\n catch: failed('Failed to find stale saga instances', ''),\n }),\n };\n};\n\n/**\n * Provides {@link SagaStoreTag} from the service's own database, creating the\n * indexes first.\n */\nexport const MongoSagaStoreLayer = Layer.effect(\n SagaStoreTag,\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n yield* ensureSagaIndexes(db);\n return makeMongoSagaStore(db);\n }),\n);\n"],"names":["EntifixConnError","SagaStoreTag","Effect","Layer","MongoDatabaseTag","SAGA_COLLECTION","UNFINISHED","failed","message","sagaId","error","sagaCollection","db","collection","deadline","olderThanMs","Date","now","toISOString","ensureSagaIndexes","tryPromise","try","createIndex","unique","state","updatedAt","catch","database","databaseName","makeMongoSagaStore","stamp","start","instance","updateOne","$setOnInsert","upsert","beginStep","stepIndex","$set","recordOutcome","outcome","$push","outcomes","settle","claimForResume","claimed","findOneAndUpdate","$in","$lt","$inc","resumeAttempts","returnDocument","projection","_id","undefined","markCompensated","stepId","arrayFilters","get","findOne","findStale","find","toArray","MongoSagaStoreLayer","effect","gen"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/saga/store.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport {\n type SagaInstance,\n type SagaState,\n type SagaStepOutcome,\n type SagaStore,\n SagaStoreTag,\n} from '@entifix/transactions';\nimport { Effect, Layer } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport {\n MongoClientTag,\n MongoDatabaseTag,\n} from '../mongo-database/mongo-database.js';\n\n/**\n * Saga instances: one document per flow, the durable record a coordinator\n * resumes from, so where to resume is derived from the outcomes rather than\n * stored beside them.\n */\nexport const SAGA_COLLECTION = 'saga_instances';\n\n/** The two states a sweep treats as unfinished. */\nconst UNFINISHED: readonly SagaState[] = ['RUNNING', 'COMPENSATING'];\n\nconst failed = (message: string, sagaId: string) => (error: unknown) =>\n new EntifixConnError(message, error, { sagaId });\n\nconst sagaCollection = (db: Db) => db.collection<SagaInstance>(SAGA_COLLECTION);\n\nconst deadline = (olderThanMs: number) =>\n new Date(Date.now() - olderThanMs).toISOString();\n\n/**\n * A unique index on `sagaId`, so a resumed coordinator can never create a\n * second instance for one flow, and `{ state, updatedAt }` for the sweep —\n * without it every pass is a scan of every saga ever run, on a schedule.\n */\nexport const ensureSagaIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n await sagaCollection(db).createIndex({ sagaId: 1 }, { unique: true });\n await sagaCollection(db).createIndex({ state: 1, updatedAt: 1 });\n },\n catch: error =>\n new EntifixConnError('Failed to ensure saga indexes', error, {\n database: db.databaseName,\n }),\n });\n\n/** {@link SagaStore} over one Mongo database. */\nexport const makeMongoSagaStore = (db: Db): SagaStore => {\n const collection = sagaCollection(db);\n const stamp = () => new Date().toISOString();\n\n return {\n start: instance =>\n Effect.tryPromise({\n try: async () => {\n // `$setOnInsert` with an upsert rather than `insertOne`: a coordinator\n // restarted mid-flight re-enters with an id it already wrote, and\n // refusing that would turn a recoverable resume into a hard failure.\n await collection.updateOne(\n { sagaId: instance.sagaId },\n { $setOnInsert: { ...instance, updatedAt: stamp() } },\n { upsert: true },\n );\n },\n catch: failed('Failed to start the saga instance', instance.sagaId),\n }),\n\n beginStep: (sagaId, stepIndex) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { stepIndex, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step transition', sagaId),\n }),\n\n recordOutcome: (sagaId, outcome: SagaStepOutcome) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n // `$push`, never a read-modify-write of the array: two outcomes\n // recorded from one process would otherwise lose one another, and a\n // lost outcome is a compensation that cannot address what it undoes.\n { $push: { outcomes: outcome }, $set: { updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to record the step outcome', sagaId),\n }),\n\n settle: (sagaId, state, error) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n { $set: { state, error, updatedAt: stamp() } },\n );\n },\n catch: failed('Failed to settle the saga instance', sagaId),\n }),\n\n /**\n * Take ownership of a stale instance, or answer `undefined`.\n *\n * ⚠️ **One conditional write, not a read and then a write.** The filter\n * repeats `findStale`'s predicate, so two sweepers racing the same instance\n * produce one winner: the loser's filter no longer matches because the\n * winner re-stamped `updatedAt`. That stamp is also what stops the next tick\n * finding the instance this sweep is still working on.\n */\n claimForResume: (sagaId, olderThanMs) =>\n Effect.tryPromise({\n try: async () => {\n const claimed = await collection.findOneAndUpdate(\n {\n sagaId,\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { $inc: { resumeAttempts: 1 }, $set: { updatedAt: stamp() } },\n { returnDocument: 'after', projection: { _id: 0 } },\n );\n return (claimed ?? undefined) as SagaInstance | undefined;\n },\n catch: failed('Failed to claim the saga instance', sagaId),\n }),\n\n /**\n * Flag one step's calls as given back, addressing the outcome by its step\n * id: outcomes are `$push`ed, so an index is a fact about arrival order and\n * a resumed walk must not depend on one.\n */\n markCompensated: (sagaId, stepId) =>\n Effect.tryPromise({\n try: async () => {\n await collection.updateOne(\n { sagaId },\n {\n $set: {\n 'outcomes.$[entry].compensated': true,\n updatedAt: stamp(),\n },\n },\n { arrayFilters: [{ 'entry.stepId': stepId }] },\n );\n },\n catch: failed('Failed to mark the step compensated', sagaId),\n }),\n\n get: sagaId =>\n Effect.tryPromise({\n try: async () =>\n ((await collection.findOne({ sagaId }, { projection: { _id: 0 } })) ??\n undefined) as SagaInstance | undefined,\n catch: failed('Failed to read the saga instance', sagaId),\n }),\n\n /**\n * Instances stuck mid-flight past a deadline. ⚠️ `COMPENSATING` counts: a\n * coordinator that died while unwinding is the state that leaves holds in\n * place, and the one an operator most needs to find.\n */\n findStale: olderThanMs =>\n Effect.tryPromise({\n try: async () =>\n (await collection\n .find(\n {\n state: { $in: [...UNFINISHED] },\n updatedAt: { $lt: deadline(olderThanMs) },\n },\n { projection: { _id: 0 } },\n )\n .toArray()) as SagaInstance[],\n catch: failed('Failed to find stale saga instances', ''),\n }),\n };\n};\n\n/**\n * Provides {@link SagaStoreTag} from the service's own database, creating the\n * indexes first.\n */\nexport const MongoSagaStoreLayer = Layer.effect(\n SagaStoreTag,\n Effect.gen(function* () {\n const db = yield* MongoDatabaseTag;\n yield* ensureSagaIndexes(db);\n return makeMongoSagaStore(db);\n }),\n);\n\n/**\n * Provides {@link SagaStoreTag} from a database the host **names**, resolved on\n * the shared client — for a process whose saga store is one of several stores\n * it holds, rather than the one database {@link MongoDatabaseTag} carries.\n *\n * Same pool, same indexes, same store; only where the handle comes from\n * differs.\n */\nexport const mongoSagaStoreLayer = (options: { readonly database: string }) =>\n Layer.effect(\n SagaStoreTag,\n Effect.gen(function* () {\n const client = yield* MongoClientTag;\n const db = client.db(options.database);\n yield* ensureSagaIndexes(db);\n return makeMongoSagaStore(db);\n }),\n );\n"],"names":["EntifixConnError","SagaStoreTag","Effect","Layer","MongoClientTag","MongoDatabaseTag","SAGA_COLLECTION","UNFINISHED","failed","message","sagaId","error","sagaCollection","db","collection","deadline","olderThanMs","Date","now","toISOString","ensureSagaIndexes","tryPromise","try","createIndex","unique","state","updatedAt","catch","database","databaseName","makeMongoSagaStore","stamp","start","instance","updateOne","$setOnInsert","upsert","beginStep","stepIndex","$set","recordOutcome","outcome","$push","outcomes","settle","claimForResume","claimed","findOneAndUpdate","$in","$lt","$inc","resumeAttempts","returnDocument","projection","_id","undefined","markCompensated","stepId","arrayFilters","get","findOne","findStale","find","toArray","MongoSagaStoreLayer","effect","gen","mongoSagaStoreLayer","options","client"],"mappings":"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"}
|
|
@@ -14,4 +14,6 @@ export * from '../outbox/metrics.js';
|
|
|
14
14
|
export * from '../outbox/relay.js';
|
|
15
15
|
export * from '../outbox/store.js';
|
|
16
16
|
export * from '../saga/store.js';
|
|
17
|
+
export * from '../unit-of-work/command-inbox.js';
|
|
18
|
+
export * from '../unit-of-work/mongo-unit-of-work.js';
|
|
17
19
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transactions/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/transactions/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,kCAAkC,CAAC;AACjD,cAAc,uCAAuC,CAAC"}
|
|
@@ -13,5 +13,7 @@ export * from '../outbox/metrics.js';
|
|
|
13
13
|
export * from '../outbox/relay.js';
|
|
14
14
|
export * from '../outbox/store.js';
|
|
15
15
|
export * from '../saga/store.js';
|
|
16
|
+
export * from '../unit-of-work/command-inbox.js';
|
|
17
|
+
export * from '../unit-of-work/mongo-unit-of-work.js';
|
|
16
18
|
|
|
17
19
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the\n * transactional outbox written inside the entity's own Mongo\n * transaction, and the inbox that makes a redelivered command idempotent.\n *\n * Behind a subpath so entity CRUD over Mongo does not arrive with the saga\n * engine attached. Package-level dependencies are not per-subpath, so what\n * actually keeps it optional is the `peerDependenciesMeta.optional` entry in\n * the manifest — importing this module without that peer installed is the\n * error, and it is the adopter's to make deliberately.\n */\nexport * from '../inbox/store.js';\nexport * from '../outbox/metrics.js';\nexport * from '../outbox/relay.js';\nexport * from '../outbox/store.js';\nexport * from '../saga/store.js';\n"],"names":[],"mappings":"AAAA;;;;;;;;;;CAUC,GACD,cAAc,oBAAoB;AAClC,cAAc,uBAAuB;AACrC,cAAc,qBAAqB;AACnC,cAAc,qBAAqB;AACnC,cAAc,mBAAmB"}
|
|
1
|
+
{"version":3,"sources":["../../src/transactions/index.ts"],"sourcesContent":["/**\n * The half of this package that needs `@entifix/transactions`: the\n * transactional outbox written inside the entity's own Mongo\n * transaction, and the inbox that makes a redelivered command idempotent.\n *\n * Behind a subpath so entity CRUD over Mongo does not arrive with the saga\n * engine attached. Package-level dependencies are not per-subpath, so what\n * actually keeps it optional is the `peerDependenciesMeta.optional` entry in\n * the manifest — importing this module without that peer installed is the\n * error, and it is the adopter's to make deliberately.\n */\nexport * from '../inbox/store.js';\nexport * from '../outbox/metrics.js';\nexport * from '../outbox/relay.js';\nexport * from '../outbox/store.js';\nexport * from '../saga/store.js';\nexport * from '../unit-of-work/command-inbox.js';\nexport * from '../unit-of-work/mongo-unit-of-work.js';\n"],"names":[],"mappings":"AAAA;;;;;;;;;;CAUC,GACD,cAAc,oBAAoB;AAClC,cAAc,uBAAuB;AACrC,cAAc,qBAAqB;AACnC,cAAc,qBAAqB;AACnC,cAAc,mBAAmB;AACjC,cAAc,mCAAmC;AACjD,cAAc,wCAAwC"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Effect, Option } from 'effect';
|
|
3
|
+
import type { ClientSession, Db } from 'mongodb';
|
|
4
|
+
/**
|
|
5
|
+
* Where a participant records the command ids it has acted on — in the
|
|
6
|
+
* database its own writes go to, so the claim and the write are one fact.
|
|
7
|
+
*
|
|
8
|
+
* The command-side twin of the inbox. A bus consumer dedupes on
|
|
9
|
+
* `(consumer, eventId)`; a participant answering a dispatched command dedupes
|
|
10
|
+
* on the command id alone, because a command has exactly one addressee. And
|
|
11
|
+
* unlike the inbox it remembers an answer: a redelivered capture must be told
|
|
12
|
+
* what the first delivery decided, not merely that there was one.
|
|
13
|
+
*/
|
|
14
|
+
export declare const COMMAND_INBOX_COLLECTION = "command_inbox";
|
|
15
|
+
/** The stored shape of a command claim. */
|
|
16
|
+
export interface CommandClaim {
|
|
17
|
+
readonly commandId: string;
|
|
18
|
+
readonly claimedAt: string;
|
|
19
|
+
/** What a duplicate is answered with, when the claiming unit asked for one. */
|
|
20
|
+
readonly replay?: unknown;
|
|
21
|
+
}
|
|
22
|
+
/** The unique index the claim rides. */
|
|
23
|
+
export declare const ensureCommandInboxIndexes: (db: Db) => Effect.Effect<void, EntifixConnError, never>;
|
|
24
|
+
/** The claim document a unit inserts inside its session. */
|
|
25
|
+
export declare const commandClaimDocument: (commandId: string) => CommandClaim;
|
|
26
|
+
/**
|
|
27
|
+
* Stores what a duplicate of `commandId` is answered with, inside the session
|
|
28
|
+
* that took the claim.
|
|
29
|
+
*/
|
|
30
|
+
export declare const rememberCommandReplay: (db: Db, session: ClientSession, commandId: string, replay: unknown) => Promise<import("mongodb").UpdateResult<CommandClaim>>;
|
|
31
|
+
/** What the run that took `commandId` asked to be remembered. */
|
|
32
|
+
export declare const readCommandReplay: (db: Db, commandId: string) => Effect.Effect<Option.None<{} | null> | Option.Some<{} | null>, EntifixConnError, never>;
|
|
33
|
+
//# sourceMappingURL=command-inbox.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"command-inbox.d.ts","sourceRoot":"","sources":["../../src/unit-of-work/command-inbox.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACjD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAEjD;;;;;;;;;GASG;AACH,eAAO,MAAM,wBAAwB,kBAAkB,CAAC;AAExD,2CAA2C;AAC3C,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,+EAA+E;IAC/E,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,wCAAwC;AACxC,eAAO,MAAM,yBAAyB,GAAI,IAAI,EAAE,iDAW5C,CAAC;AAEL,4DAA4D;AAC5D,eAAO,MAAM,oBAAoB,GAAI,WAAW,MAAM,KAAG,YAGvD,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAChC,IAAI,EAAE,EACN,SAAS,aAAa,EACtB,WAAW,MAAM,EACjB,QAAQ,OAAO,0DAI+C,CAAC;AAEjE,iEAAiE;AACjE,eAAO,MAAM,iBAAiB,GAAI,IAAI,EAAE,EAAE,WAAW,MAAM,4FAgBxD,CAAC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Effect, Option } from 'effect';
|
|
3
|
+
/**
|
|
4
|
+
* Where a participant records the command ids it has acted on — in the
|
|
5
|
+
* database its own writes go to, so the claim and the write are one fact.
|
|
6
|
+
*
|
|
7
|
+
* The command-side twin of the inbox. A bus consumer dedupes on
|
|
8
|
+
* `(consumer, eventId)`; a participant answering a dispatched command dedupes
|
|
9
|
+
* on the command id alone, because a command has exactly one addressee. And
|
|
10
|
+
* unlike the inbox it remembers an answer: a redelivered capture must be told
|
|
11
|
+
* what the first delivery decided, not merely that there was one.
|
|
12
|
+
*/ export const COMMAND_INBOX_COLLECTION = 'command_inbox';
|
|
13
|
+
/** The unique index the claim rides. */ export const ensureCommandInboxIndexes = (db)=>Effect.tryPromise({
|
|
14
|
+
try: async ()=>{
|
|
15
|
+
await db.collection(COMMAND_INBOX_COLLECTION).createIndex({
|
|
16
|
+
commandId: 1
|
|
17
|
+
}, {
|
|
18
|
+
unique: true,
|
|
19
|
+
name: 'commandId_1'
|
|
20
|
+
});
|
|
21
|
+
},
|
|
22
|
+
catch: (error)=>new EntifixConnError('Failed to ensure command inbox indexes', error, {
|
|
23
|
+
database: db.databaseName
|
|
24
|
+
})
|
|
25
|
+
});
|
|
26
|
+
/** The claim document a unit inserts inside its session. */ export const commandClaimDocument = (commandId)=>({
|
|
27
|
+
commandId,
|
|
28
|
+
claimedAt: new Date().toISOString()
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* Stores what a duplicate of `commandId` is answered with, inside the session
|
|
32
|
+
* that took the claim.
|
|
33
|
+
*/ export const rememberCommandReplay = (db, session, commandId, replay)=>db.collection(COMMAND_INBOX_COLLECTION).updateOne({
|
|
34
|
+
commandId
|
|
35
|
+
}, {
|
|
36
|
+
$set: {
|
|
37
|
+
replay
|
|
38
|
+
}
|
|
39
|
+
}, {
|
|
40
|
+
session
|
|
41
|
+
});
|
|
42
|
+
/** What the run that took `commandId` asked to be remembered. */ export const readCommandReplay = (db, commandId)=>Effect.tryPromise({
|
|
43
|
+
try: ()=>db.collection(COMMAND_INBOX_COLLECTION).findOne({
|
|
44
|
+
commandId
|
|
45
|
+
}, {
|
|
46
|
+
projection: {
|
|
47
|
+
_id: 0
|
|
48
|
+
}
|
|
49
|
+
}),
|
|
50
|
+
catch: (error)=>new EntifixConnError('Failed to read a command claim', error, {
|
|
51
|
+
commandId
|
|
52
|
+
})
|
|
53
|
+
}).pipe(Effect.map((claim)=>claim === null || claim.replay === undefined ? Option.none() : Option.some(claim.replay)));
|
|
54
|
+
|
|
55
|
+
//# sourceMappingURL=command-inbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/unit-of-work/command-inbox.ts"],"sourcesContent":["import { EntifixConnError } from '@entifix/core';\nimport { Effect, Option } from 'effect';\nimport type { ClientSession, Db } from 'mongodb';\n\n/**\n * Where a participant records the command ids it has acted on — in the\n * database its own writes go to, so the claim and the write are one fact.\n *\n * The command-side twin of the inbox. A bus consumer dedupes on\n * `(consumer, eventId)`; a participant answering a dispatched command dedupes\n * on the command id alone, because a command has exactly one addressee. And\n * unlike the inbox it remembers an answer: a redelivered capture must be told\n * what the first delivery decided, not merely that there was one.\n */\nexport const COMMAND_INBOX_COLLECTION = 'command_inbox';\n\n/** The stored shape of a command claim. */\nexport interface CommandClaim {\n readonly commandId: string;\n readonly claimedAt: string;\n /** What a duplicate is answered with, when the claiming unit asked for one. */\n readonly replay?: unknown;\n}\n\n/** The unique index the claim rides. */\nexport const ensureCommandInboxIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n await db\n .collection<CommandClaim>(COMMAND_INBOX_COLLECTION)\n .createIndex({ commandId: 1 }, { unique: true, name: 'commandId_1' });\n },\n catch: error =>\n new EntifixConnError('Failed to ensure command inbox indexes', error, {\n database: db.databaseName,\n }),\n });\n\n/** The claim document a unit inserts inside its session. */\nexport const commandClaimDocument = (commandId: string): CommandClaim => ({\n commandId,\n claimedAt: new Date().toISOString(),\n});\n\n/**\n * Stores what a duplicate of `commandId` is answered with, inside the session\n * that took the claim.\n */\nexport const rememberCommandReplay = (\n db: Db,\n session: ClientSession,\n commandId: string,\n replay: unknown,\n) =>\n db\n .collection<CommandClaim>(COMMAND_INBOX_COLLECTION)\n .updateOne({ commandId }, { $set: { replay } }, { session });\n\n/** What the run that took `commandId` asked to be remembered. */\nexport const readCommandReplay = (db: Db, commandId: string) =>\n Effect.tryPromise({\n try: () =>\n db\n .collection<CommandClaim>(COMMAND_INBOX_COLLECTION)\n .findOne({ commandId }, { projection: { _id: 0 } }),\n catch: error =>\n new EntifixConnError('Failed to read a command claim', error, {\n commandId,\n }),\n }).pipe(\n Effect.map(claim =>\n claim === null || claim.replay === undefined\n ? Option.none()\n : Option.some(claim.replay),\n ),\n );\n"],"names":["EntifixConnError","Effect","Option","COMMAND_INBOX_COLLECTION","ensureCommandInboxIndexes","db","tryPromise","try","collection","createIndex","commandId","unique","name","catch","error","database","databaseName","commandClaimDocument","claimedAt","Date","toISOString","rememberCommandReplay","session","replay","updateOne","$set","readCommandReplay","findOne","projection","_id","pipe","map","claim","undefined","none","some"],"mappings":"AAAA,SAASA,gBAAgB,QAAQ,gBAAgB;AACjD,SAASC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAGxC;;;;;;;;;CASC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAUxD,sCAAsC,GACtC,OAAO,MAAMC,4BAA4B,CAACC,KACxCJ,OAAOK,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMF,GACHG,UAAU,CAAeL,0BACzBM,WAAW,CAAC;gBAAEC,WAAW;YAAE,GAAG;gBAAEC,QAAQ;gBAAMC,MAAM;YAAc;QACvE;QACAC,OAAOC,CAAAA,QACL,IAAId,iBAAiB,0CAA0Cc,OAAO;gBACpEC,UAAUV,GAAGW,YAAY;YAC3B;IACJ,GAAG;AAEL,0DAA0D,GAC1D,OAAO,MAAMC,uBAAuB,CAACP,YAAqC,CAAA;QACxEA;QACAQ,WAAW,IAAIC,OAAOC,WAAW;IACnC,CAAA,EAAG;AAEH;;;CAGC,GACD,OAAO,MAAMC,wBAAwB,CACnChB,IACAiB,SACAZ,WACAa,SAEAlB,GACGG,UAAU,CAAeL,0BACzBqB,SAAS,CAAC;QAAEd;IAAU,GAAG;QAAEe,MAAM;YAAEF;QAAO;IAAE,GAAG;QAAED;IAAQ,GAAG;AAEjE,+DAA+D,GAC/D,OAAO,MAAMI,oBAAoB,CAACrB,IAAQK,YACxCT,OAAOK,UAAU,CAAC;QAChBC,KAAK,IACHF,GACGG,UAAU,CAAeL,0BACzBwB,OAAO,CAAC;gBAAEjB;YAAU,GAAG;gBAAEkB,YAAY;oBAAEC,KAAK;gBAAE;YAAE;QACrDhB,OAAOC,CAAAA,QACL,IAAId,iBAAiB,kCAAkCc,OAAO;gBAC5DJ;YACF;IACJ,GAAGoB,IAAI,CACL7B,OAAO8B,GAAG,CAACC,CAAAA,QACTA,UAAU,QAAQA,MAAMT,MAAM,KAAKU,YAC/B/B,OAAOgC,IAAI,KACXhC,OAAOiC,IAAI,CAACH,MAAMT,MAAM,IAE9B"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { UnitOfWork } from '@entifix/transactions';
|
|
2
|
+
import { Context, Layer } from 'effect';
|
|
3
|
+
import type { ClientSession, Db } from 'mongodb';
|
|
4
|
+
import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
|
+
/**
|
|
6
|
+
* What a Mongo unit's body writes through: the database and the session bound
|
|
7
|
+
* to the unit's transaction. Every write must pass `{ session }`, or it is not
|
|
8
|
+
* part of the unit.
|
|
9
|
+
*/
|
|
10
|
+
export interface MongoUnit {
|
|
11
|
+
readonly db: Db;
|
|
12
|
+
readonly session: ClientSession;
|
|
13
|
+
}
|
|
14
|
+
/** What starts a session — a `MongoClient`, structurally. */
|
|
15
|
+
export interface MongoSessionSource {
|
|
16
|
+
startSession(): ClientSession;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A {@link UnitOfWork} per database, sharing one client — the shape a
|
|
20
|
+
* per-organization store needs, where the database is resolved inside the
|
|
21
|
+
* request. Each database's indexes are ensured once, before its first unit.
|
|
22
|
+
*/
|
|
23
|
+
export declare const makeMongoUnitsOfWork: (client: MongoSessionSource) => {
|
|
24
|
+
forDatabase: (db: Db) => UnitOfWork<MongoUnit>;
|
|
25
|
+
};
|
|
26
|
+
/** A {@link UnitOfWork} over one database. */
|
|
27
|
+
export declare const makeMongoUnitOfWork: (options: {
|
|
28
|
+
readonly client: MongoSessionSource;
|
|
29
|
+
readonly db: Db;
|
|
30
|
+
}) => UnitOfWork<MongoUnit>;
|
|
31
|
+
declare const MongoUnitOfWorkTag_base: Context.ServiceClass<MongoUnitOfWorkTag, "MongoUnitOfWorkTag", UnitOfWork<MongoUnit>>;
|
|
32
|
+
/** The unit of work over the service's own database. */
|
|
33
|
+
export declare class MongoUnitOfWorkTag extends MongoUnitOfWorkTag_base {
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Provides {@link MongoUnitOfWorkTag} from the client and the named database
|
|
37
|
+
* {@link MongoDatabaseLayer} opened.
|
|
38
|
+
*/
|
|
39
|
+
export declare const MongoUnitOfWorkLayer: Layer.Layer<MongoUnitOfWorkTag, never, MongoDatabaseTag | MongoClientTag>;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=mongo-unit-of-work.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mongo-unit-of-work.d.ts","sourceRoot":"","sources":["../../src/unit-of-work/mongo-unit-of-work.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAEV,UAAU,EAGX,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,OAAO,EAAU,KAAK,EAA4B,MAAM,QAAQ,CAAC;AAC1E,OAAO,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAQjD,OAAO,EACL,cAAc,EACd,gBAAgB,EACjB,MAAM,qCAAqC,CAAC;AAc7C;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC;IAChB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;CACjC;AAED,6DAA6D;AAC7D,MAAM,WAAW,kBAAkB;IACjC,YAAY,IAAI,aAAa,CAAC;CAC/B;AAsHD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,kBAAkB;sBAuBpC,EAAE,KAAG,UAAU,CAAC,SAAS,CAAC;CAgIpD,CAAC;AAEF,8CAA8C;AAC9C,eAAO,MAAM,mBAAmB,GAAI,SAAS;IAC3C,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IACpC,QAAQ,CAAC,EAAE,EAAE,EAAE,CAAC;CACjB,KAAG,UAAU,CAAC,SAAS,CACsC,CAAC;;AAE/D,wDAAwD;AACxD,qBAAa,kBAAmB,SAAQ,uBAGf;CAAG;AAE5B;;;GAGG;AACH,eAAO,MAAM,oBAAoB,2EAOhC,CAAC"}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { EntifixConnError } from '@entifix/core';
|
|
2
|
+
import { Context, Effect, Layer, Option, Result, Schedule } from 'effect';
|
|
3
|
+
import { ensureInboxIndexes, INBOX_COLLECTION, inboxDocument, isDuplicateKey } from '../inbox/store.js';
|
|
4
|
+
import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
|
+
import { ensureOutboxIndexes, OUTBOX_COLLECTION, outboxDocument } from '../outbox/store.js';
|
|
6
|
+
import { COMMAND_INBOX_COLLECTION, commandClaimDocument, ensureCommandInboxIndexes, readCommandReplay, rememberCommandReplay } from './command-inbox.js';
|
|
7
|
+
/**
|
|
8
|
+
* How many times a unit runs before a transient conflict is reported as the
|
|
9
|
+
* failure it is, and how long it waits between runs.
|
|
10
|
+
*
|
|
11
|
+
* Measured against a real replica set: eight units incrementing one document
|
|
12
|
+
* at once conflict on every attempt but one, so a short fixed retry count
|
|
13
|
+
* starves most of them. A jittered exponential back-off spreads the reruns so
|
|
14
|
+
* each eventually runs alone.
|
|
15
|
+
*/ const ATTEMPTS = 10;
|
|
16
|
+
const BACK_OFF = Schedule.exponential('5 millis', 1.5).pipe(Schedule.jittered);
|
|
17
|
+
const hasErrorLabel = (error, label)=>{
|
|
18
|
+
var _ref, _ref1;
|
|
19
|
+
var _candidate_hasErrorLabel, _candidate_errorLabels;
|
|
20
|
+
const candidate = error;
|
|
21
|
+
return (_ref = (_ref1 = candidate == null ? void 0 : (_candidate_hasErrorLabel = candidate.hasErrorLabel) == null ? void 0 : _candidate_hasErrorLabel.call(candidate, label)) != null ? _ref1 : candidate == null ? void 0 : (_candidate_errorLabels = candidate.errorLabels) == null ? void 0 : _candidate_errorLabels.includes(label)) != null ? _ref : false;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Whether a server label is on `error` or anywhere in its cause chain — a
|
|
25
|
+
* driver error reaches here wrapped once by the adapter and possibly again by
|
|
26
|
+
* the body that called the driver.
|
|
27
|
+
*/ const labelledInChain = (error, label)=>{
|
|
28
|
+
let current = error;
|
|
29
|
+
for(let depth = 0; current != null && depth < 5; depth += 1){
|
|
30
|
+
if (hasErrorLabel(current, label)) return true;
|
|
31
|
+
current = current.cause;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
};
|
|
35
|
+
const isTransient = (error)=>error instanceof EntifixConnError && labelledInChain(error, 'TransientTransactionError');
|
|
36
|
+
const driver = (message, run, details)=>Effect.tryPromise({
|
|
37
|
+
try: run,
|
|
38
|
+
catch: (error)=>new EntifixConnError(message, error, details)
|
|
39
|
+
});
|
|
40
|
+
/** A duplicate-key rejection answered as `duplicate` rather than as an error. */ const insertOrDuplicate = (message, run, details)=>Effect.tryPromise({
|
|
41
|
+
try: async ()=>{
|
|
42
|
+
try {
|
|
43
|
+
await run();
|
|
44
|
+
return 'inserted';
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isDuplicateKey(error)) return 'duplicate';
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
catch: (error)=>new EntifixConnError(message, error, details)
|
|
51
|
+
});
|
|
52
|
+
const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplicate('Failed to claim an event', ()=>db.collection(INBOX_COLLECTION).insertOne(inboxDocument(claim.consumer, claim.eventId), {
|
|
53
|
+
session
|
|
54
|
+
}), {
|
|
55
|
+
consumer: claim.consumer,
|
|
56
|
+
eventId: claim.eventId
|
|
57
|
+
}) : insertOrDuplicate('Failed to claim a command', ()=>db.collection(COMMAND_INBOX_COLLECTION).insertOne(commandClaimDocument(claim.commandId), {
|
|
58
|
+
session
|
|
59
|
+
}), {
|
|
60
|
+
commandId: claim.commandId
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* Commits, retrying a commit whose result the server could not report. Such a
|
|
64
|
+
* commit may or may not have applied, and Mongo makes retrying it safe.
|
|
65
|
+
*/ const commit = (session)=>driver('Failed to commit a unit of work', ()=>session.commitTransaction()).pipe(Effect.retry({
|
|
66
|
+
times: ATTEMPTS - 1,
|
|
67
|
+
while: (error)=>labelledInChain(error, 'UnknownTransactionCommitResult')
|
|
68
|
+
}));
|
|
69
|
+
/** Rolls back, and never fails: the unit is already ending for another reason. */ const abort = (session)=>driver('Failed to abort a unit of work', ()=>session.abortTransaction()).pipe(Effect.ignore);
|
|
70
|
+
/**
|
|
71
|
+
* A {@link UnitOfWork} per database, sharing one client — the shape a
|
|
72
|
+
* per-organization store needs, where the database is resolved inside the
|
|
73
|
+
* request. Each database's indexes are ensured once, before its first unit.
|
|
74
|
+
*/ export const makeMongoUnitsOfWork = (client)=>{
|
|
75
|
+
const ensured = new Map();
|
|
76
|
+
const ensureIndexes = (db)=>Effect.suspend(()=>{
|
|
77
|
+
const key = db.databaseName;
|
|
78
|
+
const done = ensured.get(key);
|
|
79
|
+
if (done !== undefined) {
|
|
80
|
+
return driver('Failed to ensure unit-of-work indexes', ()=>done);
|
|
81
|
+
}
|
|
82
|
+
const pending = Effect.runPromise(Effect.all([
|
|
83
|
+
ensureOutboxIndexes(db),
|
|
84
|
+
ensureInboxIndexes(db),
|
|
85
|
+
ensureCommandInboxIndexes(db)
|
|
86
|
+
])).then(()=>undefined);
|
|
87
|
+
ensured.set(key, pending);
|
|
88
|
+
// A failed ensure must be retried by the next unit, not remembered.
|
|
89
|
+
pending.catch(()=>ensured.delete(key));
|
|
90
|
+
return driver('Failed to ensure unit-of-work indexes', ()=>pending);
|
|
91
|
+
});
|
|
92
|
+
const forDatabase = (db)=>{
|
|
93
|
+
const attempt = (body, options)=>Effect.acquireUseRelease(Effect.sync(()=>client.startSession()), (session)=>Effect.gen(function*() {
|
|
94
|
+
yield* Effect.try({
|
|
95
|
+
try: ()=>session.startTransaction(),
|
|
96
|
+
catch: (error)=>new EntifixConnError('Failed to start a unit of work', error)
|
|
97
|
+
});
|
|
98
|
+
const work = Effect.gen(function*() {
|
|
99
|
+
const claim = options == null ? void 0 : options.claim;
|
|
100
|
+
if (claim && (yield* takeClaim(db, session, claim)) === 'duplicate') {
|
|
101
|
+
return {
|
|
102
|
+
kind: 'duplicate'
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const emitted = [];
|
|
106
|
+
const value = yield* body({
|
|
107
|
+
handle: {
|
|
108
|
+
db,
|
|
109
|
+
session
|
|
110
|
+
},
|
|
111
|
+
emit: (event)=>void emitted.push(event)
|
|
112
|
+
});
|
|
113
|
+
if (emitted.length > 0) {
|
|
114
|
+
const written = yield* insertOrDuplicate('Failed to record a unit of work’s events', ()=>db.collection(OUTBOX_COLLECTION).insertMany(emitted.map(outboxDocument), {
|
|
115
|
+
session
|
|
116
|
+
}));
|
|
117
|
+
// An event id already recorded is the outbox's own
|
|
118
|
+
// idempotency claim: what it announces committed before.
|
|
119
|
+
if (written === 'duplicate') {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'duplicate'
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if ((claim == null ? void 0 : claim.kind) === 'command' && (options == null ? void 0 : options.replay)) {
|
|
126
|
+
const replay = options.replay(value);
|
|
127
|
+
yield* driver('Failed to remember a command’s answer', ()=>rememberCommandReplay(db, session, claim.commandId, replay));
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
kind: 'commit',
|
|
131
|
+
value
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
const decided = yield* Effect.result(work);
|
|
135
|
+
if (Result.isFailure(decided)) {
|
|
136
|
+
yield* abort(session);
|
|
137
|
+
const failure = decided.failure;
|
|
138
|
+
if (failure instanceof EntifixConnError) {
|
|
139
|
+
return yield* Effect.fail(failure);
|
|
140
|
+
}
|
|
141
|
+
// A conflict the body surfaced without wrapping is still the
|
|
142
|
+
// store's, not a business decision: it is retried, not aborted.
|
|
143
|
+
if (labelledInChain(failure, 'TransientTransactionError')) {
|
|
144
|
+
return yield* Effect.fail(new EntifixConnError('A unit of work hit a transient conflict', failure));
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
_tag: 'aborted',
|
|
148
|
+
reason: failure
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (decided.success.kind === 'duplicate') {
|
|
152
|
+
var _options_claim;
|
|
153
|
+
yield* abort(session);
|
|
154
|
+
const replay = (options == null ? void 0 : (_options_claim = options.claim) == null ? void 0 : _options_claim.kind) === 'command' ? yield* readCommandReplay(db, options.claim.commandId) : Option.none();
|
|
155
|
+
return {
|
|
156
|
+
_tag: 'duplicate',
|
|
157
|
+
replay
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
yield* commit(session);
|
|
161
|
+
return {
|
|
162
|
+
_tag: 'committed',
|
|
163
|
+
value: decided.success.value
|
|
164
|
+
};
|
|
165
|
+
}), (session)=>driver('Failed to end a session', ()=>session.endSession()).pipe(Effect.ignore));
|
|
166
|
+
return {
|
|
167
|
+
run: (body, options)=>Effect.gen(function*() {
|
|
168
|
+
yield* ensureIndexes(db);
|
|
169
|
+
const outcome = yield* attempt(body, options).pipe(// The whole unit runs again, body included: that is why the body
|
|
170
|
+
// must be retry-safe.
|
|
171
|
+
Effect.retry({
|
|
172
|
+
times: ATTEMPTS - 1,
|
|
173
|
+
while: isTransient,
|
|
174
|
+
schedule: BACK_OFF
|
|
175
|
+
}));
|
|
176
|
+
if (outcome._tag === 'committed' && (options == null ? void 0 : options.afterCommit)) {
|
|
177
|
+
yield* options.afterCommit.pipe(Effect.ignore);
|
|
178
|
+
}
|
|
179
|
+
return outcome;
|
|
180
|
+
}),
|
|
181
|
+
replay: (commandId)=>readCommandReplay(db, commandId)
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
return {
|
|
185
|
+
forDatabase
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
/** A {@link UnitOfWork} over one database. */ export const makeMongoUnitOfWork = (options)=>makeMongoUnitsOfWork(options.client).forDatabase(options.db);
|
|
189
|
+
/** The unit of work over the service's own database. */ export class MongoUnitOfWorkTag extends Context.Service()('MongoUnitOfWorkTag') {
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Provides {@link MongoUnitOfWorkTag} from the client and the named database
|
|
193
|
+
* {@link MongoDatabaseLayer} opened.
|
|
194
|
+
*/ export const MongoUnitOfWorkLayer = Layer.effect(MongoUnitOfWorkTag, Effect.gen(function*() {
|
|
195
|
+
const client = yield* MongoClientTag;
|
|
196
|
+
const db = yield* MongoDatabaseTag;
|
|
197
|
+
return makeMongoUnitOfWork({
|
|
198
|
+
client,
|
|
199
|
+
db
|
|
200
|
+
});
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
//# sourceMappingURL=mongo-unit-of-work.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/unit-of-work/mongo-unit-of-work.ts"],"sourcesContent":["import { type DomainEvent, EntifixConnError } from '@entifix/core';\nimport type {\n UnitClaim,\n UnitOfWork,\n UnitOutcome,\n UnitRunOptions,\n} from '@entifix/transactions';\nimport { Context, Effect, Layer, Option, Result, Schedule } from 'effect';\nimport type { ClientSession, Db } from 'mongodb';\n\nimport {\n ensureInboxIndexes,\n INBOX_COLLECTION,\n inboxDocument,\n isDuplicateKey,\n} from '../inbox/store.js';\nimport {\n MongoClientTag,\n MongoDatabaseTag,\n} from '../mongo-database/mongo-database.js';\nimport {\n ensureOutboxIndexes,\n OUTBOX_COLLECTION,\n outboxDocument,\n} from '../outbox/store.js';\nimport {\n COMMAND_INBOX_COLLECTION,\n commandClaimDocument,\n ensureCommandInboxIndexes,\n readCommandReplay,\n rememberCommandReplay,\n} from './command-inbox.js';\n\n/**\n * What a Mongo unit's body writes through: the database and the session bound\n * to the unit's transaction. Every write must pass `{ session }`, or it is not\n * part of the unit.\n */\nexport interface MongoUnit {\n readonly db: Db;\n readonly session: ClientSession;\n}\n\n/** What starts a session — a `MongoClient`, structurally. */\nexport interface MongoSessionSource {\n startSession(): ClientSession;\n}\n\n/**\n * How many times a unit runs before a transient conflict is reported as the\n * failure it is, and how long it waits between runs.\n *\n * Measured against a real replica set: eight units incrementing one document\n * at once conflict on every attempt but one, so a short fixed retry count\n * starves most of them. A jittered exponential back-off spreads the reruns so\n * each eventually runs alone.\n */\nconst ATTEMPTS = 10;\nconst BACK_OFF = Schedule.exponential('5 millis', 1.5).pipe(Schedule.jittered);\n\nconst hasErrorLabel = (error: unknown, label: string): boolean => {\n const candidate = error as {\n hasErrorLabel?: (label: string) => boolean;\n errorLabels?: readonly string[];\n } | null;\n return (\n candidate?.hasErrorLabel?.(label) ??\n candidate?.errorLabels?.includes(label) ??\n false\n );\n};\n\n/**\n * Whether a server label is on `error` or anywhere in its cause chain — a\n * driver error reaches here wrapped once by the adapter and possibly again by\n * the body that called the driver.\n */\nconst labelledInChain = (error: unknown, label: string): boolean => {\n let current: unknown = error;\n for (let depth = 0; current != null && depth < 5; depth += 1) {\n if (hasErrorLabel(current, label)) return true;\n current = (current as { cause?: unknown }).cause;\n }\n return false;\n};\n\nconst isTransient = (error: unknown): boolean =>\n error instanceof EntifixConnError &&\n labelledInChain(error, 'TransientTransactionError');\n\nconst driver = <A>(\n message: string,\n run: () => Promise<A>,\n details?: Record<string, unknown>,\n) =>\n Effect.tryPromise({\n try: run,\n catch: error => new EntifixConnError(message, error, details),\n });\n\n/** A duplicate-key rejection answered as `duplicate` rather than as an error. */\nconst insertOrDuplicate = (\n message: string,\n run: () => Promise<unknown>,\n details?: Record<string, unknown>,\n) =>\n Effect.tryPromise({\n try: async () => {\n try {\n await run();\n return 'inserted' as const;\n } catch (error) {\n if (isDuplicateKey(error)) return 'duplicate' as const;\n throw error;\n }\n },\n catch: error => new EntifixConnError(message, error, details),\n });\n\nconst takeClaim = (db: Db, session: ClientSession, claim: UnitClaim) =>\n claim.kind === 'event'\n ? insertOrDuplicate(\n 'Failed to claim an event',\n () =>\n db\n .collection(INBOX_COLLECTION)\n .insertOne(inboxDocument(claim.consumer, claim.eventId), {\n session,\n }),\n { consumer: claim.consumer, eventId: claim.eventId },\n )\n : insertOrDuplicate(\n 'Failed to claim a command',\n () =>\n db\n .collection(COMMAND_INBOX_COLLECTION)\n .insertOne(commandClaimDocument(claim.commandId), { session }),\n { commandId: claim.commandId },\n );\n\n/**\n * Commits, retrying a commit whose result the server could not report. Such a\n * commit may or may not have applied, and Mongo makes retrying it safe.\n */\nconst commit = (session: ClientSession) =>\n driver('Failed to commit a unit of work', () =>\n session.commitTransaction(),\n ).pipe(\n Effect.retry({\n times: ATTEMPTS - 1,\n while: error => labelledInChain(error, 'UnknownTransactionCommitResult'),\n }),\n );\n\n/** Rolls back, and never fails: the unit is already ending for another reason. */\nconst abort = (session: ClientSession) =>\n driver('Failed to abort a unit of work', () =>\n session.abortTransaction(),\n ).pipe(Effect.ignore);\n\ntype Decision<A> =\n | { readonly kind: 'commit'; readonly value: A }\n | { readonly kind: 'duplicate' };\n\n/**\n * A {@link UnitOfWork} per database, sharing one client — the shape a\n * per-organization store needs, where the database is resolved inside the\n * request. Each database's indexes are ensured once, before its first unit.\n */\nexport const makeMongoUnitsOfWork = (client: MongoSessionSource) => {\n const ensured = new Map<string, Promise<void>>();\n\n const ensureIndexes = (db: Db) =>\n Effect.suspend(() => {\n const key = db.databaseName;\n const done = ensured.get(key);\n if (done !== undefined) {\n return driver('Failed to ensure unit-of-work indexes', () => done);\n }\n const pending = Effect.runPromise(\n Effect.all([\n ensureOutboxIndexes(db),\n ensureInboxIndexes(db),\n ensureCommandInboxIndexes(db),\n ]),\n ).then(() => undefined);\n ensured.set(key, pending);\n // A failed ensure must be retried by the next unit, not remembered.\n pending.catch(() => ensured.delete(key));\n return driver('Failed to ensure unit-of-work indexes', () => pending);\n });\n\n const forDatabase = (db: Db): UnitOfWork<MongoUnit> => {\n const attempt = <A, E, R>(\n body: (unit: {\n readonly handle: MongoUnit;\n emit(event: DomainEvent): void;\n }) => Effect.Effect<A, E, R>,\n options: UnitRunOptions<A, R> | undefined,\n ) =>\n Effect.acquireUseRelease(\n Effect.sync(() => client.startSession()),\n session =>\n Effect.gen(function* () {\n yield* Effect.try({\n try: () => session.startTransaction(),\n catch: error =>\n new EntifixConnError('Failed to start a unit of work', error),\n });\n\n const work = Effect.gen(function* () {\n const claim = options?.claim;\n if (\n claim &&\n (yield* takeClaim(db, session, claim)) === 'duplicate'\n ) {\n return { kind: 'duplicate' } as Decision<A>;\n }\n\n const emitted: DomainEvent[] = [];\n const value = yield* body({\n handle: { db, session },\n emit: event => void emitted.push(event),\n });\n\n if (emitted.length > 0) {\n const written = yield* insertOrDuplicate(\n 'Failed to record a unit of work’s events',\n () =>\n db\n .collection(OUTBOX_COLLECTION)\n .insertMany(emitted.map(outboxDocument), { session }),\n );\n // An event id already recorded is the outbox's own\n // idempotency claim: what it announces committed before.\n if (written === 'duplicate') {\n return { kind: 'duplicate' } as Decision<A>;\n }\n }\n\n if (claim?.kind === 'command' && options?.replay) {\n const replay = options.replay(value);\n yield* driver('Failed to remember a command’s answer', () =>\n rememberCommandReplay(db, session, claim.commandId, replay),\n );\n }\n return { kind: 'commit', value } as Decision<A>;\n });\n\n const decided = yield* Effect.result(work);\n\n if (Result.isFailure(decided)) {\n yield* abort(session);\n const failure = decided.failure;\n if (failure instanceof EntifixConnError) {\n return yield* Effect.fail(failure);\n }\n // A conflict the body surfaced without wrapping is still the\n // store's, not a business decision: it is retried, not aborted.\n if (labelledInChain(failure, 'TransientTransactionError')) {\n return yield* Effect.fail(\n new EntifixConnError(\n 'A unit of work hit a transient conflict',\n failure,\n ),\n );\n }\n return {\n _tag: 'aborted',\n reason: failure as Exclude<E, EntifixConnError>,\n } as UnitOutcome<A, Exclude<E, EntifixConnError>>;\n }\n\n if (decided.success.kind === 'duplicate') {\n yield* abort(session);\n const replay =\n options?.claim?.kind === 'command'\n ? yield* readCommandReplay(db, options.claim.commandId)\n : Option.none();\n return { _tag: 'duplicate', replay } as UnitOutcome<\n A,\n Exclude<E, EntifixConnError>\n >;\n }\n\n yield* commit(session);\n return {\n _tag: 'committed',\n value: decided.success.value,\n } as UnitOutcome<A, Exclude<E, EntifixConnError>>;\n }),\n session =>\n driver('Failed to end a session', () => session.endSession()).pipe(\n Effect.ignore,\n ),\n );\n\n return {\n run: (body, options) =>\n Effect.gen(function* () {\n yield* ensureIndexes(db);\n const outcome = yield* attempt(body, options).pipe(\n // The whole unit runs again, body included: that is why the body\n // must be retry-safe.\n Effect.retry({\n times: ATTEMPTS - 1,\n while: isTransient,\n schedule: BACK_OFF,\n }),\n );\n if (outcome._tag === 'committed' && options?.afterCommit) {\n yield* options.afterCommit.pipe(Effect.ignore);\n }\n return outcome;\n }),\n replay: commandId => readCommandReplay(db, commandId),\n };\n };\n\n return { forDatabase };\n};\n\n/** A {@link UnitOfWork} over one database. */\nexport const makeMongoUnitOfWork = (options: {\n readonly client: MongoSessionSource;\n readonly db: Db;\n}): UnitOfWork<MongoUnit> =>\n makeMongoUnitsOfWork(options.client).forDatabase(options.db);\n\n/** The unit of work over the service's own database. */\nexport class MongoUnitOfWorkTag extends Context.Service<\n MongoUnitOfWorkTag,\n UnitOfWork<MongoUnit>\n>()('MongoUnitOfWorkTag') {}\n\n/**\n * Provides {@link MongoUnitOfWorkTag} from the client and the named database\n * {@link MongoDatabaseLayer} opened.\n */\nexport const MongoUnitOfWorkLayer = Layer.effect(\n MongoUnitOfWorkTag,\n Effect.gen(function* () {\n const client = yield* MongoClientTag;\n const db = yield* MongoDatabaseTag;\n return makeMongoUnitOfWork({ client, db });\n }),\n);\n"],"names":["EntifixConnError","Context","Effect","Layer","Option","Result","Schedule","ensureInboxIndexes","INBOX_COLLECTION","inboxDocument","isDuplicateKey","MongoClientTag","MongoDatabaseTag","ensureOutboxIndexes","OUTBOX_COLLECTION","outboxDocument","COMMAND_INBOX_COLLECTION","commandClaimDocument","ensureCommandInboxIndexes","readCommandReplay","rememberCommandReplay","ATTEMPTS","BACK_OFF","exponential","pipe","jittered","hasErrorLabel","error","label","candidate","errorLabels","includes","labelledInChain","current","depth","cause","isTransient","driver","message","run","details","tryPromise","try","catch","insertOrDuplicate","takeClaim","db","session","claim","kind","collection","insertOne","consumer","eventId","commandId","commit","commitTransaction","retry","times","while","abort","abortTransaction","ignore","makeMongoUnitsOfWork","client","ensured","Map","ensureIndexes","suspend","key","databaseName","done","get","undefined","pending","runPromise","all","then","set","delete","forDatabase","attempt","body","options","acquireUseRelease","sync","startSession","gen","startTransaction","work","emitted","value","handle","emit","event","push","length","written","insertMany","map","replay","decided","result","isFailure","failure","fail","_tag","reason","success","none","endSession","outcome","schedule","afterCommit","makeMongoUnitOfWork","MongoUnitOfWorkTag","Service","MongoUnitOfWorkLayer","effect"],"mappings":"AAAA,SAA2BA,gBAAgB,QAAQ,gBAAgB;AAOnE,SAASC,OAAO,EAAEC,MAAM,EAAEC,KAAK,EAAEC,MAAM,EAAEC,MAAM,EAAEC,QAAQ,QAAQ,SAAS;AAG1E,SACEC,kBAAkB,EAClBC,gBAAgB,EAChBC,aAAa,EACbC,cAAc,QACT,oBAAoB;AAC3B,SACEC,cAAc,EACdC,gBAAgB,QACX,sCAAsC;AAC7C,SACEC,mBAAmB,EACnBC,iBAAiB,EACjBC,cAAc,QACT,qBAAqB;AAC5B,SACEC,wBAAwB,EACxBC,oBAAoB,EACpBC,yBAAyB,EACzBC,iBAAiB,EACjBC,qBAAqB,QAChB,qBAAqB;AAiB5B;;;;;;;;CAQC,GACD,MAAMC,WAAW;AACjB,MAAMC,WAAWhB,SAASiB,WAAW,CAAC,YAAY,KAAKC,IAAI,CAAClB,SAASmB,QAAQ;AAE7E,MAAMC,gBAAgB,CAACC,OAAgBC;QAMnCC;QAAAA,0BACAA;IANF,MAAMA,YAAYF;IAIlB,QACEE,gBAAAA,8BAAAA,2BAAAA,UAAWH,aAAa,qBAAxBG,8BAAAA,WAA2BD,0BAC3BC,8BAAAA,yBAAAA,UAAWC,WAAW,qBAAtBD,uBAAwBE,QAAQ,CAACH,kBADjCC,OAEA;AAEJ;AAEA;;;;CAIC,GACD,MAAMG,kBAAkB,CAACL,OAAgBC;IACvC,IAAIK,UAAmBN;IACvB,IAAK,IAAIO,QAAQ,GAAGD,WAAW,QAAQC,QAAQ,GAAGA,SAAS,EAAG;QAC5D,IAAIR,cAAcO,SAASL,QAAQ,OAAO;QAC1CK,UAAU,AAACA,QAAgCE,KAAK;IAClD;IACA,OAAO;AACT;AAEA,MAAMC,cAAc,CAACT,QACnBA,iBAAiB3B,oBACjBgC,gBAAgBL,OAAO;AAEzB,MAAMU,SAAS,CACbC,SACAC,KACAC,UAEAtC,OAAOuC,UAAU,CAAC;QAChBC,KAAKH;QACLI,OAAOhB,CAAAA,QAAS,IAAI3B,iBAAiBsC,SAASX,OAAOa;IACvD;AAEF,+EAA+E,GAC/E,MAAMI,oBAAoB,CACxBN,SACAC,KACAC,UAEAtC,OAAOuC,UAAU,CAAC;QAChBC,KAAK;YACH,IAAI;gBACF,MAAMH;gBACN,OAAO;YACT,EAAE,OAAOZ,OAAO;gBACd,IAAIjB,eAAeiB,QAAQ,OAAO;gBAClC,MAAMA;YACR;QACF;QACAgB,OAAOhB,CAAAA,QAAS,IAAI3B,iBAAiBsC,SAASX,OAAOa;IACvD;AAEF,MAAMK,YAAY,CAACC,IAAQC,SAAwBC,QACjDA,MAAMC,IAAI,KAAK,UACXL,kBACE,4BACA,IACEE,GACGI,UAAU,CAAC1C,kBACX2C,SAAS,CAAC1C,cAAcuC,MAAMI,QAAQ,EAAEJ,MAAMK,OAAO,GAAG;YACvDN;QACF,IACJ;QAAEK,UAAUJ,MAAMI,QAAQ;QAAEC,SAASL,MAAMK,OAAO;IAAC,KAErDT,kBACE,6BACA,IACEE,GACGI,UAAU,CAAClC,0BACXmC,SAAS,CAAClC,qBAAqB+B,MAAMM,SAAS,GAAG;YAAEP;QAAQ,IAChE;QAAEO,WAAWN,MAAMM,SAAS;IAAC;AAGrC;;;CAGC,GACD,MAAMC,SAAS,CAACR,UACdV,OAAO,mCAAmC,IACxCU,QAAQS,iBAAiB,IACzBhC,IAAI,CACJtB,OAAOuD,KAAK,CAAC;QACXC,OAAOrC,WAAW;QAClBsC,OAAOhC,CAAAA,QAASK,gBAAgBL,OAAO;IACzC;AAGJ,gFAAgF,GAChF,MAAMiC,QAAQ,CAACb,UACbV,OAAO,kCAAkC,IACvCU,QAAQc,gBAAgB,IACxBrC,IAAI,CAACtB,OAAO4D,MAAM;AAMtB;;;;CAIC,GACD,OAAO,MAAMC,uBAAuB,CAACC;IACnC,MAAMC,UAAU,IAAIC;IAEpB,MAAMC,gBAAgB,CAACrB,KACrB5C,OAAOkE,OAAO,CAAC;YACb,MAAMC,MAAMvB,GAAGwB,YAAY;YAC3B,MAAMC,OAAON,QAAQO,GAAG,CAACH;YACzB,IAAIE,SAASE,WAAW;gBACtB,OAAOpC,OAAO,yCAAyC,IAAMkC;YAC/D;YACA,MAAMG,UAAUxE,OAAOyE,UAAU,CAC/BzE,OAAO0E,GAAG,CAAC;gBACT/D,oBAAoBiC;gBACpBvC,mBAAmBuC;gBACnB5B,0BAA0B4B;aAC3B,GACD+B,IAAI,CAAC,IAAMJ;YACbR,QAAQa,GAAG,CAACT,KAAKK;YACjB,oEAAoE;YACpEA,QAAQ/B,KAAK,CAAC,IAAMsB,QAAQc,MAAM,CAACV;YACnC,OAAOhC,OAAO,yCAAyC,IAAMqC;QAC/D;IAEF,MAAMM,cAAc,CAAClC;QACnB,MAAMmC,UAAU,CACdC,MAIAC,UAEAjF,OAAOkF,iBAAiB,CACtBlF,OAAOmF,IAAI,CAAC,IAAMrB,OAAOsB,YAAY,KACrCvC,CAAAA,UACE7C,OAAOqF,GAAG,CAAC;oBACT,OAAOrF,OAAOwC,GAAG,CAAC;wBAChBA,KAAK,IAAMK,QAAQyC,gBAAgB;wBACnC7C,OAAOhB,CAAAA,QACL,IAAI3B,iBAAiB,kCAAkC2B;oBAC3D;oBAEA,MAAM8D,OAAOvF,OAAOqF,GAAG,CAAC;wBACtB,MAAMvC,QAAQmC,2BAAAA,QAASnC,KAAK;wBAC5B,IACEA,SACA,AAAC,CAAA,OAAOH,UAAUC,IAAIC,SAASC,MAAK,MAAO,aAC3C;4BACA,OAAO;gCAAEC,MAAM;4BAAY;wBAC7B;wBAEA,MAAMyC,UAAyB,EAAE;wBACjC,MAAMC,QAAQ,OAAOT,KAAK;4BACxBU,QAAQ;gCAAE9C;gCAAIC;4BAAQ;4BACtB8C,MAAMC,CAAAA,QAAS,KAAKJ,QAAQK,IAAI,CAACD;wBACnC;wBAEA,IAAIJ,QAAQM,MAAM,GAAG,GAAG;4BACtB,MAAMC,UAAU,OAAOrD,kBACrB,4CACA,IACEE,GACGI,UAAU,CAACpC,mBACXoF,UAAU,CAACR,QAAQS,GAAG,CAACpF,iBAAiB;oCAAEgC;gCAAQ;4BAEzD,mDAAmD;4BACnD,yDAAyD;4BACzD,IAAIkD,YAAY,aAAa;gCAC3B,OAAO;oCAAEhD,MAAM;gCAAY;4BAC7B;wBACF;wBAEA,IAAID,CAAAA,yBAAAA,MAAOC,IAAI,MAAK,cAAakC,2BAAAA,QAASiB,MAAM,GAAE;4BAChD,MAAMA,SAASjB,QAAQiB,MAAM,CAACT;4BAC9B,OAAOtD,OAAO,yCAAyC,IACrDjB,sBAAsB0B,IAAIC,SAASC,MAAMM,SAAS,EAAE8C;wBAExD;wBACA,OAAO;4BAAEnD,MAAM;4BAAU0C;wBAAM;oBACjC;oBAEA,MAAMU,UAAU,OAAOnG,OAAOoG,MAAM,CAACb;oBAErC,IAAIpF,OAAOkG,SAAS,CAACF,UAAU;wBAC7B,OAAOzC,MAAMb;wBACb,MAAMyD,UAAUH,QAAQG,OAAO;wBAC/B,IAAIA,mBAAmBxG,kBAAkB;4BACvC,OAAO,OAAOE,OAAOuG,IAAI,CAACD;wBAC5B;wBACA,6DAA6D;wBAC7D,gEAAgE;wBAChE,IAAIxE,gBAAgBwE,SAAS,8BAA8B;4BACzD,OAAO,OAAOtG,OAAOuG,IAAI,CACvB,IAAIzG,iBACF,2CACAwG;wBAGN;wBACA,OAAO;4BACLE,MAAM;4BACNC,QAAQH;wBACV;oBACF;oBAEA,IAAIH,QAAQO,OAAO,CAAC3D,IAAI,KAAK,aAAa;4BAGtCkC;wBAFF,OAAOvB,MAAMb;wBACb,MAAMqD,SACJjB,CAAAA,4BAAAA,iBAAAA,QAASnC,KAAK,qBAAdmC,eAAgBlC,IAAI,MAAK,YACrB,OAAO9B,kBAAkB2B,IAAIqC,QAAQnC,KAAK,CAACM,SAAS,IACpDlD,OAAOyG,IAAI;wBACjB,OAAO;4BAAEH,MAAM;4BAAaN;wBAAO;oBAIrC;oBAEA,OAAO7C,OAAOR;oBACd,OAAO;wBACL2D,MAAM;wBACNf,OAAOU,QAAQO,OAAO,CAACjB,KAAK;oBAC9B;gBACF,IACF5C,CAAAA,UACEV,OAAO,2BAA2B,IAAMU,QAAQ+D,UAAU,IAAItF,IAAI,CAChEtB,OAAO4D,MAAM;QAIrB,OAAO;YACLvB,KAAK,CAAC2C,MAAMC,UACVjF,OAAOqF,GAAG,CAAC;oBACT,OAAOpB,cAAcrB;oBACrB,MAAMiE,UAAU,OAAO9B,QAAQC,MAAMC,SAAS3D,IAAI,CAChD,iEAAiE;oBACjE,sBAAsB;oBACtBtB,OAAOuD,KAAK,CAAC;wBACXC,OAAOrC,WAAW;wBAClBsC,OAAOvB;wBACP4E,UAAU1F;oBACZ;oBAEF,IAAIyF,QAAQL,IAAI,KAAK,gBAAevB,2BAAAA,QAAS8B,WAAW,GAAE;wBACxD,OAAO9B,QAAQ8B,WAAW,CAACzF,IAAI,CAACtB,OAAO4D,MAAM;oBAC/C;oBACA,OAAOiD;gBACT;YACFX,QAAQ9C,CAAAA,YAAanC,kBAAkB2B,IAAIQ;QAC7C;IACF;IAEA,OAAO;QAAE0B;IAAY;AACvB,EAAE;AAEF,4CAA4C,GAC5C,OAAO,MAAMkC,sBAAsB,CAAC/B,UAIlCpB,qBAAqBoB,QAAQnB,MAAM,EAAEgB,WAAW,CAACG,QAAQrC,EAAE,EAAE;AAE/D,sDAAsD,GACtD,OAAO,MAAMqE,2BAA2BlH,QAAQmH,OAAO,GAGnD;AAAuB;AAE3B;;;CAGC,GACD,OAAO,MAAMC,uBAAuBlH,MAAMmH,MAAM,CAC9CH,oBACAjH,OAAOqF,GAAG,CAAC;IACT,MAAMvB,SAAS,OAAOrD;IACtB,MAAMmC,KAAK,OAAOlC;IAClB,OAAOsG,oBAAoB;QAAElD;QAAQlB;IAAG;AAC1C,IACA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@entifix/mongo",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "MongoDB adapter: an entity repository, RSQL-to-Mongo query translation, per-tenant databases, and an optional transactional outbox.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -57,17 +57,17 @@
|
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@swc/helpers": "0.5.23",
|
|
59
59
|
"mongodb": "7.7.0",
|
|
60
|
-
"@entifix/
|
|
61
|
-
"@entifix/
|
|
60
|
+
"@entifix/core": "0.7.2",
|
|
61
|
+
"@entifix/business": "0.7.2"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"effect": "4.0.5",
|
|
65
|
-
"@entifix/testing-unit": "0.7.
|
|
66
|
-
"@entifix/transactions": "0.7.
|
|
65
|
+
"@entifix/testing-unit": "0.7.2",
|
|
66
|
+
"@entifix/transactions": "0.7.2"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"effect": "^4.0.5",
|
|
70
|
-
"@entifix/transactions": "0.7.
|
|
70
|
+
"@entifix/transactions": "0.7.2"
|
|
71
71
|
},
|
|
72
72
|
"peerDependenciesMeta": {
|
|
73
73
|
"@entifix/transactions": {
|