@entifix/mongo 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/make-mongo-facet-counter.d.ts.map +1 -1
- package/dist/adapters/make-mongo-facet-counter.js +3 -4
- package/dist/adapters/make-mongo-facet-counter.js.map +1 -1
- package/dist/adapters/make-mongo-link-resolver.d.ts.map +1 -1
- package/dist/adapters/make-mongo-link-resolver.js +2 -4
- package/dist/adapters/make-mongo-link-resolver.js.map +1 -1
- package/dist/adapters/make-mongo-repository.d.ts +3 -3
- package/dist/adapters/make-mongo-repository.d.ts.map +1 -1
- package/dist/adapters/make-mongo-repository.js +11 -18
- package/dist/adapters/make-mongo-repository.js.map +1 -1
- package/dist/adapters/mongo-facet-counter-factory.d.ts.map +1 -1
- package/dist/adapters/mongo-facet-counter-factory.js +2 -4
- package/dist/adapters/mongo-facet-counter-factory.js.map +1 -1
- package/dist/adapters/mongo-repository-factory.d.ts.map +1 -1
- package/dist/adapters/mongo-repository-factory.js +2 -4
- package/dist/adapters/mongo-repository-factory.js.map +1 -1
- package/dist/errors/translate-mongo-error.d.ts +30 -0
- package/dist/errors/translate-mongo-error.d.ts.map +1 -0
- package/dist/errors/translate-mongo-error.js +74 -0
- package/dist/errors/translate-mongo-error.js.map +1 -0
- package/dist/inbox/store.d.ts +1 -2
- package/dist/inbox/store.d.ts.map +1 -1
- package/dist/inbox/store.js +3 -8
- package/dist/inbox/store.js.map +1 -1
- package/dist/mongo-database/mongo-database.d.ts +3 -3
- package/dist/mongo-database/mongo-database.d.ts.map +1 -1
- package/dist/mongo-database/mongo-database.js +2 -2
- package/dist/mongo-database/mongo-database.js.map +1 -1
- package/dist/mongo-database/mongo-tenant-directory.d.ts.map +1 -1
- package/dist/mongo-database/mongo-tenant-directory.js +2 -4
- package/dist/mongo-database/mongo-tenant-directory.js.map +1 -1
- package/dist/mongo-database/mongo-tenant-resolver.d.ts.map +1 -1
- package/dist/mongo-database/mongo-tenant-resolver.js +18 -7
- package/dist/mongo-database/mongo-tenant-resolver.js.map +1 -1
- package/dist/outbox/relay.d.ts +50 -9
- package/dist/outbox/relay.d.ts.map +1 -1
- package/dist/outbox/relay.js +67 -26
- package/dist/outbox/relay.js.map +1 -1
- package/dist/outbox/store.d.ts +3 -3
- package/dist/outbox/store.d.ts.map +1 -1
- package/dist/outbox/store.js +8 -22
- package/dist/outbox/store.js.map +1 -1
- package/dist/saga/store.d.ts +3 -4
- package/dist/saga/store.d.ts.map +1 -1
- package/dist/saga/store.js +10 -15
- package/dist/saga/store.js.map +1 -1
- package/dist/unit-of-work/command-inbox.d.ts +2 -3
- package/dist/unit-of-work/command-inbox.d.ts.map +1 -1
- package/dist/unit-of-work/command-inbox.js +3 -7
- package/dist/unit-of-work/command-inbox.js.map +1 -1
- package/dist/unit-of-work/mongo-unit-of-work.d.ts.map +1 -1
- package/dist/unit-of-work/mongo-unit-of-work.js +30 -26
- package/dist/unit-of-work/mongo-unit-of-work.js.map +1 -1
- package/package.json +8 -8
package/dist/outbox/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { EntifixConnError } from '@entifix/core';
|
|
3
2
|
import { Effect } from 'effect';
|
|
3
|
+
import { translateMongoError } from '../errors/translate-mongo-error.js';
|
|
4
4
|
/**
|
|
5
5
|
* The outbox collection, and it lives in the **same database as the entity it
|
|
6
6
|
* describes** — never in a shared control-plane store.
|
|
@@ -83,10 +83,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
83
83
|
});
|
|
84
84
|
return result.modifiedCount === 1;
|
|
85
85
|
},
|
|
86
|
-
catch: (error)=>
|
|
87
|
-
eventId,
|
|
88
|
-
db: db.databaseName
|
|
89
|
-
})
|
|
86
|
+
catch: (error)=>translateMongoError(error)
|
|
90
87
|
});
|
|
91
88
|
/**
|
|
92
89
|
* Ensures the two indexes the outbox depends on.
|
|
@@ -123,9 +120,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
123
120
|
}
|
|
124
121
|
});
|
|
125
122
|
},
|
|
126
|
-
catch: (error)=>
|
|
127
|
-
db: db.databaseName
|
|
128
|
-
})
|
|
123
|
+
catch: (error)=>translateMongoError(error)
|
|
129
124
|
});
|
|
130
125
|
/**
|
|
131
126
|
* Mongo-backed {@link TransactionOutbox} over one database.
|
|
@@ -136,7 +131,6 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
136
131
|
* tested without a broker.
|
|
137
132
|
*/ export const makeMongoOutbox = (db)=>{
|
|
138
133
|
const collection = db.collection(OUTBOX_COLLECTION);
|
|
139
|
-
const fail = (message, error, details)=>new EntifixConnError(message, error, details);
|
|
140
134
|
return {
|
|
141
135
|
enqueue: (event)=>Effect.tryPromise({
|
|
142
136
|
try: async ()=>{
|
|
@@ -152,9 +146,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
152
146
|
throw error;
|
|
153
147
|
}
|
|
154
148
|
},
|
|
155
|
-
catch: (error)=>
|
|
156
|
-
eventId: event.id
|
|
157
|
-
})
|
|
149
|
+
catch: (error)=>translateMongoError(error)
|
|
158
150
|
}),
|
|
159
151
|
pending: (limit)=>Effect.tryPromise({
|
|
160
152
|
// Oldest first, so `accepted` reaches the bus before the terminal event
|
|
@@ -165,9 +157,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
165
157
|
}, WITHOUT_MONGO_ID).sort({
|
|
166
158
|
createdAt: 1
|
|
167
159
|
}).limit(limit).toArray(),
|
|
168
|
-
catch: (error)=>
|
|
169
|
-
db: db.databaseName
|
|
170
|
-
})
|
|
160
|
+
catch: (error)=>translateMongoError(error)
|
|
171
161
|
}),
|
|
172
162
|
markSent: (entry)=>Effect.tryPromise({
|
|
173
163
|
try: ()=>collection.updateOne({
|
|
@@ -177,9 +167,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
177
167
|
sent: true
|
|
178
168
|
}
|
|
179
169
|
}),
|
|
180
|
-
catch: (error)=>
|
|
181
|
-
eventId: entry.eventId
|
|
182
|
-
})
|
|
170
|
+
catch: (error)=>translateMongoError(error)
|
|
183
171
|
}).pipe(Effect.asVoid),
|
|
184
172
|
stats: ()=>Effect.tryPromise({
|
|
185
173
|
// Two counts and one `findOne`, rather than reading the entries: the
|
|
@@ -213,7 +201,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
213
201
|
oldestPendingAt: oldest.createdAt
|
|
214
202
|
});
|
|
215
203
|
},
|
|
216
|
-
catch: (error)=>
|
|
204
|
+
catch: (error)=>translateMongoError(error)
|
|
217
205
|
}),
|
|
218
206
|
recordFailure: (entry, error, quarantine)=>Effect.tryPromise({
|
|
219
207
|
// `$inc` rather than a read-modify-write: two relays can be draining the
|
|
@@ -231,9 +219,7 @@ const isDuplicateKey = (error)=>typeof error === 'object' && error !== null && e
|
|
|
231
219
|
quarantined: quarantine
|
|
232
220
|
}
|
|
233
221
|
}),
|
|
234
|
-
catch: (cause)=>
|
|
235
|
-
eventId: entry.eventId
|
|
236
|
-
})
|
|
222
|
+
catch: (cause)=>translateMongoError(cause)
|
|
237
223
|
}).pipe(Effect.asVoid)
|
|
238
224
|
};
|
|
239
225
|
};
|
package/dist/outbox/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/outbox/store.ts"],"sourcesContent":["import { type DomainEvent, EntifixConnError } from '@entifix/core';\nimport type { OutboxEntry, TransactionOutbox } from '@entifix/transactions';\nimport { Effect } from 'effect';\nimport type { Db } from 'mongodb';\n\n/**\n * The outbox collection, and it lives in the **same database as the entity it\n * describes** — never in a shared control-plane store.\n *\n * Two reasons, and the second only shows up later. Same database means the\n * entity write and its event are one single-database transaction, which stays a\n * single-*shard* transaction if this ever shards; a control-plane outbox would\n * be cross-database from the first commit. And an outbox holds event payloads:\n * a `TransactionEvent` carries no tenant data, but `catalog.published` carries a\n * whole offering and `order.placed` a whole receipt, and neither may land in the\n * control plane on its way to the bus.\n *\n * That rule is why this lives here rather than in one service. It applies\n * identically to a per-organization `tenant_<id>` and to a single platform-plane\n * database, and the second publisher is what turned a copy into a seam.\n */\nexport const OUTBOX_COLLECTION = 'transaction_outbox';\n\n/** Drop Mongo's internal `_id` from every read. */\nconst WITHOUT_MONGO_ID = { projection: { _id: 0 } } as const;\n\n/** Mongo's duplicate-key error, which is how a replayed command is recognised. */\nconst DUPLICATE_KEY = 11000;\n\nconst isDuplicateKey = (error: unknown): boolean =>\n typeof error === 'object' &&\n error !== null &&\n (error as { code?: unknown }).code === DUPLICATE_KEY;\n\n/**\n * The stored shape of an outbox entry.\n *\n * Exported because the `completed` entry is **not** written through the port:\n * it has to join the entity write's session to be atomic with it, and a session\n * cannot live in a framework-free contract. The handler inserts this document\n * itself; everything else goes through {@link makeMongoOutbox}.\n */\nexport const outboxDocument = (event: DomainEvent): OutboxEntry => ({\n eventId: event.id,\n event,\n sent: false,\n // Defaulted here rather than at each write site because there are two of\n // them: the relay's `enqueue`, and the transaction handler's own insert\n // inside the entity's Mongo session. An entry missing `attempts` would sort\n // into `pending` and then fail `attempts + 1` arithmetic on a `undefined`.\n attempts: 0,\n quarantined: false,\n createdAt: event.at,\n});\n\n/**\n * Puts a **quarantined** entry back in the queue, and touches nothing else.\n *\n * A rebuild walk's repair for the one failure measured on a live lab: with the\n * broker down long enough, the relay spends `outbox.maxAttempts` on an\n * announcement and quarantines it (the deliberate skip-so-the-head-moves),\n * after which the tenant store reads one thing and the storefront shows another\n * with nothing reporting it. Re-emitting cannot help on its own — the event id is\n * `<offeringId>:<publishedAt>` and the unique index answers `duplicate`.\n *\n * ⚠️ **`quarantined: true` is in the filter, not the update.** An entry that is\n * merely pending keeps its `attempts`, and one already `sent` is never re-sent —\n * which is what keeps this from quietly becoming \"re-announce everything\", a\n * walk that would re-publish the whole fleet on every boot and un-quarantine\n * genuine poison each time.\n *\n * Not a `TransactionOutbox` method: the port is framework-free and shared by\n * every publisher, and this is a repair specific to the rebuild. `outboxDocument`\n * is exported beside it for the same class of reason.\n *\n * Answers whether an entry was actually revived.\n */\nexport const reviveQuarantined = (db: Db, eventId: string) =>\n Effect.tryPromise({\n try: async () => {\n const result = await db\n .collection<OutboxEntry>(OUTBOX_COLLECTION)\n .updateOne(\n { eventId, quarantined: true },\n {\n $set: { sent: false, quarantined: false, attempts: 0 },\n // The failure that put it here is spent; leaving it would make the\n // next quarantine's log name an error from a different outage.\n $unset: { lastError: '' },\n },\n );\n return result.modifiedCount === 1;\n },\n catch: error =>\n new EntifixConnError(\n 'Failed to revive a quarantined outbox entry',\n error,\n {\n eventId,\n db: db.databaseName,\n },\n ),\n });\n\n/**\n * Ensures the two indexes the outbox depends on.\n *\n * The unique one is load-bearing rather than defensive: it *is* the idempotency\n * check behind the client-generated transaction id, so a replayed command is\n * rejected by the storage engine rather than by a read-then-write race. It is on\n * `eventId` — `<transactionId>:<step>` for a transaction — so the claim and the\n * bus's deduplication key are one value rather than two that can drift. The\n * partial one keeps the relay's `pending` query cheap once the collection fills\n * with sent entries; it filters on `quarantined` too, so an entry past its\n * ceiling leaves the index rather than being read and skipped forever.\n *\n * Mongo rejects a `createIndex` that reuses a key pattern with a different\n * `partialFilterExpression` (`IndexOptionsConflict`), and this runs on every\n * sweep and every create. A database that predates the `quarantined` filter\n * therefore fails here on every pass — which is loud only because the sweep now\n * logs what it catches. The fix is `pnpm run <app>:dev:reset`; nothing runs in\n * production, so there is no migration to write.\n */\nexport const ensureOutboxIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n const collection = db.collection(OUTBOX_COLLECTION);\n await collection.createIndex({ eventId: 1 }, { unique: true });\n await collection.createIndex(\n { createdAt: 1 },\n { partialFilterExpression: { sent: false, quarantined: false } },\n );\n },\n catch: error =>\n new EntifixConnError('Failed to create outbox indexes', error, {\n db: db.databaseName,\n }),\n });\n\n/**\n * Mongo-backed {@link TransactionOutbox} over one database.\n *\n * `db` is closed over, so every method's Effect has `R = never` — the same\n * technique `makeMongoRepository` uses. There is no publishing here: the relay\n * composes these methods with the event bus, which is what lets the relay be\n * tested without a broker.\n */\nexport const makeMongoOutbox = (db: Db): TransactionOutbox => {\n const collection = db.collection<OutboxEntry>(OUTBOX_COLLECTION);\n\n const fail = (\n message: string,\n error: unknown,\n details?: Record<string, unknown>,\n ) => new EntifixConnError(message, error, details);\n\n return {\n enqueue: event =>\n Effect.tryPromise({\n try: async () => {\n try {\n await collection.insertOne(outboxDocument(event));\n return 'enqueued' as const;\n } catch (error) {\n // Not a failure: the unique index rejected a second claim for this\n // message id, which is precisely how a retry is identified.\n if (isDuplicateKey(error)) {\n return 'duplicate' as const;\n }\n throw error;\n }\n },\n catch: error =>\n fail('Failed to enqueue outbox entry', error, { eventId: event.id }),\n }),\n\n pending: limit =>\n Effect.tryPromise({\n // Oldest first, so `accepted` reaches the bus before the terminal event\n // for the same transaction and the tracker folds them in order.\n try: () =>\n collection\n .find({ sent: false, quarantined: false }, WITHOUT_MONGO_ID)\n .sort({ createdAt: 1 })\n .limit(limit)\n .toArray(),\n catch: error =>\n fail('Failed to read pending outbox entries', error, {\n db: db.databaseName,\n }),\n }),\n\n markSent: entry =>\n Effect.tryPromise({\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n { $set: { sent: true } },\n ),\n catch: error =>\n fail('Failed to mark outbox entry sent', error, {\n eventId: entry.eventId,\n }),\n }).pipe(Effect.asVoid),\n\n stats: () =>\n Effect.tryPromise({\n // Two counts and one `findOne`, rather than reading the entries: the\n // pending set can be arbitrarily large when the relay is stuck, which\n // is exactly the moment this must stay cheap. Both queries ride the\n // partial index on `{ createdAt: 1 }` filtered to the same predicate.\n try: async () => {\n const pendingFilter = { sent: false, quarantined: false };\n const [pending, quarantined, oldest] = await Promise.all([\n collection.countDocuments(pendingFilter),\n collection.countDocuments({ quarantined: true }),\n collection.findOne(pendingFilter, {\n sort: { createdAt: 1 },\n projection: { _id: 0, createdAt: 1 },\n }),\n ]);\n\n return {\n pending,\n quarantined,\n // Absent rather than zero when the outbox is empty: an age of zero\n // and \"nothing is waiting\" are opposite conditions, and a gauge\n // that reports 0 for both is indistinguishable from a healthy relay\n // at exactly the moment it is not one.\n ...(oldest === null ? {} : { oldestPendingAt: oldest.createdAt }),\n };\n },\n catch: error => fail('Failed to read outbox stats', error),\n }),\n\n recordFailure: (entry, error, quarantine) =>\n Effect.tryPromise({\n // `$inc` rather than a read-modify-write: two relays can be draining the\n // same tenant (the request's inline drain and the daemon sweep), and an\n // absolute write would let one of them lose the other's attempt and\n // stretch the ceiling indefinitely.\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n {\n $inc: { attempts: 1 },\n $set: { lastError: error, quarantined: quarantine },\n },\n ),\n catch: cause =>\n fail('Failed to record an outbox publish failure', cause, {\n eventId: entry.eventId,\n }),\n }).pipe(Effect.asVoid),\n };\n};\n"],"names":["EntifixConnError","Effect","OUTBOX_COLLECTION","WITHOUT_MONGO_ID","projection","_id","DUPLICATE_KEY","isDuplicateKey","error","code","outboxDocument","event","eventId","id","sent","attempts","quarantined","createdAt","at","reviveQuarantined","db","tryPromise","try","result","collection","updateOne","$set","$unset","lastError","modifiedCount","catch","databaseName","ensureOutboxIndexes","createIndex","unique","partialFilterExpression","makeMongoOutbox","fail","message","details","enqueue","insertOne","pending","limit","find","sort","toArray","markSent","entry","pipe","asVoid","stats","pendingFilter","oldest","Promise","all","countDocuments","findOne","oldestPendingAt","recordFailure","quarantine","$inc","cause"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../src/outbox/store.ts"],"sourcesContent":["import type { DomainEvent } from '@entifix/core';\nimport type { OutboxEntry, TransactionOutbox } from '@entifix/transactions';\nimport { Effect } from 'effect';\nimport type { Db } from 'mongodb';\n\nimport { translateMongoError } from '../errors/translate-mongo-error.js';\n\n/**\n * The outbox collection, and it lives in the **same database as the entity it\n * describes** — never in a shared control-plane store.\n *\n * Two reasons, and the second only shows up later. Same database means the\n * entity write and its event are one single-database transaction, which stays a\n * single-*shard* transaction if this ever shards; a control-plane outbox would\n * be cross-database from the first commit. And an outbox holds event payloads:\n * a `TransactionEvent` carries no tenant data, but `catalog.published` carries a\n * whole offering and `order.placed` a whole receipt, and neither may land in the\n * control plane on its way to the bus.\n *\n * That rule is why this lives here rather than in one service. It applies\n * identically to a per-organization `tenant_<id>` and to a single platform-plane\n * database, and the second publisher is what turned a copy into a seam.\n */\nexport const OUTBOX_COLLECTION = 'transaction_outbox';\n\n/** Drop Mongo's internal `_id` from every read. */\nconst WITHOUT_MONGO_ID = { projection: { _id: 0 } } as const;\n\n/** Mongo's duplicate-key error, which is how a replayed command is recognised. */\nconst DUPLICATE_KEY = 11000;\n\nconst isDuplicateKey = (error: unknown): boolean =>\n typeof error === 'object' &&\n error !== null &&\n (error as { code?: unknown }).code === DUPLICATE_KEY;\n\n/**\n * The stored shape of an outbox entry.\n *\n * Exported because the `completed` entry is **not** written through the port:\n * it has to join the entity write's session to be atomic with it, and a session\n * cannot live in a framework-free contract. The handler inserts this document\n * itself; everything else goes through {@link makeMongoOutbox}.\n */\nexport const outboxDocument = (event: DomainEvent): OutboxEntry => ({\n eventId: event.id,\n event,\n sent: false,\n // Defaulted here rather than at each write site because there are two of\n // them: the relay's `enqueue`, and the transaction handler's own insert\n // inside the entity's Mongo session. An entry missing `attempts` would sort\n // into `pending` and then fail `attempts + 1` arithmetic on a `undefined`.\n attempts: 0,\n quarantined: false,\n createdAt: event.at,\n});\n\n/**\n * Puts a **quarantined** entry back in the queue, and touches nothing else.\n *\n * A rebuild walk's repair for the one failure measured on a live lab: with the\n * broker down long enough, the relay spends `outbox.maxAttempts` on an\n * announcement and quarantines it (the deliberate skip-so-the-head-moves),\n * after which the tenant store reads one thing and the storefront shows another\n * with nothing reporting it. Re-emitting cannot help on its own — the event id is\n * `<offeringId>:<publishedAt>` and the unique index answers `duplicate`.\n *\n * ⚠️ **`quarantined: true` is in the filter, not the update.** An entry that is\n * merely pending keeps its `attempts`, and one already `sent` is never re-sent —\n * which is what keeps this from quietly becoming \"re-announce everything\", a\n * walk that would re-publish the whole fleet on every boot and un-quarantine\n * genuine poison each time.\n *\n * Not a `TransactionOutbox` method: the port is framework-free and shared by\n * every publisher, and this is a repair specific to the rebuild. `outboxDocument`\n * is exported beside it for the same class of reason.\n *\n * Answers whether an entry was actually revived.\n */\nexport const reviveQuarantined = (db: Db, eventId: string) =>\n Effect.tryPromise({\n try: async () => {\n const result = await db\n .collection<OutboxEntry>(OUTBOX_COLLECTION)\n .updateOne(\n { eventId, quarantined: true },\n {\n $set: { sent: false, quarantined: false, attempts: 0 },\n // The failure that put it here is spent; leaving it would make the\n // next quarantine's log name an error from a different outage.\n $unset: { lastError: '' },\n },\n );\n return result.modifiedCount === 1;\n },\n catch: error => translateMongoError(error),\n });\n\n/**\n * Ensures the two indexes the outbox depends on.\n *\n * The unique one is load-bearing rather than defensive: it *is* the idempotency\n * check behind the client-generated transaction id, so a replayed command is\n * rejected by the storage engine rather than by a read-then-write race. It is on\n * `eventId` — `<transactionId>:<step>` for a transaction — so the claim and the\n * bus's deduplication key are one value rather than two that can drift. The\n * partial one keeps the relay's `pending` query cheap once the collection fills\n * with sent entries; it filters on `quarantined` too, so an entry past its\n * ceiling leaves the index rather than being read and skipped forever.\n *\n * Mongo rejects a `createIndex` that reuses a key pattern with a different\n * `partialFilterExpression` (`IndexOptionsConflict`), and this runs on every\n * sweep and every create. A database that predates the `quarantined` filter\n * therefore fails here on every pass — which is loud only because the sweep now\n * logs what it catches. The fix is `pnpm run <app>:dev:reset`; nothing runs in\n * production, so there is no migration to write.\n */\nexport const ensureOutboxIndexes = (db: Db) =>\n Effect.tryPromise({\n try: async () => {\n const collection = db.collection(OUTBOX_COLLECTION);\n await collection.createIndex({ eventId: 1 }, { unique: true });\n await collection.createIndex(\n { createdAt: 1 },\n { partialFilterExpression: { sent: false, quarantined: false } },\n );\n },\n catch: error => translateMongoError(error),\n });\n\n/**\n * Mongo-backed {@link TransactionOutbox} over one database.\n *\n * `db` is closed over, so every method's Effect has `R = never` — the same\n * technique `makeMongoRepository` uses. There is no publishing here: the relay\n * composes these methods with the event bus, which is what lets the relay be\n * tested without a broker.\n */\nexport const makeMongoOutbox = (db: Db): TransactionOutbox => {\n const collection = db.collection<OutboxEntry>(OUTBOX_COLLECTION);\n\n return {\n enqueue: event =>\n Effect.tryPromise({\n try: async () => {\n try {\n await collection.insertOne(outboxDocument(event));\n return 'enqueued' as const;\n } catch (error) {\n // Not a failure: the unique index rejected a second claim for this\n // message id, which is precisely how a retry is identified.\n if (isDuplicateKey(error)) {\n return 'duplicate' as const;\n }\n throw error;\n }\n },\n catch: error => translateMongoError(error),\n }),\n\n pending: limit =>\n Effect.tryPromise({\n // Oldest first, so `accepted` reaches the bus before the terminal event\n // for the same transaction and the tracker folds them in order.\n try: () =>\n collection\n .find({ sent: false, quarantined: false }, WITHOUT_MONGO_ID)\n .sort({ createdAt: 1 })\n .limit(limit)\n .toArray(),\n catch: error => translateMongoError(error),\n }),\n\n markSent: entry =>\n Effect.tryPromise({\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n { $set: { sent: true } },\n ),\n catch: error => translateMongoError(error),\n }).pipe(Effect.asVoid),\n\n stats: () =>\n Effect.tryPromise({\n // Two counts and one `findOne`, rather than reading the entries: the\n // pending set can be arbitrarily large when the relay is stuck, which\n // is exactly the moment this must stay cheap. Both queries ride the\n // partial index on `{ createdAt: 1 }` filtered to the same predicate.\n try: async () => {\n const pendingFilter = { sent: false, quarantined: false };\n const [pending, quarantined, oldest] = await Promise.all([\n collection.countDocuments(pendingFilter),\n collection.countDocuments({ quarantined: true }),\n collection.findOne(pendingFilter, {\n sort: { createdAt: 1 },\n projection: { _id: 0, createdAt: 1 },\n }),\n ]);\n\n return {\n pending,\n quarantined,\n // Absent rather than zero when the outbox is empty: an age of zero\n // and \"nothing is waiting\" are opposite conditions, and a gauge\n // that reports 0 for both is indistinguishable from a healthy relay\n // at exactly the moment it is not one.\n ...(oldest === null ? {} : { oldestPendingAt: oldest.createdAt }),\n };\n },\n catch: error => translateMongoError(error),\n }),\n\n recordFailure: (entry, error, quarantine) =>\n Effect.tryPromise({\n // `$inc` rather than a read-modify-write: two relays can be draining the\n // same tenant (the request's inline drain and the daemon sweep), and an\n // absolute write would let one of them lose the other's attempt and\n // stretch the ceiling indefinitely.\n try: () =>\n collection.updateOne(\n { eventId: entry.eventId },\n {\n $inc: { attempts: 1 },\n $set: { lastError: error, quarantined: quarantine },\n },\n ),\n catch: cause => translateMongoError(cause),\n }).pipe(Effect.asVoid),\n };\n};\n"],"names":["Effect","translateMongoError","OUTBOX_COLLECTION","WITHOUT_MONGO_ID","projection","_id","DUPLICATE_KEY","isDuplicateKey","error","code","outboxDocument","event","eventId","id","sent","attempts","quarantined","createdAt","at","reviveQuarantined","db","tryPromise","try","result","collection","updateOne","$set","$unset","lastError","modifiedCount","catch","ensureOutboxIndexes","createIndex","unique","partialFilterExpression","makeMongoOutbox","enqueue","insertOne","pending","limit","find","sort","toArray","markSent","entry","pipe","asVoid","stats","pendingFilter","oldest","Promise","all","countDocuments","findOne","oldestPendingAt","recordFailure","quarantine","$inc","cause"],"mappings":"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"}
|
package/dist/saga/store.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { EntifixConnError } from '@entifix/core';
|
|
2
1
|
import { type SagaStore, SagaStoreTag } from '@entifix/transactions';
|
|
3
2
|
import { Effect, Layer } from 'effect';
|
|
4
3
|
import type { Db } from 'mongodb';
|
|
@@ -14,14 +13,14 @@ export declare const SAGA_COLLECTION = "saga_instances";
|
|
|
14
13
|
* second instance for one flow, and `{ state, updatedAt }` for the sweep —
|
|
15
14
|
* without it every pass is a scan of every saga ever run, on a schedule.
|
|
16
15
|
*/
|
|
17
|
-
export declare const ensureSagaIndexes: (db: Db) => Effect.Effect<void,
|
|
16
|
+
export declare const ensureSagaIndexes: (db: Db) => Effect.Effect<void, import("@entifix/core").StoreFailure, never>;
|
|
18
17
|
/** {@link SagaStore} over one Mongo database. */
|
|
19
18
|
export declare const makeMongoSagaStore: (db: Db) => SagaStore;
|
|
20
19
|
/**
|
|
21
20
|
* Provides {@link SagaStoreTag} from the service's own database, creating the
|
|
22
21
|
* indexes first.
|
|
23
22
|
*/
|
|
24
|
-
export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag,
|
|
23
|
+
export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag, import("@entifix/core").StoreFailure, MongoDatabaseTag>;
|
|
25
24
|
/**
|
|
26
25
|
* Provides {@link SagaStoreTag} from a database the host **names**, resolved on
|
|
27
26
|
* the shared client — for a process whose saga store is one of several stores
|
|
@@ -32,5 +31,5 @@ export declare const MongoSagaStoreLayer: Layer.Layer<SagaStoreTag, EntifixConnE
|
|
|
32
31
|
*/
|
|
33
32
|
export declare const mongoSagaStoreLayer: (options: {
|
|
34
33
|
readonly database: string;
|
|
35
|
-
}) => Layer.Layer<SagaStoreTag,
|
|
34
|
+
}) => Layer.Layer<SagaStoreTag, import("@entifix/core").StoreFailure, MongoClientTag>;
|
|
36
35
|
//# 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,
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/saga/store.ts"],"names":[],"mappings":"AAAA,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;AAGlC,OAAO,EACL,cAAc,EACd,gBAAgB,EACjB,MAAM,qCAAqC,CAAC;AAE7C;;;;GAIG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAUhD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,GAAI,IAAI,EAAE,qEAOpC,CAAC;AAEL,iDAAiD;AACjD,eAAO,MAAM,kBAAkB,GAAI,IAAI,EAAE,KAAG,SAoI3C,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,mBAAmB,mFAO/B,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,GAAI,SAAS;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,oFASvE,CAAC"}
|
package/dist/saga/store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
-
import { EntifixConnError } from '@entifix/core';
|
|
3
2
|
import { SagaStoreTag } from '@entifix/transactions';
|
|
4
3
|
import { Effect, Layer } from 'effect';
|
|
4
|
+
import { translateMongoError } from '../errors/translate-mongo-error.js';
|
|
5
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
|
|
@@ -12,9 +12,6 @@ import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-databa
|
|
|
12
12
|
'RUNNING',
|
|
13
13
|
'COMPENSATING'
|
|
14
14
|
];
|
|
15
|
-
const failed = (message, sagaId)=>(error)=>new EntifixConnError(message, error, {
|
|
16
|
-
sagaId
|
|
17
|
-
});
|
|
18
15
|
const sagaCollection = (db)=>db.collection(SAGA_COLLECTION);
|
|
19
16
|
const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString();
|
|
20
17
|
/**
|
|
@@ -33,9 +30,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
33
30
|
updatedAt: 1
|
|
34
31
|
});
|
|
35
32
|
},
|
|
36
|
-
catch: (error)=>
|
|
37
|
-
database: db.databaseName
|
|
38
|
-
})
|
|
33
|
+
catch: (error)=>translateMongoError(error)
|
|
39
34
|
});
|
|
40
35
|
/** {@link SagaStore} over one Mongo database. */ export const makeMongoSagaStore = (db)=>{
|
|
41
36
|
const collection = sagaCollection(db);
|
|
@@ -56,7 +51,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
56
51
|
upsert: true
|
|
57
52
|
});
|
|
58
53
|
},
|
|
59
|
-
catch:
|
|
54
|
+
catch: (error)=>translateMongoError(error)
|
|
60
55
|
}),
|
|
61
56
|
beginStep: (sagaId, stepIndex)=>Effect.tryPromise({
|
|
62
57
|
try: async ()=>{
|
|
@@ -69,7 +64,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
69
64
|
}
|
|
70
65
|
});
|
|
71
66
|
},
|
|
72
|
-
catch:
|
|
67
|
+
catch: (error)=>translateMongoError(error)
|
|
73
68
|
}),
|
|
74
69
|
recordOutcome: (sagaId, outcome)=>Effect.tryPromise({
|
|
75
70
|
try: async ()=>{
|
|
@@ -87,7 +82,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
87
82
|
}
|
|
88
83
|
});
|
|
89
84
|
},
|
|
90
|
-
catch:
|
|
85
|
+
catch: (error)=>translateMongoError(error)
|
|
91
86
|
}),
|
|
92
87
|
settle: (sagaId, state, error)=>Effect.tryPromise({
|
|
93
88
|
try: async ()=>{
|
|
@@ -101,7 +96,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
101
96
|
}
|
|
102
97
|
});
|
|
103
98
|
},
|
|
104
|
-
catch:
|
|
99
|
+
catch: (error)=>translateMongoError(error)
|
|
105
100
|
}),
|
|
106
101
|
/**
|
|
107
102
|
* Take ownership of a stale instance, or answer `undefined`.
|
|
@@ -138,7 +133,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
138
133
|
});
|
|
139
134
|
return claimed != null ? claimed : undefined;
|
|
140
135
|
},
|
|
141
|
-
catch:
|
|
136
|
+
catch: (error)=>translateMongoError(error)
|
|
142
137
|
}),
|
|
143
138
|
/**
|
|
144
139
|
* Flag one step's calls as given back, addressing the outcome by its step
|
|
@@ -161,7 +156,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
161
156
|
]
|
|
162
157
|
});
|
|
163
158
|
},
|
|
164
|
-
catch:
|
|
159
|
+
catch: (error)=>translateMongoError(error)
|
|
165
160
|
}),
|
|
166
161
|
get: (sagaId)=>Effect.tryPromise({
|
|
167
162
|
try: async ()=>{
|
|
@@ -174,7 +169,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
174
169
|
}
|
|
175
170
|
})) != null ? _ref : undefined;
|
|
176
171
|
},
|
|
177
|
-
catch:
|
|
172
|
+
catch: (error)=>translateMongoError(error)
|
|
178
173
|
}),
|
|
179
174
|
/**
|
|
180
175
|
* Instances stuck mid-flight past a deadline. ⚠️ `COMPENSATING` counts: a
|
|
@@ -195,7 +190,7 @@ const deadline = (olderThanMs)=>new Date(Date.now() - olderThanMs).toISOString()
|
|
|
195
190
|
_id: 0
|
|
196
191
|
}
|
|
197
192
|
}).toArray(),
|
|
198
|
-
catch:
|
|
193
|
+
catch: (error)=>translateMongoError(error)
|
|
199
194
|
})
|
|
200
195
|
};
|
|
201
196
|
};
|
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 {\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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"}
|
|
1
|
+
{"version":3,"sources":["../../src/saga/store.ts"],"sourcesContent":["import {\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 { translateMongoError } from '../errors/translate-mongo-error.js';\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 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 => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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: error => translateMongoError(error),\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":["SagaStoreTag","Effect","Layer","translateMongoError","MongoClientTag","MongoDatabaseTag","SAGA_COLLECTION","UNFINISHED","sagaCollection","db","collection","deadline","olderThanMs","Date","now","toISOString","ensureSagaIndexes","tryPromise","try","createIndex","sagaId","unique","state","updatedAt","catch","error","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","database"],"mappings":";AAAA,SAKEA,YAAY,QACP,wBAAwB;AAC/B,SAASC,MAAM,EAAEC,KAAK,QAAQ,SAAS;AAGvC,SAASC,mBAAmB,QAAQ,qCAAqC;AACzE,SACEC,cAAc,EACdC,gBAAgB,QACX,sCAAsC;AAE7C;;;;CAIC,GACD,OAAO,MAAMC,kBAAkB,iBAAiB;AAEhD,iDAAiD,GACjD,MAAMC,aAAmC;IAAC;IAAW;CAAe;AAEpE,MAAMC,iBAAiB,CAACC,KAAWA,GAAGC,UAAU,CAAeJ;AAE/D,MAAMK,WAAW,CAACC,cAChB,IAAIC,KAAKA,KAAKC,GAAG,KAAKF,aAAaG,WAAW;AAEhD;;;;CAIC,GACD,OAAO,MAAMC,oBAAoB,CAACP,KAChCR,OAAOgB,UAAU,CAAC;QAChBC,KAAK;YACH,MAAMV,eAAeC,IAAIU,WAAW,CAAC;gBAAEC,QAAQ;YAAE,GAAG;gBAAEC,QAAQ;YAAK;YACnE,MAAMb,eAAeC,IAAIU,WAAW,CAAC;gBAAEG,OAAO;gBAAGC,WAAW;YAAE;QAChE;QACAC,OAAOC,CAAAA,QAAStB,oBAAoBsB;IACtC,GAAG;AAEL,+CAA+C,GAC/C,OAAO,MAAMC,qBAAqB,CAACjB;IACjC,MAAMC,aAAaF,eAAeC;IAClC,MAAMkB,QAAQ,IAAM,IAAId,OAAOE,WAAW;IAE1C,OAAO;QACLa,OAAOC,CAAAA,WACL5B,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;oBACH,uEAAuE;oBACvE,kEAAkE;oBAClE,qEAAqE;oBACrE,MAAMR,WAAWoB,SAAS,CACxB;wBAAEV,QAAQS,SAAST,MAAM;oBAAC,GAC1B;wBAAEW,cAAc,aAAKF;4BAAUN,WAAWI;;oBAAU,GACpD;wBAAEK,QAAQ;oBAAK;gBAEnB;gBACAR,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEFQ,WAAW,CAACb,QAAQc,YAClBjC,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAMR,WAAWoB,SAAS,CACxB;wBAAEV;oBAAO,GACT;wBAAEe,MAAM;4BAAED;4BAAWX,WAAWI;wBAAQ;oBAAE;gBAE9C;gBACAH,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEFW,eAAe,CAAChB,QAAQiB,UACtBpC,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAMR,WAAWoB,SAAS,CACxB;wBAAEV;oBAAO,GACT,gEAAgE;oBAChE,oEAAoE;oBACpE,qEAAqE;oBACrE;wBAAEkB,OAAO;4BAAEC,UAAUF;wBAAQ;wBAAGF,MAAM;4BAAEZ,WAAWI;wBAAQ;oBAAE;gBAEjE;gBACAH,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEFe,QAAQ,CAACpB,QAAQE,OAAOG,QACtBxB,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAMR,WAAWoB,SAAS,CACxB;wBAAEV;oBAAO,GACT;wBAAEe,MAAM;4BAAEb;4BAAOG;4BAAOF,WAAWI;wBAAQ;oBAAE;gBAEjD;gBACAH,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEF;;;;;;;;KAQC,GACDgB,gBAAgB,CAACrB,QAAQR,cACvBX,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAMwB,UAAU,MAAMhC,WAAWiC,gBAAgB,CAC/C;wBACEvB;wBACAE,OAAO;4BAAEsB,KAAK;mCAAIrC;6BAAW;wBAAC;wBAC9BgB,WAAW;4BAAEsB,KAAKlC,SAASC;wBAAa;oBAC1C,GACA;wBAAEkC,MAAM;4BAAEC,gBAAgB;wBAAE;wBAAGZ,MAAM;4BAAEZ,WAAWI;wBAAQ;oBAAE,GAC5D;wBAAEqB,gBAAgB;wBAASC,YAAY;4BAAEC,KAAK;wBAAE;oBAAE;oBAEpD,OAAQR,kBAAAA,UAAWS;gBACrB;gBACA3B,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEF;;;;KAIC,GACD2B,iBAAiB,CAAChC,QAAQiC,SACxBpD,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;oBACH,MAAMR,WAAWoB,SAAS,CACxB;wBAAEV;oBAAO,GACT;wBACEe,MAAM;4BACJ,iCAAiC;4BACjCZ,WAAWI;wBACb;oBACF,GACA;wBAAE2B,cAAc;4BAAC;gCAAE,gBAAgBD;4BAAO;yBAAE;oBAAC;gBAEjD;gBACA7B,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEF8B,KAAKnC,CAAAA,SACHnB,OAAOgB,UAAU,CAAC;gBAChBC,KAAK;wBACD;4BAAA,OAAA,MAAMR,WAAW8C,OAAO,CAAC;wBAAEpC;oBAAO,GAAG;wBAAE6B,YAAY;4BAAEC,KAAK;wBAAE;oBAAE,cAA9D,OACAC;;gBACJ3B,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;QAEF;;;;KAIC,GACDgC,WAAW7C,CAAAA,cACTX,OAAOgB,UAAU,CAAC;gBAChBC,KAAK,UACF,MAAMR,WACJgD,IAAI,CACH;wBACEpC,OAAO;4BAAEsB,KAAK;mCAAIrC;6BAAW;wBAAC;wBAC9BgB,WAAW;4BAAEsB,KAAKlC,SAASC;wBAAa;oBAC1C,GACA;wBAAEqC,YAAY;4BAAEC,KAAK;wBAAE;oBAAE,GAE1BS,OAAO;gBACZnC,OAAOC,CAAAA,QAAStB,oBAAoBsB;YACtC;IACJ;AACF,EAAE;AAEF;;;CAGC,GACD,OAAO,MAAMmC,sBAAsB1D,MAAM2D,MAAM,CAC7C7D,cACAC,OAAO6D,GAAG,CAAC;IACT,MAAMrD,KAAK,OAAOJ;IAClB,OAAOW,kBAAkBP;IACzB,OAAOiB,mBAAmBjB;AAC5B,IACA;AAEF;;;;;;;CAOC,GACD,OAAO,MAAMsD,sBAAsB,CAACC,UAClC9D,MAAM2D,MAAM,CACV7D,cACAC,OAAO6D,GAAG,CAAC;QACT,MAAMG,SAAS,OAAO7D;QACtB,MAAMK,KAAKwD,OAAOxD,EAAE,CAACuD,QAAQE,QAAQ;QACrC,OAAOlD,kBAAkBP;QACzB,OAAOiB,mBAAmBjB;IAC5B,IACA"}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { EntifixConnError } from '@entifix/core';
|
|
2
1
|
import { Effect, Option } from 'effect';
|
|
3
2
|
import type { ClientSession, Db } from 'mongodb';
|
|
4
3
|
/**
|
|
@@ -20,7 +19,7 @@ export interface CommandClaim {
|
|
|
20
19
|
readonly replay?: unknown;
|
|
21
20
|
}
|
|
22
21
|
/** The unique index the claim rides. */
|
|
23
|
-
export declare const ensureCommandInboxIndexes: (db: Db) => Effect.Effect<void,
|
|
22
|
+
export declare const ensureCommandInboxIndexes: (db: Db) => Effect.Effect<void, import("@entifix/core").StoreFault, never>;
|
|
24
23
|
/** The claim document a unit inserts inside its session. */
|
|
25
24
|
export declare const commandClaimDocument: (commandId: string) => CommandClaim;
|
|
26
25
|
/**
|
|
@@ -29,5 +28,5 @@ export declare const commandClaimDocument: (commandId: string) => CommandClaim;
|
|
|
29
28
|
*/
|
|
30
29
|
export declare const rememberCommandReplay: (db: Db, session: ClientSession, commandId: string, replay: unknown) => Promise<import("mongodb").UpdateResult<CommandClaim>>;
|
|
31
30
|
/** 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>,
|
|
31
|
+
export declare const readCommandReplay: (db: Db, commandId: string) => Effect.Effect<Option.None<{} | null> | Option.Some<{} | null>, import("@entifix/core").StoreFault, never>;
|
|
33
32
|
//# sourceMappingURL=command-inbox.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command-inbox.d.ts","sourceRoot":"","sources":["../../src/unit-of-work/command-inbox.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"command-inbox.d.ts","sourceRoot":"","sources":["../../src/unit-of-work/command-inbox.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,KAAK,EAAE,aAAa,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAIjD;;;;;;;;;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,mEAQ5C,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,8GAaxD,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { EntifixConnError } from '@entifix/core';
|
|
2
1
|
import { Effect, Option } from 'effect';
|
|
2
|
+
import { translateMongoFault } from '../errors/translate-mongo-error.js';
|
|
3
3
|
/**
|
|
4
4
|
* Where a participant records the command ids it has acted on — in the
|
|
5
5
|
* database its own writes go to, so the claim and the write are one fact.
|
|
@@ -19,9 +19,7 @@ import { Effect, Option } from 'effect';
|
|
|
19
19
|
name: 'commandId_1'
|
|
20
20
|
});
|
|
21
21
|
},
|
|
22
|
-
catch: (error)=>
|
|
23
|
-
database: db.databaseName
|
|
24
|
-
})
|
|
22
|
+
catch: (error)=>translateMongoFault(error)
|
|
25
23
|
});
|
|
26
24
|
/** The claim document a unit inserts inside its session. */ export const commandClaimDocument = (commandId)=>({
|
|
27
25
|
commandId,
|
|
@@ -47,9 +45,7 @@ import { Effect, Option } from 'effect';
|
|
|
47
45
|
_id: 0
|
|
48
46
|
}
|
|
49
47
|
}),
|
|
50
|
-
catch: (error)=>
|
|
51
|
-
commandId
|
|
52
|
-
})
|
|
48
|
+
catch: (error)=>translateMongoFault(error)
|
|
53
49
|
}).pipe(Effect.map((claim)=>claim === null || claim.replay === undefined ? Option.none() : Option.some(claim.replay)));
|
|
54
50
|
|
|
55
51
|
//# sourceMappingURL=command-inbox.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/unit-of-work/command-inbox.ts"],"sourcesContent":["import {
|
|
1
|
+
{"version":3,"sources":["../../src/unit-of-work/command-inbox.ts"],"sourcesContent":["import { Effect, Option } from 'effect';\nimport type { ClientSession, Db } from 'mongodb';\n\nimport { translateMongoFault } from '../errors/translate-mongo-error.js';\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 => translateMongoFault(error),\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 => translateMongoFault(error),\n }).pipe(\n Effect.map(claim =>\n claim === null || claim.replay === undefined\n ? Option.none()\n : Option.some(claim.replay),\n ),\n );\n"],"names":["Effect","Option","translateMongoFault","COMMAND_INBOX_COLLECTION","ensureCommandInboxIndexes","db","tryPromise","try","collection","createIndex","commandId","unique","name","catch","error","commandClaimDocument","claimedAt","Date","toISOString","rememberCommandReplay","session","replay","updateOne","$set","readCommandReplay","findOne","projection","_id","pipe","map","claim","undefined","none","some"],"mappings":"AAAA,SAASA,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAGxC,SAASC,mBAAmB,QAAQ,qCAAqC;AAEzE;;;;;;;;;CASC,GACD,OAAO,MAAMC,2BAA2B,gBAAgB;AAUxD,sCAAsC,GACtC,OAAO,MAAMC,4BAA4B,CAACC,KACxCL,OAAOM,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,QAASZ,oBAAoBY;IACtC,GAAG;AAEL,0DAA0D,GAC1D,OAAO,MAAMC,uBAAuB,CAACL,YAAqC,CAAA;QACxEA;QACAM,WAAW,IAAIC,OAAOC,WAAW;IACnC,CAAA,EAAG;AAEH;;;CAGC,GACD,OAAO,MAAMC,wBAAwB,CACnCd,IACAe,SACAV,WACAW,SAEAhB,GACGG,UAAU,CAAeL,0BACzBmB,SAAS,CAAC;QAAEZ;IAAU,GAAG;QAAEa,MAAM;YAAEF;QAAO;IAAE,GAAG;QAAED;IAAQ,GAAG;AAEjE,+DAA+D,GAC/D,OAAO,MAAMI,oBAAoB,CAACnB,IAAQK,YACxCV,OAAOM,UAAU,CAAC;QAChBC,KAAK,IACHF,GACGG,UAAU,CAAeL,0BACzBsB,OAAO,CAAC;gBAAEf;YAAU,GAAG;gBAAEgB,YAAY;oBAAEC,KAAK;gBAAE;YAAE;QACrDd,OAAOC,CAAAA,QAASZ,oBAAoBY;IACtC,GAAGc,IAAI,CACL5B,OAAO6B,GAAG,CAACC,CAAAA,QACTA,UAAU,QAAQA,MAAMT,MAAM,KAAKU,YAC/B9B,OAAO+B,IAAI,KACX/B,OAAOgC,IAAI,CAACH,MAAMT,MAAM,IAE9B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mongo-unit-of-work.d.ts","sourceRoot":"","sources":["../../src/unit-of-work/mongo-unit-of-work.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"mongo-unit-of-work.d.ts","sourceRoot":"","sources":["../../src/unit-of-work/mongo-unit-of-work.ts"],"names":[],"mappings":"AAOA,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;AASjD,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;AA4GD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,GAAI,QAAQ,kBAAkB;sBAuBpC,EAAE,KAAG,UAAU,CAAC,SAAS,CAAC;CAqHpD,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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Contention, isFailure, isStoreFault } from '@entifix/core';
|
|
2
2
|
import { Context, Effect, Layer, Option, Result, Schedule } from 'effect';
|
|
3
|
+
import { translateMongoFault } from '../errors/translate-mongo-error.js';
|
|
3
4
|
import { ensureInboxIndexes, INBOX_COLLECTION, inboxDocument, isDuplicateKey } from '../inbox/store.js';
|
|
4
5
|
import { MongoClientTag, MongoDatabaseTag } from '../mongo-database/mongo-database.js';
|
|
5
6
|
import { ensureOutboxIndexes, OUTBOX_COLLECTION, outboxDocument } from '../outbox/store.js';
|
|
@@ -32,12 +33,13 @@ const hasErrorLabel = (error, label)=>{
|
|
|
32
33
|
}
|
|
33
34
|
return false;
|
|
34
35
|
};
|
|
35
|
-
const isTransient = (error)=>error
|
|
36
|
-
const
|
|
36
|
+
/** A transient conflict, which reruns the whole unit. */ const isTransient = (error)=>isFailure(error) && error._tag === 'Contention';
|
|
37
|
+
const fault = (error)=>translateMongoFault(error);
|
|
38
|
+
const driver = (run)=>Effect.tryPromise({
|
|
37
39
|
try: run,
|
|
38
|
-
catch:
|
|
40
|
+
catch: fault
|
|
39
41
|
});
|
|
40
|
-
/** A duplicate-key rejection answered as `duplicate` rather than as an error. */ const insertOrDuplicate = (
|
|
42
|
+
/** A duplicate-key rejection answered as `duplicate` rather than as an error. */ const insertOrDuplicate = (run)=>Effect.tryPromise({
|
|
41
43
|
try: async ()=>{
|
|
42
44
|
try {
|
|
43
45
|
await run();
|
|
@@ -47,26 +49,28 @@ const driver = (message, run, details)=>Effect.tryPromise({
|
|
|
47
49
|
throw error;
|
|
48
50
|
}
|
|
49
51
|
},
|
|
50
|
-
catch:
|
|
52
|
+
catch: fault
|
|
51
53
|
});
|
|
52
|
-
const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplicate(
|
|
54
|
+
const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplicate(()=>db.collection(INBOX_COLLECTION).insertOne(inboxDocument(claim.consumer, claim.eventId), {
|
|
53
55
|
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), {
|
|
56
|
+
})) : insertOrDuplicate(()=>db.collection(COMMAND_INBOX_COLLECTION).insertOne(commandClaimDocument(claim.commandId), {
|
|
58
57
|
session
|
|
59
|
-
})
|
|
60
|
-
commandId: claim.commandId
|
|
61
|
-
});
|
|
58
|
+
}));
|
|
62
59
|
/**
|
|
63
60
|
* Commits, retrying a commit whose result the server could not report. Such a
|
|
64
61
|
* commit may or may not have applied, and Mongo makes retrying it safe.
|
|
65
|
-
*/
|
|
62
|
+
*/ /**
|
|
63
|
+
* Retried on the **driver's** error, before translation: the label that says
|
|
64
|
+
* the commit's result is unknown lives on it, and a translated failure no
|
|
65
|
+
* longer carries it.
|
|
66
|
+
*/ const commit = (session)=>Effect.tryPromise({
|
|
67
|
+
try: ()=>session.commitTransaction(),
|
|
68
|
+
catch: (error)=>error
|
|
69
|
+
}).pipe(Effect.retry({
|
|
66
70
|
times: ATTEMPTS - 1,
|
|
67
71
|
while: (error)=>labelledInChain(error, 'UnknownTransactionCommitResult')
|
|
68
|
-
}));
|
|
69
|
-
/** Rolls back, and never fails: the unit is already ending for another reason. */ const abort = (session)=>driver(
|
|
72
|
+
}), Effect.mapError(fault));
|
|
73
|
+
/** Rolls back, and never fails: the unit is already ending for another reason. */ const abort = (session)=>driver(()=>session.abortTransaction()).pipe(Effect.ignore);
|
|
70
74
|
/**
|
|
71
75
|
* A {@link UnitOfWork} per database, sharing one client — the shape a
|
|
72
76
|
* per-organization store needs, where the database is resolved inside the
|
|
@@ -77,7 +81,7 @@ const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplica
|
|
|
77
81
|
const key = db.databaseName;
|
|
78
82
|
const done = ensured.get(key);
|
|
79
83
|
if (done !== undefined) {
|
|
80
|
-
return driver(
|
|
84
|
+
return driver(()=>done);
|
|
81
85
|
}
|
|
82
86
|
const pending = Effect.runPromise(Effect.all([
|
|
83
87
|
ensureOutboxIndexes(db),
|
|
@@ -87,13 +91,13 @@ const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplica
|
|
|
87
91
|
ensured.set(key, pending);
|
|
88
92
|
// A failed ensure must be retried by the next unit, not remembered.
|
|
89
93
|
pending.catch(()=>ensured.delete(key));
|
|
90
|
-
return driver(
|
|
94
|
+
return driver(()=>pending);
|
|
91
95
|
});
|
|
92
96
|
const forDatabase = (db)=>{
|
|
93
97
|
const attempt = (body, options)=>Effect.acquireUseRelease(Effect.sync(()=>client.startSession()), (session)=>Effect.gen(function*() {
|
|
94
98
|
yield* Effect.try({
|
|
95
99
|
try: ()=>session.startTransaction(),
|
|
96
|
-
catch:
|
|
100
|
+
catch: fault
|
|
97
101
|
});
|
|
98
102
|
const work = Effect.gen(function*() {
|
|
99
103
|
const claim = options == null ? void 0 : options.claim;
|
|
@@ -111,7 +115,7 @@ const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplica
|
|
|
111
115
|
emit: (event)=>void emitted.push(event)
|
|
112
116
|
});
|
|
113
117
|
if (emitted.length > 0) {
|
|
114
|
-
const written = yield* insertOrDuplicate(
|
|
118
|
+
const written = yield* insertOrDuplicate(()=>db.collection(OUTBOX_COLLECTION).insertMany(emitted.map(outboxDocument), {
|
|
115
119
|
session
|
|
116
120
|
}));
|
|
117
121
|
// An event id already recorded is the outbox's own
|
|
@@ -124,7 +128,7 @@ const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplica
|
|
|
124
128
|
}
|
|
125
129
|
if ((claim == null ? void 0 : claim.kind) === 'command' && (options == null ? void 0 : options.replay)) {
|
|
126
130
|
const replay = options.replay(value);
|
|
127
|
-
yield* driver(
|
|
131
|
+
yield* driver(()=>rememberCommandReplay(db, session, claim.commandId, replay));
|
|
128
132
|
}
|
|
129
133
|
return {
|
|
130
134
|
kind: 'commit',
|
|
@@ -135,13 +139,13 @@ const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplica
|
|
|
135
139
|
if (Result.isFailure(decided)) {
|
|
136
140
|
yield* abort(session);
|
|
137
141
|
const failure = decided.failure;
|
|
138
|
-
if (failure
|
|
142
|
+
if (isStoreFault(failure)) {
|
|
139
143
|
return yield* Effect.fail(failure);
|
|
140
144
|
}
|
|
141
|
-
// A conflict the body surfaced without
|
|
145
|
+
// A conflict the body surfaced without translating is still the
|
|
142
146
|
// store's, not a business decision: it is retried, not aborted.
|
|
143
147
|
if (labelledInChain(failure, 'TransientTransactionError')) {
|
|
144
|
-
return yield* Effect.fail(new
|
|
148
|
+
return yield* Effect.fail(new Contention());
|
|
145
149
|
}
|
|
146
150
|
return {
|
|
147
151
|
_tag: 'aborted',
|
|
@@ -162,7 +166,7 @@ const takeClaim = (db, session, claim)=>claim.kind === 'event' ? insertOrDuplica
|
|
|
162
166
|
_tag: 'committed',
|
|
163
167
|
value: decided.success.value
|
|
164
168
|
};
|
|
165
|
-
}), (session)=>driver(
|
|
169
|
+
}), (session)=>driver(()=>session.endSession()).pipe(Effect.ignore));
|
|
166
170
|
return {
|
|
167
171
|
run: (body, options)=>Effect.gen(function*() {
|
|
168
172
|
yield* ensureIndexes(db);
|