@entifix/testing-unit 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ import { type EntifixFailure } from '@entifix/core';
2
+ /**
3
+ * What every failure must be, wherever it is defined
4
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):
5
+ * registered under its code, categorised, and able to cross a wire and come
6
+ * back as itself — without carrying a stack or a stringified error with it.
7
+ *
8
+ * Run it once per failure a package defines, with a sample built the way the
9
+ * package builds it. A failure that cannot round-trip decodes in a client as a
10
+ * `ProtocolFailure`, which is a refusal the user is never told the reason for.
11
+ */
12
+ export declare const describeFailureContract: (name: string, sample: EntifixFailure) => void;
13
+ //# sourceMappingURL=failure.contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"failure.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/failure.contract.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EAKpB,MAAM,eAAe,CAAC;AAGvB;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GAClC,MAAM,MAAM,EACZ,QAAQ,cAAc,KACrB,IA4BF,CAAC"}
@@ -0,0 +1,38 @@
1
+ import { decodeProblem, FAILURE_CATEGORIES, familyOf, lookupFailure, problemOf } from '@entifix/core';
2
+ import { describe, expect, it } from 'vitest';
3
+ /**
4
+ * What every failure must be, wherever it is defined
5
+ * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):
6
+ * registered under its code, categorised, and able to cross a wire and come
7
+ * back as itself — without carrying a stack or a stringified error with it.
8
+ *
9
+ * Run it once per failure a package defines, with a sample built the way the
10
+ * package builds it. A failure that cannot round-trip decodes in a client as a
11
+ * `ProtocolFailure`, which is a refusal the user is never told the reason for.
12
+ */ export const describeFailureContract = (name, sample)=>{
13
+ describe(`failure contract: ${name}`, ()=>{
14
+ it('is registered under its code, by its own tag', ()=>{
15
+ var _lookupFailure;
16
+ expect((_lookupFailure = lookupFailure(sample.code)) == null ? void 0 : _lookupFailure.tag).toBe(sample._tag);
17
+ });
18
+ it('belongs to one of the closed categories, and its family follows', ()=>{
19
+ expect(FAILURE_CATEGORIES).toContain(sample.category);
20
+ expect(sample.family).toBe(familyOf(sample.category));
21
+ });
22
+ it('decodes from its problem body back to itself, fields and all', ()=>{
23
+ const body = problemOf(sample);
24
+ const decoded = decodeProblem(JSON.parse(JSON.stringify(body)));
25
+ expect(decoded._tag).toBe(sample._tag);
26
+ expect(problemOf(decoded)).toEqual(body);
27
+ });
28
+ it('carries no stack and no stringified error onto the wire', ()=>{
29
+ const body = problemOf(sample);
30
+ const text = JSON.stringify(body);
31
+ expect(body).not.toHaveProperty('stack');
32
+ expect(text).not.toContain('[object');
33
+ expect(text).not.toMatch(/\n\s+at /);
34
+ });
35
+ });
36
+ };
37
+
38
+ //# sourceMappingURL=failure.contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/contracts/failure.contract.ts"],"sourcesContent":["import {\n decodeProblem,\n type EntifixFailure,\n FAILURE_CATEGORIES,\n familyOf,\n lookupFailure,\n problemOf,\n} from '@entifix/core';\nimport { describe, expect, it } from 'vitest';\n\n/**\n * What every failure must be, wherever it is defined\n * ([ADR 0013](../../../../../docs/adr/0013-failures-are-typed-values-decided-at-the-shell.md)):\n * registered under its code, categorised, and able to cross a wire and come\n * back as itself — without carrying a stack or a stringified error with it.\n *\n * Run it once per failure a package defines, with a sample built the way the\n * package builds it. A failure that cannot round-trip decodes in a client as a\n * `ProtocolFailure`, which is a refusal the user is never told the reason for.\n */\nexport const describeFailureContract = (\n name: string,\n sample: EntifixFailure,\n): void => {\n describe(`failure contract: ${name}`, () => {\n it('is registered under its code, by its own tag', () => {\n expect(lookupFailure(sample.code)?.tag).toBe(sample._tag);\n });\n\n it('belongs to one of the closed categories, and its family follows', () => {\n expect(FAILURE_CATEGORIES).toContain(sample.category);\n expect(sample.family).toBe(familyOf(sample.category));\n });\n\n it('decodes from its problem body back to itself, fields and all', () => {\n const body = problemOf(sample);\n const decoded = decodeProblem(JSON.parse(JSON.stringify(body)));\n\n expect(decoded._tag).toBe(sample._tag);\n expect(problemOf(decoded)).toEqual(body);\n });\n\n it('carries no stack and no stringified error onto the wire', () => {\n const body = problemOf(sample);\n const text = JSON.stringify(body);\n\n expect(body).not.toHaveProperty('stack');\n expect(text).not.toContain('[object');\n expect(text).not.toMatch(/\\n\\s+at /);\n });\n });\n};\n"],"names":["decodeProblem","FAILURE_CATEGORIES","familyOf","lookupFailure","problemOf","describe","expect","it","describeFailureContract","name","sample","code","tag","toBe","_tag","toContain","category","family","body","decoded","JSON","parse","stringify","toEqual","text","not","toHaveProperty","toMatch"],"mappings":"AAAA,SACEA,aAAa,EAEbC,kBAAkB,EAClBC,QAAQ,EACRC,aAAa,EACbC,SAAS,QACJ,gBAAgB;AACvB,SAASC,QAAQ,EAAEC,MAAM,EAAEC,EAAE,QAAQ,SAAS;AAE9C;;;;;;;;;CASC,GACD,OAAO,MAAMC,0BAA0B,CACrCC,MACAC;IAEAL,SAAS,CAAC,kBAAkB,EAAEI,MAAM,EAAE;QACpCF,GAAG,gDAAgD;gBAC1CJ;YAAPG,QAAOH,iBAAAA,cAAcO,OAAOC,IAAI,sBAAzBR,eAA4BS,GAAG,EAAEC,IAAI,CAACH,OAAOI,IAAI;QAC1D;QAEAP,GAAG,mEAAmE;YACpED,OAAOL,oBAAoBc,SAAS,CAACL,OAAOM,QAAQ;YACpDV,OAAOI,OAAOO,MAAM,EAAEJ,IAAI,CAACX,SAASQ,OAAOM,QAAQ;QACrD;QAEAT,GAAG,gEAAgE;YACjE,MAAMW,OAAOd,UAAUM;YACvB,MAAMS,UAAUnB,cAAcoB,KAAKC,KAAK,CAACD,KAAKE,SAAS,CAACJ;YAExDZ,OAAOa,QAAQL,IAAI,EAAED,IAAI,CAACH,OAAOI,IAAI;YACrCR,OAAOF,UAAUe,UAAUI,OAAO,CAACL;QACrC;QAEAX,GAAG,2DAA2D;YAC5D,MAAMW,OAAOd,UAAUM;YACvB,MAAMc,OAAOJ,KAAKE,SAAS,CAACJ;YAE5BZ,OAAOY,MAAMO,GAAG,CAACC,cAAc,CAAC;YAChCpB,OAAOkB,MAAMC,GAAG,CAACV,SAAS,CAAC;YAC3BT,OAAOkB,MAAMC,GAAG,CAACE,OAAO,CAAC;QAC3B;IACF;AACF,EAAE"}
@@ -1,5 +1,6 @@
1
1
  export * from './entity-facet-counter.contract.js';
2
2
  export * from './entity-repository.contract.js';
3
+ export * from './failure.contract.js';
3
4
  export * from './saga-store.contract.js';
4
5
  export * from './transaction-ports.contract.js';
5
6
  export * from './unit-of-work.contract.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/contracts/index.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAC;AACnD,cAAc,iCAAiC,CAAC;AAChD,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/contracts/index.ts"],"names":[],"mappings":"AAAA,cAAc,oCAAoC,CAAC;AACnD,cAAc,iCAAiC,CAAC;AAChD,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,4BAA4B,CAAC"}
@@ -1,5 +1,6 @@
1
1
  export * from './entity-facet-counter.contract.js';
2
2
  export * from './entity-repository.contract.js';
3
+ export * from './failure.contract.js';
3
4
  export * from './saga-store.contract.js';
4
5
  export * from './transaction-ports.contract.js';
5
6
  export * from './unit-of-work.contract.js';
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/contracts/index.ts"],"sourcesContent":["export * from './entity-facet-counter.contract.js';\nexport * from './entity-repository.contract.js';\nexport * from './saga-store.contract.js';\nexport * from './transaction-ports.contract.js';\nexport * from './unit-of-work.contract.js';\n"],"names":[],"mappings":"AAAA,cAAc,qCAAqC;AACnD,cAAc,kCAAkC;AAChD,cAAc,2BAA2B;AACzC,cAAc,kCAAkC;AAChD,cAAc,6BAA6B"}
1
+ {"version":3,"sources":["../../src/contracts/index.ts"],"sourcesContent":["export * from './entity-facet-counter.contract.js';\nexport * from './entity-repository.contract.js';\nexport * from './failure.contract.js';\nexport * from './saga-store.contract.js';\nexport * from './transaction-ports.contract.js';\nexport * from './unit-of-work.contract.js';\n"],"names":[],"mappings":"AAAA,cAAc,qCAAqC;AACnD,cAAc,kCAAkC;AAChD,cAAc,wBAAwB;AACtC,cAAc,2BAA2B;AACzC,cAAc,kCAAkC;AAChD,cAAc,6BAA6B"}
@@ -1,13 +1,13 @@
1
- import { EntifixConnError } from '@entifix/core';
1
+ import { type StoreFault } from '@entifix/core';
2
2
  import type { UnitOfWork } from '@entifix/transactions';
3
3
  import { Effect } from 'effect';
4
4
  /** What {@link describeUnitOfWorkContract} needs to observe a store. */
5
5
  export interface UnitOfWorkContractHarness<H> {
6
6
  readonly unitOfWork: UnitOfWork<H>;
7
7
  /** Writes one record through the unit's handle. */
8
- write(handle: H, id: string): Effect.Effect<void, EntifixConnError>;
8
+ write(handle: H, id: string): Effect.Effect<void, StoreFault>;
9
9
  /** Whether a record exists, read through the unit's handle. */
10
- readIn(handle: H, id: string): Effect.Effect<boolean, EntifixConnError>;
10
+ readIn(handle: H, id: string): Effect.Effect<boolean, StoreFault>;
11
11
  /** Whether a record is durable, read outside any unit. */
12
12
  stored(id: string): Promise<boolean>;
13
13
  /** The ids of every event the outbox holds. */
@@ -1 +1 @@
1
- {"version":3,"file":"unit-of-work.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/unit-of-work.contract.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,gBAAgB,EAAE,MAAM,eAAe,CAAC;AACnE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAGxC,wEAAwE;AACxE,MAAM,WAAW,yBAAyB,CAAC,CAAC;IAC1C,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC;IACnC,mDAAmD;IACnD,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,gBAAgB,CAAC,CAAC;IACpE,+DAA+D;IAC/D,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,gBAAgB,CAAC,CAAC;IACxE,0DAA0D;IAC1D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrC,+CAA+C;IAC/C,QAAQ,IAAI,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;CACxC;AAaD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,CAAC,EAC1C,MAAM,MAAM,EACZ,aAAa,MACX,yBAAyB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAC,KACrE,IA6MF,CAAC"}
1
+ {"version":3,"file":"unit-of-work.contract.d.ts","sourceRoot":"","sources":["../../src/contracts/unit-of-work.contract.ts"],"names":[],"mappings":"AAAA,OAAO,EAAoB,KAAK,UAAU,EAAe,MAAM,eAAe,CAAC;AAC/E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AAGxC,wEAAwE;AACxE,MAAM,WAAW,yBAAyB,CAAC,CAAC;IAC1C,QAAQ,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC;IACnC,mDAAmD;IACnD,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IAC9D,+DAA+D;IAC/D,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;IAClE,0DAA0D;IAC1D,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrC,+CAA+C;IAC/C,QAAQ,IAAI,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;CACxC;AAaD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,GAAI,CAAC,EAC1C,MAAM,MAAM,EACZ,aAAa,MACX,yBAAyB,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,yBAAyB,CAAC,CAAC,CAAC,CAAC,KACrE,IA6MF,CAAC"}
@@ -1,4 +1,4 @@
1
- import { EntifixConnError } from '@entifix/core';
1
+ import { Unavailable } from '@entifix/core';
2
2
  import { Effect, Option } from 'effect';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  const anEvent = (id)=>({
@@ -40,8 +40,12 @@ const run = (effect)=>Effect.runPromise(effect);
40
40
  });
41
41
  it('surfaces a store failure as an error, and writes nothing', async ()=>{
42
42
  const harness = await makeHarness();
43
- const error = await run(harness.unitOfWork.run((unit)=>harness.write(unit.handle, 'w-3').pipe(Effect.andThen(Effect.fail(new EntifixConnError('store went away'))))).pipe(Effect.flip));
44
- expect(error).toBeInstanceOf(EntifixConnError);
43
+ const error = await run(harness.unitOfWork.run((unit)=>harness.write(unit.handle, 'w-3').pipe(Effect.andThen(Effect.fail(new Unavailable({
44
+ dependency: 'store'
45
+ }))))).pipe(Effect.flip));
46
+ expect(error).toMatchObject({
47
+ _tag: 'Unavailable'
48
+ });
45
49
  expect(await harness.stored('w-3')).toBe(false);
46
50
  });
47
51
  it('lets a unit read its own uncommitted writes', async ()=>{
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/contracts/unit-of-work.contract.ts"],"sourcesContent":["import { type DomainEvent, EntifixConnError } from '@entifix/core';\nimport type { UnitOfWork } from '@entifix/transactions';\nimport { Effect, Option } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\n/** What {@link describeUnitOfWorkContract} needs to observe a store. */\nexport interface UnitOfWorkContractHarness<H> {\n readonly unitOfWork: UnitOfWork<H>;\n /** Writes one record through the unit's handle. */\n write(handle: H, id: string): Effect.Effect<void, EntifixConnError>;\n /** Whether a record exists, read through the unit's handle. */\n readIn(handle: H, id: string): Effect.Effect<boolean, EntifixConnError>;\n /** Whether a record is durable, read outside any unit. */\n stored(id: string): Promise<boolean>;\n /** The ids of every event the outbox holds. */\n recorded(): Promise<readonly string[]>;\n}\n\nconst anEvent = (id: string): DomainEvent => ({\n name: 'contract.widget.changed',\n id,\n source: 'contract-slice',\n at: '2026-01-01T00:00:00.000Z',\n correlationId: id,\n data: {},\n});\n\nconst run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromise(effect);\n\n/**\n * What every {@link UnitOfWork} must guarantee: the writes, the events and the\n * claim of one unit commit or vanish together; a taken claim turns the next\n * unit into a `duplicate` without running it; a business failure is an\n * `aborted` value and a store failure is an error; and what a unit wrote is\n * visible to itself before it commits.\n */\nexport const describeUnitOfWorkContract = <H>(\n name: string,\n makeHarness: () =>\n UnitOfWorkContractHarness<H> | Promise<UnitOfWorkContractHarness<H>>,\n): void => {\n describe(`UnitOfWork contract: ${name}`, () => {\n it('commits the writes and the events together', async () => {\n const harness = await makeHarness();\n\n const outcome = await run(\n harness.unitOfWork.run(unit =>\n harness.write(unit.handle, 'w-1').pipe(\n Effect.tap(() => Effect.sync(() => unit.emit(anEvent('e-1')))),\n Effect.as('done'),\n ),\n ),\n );\n\n expect(outcome).toEqual({ _tag: 'committed', value: 'done' });\n expect(await harness.stored('w-1')).toBe(true);\n expect(await harness.recorded()).toContain('e-1');\n });\n\n it('answers a business failure as aborted, and writes nothing', async () => {\n const harness = await makeHarness();\n\n const outcome = await run(\n harness.unitOfWork.run(unit =>\n harness.write(unit.handle, 'w-2').pipe(\n Effect.tap(() => Effect.sync(() => unit.emit(anEvent('e-2')))),\n Effect.andThen(Effect.fail('outOfStock' as const)),\n ),\n ),\n );\n\n expect(outcome).toEqual({ _tag: 'aborted', reason: 'outOfStock' });\n expect(await harness.stored('w-2')).toBe(false);\n expect(await harness.recorded()).not.toContain('e-2');\n });\n\n it('surfaces a store failure as an error, and writes nothing', async () => {\n const harness = await makeHarness();\n\n const error = await run(\n harness.unitOfWork\n .run(unit =>\n harness\n .write(unit.handle, 'w-3')\n .pipe(\n Effect.andThen(\n Effect.fail(new EntifixConnError('store went away')),\n ),\n ),\n )\n .pipe(Effect.flip),\n );\n\n expect(error).toBeInstanceOf(EntifixConnError);\n expect(await harness.stored('w-3')).toBe(false);\n });\n\n it('lets a unit read its own uncommitted writes', async () => {\n const harness = await makeHarness();\n\n const outcome = await run(\n harness.unitOfWork.run(unit =>\n harness\n .write(unit.handle, 'w-4')\n .pipe(Effect.andThen(harness.readIn(unit.handle, 'w-4'))),\n ),\n );\n\n expect(outcome).toEqual({ _tag: 'committed', value: true });\n });\n\n it('turns a second unit on a taken event claim into a duplicate that does not run', async () => {\n const harness = await makeHarness();\n const claim = {\n kind: 'event',\n consumer: 'contract.fold',\n eventId: 'm-1',\n } as const;\n let runs = 0;\n const body = (unit: { handle: H }) =>\n Effect.suspend(() => {\n runs += 1;\n return harness.write(unit.handle, `fold-${runs}`);\n });\n\n const first = await run(harness.unitOfWork.run(body, { claim }));\n const second = await run(harness.unitOfWork.run(body, { claim }));\n\n expect(first._tag).toBe('committed');\n expect(second).toEqual({ _tag: 'duplicate', replay: Option.none() });\n expect(await harness.stored('fold-2')).toBe(false);\n });\n\n it('keys an event claim on the consumer as well as the event', async () => {\n const harness = await makeHarness();\n const noop = () => Effect.void;\n\n const one = await run(\n harness.unitOfWork.run(noop, {\n claim: { kind: 'event', consumer: 'contract.a', eventId: 'm-2' },\n }),\n );\n const other = await run(\n harness.unitOfWork.run(noop, {\n claim: { kind: 'event', consumer: 'contract.b', eventId: 'm-2' },\n }),\n );\n\n expect(one._tag).toBe('committed');\n expect(other._tag).toBe('committed');\n });\n\n it('answers a duplicate command with what the first run remembered', async () => {\n const harness = await makeHarness();\n const claim = { kind: 'command', commandId: 'c-1' } as const;\n const options = {\n claim,\n replay: (value: { paymentId: string }) => value,\n };\n\n let runs = 0;\n const capture = (unit: { handle: H }) =>\n Effect.suspend(() => {\n runs += 1;\n const paymentId = `pay-${runs}`;\n return harness\n .write(unit.handle, paymentId)\n .pipe(Effect.as({ paymentId }));\n });\n\n await run(harness.unitOfWork.run(capture, options));\n const again = await run(harness.unitOfWork.run(capture, options));\n\n expect(again).toEqual({\n _tag: 'duplicate',\n replay: Option.some({ paymentId: 'pay-1' }),\n });\n // A redelivered capture must not charge again, so the body never reruns.\n expect(runs).toBe(1);\n expect(await run(harness.unitOfWork.replay('c-1'))).toEqual(\n Option.some({ paymentId: 'pay-1' }),\n );\n expect(await run(harness.unitOfWork.replay('c-unknown'))).toEqual(\n Option.none(),\n );\n });\n\n it('leaves a claim untaken when its unit aborts', async () => {\n const harness = await makeHarness();\n const claim = { kind: 'command', commandId: 'c-2' } as const;\n\n const refused = await run(\n harness.unitOfWork.run(() => Effect.fail('declined' as const), {\n claim,\n }),\n );\n const retried = await run(\n harness.unitOfWork.run(() => Effect.succeed('ok'), { claim }),\n );\n\n expect(refused._tag).toBe('aborted');\n expect(retried).toEqual({ _tag: 'committed', value: 'ok' });\n });\n\n it('treats an event id already in the outbox as a duplicate', async () => {\n const harness = await makeHarness();\n const announce = (id: string) =>\n harness.unitOfWork.run(unit =>\n harness\n .write(unit.handle, id)\n .pipe(\n Effect.tap(() => Effect.sync(() => unit.emit(anEvent('e-same')))),\n ),\n );\n\n await run(announce('w-5'));\n const again = await run(announce('w-6'));\n\n expect(again._tag).toBe('duplicate');\n expect(await harness.stored('w-6')).toBe(false);\n });\n\n it('runs afterCommit only on commit, and never lets it change the outcome', async () => {\n const harness = await makeHarness();\n const ran: string[] = [];\n const after = (label: string) => ({\n afterCommit: Effect.sync(() => ran.push(label)).pipe(\n Effect.andThen(Effect.fail('drain failed')),\n ),\n });\n\n const committed = await run(\n harness.unitOfWork.run(() => Effect.succeed(1), after('commit')),\n );\n await run(\n harness.unitOfWork.run(\n () => Effect.fail('no' as const),\n after('abort'),\n ),\n );\n\n expect(committed).toEqual({ _tag: 'committed', value: 1 });\n expect(ran).toEqual(['commit']);\n });\n });\n};\n"],"names":["EntifixConnError","Effect","Option","describe","expect","it","anEvent","id","name","source","at","correlationId","data","run","effect","runPromise","describeUnitOfWorkContract","makeHarness","harness","outcome","unitOfWork","unit","write","handle","pipe","tap","sync","emit","as","toEqual","_tag","value","stored","toBe","recorded","toContain","andThen","fail","reason","not","error","flip","toBeInstanceOf","readIn","claim","kind","consumer","eventId","runs","body","suspend","first","second","replay","none","noop","void","one","other","commandId","options","capture","paymentId","again","some","refused","retried","succeed","announce","ran","after","label","afterCommit","push","committed"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/contracts/unit-of-work.contract.ts"],"sourcesContent":["import { type DomainEvent, type StoreFault, Unavailable } from '@entifix/core';\nimport type { UnitOfWork } from '@entifix/transactions';\nimport { Effect, Option } from 'effect';\nimport { describe, expect, it } from 'vitest';\n\n/** What {@link describeUnitOfWorkContract} needs to observe a store. */\nexport interface UnitOfWorkContractHarness<H> {\n readonly unitOfWork: UnitOfWork<H>;\n /** Writes one record through the unit's handle. */\n write(handle: H, id: string): Effect.Effect<void, StoreFault>;\n /** Whether a record exists, read through the unit's handle. */\n readIn(handle: H, id: string): Effect.Effect<boolean, StoreFault>;\n /** Whether a record is durable, read outside any unit. */\n stored(id: string): Promise<boolean>;\n /** The ids of every event the outbox holds. */\n recorded(): Promise<readonly string[]>;\n}\n\nconst anEvent = (id: string): DomainEvent => ({\n name: 'contract.widget.changed',\n id,\n source: 'contract-slice',\n at: '2026-01-01T00:00:00.000Z',\n correlationId: id,\n data: {},\n});\n\nconst run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromise(effect);\n\n/**\n * What every {@link UnitOfWork} must guarantee: the writes, the events and the\n * claim of one unit commit or vanish together; a taken claim turns the next\n * unit into a `duplicate` without running it; a business failure is an\n * `aborted` value and a store failure is an error; and what a unit wrote is\n * visible to itself before it commits.\n */\nexport const describeUnitOfWorkContract = <H>(\n name: string,\n makeHarness: () =>\n UnitOfWorkContractHarness<H> | Promise<UnitOfWorkContractHarness<H>>,\n): void => {\n describe(`UnitOfWork contract: ${name}`, () => {\n it('commits the writes and the events together', async () => {\n const harness = await makeHarness();\n\n const outcome = await run(\n harness.unitOfWork.run(unit =>\n harness.write(unit.handle, 'w-1').pipe(\n Effect.tap(() => Effect.sync(() => unit.emit(anEvent('e-1')))),\n Effect.as('done'),\n ),\n ),\n );\n\n expect(outcome).toEqual({ _tag: 'committed', value: 'done' });\n expect(await harness.stored('w-1')).toBe(true);\n expect(await harness.recorded()).toContain('e-1');\n });\n\n it('answers a business failure as aborted, and writes nothing', async () => {\n const harness = await makeHarness();\n\n const outcome = await run(\n harness.unitOfWork.run(unit =>\n harness.write(unit.handle, 'w-2').pipe(\n Effect.tap(() => Effect.sync(() => unit.emit(anEvent('e-2')))),\n Effect.andThen(Effect.fail('outOfStock' as const)),\n ),\n ),\n );\n\n expect(outcome).toEqual({ _tag: 'aborted', reason: 'outOfStock' });\n expect(await harness.stored('w-2')).toBe(false);\n expect(await harness.recorded()).not.toContain('e-2');\n });\n\n it('surfaces a store failure as an error, and writes nothing', async () => {\n const harness = await makeHarness();\n\n const error = await run(\n harness.unitOfWork\n .run(unit =>\n harness\n .write(unit.handle, 'w-3')\n .pipe(\n Effect.andThen(\n Effect.fail(new Unavailable({ dependency: 'store' })),\n ),\n ),\n )\n .pipe(Effect.flip),\n );\n\n expect(error).toMatchObject({ _tag: 'Unavailable' });\n expect(await harness.stored('w-3')).toBe(false);\n });\n\n it('lets a unit read its own uncommitted writes', async () => {\n const harness = await makeHarness();\n\n const outcome = await run(\n harness.unitOfWork.run(unit =>\n harness\n .write(unit.handle, 'w-4')\n .pipe(Effect.andThen(harness.readIn(unit.handle, 'w-4'))),\n ),\n );\n\n expect(outcome).toEqual({ _tag: 'committed', value: true });\n });\n\n it('turns a second unit on a taken event claim into a duplicate that does not run', async () => {\n const harness = await makeHarness();\n const claim = {\n kind: 'event',\n consumer: 'contract.fold',\n eventId: 'm-1',\n } as const;\n let runs = 0;\n const body = (unit: { handle: H }) =>\n Effect.suspend(() => {\n runs += 1;\n return harness.write(unit.handle, `fold-${runs}`);\n });\n\n const first = await run(harness.unitOfWork.run(body, { claim }));\n const second = await run(harness.unitOfWork.run(body, { claim }));\n\n expect(first._tag).toBe('committed');\n expect(second).toEqual({ _tag: 'duplicate', replay: Option.none() });\n expect(await harness.stored('fold-2')).toBe(false);\n });\n\n it('keys an event claim on the consumer as well as the event', async () => {\n const harness = await makeHarness();\n const noop = () => Effect.void;\n\n const one = await run(\n harness.unitOfWork.run(noop, {\n claim: { kind: 'event', consumer: 'contract.a', eventId: 'm-2' },\n }),\n );\n const other = await run(\n harness.unitOfWork.run(noop, {\n claim: { kind: 'event', consumer: 'contract.b', eventId: 'm-2' },\n }),\n );\n\n expect(one._tag).toBe('committed');\n expect(other._tag).toBe('committed');\n });\n\n it('answers a duplicate command with what the first run remembered', async () => {\n const harness = await makeHarness();\n const claim = { kind: 'command', commandId: 'c-1' } as const;\n const options = {\n claim,\n replay: (value: { paymentId: string }) => value,\n };\n\n let runs = 0;\n const capture = (unit: { handle: H }) =>\n Effect.suspend(() => {\n runs += 1;\n const paymentId = `pay-${runs}`;\n return harness\n .write(unit.handle, paymentId)\n .pipe(Effect.as({ paymentId }));\n });\n\n await run(harness.unitOfWork.run(capture, options));\n const again = await run(harness.unitOfWork.run(capture, options));\n\n expect(again).toEqual({\n _tag: 'duplicate',\n replay: Option.some({ paymentId: 'pay-1' }),\n });\n // A redelivered capture must not charge again, so the body never reruns.\n expect(runs).toBe(1);\n expect(await run(harness.unitOfWork.replay('c-1'))).toEqual(\n Option.some({ paymentId: 'pay-1' }),\n );\n expect(await run(harness.unitOfWork.replay('c-unknown'))).toEqual(\n Option.none(),\n );\n });\n\n it('leaves a claim untaken when its unit aborts', async () => {\n const harness = await makeHarness();\n const claim = { kind: 'command', commandId: 'c-2' } as const;\n\n const refused = await run(\n harness.unitOfWork.run(() => Effect.fail('declined' as const), {\n claim,\n }),\n );\n const retried = await run(\n harness.unitOfWork.run(() => Effect.succeed('ok'), { claim }),\n );\n\n expect(refused._tag).toBe('aborted');\n expect(retried).toEqual({ _tag: 'committed', value: 'ok' });\n });\n\n it('treats an event id already in the outbox as a duplicate', async () => {\n const harness = await makeHarness();\n const announce = (id: string) =>\n harness.unitOfWork.run(unit =>\n harness\n .write(unit.handle, id)\n .pipe(\n Effect.tap(() => Effect.sync(() => unit.emit(anEvent('e-same')))),\n ),\n );\n\n await run(announce('w-5'));\n const again = await run(announce('w-6'));\n\n expect(again._tag).toBe('duplicate');\n expect(await harness.stored('w-6')).toBe(false);\n });\n\n it('runs afterCommit only on commit, and never lets it change the outcome', async () => {\n const harness = await makeHarness();\n const ran: string[] = [];\n const after = (label: string) => ({\n afterCommit: Effect.sync(() => ran.push(label)).pipe(\n Effect.andThen(Effect.fail('drain failed')),\n ),\n });\n\n const committed = await run(\n harness.unitOfWork.run(() => Effect.succeed(1), after('commit')),\n );\n await run(\n harness.unitOfWork.run(\n () => Effect.fail('no' as const),\n after('abort'),\n ),\n );\n\n expect(committed).toEqual({ _tag: 'committed', value: 1 });\n expect(ran).toEqual(['commit']);\n });\n });\n};\n"],"names":["Unavailable","Effect","Option","describe","expect","it","anEvent","id","name","source","at","correlationId","data","run","effect","runPromise","describeUnitOfWorkContract","makeHarness","harness","outcome","unitOfWork","unit","write","handle","pipe","tap","sync","emit","as","toEqual","_tag","value","stored","toBe","recorded","toContain","andThen","fail","reason","not","error","dependency","flip","toMatchObject","readIn","claim","kind","consumer","eventId","runs","body","suspend","first","second","replay","none","noop","void","one","other","commandId","options","capture","paymentId","again","some","refused","retried","succeed","announce","ran","after","label","afterCommit","push","committed"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { type DomainEvent, type EntifixError } from '@entifix/core';
1
+ import { type DomainEvent, type TransitionalFailure } from '@entifix/core';
2
2
  import type { EventBus, LockService, SagaInstance, SagaStore, SequenceService, TransactionInbox } from '@entifix/transactions';
3
3
  import { Effect } from 'effect';
4
4
  export interface InMemoryLockService extends LockService {
@@ -11,7 +11,7 @@ export interface InMemoryLockService extends LockService {
11
11
  }>;
12
12
  /**
13
13
  * Marks `key` as already taken by someone else, so the next `acquire` fails
14
- * with {@link EntifixLockError} — the 409 path of the transaction facade.
14
+ * with {@link Contention} — the 409 path of the transaction facade.
15
15
  */
16
16
  contendOn(key: string): void;
17
17
  }
@@ -40,7 +40,7 @@ export interface RecordingEventBus extends EventBus {
40
40
  * broker's job in production, so a double that ignored it would let a
41
41
  * consumer pass its tests while receiving traffic it never bound to.
42
42
  */
43
- deliver(event: DomainEvent): Effect.Effect<void, EntifixError>;
43
+ deliver(event: DomainEvent): Effect.Effect<void, TransitionalFailure>;
44
44
  /** Makes the next `publish` fail, for the broker-unavailable branch. */
45
45
  failNextPublish(): void;
46
46
  }
@@ -1 +1 @@
1
- {"version":3,"file":"transaction-ports.d.ts","sourceRoot":"","sources":["../../src/doubles/transaction-ports.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,WAAW,EAEhB,KAAK,YAAY,EAGlB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,WAAW,EACX,YAAY,EACZ,SAAS,EACT,eAAe,EACf,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,WAAW,mBAAoB,SAAQ,WAAW;IACtD,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IACxB,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,SAAS,GAAG,SAAS,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E;;;OAGG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAO,mBAsC1C,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD;AAED;;;GAGG;AACH,eAAO,MAAM,2BAA2B,QAAO,uBAc9C,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,WAAW,EAAE,CAAC;IAClC;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,YAAY,CAAC,CAAC;IAC/D,wEAAwE;IACxE,eAAe,IAAI,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBA4CxC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;IAI5B,0DAA0D;oBAC1C,MAAM,KAAG,gBAAgB;qBAS3B,SAAS;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE;CAOjE,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,SAAS;IAClD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,EAAE,SAAS,YAAY,EAAE,CAAC;CAC7C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBAwExC,CAAC"}
1
+ {"version":3,"file":"transaction-ports.d.ts","sourceRoot":"","sources":["../../src/doubles/transaction-ports.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,WAAW,EAEhB,KAAK,mBAAmB,EAEzB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EACV,QAAQ,EAER,WAAW,EACX,YAAY,EACZ,SAAS,EACT,eAAe,EACf,gBAAgB,EACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,WAAW,mBAAoB,SAAQ,WAAW;IACtD,iDAAiD;IACjD,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC;IACxB,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,aAAa,CAAC;QAAE,MAAM,EAAE,SAAS,GAAG,SAAS,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC5E;;;OAGG;IACH,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAO,mBAkC1C,CAAC;AAEF,MAAM,WAAW,uBAAwB,SAAQ,eAAe;IAC9D,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CACnD;AAED;;;GAGG;AACH,eAAO,MAAM,2BAA2B,QAAO,uBAc9C,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,QAAQ;IACjD,uCAAuC;IACvC,QAAQ,CAAC,SAAS,EAAE,WAAW,EAAE,CAAC;IAClC;;;;;OAKG;IACH,OAAO,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,mBAAmB,CAAC,CAAC;IACtE,wEAAwE;IACxE,eAAe,IAAI,IAAI,CAAC;CACzB;AAED;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBAwCxC,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB;IAI5B,0DAA0D;oBAC1C,MAAM,KAAG,gBAAgB;qBAS3B,SAAS;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE;CAOjE,CAAC;AAEF,MAAM,WAAW,iBAAkB,SAAQ,SAAS;IAClD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,EAAE,SAAS,YAAY,EAAE,CAAC;CAC7C;AAED;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,QAAO,iBAwExC,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { _ as _extends } from "@swc/helpers/_/_extends";
2
- import { EntifixConnError, EntifixLockError, matchesEventPattern } from '@entifix/core';
2
+ import { Contention, matchesEventPattern, Unavailable } from '@entifix/core';
3
3
  import { Effect } from 'effect';
4
4
  /**
5
5
  * In-memory {@link LockService}. Acquisition is immediate and uncontended
@@ -14,9 +14,7 @@ import { Effect } from 'effect';
14
14
  return {
15
15
  acquire: (key)=>Effect.suspend(()=>{
16
16
  if (contended.has(key) || tokens.has(key)) {
17
- return Effect.fail(new EntifixLockError(`Could not acquire lock "${key}"`, undefined, {
18
- key
19
- }));
17
+ return Effect.fail(new Contention());
20
18
  }
21
19
  const token = `token-${counter += 1}`;
22
20
  tokens.set(key, token);
@@ -80,8 +78,8 @@ import { Effect } from 'effect';
80
78
  publish: (event)=>Effect.suspend(()=>{
81
79
  if (failNext) {
82
80
  failNext = false;
83
- return Effect.fail(new EntifixConnError('Event bus unavailable', undefined, {
84
- eventId: event.id
81
+ return Effect.fail(new Unavailable({
82
+ dependency: 'bus'
85
83
  }));
86
84
  }
87
85
  published.push(event);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/doubles/transaction-ports.ts"],"sourcesContent":["import {\n type DomainEvent,\n EntifixConnError,\n type EntifixError,\n EntifixLockError,\n matchesEventPattern,\n} from '@entifix/core';\nimport type {\n EventBus,\n LockHandle,\n LockService,\n SagaInstance,\n SagaStore,\n SequenceService,\n TransactionInbox,\n} from '@entifix/transactions';\nimport { Effect } from 'effect';\n\nexport interface InMemoryLockService extends LockService {\n /** Keys currently held, in acquisition order. */\n readonly held: string[];\n /** Every acquire/release in the order it happened. */\n readonly log: ReadonlyArray<{ action: 'acquire' | 'release'; key: string }>;\n /**\n * Marks `key` as already taken by someone else, so the next `acquire` fails\n * with {@link EntifixLockError} — the 409 path of the transaction facade.\n */\n contendOn(key: string): void;\n}\n\n/**\n * In-memory {@link LockService}. Acquisition is immediate and uncontended\n * unless {@link InMemoryLockService.contendOn} says otherwise, and `release`\n * only frees a lock whose token matches — the compare-and-delete rule the Redis\n * adapter enforces with a Lua script.\n */\nexport const makeInMemoryLockService = (): InMemoryLockService => {\n const tokens = new Map<string, string>();\n const contended = new Set<string>();\n const log: Array<{ action: 'acquire' | 'release'; key: string }> = [];\n let counter = 0;\n\n return {\n acquire: key =>\n Effect.suspend(() => {\n if (contended.has(key) || tokens.has(key)) {\n return Effect.fail(\n new EntifixLockError(`Could not acquire lock \"${key}\"`, undefined, {\n key,\n }),\n );\n }\n const token = `token-${(counter += 1)}`;\n tokens.set(key, token);\n log.push({ action: 'acquire', key });\n return Effect.succeed({ key, token });\n }),\n release: (handle: LockHandle) =>\n Effect.sync(() => {\n if (tokens.get(handle.key) === handle.token) {\n tokens.delete(handle.key);\n log.push({ action: 'release', key: handle.key });\n }\n }),\n get held() {\n return [...tokens.keys()];\n },\n get log() {\n return log;\n },\n contendOn: key => {\n contended.add(key);\n },\n };\n};\n\nexport interface InMemorySequenceService extends SequenceService {\n /** The current value of every sequence drawn from so far. */\n readonly values: Readonly<Record<string, number>>;\n}\n\n/**\n * In-memory {@link SequenceService} — a plain counter per name. Atomicity is\n * what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.\n */\nexport const makeInMemorySequenceService = (): InMemorySequenceService => {\n const sequences = new Map<string, number>();\n\n return {\n next: name =>\n Effect.sync(() => {\n const next = (sequences.get(name) ?? 0) + 1;\n sequences.set(name, next);\n return next;\n }),\n get values() {\n return Object.fromEntries(sequences);\n },\n };\n};\n\nexport interface RecordingEventBus extends EventBus {\n /** Every event published, in order. */\n readonly published: DomainEvent[];\n /**\n * Pushes an event to the subscribers whose pattern matches it, as a topic\n * exchange would. Deliberately not \"to all subscribers\": routing is the\n * broker's job in production, so a double that ignored it would let a\n * consumer pass its tests while receiving traffic it never bound to.\n */\n deliver(event: DomainEvent): Effect.Effect<void, EntifixError>;\n /** Makes the next `publish` fail, for the broker-unavailable branch. */\n failNextPublish(): void;\n}\n\n/**\n * In-memory {@link EventBus} that records what was published rather than\n * asserting on it. Prefer reading `bus.published` in a test over a mock's call\n * assertions: the expectation then reads as state, not as a spy protocol.\n */\nexport const makeRecordingEventBus = (): RecordingEventBus => {\n const published: DomainEvent[] = [];\n const handlers: Array<{\n pattern: string;\n handle: (event: DomainEvent) => Effect.Effect<void, EntifixError>;\n }> = [];\n let failNext = false;\n\n return {\n publish: event =>\n Effect.suspend(() => {\n if (failNext) {\n failNext = false;\n return Effect.fail(\n new EntifixConnError('Event bus unavailable', undefined, {\n eventId: event.id,\n }),\n );\n }\n published.push(event);\n return Effect.void;\n }),\n subscribe: (subscription, handle) =>\n Effect.sync(() => {\n // Only the pattern is retained: queue durability and the delivery\n // ceiling are broker facts with no in-memory analogue, and faking them\n // would assert against the double rather than against a broker.\n handlers.push({ pattern: subscription.pattern, handle });\n }),\n deliver: event =>\n Effect.forEach(\n handlers.filter(({ pattern }) =>\n matchesEventPattern(pattern, event.name),\n ),\n ({ handle }) => handle(event),\n { discard: true },\n ),\n get published() {\n return published;\n },\n failNextPublish: () => {\n failNext = true;\n },\n };\n};\n\n/**\n * An in-memory {@link TransactionInbox}, one per consumer, over a shared claim\n * set.\n *\n * Sharing the set across the inboxes one factory hands out is what makes the\n * contract's \"one consumer cannot consume another's claim\" case a real\n * assertion, rather than one the double satisfies by only ever having seen a\n * single consumer.\n */\nexport const makeInMemoryInboxes = () => {\n const claimed = new Set<string>();\n\n return {\n /** An inbox for `consumer`, over the shared claim set. */\n for: (consumer: string): TransactionInbox => ({\n claim: eventId =>\n Effect.sync(() => {\n const key = JSON.stringify([consumer, eventId]);\n if (claimed.has(key)) return 'duplicate' as const;\n claimed.add(key);\n return 'claimed' as const;\n }),\n }),\n get claims(): readonly { consumer: string; eventId: string }[] {\n return [...claimed].map(key => {\n const [consumer, eventId] = JSON.parse(key) as [string, string];\n return { consumer, eventId };\n });\n },\n };\n};\n\nexport interface InMemorySagaStore extends SagaStore {\n /** Every instance, as stored — for a spec to assert on state, not calls. */\n readonly instances: readonly SagaInstance[];\n}\n\n/**\n * In-memory {@link SagaStore}, with the Mongo adapter's semantics: `start`\n * never overwrites, outcomes append, and a resume claim only takes an\n * unfinished instance whose `updatedAt` is past the deadline — counting the\n * attempt and re-stamping it, so the next sweep does not find it again.\n */\nexport const makeInMemorySagaStore = (): InMemorySagaStore => {\n const instances = new Map<string, SagaInstance>();\n const stamp = () => new Date().toISOString();\n const unfinished = (entry: SagaInstance) =>\n entry.state === 'RUNNING' || entry.state === 'COMPENSATING';\n const pastDeadline = (entry: SagaInstance, olderThanMs: number) =>\n entry.updatedAt < new Date(Date.now() - olderThanMs).toISOString();\n const update = (\n sagaId: string,\n change: (entry: SagaInstance) => SagaInstance,\n ) =>\n Effect.sync(() => {\n const current = instances.get(sagaId);\n if (current !== undefined) {\n instances.set(sagaId, { ...change(current), updatedAt: stamp() });\n }\n });\n\n return {\n start: started =>\n Effect.sync(() => {\n if (!instances.has(started.sagaId)) {\n instances.set(started.sagaId, { ...started, updatedAt: stamp() });\n }\n }),\n beginStep: (sagaId, stepIndex) =>\n update(sagaId, entry => ({ ...entry, stepIndex })),\n recordOutcome: (sagaId, outcome) =>\n update(sagaId, entry => ({\n ...entry,\n outcomes: [...entry.outcomes, outcome],\n })),\n settle: (sagaId, state, error) =>\n update(sagaId, entry => ({ ...entry, state, error })),\n markCompensated: (sagaId, stepId) =>\n update(sagaId, entry => ({\n ...entry,\n outcomes: entry.outcomes.map(outcome =>\n outcome.stepId === stepId\n ? { ...outcome, compensated: true }\n : outcome,\n ),\n })),\n get: sagaId => Effect.sync(() => instances.get(sagaId)),\n findStale: olderThanMs =>\n Effect.sync(() =>\n [...instances.values()].filter(\n entry => unfinished(entry) && pastDeadline(entry, olderThanMs),\n ),\n ),\n claimForResume: (sagaId, olderThanMs) =>\n Effect.sync(() => {\n const current = instances.get(sagaId);\n if (\n current === undefined ||\n !unfinished(current) ||\n !pastDeadline(current, olderThanMs)\n ) {\n return undefined;\n }\n const claimed = {\n ...current,\n resumeAttempts: current.resumeAttempts + 1,\n updatedAt: stamp(),\n };\n instances.set(sagaId, claimed);\n return claimed;\n }),\n get instances() {\n return [...instances.values()];\n },\n };\n};\n"],"names":["EntifixConnError","EntifixLockError","matchesEventPattern","Effect","makeInMemoryLockService","tokens","Map","contended","Set","log","counter","acquire","key","suspend","has","fail","undefined","token","set","push","action","succeed","release","handle","sync","get","delete","held","keys","contendOn","add","makeInMemorySequenceService","sequences","next","name","values","Object","fromEntries","makeRecordingEventBus","published","handlers","failNext","publish","event","eventId","id","void","subscribe","subscription","pattern","deliver","forEach","filter","discard","failNextPublish","makeInMemoryInboxes","claimed","for","consumer","claim","JSON","stringify","claims","map","parse","makeInMemorySagaStore","instances","stamp","Date","toISOString","unfinished","entry","state","pastDeadline","olderThanMs","updatedAt","now","update","sagaId","change","current","start","started","beginStep","stepIndex","recordOutcome","outcome","outcomes","settle","error","markCompensated","stepId","compensated","findStale","claimForResume","resumeAttempts"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/doubles/transaction-ports.ts"],"sourcesContent":["import {\n Contention,\n type DomainEvent,\n matchesEventPattern,\n type TransitionalFailure,\n Unavailable,\n} from '@entifix/core';\nimport type {\n EventBus,\n LockHandle,\n LockService,\n SagaInstance,\n SagaStore,\n SequenceService,\n TransactionInbox,\n} from '@entifix/transactions';\nimport { Effect } from 'effect';\n\nexport interface InMemoryLockService extends LockService {\n /** Keys currently held, in acquisition order. */\n readonly held: string[];\n /** Every acquire/release in the order it happened. */\n readonly log: ReadonlyArray<{ action: 'acquire' | 'release'; key: string }>;\n /**\n * Marks `key` as already taken by someone else, so the next `acquire` fails\n * with {@link Contention} — the 409 path of the transaction facade.\n */\n contendOn(key: string): void;\n}\n\n/**\n * In-memory {@link LockService}. Acquisition is immediate and uncontended\n * unless {@link InMemoryLockService.contendOn} says otherwise, and `release`\n * only frees a lock whose token matches — the compare-and-delete rule the Redis\n * adapter enforces with a Lua script.\n */\nexport const makeInMemoryLockService = (): InMemoryLockService => {\n const tokens = new Map<string, string>();\n const contended = new Set<string>();\n const log: Array<{ action: 'acquire' | 'release'; key: string }> = [];\n let counter = 0;\n\n return {\n acquire: key =>\n Effect.suspend(() => {\n if (contended.has(key) || tokens.has(key)) {\n return Effect.fail(new Contention());\n }\n const token = `token-${(counter += 1)}`;\n tokens.set(key, token);\n log.push({ action: 'acquire', key });\n return Effect.succeed({ key, token });\n }),\n release: (handle: LockHandle) =>\n Effect.sync(() => {\n if (tokens.get(handle.key) === handle.token) {\n tokens.delete(handle.key);\n log.push({ action: 'release', key: handle.key });\n }\n }),\n get held() {\n return [...tokens.keys()];\n },\n get log() {\n return log;\n },\n contendOn: key => {\n contended.add(key);\n },\n };\n};\n\nexport interface InMemorySequenceService extends SequenceService {\n /** The current value of every sequence drawn from so far. */\n readonly values: Readonly<Record<string, number>>;\n}\n\n/**\n * In-memory {@link SequenceService} — a plain counter per name. Atomicity is\n * what the Redis `INCR` adapter buys; single-threaded JS gives it for free here.\n */\nexport const makeInMemorySequenceService = (): InMemorySequenceService => {\n const sequences = new Map<string, number>();\n\n return {\n next: name =>\n Effect.sync(() => {\n const next = (sequences.get(name) ?? 0) + 1;\n sequences.set(name, next);\n return next;\n }),\n get values() {\n return Object.fromEntries(sequences);\n },\n };\n};\n\nexport interface RecordingEventBus extends EventBus {\n /** Every event published, in order. */\n readonly published: DomainEvent[];\n /**\n * Pushes an event to the subscribers whose pattern matches it, as a topic\n * exchange would. Deliberately not \"to all subscribers\": routing is the\n * broker's job in production, so a double that ignored it would let a\n * consumer pass its tests while receiving traffic it never bound to.\n */\n deliver(event: DomainEvent): Effect.Effect<void, TransitionalFailure>;\n /** Makes the next `publish` fail, for the broker-unavailable branch. */\n failNextPublish(): void;\n}\n\n/**\n * In-memory {@link EventBus} that records what was published rather than\n * asserting on it. Prefer reading `bus.published` in a test over a mock's call\n * assertions: the expectation then reads as state, not as a spy protocol.\n */\nexport const makeRecordingEventBus = (): RecordingEventBus => {\n const published: DomainEvent[] = [];\n const handlers: Array<{\n pattern: string;\n handle: (event: DomainEvent) => Effect.Effect<void, TransitionalFailure>;\n }> = [];\n let failNext = false;\n\n return {\n publish: event =>\n Effect.suspend(() => {\n if (failNext) {\n failNext = false;\n return Effect.fail(new Unavailable({ dependency: 'bus' }));\n }\n published.push(event);\n return Effect.void;\n }),\n subscribe: (subscription, handle) =>\n Effect.sync(() => {\n // Only the pattern is retained: queue durability and the delivery\n // ceiling are broker facts with no in-memory analogue, and faking them\n // would assert against the double rather than against a broker.\n handlers.push({ pattern: subscription.pattern, handle });\n }),\n deliver: event =>\n Effect.forEach(\n handlers.filter(({ pattern }) =>\n matchesEventPattern(pattern, event.name),\n ),\n ({ handle }) => handle(event),\n { discard: true },\n ),\n get published() {\n return published;\n },\n failNextPublish: () => {\n failNext = true;\n },\n };\n};\n\n/**\n * An in-memory {@link TransactionInbox}, one per consumer, over a shared claim\n * set.\n *\n * Sharing the set across the inboxes one factory hands out is what makes the\n * contract's \"one consumer cannot consume another's claim\" case a real\n * assertion, rather than one the double satisfies by only ever having seen a\n * single consumer.\n */\nexport const makeInMemoryInboxes = () => {\n const claimed = new Set<string>();\n\n return {\n /** An inbox for `consumer`, over the shared claim set. */\n for: (consumer: string): TransactionInbox => ({\n claim: eventId =>\n Effect.sync(() => {\n const key = JSON.stringify([consumer, eventId]);\n if (claimed.has(key)) return 'duplicate' as const;\n claimed.add(key);\n return 'claimed' as const;\n }),\n }),\n get claims(): readonly { consumer: string; eventId: string }[] {\n return [...claimed].map(key => {\n const [consumer, eventId] = JSON.parse(key) as [string, string];\n return { consumer, eventId };\n });\n },\n };\n};\n\nexport interface InMemorySagaStore extends SagaStore {\n /** Every instance, as stored — for a spec to assert on state, not calls. */\n readonly instances: readonly SagaInstance[];\n}\n\n/**\n * In-memory {@link SagaStore}, with the Mongo adapter's semantics: `start`\n * never overwrites, outcomes append, and a resume claim only takes an\n * unfinished instance whose `updatedAt` is past the deadline — counting the\n * attempt and re-stamping it, so the next sweep does not find it again.\n */\nexport const makeInMemorySagaStore = (): InMemorySagaStore => {\n const instances = new Map<string, SagaInstance>();\n const stamp = () => new Date().toISOString();\n const unfinished = (entry: SagaInstance) =>\n entry.state === 'RUNNING' || entry.state === 'COMPENSATING';\n const pastDeadline = (entry: SagaInstance, olderThanMs: number) =>\n entry.updatedAt < new Date(Date.now() - olderThanMs).toISOString();\n const update = (\n sagaId: string,\n change: (entry: SagaInstance) => SagaInstance,\n ) =>\n Effect.sync(() => {\n const current = instances.get(sagaId);\n if (current !== undefined) {\n instances.set(sagaId, { ...change(current), updatedAt: stamp() });\n }\n });\n\n return {\n start: started =>\n Effect.sync(() => {\n if (!instances.has(started.sagaId)) {\n instances.set(started.sagaId, { ...started, updatedAt: stamp() });\n }\n }),\n beginStep: (sagaId, stepIndex) =>\n update(sagaId, entry => ({ ...entry, stepIndex })),\n recordOutcome: (sagaId, outcome) =>\n update(sagaId, entry => ({\n ...entry,\n outcomes: [...entry.outcomes, outcome],\n })),\n settle: (sagaId, state, error) =>\n update(sagaId, entry => ({ ...entry, state, error })),\n markCompensated: (sagaId, stepId) =>\n update(sagaId, entry => ({\n ...entry,\n outcomes: entry.outcomes.map(outcome =>\n outcome.stepId === stepId\n ? { ...outcome, compensated: true }\n : outcome,\n ),\n })),\n get: sagaId => Effect.sync(() => instances.get(sagaId)),\n findStale: olderThanMs =>\n Effect.sync(() =>\n [...instances.values()].filter(\n entry => unfinished(entry) && pastDeadline(entry, olderThanMs),\n ),\n ),\n claimForResume: (sagaId, olderThanMs) =>\n Effect.sync(() => {\n const current = instances.get(sagaId);\n if (\n current === undefined ||\n !unfinished(current) ||\n !pastDeadline(current, olderThanMs)\n ) {\n return undefined;\n }\n const claimed = {\n ...current,\n resumeAttempts: current.resumeAttempts + 1,\n updatedAt: stamp(),\n };\n instances.set(sagaId, claimed);\n return claimed;\n }),\n get instances() {\n return [...instances.values()];\n },\n };\n};\n"],"names":["Contention","matchesEventPattern","Unavailable","Effect","makeInMemoryLockService","tokens","Map","contended","Set","log","counter","acquire","key","suspend","has","fail","token","set","push","action","succeed","release","handle","sync","get","delete","held","keys","contendOn","add","makeInMemorySequenceService","sequences","next","name","values","Object","fromEntries","makeRecordingEventBus","published","handlers","failNext","publish","event","dependency","void","subscribe","subscription","pattern","deliver","forEach","filter","discard","failNextPublish","makeInMemoryInboxes","claimed","for","consumer","claim","eventId","JSON","stringify","claims","map","parse","makeInMemorySagaStore","instances","stamp","Date","toISOString","unfinished","entry","state","pastDeadline","olderThanMs","updatedAt","now","update","sagaId","change","current","undefined","start","started","beginStep","stepIndex","recordOutcome","outcome","outcomes","settle","error","markCompensated","stepId","compensated","findStale","claimForResume","resumeAttempts"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"unit-of-work.d.ts","sourceRoot":"","sources":["../../src/doubles/unit-of-work.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAoB,MAAM,eAAe,CAAC;AACnE,OAAO,KAAK,EAAa,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAGnE;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,kBAAmB,SAAQ,UAAU,CAAC,kBAAkB,CAAC;IACxE,6BAA6B;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/C,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CACzC;AAOD;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAM,QAAQ,CAAC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAM,KAC9C,kBA0EF,CAAC"}
1
+ {"version":3,"file":"unit-of-work.d.ts","sourceRoot":"","sources":["../../src/doubles/unit-of-work.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgB,MAAM,eAAe,CAAC;AAC/D,OAAO,KAAK,EAAa,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAGnE;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,kBAAmB,SAAQ,UAAU,CAAC,kBAAkB,CAAC;IACxE,6BAA6B;IAC7B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/C,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CACzC;AAOD;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,GACjC,OAAM,QAAQ,CAAC,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,CAAM,KAC9C,kBA0EF,CAAC"}
@@ -1,4 +1,4 @@
1
- import { EntifixConnError } from '@entifix/core';
1
+ import { isStoreFault } from '@entifix/core';
2
2
  import { Effect, Option, Result } from 'effect';
3
3
  const claimKey = (claim)=>claim.kind === 'event' ? `event:${claim.consumer}:${claim.eventId}` : `command:${claim.commandId}`;
4
4
  /**
@@ -41,7 +41,7 @@ const claimKey = (claim)=>claim.kind === 'event' ? `event:${claim.consumer}:${cl
41
41
  emit: (event)=>void emitted.push(event)
42
42
  }));
43
43
  if (Result.isFailure(outcome)) {
44
- if (outcome.failure instanceof EntifixConnError) {
44
+ if (isStoreFault(outcome.failure)) {
45
45
  return yield* Effect.fail(outcome.failure);
46
46
  }
47
47
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/doubles/unit-of-work.ts"],"sourcesContent":["import { type DomainEvent, EntifixConnError } from '@entifix/core';\nimport type { UnitClaim, UnitOfWork } from '@entifix/transactions';\nimport { Effect, Option, Result } from 'effect';\n\n/**\n * The handle an in-memory unit hands its body: a working copy of the records,\n * which becomes the store only if the unit commits.\n */\nexport interface InMemoryUnitHandle {\n readonly records: Map<string, unknown>;\n}\n\nexport interface InMemoryUnitOfWork extends UnitOfWork<InMemoryUnitHandle> {\n /** The committed records. */\n readonly records: ReadonlyMap<string, unknown>;\n /** Every event a committed unit emitted, in commit order. */\n readonly outbox: readonly DomainEvent[];\n}\n\nconst claimKey = (claim: UnitClaim): string =>\n claim.kind === 'event'\n ? `event:${claim.consumer}:${claim.eventId}`\n : `command:${claim.commandId}`;\n\n/**\n * A {@link UnitOfWork} over plain maps, for testing code that runs units\n * without a database.\n *\n * Atomic the way a real one is: the body writes to a copy, and a body that\n * fails — with a business reason or with a store error — leaves the committed\n * records, the outbox and the claims exactly as they were. Like the Mongo\n * driver fake it is atomicity and not isolation; nothing here runs\n * concurrently, so a race is a `live` test's to prove.\n */\nexport const makeInMemoryUnitOfWork = (\n seed: Iterable<readonly [string, unknown]> = [],\n): InMemoryUnitOfWork => {\n let records = new Map<string, unknown>(seed);\n const outbox: DomainEvent[] = [];\n const eventIds = new Set<string>();\n // A taken claim, and for a command what it is to be answered with.\n const claims = new Map<string, Option.Option<unknown>>();\n\n const unitOfWork: InMemoryUnitOfWork = {\n get records() {\n return records;\n },\n get outbox() {\n return outbox;\n },\n\n run: (body, options) =>\n Effect.gen(function* () {\n const key = options?.claim ? claimKey(options.claim) : undefined;\n const taken = key === undefined ? undefined : claims.get(key);\n if (taken !== undefined) {\n return { _tag: 'duplicate' as const, replay: taken };\n }\n\n const handle: InMemoryUnitHandle = { records: new Map(records) };\n const emitted: DomainEvent[] = [];\n const outcome = yield* Effect.result(\n body({ handle, emit: event => void emitted.push(event) }),\n );\n\n if (Result.isFailure(outcome)) {\n if (outcome.failure instanceof EntifixConnError) {\n return yield* Effect.fail(outcome.failure);\n }\n return {\n _tag: 'aborted' as const,\n reason: outcome.failure as never,\n };\n }\n\n // An event id already recorded is the outbox's own idempotency claim:\n // the work it announces has committed before.\n if (emitted.some(event => eventIds.has(event.id))) {\n return { _tag: 'duplicate' as const, replay: Option.none() };\n }\n\n records = handle.records;\n for (const event of emitted) {\n eventIds.add(event.id);\n outbox.push(event);\n }\n if (key !== undefined) {\n claims.set(\n key,\n options?.replay && options.claim?.kind === 'command'\n ? Option.some(options.replay(outcome.success))\n : Option.none(),\n );\n }\n\n if (options?.afterCommit) {\n yield* options.afterCommit.pipe(Effect.ignore);\n }\n return { _tag: 'committed' as const, value: outcome.success };\n }),\n\n replay: commandId =>\n Effect.sync(() =>\n Option.flatten(\n Option.fromUndefinedOr(claims.get(`command:${commandId}`)),\n ),\n ),\n };\n\n return unitOfWork;\n};\n"],"names":["EntifixConnError","Effect","Option","Result","claimKey","claim","kind","consumer","eventId","commandId","makeInMemoryUnitOfWork","seed","records","Map","outbox","eventIds","Set","claims","unitOfWork","run","body","options","gen","key","undefined","taken","get","_tag","replay","handle","emitted","outcome","result","emit","event","push","isFailure","failure","fail","reason","some","has","id","none","add","set","success","afterCommit","pipe","ignore","value","sync","flatten","fromUndefinedOr"],"mappings":"AAAA,SAA2BA,gBAAgB,QAAQ,gBAAgB;AAEnE,SAASC,MAAM,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAiBhD,MAAMC,WAAW,CAACC,QAChBA,MAAMC,IAAI,KAAK,UACX,CAAC,MAAM,EAAED,MAAME,QAAQ,CAAC,CAAC,EAAEF,MAAMG,OAAO,EAAE,GAC1C,CAAC,QAAQ,EAAEH,MAAMI,SAAS,EAAE;AAElC;;;;;;;;;CASC,GACD,OAAO,MAAMC,yBAAyB,CACpCC,OAA6C,EAAE;IAE/C,IAAIC,UAAU,IAAIC,IAAqBF;IACvC,MAAMG,SAAwB,EAAE;IAChC,MAAMC,WAAW,IAAIC;IACrB,mEAAmE;IACnE,MAAMC,SAAS,IAAIJ;IAEnB,MAAMK,aAAiC;QACrC,IAAIN;YACF,OAAOA;QACT;QACA,IAAIE;YACF,OAAOA;QACT;QAEAK,KAAK,CAACC,MAAMC,UACVpB,OAAOqB,GAAG,CAAC;gBACT,MAAMC,MAAMF,CAAAA,2BAAAA,QAAShB,KAAK,IAAGD,SAASiB,QAAQhB,KAAK,IAAImB;gBACvD,MAAMC,QAAQF,QAAQC,YAAYA,YAAYP,OAAOS,GAAG,CAACH;gBACzD,IAAIE,UAAUD,WAAW;oBACvB,OAAO;wBAAEG,MAAM;wBAAsBC,QAAQH;oBAAM;gBACrD;gBAEA,MAAMI,SAA6B;oBAAEjB,SAAS,IAAIC,IAAID;gBAAS;gBAC/D,MAAMkB,UAAyB,EAAE;gBACjC,MAAMC,UAAU,OAAO9B,OAAO+B,MAAM,CAClCZ,KAAK;oBAAES;oBAAQI,MAAMC,CAAAA,QAAS,KAAKJ,QAAQK,IAAI,CAACD;gBAAO;gBAGzD,IAAI/B,OAAOiC,SAAS,CAACL,UAAU;oBAC7B,IAAIA,QAAQM,OAAO,YAAYrC,kBAAkB;wBAC/C,OAAO,OAAOC,OAAOqC,IAAI,CAACP,QAAQM,OAAO;oBAC3C;oBACA,OAAO;wBACLV,MAAM;wBACNY,QAAQR,QAAQM,OAAO;oBACzB;gBACF;gBAEA,sEAAsE;gBACtE,8CAA8C;gBAC9C,IAAIP,QAAQU,IAAI,CAACN,CAAAA,QAASnB,SAAS0B,GAAG,CAACP,MAAMQ,EAAE,IAAI;oBACjD,OAAO;wBAAEf,MAAM;wBAAsBC,QAAQ1B,OAAOyC,IAAI;oBAAG;gBAC7D;gBAEA/B,UAAUiB,OAAOjB,OAAO;gBACxB,KAAK,MAAMsB,SAASJ,QAAS;oBAC3Bf,SAAS6B,GAAG,CAACV,MAAMQ,EAAE;oBACrB5B,OAAOqB,IAAI,CAACD;gBACd;gBACA,IAAIX,QAAQC,WAAW;wBAGAH;oBAFrBJ,OAAO4B,GAAG,CACRtB,KACAF,CAAAA,2BAAAA,QAASO,MAAM,KAAIP,EAAAA,iBAAAA,QAAQhB,KAAK,qBAAbgB,eAAef,IAAI,MAAK,YACvCJ,OAAOsC,IAAI,CAACnB,QAAQO,MAAM,CAACG,QAAQe,OAAO,KAC1C5C,OAAOyC,IAAI;gBAEnB;gBAEA,IAAItB,2BAAAA,QAAS0B,WAAW,EAAE;oBACxB,OAAO1B,QAAQ0B,WAAW,CAACC,IAAI,CAAC/C,OAAOgD,MAAM;gBAC/C;gBACA,OAAO;oBAAEtB,MAAM;oBAAsBuB,OAAOnB,QAAQe,OAAO;gBAAC;YAC9D;QAEFlB,QAAQnB,CAAAA,YACNR,OAAOkD,IAAI,CAAC,IACVjD,OAAOkD,OAAO,CACZlD,OAAOmD,eAAe,CAACpC,OAAOS,GAAG,CAAC,CAAC,QAAQ,EAAEjB,WAAW;IAGhE;IAEA,OAAOS;AACT,EAAE"}
1
+ {"version":3,"sources":["../../src/doubles/unit-of-work.ts"],"sourcesContent":["import { type DomainEvent, isStoreFault } from '@entifix/core';\nimport type { UnitClaim, UnitOfWork } from '@entifix/transactions';\nimport { Effect, Option, Result } from 'effect';\n\n/**\n * The handle an in-memory unit hands its body: a working copy of the records,\n * which becomes the store only if the unit commits.\n */\nexport interface InMemoryUnitHandle {\n readonly records: Map<string, unknown>;\n}\n\nexport interface InMemoryUnitOfWork extends UnitOfWork<InMemoryUnitHandle> {\n /** The committed records. */\n readonly records: ReadonlyMap<string, unknown>;\n /** Every event a committed unit emitted, in commit order. */\n readonly outbox: readonly DomainEvent[];\n}\n\nconst claimKey = (claim: UnitClaim): string =>\n claim.kind === 'event'\n ? `event:${claim.consumer}:${claim.eventId}`\n : `command:${claim.commandId}`;\n\n/**\n * A {@link UnitOfWork} over plain maps, for testing code that runs units\n * without a database.\n *\n * Atomic the way a real one is: the body writes to a copy, and a body that\n * fails — with a business reason or with a store error — leaves the committed\n * records, the outbox and the claims exactly as they were. Like the Mongo\n * driver fake it is atomicity and not isolation; nothing here runs\n * concurrently, so a race is a `live` test's to prove.\n */\nexport const makeInMemoryUnitOfWork = (\n seed: Iterable<readonly [string, unknown]> = [],\n): InMemoryUnitOfWork => {\n let records = new Map<string, unknown>(seed);\n const outbox: DomainEvent[] = [];\n const eventIds = new Set<string>();\n // A taken claim, and for a command what it is to be answered with.\n const claims = new Map<string, Option.Option<unknown>>();\n\n const unitOfWork: InMemoryUnitOfWork = {\n get records() {\n return records;\n },\n get outbox() {\n return outbox;\n },\n\n run: (body, options) =>\n Effect.gen(function* () {\n const key = options?.claim ? claimKey(options.claim) : undefined;\n const taken = key === undefined ? undefined : claims.get(key);\n if (taken !== undefined) {\n return { _tag: 'duplicate' as const, replay: taken };\n }\n\n const handle: InMemoryUnitHandle = { records: new Map(records) };\n const emitted: DomainEvent[] = [];\n const outcome = yield* Effect.result(\n body({ handle, emit: event => void emitted.push(event) }),\n );\n\n if (Result.isFailure(outcome)) {\n if (isStoreFault(outcome.failure)) {\n return yield* Effect.fail(outcome.failure);\n }\n return {\n _tag: 'aborted' as const,\n reason: outcome.failure as never,\n };\n }\n\n // An event id already recorded is the outbox's own idempotency claim:\n // the work it announces has committed before.\n if (emitted.some(event => eventIds.has(event.id))) {\n return { _tag: 'duplicate' as const, replay: Option.none() };\n }\n\n records = handle.records;\n for (const event of emitted) {\n eventIds.add(event.id);\n outbox.push(event);\n }\n if (key !== undefined) {\n claims.set(\n key,\n options?.replay && options.claim?.kind === 'command'\n ? Option.some(options.replay(outcome.success))\n : Option.none(),\n );\n }\n\n if (options?.afterCommit) {\n yield* options.afterCommit.pipe(Effect.ignore);\n }\n return { _tag: 'committed' as const, value: outcome.success };\n }),\n\n replay: commandId =>\n Effect.sync(() =>\n Option.flatten(\n Option.fromUndefinedOr(claims.get(`command:${commandId}`)),\n ),\n ),\n };\n\n return unitOfWork;\n};\n"],"names":["isStoreFault","Effect","Option","Result","claimKey","claim","kind","consumer","eventId","commandId","makeInMemoryUnitOfWork","seed","records","Map","outbox","eventIds","Set","claims","unitOfWork","run","body","options","gen","key","undefined","taken","get","_tag","replay","handle","emitted","outcome","result","emit","event","push","isFailure","failure","fail","reason","some","has","id","none","add","set","success","afterCommit","pipe","ignore","value","sync","flatten","fromUndefinedOr"],"mappings":"AAAA,SAA2BA,YAAY,QAAQ,gBAAgB;AAE/D,SAASC,MAAM,EAAEC,MAAM,EAAEC,MAAM,QAAQ,SAAS;AAiBhD,MAAMC,WAAW,CAACC,QAChBA,MAAMC,IAAI,KAAK,UACX,CAAC,MAAM,EAAED,MAAME,QAAQ,CAAC,CAAC,EAAEF,MAAMG,OAAO,EAAE,GAC1C,CAAC,QAAQ,EAAEH,MAAMI,SAAS,EAAE;AAElC;;;;;;;;;CASC,GACD,OAAO,MAAMC,yBAAyB,CACpCC,OAA6C,EAAE;IAE/C,IAAIC,UAAU,IAAIC,IAAqBF;IACvC,MAAMG,SAAwB,EAAE;IAChC,MAAMC,WAAW,IAAIC;IACrB,mEAAmE;IACnE,MAAMC,SAAS,IAAIJ;IAEnB,MAAMK,aAAiC;QACrC,IAAIN;YACF,OAAOA;QACT;QACA,IAAIE;YACF,OAAOA;QACT;QAEAK,KAAK,CAACC,MAAMC,UACVpB,OAAOqB,GAAG,CAAC;gBACT,MAAMC,MAAMF,CAAAA,2BAAAA,QAAShB,KAAK,IAAGD,SAASiB,QAAQhB,KAAK,IAAImB;gBACvD,MAAMC,QAAQF,QAAQC,YAAYA,YAAYP,OAAOS,GAAG,CAACH;gBACzD,IAAIE,UAAUD,WAAW;oBACvB,OAAO;wBAAEG,MAAM;wBAAsBC,QAAQH;oBAAM;gBACrD;gBAEA,MAAMI,SAA6B;oBAAEjB,SAAS,IAAIC,IAAID;gBAAS;gBAC/D,MAAMkB,UAAyB,EAAE;gBACjC,MAAMC,UAAU,OAAO9B,OAAO+B,MAAM,CAClCZ,KAAK;oBAAES;oBAAQI,MAAMC,CAAAA,QAAS,KAAKJ,QAAQK,IAAI,CAACD;gBAAO;gBAGzD,IAAI/B,OAAOiC,SAAS,CAACL,UAAU;oBAC7B,IAAI/B,aAAa+B,QAAQM,OAAO,GAAG;wBACjC,OAAO,OAAOpC,OAAOqC,IAAI,CAACP,QAAQM,OAAO;oBAC3C;oBACA,OAAO;wBACLV,MAAM;wBACNY,QAAQR,QAAQM,OAAO;oBACzB;gBACF;gBAEA,sEAAsE;gBACtE,8CAA8C;gBAC9C,IAAIP,QAAQU,IAAI,CAACN,CAAAA,QAASnB,SAAS0B,GAAG,CAACP,MAAMQ,EAAE,IAAI;oBACjD,OAAO;wBAAEf,MAAM;wBAAsBC,QAAQ1B,OAAOyC,IAAI;oBAAG;gBAC7D;gBAEA/B,UAAUiB,OAAOjB,OAAO;gBACxB,KAAK,MAAMsB,SAASJ,QAAS;oBAC3Bf,SAAS6B,GAAG,CAACV,MAAMQ,EAAE;oBACrB5B,OAAOqB,IAAI,CAACD;gBACd;gBACA,IAAIX,QAAQC,WAAW;wBAGAH;oBAFrBJ,OAAO4B,GAAG,CACRtB,KACAF,CAAAA,2BAAAA,QAASO,MAAM,KAAIP,EAAAA,iBAAAA,QAAQhB,KAAK,qBAAbgB,eAAef,IAAI,MAAK,YACvCJ,OAAOsC,IAAI,CAACnB,QAAQO,MAAM,CAACG,QAAQe,OAAO,KAC1C5C,OAAOyC,IAAI;gBAEnB;gBAEA,IAAItB,2BAAAA,QAAS0B,WAAW,EAAE;oBACxB,OAAO1B,QAAQ0B,WAAW,CAACC,IAAI,CAAC/C,OAAOgD,MAAM;gBAC/C;gBACA,OAAO;oBAAEtB,MAAM;oBAAsBuB,OAAOnB,QAAQe,OAAO;gBAAC;YAC9D;QAEFlB,QAAQnB,CAAAA,YACNR,OAAOkD,IAAI,CAAC,IACVjD,OAAOkD,OAAO,CACZlD,OAAOmD,eAAe,CAACpC,OAAOS,GAAG,CAAC,CAAC,QAAQ,EAAEjB,WAAW;IAGhE;IAEA,OAAOS;AACT,EAAE"}
@@ -7,6 +7,10 @@
7
7
  */
8
8
  export interface FakeAmqpMessage {
9
9
  content: Buffer;
10
+ /** Present only for a delivery made with headers. */
11
+ properties?: {
12
+ headers: Record<string, unknown>;
13
+ };
10
14
  }
11
15
  export interface FakeAmqpChannel {
12
16
  /** Everything published, decoded from the wire. */
@@ -14,6 +18,8 @@ export interface FakeAmqpChannel {
14
18
  exchange: string;
15
19
  routingKey: string;
16
20
  body: unknown;
21
+ /** The headers it was published with — a quarantine's reason rides here. */
22
+ headers: Record<string, unknown> | undefined;
17
23
  }>;
18
24
  /**
19
25
  * Every queue binding the adapter asked for. Recorded because the exchange is
@@ -77,7 +83,7 @@ export interface FakeAmqpChannel {
77
83
  * would. The routing key defaults to the envelope's own `meta.event.name`,
78
84
  * which is what the adapter publishes with.
79
85
  */
80
- deliver(body: unknown, routingKey?: string): Promise<void>;
86
+ deliver(body: unknown, routingKey?: string, headers?: Record<string, unknown>): Promise<void>;
81
87
  /** Pushes a raw payload, for malformed-message paths. */
82
88
  deliverRaw(payload: string): Promise<void>;
83
89
  /** Simulates the broker cancelling the consumer (amqplib delivers `null`). */
@@ -1 +1 @@
1
- {"version":3,"file":"fake-amqp.d.ts","sourceRoot":"","sources":["../../src/drivers/fake-amqp.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,OAAO,CAAC;KACf,CAAC,CAAC;IACH;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;QAC/B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC,CAAC;IACH;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAC7B,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAC7B,OAAO,EAAE,eAAe,CAAC;QACzB,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC,CAAC;IACH,iEAAiE;IACjE,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,yDAAyD;IACzD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,8EAA8E;IAC9E,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AASD,eAAO,MAAM,mBAAmB,QAAO,eA0KtC,CAAC"}
1
+ {"version":3,"file":"fake-amqp.d.ts","sourceRoot":"","sources":["../../src/drivers/fake-amqp.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,UAAU,CAAC,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC;CACnD;AAED,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;QACjB,UAAU,EAAE,MAAM,CAAC;QACnB,IAAI,EAAE,OAAO,CAAC;QACd,4EAA4E;QAC5E,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;QAC/B,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,EAAE,MAAM,CAAC;QACjB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC,CAAC;IACH;;;;;OAKG;IACH,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;QAChC,QAAQ,EAAE,MAAM,CAAC;QACjB,IAAI,EAAE,MAAM,CAAC;QACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAC7B,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;KAC9C,CAAC,CAAC;IACH,6DAA6D;IAC7D,QAAQ,CAAC,KAAK,EAAE,eAAe,EAAE,CAAC;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;QAC7B,OAAO,EAAE,eAAe,CAAC;QACzB,OAAO,EAAE,OAAO,CAAC;QACjB,OAAO,EAAE,OAAO,CAAC;KAClB,CAAC,CAAC;IACH,iEAAiE;IACjE,QAAQ,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3C;;;;;;;OAOG;IACH,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC;;;;OAIG;IACH,OAAO,CACL,IAAI,EAAE,OAAO,EACb,UAAU,CAAC,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB,yDAAyD;IACzD,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,8EAA8E;IAC9E,mBAAmB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAsBD,eAAO,MAAM,mBAAmB,QAAO,eA0LtC,CAAC"}
@@ -1,9 +1,21 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  import { matchesEventPattern } from '@entifix/core';
2
3
  /** The routing key a body would have been published with. */ const routingKeyOf = (body)=>{
3
4
  var _body_meta;
4
5
  const event = body == null ? void 0 : (_body_meta = body.meta) == null ? void 0 : _body_meta.event;
5
6
  return typeof (event == null ? void 0 : event.name) === 'string' ? event.name : '';
6
7
  };
8
+ /**
9
+ * The body as JSON, or as the text it is when it is not — a poison message
10
+ * republished to quarantine is exactly the bytes that did not parse.
11
+ */ const decode = (content)=>{
12
+ const text = content.toString();
13
+ try {
14
+ return JSON.parse(text);
15
+ } catch (unused) {
16
+ return text;
17
+ }
18
+ };
7
19
  export const makeFakeAmqpChannel = ()=>{
8
20
  const published = [];
9
21
  const bindings = [];
@@ -21,12 +33,13 @@ export const makeFakeAmqpChannel = ()=>{
21
33
  if (failure !== undefined) throw failure;
22
34
  };
23
35
  const channel = {
24
- publish: (exchange, routingKey, content)=>{
36
+ publish: (exchange, routingKey, content, options)=>{
25
37
  guard();
26
38
  published.push({
27
39
  exchange,
28
40
  routingKey,
29
- body: JSON.parse(content.toString())
41
+ body: decode(content),
42
+ headers: options == null ? void 0 : options.headers
30
43
  });
31
44
  return true;
32
45
  },
@@ -108,16 +121,20 @@ export const makeFakeAmqpChannel = ()=>{
108
121
  * `routingKey === undefined` means "the broker already routed this" — used by
109
122
  * the malformed-payload path, where the message reached the queue and it is
110
123
  * the *content* that is wrong.
111
- */ const push = async (payload, routingKey)=>{
124
+ */ const push = async (payload, routingKey, headers)=>{
112
125
  if (consumer === undefined) {
113
126
  throw new Error('fake amqp: nothing subscribed yet');
114
127
  }
115
128
  if (routingKey !== undefined && !routes(routingKey)) {
116
129
  return;
117
130
  }
118
- consumer({
131
+ consumer(_extends({
119
132
  content: Buffer.from(payload)
120
- });
133
+ }, headers === undefined ? {} : {
134
+ properties: {
135
+ headers
136
+ }
137
+ }));
121
138
  await settle();
122
139
  };
123
140
  return {
@@ -145,7 +162,7 @@ export const makeFakeAmqpChannel = ()=>{
145
162
  get cancelled () {
146
163
  return cancelled;
147
164
  },
148
- deliver: (body, routingKey)=>push(JSON.stringify(body), routingKey != null ? routingKey : routingKeyOf(body)),
165
+ deliver: (body, routingKey, headers)=>push(JSON.stringify(body), routingKey != null ? routingKey : routingKeyOf(body), headers),
149
166
  deliverRaw: (payload)=>push(payload, undefined),
150
167
  deliverCancellation: async ()=>{
151
168
  if (consumer === undefined) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/drivers/fake-amqp.ts"],"sourcesContent":["import { matchesEventPattern } from '@entifix/core';\n\n/**\n * A fake of the amqplib channel, not of the {@link EventBus} port.\n *\n * `makeAmqpEventBus` runs on top of it, so the envelope framing, the\n * `prefetch(1)` the saga tracker depends on, and the ack/nack policy are\n * all exercised for real.\n */\n\nexport interface FakeAmqpMessage {\n content: Buffer;\n}\n\nexport interface FakeAmqpChannel {\n /** Everything published, decoded from the wire. */\n readonly published: ReadonlyArray<{\n exchange: string;\n routingKey: string;\n body: unknown;\n }>;\n /**\n * Every queue binding the adapter asked for. Recorded because the exchange is\n * a topic now: the pattern a subscriber binds is the whole of its routing, so\n * a fake that swallowed it would let a wrong binding pass every test.\n */\n readonly bindings: ReadonlyArray<{\n queue: string;\n exchange: string;\n pattern: string;\n }>;\n /**\n * Every exchange the adapter declared, with its type. Recorded because the\n * dead-letter exchange is `direct` while the event exchange is `topic`, and a\n * broker will not retype an existing exchange — declaring the wrong type is a\n * failure that only appears against a real broker.\n */\n readonly exchanges: ReadonlyArray<{\n exchange: string;\n type: string;\n options: Record<string, unknown> | undefined;\n }>;\n /**\n * Every queue the adapter declared, with the arguments it declared it with.\n * The arguments *are* the delivery policy — `x-queue-type`,\n * `x-delivery-limit`, `x-dead-letter-exchange` — so a fake that swallowed\n * them would let a queue with no retry ceiling and no dead-letter path pass\n * every test, which is the exact defect a quarantine path exists to close.\n */\n readonly queues: ReadonlyArray<{\n queue: string;\n options: Record<string, unknown> | undefined;\n }>;\n /** Messages acked, so the success policy can be asserted. */\n readonly acked: FakeAmqpMessage[];\n /**\n * Messages nacked, **with their flags**. `requeue` is the whole distinction\n * between a transient failure the broker should retry and a poison message it\n * should quarantine at once, so recording only that a nack happened is how\n * \"dead-letters a failed message\" stayed a passing test for a bus that\n * discarded it.\n */\n readonly nacked: ReadonlyArray<{\n message: FakeAmqpMessage;\n allUpTo: boolean;\n requeue: boolean;\n }>;\n /** The prefetch the adapter asked for — 1, or the fold races. */\n readonly prefetchCount: number | undefined;\n /**\n * The consumer tags cancelled, in order.\n *\n * Recorded rather than merely accepted because a graceful shutdown's first\n * phase *is* the cancel: a connector that dropped the tag on the\n * floor would pass every test while leaving the broker delivering into a\n * process that is closing its connection.\n */\n readonly cancelled: readonly string[];\n /**\n * Pushes a message to the consumers whose binding matches it, as the broker\n * would. The routing key defaults to the envelope's own `meta.event.name`,\n * which is what the adapter publishes with.\n */\n deliver(body: unknown, routingKey?: string): Promise<void>;\n /** Pushes a raw payload, for malformed-message paths. */\n deliverRaw(payload: string): Promise<void>;\n /** Simulates the broker cancelling the consumer (amqplib delivers `null`). */\n deliverCancellation(): Promise<void>;\n /** Makes every subsequent channel call throw/reject with `error`. */\n failWith(error: unknown): void;\n /** The object to pass where an amqplib `Channel` is expected. */\n readonly channel: unknown;\n}\n\n/** The routing key a body would have been published with. */\nconst routingKeyOf = (body: unknown): string => {\n const event = (body as { meta?: { event?: { name?: unknown } } })?.meta\n ?.event;\n return typeof event?.name === 'string' ? event.name : '';\n};\n\nexport const makeFakeAmqpChannel = (): FakeAmqpChannel => {\n const published: Array<{\n exchange: string;\n routingKey: string;\n body: unknown;\n }> = [];\n const bindings: Array<{ queue: string; exchange: string; pattern: string }> =\n [];\n const exchanges: Array<{\n exchange: string;\n type: string;\n options: Record<string, unknown> | undefined;\n }> = [];\n const queues: Array<{\n queue: string;\n options: Record<string, unknown> | undefined;\n }> = [];\n const acked: FakeAmqpMessage[] = [];\n const nacked: Array<{\n message: FakeAmqpMessage;\n allUpTo: boolean;\n requeue: boolean;\n }> = [];\n let consumer: ((message: FakeAmqpMessage | null) => void) | undefined;\n const cancelled: string[] = [];\n let consumerTags = 0;\n let boundQueue: string | undefined;\n let prefetchCount: number | undefined;\n let failure: unknown;\n\n const guard = () => {\n if (failure !== undefined) throw failure;\n };\n\n const channel = {\n publish: (exchange: string, routingKey: string, content: Buffer) => {\n guard();\n published.push({\n exchange,\n routingKey,\n body: JSON.parse(content.toString()) as unknown,\n });\n return true;\n },\n prefetch: async (count: number) => {\n guard();\n prefetchCount = count;\n },\n assertExchange: async (\n exchange: string,\n type: string,\n options?: Record<string, unknown>,\n ) => {\n guard();\n exchanges.push({ exchange, type, options });\n return { exchange };\n },\n assertQueue: async (queue: string, options?: Record<string, unknown>) => {\n guard();\n const name = queue === '' ? 'amq.gen-fake' : queue;\n queues.push({ queue: name, options });\n return { queue: name };\n },\n bindQueue: async (queue: string, exchange: string, pattern: string) => {\n guard();\n bindings.push({ queue, exchange, pattern });\n },\n consume: async (\n queue: string,\n handler: (message: FakeAmqpMessage | null) => void,\n ) => {\n guard();\n consumer = handler;\n boundQueue = queue;\n // A fresh tag per `consume`, so a reconnect's re-bind is distinguishable\n // from the binding it replaced — the connector cancels what it currently\n // holds, and a constant tag would hide it cancelling a stale one.\n consumerTags += 1;\n return { consumerTag: `fake-consumer-${consumerTags}` };\n },\n cancel: async (consumerTag: string) => {\n guard();\n cancelled.push(consumerTag);\n consumer = undefined;\n },\n ack: (message: FakeAmqpMessage) => {\n acked.push(message);\n },\n nack: (message: FakeAmqpMessage, allUpTo: boolean, requeue: boolean) => {\n nacked.push({ message, allUpTo, requeue });\n },\n close: async () => {\n guard();\n },\n };\n\n /**\n * The adapter acks/nacks from inside a floating promise chain, so a delivery\n * is only settled once the microtask queue drains.\n */\n const settle = () => new Promise<void>(resolve => setTimeout(resolve, 0));\n\n /**\n * Whether the consumer's queue is bound to a pattern matching `routingKey`.\n * The broker decides this in production, so the fake has to as well — without\n * it every subscriber sees every message and the topic exchange is untested.\n */\n const routes = (routingKey: string) =>\n bindings.some(\n binding =>\n binding.queue === boundQueue &&\n matchesEventPattern(binding.pattern, routingKey),\n );\n\n /**\n * `routingKey === undefined` means \"the broker already routed this\" — used by\n * the malformed-payload path, where the message reached the queue and it is\n * the *content* that is wrong.\n */\n const push = async (payload: string, routingKey: string | undefined) => {\n if (consumer === undefined) {\n throw new Error('fake amqp: nothing subscribed yet');\n }\n if (routingKey !== undefined && !routes(routingKey)) {\n return;\n }\n consumer({ content: Buffer.from(payload) });\n await settle();\n };\n\n return {\n get published() {\n return published;\n },\n get bindings() {\n return bindings;\n },\n get exchanges() {\n return exchanges;\n },\n get queues() {\n return queues;\n },\n get acked() {\n return acked;\n },\n get nacked() {\n return nacked;\n },\n get prefetchCount() {\n return prefetchCount;\n },\n get cancelled() {\n return cancelled;\n },\n deliver: (body, routingKey) =>\n push(JSON.stringify(body), routingKey ?? routingKeyOf(body)),\n deliverRaw: payload => push(payload, undefined),\n deliverCancellation: async () => {\n if (consumer === undefined) {\n throw new Error('fake amqp: nothing subscribed yet');\n }\n consumer(null);\n await settle();\n },\n failWith: error => {\n failure = error;\n },\n channel,\n };\n};\n"],"names":["matchesEventPattern","routingKeyOf","body","event","meta","name","makeFakeAmqpChannel","published","bindings","exchanges","queues","acked","nacked","consumer","cancelled","consumerTags","boundQueue","prefetchCount","failure","guard","undefined","channel","publish","exchange","routingKey","content","push","JSON","parse","toString","prefetch","count","assertExchange","type","options","assertQueue","queue","bindQueue","pattern","consume","handler","consumerTag","cancel","ack","message","nack","allUpTo","requeue","close","settle","Promise","resolve","setTimeout","routes","some","binding","payload","Error","Buffer","from","deliver","stringify","deliverRaw","deliverCancellation","failWith","error"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/drivers/fake-amqp.ts"],"sourcesContent":["import { matchesEventPattern } from '@entifix/core';\n\n/**\n * A fake of the amqplib channel, not of the {@link EventBus} port.\n *\n * `makeAmqpEventBus` runs on top of it, so the envelope framing, the\n * `prefetch(1)` the saga tracker depends on, and the ack/nack policy are\n * all exercised for real.\n */\n\nexport interface FakeAmqpMessage {\n content: Buffer;\n /** Present only for a delivery made with headers. */\n properties?: { headers: Record<string, unknown> };\n}\n\nexport interface FakeAmqpChannel {\n /** Everything published, decoded from the wire. */\n readonly published: ReadonlyArray<{\n exchange: string;\n routingKey: string;\n body: unknown;\n /** The headers it was published with — a quarantine's reason rides here. */\n headers: Record<string, unknown> | undefined;\n }>;\n /**\n * Every queue binding the adapter asked for. Recorded because the exchange is\n * a topic now: the pattern a subscriber binds is the whole of its routing, so\n * a fake that swallowed it would let a wrong binding pass every test.\n */\n readonly bindings: ReadonlyArray<{\n queue: string;\n exchange: string;\n pattern: string;\n }>;\n /**\n * Every exchange the adapter declared, with its type. Recorded because the\n * dead-letter exchange is `direct` while the event exchange is `topic`, and a\n * broker will not retype an existing exchange — declaring the wrong type is a\n * failure that only appears against a real broker.\n */\n readonly exchanges: ReadonlyArray<{\n exchange: string;\n type: string;\n options: Record<string, unknown> | undefined;\n }>;\n /**\n * Every queue the adapter declared, with the arguments it declared it with.\n * The arguments *are* the delivery policy — `x-queue-type`,\n * `x-delivery-limit`, `x-dead-letter-exchange` — so a fake that swallowed\n * them would let a queue with no retry ceiling and no dead-letter path pass\n * every test, which is the exact defect a quarantine path exists to close.\n */\n readonly queues: ReadonlyArray<{\n queue: string;\n options: Record<string, unknown> | undefined;\n }>;\n /** Messages acked, so the success policy can be asserted. */\n readonly acked: FakeAmqpMessage[];\n /**\n * Messages nacked, **with their flags**. `requeue` is the whole distinction\n * between a transient failure the broker should retry and a poison message it\n * should quarantine at once, so recording only that a nack happened is how\n * \"dead-letters a failed message\" stayed a passing test for a bus that\n * discarded it.\n */\n readonly nacked: ReadonlyArray<{\n message: FakeAmqpMessage;\n allUpTo: boolean;\n requeue: boolean;\n }>;\n /** The prefetch the adapter asked for — 1, or the fold races. */\n readonly prefetchCount: number | undefined;\n /**\n * The consumer tags cancelled, in order.\n *\n * Recorded rather than merely accepted because a graceful shutdown's first\n * phase *is* the cancel: a connector that dropped the tag on the\n * floor would pass every test while leaving the broker delivering into a\n * process that is closing its connection.\n */\n readonly cancelled: readonly string[];\n /**\n * Pushes a message to the consumers whose binding matches it, as the broker\n * would. The routing key defaults to the envelope's own `meta.event.name`,\n * which is what the adapter publishes with.\n */\n deliver(\n body: unknown,\n routingKey?: string,\n headers?: Record<string, unknown>,\n ): Promise<void>;\n /** Pushes a raw payload, for malformed-message paths. */\n deliverRaw(payload: string): Promise<void>;\n /** Simulates the broker cancelling the consumer (amqplib delivers `null`). */\n deliverCancellation(): Promise<void>;\n /** Makes every subsequent channel call throw/reject with `error`. */\n failWith(error: unknown): void;\n /** The object to pass where an amqplib `Channel` is expected. */\n readonly channel: unknown;\n}\n\n/** The routing key a body would have been published with. */\nconst routingKeyOf = (body: unknown): string => {\n const event = (body as { meta?: { event?: { name?: unknown } } })?.meta\n ?.event;\n return typeof event?.name === 'string' ? event.name : '';\n};\n\n/**\n * The body as JSON, or as the text it is when it is not — a poison message\n * republished to quarantine is exactly the bytes that did not parse.\n */\nconst decode = (content: Buffer): unknown => {\n const text = content.toString();\n try {\n return JSON.parse(text) as unknown;\n } catch {\n return text;\n }\n};\n\nexport const makeFakeAmqpChannel = (): FakeAmqpChannel => {\n const published: Array<{\n exchange: string;\n routingKey: string;\n body: unknown;\n headers: Record<string, unknown> | undefined;\n }> = [];\n const bindings: Array<{ queue: string; exchange: string; pattern: string }> =\n [];\n const exchanges: Array<{\n exchange: string;\n type: string;\n options: Record<string, unknown> | undefined;\n }> = [];\n const queues: Array<{\n queue: string;\n options: Record<string, unknown> | undefined;\n }> = [];\n const acked: FakeAmqpMessage[] = [];\n const nacked: Array<{\n message: FakeAmqpMessage;\n allUpTo: boolean;\n requeue: boolean;\n }> = [];\n let consumer: ((message: FakeAmqpMessage | null) => void) | undefined;\n const cancelled: string[] = [];\n let consumerTags = 0;\n let boundQueue: string | undefined;\n let prefetchCount: number | undefined;\n let failure: unknown;\n\n const guard = () => {\n if (failure !== undefined) throw failure;\n };\n\n const channel = {\n publish: (\n exchange: string,\n routingKey: string,\n content: Buffer,\n options?: { headers?: Record<string, unknown> },\n ) => {\n guard();\n published.push({\n exchange,\n routingKey,\n body: decode(content),\n headers: options?.headers,\n });\n return true;\n },\n prefetch: async (count: number) => {\n guard();\n prefetchCount = count;\n },\n assertExchange: async (\n exchange: string,\n type: string,\n options?: Record<string, unknown>,\n ) => {\n guard();\n exchanges.push({ exchange, type, options });\n return { exchange };\n },\n assertQueue: async (queue: string, options?: Record<string, unknown>) => {\n guard();\n const name = queue === '' ? 'amq.gen-fake' : queue;\n queues.push({ queue: name, options });\n return { queue: name };\n },\n bindQueue: async (queue: string, exchange: string, pattern: string) => {\n guard();\n bindings.push({ queue, exchange, pattern });\n },\n consume: async (\n queue: string,\n handler: (message: FakeAmqpMessage | null) => void,\n ) => {\n guard();\n consumer = handler;\n boundQueue = queue;\n // A fresh tag per `consume`, so a reconnect's re-bind is distinguishable\n // from the binding it replaced — the connector cancels what it currently\n // holds, and a constant tag would hide it cancelling a stale one.\n consumerTags += 1;\n return { consumerTag: `fake-consumer-${consumerTags}` };\n },\n cancel: async (consumerTag: string) => {\n guard();\n cancelled.push(consumerTag);\n consumer = undefined;\n },\n ack: (message: FakeAmqpMessage) => {\n acked.push(message);\n },\n nack: (message: FakeAmqpMessage, allUpTo: boolean, requeue: boolean) => {\n nacked.push({ message, allUpTo, requeue });\n },\n close: async () => {\n guard();\n },\n };\n\n /**\n * The adapter acks/nacks from inside a floating promise chain, so a delivery\n * is only settled once the microtask queue drains.\n */\n const settle = () => new Promise<void>(resolve => setTimeout(resolve, 0));\n\n /**\n * Whether the consumer's queue is bound to a pattern matching `routingKey`.\n * The broker decides this in production, so the fake has to as well — without\n * it every subscriber sees every message and the topic exchange is untested.\n */\n const routes = (routingKey: string) =>\n bindings.some(\n binding =>\n binding.queue === boundQueue &&\n matchesEventPattern(binding.pattern, routingKey),\n );\n\n /**\n * `routingKey === undefined` means \"the broker already routed this\" — used by\n * the malformed-payload path, where the message reached the queue and it is\n * the *content* that is wrong.\n */\n const push = async (\n payload: string,\n routingKey: string | undefined,\n headers?: Record<string, unknown>,\n ) => {\n if (consumer === undefined) {\n throw new Error('fake amqp: nothing subscribed yet');\n }\n if (routingKey !== undefined && !routes(routingKey)) {\n return;\n }\n consumer({\n content: Buffer.from(payload),\n // Only when asked for, so a delivery without headers stays exactly the\n // shape amqplib hands over for a message published with none.\n ...(headers === undefined ? {} : { properties: { headers } }),\n });\n await settle();\n };\n\n return {\n get published() {\n return published;\n },\n get bindings() {\n return bindings;\n },\n get exchanges() {\n return exchanges;\n },\n get queues() {\n return queues;\n },\n get acked() {\n return acked;\n },\n get nacked() {\n return nacked;\n },\n get prefetchCount() {\n return prefetchCount;\n },\n get cancelled() {\n return cancelled;\n },\n deliver: (body, routingKey, headers) =>\n push(JSON.stringify(body), routingKey ?? routingKeyOf(body), headers),\n deliverRaw: payload => push(payload, undefined),\n deliverCancellation: async () => {\n if (consumer === undefined) {\n throw new Error('fake amqp: nothing subscribed yet');\n }\n consumer(null);\n await settle();\n },\n failWith: error => {\n failure = error;\n },\n channel,\n };\n};\n"],"names":["matchesEventPattern","routingKeyOf","body","event","meta","name","decode","content","text","toString","JSON","parse","makeFakeAmqpChannel","published","bindings","exchanges","queues","acked","nacked","consumer","cancelled","consumerTags","boundQueue","prefetchCount","failure","guard","undefined","channel","publish","exchange","routingKey","options","push","headers","prefetch","count","assertExchange","type","assertQueue","queue","bindQueue","pattern","consume","handler","consumerTag","cancel","ack","message","nack","allUpTo","requeue","close","settle","Promise","resolve","setTimeout","routes","some","binding","payload","Error","Buffer","from","properties","deliver","stringify","deliverRaw","deliverCancellation","failWith","error"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { type Entity, type EntityConstructor, type EntityId } from '@entifix/core';
2
- import { type RequestHandler } from 'msw';
1
+ import { type EntifixFailure, type Entity, type EntityConstructor, type EntityId } from '@entifix/core';
2
+ import { HttpResponse, type RequestHandler } from 'msw';
3
3
  export interface EntityRestHandlerOptions<TEntity extends Entity> {
4
4
  /** The collection endpoint, e.g. `http://service/api/product`. */
5
5
  baseUrl: string;
@@ -18,6 +18,13 @@ export interface EntityRestHandlerOptions<TEntity extends Entity> {
18
18
  export declare const entityRestHandlers: <TEntity extends Entity>(entityConstructor: EntityConstructor<TEntity>, { baseUrl, data }: EntityRestHandlerOptions<TEntity>) => RequestHandler[];
19
19
  /** A single entity response, for specs that need one endpoint only. */
20
20
  export declare const respondWithEntity: <TEntity extends Entity>(url: string, entityConstructor: EntityConstructor<TEntity>, instance: TEntity) => RequestHandler;
21
+ /**
22
+ * A failure as a service answers it: its problem body, its category's status
23
+ * and the problem content type.
24
+ */
25
+ export declare const problem: (failure: EntifixFailure) => HttpResponse<import("msw").JsonBodyType>;
26
+ /** Answers every call to `url` with `failure`'s problem body. */
27
+ export declare const respondWithProblem: (url: string, failure: EntifixFailure, method?: "get" | "post" | "put" | "delete") => RequestHandler;
21
28
  /**
22
29
  * The failure handlers. Adapters map transport and shape failures onto
23
30
  * `EntifixError`s, and those branches are only reachable from here.
@@ -1 +1 @@
1
- {"version":3,"file":"entity-handlers.d.ts","sourceRoot":"","sources":["../../src/http/entity-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EAKd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,KAAK,CAAC;AAE9D,MAAM,WAAW,wBAAwB,CAAC,OAAO,SAAS,MAAM;IAC9D,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,SAAS,MAAM,EACvD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,mBAAwB,wBAAwB,CAAC,OAAO,CAAC,KACxD,cAAc,EAgGhB,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,GAAI,OAAO,SAAS,MAAM,EACtD,KAAK,MAAM,EACX,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,UAAU,OAAO,KAChB,cAGA,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,cAAc,GACzB,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAC+D,CAAC;AAEnE,eAAO,MAAM,cAAc,GACzB,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAC+D,CAAC;AAEnE,8DAA8D;AAC9D,eAAO,MAAM,4BAA4B,GACvC,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,EACjD,OAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAA0C,KACrE,cAAkE,CAAC;AAEtE,gDAAgD;AAChD,eAAO,MAAM,kBAAkB,GAC7B,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAIA,CAAC;AAEJ,+EAA+E;AAC/E,eAAO,MAAM,uBAAuB,GAClC,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAA+D,CAAC;AAEnE,YAAY,EAAE,cAAc,EAAE,MAAM,KAAK,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,KAAK,CAAC;AAEzC,oEAAoE;AACpE,YAAY,EAAE,QAAQ,EAAE,CAAC"}
1
+ {"version":3,"file":"entity-handlers.d.ts","sourceRoot":"","sources":["../../src/http/entity-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,MAAM,EACX,KAAK,iBAAiB,EACtB,KAAK,QAAQ,EASd,MAAM,eAAe,CAAC;AACvB,OAAO,EAAQ,YAAY,EAAE,KAAK,cAAc,EAAE,MAAM,KAAK,CAAC;AAE9D,MAAM,WAAW,wBAAwB,CAAC,OAAO,SAAS,MAAM;IAC9D,kEAAkE;IAClE,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO,SAAS,MAAM,EACvD,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,mBAAwB,wBAAwB,CAAC,OAAO,CAAC,KACxD,cAAc,EA6FhB,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,iBAAiB,GAAI,OAAO,SAAS,MAAM,EACtD,KAAK,MAAM,EACX,mBAAmB,iBAAiB,CAAC,OAAO,CAAC,EAC7C,UAAU,OAAO,KAChB,cAGA,CAAC;AAEJ;;;GAGG;AACH,eAAO,MAAM,OAAO,GAAI,SAAS,cAAc,6CAM9C,CAAC;AAEF,iEAAiE;AACjE,eAAO,MAAM,kBAAkB,GAC7B,KAAK,MAAM,EACX,SAAS,cAAc,EACvB,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAA2D,CAAC;AAE/D;;;GAGG;AACH,eAAO,MAAM,cAAc,GACzB,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAC+D,CAAC;AAEnE,eAAO,MAAM,cAAc,GACzB,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAC+D,CAAC;AAEnE,8DAA8D;AAC9D,eAAO,MAAM,4BAA4B,GACvC,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,EACjD,OAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAA0C,KACrE,cAAkE,CAAC;AAEtE,gDAAgD;AAChD,eAAO,MAAM,kBAAkB,GAC7B,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAIA,CAAC;AAEJ,+EAA+E;AAC/E,eAAO,MAAM,uBAAuB,GAClC,KAAK,MAAM,EACX,SAAQ,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,QAAgB,KAChD,cAA+D,CAAC;AAEnE,YAAY,EAAE,cAAc,EAAE,MAAM,KAAK,CAAC;AAC1C,OAAO,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,KAAK,CAAC;AAEzC,oEAAoE;AACpE,YAAY,EAAE,QAAQ,EAAE,CAAC"}
@@ -1,4 +1,4 @@
1
- import { countFacets, makeEntityEnvelope, makeEntityFacetsEnvelope, makeEntityPageEnvelope, parseFacetQueryParams } from '@entifix/core';
1
+ import { countFacets, InvalidQuery, makeEntityEnvelope, makeEntityFacetsEnvelope, makeEntityPageEnvelope, NotFound, parseFacetQueryParams, PROBLEM_CONTENT_TYPE, problemOf } from '@entifix/core';
2
2
  import { http, HttpResponse } from 'msw';
3
3
  /**
4
4
  * MSW handlers for the five REST operations the entity adapters issue, and the
@@ -35,19 +35,14 @@ import { http, HttpResponse } from 'msw';
35
35
  const queries = parseFacetQueryParams(entityConstructor, new URL(request.url).searchParams);
36
36
  return HttpResponse.json(makeEntityFacetsEnvelope(entityConstructor, countFacets(rows, queries)));
37
37
  } catch (error) {
38
- return HttpResponse.json({
39
- message: error.message,
40
- code: 'invalidQuery'
41
- }, {
42
- status: 400
43
- });
38
+ return problem(new InvalidQuery({
39
+ reason: error.message
40
+ }));
44
41
  }
45
42
  }),
46
43
  http.get(`${baseUrl}/:id`, ({ params })=>{
47
44
  const found = findById(String(params['id']));
48
- return found === undefined ? new HttpResponse(null, {
49
- status: 404
50
- }) : HttpResponse.json(makeEntityEnvelope(entityConstructor, found));
45
+ return found === undefined ? problem(new NotFound()) : HttpResponse.json(makeEntityEnvelope(entityConstructor, found));
51
46
  }),
52
47
  http.post(baseUrl, async ({ request })=>{
53
48
  var _body_data_id;
@@ -86,6 +81,19 @@ import { http, HttpResponse } from 'msw';
86
81
  ];
87
82
  };
88
83
  /** A single entity response, for specs that need one endpoint only. */ export const respondWithEntity = (url, entityConstructor, instance)=>http.get(url, ()=>HttpResponse.json(makeEntityEnvelope(entityConstructor, instance)));
84
+ /**
85
+ * A failure as a service answers it: its problem body, its category's status
86
+ * and the problem content type.
87
+ */ export const problem = (failure)=>{
88
+ const body = problemOf(failure);
89
+ return HttpResponse.json(body, {
90
+ status: body.status,
91
+ headers: {
92
+ 'content-type': PROBLEM_CONTENT_TYPE
93
+ }
94
+ });
95
+ };
96
+ /** Answers every call to `url` with `failure`'s problem body. */ export const respondWithProblem = (url, failure, method = 'get')=>http[method](url, ()=>problem(failure));
89
97
  /**
90
98
  * The failure handlers. Adapters map transport and shape failures onto
91
99
  * `EntifixError`s, and those branches are only reachable from here.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/http/entity-handlers.ts"],"sourcesContent":["import {\n countFacets,\n type Entity,\n type EntityConstructor,\n type EntityId,\n makeEntityEnvelope,\n makeEntityFacetsEnvelope,\n makeEntityPageEnvelope,\n parseFacetQueryParams,\n} from '@entifix/core';\nimport { http, HttpResponse, type RequestHandler } from 'msw';\n\nexport interface EntityRestHandlerOptions<TEntity extends Entity> {\n /** The collection endpoint, e.g. `http://service/api/product`. */\n baseUrl: string;\n /** The rows the fake service serves. Mutated in place by writes. */\n data?: TEntity[];\n}\n\n/**\n * MSW handlers for the five REST operations the entity adapters issue, and the\n * `$facets` count beside them, speaking the real entifix envelope.\n *\n * Responses are built with `makeEntityEnvelope`/`makeEntityPageEnvelope` — the\n * production serializer — so a fixture cannot drift from the wire format the\n * adapters parse. Hand-written JSON literals in specs are exactly how that\n * drift starts.\n */\nexport const entityRestHandlers = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n { baseUrl, data = [] }: EntityRestHandlerOptions<TEntity>,\n): RequestHandler[] => {\n const rows = data;\n\n const findById = (id: string): TEntity | undefined =>\n rows.find(row => String(row.id) === id);\n\n return [\n http.get(baseUrl, ({ request }) => {\n const url = new URL(request.url);\n const page = Number(url.searchParams.get('page') ?? '1');\n const pageSize = Number(url.searchParams.get('pageSize') ?? '10');\n const start = (page - 1) * pageSize;\n\n return HttpResponse.json(\n makeEntityPageEnvelope(entityConstructor, {\n items: rows.slice(start, start + pageSize),\n total: rows.length,\n request: { page, pageSize },\n }),\n );\n }),\n\n // Registered before `/:id`, which would otherwise take `$facets` for an id.\n // The query goes through the real protocol parser and the in-memory\n // counter, so a refused member is a `400` here as it is at the service.\n http.get(`${baseUrl}/$facets`, ({ request }) => {\n try {\n const queries = parseFacetQueryParams(\n entityConstructor,\n new URL(request.url).searchParams,\n );\n return HttpResponse.json(\n makeEntityFacetsEnvelope(\n entityConstructor,\n countFacets(rows, queries),\n ),\n );\n } catch (error) {\n return HttpResponse.json(\n { message: (error as Error).message, code: 'invalidQuery' },\n { status: 400 },\n );\n }\n }),\n\n http.get(`${baseUrl}/:id`, ({ params }) => {\n const found = findById(String(params['id']));\n return found === undefined\n ? new HttpResponse(null, { status: 404 })\n : HttpResponse.json(makeEntityEnvelope(entityConstructor, found));\n }),\n\n http.post(baseUrl, async ({ request }) => {\n const body = (await request.json()) as { data: Record<string, unknown> };\n // The service is what mints an id on create; echoing the request back\n // unchanged would hide that the adapter must read the response.\n const created = Object.assign(new entityConstructor(), body.data, {\n id: body.data['id'] ?? `generated-${rows.length + 1}`,\n }) as TEntity;\n rows.push(created);\n return HttpResponse.json(makeEntityEnvelope(entityConstructor, created), {\n status: 201,\n });\n }),\n\n http.put(`${baseUrl}/:id`, async ({ params, request }) => {\n const body = (await request.json()) as { data: Record<string, unknown> };\n const id = String(params['id']);\n const index = rows.findIndex(row => String(row.id) === id);\n const updated = Object.assign(\n new entityConstructor(),\n body.data,\n ) as TEntity;\n if (index === -1) {\n rows.push(updated);\n } else {\n rows[index] = updated;\n }\n return HttpResponse.json(makeEntityEnvelope(entityConstructor, updated));\n }),\n\n // A delete answers with the removed entity's envelope rather than a bare\n // `204`: the shared fetch client always parses the response as JSON, so an\n // empty body would fail that parse — which is what the services do too.\n http.delete(`${baseUrl}/:id`, ({ params }) => {\n const id = String(params['id']);\n const index = rows.findIndex(row => String(row.id) === id);\n const [removed] = index === -1 ? [] : rows.splice(index, 1);\n return HttpResponse.json(\n makeEntityEnvelope(\n entityConstructor,\n removed ?? (new entityConstructor() as TEntity),\n ),\n );\n }),\n ];\n};\n\n/** A single entity response, for specs that need one endpoint only. */\nexport const respondWithEntity = <TEntity extends Entity>(\n url: string,\n entityConstructor: EntityConstructor<TEntity>,\n instance: TEntity,\n): RequestHandler =>\n http.get(url, () =>\n HttpResponse.json(makeEntityEnvelope(entityConstructor, instance)),\n );\n\n/**\n * The failure handlers. Adapters map transport and shape failures onto\n * `EntifixError`s, and those branches are only reachable from here.\n */\nexport const respondWith500 = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler =>\n http[method](url, () => new HttpResponse(null, { status: 500 }));\n\nexport const respondWith404 = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler =>\n http[method](url, () => new HttpResponse(null, { status: 404 }));\n\n/** Answers 200 with a body that is not an envelope at all. */\nexport const respondWithMalformedEnvelope = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n body: Record<string, unknown> = { id: 'raw', name: 'not an envelope' },\n): RequestHandler => http[method](url, () => HttpResponse.json(body));\n\n/** Answers 200 with a body that is not JSON. */\nexport const respondWithNonJson = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler =>\n http[method](\n url,\n () => new HttpResponse('<html>nope</html>', { status: 200 }),\n );\n\n/** Fails the request at the transport level, as a dropped connection would. */\nexport const respondWithNetworkError = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler => http[method](url, () => HttpResponse.error());\n\nexport type { RequestHandler } from 'msw';\nexport { http, HttpResponse } from 'msw';\n\n/** Re-exported so `EntityId` stays available to handler authors. */\nexport type { EntityId };\n"],"names":["countFacets","makeEntityEnvelope","makeEntityFacetsEnvelope","makeEntityPageEnvelope","parseFacetQueryParams","http","HttpResponse","entityRestHandlers","entityConstructor","baseUrl","data","rows","findById","id","find","row","String","get","request","url","URL","page","Number","searchParams","pageSize","start","json","items","slice","total","length","queries","error","message","code","status","params","found","undefined","post","body","created","Object","assign","push","put","index","findIndex","updated","delete","removed","splice","respondWithEntity","instance","respondWith500","method","respondWith404","respondWithMalformedEnvelope","name","respondWithNonJson","respondWithNetworkError"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/http/entity-handlers.ts"],"sourcesContent":["import {\n countFacets,\n type EntifixFailure,\n type Entity,\n type EntityConstructor,\n type EntityId,\n InvalidQuery,\n makeEntityEnvelope,\n makeEntityFacetsEnvelope,\n makeEntityPageEnvelope,\n NotFound,\n parseFacetQueryParams,\n PROBLEM_CONTENT_TYPE,\n problemOf,\n} from '@entifix/core';\nimport { http, HttpResponse, type RequestHandler } from 'msw';\n\nexport interface EntityRestHandlerOptions<TEntity extends Entity> {\n /** The collection endpoint, e.g. `http://service/api/product`. */\n baseUrl: string;\n /** The rows the fake service serves. Mutated in place by writes. */\n data?: TEntity[];\n}\n\n/**\n * MSW handlers for the five REST operations the entity adapters issue, and the\n * `$facets` count beside them, speaking the real entifix envelope.\n *\n * Responses are built with `makeEntityEnvelope`/`makeEntityPageEnvelope` — the\n * production serializer — so a fixture cannot drift from the wire format the\n * adapters parse. Hand-written JSON literals in specs are exactly how that\n * drift starts.\n */\nexport const entityRestHandlers = <TEntity extends Entity>(\n entityConstructor: EntityConstructor<TEntity>,\n { baseUrl, data = [] }: EntityRestHandlerOptions<TEntity>,\n): RequestHandler[] => {\n const rows = data;\n\n const findById = (id: string): TEntity | undefined =>\n rows.find(row => String(row.id) === id);\n\n return [\n http.get(baseUrl, ({ request }) => {\n const url = new URL(request.url);\n const page = Number(url.searchParams.get('page') ?? '1');\n const pageSize = Number(url.searchParams.get('pageSize') ?? '10');\n const start = (page - 1) * pageSize;\n\n return HttpResponse.json(\n makeEntityPageEnvelope(entityConstructor, {\n items: rows.slice(start, start + pageSize),\n total: rows.length,\n request: { page, pageSize },\n }),\n );\n }),\n\n // Registered before `/:id`, which would otherwise take `$facets` for an id.\n // The query goes through the real protocol parser and the in-memory\n // counter, so a refused member is a `400` here as it is at the service.\n http.get(`${baseUrl}/$facets`, ({ request }) => {\n try {\n const queries = parseFacetQueryParams(\n entityConstructor,\n new URL(request.url).searchParams,\n );\n return HttpResponse.json(\n makeEntityFacetsEnvelope(\n entityConstructor,\n countFacets(rows, queries),\n ),\n );\n } catch (error) {\n return problem(new InvalidQuery({ reason: (error as Error).message }));\n }\n }),\n\n http.get(`${baseUrl}/:id`, ({ params }) => {\n const found = findById(String(params['id']));\n return found === undefined\n ? problem(new NotFound())\n : HttpResponse.json(makeEntityEnvelope(entityConstructor, found));\n }),\n\n http.post(baseUrl, async ({ request }) => {\n const body = (await request.json()) as { data: Record<string, unknown> };\n // The service is what mints an id on create; echoing the request back\n // unchanged would hide that the adapter must read the response.\n const created = Object.assign(new entityConstructor(), body.data, {\n id: body.data['id'] ?? `generated-${rows.length + 1}`,\n }) as TEntity;\n rows.push(created);\n return HttpResponse.json(makeEntityEnvelope(entityConstructor, created), {\n status: 201,\n });\n }),\n\n http.put(`${baseUrl}/:id`, async ({ params, request }) => {\n const body = (await request.json()) as { data: Record<string, unknown> };\n const id = String(params['id']);\n const index = rows.findIndex(row => String(row.id) === id);\n const updated = Object.assign(\n new entityConstructor(),\n body.data,\n ) as TEntity;\n if (index === -1) {\n rows.push(updated);\n } else {\n rows[index] = updated;\n }\n return HttpResponse.json(makeEntityEnvelope(entityConstructor, updated));\n }),\n\n // A delete answers with the removed entity's envelope rather than a bare\n // `204`: the shared fetch client always parses the response as JSON, so an\n // empty body would fail that parse — which is what the services do too.\n http.delete(`${baseUrl}/:id`, ({ params }) => {\n const id = String(params['id']);\n const index = rows.findIndex(row => String(row.id) === id);\n const [removed] = index === -1 ? [] : rows.splice(index, 1);\n return HttpResponse.json(\n makeEntityEnvelope(\n entityConstructor,\n removed ?? (new entityConstructor() as TEntity),\n ),\n );\n }),\n ];\n};\n\n/** A single entity response, for specs that need one endpoint only. */\nexport const respondWithEntity = <TEntity extends Entity>(\n url: string,\n entityConstructor: EntityConstructor<TEntity>,\n instance: TEntity,\n): RequestHandler =>\n http.get(url, () =>\n HttpResponse.json(makeEntityEnvelope(entityConstructor, instance)),\n );\n\n/**\n * A failure as a service answers it: its problem body, its category's status\n * and the problem content type.\n */\nexport const problem = (failure: EntifixFailure) => {\n const body = problemOf(failure);\n return HttpResponse.json(body, {\n status: body.status,\n headers: { 'content-type': PROBLEM_CONTENT_TYPE },\n });\n};\n\n/** Answers every call to `url` with `failure`'s problem body. */\nexport const respondWithProblem = (\n url: string,\n failure: EntifixFailure,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler => http[method](url, () => problem(failure));\n\n/**\n * The failure handlers. Adapters map transport and shape failures onto\n * `EntifixError`s, and those branches are only reachable from here.\n */\nexport const respondWith500 = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler =>\n http[method](url, () => new HttpResponse(null, { status: 500 }));\n\nexport const respondWith404 = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler =>\n http[method](url, () => new HttpResponse(null, { status: 404 }));\n\n/** Answers 200 with a body that is not an envelope at all. */\nexport const respondWithMalformedEnvelope = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n body: Record<string, unknown> = { id: 'raw', name: 'not an envelope' },\n): RequestHandler => http[method](url, () => HttpResponse.json(body));\n\n/** Answers 200 with a body that is not JSON. */\nexport const respondWithNonJson = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler =>\n http[method](\n url,\n () => new HttpResponse('<html>nope</html>', { status: 200 }),\n );\n\n/** Fails the request at the transport level, as a dropped connection would. */\nexport const respondWithNetworkError = (\n url: string,\n method: 'get' | 'post' | 'put' | 'delete' = 'get',\n): RequestHandler => http[method](url, () => HttpResponse.error());\n\nexport type { RequestHandler } from 'msw';\nexport { http, HttpResponse } from 'msw';\n\n/** Re-exported so `EntityId` stays available to handler authors. */\nexport type { EntityId };\n"],"names":["countFacets","InvalidQuery","makeEntityEnvelope","makeEntityFacetsEnvelope","makeEntityPageEnvelope","NotFound","parseFacetQueryParams","PROBLEM_CONTENT_TYPE","problemOf","http","HttpResponse","entityRestHandlers","entityConstructor","baseUrl","data","rows","findById","id","find","row","String","get","request","url","URL","page","Number","searchParams","pageSize","start","json","items","slice","total","length","queries","error","problem","reason","message","params","found","undefined","post","body","created","Object","assign","push","status","put","index","findIndex","updated","delete","removed","splice","respondWithEntity","instance","failure","headers","respondWithProblem","method","respondWith500","respondWith404","respondWithMalformedEnvelope","name","respondWithNonJson","respondWithNetworkError"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entifix/testing-unit",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Test doubles for entifix: in-memory ports, driver fakes for Mongo, Redis, RabbitMQ and SQL, MSW handlers and port contract suites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -72,17 +72,17 @@
72
72
  },
73
73
  "dependencies": {
74
74
  "@swc/helpers": "0.5.23",
75
- "@entifix/transactions": "0.9.0",
76
- "@entifix/core": "0.9.0",
77
- "@entifix/business": "0.9.0"
75
+ "@entifix/business": "0.10.1",
76
+ "@entifix/core": "0.10.1",
77
+ "@entifix/transactions": "0.10.1"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "@testing-library/react": "*",
81
- "effect": "^4.0.5",
81
+ "effect": "^4.0.6",
82
82
  "msw": ">=3",
83
83
  "react": "*",
84
84
  "vitest": "*",
85
- "@entifix/react-controls": "0.9.0"
85
+ "@entifix/react-controls": "0.10.1"
86
86
  },
87
87
  "peerDependenciesMeta": {
88
88
  "@entifix/react-controls": {
@@ -105,6 +105,6 @@
105
105
  }
106
106
  },
107
107
  "devDependencies": {
108
- "effect": "4.0.5"
108
+ "effect": "4.0.6"
109
109
  }
110
110
  }